yt-briefing 0.5.0 → 0.6.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.
@@ -1,15 +1,19 @@
1
1
  ---
2
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.
3
+ description: Search WITHIN one YouTube channel by intent — name a channel and what you're after; the engine lists that channel's uploads, ranks them against your intent (metadata only, no transcript yet), then lazily yields ONE matching video at a time with a rich summary. You keep or skip each; at the end it synthesizes a comparison from everything you kept. Channel-scoped, not whole-YouTube. 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 channel (@handle or URL) and a descriptive intent, e.g. "@t3dotgg which terminal for AI coding". Optional --max N, --scan N, --since YYYY-MM-DD.
5
5
  ---
6
6
 
7
7
  ## How it works
8
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.
9
+ `src/yt-search.ts` is the whole engine: list one channel's uploads (cheap — `playlistItems`, ~1 quota unit/page; NOT `search.list`) → re-rank them against your intent on metadata only (title/description, no transcript) → **lazy** one-candidate-at-a-time yield with a rich summary → record keep/skip → on demand synthesize a comparison from everything kept. **Channel-scoped on purpose** — you choose where to look; it does NOT search all of YouTube. Matching is descriptive: the LLM filters the channel's videos by intent. This skill is a thin loop — paste the summary, collect keep/skip, show the final comparison.
10
10
 
11
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
12
 
13
+ ## Inputs
14
+
15
+ The user gives a **channel** (`@handle` or a channel URL) and an **intent** (what to look for). Pass the channel via `--channel` and the intent as the quoted positional. If the user names a channel but no clear intent (or vice versa), ask for the missing half before running.
16
+
13
17
  ## Language
14
18
 
15
19
  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.
@@ -17,11 +21,11 @@ Read `data/config.json` → `output_lang` once at the start. Phrase the question
17
21
  ## Loop
18
22
 
19
23
  ```
20
- out = JSON.parse(`bun run src/yt-search.ts "<intent from the user>" --reset`) // first call
24
+ out = JSON.parse(`bun run src/yt-search.ts "<intent>" --channel <@handle|url> --reset`) // first call
21
25
  while true:
22
26
  out.status:
23
27
  "error" → show out.error verbatim, stop
24
- "no_results" → tell the user nothing relevant was found, stop
28
+ "no_results" → tell the user nothing in that channel matched, stop
25
29
  "rate_limited" → transcript fetch blocked (datacenter IP) — tell the user, stop; recovery in README.md → Running on a VPS
26
30
  "decision_needed" → steps A–C
27
31
  "done" → step D
@@ -38,16 +42,16 @@ while true:
38
42
  - Keep → `bun run src/yt-search.ts --keep`
39
43
  - Skip → `bun run src/yt-search.ts --skip`
40
44
  - 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.
45
+ - The script reads the pending candidate from cache; pass only `--keep` / `--skip` / `--compare` (no channel/intent again). Its JSON becomes the next `out` — back to the top of the loop.
42
46
 
43
47
  **On `done` (step D):**
44
48
 
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.
49
+ - 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 (the artifact — a decision-grade comparison in `output_lang`). Stop.
46
50
  - If `out.kept == 0` → tell the user nothing was kept, so there's nothing to compare. Stop.
47
51
 
48
52
  ## Rules
49
53
 
50
54
  - **Verbatim:** paste `summary` and `comparison` exactly as returned; never paste a raw transcript.
51
55
  - **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`.
56
+ - **Scope:** one channel per search. Listing is cheap; the cost is the lazy transcript fetches, so let the user keep/skip rather than pulling everything. A bare resume (no `--reset`) continues the same ranked queue. `--scan N` (default 50) caps how many recent uploads are considered; `--since` widens by date.
57
+ - **Stateless triage:** independent of `/yt` (no channel profiles, no ratings written). For the recurring multi-channel briefing use `/yt`; for one known video use `/yt-transcribe`.
package/README.md CHANGED
@@ -77,24 +77,26 @@ The skill is installed alongside `/yt` by `init` / `install-skill`. From the pla
77
77
  npx yt-briefing transcribe <url-or-id> --lang auto # prints the transcript to stdout
