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 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` for faster runs), and `YT_BRIEFING_PROXY` routes
65
- transcript fetches through a proxy on datacenter/VPS IPs.
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 costs no tokens of your session, and
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
 
@@ -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/rating language
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.1.0",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "The /yt briefing loop as a Claude Code pane: summary, one-key rating, research hand-off.",
5
5
  "author": {
6
6
  "name": "Michal Ryzio"
@@ -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
- const run = await $.process.run([...engine('yt-sweep'), ...(reset ? ['--reset'] : [])], { timeoutMs: SWEEP_MS })
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
- const p = v.pending
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
- v.summary,
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 ${v.lang ?? 'English'}.`,
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 $.ui.open({ id: PANE, title: 'yt-briefing', focus: true, closeOnEscape: true })
142
- void sweep($, true)
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: 'Briefing opened in a pane.' }
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) => {