yt-briefing 1.1.1 → 1.2.1
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 +3 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/register.tsx +105 -11
package/README.md
CHANGED
|
@@ -137,6 +137,9 @@ The **Comment** field takes anything else. Type what you think in your own words
|
|
|
137
137
|
shows, skip those") and press Enter: Claude turns it into a standing rule for that channel and
|
|
138
138
|
infers the rating. `? your question` starts research with that question, `stop` closes the pane.
|
|
139
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.
|
|
140
143
|
|
|
141
144
|
Each step is the engine, not a chat turn: rating a video takes no turn and no context of your
|
|
142
145
|
session, and the next summary is usually ready before you have finished reading the current one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-briefing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
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,57 @@ 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
|
+
.replace(/(\d{4}-\d{2}-\d{2})T[\d:.]+Z/g, '$1')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** True when the person typed /yt through Remote Control and nothing attached can show a pane. */
|
|
170
|
+
async function paneUnseen($: EngineInterface, origin: { kind: string } | undefined): Promise<boolean> {
|
|
171
|
+
if (origin?.kind !== 'bridge') return false
|
|
172
|
+
return !(await $.session.surfaces()).some(s => s !== 'terminal')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The rating loop in the engine's question dialog, from a sweep already run, for a session where no pane can be seen. */
|
|
176
|
+
async function dialogLoop($: EngineInterface, first: SweepOut) {
|
|
177
|
+
let out = first
|
|
178
|
+
for (;;) {
|
|
179
|
+
if (out.status !== 'rating_needed' || !out.summary || !out.pending) return void $.ui.log(endText(out))
|
|
180
|
+
|
|
181
|
+
const filtered = out.skipped ? `(${out.skipped} filtered out)` : undefined
|
|
182
|
+
const question = [plain(out.summary), filtered, '', 'Rating?'].filter(line => line !== undefined).join('\n')
|
|
183
|
+
let answer: string
|
|
184
|
+
try {
|
|
185
|
+
answer = (await $.ui.ask(question, { header: 'yt-briefing', options: CHOICES })).trim()
|
|
186
|
+
} catch {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (answer === 'Stop' || answer.toLowerCase() === 'stop' || !answer) return
|
|
191
|
+
if (answer === 'Research' || answer.startsWith('?')) {
|
|
192
|
+
if (!(await record($, ['--rating', '1']))) return
|
|
193
|
+
const q = answer.startsWith('?') ? answer.slice(1).trim() || undefined : undefined
|
|
194
|
+
return handOff($, out.pending, out.summary, out.lang, q)
|
|
195
|
+
}
|
|
196
|
+
const args = answer === 'OK' ? ['--rating', '1'] : answer === 'Weak' ? ['--rating', '0'] : ['--raw-comment', answer]
|
|
197
|
+
const saved = await record($, args)
|
|
198
|
+
if (!saved) return
|
|
199
|
+
$.ui.log(saved.rule ? `Rule saved: ${saved.rule}. Looking for the next video…` : 'Saved. Looking for the next video…')
|
|
200
|
+
try {
|
|
201
|
+
out = await runSweep($, false)
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return void $.ui.log(`The engine did not answer: ${String(err)}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
129
207
|
|
|
130
208
|
export const register: Register = on => {
|
|
131
209
|
on('session.start', async ($, e, next) => {
|
|
@@ -137,11 +215,27 @@ export const register: Register = on => {
|
|
|
137
215
|
return next(e)
|
|
138
216
|
})
|
|
139
217
|
|
|
140
|
-
on('command.run', { command: 'yt' }, async
|
|
141
|
-
await
|
|
142
|
-
|
|
218
|
+
on('command.run', { command: 'yt' }, async ($, e) => {
|
|
219
|
+
if (!(await paneUnseen($, e.origin))) {
|
|
220
|
+
const opened = await $.ui.open({ id: PANE, title: 'yt-briefing', focus: true, closeOnEscape: true })
|
|
221
|
+
if (opened.isPlaced) {
|
|
222
|
+
void sweep($, true)
|
|
223
|
+
return { text: 'Briefing opened in a pane.' }
|
|
224
|
+
}
|
|
225
|
+
await $.ui.close({ id: PANE })
|
|
226
|
+
}
|
|
227
|
+
// No pane to show progress in: the command itself runs while the first sweep does, so the
|
|
228
|
+
// session shows it working, and only then hands over to the dialogs.
|
|
229
|
+
let first: SweepOut
|
|
230
|
+
try {
|
|
231
|
+
first = await runSweep($, true)
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return { text: `The engine did not answer: ${String(err)}` }
|
|
234
|
+
}
|
|
235
|
+
if (first.status !== 'rating_needed') return { text: endText(first) }
|
|
236
|
+
void dialogLoop($, first)
|
|
143
237
|
|
|
144
|
-
return { text: 'Briefing
|
|
238
|
+
return { text: 'Briefing ready: rate it in the question dialog.' }
|
|
145
239
|
})
|
|
146
240
|
|
|
147
241
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|