78
78
  ```
79
79
 
80
- ## Research a topic across YouTube
80
+ ## Search within a channel
81
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.
82
+ Following a creator and want to mine *their* videos for something specific — and get an actual
83
+ comparison, not a pile of links? Run `/yt-search`, name a **channel** and your **intent**, e.g.
84
+ `@t3dotgg which terminal for AI coding`. It lists that channel's uploads, ranks them against your
85
+ intent (titles/descriptions — no transcripts yet, matching is **descriptive, not exact keywords**),
86
+ then hands you **one matching video at a time** with a rich summary; you **Keep** or **Skip** each.
87
+ At the end it synthesizes a **comparison** from everything you kept.
88
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.
89
+ It's channel-scoped on purpose (you choose where to look) and lazy — one transcript per step, never
90
+ a burst (a burst gets your IP blocked, same as `/yt`). Summaries and the comparison use the
91
+ language you chose at setup.
91
92
 
92
93
  ```bash
93
- yt-briefing search "<intent>" [--max N] [--queries 1..3] [--since YYYY-MM-DD] # JSON status line
94
+ yt-briefing search "<intent>" --channel <@handle|url> [--max N] [--scan N] [--since YYYY-MM-DD]
94
95
  ```
95
96
 
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.
97
+ > Listing a channel's uploads is cheap (`playlistItems`, ~1 quota unit per 50 videos — not the
98
+ > 100-unit `search.list`). `--scan` (default 50) caps how many recent uploads are considered;
99
+ > `--since` widens by date.
98
100
 
99
101
  ## Run it
100
102
 
package/dist/cli.js CHANGED
@@ -12,7 +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
+ * yt-briefing search "<intent>" --channel <@handle|url> search within one channel → triage → compare
16
16
  */
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { script } from "./lib/paths.js";
@@ -58,7 +58,7 @@ async function listUploads(playlistId, since, maxCount) {
58
58
  hitOld = true;
59
59
  break;
60
60
  }
61
- videos.push({ videoId: item.snippet.resourceId.videoId, title: item.snippet.title, publishedAt });
61
+ videos.push({ videoId: item.snippet.resourceId.videoId, title: item.snippet.title, publishedAt, description: item.snippet.description ?? '' });
62
62
  if (maxCount !== null && videos.length >= maxCount)
63
63
  return videos;
64
64
  }
@@ -110,35 +110,6 @@ 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
- }
142
113
  /**
143
114
  * List a channel's uploads (newest first). With `enrich` (default true) each video is
144
115
  * typed (short/live/longform) and current/upcoming live broadcasts are filtered out.
package/dist/yt-search.js CHANGED
@@ -1,23 +1,22 @@
1
1
  #!/usr/bin/env node
2
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).
3
+ * yt-search.ts — search WITHIN one channel by intent, then lazy triage → comparison. The third
4
+ * yt-briefing mode, sibling to the channel briefing (yt-sweep) and one-shot transcribe.
5
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.
6
+ * You point it at a channel and describe what you're after ("which terminal does he recommend
7
+ * for AI coding"); the engine:
8
+ * 1. lists that channel's uploads (cheap — playlistItems, 1 quota unit/page; NOT search.list),
9
+ * 2. re-ranks them against your intent on metadata only — title/description, NO transcript yet,
10
+ * 3. yields ONE matching video at a time with a rich summary, lazily — never a burst of
11
+ * transcript fetches (a burst looks like scraping and gets the IP blocked),
12
+ * 4. records your keep/skip decision; kept summaries accumulate in a cache,
13
+ * 5. on demand synthesizes a comparison across everything you kept.
15
14
  *
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).
15
+ * Channel-scoped on purpose: you choose where to look. Matching is descriptive — the LLM filters
16
+ * the channel's videos by intent (no exact-keyword needed).
18
17
  *
19
18
  * Usage (the skill / CLI drives these; one JSON line per call):
20
- * yt-search "<intent>" [--reset] [--max N] [--queries N] [--since DATE] [--lang auto]
19
+ * yt-search "<intent>" --channel <@handle|url> [--reset] [--max N] [--scan N] [--since DATE] [--lang auto]
21
20
  * yt-search --keep record the pending candidate, advance, yield next
22
21
  * yt-search --skip drop the pending candidate, advance, yield next
23
22
  * yt-search --compare synthesize a comparison from everything kept
@@ -26,9 +25,9 @@
26
25
  * {"status":"decision_needed","summary":"<md>","pending":{videoId,title,channelTitle,publishedAt,position,total}}
27
26
  * {"status":"done","kept":N} queue exhausted — caller runs --compare if kept>0
28
27
  * {"status":"compare","comparison":"<md>"}
29
- * {"status":"no_results"} search returned nothing for the intent
28
+ * {"status":"no_results"} the channel has no videos matching the intent
30
29
  * {"status":"rate_limited"} transcript fetch blocked (datacenter IP — see docs/warp-proxy.md)
31
- * {"status":"error","error":"<msg>"} setup/config problem (missing key, etc.)
30
+ * {"status":"error","error":"<msg>"} setup/config problem (missing key, missing channel, …)
32
31
  *
33
32
  * Cache (all throwaway, under DATA_DIR/.cache): search-queue.json (ranked candidates + cursor),
34
33
  * search-pending.json (current candidate), search-kept.json (kept summaries = compare corpus).
@@ -38,27 +37,43 @@ import { spawn } from 'node:child_process';
38
37
  import dotenv from 'dotenv';
39
38
  import { chat, getModel } from "./lib/llm.js";
40
39
  import { outputLang } from "./lib/config.js";
41
- import { searchVideos } from "./lib/yt-api.js";
40
+ import { fetchChannelVideos } from "./lib/yt-api.js";
41
+ import { normalizeHandle } from "./lib/channels.js";
42
42
  import { PKG_ROOT, ENV_PATH, CACHE_DIR, SEARCH_QUEUE_FILE, SEARCH_PENDING_FILE, SEARCH_KEPT_FILE, script, } from "./lib/paths.js";
43
43
  dotenv.config({ path: ENV_PATH });
44
44
  mkdirSync(CACHE_DIR, { recursive: true });
45
45
  const RUNTIME = process.execPath;
46
46
  const LANG = outputLang();
47
47
  const argv = process.argv.slice(2);
48
+ const VALUE_FLAGS = new Set(['--channel', '--max', '--scan', '--since', '--lang']);
48
49
  const has = (f) => argv.includes(f);
49
50
  const flagVal = (f) => {
50
51
  const i = argv.indexOf(f);
51
- return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
52
+ return i !== -1 && argv[i + 1] != null ? argv[i + 1] : null;
52
53
  };
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;
54
+ // First token that is neither a flag nor a flag's value is the intent.
55
+ function positionalIntent() {
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i];
58
+ if (a.startsWith('--')) {
59
+ if (VALUE_FLAGS.has(a))
60
+ i++;
61
+ continue;
62
+ }
63
+ if (i > 0 && VALUE_FLAGS.has(argv[i - 1]))
64
+ continue;
65
+ return a;
66
+ }
67
+ return null;
68
+ }
69
+ const intentArg = positionalIntent();
56
70
  const RESET = has('--reset');
57
71
  const KEEP = has('--keep');
58
72
  const SKIP = has('--skip');
59
73
  const COMPARE = has('--compare');
74
+ const CHANNEL = flagVal('--channel');
60
75
  const MAX = Math.max(1, parseInt(flagVal('--max') || '8', 10));
61
- const QUERIES = Math.max(1, Math.min(3, parseInt(flagVal('--queries') || '2', 10)));
76
+ const SCAN = Math.max(1, parseInt(flagVal('--scan') || '50', 10)); // recent uploads to consider when no --since
62
77
  const SINCE = flagVal('--since');
63
78
  const LANGTRACK = flagVal('--lang') || 'auto';
64
79
  function emit(obj) {
@@ -89,7 +104,7 @@ function fetchTranscript(videoId) {
89
104
  p.on('error', reject);
90
105
  });
91
106
  }
92
- /** Strip ```fences``` and slice the outermost JSON array from an LLM reply. */
107
+ /** Slice the outermost JSON array from an LLM reply (tolerates stray prose / fences). */
93
108
  function parseJsonArray(out) {
94
109
  const start = out.indexOf('['), end = out.lastIndexOf(']');
95
110
  if (start === -1 || end === -1 || end < start)
@@ -101,44 +116,27 @@ function parseJsonArray(out) {
101
116
  return null;
102
117
  }
103
118
  }
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)
119
+ /** Re-rank a channel's videos against the intent (metadata only — no transcript). */
120
+ async function rerank(intent, items) {
121
+ if (items.length === 0)
124
122
  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.
123
+ const compact = items.map(h => ({ id: h.videoId, title: h.title, published: h.publishedAt, desc: (h.description || '').slice(0, 280) }));
124
+ const prompt = `From this YouTube channel's videos, pick the ones that serve the user's intent and rank them. Judge on title + description only (no transcripts). Drop anything off-topic.
127
125
 
