yt-briefing 0.15.1 → 1.1.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/lib/llm.js CHANGED
@@ -1,58 +1,101 @@
1
1
  /**
2
- * Minimal OpenAI-compatible chat client — the only LLM dependency in yt-briefing.
2
+ * The one LLM call in yt-briefing — a headless Claude Code run (`claude -p`).
3
3
  *
4
- * Provider-agnostic: point YT_BRIEFING_LLM_BASE_URL at any OpenAI-compatible endpoint —
5
- * OpenRouter (default, "any model, one key"), Google Gemini's OpenAI-compat
6
- * endpoint, OpenAI itself, a local Ollama, etc. The tool depends only on an API key
7
- * here — not on any specific vendor and not on a coding agent being installed.
4
+ * There is no API key and no provider to configure: the engine asks the `claude` CLI already
5
+ * installed and logged in on this machine, so title filtering, summaries and search all run on
6
+ * the user's Claude Code login (subscription or whatever auth they set up for it).
8
7
  *
9
- * One model does both stages (title classification + summaries). Gemini 2.5 Flash is
10
- * the default — cheap and fast enough for the batch title filter, capable enough for
11
- * the summaries.
8
+ * The run is deliberately bare of everything that makes Claude Code an agent: no tools, no
9
+ * settings sources (so none of the project's hooks or permissions), no MCP servers, no session
10
+ * file. It is a single prompt in, a single answer out — the same contract the old
11
+ * OpenAI-compatible client had.
12
12
  *
13
- * Env (see .env.example) — all required, no defaults:
14
- * YT_BRIEFING_LLM_BASE_URL required (e.g. https://openrouter.ai/api/v1)
15
- * YT_BRIEFING_LLM_API_KEY required
16
- * YT_BRIEFING_LLM_MODEL required (e.g. google/gemini-2.5-flash)
13
+ * `ANTHROPIC_API_KEY` is removed from the child's environment: when it is set, Claude Code
14
+ * prefers it over the interactive login, which silently turns every summary into paid API usage
15
+ * (or a "credit balance too low" failure). The briefing is meant to run on the login.
16
+ *
17
+ * Env (all optional):
18
+ * YT_BRIEFING_MODEL model alias or full name passed to `claude --model` (default: sonnet)
19
+ */
20
+ import { spawn, spawnSync } from 'node:child_process';
21
+ /**
22
+ * Model alias for every call; one model does both stages. Sonnet: the summary is the product, and a
23
+ * subscription login does not bill per token, so the default buys quality over speed. Set
24
+ * YT_BRIEFING_MODEL=haiku for faster, lighter runs.
17
25
  */
18
- import { requireEnv, REQUIRED_LLM } from "./env.js";
26
+ export const DEFAULT_MODEL = 'sonnet';
19
27
  export function getModel() {
20
- const model = process.env.YT_BRIEFING_LLM_MODEL;
21
- if (!model)
22
- throw new Error("Missing required environment variable: YT_BRIEFING_LLM_MODEL. Set it in your project root .env.");
23
- return model;
28
+ return process.env.YT_BRIEFING_MODEL || DEFAULT_MODEL;
24
29
  }
