yt-briefing 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: yt-search
3
+ description: Research a topic across YouTube — describe an intent, the engine expands it into search queries, ranks results against your intent, then lazily yields ONE video at a time with a rich summary. You keep or skip each; at the end it synthesizes a comparison from everything you kept. Same transcript engine + proxy as /yt; lazy on purpose (no transcript bursts → no IP block). Summaries and prompts use the language chosen at onboarding.
4
+ argument-hint: A descriptive intent in quotes, e.g. "which terminal for coding with Claude Code". Optional --max N, --since YYYY-MM-DD, --queries 1..3.
5
+ ---
6
+
7
+ ## How it works
8
+
9
+ `src/yt-search.ts` is the whole engine: intent → query expansion (LLM) → `search.list` → re-rank against the intent on metadata only (no transcript) → **lazy** one-candidate-at-a-time yield with a rich summary → record keep/skip → on demand synthesize a comparison from everything kept. Matching is **descriptive, not exact-keyword** — YouTube ranks by relevance and the LLM bridges intent→query and filters noise. This skill is a thin loop: paste the summary, collect keep/skip, show the final comparison. It runs no filters and formats nothing itself.
10
+
11
+ **Lazy on purpose:** one transcript per step, never a burst — a burst looks like scraping and gets the IP blocked (same reason `/yt` is lazy). Run the engine bare — stdout is a single JSON line, stderr empty; never redirect.
12
+
13
+ ## Language
14
+
15
+ Read `data/config.json` → `output_lang` once at the start. Phrase the question text and option descriptions in that language. The two button labels stay the short English words `Keep` / `Skip`. Summaries and the final comparison are already written in `output_lang` by the engine — paste them verbatim.
16
+
17
+ ## Loop
18
+
19
+ ```
20
+ out = JSON.parse(`bun run src/yt-search.ts "<intent from the user>" --reset`) // first call
21
+ while true:
22
+ out.status:
23
+ "error" → show out.error verbatim, stop
24
+ "no_results" → tell the user nothing relevant was found, stop
25
+ "rate_limited" → transcript fetch blocked (datacenter IP) — tell the user, stop; recovery in README.md → Running on a VPS
26
+ "decision_needed" → steps A–C
27
+ "done" → step D
28
+ ```
29
+
30
+ **On `decision_needed`:**
31
+
32
+ - **A.** Take `out.summary` (markdown) and `out.pending` (`{videoId,title,channelTitle,publishedAt,position,total}`).
33
+ - **B.** In the SAME turn, as your chat text (NOT command output — the UI doesn't show it), paste `summary` **verbatim** — no paraphrase, no shortening. Optionally prefix one line like `Wynik {position}/{total}`. The user must see it before the popup.
34
+ - **C.** In the same message call `AskUserQuestion` — 1 call, 1 question, phrased in `output_lang`:
35
+ - Question e.g. "Brać pod uwagę w porównaniu?" with two options: **Keep** = include this video in the final comparison; **Skip** = drop it.
36
+ - **Other** is the stop channel: if the user types `stop` (case-insensitive, trimmed) or dismisses the popup (✕) → **end the loop early** and go to step D (compare what's kept so far).
37
+ - Then act:
38
+ - Keep → `bun run src/yt-search.ts --keep`
39
+ - Skip → `bun run src/yt-search.ts --skip`
40
+ - stop/dismissed → `bun run src/yt-search.ts --compare` (skip straight to D)
41
+ - The script reads the pending candidate from cache; pass only `--keep` / `--skip` / `--compare`. Its JSON becomes the next `out` — back to the top of the loop.
42
+
43
+ **On `done` (step D):**
44
+
45
+ - If `out.kept > 0` → run `out = JSON.parse(\`bun run src/yt-search.ts --compare\`)`; when it returns `status:"compare"`, paste `out.comparison` **verbatim** as your chat text (it's the artifact — a decision-grade comparison in `output_lang`). Stop.
46
+ - If `out.kept == 0` → tell the user nothing was kept, so there's nothing to compare. Stop.
47
+
48
+ ## Rules
49
+
50
+ - **Verbatim:** paste `summary` and `comparison` exactly as returned; never paste a raw transcript.
51
+ - **Language:** question text + option descriptions follow `output_lang`; button labels stay `Keep` / `Skip`.
52
+ - **Cost awareness:** each search runs `search.list` (100 quota units/query, up to `--queries`). Don't silently re-run `--reset` in a loop. A bare resume (no `--reset`) continues the same ranked queue without new searches.
53
+ - **Stateless triage:** this is independent of `/yt` (no channels, no ratings). For the recurring channel briefing use `/yt`; for one video use `/yt-transcribe`.
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: yt-transcribe
3
+ description: Fetch a single YouTube video's transcript and summarize it. One-shot — paste a URL or video ID, get a journalist-grade summary. Same transcript engine (and WARP proxy support) as the /yt briefing loop; no channels, no state, no rating.
4
+ argument-hint: URL or VIDEO_ID. Optional --lang pl|en|auto (defaults to auto).
5
+ ---
6
+
7
+ ## Step 1 — Fetch the transcript
8
+
9
+ ```bash
10
+ bun run src/yt-transcript.ts <VIDEO_ID_OR_URL> --lang <lang>
11
+ ```
12
+
13
+ - First argument: URL or VIDEO_ID straight from the user — the script parses both formats (`youtube.com/watch?v=…`, `youtu.be/…`, or a bare 11-char ID).
14
+ - `--lang` selects which **caption track to fetch** (the input), not the summary language. `auto`
15
+ by default (best available track); pass `pl` / `en` only if the user wants a specific track.
16
+ - The transcript goes to **stdout**; stderr carries diagnostics. Run it bare — no redirects.
17
+
18
+ Exit codes — the message you show the user must match the real cause. Never report a
19
+ tooling failure as "no subtitles". When the code is non-zero, read the script's stderr
20
+ and surface that actual reason; do not paraphrase it away.
21
+ - `0` — transcript on stdout → continue to Step 2.
22
+ - `1` → the video genuinely has no subtitles. Tell the user exactly that and stop.
23
+ - `2` → YouTube rate-limit / IP block (common on datacenter/VPS IPs). Tell the user and
24
+ stop; it's transient. Recovery: route fetches through a proxy — see
25
+ `docs/warp-proxy.md` (set `YT_BRIEFING_PROXY`).
26
+ - `3` → tooling/integration failure (yt-dlp missing, fetch error, unavailable or private
27
+ video, empty/unparseable track, bad input). This is NOT a missing-captions case.
28
+ Show the stderr line verbatim so the user sees the real problem, and stop.
29
+
30
+ ---
31
+
32
+ ## Step 2 — Summary
33
+
34
+ Read the transcript from the tool result and respond as a narrative.
35
+
36
+ - **Header:** title, author, date
37
+ - One introductory sentence
38
+ - One paragraph per significant thread — a bold label + continuous prose, concrete facts. Preserve the logic and rhetoric of the original.
39
+ - Verdict: what's strong, what's weak, whether it was worth it
40
+
41
+ Style:
42
+ - an intelligent journalist — not like an AI assistant summarizing an article
43
+ - no generalities, always specifics: what was the thesis, what was the argument
44
+ - if the material has a clear host and guests, show that in the text
45
+
46
+ **Language:** Read `data/config.json` → `output_lang` once and write the summary in that
47
+ language — the same language chosen at onboarding that `/yt` uses. Order of precedence:
48
+ (1) a language the user explicitly asks for in this request wins; (2) otherwise `output_lang`
49
+ from config; (3) only if config is missing, match the language of the video. This is the
50
+ **output** language and is independent of `--lang` (which only picks the caption track to
51
+ fetch). Don't mix languages: write fully in the target language and insert foreign words only
52
+ when they are (a) a proper name of a technology/product, or (b) an established technical term
53
+ with no natural equivalent (API, REST, JSON, webhook, endpoint). Translate everything else.
54
+
55
+ ---
56
+
57
+ ## Rules
58
+
59
+ - **Transcripts:** never paste the raw transcript into chat — the summary is the artifact.
60
+ - **One-shot, stateless:** this skill reads no channels, writes no state, asks for no rating.
61
+ It is independent of the `/yt` briefing loop, though it shares the same transcript engine
62
+ and proxy. For the recurring channel briefing, use `/yt` instead.
package/README.md CHANGED
@@ -24,7 +24,8 @@ the last one left off.
24
24
  You'll need Node 18+ or Bun, a YouTube Data API v3 key, an LLM key (a
25
25
  [free Gemini key](https://aistudio.google.com/apikey) works, see [Providers](#providers)), and
26
26
  a tool that runs skills: [Claude Code](https://claude.com/claude-code),
27
- [Cursor](https://cursor.com), or anything else that loads `SKILL.md`.
27
+ [Cursor](https://cursor.com), [Codex](https://developers.openai.com/codex), or anything else
28
+ that loads the standard `SKILL.md` (Agent Skills — 30+ agents).
28
29
 
29
30
  1. Install yt-dlp (it pulls the subtitles):
30
31
 
@@ -61,11 +62,45 @@ npx yt-briefing remove @handle # also deletes its l
61
62
  npx yt-briefing list # show the current list
62
63
  ```
63
64
 
65
+ ## One-off: transcribe a single video
66
+
67
+ Just want one video summarized — no channels, no queue, no rating? Run `/yt-transcribe` and
68
+ paste a URL or video ID. It pulls that video's transcript and writes a journalist-grade
69
+ summary in the language you chose at setup (the same `output_lang` as `/yt`). Want a one-off in
70
+ another language? Just say so when you run it (e.g. `/yt-transcribe <url> in German`) — it
71
+ won't change your setup. `--lang pl|en` is separate — it picks which caption track to fetch,
72
+ not the summary language.
73
+
74
+ The skill is installed alongside `/yt` by `init` / `install-skill`. From the plain CLI:
75
+
76
+ ```bash
77
+ npx yt-briefing transcribe <url-or-id> --lang auto # prints the transcript to stdout
78
+ ```
79
+
80
+ ## Research a topic across YouTube
81
+
82
+ Want to know what YouTube says about something — and get an actual comparison, not a pile of
83
+ links? Run `/yt-search` and describe the intent, e.g. `which terminal for coding with Claude
84
+ Code`. The matching is **descriptive, not exact keywords**: an LLM turns your intent into search
85
+ queries, then ranks the results against what you actually meant (on titles/descriptions — no
86
+ transcripts yet). Then it hands you **one video at a time** with a rich summary; you **Keep** or
87
+ **Skip** each. At the end it synthesizes a **comparison** from everything you kept.
88
+
89
+ It's lazy on purpose — one transcript per step, never a burst (a burst gets your IP blocked, same
90
+ as `/yt`). Summaries and the comparison use the language you chose at setup.
91
+
92
+ ```bash
93
+ yt-briefing search "<intent>" [--max N] [--queries 1..3] [--since YYYY-MM-DD] # JSON status line
94
+ ```
95
+
96
+ > Each search calls the YouTube `search.list` endpoint, which costs **100 quota units per query**
97
+ > (plain reads cost 1; the daily free quota is 10,000). `--queries` (default 2) caps how many.
98
+
64
99
  ## Run it
65
100
 
66
101
  Open your project in Claude Code or Cursor and run `/yt`. If it's not listed, start a fresh
67
- session. To install the skill again for another tool or project, run
68
- `npx yt-briefing install-skill`.
102
+ session. To install the skills again for another tool or project, run
103
+ `npx yt-briefing install-skill` (it installs `/yt`, `/yt-transcribe`, and `/yt-search`).
69
104
 
70
105
  ## Providers
71
106
 
@@ -94,9 +129,9 @@ summarizing the next video while you rate the current one, so the following step
94
129
  ready with no wait. An agent's turn-by-turn loop cannot prefetch like that, and every step pays
95
130
  its own cold start, which adds up across a whole queue.
96
131
 
97
- Compatibility. A standard API plus a small skill means one engine runs everywhere: Claude Code,
98
- Cursor, any other tool that loads a skill, or the plain CLI. A tool-native approach would tie it
99
- to that one tool and one model.
132
+ Compatibility. A standard API plus a standard `SKILL.md` means one engine runs everywhere: Claude
133
+ Code, Cursor, Codex, any other Agent-Skills-compatible tool, or the plain CLI. A tool-native
134
+ approach would tie it to that one tool and one model.
100
135
 
101
136
  ## Why one transcript at a time
102
137
 
package/dist/bootstrap.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ENV_PATH, profilePath, } from "./lib/paths.js";
19
- import { AGENTS, installSkill, projectSkillDir, customSkillDirDefault, isPackageDevCwd } from "./lib/skill-install.js";
19
+ import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd } from "./lib/skill-install.js";
20
20
  import { question } from "./lib/prompt.js";
21
21
  import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow } from "./lib/channels.js";
22
22
  const ask = (q, def = '') => {
@@ -108,13 +108,14 @@ function main() {
108
108
  // 6. Coding agent ------------------------------------------------------------
109
109
  // Place the skill INTO THIS PROJECT (the package folder you open in the agent) — never a
110
110
  // home-global dir (that's the npm -g antipattern: machine-wide, invisible, easy to forget).
111
- // Claude Code reads .claude/skills/ (already shipped here); Cursor reads .cursor/skills/.
112
- // 1/2 = known agents; 3 = any other agent (a project folder you name).
113
- console.log('\n 6) Which agent will you run /yt in?');
114
- console.log(' 1) Claude Code 2) Cursor 3) Custom folder (any other agent)\n');
111
+ // SKILL.md is the cross-agent standard, so the shipped skill runs in any compatible agent —
112
+ // we just install it into that agent's skills dir (.claude/skills, .cursor/skills, .codex/skills).
113
+ // 1/2/3 = known agents; 4 = any other compatible agent (a project folder you name).
114
+ console.log('\n 6) Which agent will you run /yt in? (it ships a standard Agent Skill — any compatible agent works)');
115
+ console.log(' 1) Claude Code 2) Cursor 3) Codex 4) Custom folder (any other agent)\n');
115
116
  const agentKey = ask(' Your agent', '1');
116
117
  // For a custom target, ask the folder now (keeps all prompts in the interactive block).
117
- const customDir = AGENTS[agentKey] ? '' : ask(' Folder to install the skill into', customSkillDirDefault());
118
+ const customDir = AGENTS[agentKey] ? '' : ask(' Skills folder to install into', customSkillsRootDefault());
118
119
  // 7. Write everything --------------------------------------------------------
119
120
  mkdirSync(CHANNELS_DIR, { recursive: true });
120
121
  // .env
@@ -148,23 +149,24 @@ function main() {
148
149
  console.log(` ${CHANNELS_MD}`);
149
150
  console.log(` ${STATE_MD}`);
150
151
  console.log(` ${channels.length} profile(s) in ${CHANNELS_DIR}/`);
151
- // Install the /yt skill for the chosen agent (step 6), into THIS project — process.cwd(),
152
- // i.e. wherever you ran the command (the package clone in dev, or your own project when the
153
- // package is a dependency). The command baked in is the shipped `bun run src` only for the
154
- // dev-in-clone case; otherwise the compiled `dist/` command (so a consumed package works).
152
+ // Install the /yt + /yt-transcribe skills for the chosen agent (step 6), into THIS project —
153
+ // process.cwd(), i.e. wherever you ran the command (the package clone in dev, or your own
154
+ // project when the package is a dependency). The command baked in is the shipped `bun run src`
155
+ // only for the dev-in-clone case; otherwise the compiled `dist/` command (so a consumed package works).
155
156
  const agent = AGENTS[agentKey];
156
157
  try {
157
- const target = agent
158
- ? installSkill(projectSkillDir(agentKey, process.cwd()), /* dist */ !isPackageDevCwd())
159
- : installSkill(customDir, /* dist */ true);
160
- console.log(` /yt skill → ${target}`);
158
+ const targets = agent
159
+ ? installSkills(projectSkillsRoot(agentKey, process.cwd()), /* dist */ !isPackageDevCwd())
160
+ : installSkills(customDir, /* dist */ true);
161
+ for (const t of targets)
162
+ console.log(` skill → ${t}`);
161
163
  }
162
164
  catch (e) {
163
- console.log(` ! Couldn't install the skill (${e.message}) — run yt-briefing install-skill later.`);
165
+ console.log(` ! Couldn't install the skills (${e.message}) — run yt-briefing install-skill later.`);
164
166
  }
165
167
  console.log('\n Next:');
166
168
  console.log(` 1. Open this folder in ${agent ? agent.name : 'your agent'}.`);
167
- console.log(' 2. Start a new chat and type /yt');
169
+ console.log(' 2. Start a new chat and type /yt (or /yt-transcribe <url>)');
168
170
  console.log('\n No agent? Run it in the terminal instead — see the README.\n');
169
171
  }
170
172
  try {
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * yt-briefing sweep [--reset] advance one step; prints a JSON status line
13
13
  * yt-briefing rate --rating 0|1 [...] record a rating for the pending video
14
14
  * yt-briefing transcribe <url|id> print a single video's transcript
15
+ * yt-briefing search "<intent>" [...] topic search → lazy triage → compare (JSON status line)
15
16
  */
16
17
  import { spawnSync } from 'node:child_process';
17
18
  import { script } from "./lib/paths.js";
@@ -24,6 +25,7 @@ const TARGETS = {
24
25
  sweep: 'yt-sweep',
25
26
  rate: 'yt-rating',
26
27
  transcribe: 'yt-transcript',
28
+ search: 'yt-search',
27
29
  };
28
30
  const CHANNEL_ACTIONS = new Set(['add', 'remove', 'list']);
29
31
  let argv = null;
@@ -32,7 +34,7 @@ if (cmd && CHANNEL_ACTIONS.has(cmd))
32
34
  else if (cmd && TARGETS[cmd])
33
35
  argv = [script(TARGETS[cmd]), ...rest];
34
36
  if (!argv) {
35
- console.error('Usage: yt-briefing <init|install-skill|add|remove|list|sweep|rate|transcribe> [args...]');
37
+ console.error('Usage: yt-briefing <init|install-skill|add|remove|list|sweep|rate|transcribe|search> [args...]');
36
38
  process.exit(cmd ? 1 : 0);
37
39
  }
38
40
  const res = spawnSync(process.execPath, argv, { stdio: 'inherit' });
@@ -1,51 +1,55 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * install-skill — copy the `/yt` skill into a coding agent's skills directory so the
4
- * agent detects it. For any target that isn't this package under Bun, the command is baked to
5
- * `"<this runtime>" "<abs>/dist/X.js"` (the compiled build), so it works no matter the agent's
6
- * working directory or runtime (the engine resolves data/.env from its own location).
3
+ * install-skill — copy this package's skills (`/yt` + `/yt-transcribe`) into a coding agent's
4
+ * skills directory so the agent detects them. For any target that isn't this package under Bun,
5
+ * each command is baked to `"<this runtime>" "<abs>/dist/X.js"` (the compiled build), so it works
6
+ * no matter the agent's working directory or runtime (the engine resolves data/.env from its own
7
+ * location).
7
8
  *
8
9
  * yt-briefing install-skill # interactive: pick agent + scope
9
10
  *
10
- * `bun run init` already installs this skill into the project as its final step; this
11
+ * `bun run init` already installs these skills into the project as its final step; this
11
12
  * standalone command is for re-installing, a different project, or a second agent. There is
12
- * deliberately no home-global install — the skill lives with the project that uses it.
13
+ * deliberately no home-global install — the skills live with the project that uses them.
13
14
  *
14
- * Both Claude Code and Cursor load `SKILL.md` skills and invoke them as `/<name>`.
15
- * Cursor also reads `.claude/skills/` for compatibility, so the copy this package already
16
- * ships often works in both — this command just (re)places it where you want.
15
+ * SKILL.md is the cross-agent Agent Skills standard, so the shipped skills run in any compatible
16
+ * agent (Claude Code, Cursor, Codex, and 30+ others); this command just (re)places them in the
17
+ * skills dir of whichever agent you pick.
17
18
  */
18
- import { AGENTS, installSkill, projectSkillDir, customSkillDirDefault, isPackageDevCwd } from "./lib/skill-install.js";
19
+ import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd } from "./lib/skill-install.js";
19
20
  import { question } from "./lib/prompt.js";
20
21
  const ask = (q, def = '') => question(def ? `${q} [${def}]:` : `${q}:`).trim() || def;
21
- function done(target) {
22
- console.log(`\n ✓ Installed → ${target}`);
23
- console.log(' Start a fresh agent session, then run /yt\n');
22
+ function done(targets) {
23
+ console.log('\n ✓ Installed:');
24
+ for (const t of targets)
25
+ console.log(` ${t}`);
26
+ console.log(' Start a fresh agent session, then run /yt or /yt-transcribe\n');
24
27
  }
25
28
  // 1) which agent → which skills subdir
26
- console.log('\n Install the /yt skill — which agent?\n');
29
+ console.log('\n Install the /yt + /yt-transcribe skills — which agent?\n');
27
30
  console.log(' 1) Claude Code');
28
- console.log(' 2) Cursor (also reads Claude\'s .claude/skills)');
29
- console.log(' 3) Custom folder (any other agent)\n');
31
+ console.log(' 2) Cursor');
32
+ console.log(' 3) Codex');
33
+ console.log(' 4) Custom folder (any other compatible agent)\n');
30
34
  const agentKey = ask(' Agent', '1');
31
35
  const agent = AGENTS[agentKey];
32
- // 3) Custom — write SKILL.md straight into a folder the user names (their agent's skills dir).
33
- // Arbitrary location → bake the absolute dist command so it works whatever the agent's cwd is.
36
+ // 3) Custom — write the skills straight into a skills root the user names (their agent's dir).
37
+ // Arbitrary location → bake the absolute dist commands so they work whatever the agent's cwd is.
34
38
  if (!agent) {
35
- done(installSkill(ask(' Folder to install the skill into', customSkillDirDefault()), true));
39
+ done(installSkills(ask(' Skills folder to install into', customSkillsRootDefault()), true));
36
40
  process.exit(0);
37
41
  }
38
42
  // 2) Known agent → which project (default: the current folder). No home-global option by
39
- // design — the skill is always scoped to a project that uses it.
43
+ // design — the skills are always scoped to a project that uses them.
40
44
  console.log(`\n ${agent.name} — which project?\n`);
41
45
  console.log(' 1) This project (current folder) — recommended');
42
46
  console.log(' 2) Another project folder\n');
43
47
  if (ask(' Where', '1') === '2') {
44
- // A different project → the agent's cwd won't be the package, so bake the absolute dist command.
45
- done(installSkill(projectSkillDir(agentKey, ask(' Project folder', process.cwd())), true));
48
+ // A different project → the agent's cwd won't be the package, so bake the absolute dist commands.
49
+ done(installSkills(projectSkillsRoot(agentKey, ask(' Project folder', process.cwd())), true));
46
50
  }
47
51
  else {
48
52
  // Current folder: shipped `bun run src` only when developing in the package clone under Bun;
49
- // otherwise (incl. consuming the package as a dependency) bake the compiled dist command.
50
- done(installSkill(projectSkillDir(agentKey, process.cwd()), !isPackageDevCwd()));
53
+ // otherwise (incl. consuming the package as a dependency) bake the compiled dist commands.
54
+ done(installSkills(projectSkillsRoot(agentKey, process.cwd()), !isPackageDevCwd()));
51
55
  }
package/dist/lib/paths.js CHANGED
@@ -47,6 +47,10 @@ export const REST_FILE = join(CACHE_DIR, 'queue-rest.json');
47
47
  export const PENDING_FILE = join(CACHE_DIR, 'pending.json');
48
48
  export const PREFETCH_FILE = join(CACHE_DIR, 'prefetch.json');
49
49
  export const LOG_FILE = join(CACHE_DIR, 'sweep.log');
50
+ // /yt-search (ad-hoc topic search → lazy triage → compare). All throwaway, under .cache/.
51
+ export const SEARCH_QUEUE_FILE = join(CACHE_DIR, 'search-queue.json'); // ranked candidates + cursor
52
+ export const SEARCH_PENDING_FILE = join(CACHE_DIR, 'search-pending.json'); // current candidate awaiting keep/skip
53
+ export const SEARCH_KEPT_FILE = join(CACHE_DIR, 'search-kept.json'); // kept mega-summaries → compare corpus
50
54
  /** Absolute path to a channel profile from its slug. */
51
55
  export const profilePath = (slug) => join(CHANNELS_DIR, `${slug}.md`);
52
56
  /**
@@ -1,12 +1,17 @@
1
1
  /**
2
- * skill-install — place the `/yt` SKILL.md into a coding agent's skills directory.
2
+ * skill-install — place this package's SKILL.md files into a coding agent's skills directory.
3
3
  *
4
4
  * Shared by the onboarding wizard (`bootstrap.ts`, final step) and the standalone
5
- * `install-skill.ts` command, so both write the skill identically.
5
+ * `install-skill.ts` command, so both write the skills identically.
6
6
  *
7
- * The shipped SKILL.md uses `bun run src/X.ts` — the dev shortcut: it works when the agent's
8
- * cwd IS the package folder AND the runtime is Bun (which runs TypeScript directly). That's
9
- * true for the publisher's own day-to-day use, so it stays the default.
7
+ * The package ships TWO skills, each at `.claude/skills/<name>/SKILL.md`:
8
+ * yt — the recurring channel briefing loop (sweep + rate).
9
+ * yt-transcribe — one-shot: a single video's transcript → summary.
10
+ * Both are installed together so an agent gets the whole toolset in one step.
11
+ *
12
+ * The shipped SKILL.md files use `bun run src/X.ts` — the dev shortcut: it works when the
13
+ * agent's cwd IS the package folder AND the runtime is Bun (which runs TypeScript directly).
14
+ * That's true for the publisher's own day-to-day use, so it stays the default.
10
15
  *
11
16
  * For everyone else — a Node user, or any install whose cwd won't be the package — we bake an
12
17
  * absolute, runtime-correct command instead: `"<this runtime>" "<abs>/dist/X.js"`. The runtime
@@ -20,6 +25,8 @@ import { join, resolve } from 'node:path';
20
25
  import { PKG_ROOT, DATA_DIR } from "./paths.js";
21
26
  /** Compiled output dir — what a baked (dist) skill command points the runtime at. */
22
27
  const DIST_DIR = join(PKG_ROOT, 'dist');
28
+ /** The skills this package ships — each lives at `.claude/skills/<name>/SKILL.md`. */
29
+ export const SKILLS = ['yt', 'yt-transcribe', 'yt-search'];
23
30
  /** True when the installer itself is running under Bun (vs plain Node). */
24
31
  export const isBun = process.versions.bun != null;
25
32
  /**
@@ -30,23 +37,34 @@ export const isBun = process.versions.bun != null;
30
37
  * compiled `dist/` command instead. This detects that one dev-in-clone case.
31
38
  */
32
39
  export const isPackageDevCwd = () => isBun && resolve(process.cwd()) === PKG_ROOT;
33
- export const SOURCE = join(PKG_ROOT, '.claude/skills/yt/SKILL.md');
34
- /** Agent key → display name + the skills subdirectory it scans. */
40
+ /** Source path of a shipped skill's SKILL.md, by skill name. */
41
+ export const skillSource = (name) => join(PKG_ROOT, '.claude', 'skills', name, 'SKILL.md');
42
+ /**
43
+ * Agent key → display name + the skills ROOT directory it scans (skills install under it).
44
+ *
45
+ * SKILL.md is the cross-agent Agent Skills standard (Anthropic, Dec 2025), now read by 30+
46
+ * tools that each scan their own `<agent-home>/skills/` dir. We only need the right directory
47
+ * per agent — the shipped SKILL.md works unmodified in all of them. The "custom folder" picker
48
+ * option (no AGENTS entry) covers every other compatible agent (Gemini CLI, Copilot, Windsurf…)
49
+ * and defaults to the neutral `.agents/skills/` location.
50
+ */
35
51
  export const AGENTS = {
36
- '1': { name: 'Claude Code', sub: join('.claude', 'skills', 'yt') },
37
- '2': { name: 'Cursor', sub: join('.cursor', 'skills', 'yt') },
52
+ '1': { name: 'Claude Code', sub: join('.claude', 'skills') },
53
+ '2': { name: 'Cursor', sub: join('.cursor', 'skills') },
54
+ '3': { name: 'Codex', sub: join('.codex', 'skills') },
38
55
  };
39
56
  /**
40
- * The shipped SKILL.md. `dist=false` (default) returns it verbatim — the `bun run src/X.ts`
41
- * dev form, correct only when cwd is the package AND the runtime is Bun. `dist=true` rewrites
42
- * for the consumed case: engine commands become `"<process.execPath>" "<abs>/dist/X.js"` (this
43
- * machine's runtime, Node or Bun, against the compiled build, so they run from any cwd), and the
44
- * bare `data/…` paths the agent reads (e.g. `data/config.json`) become the absolute `DATA_DIR`.
45
- * In dev the agent's cwd IS the package so `data/` resolves; when consumed, DATA_DIR moves to
46
- * `<project>/.yt-briefing/data`, so the dev-relative paths would miss — hence the rewrite.
57
+ * One shipped skill's SKILL.md. `dist=false` (default) returns it verbatim — the
58
+ * `bun run src/X.ts` dev form, correct only when cwd is the package AND the runtime is Bun.
59
+ * `dist=true` rewrites for the consumed case: engine commands become
60
+ * `"<process.execPath>" "<abs>/dist/X.js"` (this machine's runtime, Node or Bun, against the
61
+ * compiled build, so they run from any cwd), and the bare `data/…` paths the agent reads (e.g.
62
+ * `data/config.json`) become the absolute `DATA_DIR`. In dev the agent's cwd IS the package so
63
+ * `data/` resolves; when consumed, DATA_DIR moves to `<project>/.yt-briefing/data`, so the
64
+ * dev-relative paths would miss — hence the rewrite.
47
65
  */
48
- export function skillBody(dist = false) {
49
- const raw = readFileSync(SOURCE, 'utf8');
66
+ export function skillBody(name, dist = false) {
67
+ const raw = readFileSync(skillSource(name), 'utf8');
50
68
  if (!dist)
51
69
  return raw;
52
70
  const exe = process.execPath;
@@ -54,17 +72,25 @@ export function skillBody(dist = false) {
54
72
  return raw
55
73
  .replace(/bun run src\/yt-sweep\.ts/g, cmd('yt-sweep'))
56
74
  .replace(/bun run src\/yt-rating\.ts/g, cmd('yt-rating'))
75
+ .replace(/bun run src\/yt-transcript\.ts/g, cmd('yt-transcript'))
76
+ .replace(/bun run src\/yt-search\.ts/g, cmd('yt-search'))
57
77
  .replace(/data\//g, DATA_DIR + '/');
58
78
  }
59
- /** Write the skill into `dir` (created if needed). Returns the SKILL.md path written. */
60
- export function installSkill(dir, dist = false) {
61
- mkdirSync(dir, { recursive: true });
62
- const target = join(dir, 'SKILL.md');
63
- writeFileSync(target, skillBody(dist), 'utf8');
64
- return target;
79
+ /**
80
+ * Write every shipped skill into `root`, each under its own `<name>/SKILL.md` subdir
81
+ * (created if needed). Returns the SKILL.md paths written, in `SKILLS` order.
82
+ */
83
+ export function installSkills(root, dist = false) {
84
+ return SKILLS.map((name) => {
85
+ const dir = join(root, name);
86
+ mkdirSync(dir, { recursive: true });
87
+ const target = join(dir, 'SKILL.md');
88
+ writeFileSync(target, skillBody(name, dist), 'utf8');
89
+ return target;
90
+ });
65
91
  }
66
- /** The agent's skills directory inside a project folder (the project you open in the agent). */
67
- export const projectSkillDir = (agentKey, projectDir) => join(projectDir, AGENTS[agentKey].sub);
92
+ /** The agent's skills ROOT inside a project folder (the project you open in the agent). */
93
+ export const projectSkillsRoot = (agentKey, projectDir) => join(projectDir, AGENTS[agentKey].sub);
68
94
  /** Suggested target for a "custom" (any other agent) install — the open `.agents` convention,
69
95
  * rooted at the user's current project (not the package, which may be in node_modules). */
70
- export const customSkillDirDefault = () => join(process.cwd(), '.agents', 'skills', 'yt');
96
+ export const customSkillsRootDefault = () => join(process.cwd(), '.agents', 'skills');
@@ -110,6 +110,35 @@ async function enrichWithTypes(videos) {
110
110
  }
111
111
  return out;
112
112
  }
113
+ /**
114
+ * Free-text video search via `search.list`. YouTube ranks by relevance, so the query can be
115
+ * descriptive — no exact keyword match required. NOTE: search.list costs 100 quota units per
116
+ * call (plain reads cost 1), so callers should keep the number of queries small.
117
+ *
118
+ * `since` (ISO date) maps to publishedAfter — useful to cut stale results on fast-moving topics.
119
+ */
120
+ export async function searchVideos(query, opts = {}) {
121
+ const { maxResults = 10, since = null } = opts;
122
+ const params = {
123
+ part: 'snippet',
124
+ q: query,
125
+ type: 'video',
126
+ order: 'relevance',
127
+ maxResults: String(Math.min(Math.max(maxResults, 1), 50)),
128
+ };
129
+ if (since)
130
+ params.publishedAfter = new Date(since).toISOString();
131
+ const data = await get('search', params);
132
+ return (data.items || [])
133
+ .filter((it) => it.id?.videoId)
134
+ .map((it) => ({
135
+ videoId: it.id.videoId,
136
+ title: it.snippet?.title ?? '',
137
+ channelTitle: it.snippet?.channelTitle ?? '',
138
+ publishedAt: it.snippet?.publishedAt ?? '',
139
+ description: it.snippet?.description ?? '',
140
+ }));
141
+ }
113
142
  /**
114
143
  * List a channel's uploads (newest first). With `enrich` (default true) each video is
115
144
  * typed (short/live/longform) and current/upcoming live broadcasts are filtered out.
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * yt-search.ts — ad-hoc topic search → lazy triage → comparison. The third yt-briefing mode,
4
+ * sibling to the channel briefing (yt-sweep) and one-shot transcribe (yt-transcript).
5
+ *
6
+ * You describe an intent ("which terminal for coding with Claude Code"); the engine:
7
+ * 1. expands it into a few YouTube search queries (LLM),
8
+ * 2. runs search.list and merges candidates,
9
+ * 3. re-ranks candidates against your intent on metadata only — title/channel/description,
10
+ * NO transcript yet (cheap; protects the expensive/rate-limited transcript step),
11
+ * 4. yields ONE candidate at a time with a rich summary, lazily — never a burst of transcript
12
+ * fetches (a burst looks like scraping and gets the IP blocked, same reason yt-sweep is lazy),
13
+ * 5. records your keep/skip decision; kept summaries accumulate in a cache,
14
+ * 6. on demand synthesizes a comparison across everything you kept.
15
+ *
16
+ * Matching is descriptive, not exact-keyword: search.list already ranks by relevance, and the
17
+ * LLM bridges intent→query (step 1) and filters noise (step 3).
18
+ *
19
+ * Usage (the skill / CLI drives these; one JSON line per call):
20
+ * yt-search "<intent>" [--reset] [--max N] [--queries N] [--since DATE] [--lang auto]
21
+ * yt-search --keep record the pending candidate, advance, yield next
22
+ * yt-search --skip drop the pending candidate, advance, yield next
23
+ * yt-search --compare synthesize a comparison from everything kept
24
+ *
25
+ * Output statuses:
26
+ * {"status":"decision_needed","summary":"<md>","pending":{videoId,title,channelTitle,publishedAt,position,total}}
27
+ * {"status":"done","kept":N} queue exhausted — caller runs --compare if kept>0
28
+ * {"status":"compare","comparison":"<md>"}
29
+ * {"status":"no_results"} search returned nothing for the intent
30
+ * {"status":"rate_limited"} transcript fetch blocked (datacenter IP — see docs/warp-proxy.md)
31
+ * {"status":"error","error":"<msg>"} setup/config problem (missing key, etc.)
32
+ *
33
+ * Cache (all throwaway, under DATA_DIR/.cache): search-queue.json (ranked candidates + cursor),
34
+ * search-pending.json (current candidate), search-kept.json (kept summaries = compare corpus).
35
+ */
36
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
37
+ import { spawn } from 'node:child_process';
38
+ import dotenv from 'dotenv';
39
+ import { chat, getModel } from "./lib/llm.js";
40
+ import { outputLang } from "./lib/config.js";
41
+ import { searchVideos } from "./lib/yt-api.js";
42
+ import { PKG_ROOT, ENV_PATH, CACHE_DIR, SEARCH_QUEUE_FILE, SEARCH_PENDING_FILE, SEARCH_KEPT_FILE, script, } from "./lib/paths.js";
43
+ dotenv.config({ path: ENV_PATH });
44
+ mkdirSync(CACHE_DIR, { recursive: true });
45
+ const RUNTIME = process.execPath;
46
+ const LANG = outputLang();
47
+ const argv = process.argv.slice(2);
48
+ const has = (f) => argv.includes(f);
49
+ const flagVal = (f) => {
50
+ const i = argv.indexOf(f);
51
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
52
+ };
53
+ // First non-flag token is the intent (only on the initial / --reset call).
54
+ const positional = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && argv[i - 1] !== '--reset' && argv[i - 1] !== '--keep' && argv[i - 1] !== '--skip' && argv[i - 1] !== '--compare'));
55
+ const intentArg = positional[0] ?? null;
56
+ const RESET = has('--reset');
57
+ const KEEP = has('--keep');
58
+ const SKIP = has('--skip');
59
+ const COMPARE = has('--compare');
60
+ const MAX = Math.max(1, parseInt(flagVal('--max') || '8', 10));
61
+ const QUERIES = Math.max(1, Math.min(3, parseInt(flagVal('--queries') || '2', 10)));
62
+ const SINCE = flagVal('--since');
63
+ const LANGTRACK = flagVal('--lang') || 'auto';
64
+ function emit(obj) {
65
+ process.stdout.write(JSON.stringify(obj));
66
+ process.exit(0);
67
+ }
68
+ const readJSON = (p, fallback) => {
69
+ if (!existsSync(p))
70
+ return fallback;
71
+ try {
72
+ return JSON.parse(readFileSync(p, 'utf8'));
73
+ }
74
+ catch {
75
+ return fallback;
76
+ }
77
+ };
78
+ const writeJSON = (p, v) => writeFileSync(p, JSON.stringify(v));
79
+ const loadQueue = () => readJSON(SEARCH_QUEUE_FILE, null);
80
+ const loadKept = () => readJSON(SEARCH_KEPT_FILE, []);
81
+ /** Fetch a transcript via the sibling script with the launching runtime. Mirrors yt-sweep. */
82
+ function fetchTranscript(videoId) {
83
+ return new Promise((resolve, reject) => {
84
+ const p = spawn(RUNTIME, [script('yt-transcript'), videoId, '--lang', LANGTRACK], { cwd: PKG_ROOT, env: { ...process.env } });
85
+ let stdout = '';
86
+ p.stdout.on('data', d => { stdout += d.toString(); });
87
+ p.stderr.resume();
88
+ p.on('close', code => resolve({ stdout, code: code ?? 1 }));
89
+ p.on('error', reject);
90
+ });
91
+ }
92
+ /** Strip ```fences``` and slice the outermost JSON array from an LLM reply. */
93
+ function parseJsonArray(out) {
94
+ const start = out.indexOf('['), end = out.lastIndexOf(']');
95
+ if (start === -1 || end === -1 || end < start)
96
+ return null;
97
+ try {
98
+ return JSON.parse(out.slice(start, end + 1));
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ // ---------- stage 1: intent → search queries ----------
105
+ async function expandQueries(intent) {
106
+ const prompt = `A user wants to research a topic on YouTube. Turn their intent into up to ${QUERIES} effective YouTube search queries (short, keyword-rich, the way people actually search). Cover slightly different angles if useful. Use the language the topic is most discussed in (usually English for tech).
107
+
108
+ Intent: "${intent}"
109
+
110
+ Output ONLY a raw JSON array of strings, no fences, no commentary. Example: ["query one","query two"]`;
111
+ try {
112
+ const out = await chat(prompt, { system: 'You output ONLY a raw JSON array of search-query strings.', temperature: 0.4 });
113
+ const arr = parseJsonArray(out);
114
+ const qs = (arr ?? []).filter((s) => typeof s === 'string' && s.trim().length > 0).slice(0, QUERIES);
115
+ return qs.length ? qs : [intent];
116
+ }
117
+ catch {
118
+ return [intent]; // expansion is best-effort; fall back to the raw intent
119
+ }
120
+ }
121
+ // ---------- stage 3: re-rank candidates against intent (metadata only) ----------
122
+ async function rerank(intent, hits) {
123
+ if (hits.length === 0)
124
+ return [];
125
+ const compact = hits.map(h => ({ id: h.videoId, title: h.title, channel: h.channelTitle, published: h.publishedAt, desc: (h.description || '').slice(0, 280) }));
126
+ const prompt = `Rank these YouTube videos by how well they serve the user's intent. Judge on title + channel + description only (no transcripts). Drop clearly off-topic, clickbait, or duplicate-angle results.
127
+
128
+ Intent: "${intent}"
129
+
130
+ Candidates:
131
+ ${JSON.stringify(compact)}
132
+
133
+ Output ONLY a raw JSON array, best first, no fences:
134
+ [{"id":"VIDEO_ID","keep":true,"score":0-100,"reason":"max 12 words"},...]
135
+ Set keep=false for anything not worth the user's time.`;
136
+ try {
137
+ const out = await chat(prompt, { system: 'You output ONLY a raw JSON array as instructed.', temperature: 0 });
138
+ const arr = parseJsonArray(out);
139
+ if (!arr)
140
+ return hits.map(h => ({ ...h })); // fall back: keep all, original order
141
+ const byId = new Map(hits.map(h => [h.videoId, h]));
142
+ const ranked = [];
143
+ for (const r of arr) {
144
+ if (!r || r.keep === false)
145
+ continue;
146
+ const h = byId.get(r.id);
147
+ if (h)
148
+ ranked.push({ ...h, score: typeof r.score === 'number' ? r.score : undefined, reason: r.reason });
149
+ }
150
+ return ranked.length ? ranked : hits.map(h => ({ ...h }));
151
+ }
152
+ catch {
153
+ return hits.map(h => ({ ...h }));
154
+ }
155
+ }
156
+ // ---------- mega-summary for one candidate (the triage artifact) ----------
157
+ async function megaSummary(c, transcript, intent) {
158
+ const prompt = `Summarize this YouTube video in ${LANG} for a user researching: "${intent}". Make it a RICH, standalone summary they can decide on and that will later feed a cross-video comparison — OR return 'OFFTOPIC: <reason>' if the transcript clearly doesn't serve the intent.
159
+
160
+ Video:
161
+ - title: ${c.title}
162
+ - channel: ${c.channelTitle}
163
+ - published: ${c.publishedAt}
164
+ - url: https://youtube.com/watch?v=${c.videoId}
165
+
166
+ Transcript:
167
+ ${transcript}
168
+
169
+ If on-topic, write:
170
+ - Header: ### ${c.channelTitle} — "${c.title}"
171
+ - Subtitle: _${c.publishedAt} · https://youtube.com/watch?v=${c.videoId}_
172
+ - One sentence on relevance to the intent
173
+ - 3-6 numbered thematic sections × 2-4 sentences, concrete: which options/tools are discussed, the criteria, the author's verdict and reasoning. Pull out anything directly comparable (names, pros/cons, recommendations).
174
+ - A short "Bottom line for the intent" line
175
+ - At most 5-8 short quotes from the transcript. No timestamps.
176
+
177
+ Language: natural ${LANG}; foreign words only for proper nouns or established technical terms.
178
+ Output ONLY the summary OR 'OFFTOPIC: <reason>'. No preamble.`;
179
+ return chat(prompt, {
180
+ system: `You are a research-grade video summarizer writing in ${LANG}. Output only the summary or 'OFFTOPIC: <reason>'.`,
181
+ model: getModel(),
182
+ });
183
+ }
184
+ // ---------- stage 6: comparison across kept summaries ----------
185
+ async function synthesizeComparison(intent, kept) {
186
+ const corpus = kept.map((k, i) => `--- VIDEO ${i + 1}: ${k.channelTitle} — "${k.title}" (${k.publishedAt})\nhttps://youtube.com/watch?v=${k.videoId}\n${k.summary}`).join('\n\n');
187
+ const prompt = `The user researched "${intent}" and kept ${kept.length} YouTube video summaries below. Synthesize a single comparison in ${LANG} that actually helps them decide.
188
+
189
+ ${corpus}
190
+
191
+ Write:
192
+ - One-paragraph bottom line answering the intent directly.
193
+ - A comparison of the concrete options/tools across the videos (a Markdown table when it fits: option · who recommends it · pros · cons · best for).
194
+ - Consensus vs disagreements between the sources.
195
+ - A final recommendation with the reasoning, and who it's for.
196
+
197
+ Language: natural ${LANG}; foreign words only for proper nouns or established technical terms. Cite videos as [1], [2]… matching the order above. Output only the comparison.`;
198
+ return chat(prompt, { system: `You synthesize a decision-grade comparison in ${LANG}. No preamble.`, model: getModel() });
199
+ }
200
+ // ---------- lazy yield: advance to the next candidate that has a transcript ----------
201
+ async function yieldNext(queue) {
202
+ while (queue.cursor < queue.candidates.length) {
203
+ const c = queue.candidates[queue.cursor];
204
+ const t = await fetchTranscript(c.videoId);
205
+ if (t.code === 2)
206
+ emit({ status: 'rate_limited' }); // blocked IP — stop, don't advance
207
+ if (t.code !== 0 || !t.stdout.trim()) { // no transcript → auto-skip
208
+ queue.cursor++;
209
+ writeJSON(SEARCH_QUEUE_FILE, queue);
210
+ continue;
211
+ }
212
+ const summary = await megaSummary(c, t.stdout, queue.intent);
213
+ if (summary.startsWith('OFFTOPIC:')) { // re-rank missed it → auto-skip
214
+ queue.cursor++;
215
+ writeJSON(SEARCH_QUEUE_FILE, queue);
216
+ continue;
217
+ }
218
+ const pending = { videoId: c.videoId, title: c.title, channelTitle: c.channelTitle, publishedAt: c.publishedAt, summary };
219
+ writeJSON(SEARCH_PENDING_FILE, pending);
220
+ emit({
221
+ status: 'decision_needed',
222
+ summary,
223
+ pending: { videoId: c.videoId, title: c.title, channelTitle: c.channelTitle, publishedAt: c.publishedAt, position: queue.cursor + 1, total: queue.candidates.length },
224
+ });
225
+ }
226
+ emit({ status: 'done', kept: loadKept().length });
227
+ }
228
+ // ---------- main ----------
229
+ async function main() {
230
+ if (!process.env.YT_BRIEFING_YOUTUBE_API_KEY && (RESET || (intentArg && !loadQueue()))) {
231
+ emit({ status: 'error', error: 'YT_BRIEFING_YOUTUBE_API_KEY is not set — add a YouTube Data API v3 key to .yt-briefing/.env (see README → setup / .env.example).' });
232
+ }
233
+ // --compare: synthesize from kept summaries.
234
+ if (COMPARE) {
235
+ const queue = loadQueue();
236
+ const kept = loadKept();
237
+ if (kept.length === 0)
238
+ emit({ status: 'done', kept: 0 });
239
+ const comparison = await synthesizeComparison(queue?.intent ?? '', kept);
240
+ emit({ status: 'compare', comparison });
241
+ }
242
+ // --keep / --skip: record decision on the pending candidate, then advance + yield next.
243
+ if (KEEP || SKIP) {
244
+ const queue = loadQueue();
245
+ if (!queue)
246
+ emit({ status: 'error', error: 'No active search. Start one: yt-search "<intent>".' });
247
+ if (KEEP) {
248
+ const pending = readJSON(SEARCH_PENDING_FILE, null);
249
+ if (pending) {
250
+ const kept = loadKept();
251
+ kept.push(pending);
252
+ writeJSON(SEARCH_KEPT_FILE, kept);
253
+ }
254
+ }
255
+ queue.cursor++;
256
+ writeJSON(SEARCH_QUEUE_FILE, queue);
257
+ await yieldNext(queue);
258
+ }
259
+ // Resume an in-progress search (bare call, same intent) without rebuilding.
260
+ const existing = loadQueue();
261
+ if (existing && !RESET && (!intentArg || intentArg === existing.intent)) {
262
+ await yieldNext(existing);
263
+ }
264
+ // Fresh search (new intent or --reset).
265
+ if (!intentArg)
266
+ emit({ status: 'error', error: 'Provide an intent: yt-search "<what to research>".' });
267
+ const queries = await expandQueries(intentArg);
268
+ const seen = new Set();
269
+ const hits = [];
270
+ for (const q of queries) {
271
+ let batch = [];
272
+ try {
273
+ batch = await searchVideos(q, { maxResults: 10, since: SINCE });
274
+ }
275
+ catch (e) {
276
+ emit({ status: 'error', error: e.message });
277
+ }
278
+ for (const h of batch)
279
+ if (!seen.has(h.videoId)) {
280
+ seen.add(h.videoId);
281
+ hits.push(h);
282
+ }
283
+ }
284
+ if (hits.length === 0)
285
+ emit({ status: 'no_results' });
286
+ const ranked = (await rerank(intentArg, hits)).slice(0, MAX);
287
+ if (ranked.length === 0)
288
+ emit({ status: 'no_results' });
289
+ const queue = { built_at: new Date().toISOString(), intent: intentArg, candidates: ranked, cursor: 0 };
290
+ writeJSON(SEARCH_QUEUE_FILE, queue);
291
+ writeJSON(SEARCH_KEPT_FILE, []); // fresh search → fresh compare corpus
292
+ await yieldNext(queue);
293
+ }
294
+ main().catch(err => emit({ status: 'error', error: err.message }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "A self-learning YouTube briefing engine: it sweeps the channels you follow, filters noise in two stages (title, then transcript), summarizes the rest in your language, and adapts to your ratings — one video at a time.",
5
5
  "type": "module",
6
6
  "bin": {