128
126
  Intent: "${intent}"
129
127
 
130
- Candidates:
128
+ Videos:
131
129
  ${JSON.stringify(compact)}
132
130
 
133
131
  Output ONLY a raw JSON array, best first, no fences:
134
132
  [{"id":"VIDEO_ID","keep":true,"score":0-100,"reason":"max 12 words"},...]
135
- Set keep=false for anything not worth the user's time.`;
133
+ Set keep=false for anything not relevant to the intent.`;
136
134
  try {
137
135
  const out = await chat(prompt, { system: 'You output ONLY a raw JSON array as instructed.', temperature: 0 });
138
136
  const arr = parseJsonArray(out);
139
137
  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]));
138
+ return items; // fall back: keep all, original (newest-first) order
139
+ const byId = new Map(items.map(h => [h.videoId, h]));
142
140
  const ranked = [];
143
141
  for (const r of arr) {
144
142
  if (!r || r.keep === false)
@@ -147,13 +145,13 @@ Set keep=false for anything not worth the user's time.`;
147
145
  if (h)
148
146
  ranked.push({ ...h, score: typeof r.score === 'number' ? r.score : undefined, reason: r.reason });
149
147
  }
150
- return ranked.length ? ranked : hits.map(h => ({ ...h }));
148
+ return ranked.length ? ranked : items;
151
149
  }