25
- export async function chat(prompt, opts = {}) {
26
- requireEnv(REQUIRED_LLM);
27
- const baseUrl = process.env.YT_BRIEFING_LLM_BASE_URL.replace(/\/+$/, "");
28
- const apiKey = process.env.YT_BRIEFING_LLM_API_KEY;
29
- const model = opts.model || getModel();
30
- const messages = [];
30
+ /** A hung CLI must not hold the sweep forever; a long transcript summary takes well under this. */
31
+ const TIMEOUT_MS = 5 * 60_000;
32
+ /** The child environment: everything but the API key that would override the login. */
33
+ export function childEnv(env = process.env) {
34
+ const { ANTHROPIC_API_KEY: _drop, ...rest } = env;
35
+ return rest;
36
+ }
37
+ /** argv for one bare, tool-less, settings-less `claude -p` call. */
38
+ export function claudeArgs(opts = {}) {
39
+ const args = [
40
+ '-p',
41
+ '--model', opts.model || getModel(),
42
+ '--output-format', 'json',
43
+ '--tools', '',
44
+ '--setting-sources', '',
45
+ '--strict-mcp-config',
46
+ '--no-session-persistence',
47
+ ];
31
48
  if (opts.system)
32
- messages.push({ role: "system", content: opts.system });
33
- messages.push({ role: "user", content: prompt });
34
- const headers = {
35
- "Content-Type": "application/json",
36
- Authorization: `Bearer ${apiKey}`,
37
- "X-Title": "yt-briefing",
38
- };
39
- const res = await fetch(`${baseUrl}/chat/completions`, {
40
- method: "POST",
41
- headers,
42
- body: JSON.stringify({
43
- model,
44
- messages,
45
- temperature: opts.temperature ?? 0.3,
46
- }),
47
- });
48
- if (!res.ok) {
49
- const body = await res.text().catch(() => "");
50
- throw new Error(`LLM ${res.status} ${res.statusText}: ${body.slice(0, 300)}`);
49
+ args.push('--system-prompt', opts.system);
50
+ return args;
51
+ }
52
+ /** Pull the answer out of `--output-format json`; throws with Claude Code's own error text. */
53
+ export function parseClaudeOutput(stdout) {
54
+ let data;
55
+ try {
56
+ data = JSON.parse(stdout);
51
57
  }
52
- const data = await res.json();
53
- const text = data?.choices?.[0]?.message?.content;
54
- if (typeof text !== "string") {
55
- throw new Error(`LLM: no content in response: ${JSON.stringify(data).slice(0, 300)}`);
58
+ catch {
59
+ throw new Error(`claude: unreadable output: ${stdout.slice(0, 300)}`);
56
60
  }
57
- return text.trim();
61
+ if (data.is_error || typeof data.result !== 'string') {
62
+ throw new Error(`claude: ${String(data.result ?? 'no result').slice(0, 300)}`);
63
+ }
64
+ return data.result.trim();
65
+ }
66
+ export async function chat(prompt, opts = {}) {
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn('claude', claudeArgs(opts), { env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
69
+ let out = '';
70
+ let err = '';
71
+ const timer = setTimeout(() => {
72
+ child.kill();
73
+ reject(new Error(`claude: no answer within ${TIMEOUT_MS / 1000}s`));
74
+ }, TIMEOUT_MS);
75
+ child.stdout.on('data', (d) => { out += d; });
76
+ child.stderr.on('data', (d) => { err += d; });
77
+ child.on('error', (e) => { clearTimeout(timer); reject(new Error(`claude: cannot start (${e.message})`)); });
78
+ child.on('close', () => {
79
+ clearTimeout(timer);
80
+ try {
81
+ resolve(parseClaudeOutput(out));
82
+ }
83
+ catch (e) {
84
+ reject(out.trim() ? e : new Error(`claude: ${err.trim().slice(0, 300) || 'no output'}`));
85
+ }
86
+ });
87
+ child.stdin.end(prompt);
88
+ });
89
+ }
90
+ /**
91
+ * Preflight for the entrypoints: null when the `claude` CLI runs, else a message saying what to
92
+ * install. Checked once per foreground run, so a missing CLI is a named error instead of every
93
+ * summary failing (and the title filter silently keeping everything).
94
+ */
95
+ export function claudeMissing() {
96
+ const res = spawnSync('claude', ['--version'], { encoding: 'utf8' });
97
+ if (!res.error && res.status === 0)
98
+ return null;
99
+ return 'Claude Code CLI not found: yt-briefing runs its filters and summaries through `claude -p`. ' +
100
+ 'Install Claude Code (https://claude.com/claude-code), log in once, and make sure `claude` is on PATH.';
58
101
  }
@@ -1,163 +1,171 @@
1
1
  /**
2
- * skill-install — place this package's SKILL.md files into a coding agent's skills directory.
2
+ * skill-install — put yt-briefing into a Claude Code project.
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 skills identically.
5
+ * `install-skill.ts` command, so both install identically. Two things land in
6
+ * `<project>/.claude/skills/`:
6
7
  *
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.
8
+ * yt-briefing/ the Claude Code mod (a plugin of function hooks) behind `/yt`: the rating loop
9
+ * as a pane. Claude Code loads a plugin from the project's skills folder by itself
10
+ * (as `yt-briefing@skills-dir`) once the workspace is trusted.
11
+ * yt-transcribe/ one-shot skill: a single video's transcript → summary.
12
+ * yt-search/ skill: search within one channel → triage → comparison.
11
13
  *
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.
14
+ * The shipped files use the dev form (`bun run src/X.ts`, or `bun src/X.ts` in the mod): correct
15
+ * only when the agent's cwd IS the package clone AND the runtime is Bun. For every other install —
16
+ * a Node user, or the package consumed as a dependency — they are rewritten to a PORTABLE command:
17
+ * `<runtime> "<project-relative>/dist/X.js"`, the runtime a bare name from PATH and every path
18
+ * relative to the project root, never machine-absolute. The invariant this rests on is the one the
19
+ * whole package relies on (paths.ts derives BASE_DIR/DATA_DIR from `process.cwd()` when consumed):
20
+ * Claude Code runs from the project root. So the installed files survive being committed to git and
21
+ * shared across machines (a Mac and a Linux VPS). (Requires `dist/` — `bun run build`.)
15
22
  *
16
- * For everyone else — a Node user, or any install whose cwd won't be the package — we rewrite to
17
- * a PORTABLE command: `<runtime> "<project-relative>/dist/X.js"`. The runtime is the bare name
18
- * (`node`/`bun`) resolved from PATH, never an absolute binary; the script and `data/` paths are
19
- * relative to the PROJECT ROOT, never machine-absolute. The invariant this rests on is the same
20
- * one the whole package already relies on (paths.ts derives BASE_DIR/DATA_DIR from
21
- * `process.cwd()` when consumed): the agent runs from the project root. So the rewritten skill is
22
- * machine-independent — it survives being committed to git and shared across machines (e.g. a
23
- * Mac dev box and a Linux VPS), which an absolute `process.execPath`/`<abs>/dist` baking did not.
24
- * (Requires `dist/` — build once with `bun run build` / `npm run build`.)
23
+ * Upgrading from 0.x also removes what 1.0 replaced: the old chat-driven `/yt` skill and the
24
+ * summary-gate PreToolUse hook in `.claude/settings.json`.
25
25
  */
26
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
26
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } 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
- /** Compiled output dir — what a rewritten (dist) skill command points the runtime at. */
29
+ /** Compiled output dir — what a rewritten (dist) command points the runtime at. */
30
30
  const DIST_DIR = join(PKG_ROOT, 'dist');
31
31
  /** Consumed as a dependency? Then PKG_ROOT lives under node_modules (mirrors paths.ts). */
32
32
  const CONSUMED = PKG_ROOT.split(sep).includes('node_modules');
33
33
  /**
34
- * The project root the agent runs from — the cwd against which the rewritten skill's relative
35
- * paths resolve. When consumed, that's the user's project (parent of `<project>/.yt-briefing`);
36
- * in a clone it's the package itself. Matches how paths.ts picks BASE_DIR.
34
+ * The project root Claude Code runs from — the cwd against which the rewritten relative paths
35
+ * resolve. When consumed, that's the user's project (parent of `<project>/.yt-briefing`); in a
36
+ * clone it's the package itself. Matches how paths.ts picks BASE_DIR.
37
37
  */
38
38
  const PROJECT_ROOT = CONSUMED ? dirname(BASE_DIR) : PKG_ROOT;
39
39
  /** An absolute path expressed relative to PROJECT_ROOT, with POSIX `/` (portable on Windows too). */
40
40
  const toProjectRel = (abs) => relative(PROJECT_ROOT, abs).split(sep).join('/');
41
41
  /** The skills this package ships — each lives at `.claude/skills/<name>/SKILL.md`. */
42
- export const SKILLS = ['yt', 'yt-transcribe', 'yt-search'];
42
+ export const SKILLS = ['yt-transcribe', 'yt-search'];
43
+ /** The mod's folder name under `.claude/skills/` (and its plugin name). */
44
+ export const PLUGIN = 'yt-briefing';
45
+ /** The mod's shipped files, relative to `plugin/`; `hooks/engine.ts` is written, not copied. */
46
+ export const PLUGIN_FILES = [
47
+ '.claude-plugin/plugin.json',
48
+ 'hooks/hooks.json',
49
+ 'hooks/register.tsx',
50
+ 'types/index.d.ts',
51
+ ];
43
52
  /** True when the installer itself is running under Bun (vs plain Node). */
44
53
  export const isBun = process.versions.bun != null;
45
54
  /**
46
- * The shipped `bun run src/X.ts` command is only correct in ONE situation: the publisher
47
- * developing *inside the package clone* under Bun (cwd === package, TypeScript runs directly,
48
- * no build). Everywhere else — and crucially when the package is consumed as a dependency, so
49
- * the engine lives in node_modules while the user works in their own project — we must bake the
50
- * compiled `dist/` command instead. This detects that one dev-in-clone case.
55
+ * The shipped dev commands are only correct in ONE situation: the publisher developing *inside
56
+ * the package clone* under Bun (cwd === package, TypeScript runs directly, no build). Everywhere
57
+ * else — crucially when the package is consumed as a dependency — we bake the compiled `dist/`
58
+ * command instead. This detects that one dev-in-clone case.
51
59
  */
52
60
  export const isPackageDevCwd = () => isBun && resolve(process.cwd()) === PKG_ROOT;
61
+ /** The Claude Code skills root of a project folder. */
62
+ export const projectSkillsRoot = (projectDir) => join(projectDir, '.claude', 'skills');
53
63
  /** Source path of a shipped skill's SKILL.md, by skill name. */
54
64
  export const skillSource = (name) => join(PKG_ROOT, '.claude', 'skills', name, 'SKILL.md');
65
+ const runtimeName = () => (isBun ? 'bun' : 'node');
55
66
  /**
56
- * Agent key → display name + the skills ROOT directory it scans (skills install under it).
57
- *
58
- * SKILL.md is the cross-agent Agent Skills standard (Anthropic, Dec 2025), now read by 30+
59
- * tools that each scan their own `<agent-home>/skills/` dir. We only need the right directory
60
- * per agent — the shipped SKILL.md works unmodified in all of them. The "custom folder" picker
61
- * option (no AGENTS entry) covers every other compatible agent (Gemini CLI, Copilot, Windsurf…)
62
- * and defaults to the neutral `.agents/skills/` location.
63
- */
64
- export const AGENTS = {
65
- '1': { name: 'Claude Code', sub: join('.claude', 'skills') },
66
- '2': { name: 'Cursor', sub: join('.cursor', 'skills') },
67
- '3': { name: 'Codex', sub: join('.codex', 'skills') },
68
- };
69
- /**
70
- * One shipped skill's SKILL.md. `dist=false` (default) returns it verbatim — the
71
- * `bun run src/X.ts` dev form, correct only when cwd is the package AND the runtime is Bun.
72
- * `dist=true` rewrites for the consumed case into PORTABLE, project-relative form: engine
73
- * commands become `<node|bun> "<rel>/dist/X.js"` (bare runtime from PATH + a path relative to the
74
- * project root, so they run on any machine from the project cwd), and the bare `data/…` paths the
75
- * agent reads (e.g. `data/config.json`) become the project-relative DATA_DIR (`.yt-briefing/data/`
76
- * when consumed). Nothing machine-absolute is baked, so the rewritten skill can be committed and
77
- * shared across machines. The runtime name follows whoever runs the installer (Node→`node`,
78
- * Bun→`bun`); the compiled `dist/` build runs under either.
67
+ * One shipped skill's SKILL.md. `dist=false` returns it verbatim (the dev form). `dist=true`
68
+ * rewrites engine commands to `<node|bun> "<rel>/dist/X.js"` and the bare `data/…` paths the agent
69
+ * reads to the project-relative DATA_DIR. Nothing machine-absolute is baked.
79
70
  */
80
71
  export function skillBody(name, dist = false) {
81
72
  const raw = readFileSync(skillSource(name), 'utf8');
82
73
  if (!dist)
83
74
  return raw;
84
- const runtime = isBun ? 'bun' : 'node';
85
- const cmd = (base) => `${runtime} "${toProjectRel(join(DIST_DIR, base + '.js'))}"`;
75
+ const cmd = (base) => `${runtimeName()} "${toProjectRel(join(DIST_DIR, base + '.js'))}"`;
86
76
  return raw
87
- .replace(/bun run src\/yt-sweep\.ts/g, cmd('yt-sweep'))
88
- .replace(/bun run src\/yt-rating\.ts/g, cmd('yt-rating'))
89
77
  .replace(/bun run src\/yt-transcript\.ts/g, cmd('yt-transcript'))
90
78
  .replace(/bun run src\/yt-search\.ts/g, cmd('yt-search'))
91
79
  .replace(/data\//g, toProjectRel(DATA_DIR) + '/');
92
80
  }
93
81
  /**
94
- * Write every shipped skill into `root`, each under its own `<name>/SKILL.md` subdir
95
- * (created if needed). Returns the SKILL.md paths written, in `SKILLS` order.
82
+ * The mod's `hooks/engine.ts`: how the pane runs the engine. `dist=false` is the shipped dev form;
83
+ * `dist=true` points at the compiled scripts, relative to the project root (the session's cwd).
96
84
  */
97
- export function installSkills(root, dist = false) {
85
+ export function engineModule(dist = false) {
86
+ const shipped = readFileSync(join(PKG_ROOT, 'plugin', 'hooks', 'engine.ts'), 'utf8');
87
+ if (!dist)
88
+ return shipped;
89
+ const rel = toProjectRel(DIST_DIR);
90
+ return [
91
+ '// How the pane runs the engine, relative to the project root (the session\'s working directory).',
92
+ '// Written by `yt-briefing install-skill`; re-run it after moving the project or switching runtime.',
93
+ `export const engine = (name: string): string[] => ['${runtimeName()}', \`${rel}/\${name}.js\`]`,
94
+ '',
95
+ ].join('\n');
96
+ }
97
+ /** Write the mod into `<skillsRoot>/yt-briefing/`. Returns the folder written. */
98
+ export function installPlugin(skillsRoot, dist = false) {
99
+ const dir = join(skillsRoot, PLUGIN);
100
+ for (const file of PLUGIN_FILES) {
101
+ const target = join(dir, file);
102
+ mkdirSync(dirname(target), { recursive: true });
103
+ writeFileSync(target, readFileSync(join(PKG_ROOT, 'plugin', file), 'utf8'), 'utf8');
104
+ }
105
+ writeFileSync(join(dir, 'hooks', 'engine.ts'), engineModule(dist), 'utf8');
106
+ return dir;
107
+ }
108
+ /** Write every shipped skill into `skillsRoot/<name>/SKILL.md`. Returns the paths written. */
109
+ export function installSkills(skillsRoot, dist = false) {
98
110
  return SKILLS.map((name) => {
99
- const dir = join(root, name);
111
+ const dir = join(skillsRoot, name);
100
112
  mkdirSync(dir, { recursive: true });
101
113
  const target = join(dir, 'SKILL.md');
102
114
  writeFileSync(target, skillBody(name, dist), 'utf8');
103
115
  return target;
104
116
  });
105
117
  }
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). */
118
+ /** Substring identifying the 0.x summary gate inside a settings.json hook command. */
109
119
  const GATE_ID = 'yt-summary-gate';
110
- /**
111
- * How settings.json must invoke the gate. Like the skill's engine commands it bakes nothing
112
- * machine-absolute, but a hook may NOT assume its cwd — Claude Code's hooks reference states the
113
- * working directory can vary, so a bare relative path would fail open, silently and invisibly
114
- * (the rating is written ungated, which is exactly the bug the gate exists to catch). Hence the
115
- * `$CLAUDE_PROJECT_DIR` prefix: still just a placeholder string in the committed JSON, resolved
116
- * to the project root at hook time.
117
- */
118
- export const gateCommand = (dist = false) => dist
119
- ? `${isBun ? 'bun' : 'node'} "\${CLAUDE_PROJECT_DIR}/${toProjectRel(join(DIST_DIR, GATE_ID + '.js'))}"`
120
- : `bun run src/${GATE_ID}.ts`;
121
- /**
122
- * Put the gate into a settings object: a PreToolUse hook on Bash, which is where the rating is
123
- * written (gating the popup instead cannot work — see src/yt-summary-gate.ts). Merges — every
124
- * other setting and hook is left as found, and our own entry is updated in place, so
125
- * reinstalling (or upgrading from the pre-0.15.0 AskUserQuestion matcher) never duplicates or
126
- * clobbers anything.
127
- */
128
- export function withGateHook(settings, command) {
129
- const preToolUse = ((settings.hooks ??= {}).PreToolUse ??= []);
130
- const mine = preToolUse.find((e) => e.hooks?.some((h) => h.command?.includes(GATE_ID)));
131
- const entry = { matcher: 'Bash', hooks: [{ type: 'command', command }] };
132
- if (mine)
133
- Object.assign(mine, entry);
120
+ /** Drop the 0.x summary gate from a settings object; every other setting and hook stays as found. */
121
+ export function withoutGateHook(settings) {
122
+ const pre = settings.hooks?.PreToolUse;
123
+ if (!pre)
124
+ return settings;
125
+ const kept = pre.filter((e) => !e.hooks?.some((h) => h.command?.includes(GATE_ID)));
126
+ if (kept.length === pre.length)
127
+ return settings;
128
+ if (kept.length)
129
+ settings.hooks.PreToolUse = kept;
134
130
  else
135
- preToolUse.push(entry);
131
+ delete settings.hooks.PreToolUse;
132
+ if (settings.hooks && Object.keys(settings.hooks).length === 0)
133
+ delete settings.hooks;
136
134
  return settings;
137
135
  }
138
136
  /**
139
- * Register the gate in a Claude Code project's `.claude/settings.json`.
140
- *
141
- * Returns the settings path written, or null when the file exists but isn't parseable JSON: a
142
- * hand-edited config is not ours to rewrite, so the caller tells the user to add it by hand.
137
+ * Remove what 1.0 replaced from a project: the chat-driven `/yt` skill (only if it is ours — it
138
+ * runs yt-sweep) and the summary gate in `.claude/settings.json` (left alone if the file isn't
139
+ * valid JSON: a hand-edited config is not ours to rewrite). Returns what was removed.
143
140
  */
144
- export function installClaudeGate(projectDir, dist = false) {
145
- const target = join(projectDir, '.claude', 'settings.json');
146
- let settings = {};
147
- if (existsSync(target)) {
141
+ export function removeLegacy(projectDir) {
142
+ const removed = [];
143
+ const oldSkill = join(projectSkillsRoot(projectDir), 'yt');
144
+ const oldSkillMd = join(oldSkill, 'SKILL.md');
145
+ if (existsSync(oldSkillMd) && readFileSync(oldSkillMd, 'utf8').includes('yt-sweep')) {
146
+ rmSync(oldSkillMd);
147
+ if (readdirSync(oldSkill).length === 0)
148
+ rmSync(oldSkill, { recursive: true });
149
+ removed.push(oldSkillMd);
150
+ }
151
+ const settingsPath = join(projectDir, '.claude', 'settings.json');
152
+ if (existsSync(settingsPath)) {
148
153
  try {
149
- settings = JSON.parse(readFileSync(target, 'utf8'));
150
- }
151
- catch {
152
- return null;
154
+ const before = readFileSync(settingsPath, 'utf8');
155
+ const after = JSON.stringify(withoutGateHook(JSON.parse(before)), null, 2) + '\n';
156
+ if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(JSON.parse(after))) {
157
+ writeFileSync(settingsPath, after, 'utf8');
158
+ removed.push(`${settingsPath} (summary gate hook)`);
159
+ }
153
160
  }
161
+ catch { /* not valid JSON → leave it */ }
154
162
  }
155
- mkdirSync(dirname(target), { recursive: true });
156
- writeFileSync(target, JSON.stringify(withGateHook(settings, gateCommand(dist)), null, 2) + '\n', 'utf8');
157
- return target;
163
+ return removed;
164
+ }
165
+ /** Install everything into a project: legacy cleanup, the mod, the skills. Returns paths written/removed. */
166
+ export function installAll(projectDir, dist) {
167
+ const removed = removeLegacy(projectDir);
168
+ const root = projectSkillsRoot(projectDir);
169
+ const written = [installPlugin(root, dist), ...installSkills(root, dist)];
170
+ return { written, removed };
158
171
  }
159
- /** The agent's skills ROOT inside a project folder (the project you open in the agent). */
160
- export const projectSkillsRoot = (agentKey, projectDir) => join(projectDir, AGENTS[agentKey].sub);
161
- /** Suggested target for a "custom" (any other agent) install — the open `.agents` convention,
162
- * rooted at the user's current project (not the package, which may be in node_modules). */
163
- export const customSkillsRootDefault = () => join(process.cwd(), '.agents', 'skills');
package/dist/yt-rating.js CHANGED
@@ -2,6 +2,7 @@
2
2
  /**
3
3
  * Usage:
4
4
  * bun src/yt-rating.ts --rating 0|1 [--comment "..."]
5
+ * bun src/yt-rating.ts --raw-comment "..." [--rating 0|1]
5
6
  *
6
7
  * Channel / id / title / type default to <DATA_DIR>/.cache/pending.json (written by
7
8
  * yt-sweep.ts) so the agent only passes --rating (+ optional --comment) — no fragile
@@ -12,12 +13,20 @@
12
13
  * 1 = neutral → bump the state pointer only (video seen, no signal), profile untouched.
13
14
  * 0 = worthless → append a negative few-shot to `## Skip titles` (FIFO cap, default 10).
14
15
  * comment → append a durable rule to `## Notes`, seen by both filters.
16
+ * raw comment → the user's words as typed: distilled into a rule (and, unless --rating is
17
+ * given, the rating it implies) through `claude -p`, then stored as above.
18
+ *
19
+ * After a recorded rating, `after_rate` from config.json (if set) runs detached from the project
20
+ * root — e.g. a script that commits DATA_DIR. The engine never runs VCS itself.
15
21
  *
16
22
  * Direct durable commit — no rolling buffer, no consolidation. Idempotent: identical
17
23
  * bullets are de-duplicated; a state.md re-bump is a no-op.
18
24
  */
19
25
  import { readFileSync, writeFileSync, existsSync } from 'fs';
26
+ import { spawn } from 'node:child_process';
20
27
  import { loadEnv } from "./lib/env.js";
28
+ import { loadConfig } from "./lib/config.js";
29
+ import { distillComment } from "./lib/distill.js";
21
30
  import { parseChannels, appendSkipTitle, appendNote, bumpStatePointer } from "./lib/yt-lib.js";
22
31
  import { CHANNELS_MD, STATE_MD, PENDING_FILE, QUEUE_FILE, profilePath } from "./lib/paths.js";
23
32
  loadEnv();
@@ -47,25 +56,26 @@ function parseArgs(argv) {
47
56
  const type = getArg(argv, '--type') ?? pending.type ?? null;
48
57
  const ratingRaw = getArg(argv, '--rating');
49
58
  const comment = getArg(argv, '--comment') ?? '';
59
+ const rawComment = (getArg(argv, '--raw-comment') ?? '').trim();
50
60
  const baseline = argv.includes('--baseline') || pending.is_baseline === true;
51
61
  const noState = argv.includes('--no-state');
52
62
  const capRaw = getArg(argv, '--cap');
53
- if (!channel || !id || !title || !type || !ratingRaw) {
54
- console.error('Usage: yt-briefing rate --rating 0|1 [--comment "..."] (channel/id/title/type default to .cache/pending.json; override with --channel @X --id Y --title "..." --type longform|short|live) [--baseline] [--cap 10] [--no-state]');
63
+ if (!channel || !id || !title || !type || (!ratingRaw && !rawComment)) {
64
+ console.error('Usage: yt-briefing rate --rating 0|1 [--comment "..."] | --raw-comment "..." [--rating 0|1] (channel/id/title/type default to .cache/pending.json; override with --channel @X --id Y --title "..." --type longform|short|live) [--baseline] [--cap 10] [--no-state]');
55
65
  process.exit(1);
56
66
  }
57
67
  if (!['longform', 'short', 'live'].includes(type)) {
58
68
  console.error(`Invalid --type: ${type}`);
59
69
  process.exit(1);
60
70
  }
61
- const rating = parseInt(ratingRaw, 10);
71
+ const rating = ratingRaw === null ? null : parseInt(ratingRaw, 10);
62
72
  // Permissive 0..5 so older profiles / scripts keep working; the live UI emits only 0|1.
63
- if (!Number.isFinite(rating) || rating < 0 || rating > 5) {
73
+ if (rating !== null && (!Number.isFinite(rating) || rating < 0 || rating > 5)) {
64
74
  console.error(`Invalid --rating: ${ratingRaw} (must be 0 or 1)`);
65
75
  process.exit(1);
66
76
  }
67
77
  const cap = capRaw ? parseInt(capRaw, 10) : 10;
68
- return { channel, id, title, type: type, rating, comment, baseline, cap, noState };
78
+ return { channel, id, title, type: type, rating, comment, rawComment, baseline, cap, noState };
69
79
  }
70
80
  const args = parseArgs(process.argv.slice(2));
71
81
  const channels = parseChannels(readFileSync(CHANNELS_MD, 'utf8'));
@@ -80,15 +90,31 @@ if (!existsSync(profile)) {
80
90
  process.exit(1);
81
91
  }
82
92
  const date = new Date().toISOString().slice(0, 10);
93
+ // 0. A raw comment becomes a rule (+ the implied rating unless one was given explicitly).
94
+ let rule = args.comment.trim();
95
+ let rating = args.rating;
96
+ if (args.rawComment) {
97
+ try {
98
+ const d = await distillComment(args.rawComment, { channel: args.channel, title: args.title, type: args.type });
99
+ rule = d.rule;
100
+ rating ??= d.rating;
101
+ }
102
+ catch (e) {
103
+ console.error(`Comment not recorded: ${e.message}`);
104
+ process.exit(1);
105
+ }
106
+ }
107
+ if (rating === null)
108
+ rating = 1;
83
109
  // 1. Durable profile writes (no buffer, no consolidation):
84
110
  // rating=0 → negative few-shot; comment → Notes rule. rating=1 w/o comment → nothing.
85
111
  const profileBefore = readFileSync(profile, 'utf8');
86
112
  let profileAfter = profileBefore;
87
- if (args.rating === 0) {
113
+ if (rating === 0) {
88
114
  profileAfter = appendSkipTitle(profileAfter, { title: args.title, type: args.type }, args.cap);
89
115
  }
90
- if (args.comment && args.comment.trim()) {
91
- profileAfter = appendNote(profileAfter, args.comment.trim());
116
+ if (rule) {
117
+ profileAfter = appendNote(profileAfter, rule);
92
118
  }
93
119
  if (profileAfter !== profileBefore) {
94
120
  writeFileSync(profile, profileAfter, 'utf8');
@@ -117,8 +143,16 @@ if (!args.noState && existsSync(QUEUE_FILE)) {
117
143
  }
118
144
  catch { /* corrupt / foreign queue → ignore; the next sweep rebuilds it */ }
119
145
  }
146
+ // 4. The user's after-rate command (e.g. commit DATA_DIR to git). Detached: the rating is already
147
+ // durable on disk, so a slow or failing command must not hold or fail the rating.
148
+ const afterRate = loadConfig().after_rate;
149
+ if (afterRate) {
150
+ spawn(afterRate, { shell: true, detached: true, stdio: 'ignore' }).unref();
151
+ }
120
152
  console.log(JSON.stringify({
121
153
  ok: true,
122
154
  profile: `channels/${ch.slug}.md`,
155
+ rating,
156
+ ...(rule ? { rule } : {}),
123
157
  state_bumped: stateBumped,
124
158
  }));
package/dist/yt-search.js CHANGED
@@ -35,8 +35,8 @@
35
35
  */
36
36
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
37
37
  import { spawn } from 'node:child_process';
38
- import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
39
- import { chat, getModel } from "./lib/llm.js";
38
+ import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_YOUTUBE } from "./lib/env.js";
39
+ import { chat, claudeMissing } from "./lib/llm.js";
40
40
  import { outputLang } from "./lib/config.js";
41
41
  import { fetchChannelVideos } from "./lib/yt-api.js";
42
42
  import { normalizeHandle } from "./lib/channels.js";
@@ -135,7 +135,7 @@ Output ONLY a raw JSON array, best first, no fences:
135
135
  [{"id":"VIDEO_ID","keep":true,"score":0-100,"reason":"max 12 words"},...]
136
136
  Set keep=false for anything not relevant to the intent.`;
137
137
  try {
138
- const out = await chat(prompt, { system: 'You output ONLY a raw JSON array as instructed.', temperature: 0 });
138
+ const out = await chat(prompt, { system: 'You output ONLY a raw JSON array as instructed.' });
139
139
  const arr = parseJsonArray(out);
140
140
  if (!arr)
141
141
  return [];
@@ -194,7 +194,6 @@ Language: natural ${LANG}; foreign words only for proper nouns or established te
194
194
  Output ONLY the summary OR 'OFFTOPIC: <reason>'. No preamble.`;
195
195
  return chat(prompt, {
196
196
  system: `You are a research-grade video summarizer writing in ${LANG}. Output only the summary or 'OFFTOPIC: <reason>'.`,
197
- model: getModel(),
198
197
  });
199
198
  }
200
199
  /** Synthesize a comparison across everything kept. */
@@ -211,7 +210,7 @@ Write:
211
210
  - A final recommendation with the reasoning, and who it's for.
212
211
 
213
212
  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.`;
214
- return chat(prompt, { system: `You synthesize a decision-grade comparison in ${LANG}. No preamble.`, model: getModel() });
213
+ return chat(prompt, { system: `You synthesize a decision-grade comparison in ${LANG}. No preamble.` });
215
214
  }
216
215
  /** Lazy yield: advance to the next candidate that has a transcript, summarize, emit. */
217
216
  async function yieldNext(queue) {
@@ -242,13 +241,13 @@ async function yieldNext(queue) {
242
241
  emit({ status: 'done', kept: loadKept().length });
243
242
  }
244
243
  async function main() {
245
- // LLM is needed on every path (rerank, summaries, compare). Fail fast naming any missing var
244
+ // The `claude` CLI is needed on every path (rerank, summaries, compare). Fail fast if it's missing
246
245
  // (the throw is turned into a status:"error" by the .catch below). YouTube is checked separately,
247
246
  // only when building a fresh queue (the compare/keep/skip paths work off cache, no API).
248
247
  {
249
- const missing = missingEnv(REQUIRED_LLM);
250
- if (missing.length)
251
- emit({ status: 'error', error: missingEnvMessage(missing) });
248
+ const noClaude = claudeMissing();
249
+ if (noClaude)
250
+ emit({ status: 'error', error: noClaude });
252
251
  }
253
252
  // --compare: synthesize from kept summaries.
254
253
  if (COMPARE) {