yt-briefing 0.13.0 → 0.14.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.
@@ -34,7 +34,7 @@ while true:
34
34
  **On `rating_needed` — literally:**
35
35
 
36
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. _(If the user says "I don't see the summary" — you skipped B.)_
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 blocks the popup when the pending video's id is missing from your chat text; if it fires, paste the summary and ask again.)_
38
38
  - **C.** In the same message call `AskUserQuestion` — **1 call, 1 question** (everything in one step), phrased in `output_lang`:
39
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
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`.
package/README.md CHANGED
@@ -134,6 +134,28 @@ Open your project in Claude Code or Cursor and run `/yt`. If it's not listed, st
134
134
  session. To install the skills again for another tool or project, run
135
135
  `npx yt-briefing install-skill` (it installs `/yt`, `/yt-transcribe`, and `/yt-search`).
136
136
 
137
+ ## Rating gate
138
+
139
+ The loop only works if you see the summary *before* you rate it, and that is the one step an
140
+ agent can silently drop — the popup still appears, you still answer, and the rating is recorded
141
+ against a summary nobody read. On Claude Code the installer wires a `PreToolUse` hook into your
142
+ project's `.claude/settings.json` that refuses the rating popup unless the video's summary is in
143
+ the chat. It merges with your existing hooks and updates itself on reinstall. If that file isn't
144
+ valid JSON the installer leaves it alone and says so — add the entry yourself:
145
+
146
+ ```json
147
+ {
148
+ "hooks": {
149
+ "PreToolUse": [
150
+ { "matcher": "AskUserQuestion",
151
+ "hooks": [ { "type": "command", "command": "node \"node_modules/yt-briefing/dist/yt-summary-gate.js\"" } ] }
152
+ ]
153
+ }
154
+ }
155
+ ```
156
+
157
+ Other agents have no equivalent hook, so there the instruction in `SKILL.md` is what holds.
158
+
137
159
  ## Providers
138
160
 
139
161
  Any OpenAI-compatible endpoint works. Gemini 2.5 Flash is the easy default. It's fast, cheap,
@@ -150,7 +172,7 @@ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
150
172
 
151
173
  Want something else? Change those three lines for OpenRouter (`https://openrouter.ai/api/v1`),
152
174
  OpenAI (`https://api.openai.com/v1`), Anthropic (`https://api.anthropic.com/v1/`,
153
- e.g. `claude-sonnet-4-6`), or a local Ollama (`http://localhost:11434/v1`). Set
175
+ e.g. `claude-sonnet-5`), or a local Ollama (`http://localhost:11434/v1`). Set
154
176
  `YT_BRIEFING_LLM_BASE_URL`, `_API_KEY`, and `_MODEL` in your root `.env` (see [Setup](#setup)).
155
177
 
156
178
  ## Why an API, not the agent's native model
package/dist/bootstrap.js CHANGED
@@ -18,7 +18,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, profilePath, ROOT_ENV_PATH, } from "./lib/paths.js";
20
20
  import { loadEnv, missingEnv, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
21
- import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd } from "./lib/skill-install.js";
21
+ import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd, installClaudeGate, CLAUDE_CODE } from "./lib/skill-install.js";
22
22
  import { question } from "./lib/prompt.js";
23
23
  import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow } from "./lib/channels.js";
24
24
  const ask = (q, def = '') => {
@@ -117,6 +117,14 @@ function main() {
117
117
  : installSkills(customDir, /* dist */ true);
118
118
  for (const t of targets)
119
119
  console.log(` skill → ${t}`);
120
+ // Claude Code also gets the summary gate — the hook that refuses a rating popup for a video
121
+ // whose summary was never pasted into the chat. No other agent exposes PreToolUse.
122
+ if (agentKey === CLAUDE_CODE) {
123
+ const gate = installClaudeGate(process.cwd(), /* dist */ !isPackageDevCwd());
124
+ console.log(gate
125
+ ? ` gate → ${gate}`
126
+ : ` ! .claude/settings.json isn't valid JSON — add the summary gate by hand (README → Rating gate).`);
127
+ }
120
128
  }
121
129
  catch (e) {
122
130
  console.log(` ! Couldn't install the skills (${e.message}) — run yt-briefing install-skill later.`);
@@ -16,15 +16,21 @@
16
16
  * agent (Claude Code, Cursor, Codex, and 30+ others); this command just (re)places them in the
17
17
  * skills dir of whichever agent you pick.
18
18
  */
19
- import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd } from "./lib/skill-install.js";
19
+ import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd, installClaudeGate, CLAUDE_CODE } from "./lib/skill-install.js";
20
20
  import { question } from "./lib/prompt.js";