152
150
  catch {
153
- return hits.map(h => ({ ...h }));
151
+ return items;
154
152
  }
155
153
  }
156
- // ---------- mega-summary for one candidate (the triage artifact) ----------
154
+ /** Rich, standalone summary for one candidate — the triage artifact + compare input. */
157
155
  async function megaSummary(c, transcript, intent) {
158
156
  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
157
 
@@ -181,7 +179,7 @@ Output ONLY the summary OR 'OFFTOPIC: <reason>'. No preamble.`;
181
179
  model: getModel(),
182
180
  });
183
181
  }
184
- // ---------- stage 6: comparison across kept summaries ----------
182
+ /** Synthesize a comparison across everything kept. */
185
183
  async function synthesizeComparison(intent, kept) {
186
184
  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
185
  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.
@@ -197,7 +195,7 @@ Write:
197
195
  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
196
  return chat(prompt, { system: `You synthesize a decision-grade comparison in ${LANG}. No preamble.`, model: getModel() });
199
197
  }
200
- // ---------- lazy yield: advance to the next candidate that has a transcript ----------
198
+ /** Lazy yield: advance to the next candidate that has a transcript, summarize, emit. */
201
199
  async function yieldNext(queue) {
202
200
  while (queue.cursor < queue.candidates.length) {
203
201
  const c = queue.candidates[queue.cursor];
@@ -225,11 +223,7 @@ async function yieldNext(queue) {
225
223
  }
226
224
  emit({ status: 'done', kept: loadKept().length });
227
225
  }
228
- // ---------- main ----------
229
226
  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
227
  // --compare: synthesize from kept summaries.
234
228
  if (COMPARE) {
235
229
  const queue = loadQueue();
@@ -243,7 +237,7 @@ async function main() {
243
237
  if (KEEP || SKIP) {
244
238
  const queue = loadQueue();
245
239
  if (!queue)
246
- emit({ status: 'error', error: 'No active search. Start one: yt-search "<intent>".' });
240
+ emit({ status: 'error', error: 'No active search. Start one: yt-search "<intent>" --channel <@handle>.' });
247
241
  if (KEEP) {
248
242
  const pending = readJSON(SEARCH_PENDING_FILE, null);
249
243
  if (pending) {
@@ -256,37 +250,36 @@ async function main() {
256
250
  writeJSON(SEARCH_QUEUE_FILE, queue);
257
251
  await yieldNext(queue);
258
252
  }
259
- // Resume an in-progress search (bare call, same intent) without rebuilding.
253
+ // Resume an in-progress search (bare call, same intent + channel) without rebuilding.
260
254
  const existing = loadQueue();
261
- if (existing && !RESET && (!intentArg || intentArg === existing.intent)) {
255
+ if (existing && !RESET && (!intentArg || intentArg === existing.intent) && (!CHANNEL || normalizeHandle(CHANNEL) === existing.channel)) {
262
256
  await yieldNext(existing);
263
257
  }
264
- // Fresh search (new intent or --reset).
258
+ // Fresh search: needs an intent AND a channel.
259
+ if (!process.env.YT_BRIEFING_YOUTUBE_API_KEY) {
260
+ 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).' });
261
+ }
265
262
  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
- }
263
+ emit({ status: 'error', error: 'Provide an intent: yt-search "<what to look for>" --channel <@handle|url>.' });
264
+ if (!CHANNEL)
265
+ emit({ status: 'error', error: 'Provide a channel: --channel <@handle|url>. /yt-search searches within one channel, not all of YouTube.' });
266
+ const handle = normalizeHandle(CHANNEL);
267
+ if (!handle)
268
+ emit({ status: 'error', error: `Could not read a channel handle from "${CHANNEL}" — use @name or the channel URL.` });
269
+ let videos;
270
+ try {
271
+ videos = await fetchChannelVideos(handle, { since: SINCE, limit: SINCE ? null : SCAN, enrich: false });
272
+ }
273
+ catch (e) {
274
+ emit({ status: 'error', error: e.message });
283
275
  }
284
- if (hits.length === 0)
276
+ if (videos.length === 0)
285
277
  emit({ status: 'no_results' });
286
- const ranked = (await rerank(intentArg, hits)).slice(0, MAX);
278
+ const pool = videos.map(v => ({ videoId: v.videoId, title: v.title, channelTitle: handle, publishedAt: v.publishedAt, description: v.description }));
279
+ const ranked = (await rerank(intentArg, pool)).slice(0, MAX);
287
280
  if (ranked.length === 0)
288
281
  emit({ status: 'no_results' });
289
- const queue = { built_at: new Date().toISOString(), intent: intentArg, candidates: ranked, cursor: 0 };
282
+ const queue = { built_at: new Date().toISOString(), intent: intentArg, channel: handle, candidates: ranked, cursor: 0 };
290
283
  writeJSON(SEARCH_QUEUE_FILE, queue);
291
284
  writeJSON(SEARCH_KEPT_FILE, []); // fresh search → fresh compare corpus
292
285
  await yieldNext(queue);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {