yt-briefing 1.3.2 → 1.3.3

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/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
@@ -51,7 +51,8 @@
51
51
  import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, renameSync, appendFileSync } from 'node:fs';
52
52
  import { spawn } from 'node:child_process';
53
53
  import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_YOUTUBE } from "./lib/env.js";
54
- import { parseChannels, parseState, bumpStatePointer, isResolved } from "./lib/yt-lib.js";
54
+ import { parseChannels, parseState, bumpStatePointer, isResolved, pointerFor, pointerWouldRegress } from "./lib/yt-lib.js";
55
+ import { loadVideoDates, rememberVideoDates } from "./lib/video-dates.js";
55
56
  import { chat, claudeMissing } from "./lib/llm.js";
56
57
  import { outputLang } from "./lib/config.js";
57
58
  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";
@@ -105,9 +106,14 @@ function run(cmd) {
105
106
  });
106
107
  }
107
108
  // ---------- state mutation (inline, crash-safe per write) ----------
108
- function bumpState(handle, type, videoId) {
109
+ function bumpState(handle, type, videoId, publishedAt) {
109
110
  const before = readFileSync(STATE_MD, 'utf8');
110
- const after = bumpStatePointer(before, handle, type, videoId, today);
111
+ const pointer = pointerFor(parseState(before).find(r => r.handle === handle), type);
112
+ // Never rewind: re-bump the current pointer instead, so the day's session still counts.
113
+ const regress = pointer !== null && pointerWouldRegress(pointer, videoId, publishedAt, loadVideoDates());
114
+ if (regress)
115
+ log(`pointer kept ${handle} ${type} ${pointer} (not rewound to older ${videoId})`);
116
+ const after = bumpStatePointer(before, handle, type, regress ? pointer : videoId, today);
111
117
  if (after !== before)
112
118
  writeFileSync(STATE_MD, after, 'utf8');
113
119
  }
@@ -117,20 +123,23 @@ function bumpState(handle, type, videoId) {
117
123
  */
118
124
  function persistSkip(item, status) {
119
125
  if (status !== 'no_transcript')
120
- bumpState(item.channel, item.type, item.videoId);
126
+ bumpState(item.channel, item.type, item.videoId, item.publishedAt);
121
127
  }
122
128
  /**
123
- * Apply title-skips to state.md. Always bumps the pointer (a title-skip is final).
129
+ * Apply title-skips to state.md. Bumps the pointer (a title-skip is final) unless that would
130
+ * rewind it: a background fill's skips are listed against the pointer it saw when it started.
124
131
  * Idempotent — re-applying the same skip is a no-op. ONLY the foreground calls this.
125
132
  */
126
133
  function applyTitleSkips(skips) {
134
+ rememberVideoDates(skips);
127
135
  for (const s of skips) {
128
- bumpState(s.channel, s.type, s.videoId);
136
+ bumpState(s.channel, s.type, s.videoId, s.publishedAt);
129
137
  recordSkip({ stage: 'title', channel: s.channel, videoId: s.videoId, title: s.title, reason: s.reason || 'title filter' });
130
138
  }
131
139
  }
132
140
  /** Append items, skipping any videoId already queued OR already resolved (seen). */
133
141
  function enqueue(queue, add) {
142
+ rememberVideoDates(add);
134
143
  const have = new Set([...queue.items.map(i => i.videoId), ...queue.seen]);
135
144
  for (const it of add)
136
145
  if (!have.has(it.videoId)) {
@@ -341,7 +350,7 @@ async function expandChannel(ref) {
341
350
  const skips = [];
342
351
  for (const it of candidates) {
343
352
  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' });
353
+ 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
354
  else
346
355
  items.push(it);
347
356
  }
@@ -421,11 +430,7 @@ function loadQueue() {
421
430
  // ---------- advance (the lazy step) ----------
422
431
  /** state.md pointer for this item's type, or null if the channel has no row yet. */
423
432
  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;
433
+ return pointerFor(parseState(readFileSync(STATE_MD, 'utf8')).find(r => r.handle === item.channel), item.type);
429
434
  }
430
435
  /**
431
436
  * Pure-ish pipeline for one queue item: fetch transcript → substance check + summary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
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": {