yt-briefing 1.3.2 → 1.3.4

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/dist/cli.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * yt-briefing add|remove <@handle|url> add or remove channels (also list)
11
11
  * yt-briefing list list the channels you follow
12
12
  * yt-briefing sweep [--reset] advance one step; prints a JSON status line
13
+ * yt-briefing sweep --pending read-only: the video already shown and not yet rated
13
14
  * yt-briefing rate --rating 0|1 [...] record a rating for the pending video
14
15
  * yt-briefing transcribe <url|id> print a single video's transcript
15
16
  * yt-briefing search "<intent>" --channel <@handle|url> search within one channel → triage → compare
package/dist/lib/paths.js CHANGED
@@ -54,6 +54,7 @@ export const REST_FILE = join(CACHE_DIR, 'queue-rest.json');
54
54
  export const PENDING_FILE = join(CACHE_DIR, 'pending.json');
55
55
  export const PREFETCH_FILE = join(CACHE_DIR, 'prefetch.json');
56
56
  export const LOG_FILE = join(CACHE_DIR, 'sweep.log');
57
+ export const VIDEO_DATES_FILE = join(CACHE_DIR, 'video-dates.json'); // videoId → publishedAt, for the monotonic pointer
57
58
  // /yt-search (ad-hoc topic search → lazy triage → compare). All throwaway, under .cache/.
58
59
  export const SEARCH_QUEUE_FILE = join(CACHE_DIR, 'search-queue.json'); // ranked candidates + cursor
