From a41c42104e9ac540bc1795feb7b5ca6a298bb2a4 Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Wed, 7 Oct 2026 21:44:05 +0100 Subject: [PATCH 1/3] feat(catalog): carry cron, cronOnly, cohosts and feed for skills The controller's CommunitySkill already reads cron/cronOnly/cohosts, but build-catalog only emitted label/brief/cooldown/window/context, so a `cron:` in a community SKILL.md was silently dropped on the way to every station. The builder now emits them, plus `feed` and `feedMaxItems`, which a station (with the matching controller change) installs as the skill's own feed knob: real data for a skill, with no code shipped. Each is validated rather than dropped: - cron: 5 fields, or 6 with seconds (the controller's SKILL_CRON_RE) - cronOnly / cohosts: true or false only - cronOnly without cron: refused, since the skill would never air - feed: https only; feedMaxItems: 1-50, and only alongside a feed --- CONTRIBUTING.md | 16 +++++++++++++ scripts/build-catalog.mjs | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a26d1a2..f669f76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,10 +30,26 @@ cooldown: 2h # min gap between airings: "90m" | "6h" | "2d" context: clock, time # optional "right now" fields the segment may mention: # date, clock, time, weather, festival, show, listeners window: any # "any" (default) or "commute" +cron: 0 17 * * 5 # optional fixed schedule: 5 fields, or 6 with seconds +cronOnly: true # optional: fire ONLY on the cron (needs `cron`) +cohosts: true # optional: the host and every guest each speak +feed: https://example.com/rss # optional: an https RSS/Atom feed the skill reads first +feedMaxItems: 10 # optional: how much of the feed to read, 1–50 (needs `feed`) --- Say one short line acknowledging that some listeners are probably in transit right now… ``` +**Feeds give a skill real data without code.** A station that installs a skill with a `feed:` +fetches that feed before the DJ speaks and hands it the fresh items (title + blurb). Already-used +items are skipped, and a failed fetch means the skill stays quiet rather than making things up. So +write the brief to speak *only* from those items, and to **say nothing when none fit or none came +back** — a station on an older controller installs the brief without the feed. Pick a feed that is +public, stable and on-topic; a feed full of deals or promos gives the DJ nothing to work with. +`skills/music-press` and `skills/good-news` are worked examples. + +`true`/`false` are the only values `cronOnly` and `cohosts` accept — anything else fails the build +rather than silently installing a different skill. + The slug must not shadow a built-in/reserved kind: `link`, `dj-speak`, `announcement`, `station-id`, `hourly`, `hourly-check`, `album-anniversary`, `curiosity`, `library-deep-cut`, `news`, `now-playing-dig`, `weather`, `web-search`. diff --git a/scripts/build-catalog.mjs b/scripts/build-catalog.mjs index 00ab2a5..e0a03c5 100644 --- a/scripts/build-catalog.mjs +++ b/scripts/build-catalog.mjs @@ -62,6 +62,17 @@ const APP_IMAGE_HOSTS = ['raw.githubusercontent.com', 'user-images.githubusercon const MAX_PLATFORM_LEN = 32; const MAX_PLATFORMS = 6; +// A skill's `cron:` must be 5 fields, or 6 with seconds — the controller's +// SKILL_CRON_RE. Field RANGES are node-cron's to judge, and the station checks +// them at install, so a shape check is all this layer can honestly make. +const SKILL_CRON_RE = /^\S+(?:\s+\S+){4,5}$/; + +// A skill's `feed:` becomes a fetch on every station that installs it (the +// loader generates a fetch tool from it — no code ships). The station accepts +// http(s); the catalog asks for https, since this one URL is fetched by every +// installing station rather than one operator who typed it. +const FEED_MAX_ITEMS_LIMIT = 50; + const errors = []; const fail = (where, msg) => errors.push(`${where}: ${msg}`); @@ -115,6 +126,35 @@ const provenance = data => ({ }); const clean = obj => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); +// A frontmatter boolean the station reads with `=== true`. Anything other than +// true/false is refused rather than read as false: `cohosts: yes` silently +// installing a one-voice skill is the failure this prevents. +function flag(data, key, where) { + const raw = data[key]; + if (raw == null || raw === '') return undefined; + const v = String(raw).trim().toLowerCase(); + if (v === 'true') return true; + if (v === 'false') return undefined; + fail(where, `${key} must be true or false (is "${raw}")`); + return undefined; +} + +function skillFeed(data, where) { + const raw = data.feed?.trim(); + if (data.feedMaxItems && !raw) fail(where, 'feedMaxItems needs a feed'); + if (!raw) return {}; + let url; + try { url = new URL(raw); } catch { fail(where, `feed "${raw}" is not a URL`); return {}; } + if (url.protocol !== 'https:') { fail(where, `feed "${raw}" must be an https:// URL`); return {}; } + let feedMaxItems; + if (data.feedMaxItems) { + const n = Number(data.feedMaxItems); + if (Number.isInteger(n) && n >= 1 && n <= FEED_MAX_ITEMS_LIMIT) feedMaxItems = n; + else fail(where, `feedMaxItems must be a whole number from 1 to ${FEED_MAX_ITEMS_LIMIT} (is "${data.feedMaxItems}")`); + } + return { feed: raw, feedMaxItems }; +} + async function buildSkills() { const out = []; for (const slug of await listDirs(join(ROOT, 'skills'))) { @@ -126,13 +166,21 @@ async function buildSkills() { const { data, body } = parseFrontmatter(raw); if ((data.name || slug).trim() !== slug) { fail(where, `name "${data.name}" must equal folder slug`); continue; } if (!body) { fail(where, 'brief (body) is required'); continue; } + const cron = data.cron?.trim() || undefined; + if (cron && !SKILL_CRON_RE.test(cron)) fail(where, `cron "${cron}" must be 5 fields, or 6 with seconds (e.g. "0 17 * * 5")`); + const cronOnly = flag(data, 'cronOnly', where); + if (cronOnly && !cron) fail(where, 'cronOnly needs a cron — without one the skill would never air'); out.push(clean({ slug, label: (data.label || slug).trim(), brief: body, cooldown: data.cooldown ? String(data.cooldown).trim() : undefined, + cron, + cronOnly, + cohosts: flag(data, 'cohosts', where), window: data.window === 'commute' ? 'commute' : undefined, context: (data.context ?? data.contextFields)?.trim() || undefined, + ...skillFeed(data, where), ...provenance(data), })); } From 10cba613676a64391a854b4455524a0bd607b62b Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Wed, 7 Oct 2026 21:44:05 +0100 Subject: [PATCH 2/3] feat(skills): add Music press bulletin and Good news feed skills The first data-backed community skills. Each reads a public feed and speaks only from what it returns, staying silent when nothing fits. - music-press: one story from Pitchfork's news feed. Releases, tours, reunions and signings; skips lawsuits, feuds and politics; handles a death with care. Prefers a story about the artist on air. - good-news: one concrete story from Good News Network. Skips roundups, anniversaries and opinion pieces. Pitchfork and GNN were chosen after reading the live feeds: Consequence mixes in shopping-deal posts, NME and Stereogum lean on controversy and opinion, and Positive News is mostly promos and essays. --- skills/good-news/SKILL.md | 8 ++++++++ skills/music-press/SKILL.md | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 skills/good-news/SKILL.md create mode 100644 skills/music-press/SKILL.md diff --git a/skills/good-news/SKILL.md b/skills/good-news/SKILL.md new file mode 100644 index 0000000..02b4b89 --- /dev/null +++ b/skills/good-news/SKILL.md @@ -0,0 +1,8 @@ +--- +name: good-news +label: Good news +cooldown: 4h +feed: https://www.goodnewsnetwork.org/feed/ +feedMaxItems: 10 +--- +Pass on one genuinely good story from the feed in a sentence or two: a rescue, a recovery, a kindness between strangers, a species coming back, a town fixing something together. Tell it like a friend sharing something that made their day, warm and plain. No "and now for some good news", and no moral tacked on the end. Choose a concrete story about real people or places over roundups, anniversaries, product news or opinion pieces. Use only what the item says, and add no names, numbers or places it doesn't give. If nothing on the feed is a real story, or no items came back, say nothing. diff --git a/skills/music-press/SKILL.md b/skills/music-press/SKILL.md new file mode 100644 index 0000000..71c5d8e --- /dev/null +++ b/skills/music-press/SKILL.md @@ -0,0 +1,8 @@ +--- +name: music-press +label: Music press bulletin +cooldown: 3h +feed: https://pitchfork.com/feed/feed-news/rss +feedMaxItems: 10 +--- +Pick one story from the music news feed and pass it on in a sentence or two, the way a DJ mentions something they read this morning. New records, tours, reunions, signings, reissues and live sessions are the beat; skip lawsuits, court cases, feuds and politics. If a story is about the artist on air, choose that one. If it is someone's death, say it plainly and with care, with no joke and no bright pivot back to the music. Use only what the headline and blurb say: add no dates, quotes, chart positions or details they don't give. Never read the headline out word for word, and no URLs. If nothing on the feed fits, or no headlines came back, say nothing. From 34b1f857d07bda3f53f3517a54504ae424d4e42f Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Thu, 8 Oct 2026 00:43:28 +0100 Subject: [PATCH 3/3] feat(skills): let the Add a skill form take a feed The issue form gains two optional inputs, Feed URL and Feed items to read, so a feed-backed skill can be submitted without a hand-written PR. The submission workflow validates them with the builder's rules (https only, one URL with no whitespace and at most 500 chars, 1-50 items, items only alongside a feed) and writes `feed:` / `feedMaxItems:` into the generated SKILL.md. The validate job re-checks through build-catalog.mjs. A generated PR for a feed skill opens with a "Reads a feed" note naming the URL, so the reviewer checks it is public, stable and on-topic before every installing station starts fetching it. The input ids match what the station's Share to community button now sends, so a shared feed skill arrives prefilled. --- .github/ISSUE_TEMPLATE/add-skill.yml | 26 +++++++++++++++++++++-- .github/workflows/skill-submission.yml | 29 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/add-skill.yml b/.github/ISSUE_TEMPLATE/add-skill.yml index cc66390..2fdcacc 100644 --- a/.github/ISSUE_TEMPLATE/add-skill.yml +++ b/.github/ISSUE_TEMPLATE/add-skill.yml @@ -12,7 +12,8 @@ body: merges it, and it ships to every station as an installable community skill. **This path is for prompt-only skills** — a brief plus a few knobs. Skills that - run their own `tool.mjs` code aren't accepted here (yet). **You don't need to fork anything.** + run their own `tool.mjs` code aren't accepted here (yet). A skill *can* read a public + RSS/Atom feed for real data, though; that's a URL, not code. **You don't need to fork anything.** - type: input id: skill-name attributes: @@ -69,6 +70,26 @@ body: default: 0 validations: required: false + - type: input + id: feed + attributes: + label: Feed URL + description: > + Optional. An https:// RSS or Atom feed the skill reads before it speaks. Every station + that installs your skill fetches it and hands the DJ the fresh items (title + blurb), so + pick one that is public, stable and on-topic. If you set one, write the brief to speak + only from those items and to say nothing when none fit. + placeholder: https://pitchfork.com/feed/feed-news/rss + validations: + required: false + - type: input + id: feed-max-items + attributes: + label: Feed items to read + description: Optional, needs a feed URL. How much of the feed to read each time, 1–50. Defaults to the station's setting (10). + placeholder: '10' + validations: + required: false - type: textarea id: description attributes: @@ -86,7 +107,8 @@ body: - The brief is your own work (or cleared for sharing), and contains nothing unlawful, hateful, or designed to deceive listeners. - - It contains no secrets, personal data, or instructions to fetch untrusted code. + - It contains no secrets, personal data, or instructions to fetch untrusted code, and any + feed URL is a public feed you'd be happy for every installing station to fetch. - Your GitHub username is recorded as the skill's submitter (shown as a credit line in the community catalog), along with the dates it was added and last updated. - SUB/WAVE may include, edit, or remove it from the catalog at its discretion. diff --git a/.github/workflows/skill-submission.yml b/.github/workflows/skill-submission.yml index bc5723b..ac0b42b 100644 --- a/.github/workflows/skill-submission.yml +++ b/.github/workflows/skill-submission.yml @@ -65,6 +65,11 @@ jobs: const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/; const COOLDOWN_RE = /^\d+\s*[smhd]?$/; const CONTEXT_FIELDS = new Set(['date', 'clock', 'time', 'weather', 'festival', 'show', 'listeners']); + // Same bounds scripts/build-catalog.mjs enforces (and the validate job + // below re-checks): an https feed, and a read count of 1-50 that only + // means something alongside one. + const FEED_MAX_ITEMS_LIMIT = 50; + const FEED_URL_MAX = 500; // --- 1. Parse the issue-form body into a { heading: value } map --- // GitHub renders each field as "###