yt-briefing 0.15.0 → 1.0.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.
package/dist/yt-sweep.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * bun src/yt-sweep.ts --fill (internal: detached queue builder)
13
13
  *
14
14
  * Output (stdout, single JSON line):
15
- * {"status":"rating_needed","summary":"<md>","pending":{channel,videoId,title,type,publishedAt,is_baseline}}
15
+ * {"status":"rating_needed","summary":"<md>","pending":{channel,videoId,title,type,publishedAt,is_baseline}} (+ "lang")
16
16
  * {"status":"done"}
17
17
  * {"status":"rate_limited"}
18
18
  *
@@ -49,9 +49,9 @@
49
49
  */
50
50
  import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, renameSync, appendFileSync } from 'node:fs';
51
51
  import { spawn } from 'node:child_process';
52
- import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
52
+ import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_YOUTUBE } from "./lib/env.js";
53
53
  import { parseChannels, parseState, bumpStatePointer, isResolved } from "./lib/yt-lib.js";
54
- import { chat, getModel } from "./lib/llm.js";
54
+ import { chat, claudeMissing } from "./lib/llm.js";
55
55
  import { outputLang } from "./lib/config.js";
56
56
  import { PKG_ROOT, CHANNELS_MD, STATE_MD, CACHE_DIR, QUEUE_FILE, REST_FILE, PENDING_FILE, PREFETCH_FILE, LOG_FILE, profilePath, script, } from "./lib/paths.js";
57
57
  loadEnv();
@@ -251,8 +251,6 @@ Output ONLY a raw JSON array (no markdown fences, no explanation):
251
251
  try {
252
252
  out = await chat(prompt, {
253
253
  system: "You are a video title classifier. Output ONLY a raw JSON array as instructed. No markdown fences, no explanation.",
254
- model: getModel(),
255
- temperature: 0,
256
254
  });
257
255
  }
258
256
  catch {
@@ -303,7 +301,6 @@ Steps:
303
301
  5. Output: ONLY the briefing itself OR 'OFFTOPIC: ...'. No preamble, and no meta-commentary about these instructions.`;
304
302
  return await chat(prompt, {
305
303
  system: `You write channel briefings in ${LANG}, following the task instructions and the channel's standing directives exactly. Output only the briefing or 'OFFTOPIC: <reason>' — no preamble, no meta-commentary about the instructions.`,
306
- model: getModel(),
307
304
  });
308
305
  }
309
306
  // ---------- queue build (lazy: list channels now, expand on demand) ----------
@@ -491,7 +488,7 @@ async function advance(queue) {
491
488
  writeFileSync(QUEUE_FILE, JSON.stringify(queue));
492
489
  // Warm the NEXT video in the background while the user rates this one.
493
490
  spawnPrefetch(queue.items[1]);
494
- emit({ status: 'rating_needed', summary: result.summary, pending });
491
+ emit({ status: 'rating_needed', summary: result.summary, pending, lang: LANG });
495
492
  }
496
493
  // All processed.
497
494
  if (existsSync(QUEUE_FILE))
@@ -567,12 +564,15 @@ if (reset) {
567
564
  }
568
565
  // Fatal config preflight, foreground only (the detached --fill / --prefetch children already
569
566
  // exited above). A missing key would otherwise surface as a misleading `status:"done"` ("no new
570
- // videos") — the YouTube error is collapsed by the per-channel catch, and a missing LLM key is
571
- // swallowed by the title-filter's keep-all fallback. Fail fast naming every missing var instead.
567
+ // videos") — the YouTube error is collapsed by the per-channel catch, and a missing `claude` CLI is
568
+ // swallowed by the title-filter's keep-all fallback. Fail fast naming what is missing instead.
572
569
  {
573
- const missing = missingEnv([...REQUIRED_LLM, ...REQUIRED_YOUTUBE]);
570
+ const missing = missingEnv(REQUIRED_YOUTUBE);
574
571
  if (missing.length)
575
572
  emit({ status: 'error', error: missingEnvMessage(missing) });
573
+ const noClaude = claudeMissing();
574
+ if (noClaude)
575
+ emit({ status: 'error', error: noClaude });
576
576
  }
577
577
  const queue = loadQueue() ?? buildQueue();
578
578
  await advance(queue);
