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/README.md +64 -84
- package/dist/bootstrap.js +28 -47
- package/dist/cli.js +1 -1
- package/dist/install-skill.js +22 -53
- package/dist/lib/config.js +13 -10
- package/dist/lib/distill.js +44 -0
- package/dist/lib/env.js +0 -1
- package/dist/lib/llm.js +86 -47
- package/dist/lib/skill-install.js +116 -108
- package/dist/yt-rating.js +42 -8
- package/dist/yt-search.js +8 -9
- package/dist/yt-sweep.js +10 -10
- package/docs/sync-across-machines.md +10 -17
- package/package.json +8 -7
- package/plugin/.claude-plugin/plugin.json +7 -0
- package/plugin/hooks/engine.ts +4 -0
- package/plugin/hooks/hooks.json +1 -0
- package/plugin/hooks/register.tsx +199 -0
- package/plugin/types/index.d.ts +28 -0
- package/.claude/skills/yt/SKILL.md +0 -68
- package/dist/lib/summary-gate.js +0 -43
- package/dist/yt-summary-gate.js +0 -77
package/dist/lib/llm.js
CHANGED
|
@@ -1,58 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The one LLM call in yt-briefing — a headless Claude Code run (`claude -p`).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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: haiku)
|
|
17
19
|
*/
|
|
18
|
-
import {
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
|
+
/** Model alias for every call; one model does both stages, as before. */
|
|
22
|
+
export const DEFAULT_MODEL = 'haiku';
|
|
19
23
|
export function getModel() {
|
|
20
|
-
|
|
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;
|
|
24
|
+
return process.env.YT_BRIEFING_MODEL || DEFAULT_MODEL;
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
26
|
+
/** A hung CLI must not hold the sweep forever; a long transcript summary takes well under this. */
|
|
27
|
+
const TIMEOUT_MS = 5 * 60_000;
|
|
28
|
+
/** The child environment: everything but the API key that would override the login. */
|
|
29
|
+
export function childEnv(env = process.env) {
|
|
30
|
+
const { ANTHROPIC_API_KEY: _drop, ...rest } = env;
|
|
31
|
+
return rest;
|
|
32
|
+
}
|
|
33
|
+
/** argv for one bare, tool-less, settings-less `claude -p` call. */
|
|
34
|
+
export function claudeArgs(opts = {}) {
|
|
35
|
+
const args = [
|
|
36
|
+
'-p',
|
|
37
|
+
'--model', opts.model || getModel(),
|
|
38
|
+
'--output-format', 'json',
|
|
39
|
+
'--tools', '',
|
|
40
|
+
'--setting-sources', '',
|
|
41
|
+
'--strict-mcp-config',
|
|
42
|
+
'--no-session-persistence',
|
|
43
|
+
];
|
|
31
44
|
if (opts.system)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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)}`);
|
|
45
|
+
args.push('--system-prompt', opts.system);
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
/** Pull the answer out of `--output-format json`; throws with Claude Code's own error text. */
|
|
49
|
+
export function parseClaudeOutput(stdout) {
|
|
50
|
+
let data;
|
|
51
|
+
try {
|
|
52
|
+
data = JSON.parse(stdout);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`claude: unreadable output: ${stdout.slice(0, 300)}`);
|
|
51
56
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (typeof text !== "string") {
|
|
55
|
-
throw new Error(`LLM: no content in response: ${JSON.stringify(data).slice(0, 300)}`);
|
|
57
|
+
if (data.is_error || typeof data.result !== 'string') {
|
|
58
|
+
throw new Error(`claude: ${String(data.result ?? 'no result').slice(0, 300)}`);
|
|
56
59
|
}
|
|
57
|
-
return
|
|
60
|
+
return data.result.trim();
|
|
61
|
+
}
|
|
62
|
+
export async function chat(prompt, opts = {}) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const child = spawn('claude', claudeArgs(opts), { env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
65
|
+
let out = '';
|
|
66
|
+
let err = '';
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
child.kill();
|
|
69
|
+
reject(new Error(`claude: no answer within ${TIMEOUT_MS / 1000}s`));
|
|
70
|
+
}, TIMEOUT_MS);
|
|
71
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
72
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
73
|
+
child.on('error', (e) => { clearTimeout(timer); reject(new Error(`claude: cannot start (${e.message})`)); });
|
|
74
|
+
child.on('close', () => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
try {
|
|
77
|
+
resolve(parseClaudeOutput(out));
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
reject(out.trim() ? e : new Error(`claude: ${err.trim().slice(0, 300) || 'no output'}`));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
child.stdin.end(prompt);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Preflight for the entrypoints: null when the `claude` CLI runs, else a message saying what to
|
|
88
|
+
* install. Checked once per foreground run, so a missing CLI is a named error instead of every
|
|
89
|
+
* summary failing (and the title filter silently keeping everything).
|
|
90
|
+
*/
|
|
91
|
+
export function claudeMissing() {
|
|
92
|
+
const res = spawnSync('claude', ['--version'], { encoding: 'utf8' });
|
|
93
|
+
if (!res.error && res.status === 0)
|
|
94
|
+
return null;
|
|
95
|
+
return 'Claude Code CLI not found: yt-briefing runs its filters and summaries through `claude -p`. ' +
|
|
96
|
+
'Install Claude Code (https://claude.com/claude-code), log in once, and make sure `claude` is on PATH.';
|
|
58
97
|
}
|
|
@@ -1,163 +1,171 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skill-install —
|
|
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
|
|
5
|
+
* `install-skill.ts` command, so both install identically. Two things land in
|
|
6
|
+
* `<project>/.claude/skills/`:
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
* agent's cwd IS the package
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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)
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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
|
-
*
|
|
95
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 (
|
|
113
|
+
if (rating === 0) {
|
|
88
114
|
profileAfter = appendSkipTitle(profileAfter, { title: args.title, type: args.type }, args.cap);
|
|
89
115
|
}
|
|
90
|
-
if (
|
|
91
|
-
profileAfter = appendNote(profileAfter,
|
|
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,
|
|
39
|
-
import { chat,
|
|
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.'
|
|
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
|
|
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
|
-
//
|
|
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
|
|
250
|
-
if (
|
|
251
|
-
emit({ status: 'error', error:
|
|
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) {
|