21
21
  const ask = (q, def = '') => question(def ? `${q} [${def}]:` : `${q}:`).trim() || def;
22
- function done(targets) {
22
+ function done(targets, gate) {
23
23
  console.log('\n ✓ Installed:');
24
24
  for (const t of targets)
25
25
  console.log(` ${t}`);
26
+ if (gate)
27
+ console.log(` ${gate} (summary gate)`);
28
+ if (gate === null)
29
+ console.log(` ! .claude/settings.json isn't valid JSON — add the summary gate by hand (README → Rating gate).`);
26
30
  console.log(' Start a fresh agent session, then run /yt or /yt-transcribe\n');
27
31
  }
32
+ /** Claude Code only: the PreToolUse hook that blocks a rating popup with no summary in the chat. */
33
+ const gateFor = (key, projectDir, dist) => key === CLAUDE_CODE ? installClaudeGate(projectDir, dist) : undefined;
28
34
  // 1) which agent → which skills subdir
29
35
  console.log('\n Install the /yt + /yt-transcribe skills — which agent?\n');
30
36
  console.log(' 1) Claude Code');
@@ -46,10 +52,12 @@ console.log(' 1) This project (current folder) — recommended');
46
52
  console.log(' 2) Another project folder\n');
47
53
  if (ask(' Where', '1') === '2') {
48
54
  // 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));
55
+ const projectDir = ask(' Project folder', process.cwd());
56
+ done(installSkills(projectSkillsRoot(agentKey, projectDir), true), gateFor(agentKey, projectDir, true));
50
57
  }
51
58
  else {
52
59
  // Current folder: shipped `bun run src` only when developing in the package clone under Bun;
53
60
  // otherwise (incl. consuming the package as a dependency) bake the compiled dist commands.
54
- done(installSkills(projectSkillsRoot(agentKey, process.cwd()), !isPackageDevCwd()));
61
+ const dist = !isPackageDevCwd();
62
+ done(installSkills(projectSkillsRoot(agentKey, process.cwd()), dist), gateFor(agentKey, process.cwd(), dist));
55
63
  }
@@ -23,7 +23,7 @@
23
23
  * Mac dev box and a Linux VPS), which an absolute `process.execPath`/`<abs>/dist` baking did not.
24
24
  * (Requires `dist/` — build once with `bun run build` / `npm run build`.)
25
25
  */
26
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
26
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
27
27
  import { join, resolve, relative, dirname, sep } from 'node:path';
28
28
  import { PKG_ROOT, BASE_DIR, DATA_DIR } from "./paths.js";
29
29
  /** Compiled output dir — what a rewritten (dist) skill command points the runtime at. */
@@ -103,6 +103,50 @@ export function installSkills(root, dist = false) {
103
103
  return target;
104
104
  });
105
105
  }