@@ -89,31 +89,24 @@ echo "yt-briefing: state NOT pushed — resolve: cd \"$DATA\" && git pull --reba
89
89
  exit 0
90
90
  ```
91
91
 
92
- Then trigger it after each rating. Two ways, depending on how you run yt-briefing:
93
-
94
- **A) Via a coding agent (Claude Code / Cursor)** — add a `PostToolUse` hook that fires after
95
- the engine runs. In Claude Code's `settings.json`:
92
+ Then point the engine at it. In `.yt-briefing/data/config.json` (the file is in the folder you
93
+ version, so every machine gets the setting):
96
94
 
97
95
  ```json
98
96
  {
99
- "hooks": {
100
- "PostToolUse": [
101
- { "matcher": "Bash",
102
- "hooks": [ { "type": "command", "command": "cmd=$(jq -r '.tool_input.command // \"\"'); case \"$cmd\" in *yt-rating*|*yt-sweep*) /path/to/yt-sync.sh ;; esac" } ] }
103
- ]
104
- }
97
+ "output_lang": "English",
98
+ "after_rate": "/path/to/yt-sync.sh"
105
99
  }
106
100
  ```
107
101
 
108
- **B) Via the CLI** — just call it after `rate`:
109
-
110
- ```bash
111
- yt-briefing rate --rating 0 && /path/to/yt-sync.sh
112
- ```
102
+ The engine runs `after_rate` after every recorded rating, from the pane and from the CLI
103
+ (`yt-briefing rate`) alike. It runs detached from the project root, so a slow push never holds the
104
+ next video, and a failing script never fails the rating (the rating is already on disk). The
105
+ engine itself still never runs git: the command is yours.
113
106
 
114
107
  > Optional but recommended: also `git pull --rebase` **before** the first sweep of a session
115
- > (so a machine starts on the latest cursor), e.g. a `PreToolUse` hook matching
116
- > `*yt-sweep*--reset*`, or just `cd "$YT_BRIEFING_DATA_DIR" && git pull --rebase` before you start.
108
+ > (so a machine starts on the latest cursor), e.g. `cd "$YT_BRIEFING_DATA_DIR" && git pull --rebase`
109
+ > before you open `/yt`, or a `SessionStart` hook that does the same.
117
110
 
118
111
  ---
119
112
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.15.0",
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.",
3
+ "version": "1.0.0",
4
+ "description": "A self-learning YouTube briefing for Claude Code: it sweeps the channels you follow, filters noise in two stages (title, then transcript), gives you a short briefing of the rest in your language in a pane, and adapts to your ratings — one video at a time.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "yt-briefing": "dist/cli.js"
@@ -10,6 +10,8 @@
10
10
  "dist",
11
11
  "data.example",
12
12
  ".claude/skills",
13
+ "plugin",
14
+ "!plugin/**/*.test.tsx",
13
15
  "docs",
14
16
  "README.md",
15
17
  "LICENSE"
@@ -32,12 +34,11 @@
32
34
  "keywords": [
33
35
  "youtube",
34
36
  "transcript",
35
- "summary",
36
37
  "briefing",
37
- "llm",
38
- "openai-compatible",
39
- "agent",
40
- "claude"
38
+ "claude-code",
39
+ "claude-code-plugin",
40
+ "claude",
41
+ "agent"
41
42
  ],
42
43
  "license": "MIT",
43
44
  "repository": {
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "yt-briefing",
3
+ "version": "1.0.0",
4
+ "description": "The /yt briefing loop as a Claude Code pane: summary, one-key rating, research hand-off.",
5
+ "author": { "name": "Michal Ryzio" },
6
+ "types": "./types/index.d.ts"
7
+ }
@@ -0,0 +1,4 @@
1
+ // How the pane runs the engine, relative to the project root (the session's working directory).
2
+ // This is the dev-clone form; `yt-briefing install-skill` / `init` rewrites it for the project it
3
+ // installs into (the compiled dist/ under node_modules, with the runtime that ran the installer).
4
+ export const engine = (name: string): string[] => ['bun', `src/${name}.ts`]
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,199 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { EngineInterface, Register } from 'claude-code'
3
+
4
+ import type { Pending, View } from '../types'
5
+ import { engine } from './engine.ts'
6
+
7
+ // The /yt rating loop as a pane. Every step runs the engine as a subprocess and reads its one-line
8
+ // JSON; the model is not involved unless the person asks for research, which hands the video to
9
+ // the session as a prompt. Ratings and comments are written by the engine, never by this module.
10
+
11
+ const PANE = 'yt-briefing'
12
+ const view = atom({ plugin: 'yt-briefing', key: 'view' } as const, { phase: 'idle' } as View)
13
+
14
+ /** A sweep can expand channels, fetch transcripts and summarize; give it the engine's full budget. */
15
+ const SWEEP_MS = 10 * 60_000
16
+ /** A rating is a file write; a raw comment adds one `claude -p` call to distill it. */
17
+ const RATE_MS = 3 * 60_000
18
+
19
+ type Skip = { channel: string; title: string; reason: string }
20
+ type SweepOut = {
21
+ status: string
22
+ summary?: string
23
+ pending?: Pending
24
+ lang?: string
25
+ error?: string
26
+ skipped?: number
27
+ skips?: Skip[]
28
+ }
29
+
30
+ const STATUS_TEXT: Record<string, string> = {
31
+ done: 'Sweep finished: nothing left to rate.',
32
+ rate_limited: 'YouTube is blocking this IP (429 / captcha). See README → Proxy.',
33
+ tooling_error: 'Transcript tooling failed (proxy, yt-dlp or network). See README → Proxy.',
34
+ }
35
+
36
+ /** The engine prints one JSON line on stdout; take the last non-empty line to be safe. */
37
+ function parseOut(stdout: string): SweepOut {
38
+ const line = stdout.trim().split('\n').filter(Boolean).pop() ?? ''
39
+ return JSON.parse(line) as SweepOut
40
+ }
41
+
42
+ /** Replace the whole view; the one writer for a fresh phase. */
43
+ async function setView($: EngineInterface, next: View) {
44
+ await update($, view, () => next)
45
+ }
46
+
47
+ function skipLine(out: SweepOut): string | undefined {
48
+ if (!out.skipped || !out.skips?.length) return undefined
49
+ const list = out.skips.map(s => `${s.channel} «${s.title}» — ${s.reason}`).join('; ')
50
+ return `Skipped ${out.skipped}: ${list}`
51
+ }
52
+
53
+ /** Advance to the next ratable video and put it (or the end state) in the view. */
54
+ async function sweep($: EngineInterface, reset: boolean) {
55
+ await setView($, { phase: 'loading' })
56
+ let out: SweepOut
57
+ try {
58
+ const run = await $.process.run([...engine('yt-sweep'), ...(reset ? ['--reset'] : [])], { timeoutMs: SWEEP_MS })
59
+ out = parseOut(run.stdout)
60
+ } catch (err) {
61
+ await setView($, { phase: 'error', message: `The engine did not answer: ${String(err)}` })
62
+ return
63
+ }
64
+ const skipped = skipLine(out)
65
+ if (out.status === 'rating_needed' && out.summary && out.pending) {
66
+ await setView($, { phase: 'rating', summary: out.summary, pending: out.pending, lang: out.lang, skipped })
67
+ } else if (out.status === 'done') {
68
+ await setView($, { phase: 'done', message: STATUS_TEXT.done, skipped })
69
+ } else {
70
+ await setView($, { phase: 'error', message: out.error ?? STATUS_TEXT[out.status] ?? `Engine status: ${out.status}`, skipped })
71
+ }
72
+ }
73
+
74
+ /** Record a rating (engine args), then move on; on failure stay on the video and say why. */
75
+ async function rate($: EngineInterface, args: string[], thenNext = true): Promise<{ rule?: string } | null> {
76
+ const v = await read($, view)
77
+ if (v.phase !== 'rating' || v.busy) return null
78
+ await update($, view, cur => ({ ...cur, busy: args[0] === '--raw-comment' ? 'Turning the comment into a rule…' : 'Saving…' }))
79
+ const run = await $.process.run([...engine('yt-rating'), ...args], { timeoutMs: RATE_MS }).catch(err => ({
80
+ exitCode: 1, stdout: '', stderr: String(err),
81
+ }))
82
+ if (run.exitCode !== 0) {
83
+ await update($, view, cur => ({ ...cur, busy: undefined }))
84
+ $.ui.toast(`Rating not saved: ${run.stderr.trim().slice(0, 200) || 'engine error'}`)
85
+ return null
86
+ }
87
+ let result: { rule?: string } = {}
88
+ try { result = JSON.parse(run.stdout.trim().split('\n').pop() ?? '{}') } catch { /* rating is on disk */ }
89
+ if (thenNext) void sweep($, false)
90
+ return result
91
+ }
92
+
93
+ /** Research: mark the video seen, close the pane and hand the video to the session. */
94
+ async function research($: EngineInterface, question?: string) {
95
+ const v = await read($, view)
96
+ if (!v.pending || !v.summary) return
97
+ if (!(await rate($, ['--rating', '1'], false))) return
98
+ await $.ui.close({ id: PANE })
99
+ const p = v.pending
100
+ const transcript = [...engine('yt-transcript'), p.videoId, '--lang', 'auto'].join(' ')
101
+ const ask = question
102
+ ? `My question: ${question}`
103
+ : 'Ask me first what I want to dig into.'
104
+ await $.prompt.submit({
105
+ asUser: true,
106
+ text: [
107
+ `Let's research this video together: ${p.channel} — "${p.title}" (${p.videoId}, ${p.type}).`,
108
+ '',
109
+ 'Its briefing:',
110
+ v.summary,
111
+ '',
112
+ `For anything beyond the briefing, pull the full transcript with \`${transcript}\` and work from the tool result; never paste the transcript into the chat, quote only short passages. Keep what the video claims separate from what you verify yourself, and use whatever the question needs (my project, the web). Answer in ${v.lang ?? 'English'}.`,
113
+ ask,
114
+ '',
115
+ 'If it turns out to be hype, record it with `' + [...engine('yt-rating'), '--rating', '0'].join(' ') + '`; a lasting preference about this channel goes in with `' + [...engine('yt-rating'), '--raw-comment', '"<what to remember>"'].join(' ') + '`.',
116
+ ].join('\n'),
117
+ }).catch(err => $.ui.toast(`Could not hand the video to the session: ${String(err)}`))
118
+ }
119
+
120
+ async function comment($: EngineInterface, raw: string) {
121
+ const text = raw.trim()
122
+ if (!text) return
123
+ if (text.toLowerCase() === 'stop') return void $.ui.close({ id: PANE })
124
+ if (text.startsWith('?')) return research($, text.slice(1).trim() || undefined)
125
+ const saved = await rate($, ['--raw-comment', text])
126
+ if (saved?.rule) $.ui.toast(`Rule saved: ${saved.rule}`)
127
+ }
128
+
129
+
130
+ export const register: Register = on => {
131
+ on('session.start', async ($, e, next) => {
132
+ await $.command.register({
133
+ name: 'yt',
134
+ description: 'Rate the next videos from the YouTube channels you follow, in a pane',
135
+ })
136
+
137
+ return next(e)
138
+ })
139
+
140
+ on('command.run', { command: 'yt' }, async $ => {
141
+ await $.ui.open({ id: PANE, title: 'yt-briefing', focus: true, closeOnEscape: true })
142
+ void sweep($, true)
143
+
144
+ return { text: 'Briefing opened in a pane.' }
145
+ })
146
+
147
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
148
+ const { Box, Text, Button, Markdown } = $.ui.resolve(e)
149
+ const v = await read($, view)
150
+ const close = () => void $.ui.close({ id: PANE })
151
+
152
+ if (v.phase === 'idle' || v.phase === 'loading') {
153
+ return <Text dimColor>Finding the next video…</Text>
154
+ }
155
+
156
+ if (v.phase !== 'rating') {
157
+ return (
158
+ <Box flexDirection="column">
159
+ {v.skipped && <Text dimColor>{v.skipped}</Text>}
160
+ <Text>{v.message ?? ''}</Text>
161
+ <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={close} />
162
+ </Box>
163
+ )
164
+ }
165
+
166
+ const idle = !v.busy
167
+ const buttons = (
168
+ <Box flexDirection="row" gap={2}>
169
+ <Button key="ok" label="OK" hotkey="1" variant="primary" dimColor={!idle}
170
+ onPress={() => void rate($, ['--rating', '1'])} />
171
+ <Button key="weak" label="Weak" hotkey="2" dimColor={!idle}
172
+ onPress={() => void rate($, ['--rating', '0'])} />
173
+ <Button key="research" label="Research" hotkey="3" dimColor={!idle}
174
+ onPress={() => void research($)} />
175
+ <Button key="stop" label="Stop" hotkey="4" role="dismiss" onPress={close} />
176
+ </Box>
177
+ )
178
+
179
+ let field = null
180
+ if (e.surface !== 'mobile') {
181
+ const { Input } = $.ui.resolve(e)
182
+ field = (
183
+ <Input key="comment" label="Comment" submitLabel="save rule"
184
+ placeholder="becomes a rule for this channel · ?question = research · stop"
185
+ onSubmit={value => void comment($, value)} />
186
+ )
187
+ }
188
+
189
+ return (
190
+ <Box flexDirection="column" gap={1}>
191
+ {v.skipped && <Text dimColor>{v.skipped}</Text>}
192
+ <Markdown text={(v.summary ?? '').slice(0, 10000)} />
193
+ {v.busy ? <Text dimColor>{v.busy}</Text> : buttons}
194
+ {field}
195
+ <Text dimColor>OK = neutral · Weak = teach the filter to skip titles like this · Research = dig into it with Claude</Text>
196
+ </Box>
197
+ )
198
+ })
199
+ }
@@ -0,0 +1,28 @@
1
+ export type Pending = {
2
+ channel: string
3
+ videoId: string
4
+ title: string
5
+ type: string
6
+ publishedAt: string
7
+ }
8
+
9
+ export type View = {
10
+ phase: 'idle' | 'loading' | 'rating' | 'done' | 'error'
11
+ /** Briefing markdown of the video awaiting a rating. */
12
+ summary?: string
13
+ pending?: Pending
14
+ /** Language the engine writes in; the research hand-off asks for it too. */
15
+ lang?: string
16
+ /** Videos the filters dropped on the way to this one, one line. */
17
+ skipped?: string
18
+ /** Error or status text for the done/error phases. */
19
+ message?: string
20
+ /** Set while a rating or comment is being written; presses are ignored meanwhile. */
21
+ busy?: string
22
+ }
23
+
24
+ declare module 'claude-code' {
25
+ interface PluginState {
26
+ 'yt-briefing': { view: View }
27
+ }
28
+ }
@@ -1,68 +0,0 @@
1
- ---
2
- name: yt
3
- description: Briefing from the YouTube channels you follow — the engine sweeps each channel, filters videos in two stages (title, then transcript+content), and lazily yields one video to rate per call. This skill is a thin loop that shows the summary and collects the rating (via AskUserQuestion), which writes durable signal straight into the channel profile. A third option, Research, breaks the loop to dig into the current video's content with the user (full transcript + their question). Summaries and the rating question use the language chosen at onboarding.
4
- ---
5
-
6
- ## How it works
7
-
8
- `src/yt-sweep.ts` is **the whole engine**: channel sweep, filters (title filter, then content filter on the transcript via the LLM API), and skip handling — all inside, lazy. The only state it does **not** write is the rating itself — that's `yt-rating.ts` in step D. This skill **does not run filters, read profiles, or format summaries** — it runs the script, pastes the summary, collects the rating. The one exception is **Research mode** (below): an explicit user pick that ends the loop and opens the current video's content for discussion.
9
-
10
- `data/state.md` is the only persistent cursor. Session state — queue, pending, prefetch, background fill — lives in `data/.cache/` (rebuilt each run, never important to keep). Internals — lazy queue build, filters, summary format, LLM model, transcript fetching, prefetch, proxy — are in `README.md`, not here. No manual pre-flight: the engine self-invalidates a stale queue (new day or `--reset`).
11
-
12
- **Run the engine bare — no redirects.** Its stdout is a pure JSON line and stderr is empty, so `JSON.parse` of the raw output just works; **never** redirect stderr to `/tmp` or any OS temp dir. For timing diagnostics ("why is the sweep slow") run it once with `YT_BRIEFING_DEBUG=1` — it appends per-stage timings to the gitignored `data/.cache/sweep.log`.
13
-
14
- For fast first paint, the engine expands channels in parallel waves until it has the first ratable video, while a detached background process expands the rest in parallel. And while the user rates a video, it warms the **next** video's summary in another background process, so the following step usually emits instantly. All fully internal — the loop below is unchanged.
15
-
16
- ## Rating language
17
-
18
- Read `data/config.json` → `output_lang` once at the start of the loop. Phrase the rating **question text** and **option descriptions** in that language. The two button **labels** stay the short English words `OK` / `Weak` (deliberately universal). Summaries are already written in `output_lang` by the engine — paste them verbatim.
19
-
20
- ## Rating loop
21
-
22
- ```
23
- out = JSON.parse(`bun run src/yt-sweep.ts --reset`) // Bash — first call: --reset rebuilds the queue fresh
24
- while true:
25
- if out.skipped: FIRST, in output_lang, tell the user one line — "Skipped {out.skipped}:" + a `; `-joined list of `{channel} «{title}» — {reason}` from out.skips (videos the title/content filter dropped on the way here, including your channel directives). Then handle out.status below.
26
- out.status:
27
- "done" → sweep finished — tell the user, stop
28
- "error" → setup/config problem (e.g. missing API key) — show `out.error` to the user verbatim, stop
29
- "rate_limited" → YouTube is blocking the egress IP (429 / captcha) — tell the user, stop; recovery in README.md → Proxy
30
- "tooling_error" → transcript toolchain failed (proxy down / yt-dlp / network) — tell the user, stop; check proxy health, then README.md → Proxy
31
- "rating_needed" → steps A–E
32
- ```
33
-
34
- **On `rating_needed` — literally:**
35
-
36
- - **A.** Take `out.summary` (markdown) and `out.pending` (metadata).
37
- - **B.** In the same turn, as **your chat text** (NOT command output — the UI does not show it), paste `summary` **verbatim**: no paraphrase, no shortening, no comment, no "see above". The user must see it before the popup. B is **unconditional** — it does not depend on there being anything else to write, so an iteration that returns no `skipped` line still opens with the pasted summary, never straight with the tool call. _(If the user says "I don't see the summary" — you skipped B. In Claude Code a PreToolUse gate refuses to record the rating in step D when the pending video's id is missing from your chat text; if it fires, paste the summary, ask for the rating again, then record it.)_
38
- - **C.** In the same message call `AskUserQuestion` — **1 call, 1 question** (everything in one step), phrased in `output_lang`:
39
- - The question (e.g. "Rating?") with three options whose descriptions explain: **OK** = neutral (no effect on the filter), **Weak** = worthless (teach the filter to skip such titles), **Research** = break the loop and dig into this video's content together. The digits never appear in the popup — internally map to `--rating`: **OK → 1, Weak → 0**; Research maps to no digit, it exits the loop (see Research mode). There is **no positive rating** — keeping the channel is the implicit positive; you only down-rate noise (`0`) or steer with a comment.
40
- - **Other** is the comment / stop / research channel (no second question): the user types free text. If it equals `stop` (case-insensitive, trimmed) — or the popup is dismissed (✕) — **end the loop**. If it **starts with `?`**, the text after the `?` is a **research question** → Research mode. Otherwise it is a **comment**: **distill** the user's raw text into a clean, generalizable rule, and infer the rating — clearly negative → `0`, otherwise → `1`.
41
- - **D.** Act on the answer:
42
- - `stop` / dismissed → **end the loop** (state already on disk).
43
- - A rating option → `bun run src/yt-rating.ts --rating <1|0>`.
44
- - A comment (Other, not `stop`, not `?…`) → `bun run src/yt-rating.ts --rating <inferred 1|0> --comment "<distilled rule>"`.
45
- - **Research** (the option, or a `?…` text) → **break the loop** and switch to **Research mode** below. Skip step E — no more sweep calls this session.
46
-
47
- In every non-stop case **pass only the rating (+ comment)**; the script reads channel/id/title/type from `data/.cache/pending.json`. The write is **immediate and durable — no consolidation**: `rating=0` appends to `## Skip titles` (title filter learns to skip such titles from the next run) and a comment appends the rule to `## Notes` (seen by both filters). A neutral `1` writes nothing — it only bumps the state cursor.
48
- - **E.** Re-run `bun run src/yt-sweep.ts` **bare** (no `--reset` — resumes the same queue; only the loop's first call uses `--reset`). Its JSON becomes the next iteration's `out` — back to the top of the loop.
49
-
50
- ## Research mode (break the loop and dig in)
51
-
52
- The "don't shelve it" path: instead of the briefing landing on a to-do list, the user pulls it apart with you right now — e.g. the channel announces a new tool and the question is whether it beats what their project uses today. Entry: the **Research** option, or an Other text starting with `?`.
53
-
54
- 1. **Commit first:** `bun run src/yt-rating.ts --rating 1` — engaging with a video is at least neutral, and the bump keeps it from reappearing on the next run. The rating loop is over: no step E, no further sweep calls.
55
- 2. **Get the question.** A `?…` text (or any free text attached to the Research selection) IS the question. If Research was picked bare, ask in `output_lang` what they want to dig into.
56
- 3. **Pull the full transcript** — the summary is too lossy to research from: `bun run src/yt-transcript.ts <videoId> --lang auto`, videoId from `out.pending` (or `data/.cache/pending.json`). The transcript arrives as the tool result — work from it there, never paste it into chat. Non-zero exit (`1` no subtitles / `2` IP-blocked / `3` tooling, reason on stderr) → tell the user and work from the summary instead.
57
- 4. **Work the question with the user, grounded.** The transcript is the source for what the video *claims*; keep "the video claims X" separate from what you verify yourself. Use whatever tools the question needs — read the user's project when they ask "would this fit project xyz", search the web for docs / changelogs / pricing. It is a conversation, not a one-shot report: answer, then iterate until the user is done.
58
- 5. **Closing.** `data/.cache/pending.json` is still on disk, so a verdict can still be recorded: turned out to be hype → `bun run src/yt-rating.ts --rating 0`; a durable channel preference surfaced → `--rating 1 --comment "<distilled rule>"` (the re-bump is a no-op). Mention that `/yt` resumes the queue where it left off — re-enter the rating loop (re-run the engine **bare**) only if the user asks.
59
-
60
- ## No consolidation
61
-
62
- There is no post-loop step. Each rating is **durable immediately**: `yt-rating.ts` writes `rating=0` straight to `## Skip titles` and a comment straight to `## Notes` (FIFO-capped, de-duplicated). `rating=1` only bumps the cursor. The title filter reads those sections live, so the signal takes effect on the very next sweep — nothing to flush, batch, or trigger.
63
-
64
- ## Rules
65
-
66
- - **Language:** question text, option descriptions, and loop messages follow `output_lang`; the three option **labels** are `OK` / `Weak` / `Research`. Summaries are written in `output_lang` by the content-filter prompt in the engine — not here. Research mode converses in `output_lang` unless the user switches.
67
- - **Transcripts:** never paste a raw transcript into chat; the summary is the artifact. In research mode, quote only the short passages you need.
68
- - **Resuming:** the skill can be re-run any time — `data/state.md` is the source of truth, so a re-run always skips what's already rated. A queue from a previous day self-invalidates; the loop's first call passes `--reset` to also force a **same-day** rebuild, catching videos published since that morning's queue.
@@ -1,43 +0,0 @@
1
- /**
2
- * Decision logic for the /yt summary gate — kept separate from the hook script so it is
3
- * runtime-agnostic and unit-testable: pure functions over a payload and a transcript.
4
- *
5
- * See `src/yt-summary-gate.ts` for why the gate exists and where it had to move to work at all.
6
- */
7
- /** Substring identifying the rating writer; the skill always invokes it by this script name. */
8
- const RATING_SCRIPT = 'yt-rating';
9
- /**
10
- * Is this the call that writes a rating? The skill records every rating by running
11
- * `yt-rating(.ts|.js) --rating <1|0>` through Bash, so the command line is the signal. Anything
12
- * else — a sweep, a transcript pull, unrelated shell work — is none of the gate's business, and
13
- * is waved through before this module does any I/O at all.
14
- */
15
- export function isRatingWrite(payload) {
16
- if (payload.tool_name !== 'Bash')
17
- return false;
18
- const command = payload.tool_input?.command ?? '';
19
- return command.includes(RATING_SCRIPT) && command.includes('--rating');
20
- }
21
- /**
22
- * Did the agent actually paste the summary? The summary carries the video's watch URL, so its id
23
- * appears verbatim in the pasted text. Only the assistant's own text blocks count: the id also
24
- * travels through tool calls and their results, and neither is shown to the user.
25
- */
26
- export function summaryWasPasted(transcript, videoId) {
27
- for (const line of transcript.split('\n')) {
28
- if (!line.includes(videoId))
29
- continue;
30
- let entry;
31
- try {
32
- entry = JSON.parse(line);
33
- }
34
- catch {
35
- continue;
36
- }
37
- if (entry.message?.role !== 'assistant' || !Array.isArray(entry.message.content))
38
- continue;
39
- if (entry.message.content.some((b) => b.type === 'text' && b.text?.includes(videoId)))
40
- return true;
41
- }
42
- return false;
43
- }
@@ -1,77 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * yt-summary-gate — PreToolUse gate for Claude Code: refuses to RECORD a rating until the
4
- * video's summary has actually been pasted into the chat.
5
- *
6
- * Step B of the rating loop (paste `out.summary` verbatim, THEN ask) is the one instruction the
7
- * agent can drop with no visible symptom: the popup still appears, the user still answers, and
8
- * the rating is recorded against a summary nobody saw. A blind rating is worse than no rating:
9
- * it teaches the title filter from noise.
10
- *
11
- * WHY IT GATES THE WRITE AND NOT THE POPUP. Until 0.15.0 this hook ran on `AskUserQuestion` and
12
- * blocked the popup. That could never work: the harness writes an assistant message's entries to
13
- * the transcript only once the message is COMPLETE — i.e. after its tool calls have returned —
14
- * and the skill pastes the summary and opens the popup in a single message. So the gate asked
15
- * the transcript for text that could not be there yet, and the write it was waiting on could not
16
- * happen until the gate returned. It blocked every video and cleared on a blind retry (measured
17
- * 2026-08-30: a marker in a long text block was absent from the transcript during its own
18
- * message's tool call, and present in the next message immediately). 0.14.2's 5s poll treated
19
- * that as a timing lag and so only made the block slower.
20
- *
21
- * The rating write is a separate Bash call in the FOLLOWING message, by which point the message
22
- * carrying the summary is closed and on disk — the evidence exists exactly when this hook needs
23
- * it. The tradeoff is honest: a genuinely skipped paste is now caught after the user has already
24
- * answered the popup, so that one answer is wasted and the agent has to paste and re-ask. That
25
- * costs nothing on the happy path, and it guards the thing that actually matters — what gets
26
- * written into the channel profile.
27
- *
28
- * Reads the PreToolUse payload on stdin; exit 0 allows, exit 2 blocks and feeds stderr back to
29
- * the agent. Anything unexpected — foreign tool, unreadable transcript, no pending video —
30
- * allows: a gate that misfires on unrelated work would be worse than the bug it guards. This
31
- * hook is matched on Bash, so it runs on ordinary shell work too: it must decide from the
32
- * command string and exit before touching the disk.
33
- */
34
- import { readFileSync, existsSync } from 'node:fs';
35
- import { isRatingWrite, summaryWasPasted } from "./lib/summary-gate.js";
36
- const ALLOW = 0;
37
- const BLOCK = 2;
38
- const allow = () => process.exit(ALLOW);
39
- let payload;
40
- try {
41
- payload = JSON.parse(readFileSync(0, 'utf8'));
42
- }
43
- catch {
44
- allow();
45
- }
46
- if (!isRatingWrite(payload))
47
- allow();
48
- // paths.ts derives the data dir from the cwd, which for a hook is the agent's, not ours — so the
49
- // import has to wait until we've moved there.
50
- if (payload.cwd && existsSync(payload.cwd))
51
- process.chdir(payload.cwd);
52
- const { PENDING_FILE } = await import("./lib/paths.js");
53
- let pending = {};
54
- try {
55
- pending = JSON.parse(readFileSync(PENDING_FILE, 'utf8'));
56
- }
57
- catch {
58
- allow(); // no pending video (or unreadable) — nothing to gate on
59
- }
60
- if (!pending.videoId)
61
- allow();
62
- const transcript = payload.transcript_path;
63
- if (!transcript || !existsSync(transcript))
64
- allow();
65
- let seen = false;
66
- try {
67
- seen = summaryWasPasted(readFileSync(transcript, 'utf8'), pending.videoId);
68
- }
69
- catch {
70
- allow();
71
- }
72
- if (seen)
73
- allow();
74
- process.stderr.write(`/yt step B not done: the summary for ${pending.videoId} ("${pending.title ?? '?'}") never appeared in your chat text, ` +
75
- `so this rating would be recorded against a summary the user never saw.\n` +
76
- `Paste out.summary verbatim as your reply, ask for the rating again, then record it.\n`);
77
- process.exit(BLOCK);