59
60
  export const SEARCH_PENDING_FILE = join(CACHE_DIR, 'search-pending.json'); // current candidate awaiting keep/skip
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Publish dates of every video the sweep has listed, so a state pointer bump can refuse to
3
+ * move back in time (see pointerWouldRegress). Kept across --reset: the pointer outlives the
4
+ * queue. Best-effort cache — a missing or corrupt file only turns the guard off.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
7
+ import { dirname } from 'node:path';
8
+ import { VIDEO_DATES_FILE } from "./paths.js";
9
+ /** Entries older than this are dropped on write; the channel listing looks back 45 days. */
10
+ const KEEP_DAYS = 120;
11
+ export function loadVideoDates() {
12
+ if (!existsSync(VIDEO_DATES_FILE))
13
+ return {};
14
+ try {
15
+ const d = JSON.parse(readFileSync(VIDEO_DATES_FILE, 'utf8'));
16
+ return d && typeof d === 'object' ? d : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ /** Merge newly seen videos into the cache (atomic write); no-op when nothing is new. */
23
+ export function rememberVideoDates(videos) {
24
+ const dates = loadVideoDates();
25
+ let changed = false;
26
+ for (const v of videos) {
27
+ if (!v.publishedAt || !Number.isFinite(Date.parse(v.publishedAt)) || dates[v.videoId] === v.publishedAt)
28
+ continue;
29
+ dates[v.videoId] = v.publishedAt;
30
+ changed = true;
31
+ }
32
+ if (!changed)
33
+ return;
34
+ const cutoff = Date.now() - KEEP_DAYS * 24 * 60 * 60 * 1000;
35
+ for (const [id, at] of Object.entries(dates))
36
+ if (Date.parse(at) < cutoff)
37
+ delete dates[id];
38
+ try {
39
+ mkdirSync(dirname(VIDEO_DATES_FILE), { recursive: true });
40
+ const tmp = `${VIDEO_DATES_FILE}.${process.pid}.tmp`;
41
+ writeFileSync(tmp, JSON.stringify(dates));
42
+ renameSync(tmp, VIDEO_DATES_FILE);
43
+ }
44
+ catch { /* best-effort: the guard is only off for dates not written */ }
45
+ }
@@ -46,6 +46,24 @@ export function parseState(content) {
46
46
  export function bumpStateFrontmatterDate(content, date) {
47
47
  return content.replace(/^updated:\s*\d{4}-\d{2}-\d{2}/m, `updated: ${date}`);
48
48
  }
49
+ /** The state pointer of one type for a channel row (null when unset or the row is missing). */
50
+ export function pointerFor(row, type) {
51
+ if (!row)
52
+ return null;
53
+ return type === 'longform' ? row.last_longform_id : type === 'short' ? row.last_short_id : row.last_live_id;
54
+ }
55
+ /**
56
+ * Would moving the pointer from `pointerId` to `videoId` move it back in time? `dates` maps
57
+ * videoIds to publishedAt. Unknown dates answer false (bump as before): only a known older
58
+ * video is refused. A stale skip applied after the pointer moved on used to rewind it, and
59
+ * the next queue listed already-rated videos again.
60
+ */
61
+ export function pointerWouldRegress(pointerId, videoId, publishedAt, dates) {
62
+ if (!pointerId || pointerId === videoId || !publishedAt)
63
+ return false;
64
+ const at = dates[pointerId];
65
+ return !!at && Date.parse(at) > Date.parse(publishedAt);
66
+ }
49
67
  /**
50
68
  * Per-video pointer bump. Updates one type's last_id for one channel.
51
69
  * Session counter increments only on first touch of the day (updated != date).
@@ -19,6 +19,7 @@ import { loadEnv } from "./lib/env.js";
19
19
  import { parseState } from "./lib/yt-lib.js";
20
20
  import { STATE_MD } from "./lib/paths.js";
21
21
  import { fetchChannelVideos } from "./lib/yt-api.js";
22
+ import { loadVideoDates, rememberVideoDates } from "./lib/video-dates.js";
22
23
  loadEnv();
23
24
  const handle = process.argv[2];
24
25
  if (!handle) {
@@ -47,6 +48,10 @@ catch (e) {
47
48
  console.error(e.message);
48
49
  process.exit(1);
49
50
  }
51
+ // Date cache read before this listing is added, so a pointer that left the window still has its
52
+ // old date; then every listed video (the pointer too) is remembered for the monotonic bump.
53
+ const videoDates = loadVideoDates();
54
+ rememberVideoDates(videos);
50
55
  const pointerByType = {
51
56
  longform: row.last_longform_id,
52
57
  short: row.last_short_id,
@@ -74,10 +79,12 @@ for (const type of ['longform', 'short', 'live']) {
74
79
  }
75
80
  // normal: find pointer's publishedAt, take everything strictly newer
76
81
  const pointerVideo = ofType.find(v => v.videoId === pointerId);
82
+ // Pointer not in the fetched window: older than the lookback, or deleted/unlisted. Its date
83
+ // from the sweep's cache keeps already-seen videos out; without one, take the whole window.
84
+ const cachedTs = Date.parse(videoDates[pointerId] ?? '');
77
85
  const cutoffTs = pointerVideo
78
86
  ? new Date(pointerVideo.publishedAt).getTime()
79
- : // pointer not in fetched window (older than --since cutoff) — take all videos of this type from response
80
- -Infinity;
87
+ : Number.isFinite(cachedTs) ? cachedTs : -Infinity;
81
88
  for (const v of ofType) {
82
89
  if (new Date(v.publishedAt).getTime() <= cutoffTs)
83
90
  continue;
package/dist/yt-rating.js CHANGED
@@ -27,7 +27,8 @@ import { spawn } from 'node:child_process';
27
27
  import { loadEnv } from "./lib/env.js";
28
28
  import { loadConfig } from "./lib/config.js";
29
29
  import { distillComment } from "./lib/distill.js";
30
- import { parseChannels, appendSkipTitle, appendNote, bumpStatePointer } from "./lib/yt-lib.js";
30
+ import { parseChannels, parseState, appendSkipTitle, appendNote, bumpStatePointer, pointerFor, pointerWouldRegress } from "./lib/yt-lib.js";
31
+ import { loadVideoDates } from "./lib/video-dates.js";
31
32
  import { CHANNELS_MD, STATE_MD, PENDING_FILE, QUEUE_FILE, profilePath } from "./lib/paths.js";
32
33
  loadEnv();
33
34
  function getArg(args, name) {
@@ -123,7 +124,14 @@ if (profileAfter !== profileBefore) {
123
124
  let stateBumped = false;
124
125
  if (!args.noState) {
125
126
  const stateBefore = readFileSync(STATE_MD, 'utf8');
126
- const stateAfter = bumpStatePointer(stateBefore, args.channel, args.type, args.id, date);
127
+ // An older video rated after a newer one of the same channel was title-skipped must not
128
+ // rewind the pointer; the date comes from the sweep's cache of listed videos.
129
+ const pointer = pointerFor(parseState(stateBefore).find(r => r.handle === args.channel), args.type);
130
+ const dates = loadVideoDates();
131
+ const publishedAt = dates[args.id] ?? (() => { const p = loadPending(); return p.videoId === args.id ? p.publishedAt : undefined; })();
132
+ // Never rewind: re-bump the current pointer instead, so the day's session still counts.
133
+ const regress = pointer !== null && pointerWouldRegress(pointer, args.id, publishedAt, dates);
134
+ const stateAfter = bumpStatePointer(stateBefore, args.channel, args.type, regress ? pointer : args.id, date);
127
135
  if (stateAfter !== stateBefore) {
128
136
  writeFileSync(STATE_MD, stateAfter, 'utf8');
129
137
  stateBumped = true;
package/dist/yt-sweep.js CHANGED
@@ -8,12 +8,14 @@
8
8
  *
9
9
  * Usage:
10
10
  * bun src/yt-sweep.ts [--reset]
11
+ * bun src/yt-sweep.ts --pending (read-only: the video already emitted, still unrated)
11
12
  * bun src/yt-sweep.ts --prefetch <videoId> (internal: detached next-video warmup)
12
13
  * bun src/yt-sweep.ts --fill (internal: detached queue builder)
13
14
  *
14
15
  * Output (stdout, single JSON line):
15
16
  * {"status":"rating_needed","summary":"<md>","pending":{channel,videoId,title,type,publishedAt,is_baseline}} (+ "lang")
16
17
  * {"status":"done"}
18
+ * {"status":"no_pending"} (--pending only: no emitted video awaits a rating)
17
19
  * {"status":"rate_limited"}
18
20
  *
19
21
  * The engine ONLY writes files under DATA_DIR — it never runs git or any VCS. If you
@@ -51,7 +53,8 @@
51
53
  import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, renameSync, appendFileSync } from 'node:fs';
52
54
  import { spawn } from 'node:child_process';
53
55
  import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_YOUTUBE } from "./lib/env.js";
54
- import { parseChannels, parseState, bumpStatePointer, isResolved } from "./lib/yt-lib.js";
56
+ import { parseChannels, parseState, bumpStatePointer, isResolved, pointerFor, pointerWouldRegress } from "./lib/yt-lib.js";
57
+ import { loadVideoDates, rememberVideoDates } from "./lib/video-dates.js";
55
58
  import { chat, claudeMissing } from "./lib/llm.js";
56
59
  import { outputLang } from "./lib/config.js";
57
60
  import { PKG_ROOT, CHANNELS_MD, STATE_MD, CACHE_DIR, QUEUE_FILE, REST_FILE, PENDING_FILE, PREFETCH_FILE, LOG_FILE, profilePath, script, } from "./lib/paths.js";
@@ -67,6 +70,7 @@ const CONCURRENCY = 6;
67
70
  const argv = process.argv.slice(2);
68
71
  const reset = argv.includes('--reset');
69
72
  const fillMode = argv.includes('--fill');
73
+ const pendingMode = argv.includes('--pending');
70
74
  const pfIdx = argv.indexOf('--prefetch');
71
75
  const prefetchTarget = pfIdx !== -1 ? argv[pfIdx + 1] : null;
72
76
  const today = new Date().toISOString().slice(0, 10);
@@ -105,9 +109,14 @@ function run(cmd) {
105
109
  });
106
110
  }
107
111
  // ---------- state mutation (inline, crash-safe per write) ----------
108
- function bumpState(handle, type, videoId) {
112
+ function bumpState(handle, type, videoId, publishedAt) {
109
113
  const before = readFileSync(STATE_MD, 'utf8');
110
- const after = bumpStatePointer(before, handle, type, videoId, today);
114
+ const pointer = pointerFor(parseState(before).find(r => r.handle === handle), type);
115
+ // Never rewind: re-bump the current pointer instead, so the day's session still counts.
116
+ const regress = pointer !== null && pointerWouldRegress(pointer, videoId, publishedAt, loadVideoDates());
117
+ if (regress)
118
+ log(`pointer kept ${handle} ${type} ${pointer} (not rewound to older ${videoId})`);
119
+ const after = bumpStatePointer(before, handle, type, regress ? pointer : videoId, today);
111
120
  if (after !== before)
112
121
  writeFileSync(STATE_MD, after, 'utf8');
113
122
  }
@@ -117,20 +126,23 @@ function bumpState(handle, type, videoId) {
117
126
  */
118
127
  function persistSkip(item, status) {
119
128
  if (status !== 'no_transcript')
120
- bumpState(item.channel, item.type, item.videoId);
129
+ bumpState(item.channel, item.type, item.videoId, item.publishedAt);
121
130
  }
122
131
  /**
123
- * Apply title-skips to state.md. Always bumps the pointer (a title-skip is final).
132
+ * Apply title-skips to state.md. Bumps the pointer (a title-skip is final) unless that would
133
+ * rewind it: a background fill's skips are listed against the pointer it saw when it started.
124
134
  * Idempotent — re-applying the same skip is a no-op. ONLY the foreground calls this.
125
135
  */
126
136
  function applyTitleSkips(skips) {
137
+ rememberVideoDates(skips);
127
138
  for (const s of skips) {
128
- bumpState(s.channel, s.type, s.videoId);
139
+ bumpState(s.channel, s.type, s.videoId, s.publishedAt);
129
140
  recordSkip({ stage: 'title', channel: s.channel, videoId: s.videoId, title: s.title, reason: s.reason || 'title filter' });
130
141
  }
131
142
  }
132
143
  /** Append items, skipping any videoId already queued OR already resolved (seen). */
133
144
  function enqueue(queue, add) {
145
+ rememberVideoDates(add);
134
146
  const have = new Set([...queue.items.map(i => i.videoId), ...queue.seen]);
135
147
  for (const it of add)
136
148
  if (!have.has(it.videoId)) {
@@ -341,7 +353,7 @@ async function expandChannel(ref) {
341
353
  const skips = [];
342
354
  for (const it of candidates) {
343
355
  if (titleSkip.has(it.videoId))
344
- skips.push({ channel: it.channel, type: it.type, videoId: it.videoId, title: it.title, reason: titleSkip.get(it.videoId) || 'title filter' });
356
+ skips.push({ channel: it.channel, type: it.type, videoId: it.videoId, title: it.title, reason: titleSkip.get(it.videoId) || 'title filter', publishedAt: it.publishedAt });
345
357
  else
346
358
  items.push(it);
347
359
  }
@@ -421,11 +433,7 @@ function loadQueue() {
421
433
  // ---------- advance (the lazy step) ----------
422
434
  /** state.md pointer for this item's type, or null if the channel has no row yet. */
423
435
  function statePointer(item) {
424
- const row = parseState(readFileSync(STATE_MD, 'utf8')).find(r => r.handle === item.channel);
425
- if (!row)
426
- return null;
427
- return item.type === 'longform' ? row.last_longform_id
428
- : item.type === 'short' ? row.last_short_id : row.last_live_id;
436
+ return pointerFor(parseState(readFileSync(STATE_MD, 'utf8')).find(r => r.handle === item.channel), item.type);
429
437
  }
430
438
  /**
431
439
  * Pure-ish pipeline for one queue item: fetch transcript → substance check + summary.
@@ -453,6 +461,13 @@ async function processItem(item) {
453
461
  return { kind: 'skip', status: 'content_skip', reason: summary.slice(9).trim() };
454
462
  return { kind: 'ratable', summary };
455
463
  }
464
+ /** The video's metadata as `rating_needed` reports it and yt-rating reads it from pending.json. */
465
+ function pendingOf(item) {
466
+ return {
467
+ channel: item.channel, videoId: item.videoId, title: item.title,
468
+ type: item.type, publishedAt: item.publishedAt, is_baseline: item.is_baseline,
469
+ };
470
+ }
456
471
  async function advance(queue) {
457
472
  while (true) {
458
473
  // Pull in the background fill (or expand a channel ourselves) until there's an
@@ -467,8 +482,8 @@ async function advance(queue) {
467
482
  dropHead(queue);
468
483
  continue;
469
484
  }
470
- // Already emitted (the plugin re-runs the sweep after the briefing turn to open the rating
471
- // dialog) or warmed by the background child? Use it and skip the live fetch + content filter.
485
+ // Already emitted (a repeat sweep before the rating) or warmed by the background child? Use it
486
+ // and skip the live fetch + content filter.
472
487
  const emitted = loadCachedSummary(PENDING_FILE, item.videoId);
473
488
  const cached = emitted ?? loadPrefetch(item.videoId);
474
489
  if (cached)
@@ -492,10 +507,7 @@ async function advance(queue) {
492
507
  }
493
508
  // Ratable — hand off to the frontend. Leave item at queue head (rating bumps state,
494
509
  // next call detects pointer === videoId and drops it).
495
- const pending = {
496
- channel: item.channel, videoId: item.videoId, title: item.title,
497
- type: item.type, publishedAt: item.publishedAt, is_baseline: item.is_baseline,
498
- };
510
+ const pending = pendingOf(item);
499
511
  writeAtomic(PENDING_FILE, { ...pending, summary: result.summary, built_at: today });
500
512
  writeFileSync(QUEUE_FILE, JSON.stringify(queue));
501
513
  // Warm the NEXT video in the background while the user rates this one. A repeat emit of the
@@ -511,6 +523,21 @@ async function advance(queue) {
511
523
  clearPrefetch();
512
524
  emit({ status: 'done' });
513
525
  }
526
+ /**
527
+ * --pending mode: the video the sweep already emitted, if it still awaits a rating. The plugin asks
528
+ * this after a briefing turn to open the rating dialog. Read-only and offline on purpose: a real
529
+ * sweep there rebuilt the queue once `done` had removed it, and the dialog then asked about a new
530
+ * video the chat never showed.
531
+ */
532
+ function runPending() {
533
+ const queue = loadQueue();
534
+ const head = queue?.items[0];
535
+ const summary = head && loadCachedSummary(PENDING_FILE, head.videoId);
536
+ if (!queue || !head || !summary || isResolved(statePointer(head), head.videoId, queue.seen)) {
537
+ emit({ status: 'no_pending' });
538
+ }
539
+ emit({ status: 'rating_needed', summary, pending: pendingOf(head), lang: LANG });
540
+ }
514
541
  /**
515
542
  * --prefetch mode: compute the summary for one specific queued video and cache it, with
516
543
  * NO side effects on state.md / queue / pending. Runs detached while the user rates the
@@ -568,6 +595,9 @@ if (prefetchTarget) {
568
595
  if (fillMode) {
569
596
  await runFill(); // detached background queue builder — never returns
570
597
  }
598
+ if (pendingMode) {
599
+ runPending(); // read-only answer for the rating dialog — never returns
600
+ }
571
601
  if (reset) {
572
602
  if (existsSync(QUEUE_FILE))
573
603
  rmSync(QUEUE_FILE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
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 the chat, and adapts to your ratings — one video at a time.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,8 +8,10 @@ import { engine } from './engine.ts'
8
8
  // a phone; the rating dialog opens once that turn has ended. Ratings and comments are written by
9
9
  // the engine, never by this module.
10
10
 
11
- /** A sweep can expand channels, fetch transcripts and summarize; give it the engine's full budget. */
11
+ /** A sweep can expand channels, fetch transcripts and summarize; the model's call gets this budget. */
12
12
  const SWEEP_MS = 10 * 60_000
13
+ /** Local files only, no network. */
14
+ const PENDING_MS = 30_000
13
15
  /** A rating is a file write; a raw comment adds one `claude -p` call to distill it. */
14
16
  const RATE_MS = 3 * 60_000
15
17
 
@@ -26,9 +28,9 @@ function parseOut(stdout: string): SweepOut {
26
28
  return JSON.parse(line) as SweepOut
27
29
  }
28
30
 
29
- /** One engine sweep: the next ratable video, or the end state. Throws when the engine does not answer. */
30
- async function runSweep($: EngineInterface): Promise<SweepOut> {
31
- const run = await $.process.run(engine('yt-sweep'), { timeoutMs: SWEEP_MS })
31
+ /** The video this run already showed and not yet rated. Never a real sweep: that could fetch a new one. */
32
+ async function shownVideo($: EngineInterface): Promise<SweepOut> {
33
+ const run = await $.process.run([...engine('yt-sweep'), '--pending'], { timeoutMs: PENDING_MS })
32
34
  return parseOut(run.stdout)
33
35
  }
34
36
 
@@ -93,7 +95,7 @@ function loopSection(): string {
93
95
  const sweep = engine('yt-sweep').join(' ')
94
96
  return [
95
97
  `The yt-briefing plugin is running the person's YouTube briefing. A user message reading exactly "${FIRST_TEXT}" or "${NEXT_TEXT}" is its request for the next video:`,
96
- `for "${FIRST_TEXT}" run \`${sweep} --reset\`, for "${NEXT_TEXT}" run \`${sweep}\`; it prints one JSON line. If \`status\` is \`rating_needed\`, answer with \`summary\` verbatim as your whole message, Markdown kept: nothing before or after it, no question, no tool call after it (the plugin opens the rating dialog itself).`,
98
+ `for "${FIRST_TEXT}" run \`${sweep} --reset\`, for "${NEXT_TEXT}" run \`${sweep}\`, in the foreground with a ${SWEEP_MS / 60_000}-minute timeout; it prints one JSON line. If \`status\` is \`rating_needed\`, answer with \`summary\` verbatim as your whole message, Markdown kept: nothing before or after it, no question, no tool call after it (the plugin opens the rating dialog itself).`,
97
99
  'Otherwise answer with one short line saying why there is nothing to rate. Write in the language of the summaries.',
98
100
  ].join('\n')
99
101
  }
@@ -110,9 +112,10 @@ function submitLater($: EngineInterface, reset: boolean) {
110
112
  async function rateAfterTurn($: EngineInterface) {
111
113
  let out: SweepOut
112
114
  try {
113
- out = await runSweep($)
114
- } catch {
115
+ out = await shownVideo($)
116
+ } catch (err) {
115
117
  looping = false
118
+ $.ui.toast(`Could not open the rating: ${String(err).slice(0, 200)}`)
116
119
  return
117
120
  }
118
121
  if (out.status !== 'rating_needed' || !out.pending || !out.summary) {