106
+ /** Agent key of Claude Code in AGENTS — the only agent with a PreToolUse hook to gate on. */
107
+ export const CLAUDE_CODE = '1';
108
+ /** Substring identifying our gate inside a settings.json hook command (used to update in place). */
109
+ const GATE_ID = 'yt-summary-gate';
110
+ /** How settings.json must invoke the gate — same portable form as the skill's engine commands. */
111
+ export const gateCommand = (dist = false) => dist
112
+ ? `${isBun ? 'bun' : 'node'} "${toProjectRel(join(DIST_DIR, GATE_ID + '.js'))}"`
113
+ : `bun run src/${GATE_ID}.ts`;
114
+ /**
115
+ * Put the gate into a settings object: a PreToolUse hook on AskUserQuestion. Merges — every other
116
+ * setting and hook is left as found, and our own entry is updated in place, so reinstalling (or
117
+ * moving the package, which changes the baked path) never duplicates or clobbers anything.
118
+ */
119
+ export function withGateHook(settings, command) {
120
+ const preToolUse = ((settings.hooks ??= {}).PreToolUse ??= []);
121
+ const mine = preToolUse.find((e) => e.hooks?.some((h) => h.command?.includes(GATE_ID)));
122
+ const entry = { matcher: 'AskUserQuestion', hooks: [{ type: 'command', command }] };
123
+ if (mine)
124
+ Object.assign(mine, entry);
125
+ else
126
+ preToolUse.push(entry);
127
+ return settings;
128
+ }
129
+ /**
130
+ * Register the gate in a Claude Code project's `.claude/settings.json`.
131
+ *
132
+ * Returns the settings path written, or null when the file exists but isn't parseable JSON: a
133
+ * hand-edited config is not ours to rewrite, so the caller tells the user to add it by hand.
134
+ */
135
+ export function installClaudeGate(projectDir, dist = false) {
136
+ const target = join(projectDir, '.claude', 'settings.json');
137
+ let settings = {};
138
+ if (existsSync(target)) {
139
+ try {
140
+ settings = JSON.parse(readFileSync(target, 'utf8'));
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ mkdirSync(dirname(target), { recursive: true });
147
+ writeFileSync(target, JSON.stringify(withGateHook(settings, gateCommand(dist)), null, 2) + '\n', 'utf8');
148
+ return target;
149
+ }
106
150
  /** The agent's skills ROOT inside a project folder (the project you open in the agent). */
107
151
  export const projectSkillsRoot = (agentKey, projectDir) => join(projectDir, AGENTS[agentKey].sub);
108
152
  /** Suggested target for a "custom" (any other agent) install — the open `.agents` convention,
@@ -0,0 +1,40 @@
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 how the hook wires it up.
6
+ */
7
+ /**
8
+ * Is this the /yt rating popup? Identified by its fixed option labels, which SKILL.md pins in
9
+ * English across every `output_lang` (only the question text and descriptions are localised).
10
+ * Anything else — including /yt-search's keep/skip — is none of the gate's business.
11
+ */
12
+ export function isRatingPopup(payload) {
13
+ const labels = (payload.tool_input?.questions ?? [])
14
+ .flatMap((q) => q.options ?? [])
15
+ .map((o) => (o.label ?? '').trim().toLowerCase());
16
+ return labels.includes('research') && labels.includes('weak');
17
+ }
18
+ /**
19
+ * Did the agent actually paste the summary? The summary carries the video's watch URL, so its id
20
+ * appears verbatim in the pasted text. Only the assistant's own text blocks count: the id also
21
+ * travels through tool calls and their results, and neither is shown to the user.
22
+ */
23
+ export function summaryWasPasted(transcript, videoId) {
24
+ for (const line of transcript.split('\n')) {
25
+ if (!line.includes(videoId))
26
+ continue;
27
+ let entry;
28
+ try {
29
+ entry = JSON.parse(line);
30
+ }
31
+ catch {
32
+ continue;
33
+ }
34
+ if (entry.message?.role !== 'assistant' || !Array.isArray(entry.message.content))
35
+ continue;
36
+ if (entry.message.content.some((b) => b.type === 'text' && b.text?.includes(videoId)))
37
+ return true;
38
+ }
39
+ return false;
40
+ }
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * Usage: bun src/yt-channel-pending.ts @HANDLE
4
4
  *
5
- * For one channel: reads state.md pointers + last-updated date,
6
- * fetches the channel's videos in-process via lib/yt-api.ts (--since updated),
5
+ * For one channel: reads state.md pointers, fetches the channel's videos in-process via
6
+ * lib/yt-api.ts (--since a fixed lookback, NOT state.md's `updated` — that column is a
7
+ * write-timestamp, not the pointer video's publish date; using it as the fetch cutoff
8
+ * silently orphaned backlog videos on channels swept more than once per day, see
9
+ * CHANGELOG),
7
10
  * filters to videos NEWER than each type's pointer (or baseline if pointer null),
8
11
  * sorts ASC by publishedAt (process oldest first → state pointer advances monotonically),
9
12
  * outputs JSON array: [{videoId, title, publishedAt, type, is_baseline}].
@@ -28,7 +31,14 @@ if (!row) {
28
31
  console.error(`Channel ${handle} not found in state.md`);
29
32
  process.exit(1);
30
33
  }
31
- const since = row.updated ?? '2020-01-01';
34
+ // `row.updated` is when state.md was last WRITTEN (stamped to today on every bump),
35
+ // not when the pointer video was PUBLISHED — reusing it as the fetch cutoff shrinks the
36
+ // window to "today" after the first bump of the day, so a second same-day sweep can
37
+ // silently drop any not-yet-processed backlog between the pointer and today. A fixed
38
+ // lookback avoids that: the pointer-cutoff logic below still does the real filtering,
39
+ // this just has to be wide enough to always re-include the pointer video itself.
40
+ const LOOKBACK_DAYS = 45;
41
+ const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
32
42
  let videos;
33
43
  try {
34
44
  videos = await fetchChannelVideos(handle, { since });
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * yt-summary-gate — PreToolUse gate for Claude Code: refuses the /yt rating popup 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. Observed failure mode — the paste happens
9
+ * reliably while the sweep also returns a `skipped` list (there is other prose to write) and gets
10
+ * dropped in iterations that return none, where the turn can open straight with a tool call. A
11
+ * blind rating is worse than no rating: it teaches the title filter from noise.
12
+ *
13
+ * The engine cannot enforce this — `yt-sweep` only emits the summary, `yt-rating` only reads
14
+ * `pending.json`, and neither can see the conversation. The transcript is the one place the
15
+ * evidence exists and only the agent harness exposes it, so the check has to run as a hook.
16
+ *
17
+ * Reads the PreToolUse payload on stdin; exit 0 allows, exit 2 blocks and feeds stderr back to
18
+ * the agent. Anything unexpected — foreign tool, unreadable transcript, no pending video —
19
+ * allows: a gate that misfires on unrelated work would be worse than the bug it guards.
20
+ */
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import { isRatingPopup, summaryWasPasted } from "./lib/summary-gate.js";
23
+ const ALLOW = 0;
24
+ const BLOCK = 2;
25
+ const allow = () => process.exit(ALLOW);
26
+ let payload;
27
+ try {
28
+ payload = JSON.parse(readFileSync(0, 'utf8'));
29
+ }
30
+ catch {
31
+ allow();
32
+ }
33
+ if (payload.tool_name !== 'AskUserQuestion' || !isRatingPopup(payload))
34
+ allow();
35
+ // paths.ts derives the data dir from the cwd, which for a hook is the agent's, not ours — so the
36
+ // import has to wait until we've moved there.
37
+ if (payload.cwd && existsSync(payload.cwd))
38
+ process.chdir(payload.cwd);
39
+ const { PENDING_FILE } = await import("./lib/paths.js");
40
+ let pending = {};
41
+ try {
42
+ pending = JSON.parse(readFileSync(PENDING_FILE, 'utf8'));
43
+ }
44
+ catch {
45
+ allow(); // no pending video (or unreadable) — nothing to gate on
46
+ }
47
+ if (!pending.videoId)
48
+ allow();
49
+ const transcript = payload.transcript_path;
50
+ if (!transcript || !existsSync(transcript))
51
+ allow();
52
+ let seen = false;
53
+ try {
54
+ seen = summaryWasPasted(readFileSync(transcript, 'utf8'), pending.videoId);
55
+ }
56
+ catch {
57
+ allow();
58
+ }
59
+ if (seen)
60
+ allow();
61
+ process.stderr.write(`/yt step B not done: the summary for ${pending.videoId} ("${pending.title ?? '?'}") is not in your chat text.\n` +
62
+ `Paste out.summary verbatim as your reply first, then ask for the rating — the user rates what they can see.\n`);
63
+ process.exit(BLOCK);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {