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 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.1.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
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,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 $.ui.open({ id: PANE, title: 'yt-briefing', focus: true, closeOnEscape: true })
142
- void sweep($, true)
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 opened in a pane.' }
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) => {