yt-briefing 1.1.0 → 1.2.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 +19 -4
- package/data.example/README.md +2 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/register.tsx +98 -11
package/README.md
CHANGED
|
@@ -61,8 +61,9 @@ YT_BRIEFING_YOUTUBE_API_KEY=<key> # console.cloud.google.com → enable "YouT
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Optional extras: `YT_BRIEFING_MODEL` picks the Claude model for filtering and summaries (default
|
|
64
|
-
`sonnet`, any alias or model name `claude --model` accepts, `haiku`
|
|
65
|
-
transcript fetches through a proxy on
|
|
64
|
+
`sonnet`, any alias or model name `claude --model` accepts, `haiku` to go lighter on your plan's
|
|
65
|
+
usage limits), and `YT_BRIEFING_PROXY` routes transcript fetches through a proxy on
|
|
66
|
+
datacenter/VPS IPs.
|
|
66
67
|
|
|
67
68
|
4. Onboard:
|
|
68
69
|
|
|
@@ -122,6 +123,9 @@ it to go deeper, lower it for a quicker pass:
|
|
|
122
123
|
Open your project in Claude Code and type `/yt`. The briefing opens in a pane: the summary, and
|
|
123
124
|
under it four keys.
|
|
124
125
|
|
|
126
|
+
The keys work while the pane has the focus (it takes it when it opens, `/yt` from an empty
|
|
127
|
+
prompt), and Esc closes it.
|
|
128
|
+
|
|
125
129
|
| Key | What it does |
|
|
126
130
|
|-----|--------------|
|
|
127
131
|
| `1` OK | Neutral. The video is marked as seen, the next one loads. |
|
|
@@ -132,9 +136,15 @@ under it four keys.
|
|
|
132
136
|
The **Comment** field takes anything else. Type what you think in your own words ("too many panel
|
|
133
137
|
shows, skip those") and press Enter: Claude turns it into a standing rule for that channel and
|
|
134
138
|
infers the rating. `? your question` starts research with that question, `stop` closes the pane.
|
|
139
|
+
Claude Code on a phone has no text fields, so there the pane shows the four keys only.
|
|
140
|
+
Where no pane can be seen (`/yt` sent from claude.ai/code over Remote Control, or a surface that
|
|
141
|
+
places no panes), the same loop runs in Claude Code's question dialog: the summary as plain text,
|
|
142
|
+
OK / Weak / Research / Stop as choices, and a typed **Other** answer works as the Comment field.
|
|
135
143
|
|
|
136
|
-
Each step is the engine, not a chat turn: rating a video
|
|
137
|
-
the next summary is usually ready before you have finished reading the current one.
|
|
144
|
+
Each step is the engine, not a chat turn: rating a video takes no turn and no context of your
|
|
145
|
+
session, and the next summary is usually ready before you have finished reading the current one.
|
|
146
|
+
The summaries themselves still count toward your Claude plan's usage, like any other Claude Code
|
|
147
|
+
work.
|
|
138
148
|
|
|
139
149
|
`/yt` is a Claude Code mod (a plugin in `.claude/skills/yt-briefing/`). Claude Code loads it on
|
|
140
150
|
its own once you trust the project folder. If `/yt` is not listed, start a fresh session. To
|
|
@@ -195,6 +205,11 @@ at a separate private repo) and commit after each rating: set `"after_rate"` in
|
|
|
195
205
|
|
|
196
206
|
## Running on a VPS
|
|
197
207
|
|
|
208
|
+
Claude Code has to be installed and logged in on the server too, because the engine runs
|
|
209
|
+
`claude -p` there. With no browser on the box, `claude setup-token` creates a long-lived login
|
|
210
|
+
token for your subscription. An `ANTHROPIC_API_KEY` alone is not enough: the engine removes it
|
|
211
|
+
for its calls (see above), so a server set up only with an API key fails with a login error.
|
|
212
|
+
|
|
198
213
|
YouTube blocks datacenter IPs, so transcript fetches fail on most servers. Route them through a
|
|
199
214
|
free Cloudflare WARP proxy. See [docs/warp-proxy.md](./docs/warp-proxy.md).
|
|
200
215
|
|
package/data.example/README.md
CHANGED
|
@@ -5,7 +5,8 @@ reference for the shape of that data, and a home for the channel-profile templat
|
|
|
5
5
|
|
|
6
6
|
```
|
|
7
7
|
data/
|
|
8
|
-
├── config.json { "output_lang": "English" } ← your summary
|
|
8
|
+
├── config.json { "output_lang": "English" } ← your summary language. Optional
|
|
9
|
+
│ "after_rate": "<command>" runs after every rating (sync)
|
|
9
10
|
├── channels.md the channels you follow (a flat list)
|
|
10
11
|
├── state.md per-channel per-type cursor (last video seen of each type)
|
|
11
12
|
├── channels/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-briefing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
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,6 +7,8 @@ import { engine } from './engine.ts'
|
|
|
7
7
|
// The /yt rating loop as a pane. Every step runs the engine as a subprocess and reads its one-line
|
|
8
8
|
// JSON; the model is not involved unless the person asks for research, which hands the video to
|
|
9
9
|
// the session as a prompt. Ratings and comments are written by the engine, never by this module.
|
|
10
|
+
// Where no pane can be seen (a Remote Control web client, a surface that places none) the same
|
|
11
|
+
// loop runs in the engine's own question dialog instead.
|
|
10
12
|
|
|
11
13
|
const PANE = 'yt-briefing'
|
|
12
14
|
const view = atom({ plugin: 'yt-briefing', key: 'view' } as const, { phase: 'idle' } as View)
|
|
@@ -50,13 +52,25 @@ function skipLine(out: SweepOut): string | undefined {
|
|
|
50
52
|
return `Skipped ${out.skipped}: ${list}`
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/** One engine sweep: the next ratable video, or the end state. Throws when the engine does not answer. */
|
|
56
|
+
async function runSweep($: EngineInterface, reset: boolean): Promise<SweepOut> {
|
|
57
|
+
const run = await $.process.run([...engine('yt-sweep'), ...(reset ? ['--reset'] : [])], { timeoutMs: SWEEP_MS })
|
|
58
|
+
return parseOut(run.stdout)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a sweep that found nothing to rate says, skips included. */
|
|
62
|
+
function endText(out: SweepOut): string {
|
|
63
|
+
const text = out.status === 'done' ? STATUS_TEXT.done : out.error ?? STATUS_TEXT[out.status] ?? `Engine status: ${out.status}`
|
|
64
|
+
const skipped = skipLine(out)
|
|
65
|
+
return skipped ? `${text}\n${skipped}` : text
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
/** Advance to the next ratable video and put it (or the end state) in the view. */
|
|
54
69
|
async function sweep($: EngineInterface, reset: boolean) {
|
|
55
70
|
await setView($, { phase: 'loading' })
|
|
56
71
|
let out: SweepOut
|
|
57
72
|
try {
|
|
58
|
-
|
|
59
|
-
out = parseOut(run.stdout)
|
|
73
|
+
out = await runSweep($, reset)
|
|
60
74
|
} catch (err) {
|
|
61
75
|
await setView($, { phase: 'error', message: `The engine did not answer: ${String(err)}` })
|
|
62
76
|
return
|
|
@@ -76,17 +90,26 @@ async function rate($: EngineInterface, args: string[], thenNext = true): Promis
|
|
|
76
90
|
const v = await read($, view)
|
|
77
91
|
if (v.phase !== 'rating' || v.busy) return null
|
|
78
92
|
await update($, view, cur => ({ ...cur, busy: args[0] === '--raw-comment' ? 'Turning the comment into a rule…' : 'Saving…' }))
|
|
93
|
+
const result = await record($, args)
|
|
94
|
+
if (!result) {
|
|
95
|
+
await update($, view, cur => ({ ...cur, busy: undefined }))
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
if (thenNext) void sweep($, false)
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Run the rating engine; on failure toast why and resolve null. */
|
|
103
|
+
async function record($: EngineInterface, args: string[]): Promise<{ rule?: string } | null> {
|
|
79
104
|
const run = await $.process.run([...engine('yt-rating'), ...args], { timeoutMs: RATE_MS }).catch(err => ({
|
|
80
105
|
exitCode: 1, stdout: '', stderr: String(err),
|
|
81
106
|
}))
|
|
82
107
|
if (run.exitCode !== 0) {
|
|
83
|
-
await update($, view, cur => ({ ...cur, busy: undefined }))
|
|
84
108
|
$.ui.toast(`Rating not saved: ${run.stderr.trim().slice(0, 200) || 'engine error'}`)
|
|
85
109
|
return null
|
|
86
110
|
}
|
|
87
111
|
let result: { rule?: string } = {}
|
|
88
112
|
try { result = JSON.parse(run.stdout.trim().split('\n').pop() ?? '{}') } catch { /* rating is on disk */ }
|
|
89
|
-
if (thenNext) void sweep($, false)
|
|
90
113
|
return result
|
|
91
114
|
}
|
|
92
115
|
|
|
@@ -96,7 +119,11 @@ async function research($: EngineInterface, question?: string) {
|
|
|
96
119
|
if (!v.pending || !v.summary) return
|
|
97
120
|
if (!(await rate($, ['--rating', '1'], false))) return
|
|
98
121
|
await $.ui.close({ id: PANE })
|
|
99
|
-
|
|
122
|
+
await handOff($, v.pending, v.summary, v.lang, question)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Give the video to the session as the person's prompt, to research it together. */
|
|
126
|
+
async function handOff($: EngineInterface, p: Pending, summary: string, lang: string | undefined, question?: string) {
|
|
100
127
|
const transcript = [...engine('yt-transcript'), p.videoId, '--lang', 'auto'].join(' ')
|
|
101
128
|
const ask = question
|
|
102
129
|
? `My question: ${question}`
|
|
@@ -107,9 +134,9 @@ async function research($: EngineInterface, question?: string) {
|
|
|
107
134
|
`Let's research this video together: ${p.channel} — "${p.title}" (${p.videoId}, ${p.type}).`,
|
|
108
135
|
'',
|
|
109
136
|
'Its briefing:',
|
|
110
|
-
|
|
137
|
+
summary,
|
|
111
138
|
'',
|
|
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 ${
|
|
139
|
+
`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 ${lang ?? 'English'}.`,
|
|
113
140
|
ask,
|
|
114
141
|
'',
|
|
115
142
|
'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(' ') + '`.',
|
|
@@ -126,6 +153,59 @@ async function comment($: EngineInterface, raw: string) {
|
|
|
126
153
|
if (saved?.rule) $.ui.toast(`Rule saved: ${saved.rule}`)
|
|
127
154
|
}
|
|
128
155
|
|
|
156
|
+
/** The dialog's choices; anything else is text typed under "Other". */
|
|
157
|
+
const CHOICES = ['OK', 'Weak', 'Research', 'Stop'] as const
|
|
158
|
+
|
|
159
|
+
/** The dialog draws plain text: drop the briefing's Markdown marks so no `**` or `###` shows. */
|
|
160
|
+
function plain(md: string): string {
|
|
161
|
+
return md
|
|
162
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
163
|
+
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
164
|
+
.replace(/^_(.+)_$/gm, '$1')
|
|
165
|
+
.replace(/^(\d+\.)\s+/gm, '$1 ')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True when the person typed /yt through Remote Control and nothing attached can show a pane. */
|
|
169
|
+
async function paneUnseen($: EngineInterface, origin: { kind: string } | undefined): Promise<boolean> {
|
|
170
|
+
if (origin?.kind !== 'bridge') return false
|
|
171
|
+
return !(await $.session.surfaces()).some(s => s !== 'terminal')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The rating loop in the engine's question dialog, for a session where no pane can be seen. */
|
|
175
|
+
async function dialogLoop($: EngineInterface) {
|
|
176
|
+
let reset = true
|
|
177
|
+
for (;;) {
|
|
178
|
+
let out: SweepOut
|
|
179
|
+
try {
|
|
180
|
+
out = await runSweep($, reset)
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return void $.ui.toast(`The engine did not answer: ${String(err)}`)
|
|
183
|
+
}
|
|
184
|
+
reset = false
|
|
185
|
+
if (out.status !== 'rating_needed' || !out.summary || !out.pending) return void $.ui.toast(endText(out))
|
|
186
|
+
|
|
187
|
+
const skipped = skipLine(out)
|
|
188
|
+
const question = [skipped, plain(out.summary), '', 'OK = neutral · Weak = skip titles like this · Research = dig in with Claude · Other = a rule for this channel (?question = research). Rating?']
|
|
189
|
+
.filter(line => line !== undefined).join('\n')
|
|
190
|
+
let answer: string
|
|
191
|
+
try {
|
|
192
|
+
answer = (await $.ui.ask(question, { header: 'yt-briefing', options: CHOICES })).trim()
|
|
193
|
+
} catch {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (answer === 'Stop' || answer.toLowerCase() === 'stop' || !answer) return
|
|
198
|
+
if (answer === 'Research' || answer.startsWith('?')) {
|
|
199
|
+
if (!(await record($, ['--rating', '1']))) return
|
|
200
|
+
const q = answer.startsWith('?') ? answer.slice(1).trim() || undefined : undefined
|
|
201
|
+
return handOff($, out.pending, out.summary, out.lang, q)
|
|
202
|
+
}
|
|
203
|
+
const args = answer === 'OK' ? ['--rating', '1'] : answer === 'Weak' ? ['--rating', '0'] : ['--raw-comment', answer]
|
|
204
|
+
const saved = await record($, args)
|
|
205
|
+
if (!saved) return
|
|
206
|
+
if (saved.rule) $.ui.toast(`Rule saved: ${saved.rule}`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
129
209
|
|
|
130
210
|
export const register: Register = on => {
|
|
131
211
|
on('session.start', async ($, e, next) => {
|
|
@@ -137,11 +217,18 @@ export const register: Register = on => {
|
|
|
137
217
|
return next(e)
|
|
138
218
|
})
|
|
139
219
|
|
|
140
|
-
on('command.run', { command: 'yt' }, async
|
|
141
|
-
await
|
|
142
|
-
|
|
220
|
+
on('command.run', { command: 'yt' }, async ($, e) => {
|
|
221
|
+
if (!(await paneUnseen($, e.origin))) {
|
|
222
|
+
const opened = await $.ui.open({ id: PANE, title: 'yt-briefing', focus: true, closeOnEscape: true })
|
|
223
|
+
if (opened.isPlaced) {
|
|
224
|
+
void sweep($, true)
|
|
225
|
+
return { text: 'Briefing opened in a pane.' }
|
|
226
|
+
}
|
|
227
|
+
await $.ui.close({ id: PANE })
|
|
228
|
+
}
|
|
229
|
+
void dialogLoop($)
|
|
143
230
|
|
|
144
|
-
return { text: '
|
|
231
|
+
return { text: 'No pane here: the briefing runs in question dialogs.' }
|
|
145
232
|
})
|
|
146
233
|
|
|
147
234
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|