atris 3.80.1 → 3.82.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/autoland.js +25 -26
  21. package/commands/autopilot-front.js +46 -1
  22. package/commands/autopilot.js +55 -25
  23. package/commands/clean.js +22 -11
  24. package/commands/computer.js +1 -1
  25. package/commands/doc-health.js +48 -1
  26. package/commands/improve.js +16 -3
  27. package/commands/init.js +4 -0
  28. package/commands/integrations.js +69 -1616
  29. package/commands/mission.js +109 -13
  30. package/commands/release.js +41 -10
  31. package/commands/status.js +1 -1
  32. package/commands/sync.js +12 -8
  33. package/commands/task.js +144 -118
  34. package/commands/unknowns.js +9 -5
  35. package/commands/visualize.js +1 -12
  36. package/commands/workflow.js +4 -3
  37. package/commands/worktree.js +68 -1602
  38. package/commands/xp.js +23 -2113
  39. package/commands/youtube.js +192 -3110
  40. package/lib/business-core.js +3 -4
  41. package/lib/campaign-run-lock.js +12 -1
  42. package/lib/engine-ask.js +1 -1
  43. package/lib/engine-receipt-sweep.js +64 -0
  44. package/lib/file-lock.js +175 -0
  45. package/lib/fleet.js +1 -1
  46. package/lib/integrations-core.js +1695 -0
  47. package/lib/json-file.js +22 -1
  48. package/lib/loop-doctor.js +36 -2
  49. package/lib/map-refs.js +68 -8
  50. package/lib/member-engine.js +1 -1
  51. package/lib/member-map-fold.js +49 -11
  52. package/lib/member-map-inbox.js +2 -1
  53. package/lib/mission-core.js +9 -2
  54. package/lib/recall-exam.js +171 -0
  55. package/lib/roster-markdown.js +2 -2
  56. package/lib/runner-command.js +5 -5
  57. package/lib/task-db.js +105 -37
  58. package/lib/taste-notes.js +35 -0
  59. package/lib/team-lineup.js +3 -3
  60. package/lib/todo.js +2 -0
  61. package/lib/wiki.js +11 -3
  62. package/lib/worktree-core.js +1517 -0
  63. package/lib/xp-core.js +2172 -0
  64. package/lib/youtube-core.js +3178 -0
  65. package/lib/zen-apply.js +3 -1
  66. package/package.json +2 -2
@@ -0,0 +1,3178 @@
1
+ const applyGate = require('./apply-gate');
2
+ const { spawnSync } = require('child_process');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { stateHome } = require('./state-home');
6
+ const https = require('https');
7
+
8
+ const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [auto|gemini|haiku|atris-fast|grok|codex|cursor]';
9
+ const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
10
+ const NOTES_PLAYLIST_CAP = 10;
11
+
12
+ const DEFAULT_QUERY = [
13
+ 'Create a timestamped YouTube brief for Atris.',
14
+ 'Include: metadata, timestamped outline, core claims with confidence, memorable examples, actionable takeaways, Atris/product implications, and next actions.',
15
+ 'Use transcript timestamps whenever possible.',
16
+ ].join(' ');
17
+ const DEFAULT_TIMEOUT_MS = 300000;
18
+ const LOCAL_TRANSCRIPT_MAX_BYTES = 5 * 1024 * 1024;
19
+ const LOCAL_TRANSCRIPT_MAX_CHARS = 250000;
20
+ const ALLOWED_CAPTION_HOST_SUFFIXES = [
21
+ 'youtube.com',
22
+ 'googlevideo.com',
23
+ 'youtubei.googleapis.com',
24
+ ];
25
+
26
+ function readValue(args, index, name) {
27
+ if (index >= args.length - 1 || String(args[index + 1]).startsWith('--')) {
28
+ throw new Error(`${name} requires a value`);
29
+ }
30
+ return args[index + 1];
31
+ }
32
+
33
+ function parseTimeoutMs(raw) {
34
+ const seconds = Number(raw);
35
+ if (!Number.isFinite(seconds) || seconds <= 0) {
36
+ throw new Error('--timeout must be a positive number of seconds');
37
+ }
38
+ return Math.round(seconds * 1000);
39
+ }
40
+
41
+ function parseYoutubeArgs(argv = []) {
42
+ const args = [...argv];
43
+ const options = {
44
+ help: false,
45
+ json: false,
46
+ youtubeUrl: null,
47
+ query: DEFAULT_QUERY,
48
+ agentId: null,
49
+ storeAsKnowledge: false,
50
+ timeoutMs: DEFAULT_TIMEOUT_MS,
51
+ };
52
+
53
+ if (args.length === 0 || ['help', '--help', '-h'].includes(args[0])) {
54
+ options.help = true;
55
+ return options;
56
+ }
57
+
58
+ if (['process', 'analyze'].includes(args[0])) {
59
+ args.shift();
60
+ }
61
+
62
+ for (let i = 0; i < args.length; i++) {
63
+ const arg = args[i];
64
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
65
+ options.help = true;
66
+ } else if (arg === '--json') {
67
+ options.json = true;
68
+ } else if (arg === '--store' || arg === '--store-as-knowledge') {
69
+ options.storeAsKnowledge = true;
70
+ } else if (arg === '--query' || arg === '-q') {
71
+ options.query = readValue(args, i, arg);
72
+ i++;
73
+ } else if (arg.startsWith('--query=')) {
74
+ options.query = arg.slice('--query='.length);
75
+ } else if (arg === '--agent' || arg === '--agent-id') {
76
+ options.agentId = readValue(args, i, arg);
77
+ i++;
78
+ } else if (arg.startsWith('--agent=')) {
79
+ options.agentId = arg.slice('--agent='.length);
80
+ } else if (arg === '--timeout') {
81
+ options.timeoutMs = parseTimeoutMs(readValue(args, i, arg));
82
+ i++;
83
+ } else if (arg.startsWith('--timeout=')) {
84
+ options.timeoutMs = parseTimeoutMs(arg.slice('--timeout='.length));
85
+ } else if (arg.startsWith('-')) {
86
+ throw new Error(`Unknown option: ${arg}`);
87
+ } else if (!options.youtubeUrl) {
88
+ options.youtubeUrl = arg;
89
+ } else {
90
+ throw new Error(`Unexpected argument: ${arg}`);
91
+ }
92
+ }
93
+
94
+ if (options.help) return options;
95
+ if (!options.youtubeUrl) throw new Error('Missing YouTube URL. Run "atris youtube --help".');
96
+ if (options.storeAsKnowledge && !options.agentId) {
97
+ throw new Error('--store requires --agent <id>');
98
+ }
99
+ return options;
100
+ }
101
+
102
+ function buildYoutubePayload(options) {
103
+ const payload = {
104
+ youtube_url: options.youtubeUrl,
105
+ query: options.query || DEFAULT_QUERY,
106
+ };
107
+ if (options.agentId) payload.agent_id = options.agentId;
108
+ if (options.storeAsKnowledge) payload.store_as_knowledge = true;
109
+ if (options.localTranscript?.transcriptText) {
110
+ payload.transcript_text = options.localTranscript.transcriptText;
111
+ if (options.localTranscript.language) payload.transcript_language = options.localTranscript.language;
112
+ if (options.localTranscript.durationSeconds) payload.duration_seconds = options.localTranscript.durationSeconds;
113
+ }
114
+ if (options.cacheTranscript !== undefined) {
115
+ payload.cache_transcript = Boolean(options.cacheTranscript);
116
+ }
117
+ return payload;
118
+ }
119
+
120
+ function resultErrorText(result) {
121
+ const raw = result?.error || result?.text || 'unknown error';
122
+ if (typeof raw === 'string') return raw;
123
+ try {
124
+ return JSON.stringify(raw);
125
+ } catch {
126
+ return String(raw);
127
+ }
128
+ }
129
+
130
+ function shouldRetryWithLocalTranscript(result) {
131
+ if (!result || result.ok) return false;
132
+ if (result.status === 502) return true;
133
+ if (result.status !== 400) return false;
134
+ return /YouTube video is not publicly accessible|oEmbed|metadata lookup failed/i.test(resultErrorText(result));
135
+ }
136
+
137
+ function youtubeFailureError(result) {
138
+ const hint = result.status === 401
139
+ ? ' Run "atris login --force".'
140
+ : result.status === 402
141
+ ? ' Check Atris credits.'
142
+ : result.status === 502
143
+ ? ' YouTube processing is unavailable; retry in a few seconds.'
144
+ : '';
145
+ const credits = paidSearchCredits(result.data);
146
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
147
+ ? ' credits refunded.'
148
+ : '';
149
+ const lines = [`YouTube processing failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
150
+ lines.push(...formatCreditsLines(credits));
151
+ return new Error(lines.join('\n'));
152
+ }
153
+
154
+ function captionHostAllowed(urlString) {
155
+ try {
156
+ const parsed = new URL(urlString);
157
+ if (parsed.protocol !== 'https:') return false;
158
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/, '');
159
+ return ALLOWED_CAPTION_HOST_SUFFIXES.some((suffix) => (
160
+ hostname === suffix || hostname.endsWith(`.${suffix}`)
161
+ ));
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ // Caption tracks in the order to try them. Human subtitles first, then the
168
+ // auto track in the language actually spoken ("en-orig", "es-orig"). YouTube's
169
+ // machine-translated tracks (plain "en" on most English videos) are rate
170
+ // limited with HTTP 429, so they only come after every real track.
171
+ function captionTrackCandidates(info = {}) {
172
+ const candidates = [];
173
+ const add = (trackSets = {}, languages) => {
174
+ for (const language of languages) {
175
+ if (language === 'live_chat') continue;
176
+ const tracks = (trackSets[language] || []).filter((track) => track?.url);
177
+ const track = tracks.find((t) => ['json3', 'vtt', 'srv3', 'ttml'].includes(t.ext)) || tracks[0];
178
+ if (track && !candidates.some((c) => c.track === track)) candidates.push({ language, track });
179
+ }
180
+ };
181
+ const manual = info.subtitles || {};
182
+ const auto = info.automatic_captions || {};
183
+ add(manual, ['en', 'en-US', 'en-GB', ...Object.keys(manual).filter((l) => l.startsWith('en-'))]);
184
+ add(manual, Object.keys(manual));
185
+ add(auto, ['en-orig', ...Object.keys(auto).filter((l) => l.endsWith('-orig'))]);
186
+ add(auto, ['en', 'en-US', 'en-GB']);
187
+ return candidates;
188
+ }
189
+
190
+ function formatTimestampFromMs(ms) {
191
+ const totalSeconds = Math.max(0, Math.floor(Number(ms || 0) / 1000));
192
+ const hours = Math.floor(totalSeconds / 3600);
193
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
194
+ const seconds = totalSeconds % 60;
195
+ const two = (value) => String(value).padStart(2, '0');
196
+ return hours > 0
197
+ ? `${two(hours)}:${two(minutes)}:${two(seconds)}`
198
+ : `${two(minutes)}:${two(seconds)}`;
199
+ }
200
+
201
+ function timestampedCaptionLine(text, startMs) {
202
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
203
+ if (!clean) return '';
204
+ if (!Number.isFinite(Number(startMs))) return clean;
205
+ return `[${formatTimestampFromMs(startMs)}] ${clean}`;
206
+ }
207
+
208
+ function parseVttTimestampMs(value) {
209
+ const match = String(value || '').match(/(?:(\d{2}):)?(\d{2}):(\d{2})\.(\d{3})/);
210
+ if (!match) return null;
211
+ const hours = Number(match[1] || 0);
212
+ const minutes = Number(match[2] || 0);
213
+ const seconds = Number(match[3] || 0);
214
+ const millis = Number(match[4] || 0);
215
+ return ((hours * 3600) + (minutes * 60) + seconds) * 1000 + millis;
216
+ }
217
+
218
+ function parseCleanTimestampMs(value) {
219
+ const match = String(value || '').trim().match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/);
220
+ if (!match) return null;
221
+ const hours = Number(match[1] || 0);
222
+ const minutes = Number(match[2] || 0);
223
+ const seconds = Number(match[3] || 0);
224
+ if (![hours, minutes, seconds].every(Number.isFinite)) return null;
225
+ if (minutes > 59 || seconds > 59) return null;
226
+ return ((hours * 3600) + (minutes * 60) + seconds) * 1000;
227
+ }
228
+
229
+ function parseCleanTranscriptCues(raw) {
230
+ const cues = [];
231
+ let startMs = 0;
232
+ let sawStamp = false;
233
+ const pending = [];
234
+ const flush = () => {
235
+ const text = pending.join(' ').replace(/\s+/g, ' ').trim();
236
+ pending.length = 0;
237
+ if (!text) return;
238
+ const cue = { startMs, text };
239
+ if (cues.length && cues[cues.length - 1].text === cue.text && cues[cues.length - 1].startMs === cue.startMs) {
240
+ return;
241
+ }
242
+ cues.push(cue);
243
+ };
244
+ for (const line of String(raw).split(/\r?\n/)) {
245
+ const stripped = line.trim();
246
+ if (!stripped) continue;
247
+ const stamp = stripped.match(/^\[(\d{1,2}:\d{2}(?::\d{2})?)\]$/);
248
+ if (stamp) {
249
+ flush();
250
+ const parsed = parseCleanTimestampMs(stamp[1]);
251
+ if (parsed != null) {
252
+ startMs = parsed;
253
+ sawStamp = true;
254
+ }
255
+ continue;
256
+ }
257
+ pending.push(stripped.replace(/<[^>]+>/g, ''));
258
+ }
259
+ flush();
260
+ return sawStamp && cues.length ? cues : [];
261
+ }
262
+
263
+ function fetchCaptionText(urlString, redirects = 0) {
264
+ if (!captionHostAllowed(urlString)) {
265
+ return Promise.resolve(null);
266
+ }
267
+
268
+ return new Promise((resolve) => {
269
+ const req = https.get(urlString, { timeout: 30000 }, (res) => {
270
+ if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && redirects < 3) {
271
+ res.resume();
272
+ const nextUrl = new URL(res.headers.location, urlString).toString();
273
+ resolve(fetchCaptionText(nextUrl, redirects + 1));
274
+ return;
275
+ }
276
+
277
+ if (res.statusCode !== 200 || !captionHostAllowed(res.responseUrl || urlString)) {
278
+ res.resume();
279
+ resolve(null);
280
+ return;
281
+ }
282
+
283
+ const contentLength = Number(res.headers['content-length'] || 0);
284
+ if (contentLength > LOCAL_TRANSCRIPT_MAX_BYTES) {
285
+ res.resume();
286
+ resolve(null);
287
+ return;
288
+ }
289
+
290
+ const chunks = [];
291
+ let total = 0;
292
+ res.on('data', (chunk) => {
293
+ total += chunk.length;
294
+ if (total > LOCAL_TRANSCRIPT_MAX_BYTES) {
295
+ req.destroy();
296
+ resolve(null);
297
+ return;
298
+ }
299
+ chunks.push(chunk);
300
+ });
301
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
302
+ });
303
+ req.on('timeout', () => req.destroy());
304
+ req.on('error', () => resolve(null));
305
+ });
306
+ }
307
+
308
+ function parseCaptionCues(raw) {
309
+ const trimmed = String(raw || '').trimStart();
310
+ if (!trimmed) return [];
311
+
312
+ if (trimmed.startsWith('{')) {
313
+ try {
314
+ const payload = JSON.parse(trimmed);
315
+ const cues = [];
316
+ for (const event of payload.events || []) {
317
+ const text = (event.segs || [])
318
+ .map((piece) => piece.utf8 || '')
319
+ .join('')
320
+ .replace(/\s+/g, ' ')
321
+ .trim();
322
+ if (!text) continue;
323
+ const startMs = Number(event.tStartMs);
324
+ const cue = { startMs: Number.isFinite(startMs) ? startMs : 0, text };
325
+ if (cues.length && cues[cues.length - 1].text === cue.text && cues[cues.length - 1].startMs === cue.startMs) {
326
+ continue;
327
+ }
328
+ cues.push(cue);
329
+ }
330
+ return cues;
331
+ } catch {
332
+ return [];
333
+ }
334
+ }
335
+
336
+ if (/^WEBVTT/i.test(trimmed) || trimmed.includes('-->')) {
337
+ const cues = [];
338
+ for (const block of String(raw).split(/\r?\n\r?\n+/)) {
339
+ const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
340
+ const timeLine = lines.find((line) => line.includes('-->'));
341
+ if (!timeLine) continue;
342
+ const startMs = parseVttTimestampMs(timeLine.split('-->')[0]);
343
+ const text = lines.slice(lines.indexOf(timeLine) + 1)
344
+ .filter((line) => !/^(NOTE|STYLE|REGION|Kind:|Language:)/.test(line))
345
+ .map((line) => line.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim())
346
+ .filter(Boolean)
347
+ .filter((line, index, all) => index === 0 || line !== all[index - 1])
348
+ .join(' ')
349
+ .trim();
350
+ if (!text) continue;
351
+ const cue = { startMs: startMs == null ? 0 : startMs, text };
352
+ if (cues.length && cues[cues.length - 1].text === cue.text && cues[cues.length - 1].startMs === cue.startMs) {
353
+ continue;
354
+ }
355
+ cues.push(cue);
356
+ }
357
+ return cues;
358
+ }
359
+
360
+ return parseCleanTranscriptCues(raw);
361
+ }
362
+
363
+ function parseCaptionText(raw) {
364
+ const trimmed = String(raw || '').trimStart();
365
+ if (!trimmed) return '';
366
+
367
+ const cues = parseCaptionCues(raw);
368
+ if (cues.length) {
369
+ const segments = [];
370
+ for (const cue of cues) {
371
+ const captionLine = timestampedCaptionLine(cue.text, cue.startMs);
372
+ if (!captionLine || segments[segments.length - 1] === captionLine) continue;
373
+ segments.push(captionLine);
374
+ }
375
+ if (segments.length) return segments.join('\n');
376
+ }
377
+
378
+ const segments = [];
379
+ for (const line of String(raw).split(/\r?\n/)) {
380
+ const stripped = line.trim();
381
+ if (!stripped) continue;
382
+ if (/^(WEBVTT|Kind:|Language:|NOTE|STYLE|REGION)/.test(stripped)) continue;
383
+ if (stripped.includes('-->')) continue;
384
+ if (/^\d+$/.test(stripped)) continue;
385
+ if (stripped.includes('<c>') || stripped.includes('</c>')) continue;
386
+ const text = stripped.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
387
+ if (!text) continue;
388
+ if (segments[segments.length - 1] === text) continue;
389
+ segments.push(text);
390
+ }
391
+ return segments.join(' ');
392
+ }
393
+
394
+ function parseYtDlpInfoJson(result) {
395
+ const kept = [];
396
+ for (const line of String((result && result.stdout) || '').split(/\r?\n/)) {
397
+ const trimmed = line.trim();
398
+ if (!trimmed) continue;
399
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
400
+ kept.push(trimmed);
401
+ }
402
+ const raw = kept.join('\n').trim();
403
+ if (!raw) return null;
404
+ try {
405
+ const info = JSON.parse(raw);
406
+ return info && typeof info === 'object' && !Array.isArray(info) ? info : null;
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ function localCaptionNames(id) {
413
+ return [
414
+ `yt_${id}.en.vtt`,
415
+ `yt_${id}.en-orig.vtt`,
416
+ `yt_${id}.en-US.vtt`,
417
+ `yt_${id}.en-GB.vtt`,
418
+ `yt_${id}.local.vtt`,
419
+ `yt_${id}.clean.txt`,
420
+ ];
421
+ }
422
+
423
+ function captionLookupIds({ url, id } = {}) {
424
+ const ids = [];
425
+ const seen = new Set();
426
+ const add = (value) => {
427
+ const text = String(value || '').trim();
428
+ if (!text || seen.has(text)) return;
429
+ seen.add(text);
430
+ ids.push(text);
431
+ };
432
+ add(id);
433
+ add(videoIdFromUrl(url));
434
+ return ids;
435
+ }
436
+
437
+ function readLocalCaptionText({ url, id, workDir } = {}) {
438
+ if (!workDir) return '';
439
+ for (const videoId of captionLookupIds({ url, id })) {
440
+ for (const name of localCaptionNames(videoId)) {
441
+ const abs = path.join(workDir, name);
442
+ try {
443
+ if (!fs.existsSync(abs)) continue;
444
+ const text = fs.readFileSync(abs, 'utf8');
445
+ if (String(text).trim()) return text;
446
+ } catch {
447
+ // try the next written caption file
448
+ }
449
+ }
450
+ }
451
+ return '';
452
+ }
453
+
454
+ async function loadCaptionRaw(info, youtubeUrl, deps = {}) {
455
+ const payload = info && typeof info === 'object' ? info : {};
456
+ const fetchText = deps.fetchCaptionText || fetchCaptionText;
457
+ const candidates = captionTrackCandidates(payload).slice(0, 4);
458
+ for (const candidate of candidates) {
459
+ const raw = await fetchText(candidate.track.url);
460
+ if (String(raw || '').trim()) return { raw, language: candidate.language };
461
+ }
462
+ const selected = candidates[0];
463
+ const local = readLocalCaptionText({
464
+ url: youtubeUrl,
465
+ id: payload.id,
466
+ workDir: deps.workDir,
467
+ });
468
+ if (!String(local || '').trim()) return null;
469
+ return { raw: local, language: selected?.language || 'en' };
470
+ }
471
+
472
+ function ytDlpInfoArgs(youtubeUrl) {
473
+ return ['-J', '--skip-download', '--no-warnings', '--no-playlist', youtubeUrl];
474
+ }
475
+
476
+ function teachSourceVideoId(info, youtubeUrl) {
477
+ const fromUrl = videoIdFromUrl(youtubeUrl);
478
+ const fromInfo = info && info.id;
479
+ const entries = info && Array.isArray(info.entries) ? info.entries : [];
480
+ const fromEntry = entries[0] && entries[0].id;
481
+ if (info && (info._type === 'playlist' || entries.length)) {
482
+ return fromUrl || fromEntry || fromInfo;
483
+ }
484
+ return fromInfo || fromUrl;
485
+ }
486
+
487
+ async function extractLocalTranscript(youtubeUrl, deps = {}) {
488
+ if (process.env.ATRIS_YOUTUBE_LOCAL_TRANSCRIPT === '0') return null;
489
+ const runner = deps.spawnSync || spawnSync;
490
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
491
+ encoding: 'utf8',
492
+ timeout: 20000,
493
+ maxBuffer: 64 * 1024 * 1024,
494
+ });
495
+ const info = parseYtDlpInfoJson(result);
496
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
497
+ const transcript = parseCaptionText(loaded && loaded.raw);
498
+ if (!transcript) return null;
499
+
500
+ return {
501
+ transcriptText: transcript.slice(0, LOCAL_TRANSCRIPT_MAX_CHARS),
502
+ language: loaded.language || 'unknown',
503
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
504
+ };
505
+ }
506
+
507
+ function processAnalysisText(data) {
508
+ return data?.video_analysis || data?.analysis || data?.result || '';
509
+ }
510
+
511
+ function printProcessLearnerGate(data, { json = false } = {}, output) {
512
+ printLearnerCheckGate(output, notesLessonFromText(processAnalysisText(data)), {
513
+ includeCheck: true,
514
+ json,
515
+ });
516
+ }
517
+
518
+ function formatYoutubeResult(data) {
519
+ const lines = [];
520
+ const metadata = data?.metadata || {};
521
+ lines.push(data?.message || 'YouTube video processed successfully');
522
+ if (metadata.title) lines.push(`Title: ${metadata.title}`);
523
+ if (metadata.channel) lines.push(`Channel: ${metadata.channel}`);
524
+ if (metadata.duration_seconds) lines.push(`Duration: ${formatTimestampFromMs(Number(metadata.duration_seconds) * 1000)}`);
525
+ if (metadata.processing_method || metadata.transcript_source) {
526
+ const method = metadata.processing_method || metadata.transcript_source;
527
+ const source = metadata.transcript_source && metadata.transcript_source !== method
528
+ ? ` via ${metadata.transcript_source}`
529
+ : '';
530
+ lines.push(`Processing: ${method}${source}`);
531
+ }
532
+ const creditLines = formatCreditsLines(paidSearchCredits(data));
533
+ if (creditLines.length) lines.push(...creditLines);
534
+ const analysis = processAnalysisText(data);
535
+ if (analysis) {
536
+ lines.push('');
537
+ lines.push(String(analysis).trim());
538
+ }
539
+ return lines.join('\n');
540
+ }
541
+
542
+ function videoIdFromUrl(url) {
543
+ const text = String(url || '').split('#')[0];
544
+ const watch = text.match(/[?&]v=([^&]+)/);
545
+ if (watch) return watch[1];
546
+ const short = text.match(/youtu\.be\/([^?&/]+)/);
547
+ if (short) return short[1];
548
+ const pathId = text.match(/\/(?:shorts|embed|live|v|e)\/([^?&/]+)/i);
549
+ return pathId ? pathId[1] : null;
550
+ }
551
+
552
+ function videoIdFromArg(arg) {
553
+ const fromUrl = videoIdFromUrl(arg);
554
+ if (fromUrl) return fromUrl;
555
+ const text = String(arg || '').trim();
556
+ if (/^[A-Za-z0-9_-]{6,}$/.test(text)) return text;
557
+ return null;
558
+ }
559
+
560
+ function looksLikeYoutubeUrl(arg) {
561
+ const text = String(arg || '').trim();
562
+ if (!text || text.startsWith('-')) return false;
563
+ return /youtube\.com|youtu\.be|youtube-nocookie\.com/i.test(text);
564
+ }
565
+
566
+ function isPlaylistUrl(url) {
567
+ const text = String(url || '');
568
+ return /[?&]list=/.test(text) || /\/playlist(?:\?|$|\/)/i.test(text);
569
+ }
570
+
571
+ function parseNotesArgs(argv = []) {
572
+ const urls = [];
573
+ let engine = null;
574
+ let help = false;
575
+ let save = false;
576
+ let unsave = false;
577
+ let json = false;
578
+ for (const raw of argv) {
579
+ const arg = String(raw);
580
+ if (arg === '--help' || arg === '-h' || arg === 'help') help = true;
581
+ else if (arg === '--save') save = true;
582
+ else if (arg === '--unsave') unsave = true;
583
+ else if (arg === '--json') json = true;
584
+ }
585
+ for (const raw of argv) {
586
+ const arg = String(raw);
587
+ if (arg === '--help' || arg === '-h' || arg === 'help') continue;
588
+ if (arg === '--save' || arg === '--unsave' || arg === '--json') continue;
589
+ if (arg.startsWith('-')) continue;
590
+ if (looksLikeYoutubeUrl(arg)) urls.push(arg);
591
+ else if (unsave && videoIdFromArg(arg)) urls.push(arg);
592
+ else engine = arg;
593
+ }
594
+ return { urls, engine, help, save, unsave, json };
595
+ }
596
+
597
+ function dateStamp(now) {
598
+ if (typeof now === 'string' && /^\d{4}-\d{2}-\d{2}/.test(now)) {
599
+ return now.slice(0, 10);
600
+ }
601
+ const value = now instanceof Date ? now : new Date(now || Date.now());
602
+ const day = Number.isNaN(value.getTime()) ? new Date() : value;
603
+ return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`;
604
+ }
605
+
606
+ function firstHeading(notes) {
607
+ const match = String(notes || '').replace(/\r\n/g, '\n').match(/^#{1,6}\s+(.+)$/m);
608
+ return match ? match[1].trim() : '';
609
+ }
610
+
611
+ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
612
+ try {
613
+ const id = videoIdFromUrl(url);
614
+ if (!id) return;
615
+ const notesPath = path.join(workDir, `yt_${id}.md`);
616
+ if (!fs.existsSync(notesPath)) return;
617
+ const notes = fs.readFileSync(notesPath, 'utf8');
618
+ const wikiDir = path.join(cwd, 'atris', 'wiki');
619
+ if (!fs.existsSync(wikiDir)) return;
620
+
621
+ const heading = firstHeading(notes);
622
+ const date = dateStamp(now);
623
+ const header = [
624
+ heading.toLowerCase(),
625
+ '',
626
+ `date: ${date}`,
627
+ `source: ${url}`,
628
+ 'rail: atris youtube notes, quotes repaired against the transcript',
629
+ ].join('\n');
630
+ const briefsDir = path.join(wikiDir, 'briefs');
631
+ fs.mkdirSync(briefsDir, { recursive: true });
632
+ const relBrief = `atris/wiki/briefs/youtube-${id}.md`;
633
+ fs.writeFileSync(path.join(cwd, relBrief), `${header}\n${notes}`);
634
+
635
+ const year = date.slice(0, 4);
636
+ const journalPath = path.join(cwd, 'atris', 'logs', year, `${date}.md`);
637
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
638
+ let existing = '';
639
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
640
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
641
+ const line = `- [claimable] watched: ${heading} -> ${relBrief}`;
642
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
643
+
644
+ console.log(`brief filed: ${relBrief}`);
645
+ return relBrief;
646
+ } catch {
647
+ // notes filing must never break the youtube command
648
+ }
649
+ }
650
+
651
+ const APPLY_NEXT_MESSAGE =
652
+ 'next: write one apply (change + receipt) before process.';
653
+ const PROCESS_APPLY_MESSAGE =
654
+ 'write one apply (change + receipt) before process.';
655
+
656
+ function applySidecarRel(id) {
657
+ return applyGate.applySidecarRel('youtube', id);
658
+ }
659
+
660
+ function notesApplyRel(id) {
661
+ return applyGate.applySidecarRel('notes', experimentIdToken(id));
662
+ }
663
+
664
+ function notesExperimentSlug(id) {
665
+ return `notes-${experimentIdToken(id)}`;
666
+ }
667
+
668
+ function notesExperimentRel(id) {
669
+ return `atris/experiments/${notesExperimentSlug(id)}`;
670
+ }
671
+
672
+ function notesLessonFromText(text) {
673
+ const body = String(text || '');
674
+ return {
675
+ numbers: extractTeachNumbers(body),
676
+ mechanisms: extractTeachMechanisms(body),
677
+ };
678
+ }
679
+
680
+ function readNotesText({ url, workDir } = {}) {
681
+ const id = videoIdFromUrl(url);
682
+ if (!id || !workDir) return '';
683
+ const notesPath = path.join(workDir, `yt_${id}.md`);
684
+ try {
685
+ if (!fs.existsSync(notesPath)) return '';
686
+ return fs.readFileSync(notesPath, 'utf8');
687
+ } catch {
688
+ return '';
689
+ }
690
+ }
691
+
692
+ function notesWorkDir(deps = {}) {
693
+ return deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
694
+ }
695
+
696
+ function keptPrintedNotes({ url, workDir } = {}) {
697
+ return Boolean(String(readNotesText({ url, workDir }) || '').trim());
698
+ }
699
+
700
+ function saveRichNotes(url, deps = {}) {
701
+ const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
702
+ const lesson = notesLessonFromText(readNotesText({ url, workDir }));
703
+ if (isThinTeachLesson(lesson)) {
704
+ return { thin: true, brief: null, packRel: null, lesson };
705
+ }
706
+ const brief = fileNotesBrief(url, deps);
707
+ const id = videoIdFromUrl(url);
708
+ const packRel = fileTeachExperiment({
709
+ cwd: deps.cwd || process.cwd(),
710
+ url,
711
+ lesson,
712
+ slug: id ? notesExperimentSlug(id) : null,
713
+ applyRel: id ? notesApplyRel(id) : null,
714
+ });
715
+ return { thin: false, brief, packRel, lesson };
716
+ }
717
+
718
+ function ensureNotesApply({ cwd, url, packRel, now, output } = {}) {
719
+ const id = videoIdFromUrl(url);
720
+ const pack = packRel || (id ? notesExperimentRel(id) : null);
721
+ const slug = pack ? path.basename(pack) : null;
722
+ return applyGate.ensureApply({
723
+ cwd,
724
+ source: url,
725
+ rel: id ? notesApplyRel(id) : null,
726
+ now,
727
+ output,
728
+ incompleteMessage: slug
729
+ ? `next: atris experiments keep ${slug}`
730
+ : APPLY_NEXT_MESSAGE,
731
+ required: false,
732
+ change: pack ? `apply ${pack}` : undefined,
733
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
734
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
735
+ });
736
+ }
737
+
738
+ function youtubeBriefRel(id) {
739
+ return `atris/wiki/briefs/youtube-${id}.md`;
740
+ }
741
+
742
+ function removeUnsaveRel(cwd, rel, removed) {
743
+ const abs = path.join(cwd, rel);
744
+ try {
745
+ if (!fs.existsSync(abs)) return;
746
+ const st = fs.lstatSync(abs);
747
+ if (st.isDirectory()) fs.rmSync(abs, { recursive: true, force: true });
748
+ else fs.unlinkSync(abs);
749
+ removed.push(rel);
750
+ } catch {
751
+ // already gone or unreadable: do not error
752
+ }
753
+ }
754
+
755
+ function listTeachSectionNumbers(cwd, id) {
756
+ const root = path.join(cwd, 'atris', 'experiments');
757
+ const sections = [];
758
+ try {
759
+ if (!fs.existsSync(root)) return sections;
760
+ for (const name of fs.readdirSync(root)) {
761
+ const match = String(name).match(/^teach-.+-s(\d+)$/);
762
+ if (!match) continue;
763
+ const section = Number(match[1]);
764
+ if (name === teachExperimentSlug(id, section)) sections.push(section);
765
+ }
766
+ } catch {
767
+ // missing experiments dir is fine
768
+ }
769
+ return sections.sort((a, b) => a - b);
770
+ }
771
+
772
+ function listTeachSidecarRels(cwd, id) {
773
+ const briefsDir = path.join(cwd, 'atris', 'wiki', 'briefs');
774
+ const prefix = `youtube-${id}-s`;
775
+ try {
776
+ if (!fs.existsSync(briefsDir)) return [];
777
+ return fs.readdirSync(briefsDir)
778
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.md'))
779
+ .sort((a, b) => a.localeCompare(b, 'en'))
780
+ .map((name) => `atris/wiki/briefs/${name}`);
781
+ } catch {
782
+ return [];
783
+ }
784
+ }
785
+
786
+ function unsaveYoutubeNotes(target, deps = {}) {
787
+ const output = deps.output || ((line = '') => console.log(line));
788
+ const cwd = deps.cwd || process.cwd();
789
+ const id = videoIdFromArg(target);
790
+ if (!id) {
791
+ output('usage: atris youtube unsave <url-or-id>');
792
+ return 2;
793
+ }
794
+ const briefRel = youtubeBriefRel(id);
795
+ const applyRel = applySidecarRel(id);
796
+ const sections = listTeachSectionNumbers(cwd, id);
797
+ const rels = [];
798
+ const seen = new Set();
799
+ const add = (rel) => {
800
+ if (!rel || seen.has(rel)) return;
801
+ seen.add(rel);
802
+ rels.push(rel);
803
+ };
804
+ add(briefRel);
805
+ add(applyRel);
806
+ add(notesApplyRel(id));
807
+ for (const section of sections) {
808
+ add(teachBriefRel(id, section));
809
+ add(applySidecarRel(`${id}-s${section}`));
810
+ }
811
+ for (const rel of listTeachSidecarRels(cwd, id)) add(rel);
812
+ add(notesExperimentRel(id));
813
+ add(processApplyRel(id));
814
+ add(processExperimentRel(id));
815
+ for (const section of sections) add(teachExperimentRel(id, section));
816
+
817
+ const removed = [];
818
+ for (const rel of rels) removeUnsaveRel(cwd, rel, removed);
819
+ if (!removed.length) {
820
+ output(`already gone: ${briefRel} and ${applyRel}`);
821
+ return 0;
822
+ }
823
+ output(`removed ${removed.join(' and ')}`);
824
+ return 0;
825
+ }
826
+
827
+ function ensureProcessApply({ cwd, url, now, output } = {}) {
828
+ const id = videoIdFromUrl(url);
829
+ const rel = id ? applySidecarRel(id) : null;
830
+ const code = applyGate.ensureApply({
831
+ cwd,
832
+ source: url,
833
+ rel,
834
+ now,
835
+ output,
836
+ incompleteMessage: PROCESS_APPLY_MESSAGE,
837
+ required: true,
838
+ human: true,
839
+ });
840
+ if (code !== 0 && rel) {
841
+ const print = typeof output === 'function' ? output : (line = '') => console.error(line);
842
+ const exists = Boolean(cwd) && fs.existsSync(path.join(cwd, rel));
843
+ print(exists
844
+ ? `next: fill change: and receipt: in ${rel}, then rerun process`
845
+ : `next: create ${rel} with change: and receipt: lines, then rerun process`);
846
+ }
847
+ return code;
848
+ }
849
+
850
+ function processExperimentSlug(id) {
851
+ return `process-${experimentIdToken(id)}`;
852
+ }
853
+
854
+ function processExperimentRel(id) {
855
+ return `atris/experiments/${processExperimentSlug(id)}`;
856
+ }
857
+
858
+ function processApplyRel(id) {
859
+ return applyGate.applySidecarRel('process', experimentIdToken(id));
860
+ }
861
+
862
+ function saveRichProcess({ cwd, url, lesson } = {}) {
863
+ if (isThinTeachLesson(lesson)) {
864
+ return { thin: true, packRel: null, lesson };
865
+ }
866
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
867
+ const id = videoIdFromUrl(url);
868
+ const packRel = fileTeachExperiment({
869
+ cwd,
870
+ url,
871
+ lesson,
872
+ slug: id ? processExperimentSlug(id) : null,
873
+ applyRel: id ? processApplyRel(id) : null,
874
+ });
875
+ return { thin: false, packRel, lesson, source: url };
876
+ }
877
+
878
+ function ensureProcessLearnerApply({ cwd, url, packRel, now, output, source } = {}) {
879
+ const id = videoIdFromUrl(url);
880
+ const pack = packRel || (id ? processExperimentRel(id) : null);
881
+ const slug = pack ? path.basename(pack) : null;
882
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
883
+ return applyGate.ensureApply({
884
+ cwd,
885
+ source: source || url || (id ? `process:${id}` : 'process'),
886
+ rel: id ? processApplyRel(id) : null,
887
+ now,
888
+ output,
889
+ incompleteMessage: slug
890
+ ? `next: atris experiments keep ${slug}`
891
+ : applyGate.ephemeralApplyMessage('process'),
892
+ required: false,
893
+ change: pack ? `apply ${pack}` : undefined,
894
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
895
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
896
+ });
897
+ }
898
+
899
+ function mintRichProcess({ cwd, url, data, now, output, ensureApply, json } = {}) {
900
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
901
+ const lesson = notesLessonFromText(processAnalysisText(data));
902
+ if (isThinTeachLesson(lesson)) {
903
+ printProcessLearnerGate(data, { json }, print);
904
+ return 0;
905
+ }
906
+ const saved = saveRichProcess({ cwd, url, lesson });
907
+ const applyFn = ensureApply || ensureProcessLearnerApply;
908
+ const applyCode = applyFn({
909
+ cwd,
910
+ url,
911
+ packRel: saved.packRel,
912
+ now,
913
+ output: print,
914
+ source: url,
915
+ });
916
+ if (ensureApply) return applyCode;
917
+ const id = videoIdFromUrl(url);
918
+ const baseline = proveSavedLearnerBaseline({
919
+ cwd,
920
+ applyRel: id ? processApplyRel(id) : null,
921
+ lesson: saved.lesson,
922
+ output: print,
923
+ json,
924
+ });
925
+ if (baseline !== 0) return baseline;
926
+ return applyCode;
927
+ }
928
+
929
+ const DIGEST_ENGINE_TIMEOUT_MS = 240000;
930
+ const DEFAULT_DIGEST_DAYS = 7;
931
+
932
+ function addUtcDays(stamp, delta) {
933
+ const [year, month, day] = String(stamp || '').split('-').map(Number);
934
+ const value = new Date(Date.UTC(year, month - 1, day + Number(delta || 0)));
935
+ return value.toISOString().slice(0, 10);
936
+ }
937
+
938
+ function parseBriefDate(text) {
939
+ const match = String(text || '').match(/^date:\s*(\d{4}-\d{2}-\d{2})\s*$/m);
940
+ return match ? match[1] : null;
941
+ }
942
+
943
+ function isVideoBriefText(text) {
944
+ return /^source:\s*http/m.test(String(text || ''));
945
+ }
946
+
947
+ function briefTitleLine(text) {
948
+ const heading = firstHeading(text);
949
+ if (heading) return heading;
950
+ const first = String(text || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
951
+ return first || '';
952
+ }
953
+
954
+ function dateInDigestWindow(dateStr, now, days) {
955
+ if (!dateStr) return false;
956
+ const today = dateStamp(now);
957
+ const start = addUtcDays(today, -(Number(days) - 1));
958
+ return dateStr >= start && dateStr <= today;
959
+ }
960
+
961
+ function parseDigestArgs(argv = []) {
962
+ const args = [...argv];
963
+ const options = { help: false, days: DEFAULT_DIGEST_DAYS };
964
+ for (let i = 0; i < args.length; i++) {
965
+ const arg = args[i];
966
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
967
+ options.help = true;
968
+ } else if (arg === '--days') {
969
+ const raw = args[i + 1];
970
+ const value = Number(raw);
971
+ if (raw == null || String(raw).startsWith('--') || !Number.isInteger(value) || value <= 0) {
972
+ throw new Error('--days must be a positive integer');
973
+ }
974
+ options.days = value;
975
+ i += 1;
976
+ } else if (arg.startsWith('--days=')) {
977
+ const value = Number(arg.slice('--days='.length));
978
+ if (!Number.isInteger(value) || value <= 0) {
979
+ throw new Error('--days must be a positive integer');
980
+ }
981
+ options.days = value;
982
+ } else if (arg.startsWith('-')) {
983
+ throw new Error(`Unknown option: ${arg}`);
984
+ } else {
985
+ throw new Error(`Unexpected argument: ${arg}`);
986
+ }
987
+ }
988
+ return options;
989
+ }
990
+
991
+ function collectVideoBriefs({ cwd, now, days } = {}) {
992
+ const root = cwd || process.cwd();
993
+ const briefsDir = path.join(root, 'atris', 'wiki', 'briefs');
994
+ if (!fs.existsSync(briefsDir)) return [];
995
+
996
+ const rows = [];
997
+ for (const name of fs.readdirSync(briefsDir).sort()) {
998
+ if (!name.endsWith('.md') || name.startsWith('digest-')) continue;
999
+ const abs = path.join(briefsDir, name);
1000
+ let body = '';
1001
+ try {
1002
+ if (!fs.statSync(abs).isFile()) continue;
1003
+ body = fs.readFileSync(abs, 'utf8');
1004
+ } catch {
1005
+ continue;
1006
+ }
1007
+ if (!isVideoBriefText(body)) continue;
1008
+ const date = parseBriefDate(body);
1009
+ if (!dateInDigestWindow(date, now, days)) continue;
1010
+ rows.push({
1011
+ name,
1012
+ relPath: `atris/wiki/briefs/${name}`,
1013
+ title: briefTitleLine(body),
1014
+ date,
1015
+ body,
1016
+ });
1017
+ }
1018
+ rows.sort((a, b) => (a.date === b.date ? a.name.localeCompare(b.name) : a.date.localeCompare(b.date)));
1019
+ return rows;
1020
+ }
1021
+
1022
+ function buildDigestPrompt(briefs = []) {
1023
+ const blocks = briefs.map((row) => [
1024
+ `filename: ${row.name}`,
1025
+ `title: ${row.title}`,
1026
+ `path: ${row.relPath}`,
1027
+ '',
1028
+ row.body,
1029
+ ].join('\n'));
1030
+ return [
1031
+ 'Turn these video briefs into one decision-focused page.',
1032
+ 'Write a heading exactly: # what this week\'s videos changed',
1033
+ 'Then 3-6 decision-shaped findings. Each finding must name the brief it came from as a path.',
1034
+ 'Then one contradictions or tensions paragraph if any exist.',
1035
+ 'Then a 3-item do next list.',
1036
+ 'Use plain prose. Do not use em dashes.',
1037
+ '',
1038
+ 'Briefs:',
1039
+ '',
1040
+ blocks.join('\n\n'),
1041
+ ].join('\n');
1042
+ }
1043
+
1044
+ function appendDigestJournal({ cwd, date, relDigest }) {
1045
+ const year = date.slice(0, 4);
1046
+ const journalPath = path.join(cwd, 'atris', 'logs', year, `${date}.md`);
1047
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
1048
+ let existing = '';
1049
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
1050
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
1051
+ const line = `- [claimable] digest: what this week's videos changed -> ${relDigest}`;
1052
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
1053
+ }
1054
+
1055
+ function digestExperimentSlug(date) {
1056
+ return `digest-${String(date || '').slice(0, 10)}`;
1057
+ }
1058
+
1059
+ function digestExperimentRel(date) {
1060
+ return `atris/experiments/${digestExperimentSlug(date)}`;
1061
+ }
1062
+
1063
+ function digestApplyRel(date) {
1064
+ return applyGate.applySidecarRel('digest', String(date || '').slice(0, 10));
1065
+ }
1066
+
1067
+ function ensureDigestApply({ cwd, date, packRel, now, output, source } = {}) {
1068
+ const stamp = String(date || '').slice(0, 10);
1069
+ const pack = packRel || (stamp ? digestExperimentRel(stamp) : null);
1070
+ const slug = pack ? path.basename(pack) : null;
1071
+ return applyGate.ensureApply({
1072
+ cwd,
1073
+ source: source || (stamp ? `digest:${stamp}` : 'digest'),
1074
+ rel: stamp ? digestApplyRel(stamp) : null,
1075
+ now,
1076
+ output,
1077
+ incompleteMessage: slug
1078
+ ? `next: atris experiments keep ${slug}`
1079
+ : applyGate.ephemeralApplyMessage('digest'),
1080
+ required: false,
1081
+ change: pack ? `apply ${pack}` : undefined,
1082
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1083
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
1084
+ });
1085
+ }
1086
+
1087
+ function saveRichDigest({ cwd, date, lesson, source } = {}) {
1088
+ if (isThinTeachLesson(lesson)) {
1089
+ return { thin: true, packRel: null, lesson };
1090
+ }
1091
+ const packRel = fileTeachExperiment({
1092
+ cwd,
1093
+ lesson,
1094
+ slug: date ? digestExperimentSlug(date) : null,
1095
+ applyRel: date ? digestApplyRel(date) : null,
1096
+ });
1097
+ return { thin: false, packRel, lesson, source };
1098
+ }
1099
+
1100
+ function watchExperimentSlug(id) {
1101
+ return `watch-${experimentIdToken(id)}`;
1102
+ }
1103
+
1104
+ function watchExperimentRel(id) {
1105
+ return `atris/experiments/${watchExperimentSlug(id)}`;
1106
+ }
1107
+
1108
+ function watchApplyRel(id) {
1109
+ return applyGate.applySidecarRel('watch', experimentIdToken(id));
1110
+ }
1111
+
1112
+ function ensureWatchWiki(cwd) {
1113
+ if (!cwd) return;
1114
+ fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
1115
+ }
1116
+
1117
+ function ensureWatchApply({ cwd, url, packRel, now, output, source } = {}) {
1118
+ const id = videoIdFromUrl(url);
1119
+ const pack = packRel || (id ? watchExperimentRel(id) : null);
1120
+ const slug = pack ? path.basename(pack) : null;
1121
+ ensureWatchWiki(cwd);
1122
+ return applyGate.ensureApply({
1123
+ cwd,
1124
+ source: source || url || (id ? `watch:${id}` : 'watch'),
1125
+ rel: id ? watchApplyRel(id) : null,
1126
+ now,
1127
+ output,
1128
+ incompleteMessage: slug
1129
+ ? `next: atris experiments keep ${slug}`
1130
+ : applyGate.ephemeralApplyMessage('watch'),
1131
+ required: false,
1132
+ change: pack ? `apply ${pack}` : undefined,
1133
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1134
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
1135
+ });
1136
+ }
1137
+
1138
+ function firstRichWatchLesson(urls, workDir) {
1139
+ for (const url of Array.isArray(urls) ? urls : []) {
1140
+ if (!url) continue;
1141
+ const lesson = notesLessonFromText(readNotesText({ url, workDir }));
1142
+ if (!isThinTeachLesson(lesson)) return { url, lesson };
1143
+ }
1144
+ return null;
1145
+ }
1146
+
1147
+ function saveRichWatch({ cwd, url, lesson } = {}) {
1148
+ if (isThinTeachLesson(lesson)) {
1149
+ return { thin: true, packRel: null, lesson };
1150
+ }
1151
+ const id = videoIdFromUrl(url);
1152
+ const packRel = fileTeachExperiment({
1153
+ cwd,
1154
+ url,
1155
+ lesson,
1156
+ slug: id ? watchExperimentSlug(id) : null,
1157
+ applyRel: id ? watchApplyRel(id) : null,
1158
+ });
1159
+ return { thin: false, packRel, lesson, source: url };
1160
+ }
1161
+
1162
+ function watchStatePath(cwd = process.cwd()) {
1163
+ return path.join(cwd, '.atris', 'state', 'youtube_watch.json');
1164
+ }
1165
+
1166
+ function emptyWatchState() {
1167
+ return { channels: [], seen: {}, seeded: {}, seenByChannel: {} };
1168
+ }
1169
+
1170
+ function asObject(value) {
1171
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1172
+ }
1173
+
1174
+ function loadWatchState(statePath) {
1175
+ try {
1176
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
1177
+ const channels = Array.isArray(parsed?.channels) ? parsed.channels : [];
1178
+ return {
1179
+ channels,
1180
+ seen: asObject(parsed?.seen),
1181
+ seeded: asObject(parsed?.seeded),
1182
+ seenByChannel: asObject(parsed?.seenByChannel),
1183
+ };
1184
+ } catch {
1185
+ return emptyWatchState();
1186
+ }
1187
+ }
1188
+
1189
+ function saveWatchState(statePath, state) {
1190
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
1191
+ const payload = {
1192
+ channels: (state.channels || []).map((row) => ({
1193
+ channel: row.channel,
1194
+ added: row.added,
1195
+ })),
1196
+ seen: asObject(state.seen),
1197
+ seeded: asObject(state.seeded),
1198
+ seenByChannel: asObject(state.seenByChannel),
1199
+ };
1200
+ fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`);
1201
+ }
1202
+
1203
+ function stampNow(now) {
1204
+ if (typeof now === 'function') now = now();
1205
+ if (typeof now === 'string' && now) return now;
1206
+ const value = now instanceof Date ? now : new Date(now || Date.now());
1207
+ if (Number.isNaN(value.getTime())) return new Date().toISOString();
1208
+ return value.toISOString();
1209
+ }
1210
+
1211
+ function normalizeWatchChannel(input) {
1212
+ const raw = String(input || '').trim();
1213
+ if (!raw) throw new Error('Missing channel url or @handle. Run "atris youtube watch --help".');
1214
+
1215
+ let text = raw;
1216
+ if (text.startsWith('@')) {
1217
+ text = `https://www.youtube.com/${text}`;
1218
+ } else if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text)) {
1219
+ if (/^(www\.)?youtube\.com\//i.test(text) || /^youtu\.be\//i.test(text)) {
1220
+ text = `https://${text}`;
1221
+ } else if (/^@?[\w.-]+$/.test(text)) {
1222
+ text = `https://www.youtube.com/@${text.replace(/^@/, '')}`;
1223
+ } else {
1224
+ throw new Error(`Invalid channel: ${raw}`);
1225
+ }
1226
+ }
1227
+
1228
+ let parsed;
1229
+ try {
1230
+ parsed = new URL(text);
1231
+ } catch {
1232
+ throw new Error(`Invalid channel: ${raw}`);
1233
+ }
1234
+
1235
+ parsed.hash = '';
1236
+ parsed.search = '';
1237
+ let href = parsed.toString().replace(/\/+$/, '');
1238
+ href = href.replace(/\/(videos|featured|streams|shorts)$/i, '');
1239
+ return href;
1240
+ }
1241
+
1242
+ function channelVideosUrl(channel) {
1243
+ const base = String(channel || '').replace(/\/+$/, '');
1244
+ if (/\/videos$/i.test(base)) return base;
1245
+ return `${base}/videos`;
1246
+ }
1247
+
1248
+ function looksLikeFlatVideoId(id) {
1249
+ const text = String(id || '');
1250
+ if (/^(NA|None)$/i.test(text)) return false;
1251
+ return /^[A-Za-z0-9_-]+$/.test(text);
1252
+ }
1253
+
1254
+ function parseFlatPlaylist(stdout) {
1255
+ const videos = [];
1256
+ for (const line of String(stdout || '').split(/\r?\n/)) {
1257
+ const trimmed = line.trim();
1258
+ if (!trimmed || !trimmed.includes('|')) continue;
1259
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
1260
+ const idx = trimmed.indexOf('|');
1261
+ const id = trimmed.slice(0, idx).trim();
1262
+ const title = trimmed.slice(idx + 1).trim();
1263
+ if (looksLikeFlatVideoId(id)) videos.push({ id, title });
1264
+ }
1265
+ return videos;
1266
+ }
1267
+
1268
+ function defaultChannelFetcher(videosUrl, deps = {}) {
1269
+ const spawn = deps.spawnSync || spawnSync;
1270
+ const result = spawn('yt-dlp', [
1271
+ '--no-update',
1272
+ '--flat-playlist',
1273
+ '--no-warnings',
1274
+ '--playlist-end',
1275
+ '3',
1276
+ '--print',
1277
+ '%(id)s|%(title)s',
1278
+ videosUrl,
1279
+ ], {
1280
+ encoding: 'utf8',
1281
+ timeout: 60000,
1282
+ maxBuffer: 2 * 1024 * 1024,
1283
+ });
1284
+ const videos = parseFlatPlaylist(result && result.stdout);
1285
+ if (videos.length) return videos;
1286
+ if (result.error || result.status !== 0) {
1287
+ const detail = String(result.stderr || result.error?.message || 'fetch failed').trim();
1288
+ throw new Error(detail || 'fetch failed');
1289
+ }
1290
+ return videos;
1291
+ }
1292
+
1293
+ function defaultNotesRunner(url, deps = {}) {
1294
+ const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
1295
+ const spawn = deps.spawnSync || spawnSync;
1296
+ return spawn(script, [url], { stdio: 'inherit' });
1297
+ }
1298
+
1299
+ function resolveWatchStatePath(deps = {}) {
1300
+ if (deps.statePath) return deps.statePath;
1301
+ return watchStatePath(deps.cwd || process.cwd());
1302
+ }
1303
+
1304
+ function channelSeenMap(state, channel) {
1305
+ return asObject(asObject(state.seenByChannel)[channel]);
1306
+ }
1307
+
1308
+ function markSeen(state, channel, id, timestamp) {
1309
+ if (!state.seen || typeof state.seen !== 'object') state.seen = {};
1310
+ if (!state.seenByChannel || typeof state.seenByChannel !== 'object') state.seenByChannel = {};
1311
+ if (!state.seenByChannel[channel] || typeof state.seenByChannel[channel] !== 'object') {
1312
+ state.seenByChannel[channel] = {};
1313
+ }
1314
+ state.seen[id] = timestamp;
1315
+ state.seenByChannel[channel][id] = timestamp;
1316
+ }
1317
+
1318
+ const WATCH_TICK_NEXT = 'next: atris youtube watch tick';
1319
+ const WATCH_ADD_NEXT = 'next: atris youtube watch add <channel-url-or-@handle>';
1320
+ const WATCH_SEARCH_NEXT = 'next: atris youtube search " "';
1321
+
1322
+ function printWatchTickNext(output) {
1323
+ output(WATCH_TICK_NEXT);
1324
+ }
1325
+
1326
+ function printWatchAddNext(output) {
1327
+ output(WATCH_ADD_NEXT);
1328
+ }
1329
+
1330
+ function printWatchSearchNext(output) {
1331
+ output(WATCH_SEARCH_NEXT);
1332
+ }
1333
+
1334
+ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1335
+ const spawn = deps.spawnSync || spawnSync;
1336
+ const result = spawn('yt-dlp', [
1337
+ '--no-update',
1338
+ '--flat-playlist',
1339
+ '--no-warnings',
1340
+ '--print',
1341
+ '%(id)s|%(title)s',
1342
+ playlistUrl,
1343
+ ], {
1344
+ encoding: 'utf8',
1345
+ timeout: 60000,
1346
+ maxBuffer: 2 * 1024 * 1024,
1347
+ });
1348
+ const videos = parseFlatPlaylist(result && result.stdout);
1349
+ if (videos.length) return videos;
1350
+ if (result.error || (result.status != null && result.status !== 0)) {
1351
+ const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
1352
+ throw new Error(detail || 'playlist expand failed');
1353
+ }
1354
+ return videos;
1355
+ }
1356
+
1357
+ function defaultNotesItemRunner(url, engine, deps = {}) {
1358
+ const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
1359
+ const spawn = deps.spawnSync || spawnSync;
1360
+ const childArgs = engine ? [url, engine] : [url];
1361
+ return spawn(script, childArgs, { stdio: 'inherit' });
1362
+ }
1363
+
1364
+ function readNowMs(deps = {}) {
1365
+ if (typeof deps.nowMs === 'function') return Number(deps.nowMs()) || 0;
1366
+ if (Number.isFinite(deps.nowMs)) return Number(deps.nowMs);
1367
+ return Date.now();
1368
+ }
1369
+
1370
+ function notesItemLabel(item = {}) {
1371
+ return item.id || item.url || '';
1372
+ }
1373
+
1374
+ function expandNotesTargets(urls = [], deps = {}) {
1375
+ const output = deps.output || ((line = '') => console.error(line));
1376
+ const expander = deps.expander || ((playlistUrl) => defaultPlaylistExpander(playlistUrl, deps));
1377
+ const items = [];
1378
+ for (const url of urls) {
1379
+ if (!isPlaylistUrl(url)) {
1380
+ items.push({ url, id: videoIdFromUrl(url) });
1381
+ continue;
1382
+ }
1383
+ let videos = [];
1384
+ try {
1385
+ videos = expander(url, deps);
1386
+ } catch {
1387
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1388
+ continue;
1389
+ }
1390
+ if (!Array.isArray(videos) || videos.length === 0) {
1391
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1392
+ continue;
1393
+ }
1394
+ if (videos.length > NOTES_PLAYLIST_CAP) {
1395
+ output(`playlist capped at ${NOTES_PLAYLIST_CAP} videos (${videos.length} found)`);
1396
+ videos = videos.slice(0, NOTES_PLAYLIST_CAP);
1397
+ }
1398
+ for (const video of videos) {
1399
+ if (!video?.id) continue;
1400
+ items.push({
1401
+ url: `https://www.youtube.com/watch?v=${video.id}`,
1402
+ id: video.id,
1403
+ title: video.title,
1404
+ });
1405
+ }
1406
+ }
1407
+ return items;
1408
+ }
1409
+
1410
+ function invokeNotesRunner(url, engine, deps = {}) {
1411
+ const runner = deps.runner;
1412
+ if (runner) return runner(url, engine, deps);
1413
+ return defaultNotesItemRunner(url, engine, deps);
1414
+ }
1415
+
1416
+ function readRunnerStatus(result) {
1417
+ if (typeof result === 'number') return result;
1418
+ if (result && typeof result === 'object') {
1419
+ return result.status == null ? 1 : result.status;
1420
+ }
1421
+ return 1;
1422
+ }
1423
+
1424
+ function fileNotesBrief(url, deps = {}) {
1425
+ const briefFiler = deps.briefFiler || fileBriefFromNotes;
1426
+ try {
1427
+ const filed = briefFiler({
1428
+ cwd: deps.cwd || process.cwd(),
1429
+ url,
1430
+ workDir: deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes'),
1431
+ now: deps.now || new Date(),
1432
+ });
1433
+ return typeof filed === 'string' && filed ? filed : null;
1434
+ } catch {
1435
+ return null;
1436
+ }
1437
+ }
1438
+
1439
+ function runOneNotesItem(item, engine, deps = {}) {
1440
+ const output = deps.output || ((line = '') => console.error(line));
1441
+ const label = notesItemLabel(item);
1442
+ if (item.failed) {
1443
+ output(`${label} 0s FAILED`);
1444
+ return { url: item.url, id: item.id, seconds: 0, ok: false, brief: null };
1445
+ }
1446
+
1447
+ const started = readNowMs(deps);
1448
+ let result = { status: 1 };
1449
+ try {
1450
+ result = invokeNotesRunner(item.url, engine, deps);
1451
+ } catch (err) {
1452
+ result = { status: 1, stderr: String((err && err.message) || err || '') };
1453
+ }
1454
+ const status = readRunnerStatus(result);
1455
+ let brief = null;
1456
+ let lesson = null;
1457
+ let ok = status === 0 || keptPrintedNotes({
1458
+ url: item.url,
1459
+ workDir: notesWorkDir(deps),
1460
+ result,
1461
+ });
1462
+ if (ok && deps.save) {
1463
+ const saved = saveRichNotes(item.url, deps);
1464
+ if (saved.thin) {
1465
+ output(TEACH_THIN_REFUSE);
1466
+ ok = false;
1467
+ } else {
1468
+ brief = saved.brief;
1469
+ lesson = saved.lesson;
1470
+ const ensureApply = deps.ensureApply || ensureNotesApply;
1471
+ try {
1472
+ ensureApply({
1473
+ cwd: deps.cwd || process.cwd(),
1474
+ url: item.url,
1475
+ packRel: saved.packRel,
1476
+ now: deps.now,
1477
+ output,
1478
+ });
1479
+ } catch {
1480
+ // apply filing must never break the batch
1481
+ }
1482
+ }
1483
+ }
1484
+ const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
1485
+ output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
1486
+ return { url: item.url, id: item.id, seconds, ok, brief, lesson };
1487
+ }
1488
+
1489
+ function formatNotesSummary(rows = []) {
1490
+ const lines = ['url or id seconds result'];
1491
+ for (const row of rows) {
1492
+ const result = row.ok ? (row.brief || 'ok') : 'FAILED';
1493
+ lines.push(`${notesItemLabel(row)} ${row.seconds}s ${result}`);
1494
+ }
1495
+ return lines.join('\n');
1496
+ }
1497
+
1498
+ function printEphemeralNotesLearnerGate(url, { json = false, workDir } = {}, output) {
1499
+ const dir = workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
1500
+ const lesson = notesLessonFromText(readNotesText({ url, workDir: dir }));
1501
+ if (!isThinTeachLesson(lesson)) applyGate.hintEphemeralApply(output, 'notes');
1502
+ printLearnerCheckGate(output, lesson, { includeCheck: true, json });
1503
+ }
1504
+
1505
+ function runYoutubeNotesBatch({ urls, engine, save, json } = {}, deps = {}) {
1506
+ deps = { ...deps, save: save === true || deps.save === true };
1507
+ const output = deps.output || ((line = '') => console.error(line));
1508
+ const items = expandNotesTargets(urls || [], deps);
1509
+ const rows = [];
1510
+ for (const item of items) {
1511
+ rows.push(runOneNotesItem(item, engine, deps));
1512
+ }
1513
+ if (rows.length) {
1514
+ output('');
1515
+ output(formatNotesSummary(rows));
1516
+ }
1517
+ if (!rows.length) return 2;
1518
+ const firstOk = rows.find((row) => row.ok);
1519
+ const asJson = json === true || deps.json === true;
1520
+ if (firstOk && !deps.save) {
1521
+ if (!asJson) printEphemeralNotesLearnerGate(firstOk.url, { workDir: deps.workDir }, output);
1522
+ printYoutubeTeachNext(firstOk.url, { json: asJson }, output);
1523
+ }
1524
+ if (firstOk && deps.save) {
1525
+ if (deps.ensureApply) return 0;
1526
+ const id = firstOk.id || videoIdFromUrl(firstOk.url);
1527
+ const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
1528
+ const lesson = firstOk.lesson || notesLessonFromText(readNotesText({
1529
+ url: firstOk.url,
1530
+ workDir,
1531
+ }));
1532
+ const baseline = proveSavedLearnerBaseline({
1533
+ cwd: deps.cwd || process.cwd(),
1534
+ applyRel: id ? notesApplyRel(id) : null,
1535
+ lesson,
1536
+ output,
1537
+ json: asJson,
1538
+ });
1539
+ if (baseline !== 0) return baseline;
1540
+ }
1541
+ return firstOk ? 0 : 2;
1542
+ }
1543
+
1544
+ function runSingleYoutubeNotes(url, engine, deps = {}) {
1545
+ let result;
1546
+ try {
1547
+ result = invokeNotesRunner(url, engine, deps);
1548
+ } catch {
1549
+ result = { status: 1 };
1550
+ }
1551
+ const status = readRunnerStatus(result);
1552
+ if (status !== 0 && !keptPrintedNotes({
1553
+ url,
1554
+ workDir: notesWorkDir(deps),
1555
+ result,
1556
+ })) {
1557
+ return status == null ? 1 : status;
1558
+ }
1559
+ const output = deps.output || ((line = '') => console.error(line));
1560
+ if (!deps.save) {
1561
+ const json = deps.json === true;
1562
+ printEphemeralNotesLearnerGate(url, { json, workDir: deps.workDir }, output);
1563
+ printYoutubeTeachNext(url, { json }, output);
1564
+ return 0;
1565
+ }
1566
+ const saved = saveRichNotes(url, deps);
1567
+ if (saved.thin) {
1568
+ output(TEACH_THIN_REFUSE);
1569
+ printYoutubeTeachNext(url, { json: deps.json }, output);
1570
+ return 2;
1571
+ }
1572
+ const ensureApply = deps.ensureApply || ensureNotesApply;
1573
+ const cwd = deps.cwd || process.cwd();
1574
+ const applyCode = ensureApply({
1575
+ cwd,
1576
+ url,
1577
+ packRel: saved.packRel,
1578
+ now: deps.now,
1579
+ output: deps.output,
1580
+ });
1581
+ if (deps.ensureApply) return applyCode;
1582
+ const id = videoIdFromUrl(url);
1583
+ const baseline = proveSavedLearnerBaseline({
1584
+ cwd,
1585
+ applyRel: id ? notesApplyRel(id) : null,
1586
+ lesson: saved.lesson,
1587
+ output,
1588
+ json: deps.json === true,
1589
+ });
1590
+ if (baseline !== 0) return baseline;
1591
+ return applyCode;
1592
+ }
1593
+
1594
+ const DEFAULT_SEARCH_LIMIT = 5;
1595
+ const PAID_SEARCH_TIMEOUT_MS = 120000;
1596
+ const PAID_SEARCH_COST_HINT = '5 credits per search';
1597
+ const YTSEARCH_USAGE = 'usage: atris youtube search "<query>" [--limit N] [--json]';
1598
+ const SEARCH_PRINT_FORMAT = '%(title)s | %(channel)s | %(duration_string)s | %(view_count)s | %(upload_date)s | https://youtu.be/%(id)s';
1599
+ const LOCAL_SEARCH_RATE_LIMIT_BACKOFF_MS = 1000;
1600
+ const LOCAL_SEARCH_RATE_LIMIT_MESSAGE =
1601
+ 'youtube rate-limited local search. do not use --paid as a fallback; retry later.';
1602
+ const LOCAL_SEARCH_RATE_LIMIT_RE = /429|too many requests|confirm you['\u2019]re not a bot/i;
1603
+ const LOCAL_SEARCH_CACHE_TTL_MS = 60 * 60 * 1000;
1604
+ const LOCAL_SEARCH_CACHE_FILE = 'youtube-search-cache.json';
1605
+ const LOCAL_SEARCH_CACHE_NOTE =
1606
+ 'cached because youtube rate-limited local search.';
1607
+ const PAID_SEARCH_FRESH_CACHE_REFUSE =
1608
+ 'free cache still has results for this query. drop --paid or wait until the cache expires.';
1609
+
1610
+ function parseSearchArgs(argv = []) {
1611
+ const args = [...argv];
1612
+ const options = {
1613
+ help: false,
1614
+ json: false,
1615
+ paid: false,
1616
+ query: null,
1617
+ limit: DEFAULT_SEARCH_LIMIT,
1618
+ timeoutMs: PAID_SEARCH_TIMEOUT_MS,
1619
+ };
1620
+
1621
+ if (args.length === 0 || ['help', '--help', '-h'].includes(args[0])) {
1622
+ options.help = true;
1623
+ return options;
1624
+ }
1625
+
1626
+ for (let i = 0; i < args.length; i++) {
1627
+ const arg = args[i];
1628
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
1629
+ options.help = true;
1630
+ } else if (arg === '--json') {
1631
+ options.json = true;
1632
+ } else if (arg === '--paid') {
1633
+ options.paid = true;
1634
+ } else if (arg === '--limit') {
1635
+ options.limit = parsePositiveSearchInt(readValue(args, i, arg), '--limit');
1636
+ i++;
1637
+ } else if (arg.startsWith('--limit=')) {
1638
+ options.limit = parsePositiveSearchInt(arg.slice('--limit='.length), '--limit');
1639
+ } else if (arg.startsWith('-')) {
1640
+ throw new Error(`Unknown option: ${arg}`);
1641
+ } else if (!options.query) {
1642
+ options.query = arg;
1643
+ } else {
1644
+ throw new Error(`Unexpected argument: ${arg}`);
1645
+ }
1646
+ }
1647
+
1648
+ if (options.help) return options;
1649
+ if (!options.query) throw new Error('Missing query. Run "atris youtube search --help".');
1650
+ return options;
1651
+ }
1652
+
1653
+ function parsePositiveSearchInt(raw, name) {
1654
+ const value = Number(raw);
1655
+ if (!Number.isInteger(value) || value <= 0) {
1656
+ throw new Error(`${name} must be a positive integer`);
1657
+ }
1658
+ return value;
1659
+ }
1660
+
1661
+ function parseSearchStdout(stdout = '') {
1662
+ const rows = [];
1663
+ for (const line of String(stdout || '').split(/\r?\n/)) {
1664
+ const trimmed = line.trim();
1665
+ if (!trimmed || !trimmed.includes('|')) continue;
1666
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
1667
+ const parts = trimmed.split(/\s*\|\s*/).map((part) => part.trim());
1668
+ if (parts.length < 5) continue;
1669
+ const url = parts[parts.length - 1];
1670
+ if (!/^https?:\/\/(?:www\.)?(?:youtube\.com\/|youtu\.be\/)/i.test(url)) continue;
1671
+ if (!looksLikeFlatVideoId(videoIdFromUrl(url))) continue;
1672
+ const row = {
1673
+ title: parts[0] || '',
1674
+ channel: parts[1] || '',
1675
+ duration: parts[2] || '',
1676
+ views: parts[3] || '',
1677
+ url,
1678
+ };
1679
+ if (parts.length >= 6) {
1680
+ const maybeDate = parts[parts.length - 2];
1681
+ if (/^\d{8}$/.test(maybeDate) || maybeDate === 'NA' || maybeDate === 'None') {
1682
+ row.upload_date = maybeDate === 'None' ? 'NA' : maybeDate;
1683
+ }
1684
+ }
1685
+ rows.push(row);
1686
+ }
1687
+ return rows;
1688
+ }
1689
+
1690
+ function formatSearchResults(rows = []) {
1691
+ return rows.map((row) => {
1692
+ const parts = [row.title, row.channel, row.duration, row.views];
1693
+ if (row.upload_date) parts.push(row.upload_date);
1694
+ parts.push(row.url);
1695
+ return parts.join(' | ');
1696
+ }).join('\n');
1697
+ }
1698
+
1699
+ function normalizeSearchQuery(query) {
1700
+ return String(query || '').trim().toLowerCase().replace(/\s+/g, ' ');
1701
+ }
1702
+
1703
+ function searchCacheNow(deps = {}) {
1704
+ if (typeof deps.now === 'function') return Number(deps.now());
1705
+ return Date.now();
1706
+ }
1707
+
1708
+ function searchCacheTtlMs(deps = {}) {
1709
+ const ttl = Number(deps.searchCacheTtlMs);
1710
+ return Number.isFinite(ttl) && ttl > 0 ? ttl : LOCAL_SEARCH_CACHE_TTL_MS;
1711
+ }
1712
+
1713
+ function resolveSearchCachePath(deps = {}) {
1714
+ if (deps.searchCachePath) return deps.searchCachePath;
1715
+ const homeDir = deps.homeDir || stateHome();
1716
+ return path.join(homeDir, '.atris', LOCAL_SEARCH_CACHE_FILE);
1717
+ }
1718
+
1719
+ function printYoutubeTeachNext(url, options, output) {
1720
+ if (options && options.json) return;
1721
+ if (!url) return;
1722
+ output(`next: atris youtube teach ${quoteYoutubeUrl(url)}`);
1723
+ }
1724
+
1725
+ function printSearchTeachNext(rows, options, output) {
1726
+ const firstUrl = Array.isArray(rows) && rows[0] && rows[0].url;
1727
+ printYoutubeTeachNext(firstUrl, options, output);
1728
+ }
1729
+
1730
+ function printSearchRows(rows, options, output) {
1731
+ if (options.json) {
1732
+ output(JSON.stringify(rows, null, 2));
1733
+ return;
1734
+ }
1735
+ output(formatSearchResults(rows));
1736
+ }
1737
+
1738
+ function writeLocalSearchCache(query, rows, deps = {}) {
1739
+ if (!Array.isArray(rows) || !rows.length) return;
1740
+ const fsMod = deps.fs || fs;
1741
+ const filePath = resolveSearchCachePath(deps);
1742
+ try {
1743
+ if (typeof fsMod.mkdirSync === 'function') {
1744
+ fsMod.mkdirSync(path.dirname(filePath), { recursive: true });
1745
+ }
1746
+ const payload = {
1747
+ query: String(query || ''),
1748
+ savedAt: searchCacheNow(deps),
1749
+ rows,
1750
+ };
1751
+ fsMod.writeFileSync(filePath, `${JSON.stringify(payload)}\n`);
1752
+ } catch {
1753
+ // cache write is best-effort; a live search already succeeded
1754
+ }
1755
+ }
1756
+
1757
+ function readFreshLocalSearchCache(query, deps = {}) {
1758
+ const fsMod = deps.fs || fs;
1759
+ const filePath = resolveSearchCachePath(deps);
1760
+ try {
1761
+ const parsed = JSON.parse(fsMod.readFileSync(filePath, 'utf8'));
1762
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
1763
+ if (normalizeSearchQuery(parsed.query) !== normalizeSearchQuery(query)) return null;
1764
+ const savedAt = Number(parsed.savedAt);
1765
+ if (!Number.isFinite(savedAt)) return null;
1766
+ if (searchCacheNow(deps) - savedAt >= searchCacheTtlMs(deps)) return null;
1767
+ if (!Array.isArray(parsed.rows) || !parsed.rows.length) return null;
1768
+ return parsed;
1769
+ } catch {
1770
+ return null;
1771
+ }
1772
+ }
1773
+
1774
+ function paidSearchPayload(options) {
1775
+ const payload = { query: options.query };
1776
+ if (options.limit != null) payload.limit = options.limit;
1777
+ return payload;
1778
+ }
1779
+
1780
+ function unwrapSearchPayload(data) {
1781
+ if (data?.data && typeof data.data === 'object' && !Array.isArray(data.data)) {
1782
+ return data.data;
1783
+ }
1784
+ return data && typeof data === 'object' ? data : {};
1785
+ }
1786
+
1787
+ function watchPermalink(item = {}) {
1788
+ const raw = item.url || item.permalink || item.watch_url || item.link || '';
1789
+ if (typeof raw === 'string' && /^https?:\/\//i.test(raw)) return raw;
1790
+ const id = item.video_id || item.videoId || (typeof item.id === 'string' ? item.id : '');
1791
+ if (id && /^[A-Za-z0-9_-]{6,}$/.test(id)) {
1792
+ return `https://www.youtube.com/watch?v=${id}`;
1793
+ }
1794
+ return '';
1795
+ }
1796
+
1797
+ function paidSearchVideos(data) {
1798
+ const payload = unwrapSearchPayload(data);
1799
+ const raw = payload.results || payload.videos || payload.items
1800
+ || (Array.isArray(data?.data) ? data.data : null)
1801
+ || (Array.isArray(data) ? data : []);
1802
+ if (!Array.isArray(raw)) return [];
1803
+ const rows = [];
1804
+ for (const item of raw) {
1805
+ if (!item || typeof item !== 'object') continue;
1806
+ const url = watchPermalink(item);
1807
+ if (!url) continue;
1808
+ rows.push({
1809
+ title: String(item.title || item.name || '').trim(),
1810
+ url,
1811
+ });
1812
+ }
1813
+ return rows;
1814
+ }
1815
+
1816
+ function paidSearchCredits(data) {
1817
+ if (!data || typeof data !== 'object') {
1818
+ return { used: undefined, remaining: undefined, refunded: undefined };
1819
+ }
1820
+ const payload = unwrapSearchPayload(data) || {};
1821
+ const used = data.credits_used !== undefined ? data.credits_used : payload.credits_used;
1822
+ const remaining = data.credits_remaining !== undefined
1823
+ ? data.credits_remaining
1824
+ : payload.credits_remaining;
1825
+ let refunded = data.credits_refunded !== undefined
1826
+ ? data.credits_refunded
1827
+ : payload.credits_refunded;
1828
+ if (refunded === undefined && (data.refunded === true || payload.refunded === true)) {
1829
+ refunded = true;
1830
+ }
1831
+ return { used, remaining, refunded };
1832
+ }
1833
+
1834
+ function creditsRefundedExplicitly(credits) {
1835
+ if (!credits) return false;
1836
+ if (credits.refunded === true) return true;
1837
+ return typeof credits.refunded === 'number' && credits.refunded > 0;
1838
+ }
1839
+
1840
+ function formatCreditsLines(credits) {
1841
+ const lines = [];
1842
+ if (credits.used !== undefined || credits.remaining !== undefined) {
1843
+ lines.push(`Credits: ${credits.used !== undefined ? credits.used : '?'} used, ${credits.remaining !== undefined ? credits.remaining : '?'} remaining`);
1844
+ }
1845
+ if (creditsRefundedExplicitly(credits)) {
1846
+ lines.push('credits refunded');
1847
+ }
1848
+ return lines;
1849
+ }
1850
+
1851
+ function formatPaidSearchResults(data) {
1852
+ const lines = paidSearchVideos(data).map((row) => (
1853
+ row.title ? `${row.title} | ${row.url}` : row.url
1854
+ ));
1855
+ const creditLines = formatCreditsLines(paidSearchCredits(data));
1856
+ if (creditLines.length) {
1857
+ if (lines.length) lines.push('');
1858
+ lines.push(...creditLines);
1859
+ }
1860
+ return lines.join('\n');
1861
+ }
1862
+
1863
+ function youtubeSearchFailureError(result) {
1864
+ const hint = result.status === 401
1865
+ ? ' Run "atris login --force".'
1866
+ : result.status === 402
1867
+ ? ' Check Atris credits.'
1868
+ : result.status === 502
1869
+ ? ' YouTube search is unavailable; retry in a few seconds.'
1870
+ : '';
1871
+ const credits = paidSearchCredits(result.data);
1872
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
1873
+ ? ' credits refunded.'
1874
+ : '';
1875
+ const lines = [`YouTube search failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
1876
+ lines.push(...formatCreditsLines(credits));
1877
+ return new Error(lines.join('\n'));
1878
+ }
1879
+
1880
+ function searchLessonText(rows) {
1881
+ return (Array.isArray(rows) ? rows : [])
1882
+ .map((row) => String(row && row.title ? row.title : '').trim())
1883
+ .filter(Boolean)
1884
+ .join('\n');
1885
+ }
1886
+
1887
+ function searchExperimentSlug(query) {
1888
+ return `search-${applyGate.applySlug(query)}`;
1889
+ }
1890
+
1891
+ function searchExperimentRel(query) {
1892
+ return `atris/experiments/${searchExperimentSlug(query)}`;
1893
+ }
1894
+
1895
+ function searchApplyRel(query) {
1896
+ return applyGate.applySidecarRel('search', applyGate.applySlug(query));
1897
+ }
1898
+
1899
+ function searchApplyNow(now) {
1900
+ if (typeof now === 'function') {
1901
+ const value = now();
1902
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) return value;
1903
+ const ms = Number(value);
1904
+ if (Number.isFinite(ms) && ms > 0) return dateStamp(ms);
1905
+ return undefined;
1906
+ }
1907
+ // The apply stub is dated by the local day, like the notes it follows;
1908
+ // left to itself the apply gate would stamp the UTC day.
1909
+ if (now === undefined || now === null || now instanceof Date || typeof now === 'number') return dateStamp(now);
1910
+ return now;
1911
+ }
1912
+
1913
+ function saveRichSearch({ cwd, query, lesson } = {}) {
1914
+ if (isThinTeachLesson(lesson)) {
1915
+ return { thin: true, packRel: null, lesson };
1916
+ }
1917
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
1918
+ const packRel = fileTeachExperiment({
1919
+ cwd,
1920
+ lesson,
1921
+ slug: query ? searchExperimentSlug(query) : null,
1922
+ applyRel: query ? searchApplyRel(query) : null,
1923
+ });
1924
+ return { thin: false, packRel, lesson, source: query };
1925
+ }
1926
+
1927
+ function ensureSearchApply({ cwd, query, packRel, now, output, source } = {}) {
1928
+ const pack = packRel || (query ? searchExperimentRel(query) : null);
1929
+ const slug = pack ? path.basename(pack) : null;
1930
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
1931
+ return applyGate.ensureApply({
1932
+ cwd,
1933
+ source: source || query || 'search',
1934
+ rel: query ? searchApplyRel(query) : null,
1935
+ now,
1936
+ output,
1937
+ incompleteMessage: slug
1938
+ ? `next: atris experiments keep ${slug}`
1939
+ : applyGate.ephemeralApplyMessage('search'),
1940
+ required: false,
1941
+ change: pack ? `apply ${pack}` : undefined,
1942
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1943
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
1944
+ });
1945
+ }
1946
+
1947
+ function mintRichSearch({ cwd, query, rows, now, output, ensureApply, json } = {}) {
1948
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
1949
+ const lesson = notesLessonFromText(searchLessonText(rows));
1950
+ if (json) return { thin: false, code: 0, lesson };
1951
+ if (isThinTeachLesson(lesson)) {
1952
+ printSearchLearnerGate(rows, { json: false }, print);
1953
+ return { thin: true, code: 0, lesson };
1954
+ }
1955
+ const saved = saveRichSearch({ cwd, query, lesson });
1956
+ const applyFn = ensureApply || ensureSearchApply;
1957
+ const applyCode = applyFn({
1958
+ cwd,
1959
+ query,
1960
+ packRel: saved.packRel,
1961
+ now: searchApplyNow(now),
1962
+ output: print,
1963
+ source: query,
1964
+ });
1965
+ if (ensureApply) return { thin: false, code: applyCode, lesson: saved.lesson };
1966
+ const baseline = proveSavedLearnerBaseline({
1967
+ cwd,
1968
+ applyRel: query ? searchApplyRel(query) : null,
1969
+ lesson: saved.lesson,
1970
+ output: print,
1971
+ json,
1972
+ });
1973
+ if (baseline !== 0) return { thin: false, code: baseline, lesson: saved.lesson };
1974
+ return { thin: false, code: applyCode, lesson: saved.lesson };
1975
+ }
1976
+
1977
+ function printSearchLearnerGate(rows, options, output) {
1978
+ printLearnerCheckGate(output, notesLessonFromText(searchLessonText(rows)), {
1979
+ includeCheck: true,
1980
+ json: Boolean(options && options.json),
1981
+ });
1982
+ }
1983
+
1984
+ function gateSearchLearner(rows, options, output, deps = {}) {
1985
+ if (options && options.json) return 0;
1986
+ const minted = mintRichSearch({
1987
+ cwd: deps.cwd || process.cwd(),
1988
+ query: options && options.query,
1989
+ rows,
1990
+ now: deps.now,
1991
+ output,
1992
+ ensureApply: deps.ensureApply,
1993
+ json: false,
1994
+ });
1995
+ if (minted.thin) printSearchTeachNext(rows, options, output);
1996
+ return minted.code;
1997
+ }
1998
+
1999
+ function printSearchOutcome(rows, options, output, deps = {}) {
2000
+ printSearchRows(rows, options, output);
2001
+ return gateSearchLearner(rows, options, output, deps);
2002
+ }
2003
+
2004
+ function resultStdout(result) {
2005
+ if (typeof result === 'string') return result;
2006
+ return String((result && result.stdout) || '');
2007
+ }
2008
+
2009
+ function searchRowsFromResult(result) {
2010
+ return parseSearchStdout(resultStdout(result));
2011
+ }
2012
+
2013
+ function finishSuccessfulSearch(rows, options, output, deps) {
2014
+ writeLocalSearchCache(options.query, rows, deps);
2015
+ return printSearchOutcome(rows, options, output, deps);
2016
+ }
2017
+
2018
+ function commandOnPath(name, deps = {}) {
2019
+ const spawn = deps.spawnSync || spawnSync;
2020
+ const result = spawn('sh', ['-c', `command -v ${shellSingleQuote(name)}`], {
2021
+ encoding: 'utf8',
2022
+ timeout: 5000,
2023
+ });
2024
+ if (result.error || result.status !== 0) return null;
2025
+ const found = String(result.stdout || '').trim();
2026
+ return found || null;
2027
+ }
2028
+
2029
+ function shellSingleQuote(value) {
2030
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
2031
+ }
2032
+
2033
+ function bundledYtsearchPath() {
2034
+ return path.join(__dirname, '..', 'scripts', 'det', 'ytsearch');
2035
+ }
2036
+
2037
+ function defaultSearchRunner(query, limit, deps = {}) {
2038
+ const spawn = deps.spawnSync || spawnSync;
2039
+ const options = {
2040
+ encoding: 'utf8',
2041
+ timeout: 60000,
2042
+ maxBuffer: 2 * 1024 * 1024,
2043
+ };
2044
+
2045
+ const pathBin = deps.ytsearchBin || commandOnPath('ytsearch', deps);
2046
+ if (pathBin) {
2047
+ const result = spawn(pathBin, [query, String(limit)], options);
2048
+ if (!(result.error && result.error.code === 'ENOENT')) return result;
2049
+ }
2050
+
2051
+ const bundled = deps.bundledYtsearch || bundledYtsearchPath();
2052
+ if (fs.existsSync(bundled)) {
2053
+ const result = spawn(bundled, [query, String(limit)], options);
2054
+ if (!(result.error && result.error.code === 'ENOENT')) return result;
2055
+ }
2056
+
2057
+ return spawn('yt-dlp', [
2058
+ '--no-update',
2059
+ '--flat-playlist',
2060
+ '--no-warnings',
2061
+ '--print',
2062
+ SEARCH_PRINT_FORMAT,
2063
+ `ytsearch${limit}:${query}`,
2064
+ ], options);
2065
+ }
2066
+
2067
+ function searchRunnerDetail(result) {
2068
+ return String(
2069
+ (result && result.stderr) || (result && result.error && result.error.message) || 'search failed',
2070
+ ).trim();
2071
+ }
2072
+
2073
+ function isLocalSearchRateLimited(result) {
2074
+ return LOCAL_SEARCH_RATE_LIMIT_RE.test(searchRunnerDetail(result));
2075
+ }
2076
+
2077
+ function searchRunnerStatus(result) {
2078
+ if (result && typeof result === 'object' && 'status' in result) return result.status;
2079
+ return typeof result === 'number' ? result : 0;
2080
+ }
2081
+
2082
+ async function waitLocalSearchBackoff(deps = {}) {
2083
+ const ms = Number.isFinite(Number(deps.rateLimitBackoffMs))
2084
+ ? Number(deps.rateLimitBackoffMs)
2085
+ : LOCAL_SEARCH_RATE_LIMIT_BACKOFF_MS;
2086
+ if (!(ms > 0)) return;
2087
+ if (typeof deps.sleep === 'function') return deps.sleep(ms);
2088
+ await new Promise((resolve) => setTimeout(resolve, ms));
2089
+ }
2090
+
2091
+ function isMissingSearchBinary(result) {
2092
+ return Boolean(result && typeof result === 'object' && result.error && result.error.code === 'ENOENT');
2093
+ }
2094
+
2095
+ const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip] | owed | next';
2096
+ const TEACH_PAID_REFUSE = 'teach is free local captions. drop --paid.';
2097
+ const TEACH_THIN_REFUSE = 'thin: no number or named mechanism. no brief.';
2098
+ const TEACH_OWED_FILE = 'youtube-teach-owed.json';
2099
+ const TEACH_RECAP_MISSING = '--recap needs the unpaid check';
2100
+ const TEACH_RESUME_START = 'atris youtube teach <url>';
2101
+ const TEACH_RESUME_NEXT = 'next: atris youtube teach recap TEXT or atris youtube teach skip';
2102
+ const TEACH_WATCH_TICK_NEXT = WATCH_TICK_NEXT;
2103
+ const TEACH_CONTINUE_NEXT = 'next: atris youtube teach next';
2104
+ const TEACH_APPLY_NEXT_MESSAGE = APPLY_NEXT_MESSAGE;
2105
+ const TEACH_KEEP_RULE = 'keep only if measure.py moves 0→1. scores 1 only when the fixture contains the check tokens.';
2106
+ const MECHANISM_STOP = new Set([
2107
+ 'the', 'this', 'that', 'and', 'but', 'for', 'with', 'from', 'you', 'we', 'they',
2108
+ 'what', 'when', 'how', 'why', 'there', 'here', 'then', 'just', 'also', 'very',
2109
+ 'really', 'about', 'into', 'over', 'after', 'before', 'because', 'while', 'where',
2110
+ 'which', 'their', 'your', 'our', 'its', 'not', 'all', 'any', 'some', 'more', 'most',
2111
+ 'other', 'first', 'second', 'next', 'last', 'new', 'old', 'good', 'bad', 'big',
2112
+ 'small', 'long', 'short', 'video', 'chapter', 'section', 'youtube', 'transcript',
2113
+ 'yeah', 'okay', 'ok', 'so', 'well', 'like', 'right', 'now', 'one', 'two',
2114
+ 'who', 'every', 'holy', 'want', 'operating',
2115
+ ]);
2116
+ const NUMBER_UNITS = 'percent|million|billion|thousand|people|hours?|minutes?|seconds?|years?|months?|weeks?|days?|cycles?';
2117
+ const NUMBER_CLAIM_VERBS = 'install|ship|hire|raise|cut|save|cost|last|weigh|span|spend';
2118
+ const MECHANISM_HEADS = 'window|model|principle|pattern|loop|cycle|method|rule|doctrine|framework|heuristic';
2119
+ const TEACH_SWEAR_RE = /\b(fuck(?:ing)?|shit|damn|ass|bitch|crap)\b/i;
2120
+ const GENERIC_MECHANISM_LEFT = new Set([
2121
+ ...MECHANISM_STOP,
2122
+ 'a', 'an', 'in', 'of', 'to', 'my', 'i', 'im', "i'm",
2123
+ ]);
2124
+
2125
+ function parseTeachArgs(argv = []) {
2126
+ const args = [...argv];
2127
+ const options = {
2128
+ help: false,
2129
+ save: false,
2130
+ json: false,
2131
+ skip: false,
2132
+ owed: false,
2133
+ resume: false,
2134
+ next: false,
2135
+ recap: null,
2136
+ url: null,
2137
+ section: 1,
2138
+ };
2139
+ let sectionSet = false;
2140
+
2141
+ if (args.length > 0 && ['help', '--help', '-h'].includes(args[0])) {
2142
+ options.help = true;
2143
+ return options;
2144
+ }
2145
+
2146
+ for (let i = 0; i < args.length; i += 1) {
2147
+ const arg = String(args[i]);
2148
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
2149
+ options.help = true;
2150
+ } else if (arg === '--save') {
2151
+ options.save = true;
2152
+ } else if (arg === '--json') {
2153
+ options.json = true;
2154
+ } else if (arg === '--skip') {
2155
+ options.skip = true;
2156
+ } else if (arg === '--owed') {
2157
+ options.owed = true;
2158
+ } else if (arg === '--paid') {
2159
+ throw new Error(TEACH_PAID_REFUSE);
2160
+ } else if (arg === '--recap') {
2161
+ const raw = args[i + 1];
2162
+ if (raw == null || String(raw).startsWith('--')) {
2163
+ throw new Error(TEACH_RECAP_MISSING);
2164
+ }
2165
+ options.recap = String(raw);
2166
+ i += 1;
2167
+ } else if (arg.startsWith('--recap=')) {
2168
+ const value = arg.slice('--recap='.length);
2169
+ if (!value) throw new Error(TEACH_RECAP_MISSING);
2170
+ options.recap = value;
2171
+ } else if (arg === '--section') {
2172
+ const raw = args[i + 1];
2173
+ const value = Number.parseInt(raw, 10);
2174
+ if (raw == null || String(raw).startsWith('--') || !Number.isInteger(value) || value < 1) {
2175
+ throw new Error('--section must be a positive integer');
2176
+ }
2177
+ options.section = value;
2178
+ sectionSet = true;
2179
+ i += 1;
2180
+ } else if (arg.startsWith('--section=')) {
2181
+ const value = Number.parseInt(arg.slice('--section='.length), 10);
2182
+ if (!Number.isInteger(value) || value < 1) {
2183
+ throw new Error('--section must be a positive integer');
2184
+ }
2185
+ options.section = value;
2186
+ sectionSet = true;
2187
+ } else if (arg.startsWith('-')) {
2188
+ throw new Error(`Unknown option: ${arg}`);
2189
+ } else if (arg === 'recap' && options.recap == null && !options.url) {
2190
+ const parts = [];
2191
+ i += 1;
2192
+ while (i < args.length && !String(args[i]).startsWith('--')) {
2193
+ parts.push(String(args[i]));
2194
+ i += 1;
2195
+ }
2196
+ i -= 1;
2197
+ options.recap = parts.join(' ').trim();
2198
+ if (!options.recap) throw new Error(TEACH_RECAP_MISSING);
2199
+ } else if (arg === 'skip' && !options.url) {
2200
+ options.skip = true;
2201
+ } else if (arg === 'owed' && !options.url) {
2202
+ options.owed = true;
2203
+ } else if (arg === 'next' && !options.url) {
2204
+ options.next = true;
2205
+ } else if (!options.url && looksLikeYoutubeUrl(arg)) {
2206
+ options.url = arg;
2207
+ } else {
2208
+ throw new Error(`Unexpected argument: ${arg}`);
2209
+ }
2210
+ }
2211
+
2212
+ if (options.help) return options;
2213
+ if (!options.url && options.recap == null && !options.skip && !options.owed && !options.next) {
2214
+ if (!sectionSet && !options.save) {
2215
+ options.owed = true;
2216
+ options.resume = true;
2217
+ return options;
2218
+ }
2219
+ throw new Error('Missing YouTube URL. Run "atris youtube teach --help".');
2220
+ }
2221
+ return options;
2222
+ }
2223
+
2224
+ function chapterStartSeconds(chapter) {
2225
+ if (!chapter) return NaN;
2226
+ if (chapter.startSeconds != null) return Number(chapter.startSeconds);
2227
+ return Number(chapter.start_time);
2228
+ }
2229
+
2230
+ function chapterEndSeconds(chapter) {
2231
+ if (!chapter) return NaN;
2232
+ if (chapter.endSeconds != null) return Number(chapter.endSeconds);
2233
+ return Number(chapter.end_time);
2234
+ }
2235
+
2236
+ function normalizeChapters(rawChapters, durationSeconds) {
2237
+ const duration = Number(durationSeconds) || 0;
2238
+ const list = Array.isArray(rawChapters) ? rawChapters.filter(Boolean) : [];
2239
+ if (!list.length) {
2240
+ return [{
2241
+ index: 1,
2242
+ title: 'full video',
2243
+ startSeconds: 0,
2244
+ endSeconds: duration || Infinity,
2245
+ }];
2246
+ }
2247
+ return list.map((chapter, index) => {
2248
+ const start = chapterStartSeconds(chapter);
2249
+ const startSeconds = Number.isFinite(start) ? start : 0;
2250
+ const nextStart = chapterStartSeconds(list[index + 1]);
2251
+ const explicitEnd = chapterEndSeconds(chapter);
2252
+ const endSeconds = Number.isFinite(explicitEnd)
2253
+ ? explicitEnd
2254
+ : (Number.isFinite(nextStart) ? nextStart : (duration || Infinity));
2255
+ const title = String(chapter.title || `section ${index + 1}`).trim() || `section ${index + 1}`;
2256
+ return { index: index + 1, title, startSeconds, endSeconds };
2257
+ });
2258
+ }
2259
+
2260
+ function sliceCuesForChapter(cues = [], chapter) {
2261
+ if (!chapter) return [];
2262
+ const startMs = Number(chapter.startSeconds) * 1000;
2263
+ const endMs = Number(chapter.endSeconds) * 1000;
2264
+ return (cues || []).filter((cue) => {
2265
+ const at = Number(cue.startMs);
2266
+ if (!Number.isFinite(at)) return false;
2267
+ if (!Number.isFinite(startMs)) return true;
2268
+ if (at < startMs) return false;
2269
+ if (Number.isFinite(endMs) && at >= endMs) return false;
2270
+ return true;
2271
+ });
2272
+ }
2273
+
2274
+ function teachCaptionWords(text) {
2275
+ return String(text || '')
2276
+ .replace(/\[\d{1,2}:\d{2}(?::\d{2})?\]/g, ' ')
2277
+ .replace(/\b\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\b/g, ' ')
2278
+ .replace(/[“”]/g, '"')
2279
+ .split(/\s+/)
2280
+ .map((word) => word.replace(/^[^A-Za-z0-9$%]+|[^A-Za-z0-9%]+$/g, ''))
2281
+ .filter(Boolean);
2282
+ }
2283
+
2284
+ function extractTeachNumbers(text) {
2285
+ // Keep a number only with its unit or a nearby claim. Bare 20/60 are crumbs.
2286
+ const words = teachCaptionWords(text);
2287
+ const found = [];
2288
+ const seen = new Set();
2289
+ const numberRe = /^(?:\$)?(\d[\d,]*(?:\.\d+)?)(%?)$/;
2290
+ const unitRe = new RegExp(`^(?:${NUMBER_UNITS})$`, 'i');
2291
+ const verbRe = new RegExp(`\\b(?:${NUMBER_CLAIM_VERBS})\\b`, 'i');
2292
+
2293
+ for (let i = 0; i < words.length; i += 1) {
2294
+ const match = words[i].match(numberRe);
2295
+ if (!match) continue;
2296
+ const nearby = words.slice(i + 1, i + 3);
2297
+ const unit = nearby.find((word) => unitRe.test(word));
2298
+ const hasPercent = match[2] === '%' || /%/.test(words[i]);
2299
+ if (!unit && !hasPercent) continue;
2300
+
2301
+ const windowText = words.slice(Math.max(0, i - 6), Math.min(words.length, i + 7)).join(' ');
2302
+ const verbMatch = windowText.match(verbRe);
2303
+ const number = match[1];
2304
+ let claim = hasPercent ? `${number}%` : `${number} ${String(unit).toLowerCase()}`;
2305
+ if (verbMatch) claim += ` to ${verbMatch[0].toLowerCase()}`;
2306
+ const key = claim.toLowerCase();
2307
+ if (seen.has(key)) continue;
2308
+ seen.add(key);
2309
+ found.push(claim);
2310
+ }
2311
+ return found.slice(0, 8);
2312
+ }
2313
+
2314
+ function extractTeachMechanisms(text) {
2315
+ // Named only: "Overton window", "omakase model". Drop quotes, swears, crumbs.
2316
+ const found = [];
2317
+ const seen = new Set();
2318
+ const add = (value) => {
2319
+ const token = String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
2320
+ if (!token || TEACH_SWEAR_RE.test(token)) return;
2321
+ if (seen.has(token) || MECHANISM_STOP.has(token) || GENERIC_MECHANISM_LEFT.has(token)) return;
2322
+ if (token.length < 4 && !/\d/.test(token)) return;
2323
+ seen.add(token);
2324
+ found.push(token);
2325
+ };
2326
+
2327
+ const raw = String(text || '');
2328
+ const namedRe = new RegExp(`\\b([A-Za-z][A-Za-z0-9+-]{2,})\\s+(${MECHANISM_HEADS})\\b`, 'gi');
2329
+ for (const match of raw.matchAll(namedRe)) {
2330
+ const left = String(match[1] || '').toLowerCase();
2331
+ if (GENERIC_MECHANISM_LEFT.has(left) || MECHANISM_STOP.has(left)) continue;
2332
+ add(`${match[1]} ${match[2]}`);
2333
+ }
2334
+ for (const match of raw.matchAll(/\b(\d+[A-Za-z][A-Za-z0-9]+)\b/g)) add(match[1]);
2335
+ return found.slice(0, 8);
2336
+ }
2337
+
2338
+ const LEARNER_CHECK_FILL = 'fill this';
2339
+ const LEARNER_SCORE_ZERO = 'score: 0';
2340
+ const LEARNER_CHECK_INCOMPLETE = 'incomplete: no measurable check. check: fill this';
2341
+ const LEARNER_BASELINE_INCOMPLETE = 'incomplete: check already passes. refuse invented success.';
2342
+ const LEARNER_APPLY_MISSING = 'incomplete: apply missing. check cannot score.';
2343
+
2344
+ function oneTeachCheck(mechanisms, numbers) {
2345
+ if (mechanisms[0]) {
2346
+ const name = mechanisms[0];
2347
+ const named = new RegExp(`\\b(?:${MECHANISM_HEADS})\\b`, 'i').test(name);
2348
+ if (named && !/^(the|a|an)\s/i.test(name)) return `what is the ${name}?`;
2349
+ return `what is ${name}?`;
2350
+ }
2351
+ if (numbers[0]) return `what does ${numbers[0]} measure in this chapter?`;
2352
+ return LEARNER_CHECK_FILL;
2353
+ }
2354
+
2355
+ function learnerCheckFromLesson(lesson = {}) {
2356
+ const mechanisms = Array.isArray(lesson.mechanisms) ? lesson.mechanisms : [];
2357
+ const numbers = Array.isArray(lesson.numbers) ? lesson.numbers : [];
2358
+ const inferred = mechanisms.length > 0 || numbers.length > 0;
2359
+ return {
2360
+ inferred,
2361
+ line: inferred ? oneTeachCheck(mechanisms, numbers) : LEARNER_CHECK_FILL,
2362
+ needles: teachCheckNeedles({ mechanisms, numbers }),
2363
+ };
2364
+ }
2365
+
2366
+ function scoreLearnerNeedles(text, needles) {
2367
+ const list = Array.isArray(needles) ? needles : [];
2368
+ if (!list.length) return 0;
2369
+ const blob = String(text || '').toLowerCase();
2370
+ return list.every((needle) => blob.includes(String(needle || '').toLowerCase())) ? 1 : 0;
2371
+ }
2372
+
2373
+ function printLearnerCheckGate(output, lesson, { includeCheck = false, json = false } = {}) {
2374
+ if (json || typeof output !== 'function') return;
2375
+ const check = learnerCheckFromLesson(lesson);
2376
+ if (includeCheck) output(`check: ${check.line}`);
2377
+ if (check.inferred) output(LEARNER_SCORE_ZERO);
2378
+ }
2379
+
2380
+ function proveSavedLearnerBaseline({ cwd, applyRel, lesson, output, json } = {}) {
2381
+ const print = typeof output === 'function' ? output : () => {};
2382
+ const check = learnerCheckFromLesson(lesson);
2383
+ if (!check.inferred) {
2384
+ if (!json) print(LEARNER_CHECK_INCOMPLETE);
2385
+ return 2;
2386
+ }
2387
+ if (!cwd || !applyRel) {
2388
+ if (!json) print(LEARNER_APPLY_MISSING);
2389
+ return 2;
2390
+ }
2391
+ const abs = path.join(cwd, applyRel);
2392
+ let text = '';
2393
+ try {
2394
+ if (!fs.existsSync(abs)) {
2395
+ if (!json) print(LEARNER_APPLY_MISSING);
2396
+ return 2;
2397
+ }
2398
+ text = fs.readFileSync(abs, 'utf8');
2399
+ } catch {
2400
+ if (!json) print(LEARNER_APPLY_MISSING);
2401
+ return 2;
2402
+ }
2403
+ if (scoreLearnerNeedles(text, check.needles) !== 0) {
2404
+ if (!json) print(LEARNER_BASELINE_INCOMPLETE);
2405
+ return 2;
2406
+ }
2407
+ if (!json) print(LEARNER_SCORE_ZERO);
2408
+ return 0;
2409
+ }
2410
+
2411
+ function quoteYoutubeUrl(url) {
2412
+ return `"${String(url || '').replace(/"/g, '')}"`;
2413
+ }
2414
+
2415
+ function teachLessonFromCues({ url, section, chapters, chapter, cues, title } = {}) {
2416
+ const total = Array.isArray(chapters) && chapters.length ? chapters.length : 1;
2417
+ const heading = String(chapter?.title || 'full video').trim().toLowerCase();
2418
+ const videoTitle = String(title || '').trim().toLowerCase();
2419
+ const body = (cues || []).map((cue) => cue.text).join(' ');
2420
+ const numbers = extractTeachNumbers(body);
2421
+ const mechanisms = extractTeachMechanisms(body);
2422
+ const lines = [
2423
+ `section ${section}/${total} ${heading}`,
2424
+ ];
2425
+ if (videoTitle) lines.push(videoTitle);
2426
+ lines.push('');
2427
+ lines.push('numbers');
2428
+ if (numbers.length) lines.push(...numbers);
2429
+ else lines.push('none');
2430
+ lines.push('');
2431
+ lines.push('mechanisms');
2432
+ if (mechanisms.length) lines.push(...mechanisms);
2433
+ else lines.push('none');
2434
+ lines.push('');
2435
+ lines.push('check');
2436
+ lines.push(oneTeachCheck(mechanisms, numbers, heading));
2437
+ if (section < total) {
2438
+ lines.push('');
2439
+ lines.push(TEACH_RESUME_NEXT);
2440
+ }
2441
+ return { text: lines.join('\n'), numbers, mechanisms };
2442
+ }
2443
+
2444
+ function formatTeachLesson(opts) {
2445
+ return teachLessonFromCues(opts).text;
2446
+ }
2447
+
2448
+ function isThinTeachLesson(lesson = {}) {
2449
+ const numbers = Array.isArray(lesson.numbers) ? lesson.numbers : [];
2450
+ const mechanisms = Array.isArray(lesson.mechanisms) ? lesson.mechanisms : [];
2451
+ return numbers.length === 0 && mechanisms.length === 0;
2452
+ }
2453
+
2454
+ function teachBriefRel(id, section) {
2455
+ return `atris/wiki/briefs/youtube-${id}-s${section}.md`;
2456
+ }
2457
+
2458
+ function experimentIdToken(id) {
2459
+ return String(id || 'video')
2460
+ .toLowerCase()
2461
+ .replace(/_/g, '-')
2462
+ .replace(/[^a-z0-9-]+/g, '')
2463
+ .replace(/-+/g, '-')
2464
+ .replace(/^-+|-+$/g, '') || 'video';
2465
+ }
2466
+
2467
+ function teachExperimentSlug(id, section) {
2468
+ return `teach-${experimentIdToken(id)}-s${Number(section) || 1}`;
2469
+ }
2470
+
2471
+ function teachExperimentRel(id, section) {
2472
+ return `atris/experiments/${teachExperimentSlug(id, section)}`;
2473
+ }
2474
+
2475
+ function teachCheckNeedles(lesson = {}) {
2476
+ const mechanisms = Array.isArray(lesson.mechanisms) ? lesson.mechanisms : [];
2477
+ const numbers = Array.isArray(lesson.numbers) ? lesson.numbers : [];
2478
+ if (mechanisms[0]) return [String(mechanisms[0]).toLowerCase()];
2479
+ if (numbers[0]) return [String(numbers[0]).toLowerCase()];
2480
+ return [];
2481
+ }
2482
+
2483
+ function teachRecapTokens(lesson = {}) {
2484
+ const tokens = [];
2485
+ const seen = new Set();
2486
+ for (const value of [...(lesson.mechanisms || []), ...(lesson.numbers || [])]) {
2487
+ const token = String(value || '').trim().toLowerCase();
2488
+ if (!token || seen.has(token)) continue;
2489
+ seen.add(token);
2490
+ tokens.push(token);
2491
+ }
2492
+ return tokens;
2493
+ }
2494
+
2495
+ function recapHitsTokens(text, tokens) {
2496
+ const hay = String(text || '').toLowerCase();
2497
+ return (tokens || []).some((token) => {
2498
+ const needle = String(token || '').trim().toLowerCase();
2499
+ return needle.length > 0 && hay.includes(needle);
2500
+ });
2501
+ }
2502
+
2503
+ function teachOwedPath(deps = {}) {
2504
+ if (deps.teachOwedPath) return deps.teachOwedPath;
2505
+ const cwd = deps.cwd || process.cwd();
2506
+ return path.join(cwd, '.atris', TEACH_OWED_FILE);
2507
+ }
2508
+
2509
+ function readTeachOwedStore(deps = {}) {
2510
+ try {
2511
+ const parsed = JSON.parse(fs.readFileSync(teachOwedPath(deps), 'utf8'));
2512
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
2513
+ return parsed;
2514
+ } catch {
2515
+ return {};
2516
+ }
2517
+ }
2518
+
2519
+ function writeTeachOwedStore(deps, store) {
2520
+ try {
2521
+ const filePath = teachOwedPath(deps);
2522
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
2523
+ fs.writeFileSync(filePath, `${JSON.stringify(store || {})}\n`);
2524
+ } catch {
2525
+ // owed recap is ephemeral; a failed write must not break teach
2526
+ }
2527
+ }
2528
+
2529
+ function rememberTeachOwed(deps, { url, section, lesson, total } = {}) {
2530
+ const id = videoIdFromUrl(url);
2531
+ if (!id) return;
2532
+ const store = readTeachOwedStore(deps);
2533
+ const row = {
2534
+ section: Number(section) || 1,
2535
+ check: teachCheckLine(lesson),
2536
+ tokens: teachRecapTokens(lesson),
2537
+ url: url || undefined,
2538
+ };
2539
+ const totalChapters = Number(total);
2540
+ if (Number.isInteger(totalChapters) && totalChapters > 0) row.total = totalChapters;
2541
+ store[id] = row;
2542
+ writeTeachOwedStore(deps, store);
2543
+ }
2544
+
2545
+ function writeTeachContinue(store, id, entry) {
2546
+ const nextSection = (Number(entry && entry.section) || 1) + 1;
2547
+ const total = Number(entry && entry.total);
2548
+ if (Number.isInteger(total) && total > 0 && nextSection > total) {
2549
+ delete store[id];
2550
+ return null;
2551
+ }
2552
+ const cursor = {
2553
+ section: nextSection,
2554
+ url: (entry && entry.url) || undefined,
2555
+ };
2556
+ if (Number.isInteger(total) && total > 0) cursor.total = total;
2557
+ store[id] = cursor;
2558
+ return cursor;
2559
+ }
2560
+
2561
+ function lastTeachContinueEntry(store) {
2562
+ const ids = Object.keys(store || {});
2563
+ for (let i = ids.length - 1; i >= 0; i -= 1) {
2564
+ const entry = store[ids[i]];
2565
+ if (!entry || typeof entry !== 'object') continue;
2566
+ if (String(entry.check || '').trim()) continue;
2567
+ const url = String(entry.url || '').trim();
2568
+ if (!url) continue;
2569
+ return {
2570
+ id: ids[i],
2571
+ url,
2572
+ section: Number(entry.section) || 1,
2573
+ };
2574
+ }
2575
+ return null;
2576
+ }
2577
+
2578
+ function lastTeachUnpaidEntry(store) {
2579
+ const ids = Object.keys(store || {});
2580
+ for (let i = ids.length - 1; i >= 0; i -= 1) {
2581
+ const entry = store[ids[i]];
2582
+ if (!entry || typeof entry !== 'object') continue;
2583
+ const check = String(entry.check || '').trim();
2584
+ if (!check) continue;
2585
+ return {
2586
+ id: ids[i],
2587
+ check,
2588
+ url: entry.url || null,
2589
+ section: Number(entry.section) || 1,
2590
+ };
2591
+ }
2592
+ return null;
2593
+ }
2594
+
2595
+ function previousTeachUnlocked(owed, section) {
2596
+ if (!owed) return true;
2597
+ return Number(owed.section) >= Number(section);
2598
+ }
2599
+
2600
+ function printTeachUnlockNext(parsed, entry, output) {
2601
+ if (!parsed || parsed.json) return;
2602
+ const url = parsed.url || (entry && entry.url) || '';
2603
+ if (!url) return;
2604
+ output(TEACH_CONTINUE_NEXT);
2605
+ }
2606
+
2607
+ function printTeachWatchTickNext(parsed, lesson, total, output) {
2608
+ if (!parsed || parsed.json) return;
2609
+ if (parsed.save && !isThinTeachLesson(lesson)) return;
2610
+ const section = Number(parsed.section) || 0;
2611
+ if (section < Number(total)) return;
2612
+ printWatchTickNext(output);
2613
+ }
2614
+
2615
+ function listTeachOwedEntries(store) {
2616
+ return Object.keys(store || {}).map((id) => {
2617
+ const entry = store[id] && typeof store[id] === 'object' ? store[id] : {};
2618
+ return {
2619
+ id,
2620
+ url: entry.url || null,
2621
+ section: Number(entry.section) || 1,
2622
+ check: String(entry.check || '').trim(),
2623
+ };
2624
+ }).filter((row) => row.check);
2625
+ }
2626
+
2627
+ function printTeachOwed(parsed, deps, output) {
2628
+ const rows = listTeachOwedEntries(readTeachOwedStore(deps));
2629
+ if (parsed.json) {
2630
+ output(JSON.stringify(rows));
2631
+ return 0;
2632
+ }
2633
+ if (!rows.length) {
2634
+ output('nothing owed');
2635
+ if (parsed.resume) output(TEACH_RESUME_START);
2636
+ return 0;
2637
+ }
2638
+ rows.forEach((row, index) => {
2639
+ if (index) output('');
2640
+ output(row.url || row.id);
2641
+ output(`section ${row.section}`);
2642
+ if (row.check) output(row.check);
2643
+ });
2644
+ if (parsed.resume) output(TEACH_RESUME_NEXT);
2645
+ return 0;
2646
+ }
2647
+
2648
+ function applyTeachRecap(parsed, deps, output) {
2649
+ const store = readTeachOwedStore(deps);
2650
+ let id = parsed.url ? videoIdFromUrl(parsed.url) : null;
2651
+ let entry = id ? store[id] : null;
2652
+ if (!entry && !id) {
2653
+ const ids = Object.keys(store);
2654
+ const unpaidIds = ids.filter((key) => store[key] && String(store[key].check || '').trim());
2655
+ if (parsed.recap) {
2656
+ id = unpaidIds.find((key) => recapHitsTokens(parsed.recap, store[key] && store[key].tokens));
2657
+ }
2658
+ if (!id && unpaidIds.length === 1) id = unpaidIds[0];
2659
+ if (!id && ids.length === 1) id = ids[0];
2660
+ entry = id ? store[id] : null;
2661
+ }
2662
+ if (!entry) {
2663
+ if (parsed.skip) return 0;
2664
+ if (!parsed.json) output('recap the previous section first');
2665
+ return 2;
2666
+ }
2667
+ if (!String(entry.check || '').trim()) {
2668
+ printTeachUnlockNext(parsed, entry, output);
2669
+ return 0;
2670
+ }
2671
+ if (parsed.skip) {
2672
+ printTeachUnlockNext(parsed, entry, output);
2673
+ writeTeachContinue(store, id, entry);
2674
+ writeTeachOwedStore(deps, store);
2675
+ return 0;
2676
+ }
2677
+ if (!recapHitsTokens(parsed.recap, entry.tokens)) {
2678
+ if (!parsed.json) output(entry.check);
2679
+ return 2;
2680
+ }
2681
+ printTeachUnlockNext(parsed, entry, output);
2682
+ writeTeachContinue(store, id, entry);
2683
+ writeTeachOwedStore(deps, store);
2684
+ return 0;
2685
+ }
2686
+
2687
+ function applyTeachNext(parsed, deps, output) {
2688
+ const store = readTeachOwedStore(deps);
2689
+ const cont = lastTeachContinueEntry(store);
2690
+ if (cont) {
2691
+ parsed.url = cont.url;
2692
+ parsed.section = cont.section;
2693
+ return 0;
2694
+ }
2695
+ const unpaid = lastTeachUnpaidEntry(store);
2696
+ if (unpaid) {
2697
+ if (!parsed.json) output(unpaid.check);
2698
+ return 2;
2699
+ }
2700
+ if (parsed.json) {
2701
+ output('[]');
2702
+ return 0;
2703
+ }
2704
+ output('nothing owed');
2705
+ output(TEACH_RESUME_START);
2706
+ return 0;
2707
+ }
2708
+
2709
+ function applyTeachResume(parsed, deps, output) {
2710
+ const store = readTeachOwedStore(deps);
2711
+ if (lastTeachUnpaidEntry(store)) return printTeachOwed(parsed, deps, output);
2712
+ return applyTeachNext(parsed, deps, output);
2713
+ }
2714
+
2715
+ function teachCheckLine(lesson = {}) {
2716
+ return oneTeachCheck(lesson.mechanisms || [], lesson.numbers || [], '');
2717
+ }
2718
+
2719
+ function fileTeachExperiment({ cwd, url, section, lesson, slug, applyRel } = {}) {
2720
+ try {
2721
+ const id = url ? videoIdFromUrl(url) : null;
2722
+ if (!cwd) return null;
2723
+ const packSlug = slug || (id ? teachExperimentSlug(id, section) : null);
2724
+ if (!packSlug) return null;
2725
+ const rel = `atris/experiments/${packSlug}`;
2726
+ const dir = path.join(cwd, rel);
2727
+ fs.mkdirSync(dir, { recursive: true });
2728
+
2729
+ const check = teachCheckLine(lesson);
2730
+ const needles = teachCheckNeedles(lesson);
2731
+ const sidecarRel = applyRel || (id ? applySidecarRel(`${id}-s${section}`) : null);
2732
+ if (!sidecarRel) return null;
2733
+ const program = [
2734
+ '# Program',
2735
+ '',
2736
+ `Target: the taught fail-able check ${JSON.stringify(check)}. measure.py scores 1 only when the fixture contains ${needles.map((n) => JSON.stringify(n)).join(' and ') || 'the check tokens'}. The default fixture is the teach apply sidecar. Score is 0 if that file is missing or omits the token. Keep a candidate only when the score moves from 0 to 1.`,
2737
+ '',
2738
+ ].join('\n');
2739
+
2740
+ const measurePy = [
2741
+ '"""Score whether the taught check is present in the product fixture."""',
2742
+ '',
2743
+ 'from __future__ import annotations',
2744
+ '',
2745
+ 'import json',
2746
+ 'import os',
2747
+ 'from pathlib import Path',
2748
+ '',
2749
+ '',
2750
+ 'EXPERIMENT_DIR = Path(__file__).resolve().parent',
2751
+ `CHECK = ${JSON.stringify(check)}`,
2752
+ `NEEDLES = ${JSON.stringify(needles)}`,
2753
+ `DEFAULT_TARGET = ${JSON.stringify(sidecarRel)}`,
2754
+ '',
2755
+ '',
2756
+ 'def repo_root() -> Path:',
2757
+ ' env = os.environ.get("ATRIS_REPO_ROOT")',
2758
+ ' if env:',
2759
+ ' return Path(env).resolve()',
2760
+ ' return EXPERIMENT_DIR.parents[2]',
2761
+ '',
2762
+ '',
2763
+ 'def fixture_path():',
2764
+ ' env = os.environ.get("ATRIS_TEACH_MEASURE_FIXTURE")',
2765
+ ' if env:',
2766
+ ' return Path(env).resolve()',
2767
+ ' target = repo_root() / DEFAULT_TARGET',
2768
+ ' return target if target.is_file() else None',
2769
+ '',
2770
+ '',
2771
+ 'def fail_payload(reason: str) -> dict:',
2772
+ ' return {',
2773
+ ' "score": 0,',
2774
+ ' "passed": 0,',
2775
+ ' "total": 1,',
2776
+ ' "status": "fail",',
2777
+ ' "reason": reason,',
2778
+ ' "check": CHECK,',
2779
+ ' }',
2780
+ '',
2781
+ '',
2782
+ 'def score_text(text: str) -> dict:',
2783
+ ' blob = text.lower()',
2784
+ ' found = bool(NEEDLES) and all(needle.lower() in blob for needle in NEEDLES)',
2785
+ ' score = 1 if found else 0',
2786
+ ' return {',
2787
+ ' "score": score,',
2788
+ ' "passed": score,',
2789
+ ' "total": 1,',
2790
+ ' "status": "pass" if score == 1 else "fail",',
2791
+ ' "check": CHECK,',
2792
+ ' }',
2793
+ '',
2794
+ '',
2795
+ 'def main() -> int:',
2796
+ ' path = fixture_path()',
2797
+ ' if path is None or not path.is_file():',
2798
+ ' payload = fail_payload("fixture missing")',
2799
+ ' else:',
2800
+ ' payload = score_text(path.read_text(encoding="utf-8"))',
2801
+ ' print(json.dumps(payload))',
2802
+ ' return 0',
2803
+ '',
2804
+ '',
2805
+ 'if __name__ == "__main__":',
2806
+ ' raise SystemExit(main())',
2807
+ '',
2808
+ ].join('\n');
2809
+
2810
+ const loopPy = [
2811
+ '"""Keep a candidate only when the taught-check score moves from 0 to 1."""',
2812
+ '',
2813
+ 'from __future__ import annotations',
2814
+ '',
2815
+ 'import argparse',
2816
+ 'import csv',
2817
+ 'import json',
2818
+ 'import os',
2819
+ 'from pathlib import Path',
2820
+ 'import subprocess',
2821
+ 'import sys',
2822
+ 'from datetime import datetime, timezone',
2823
+ '',
2824
+ '',
2825
+ 'EXPERIMENT_DIR = Path(__file__).resolve().parent',
2826
+ 'DEFAULT_MEASURE = EXPERIMENT_DIR / "measure.py"',
2827
+ 'DEFAULT_RESULTS = EXPERIMENT_DIR / "results.tsv"',
2828
+ '',
2829
+ '',
2830
+ 'def run_measure(measure_path: Path) -> dict:',
2831
+ ' proc = subprocess.run(',
2832
+ ' [sys.executable, str(measure_path)],',
2833
+ ' cwd=str(EXPERIMENT_DIR),',
2834
+ ' capture_output=True,',
2835
+ ' text=True,',
2836
+ ' check=True,',
2837
+ ' )',
2838
+ ' return json.loads(proc.stdout.strip().splitlines()[-1])',
2839
+ '',
2840
+ '',
2841
+ 'def append_result(results_path: Path, row: dict) -> None:',
2842
+ ' write_header = not results_path.exists() or results_path.stat().st_size == 0',
2843
+ ' with results_path.open("a", newline="", encoding="utf-8") as handle:',
2844
+ ' writer = csv.DictWriter(',
2845
+ ' handle,',
2846
+ ' fieldnames=[',
2847
+ ' "timestamp",',
2848
+ ' "trial",',
2849
+ ' "status",',
2850
+ ' "old_score",',
2851
+ ' "new_score",',
2852
+ ' "proposal",',
2853
+ ' "description",',
2854
+ ' ],',
2855
+ ' delimiter="\\t",',
2856
+ ' )',
2857
+ ' if write_header:',
2858
+ ' writer.writeheader()',
2859
+ ' writer.writerow(row)',
2860
+ '',
2861
+ '',
2862
+ 'def main() -> int:',
2863
+ ' parser = argparse.ArgumentParser(description="Run the taught-check keep/revert loop.")',
2864
+ ' parser.add_argument("--proposal", action="append", default=[])',
2865
+ ' args = parser.parse_args()',
2866
+ '',
2867
+ ' measure_path = DEFAULT_MEASURE.resolve()',
2868
+ ' results_path = DEFAULT_RESULTS.resolve()',
2869
+ '',
2870
+ ' baseline = run_measure(measure_path)',
2871
+ ' current_score = float(baseline["score"])',
2872
+ ' print(f"BASELINE {current_score:.4f}")',
2873
+ '',
2874
+ ' if not args.proposal:',
2875
+ ' append_result(',
2876
+ ' results_path,',
2877
+ ' {',
2878
+ ' "timestamp": datetime.now(timezone.utc).isoformat(),',
2879
+ ' "trial": 0,',
2880
+ ' "status": "baseline",',
2881
+ ' "old_score": f"{current_score:.4f}",',
2882
+ ' "new_score": f"{current_score:.4f}",',
2883
+ ' "proposal": "measure.py",',
2884
+ ' "description": "current taught-check fixture score",',
2885
+ ' },',
2886
+ ' )',
2887
+ '',
2888
+ ' for trial_index, proposal in enumerate(args.proposal, start=1):',
2889
+ ' proposal_path = Path(proposal).resolve()',
2890
+ ' status = "error"',
2891
+ ' old_score = current_score',
2892
+ ' new_score = current_score',
2893
+ ' description = ""',
2894
+ '',
2895
+ ' try:',
2896
+ ' proc = subprocess.run(',
2897
+ ' [sys.executable, str(proposal_path)],',
2898
+ ' cwd=str(EXPERIMENT_DIR),',
2899
+ ' capture_output=True,',
2900
+ ' text=True,',
2901
+ ' check=True,',
2902
+ ' env={**os.environ, "EXPERIMENT_DIR": str(EXPERIMENT_DIR)},',
2903
+ ' )',
2904
+ ' if proc.stdout.strip():',
2905
+ ' description = proc.stdout.strip().splitlines()[-1][:200]',
2906
+ '',
2907
+ ' measured = run_measure(measure_path)',
2908
+ ' new_score = float(measured["score"])',
2909
+ ' if old_score < 1 and new_score > old_score:',
2910
+ ' status = "kept"',
2911
+ ' current_score = new_score',
2912
+ ' else:',
2913
+ ' status = "reverted"',
2914
+ ' except subprocess.CalledProcessError as exc:',
2915
+ ' stderr = (exc.stderr or exc.stdout or "").strip()',
2916
+ ' description = (stderr.splitlines()[-1] if stderr else "proposal failed")[:200]',
2917
+ ' status = "error"',
2918
+ '',
2919
+ ' append_result(',
2920
+ ' results_path,',
2921
+ ' {',
2922
+ ' "timestamp": datetime.now(timezone.utc).isoformat(),',
2923
+ ' "trial": trial_index,',
2924
+ ' "status": status,',
2925
+ ' "old_score": f"{old_score:.4f}",',
2926
+ ' "new_score": f"{new_score:.4f}",',
2927
+ ' "proposal": proposal_path.name,',
2928
+ ' "description": description,',
2929
+ ' },',
2930
+ ' )',
2931
+ ' print(f"TRIAL {trial_index} {status.upper()} score={new_score:.4f} proposal={proposal_path.name}")',
2932
+ '',
2933
+ ' final_measure = run_measure(measure_path)',
2934
+ ' print(f"FINAL {final_measure[\'score\']:.4f}")',
2935
+ ' return 0',
2936
+ '',
2937
+ '',
2938
+ 'if __name__ == "__main__":',
2939
+ ' raise SystemExit(main())',
2940
+ '',
2941
+ ].join('\n');
2942
+
2943
+ fs.writeFileSync(path.join(dir, 'program.md'), program);
2944
+ fs.writeFileSync(path.join(dir, 'measure.py'), measurePy);
2945
+ fs.writeFileSync(path.join(dir, 'loop.py'), loopPy);
2946
+ fs.writeFileSync(
2947
+ path.join(dir, 'reset.py'),
2948
+ [
2949
+ '"""The target is the product fixture, not a local candidate file."""',
2950
+ '',
2951
+ 'print("teach experiment target is the apply sidecar or ATRIS_TEACH_MEASURE_FIXTURE; nothing local to restore")',
2952
+ '',
2953
+ ].join('\n'),
2954
+ );
2955
+ fs.writeFileSync(
2956
+ path.join(dir, 'results.tsv'),
2957
+ 'timestamp\ttrial\tstatus\told_score\tnew_score\tproposal\tdescription\n',
2958
+ );
2959
+ return rel;
2960
+ } catch {
2961
+ return null;
2962
+ }
2963
+ }
2964
+
2965
+ function fileTeachBrief({ cwd, url, section, lesson, now } = {}) {
2966
+ try {
2967
+ const id = videoIdFromUrl(url);
2968
+ if (!id || !cwd) return null;
2969
+ const wikiDir = path.join(cwd, 'atris', 'wiki');
2970
+ if (!fs.existsSync(wikiDir)) return null;
2971
+ const rel = teachBriefRel(id, section);
2972
+ const briefsDir = path.join(cwd, 'atris', 'wiki', 'briefs');
2973
+ fs.mkdirSync(briefsDir, { recursive: true });
2974
+ const date = dateStamp(now);
2975
+ const header = [
2976
+ String(lesson || '').split('\n')[0] || `teach section ${section}`,
2977
+ '',
2978
+ `date: ${date}`,
2979
+ `source: ${url}`,
2980
+ `section: ${section}`,
2981
+ 'rail: atris youtube teach, one chapter from local captions',
2982
+ ].join('\n');
2983
+ fs.writeFileSync(path.join(cwd, rel), `${header}\n\n${lesson}\n`);
2984
+
2985
+ const journalPath = path.join(cwd, 'atris', 'logs', date.slice(0, 4), `${date}.md`);
2986
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
2987
+ let existing = '';
2988
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
2989
+ const line = `- [claimable] taught: section ${section} -> ${rel}`;
2990
+ if (!existing.includes(line)) {
2991
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
2992
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
2993
+ }
2994
+ return rel;
2995
+ } catch {
2996
+ return null;
2997
+ }
2998
+ }
2999
+
3000
+ function ensureTeachApply({ cwd, url, section, packRel, now, output } = {}) {
3001
+ const id = videoIdFromUrl(url);
3002
+ const pack = packRel || (id ? teachExperimentRel(id, section) : null);
3003
+ const slug = pack ? path.basename(pack) : null;
3004
+ return applyGate.ensureApply({
3005
+ cwd,
3006
+ source: url,
3007
+ rel: id ? applySidecarRel(`${id}-s${section}`) : null,
3008
+ now,
3009
+ output,
3010
+ incompleteMessage: slug
3011
+ ? `next: atris experiments keep ${slug}`
3012
+ : TEACH_APPLY_NEXT_MESSAGE,
3013
+ required: false,
3014
+ change: pack ? `apply ${pack}` : undefined,
3015
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
3016
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
3017
+ });
3018
+ }
3019
+
3020
+ async function extractTeachSource(youtubeUrl, deps = {}) {
3021
+ if (typeof deps.extractTeachSource === 'function') {
3022
+ return deps.extractTeachSource(youtubeUrl, deps);
3023
+ }
3024
+ const runner = deps.spawnSync || spawnSync;
3025
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
3026
+ encoding: 'utf8',
3027
+ timeout: 20000,
3028
+ maxBuffer: 64 * 1024 * 1024,
3029
+ });
3030
+ const info = parseYtDlpInfoJson(result);
3031
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
3032
+ const cues = parseCaptionCues(loaded && loaded.raw);
3033
+ if (!cues.length) return null;
3034
+
3035
+ return {
3036
+ id: teachSourceVideoId(info, youtubeUrl),
3037
+ title: (info && info.title) || '',
3038
+ url: youtubeUrl,
3039
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
3040
+ language: loaded.language || 'unknown',
3041
+ chapters: normalizeChapters(info && info.chapters, info && info.duration),
3042
+ cues,
3043
+ };
3044
+ }
3045
+
3046
+ module.exports = {
3047
+ DEFAULT_QUERY,
3048
+ DEFAULT_TIMEOUT_MS,
3049
+ YTNOTES_USAGE,
3050
+ YTNOTES_HINT,
3051
+ DIGEST_ENGINE_TIMEOUT_MS,
3052
+ WATCH_TICK_NEXT,
3053
+ APPLY_NEXT_MESSAGE,
3054
+ PROCESS_APPLY_MESSAGE,
3055
+ DEFAULT_SEARCH_LIMIT,
3056
+ PAID_SEARCH_TIMEOUT_MS,
3057
+ PAID_SEARCH_COST_HINT,
3058
+ YTSEARCH_USAGE,
3059
+ LOCAL_SEARCH_RATE_LIMIT_MESSAGE,
3060
+ LOCAL_SEARCH_CACHE_NOTE,
3061
+ PAID_SEARCH_FRESH_CACHE_REFUSE,
3062
+ YTTEACH_USAGE,
3063
+ TEACH_THIN_REFUSE,
3064
+ TEACH_RESUME_NEXT,
3065
+ TEACH_WATCH_TICK_NEXT,
3066
+ LEARNER_CHECK_FILL,
3067
+ LEARNER_SCORE_ZERO,
3068
+ LEARNER_BASELINE_INCOMPLETE,
3069
+ parseYoutubeArgs,
3070
+ buildYoutubePayload,
3071
+ extractLocalTranscript,
3072
+ parseYtDlpInfoJson,
3073
+ shouldRetryWithLocalTranscript,
3074
+ formatYoutubeResult,
3075
+ resultErrorText,
3076
+ youtubeFailureError,
3077
+ ensureProcessApply,
3078
+ processExperimentSlug,
3079
+ processApplyRel,
3080
+ mintRichProcess,
3081
+ videoIdFromUrl,
3082
+ looksLikeYoutubeUrl,
3083
+ dateStamp,
3084
+ fileBriefFromNotes,
3085
+ readNotesText,
3086
+ keptPrintedNotes,
3087
+ notesLessonFromText,
3088
+ notesExperimentSlug,
3089
+ applySidecarRel,
3090
+ ensureNotesApply,
3091
+ unsaveYoutubeNotes,
3092
+ parseDigestArgs,
3093
+ collectVideoBriefs,
3094
+ buildDigestPrompt,
3095
+ appendDigestJournal,
3096
+ digestExperimentSlug,
3097
+ digestApplyRel,
3098
+ saveRichDigest,
3099
+ ensureDigestApply,
3100
+ normalizeWatchChannel,
3101
+ channelVideosUrl,
3102
+ resolveWatchStatePath,
3103
+ loadWatchState,
3104
+ saveWatchState,
3105
+ stampNow,
3106
+ parseFlatPlaylist,
3107
+ defaultChannelFetcher,
3108
+ defaultNotesRunner,
3109
+ channelSeenMap,
3110
+ markSeen,
3111
+ firstRichWatchLesson,
3112
+ watchExperimentSlug,
3113
+ watchApplyRel,
3114
+ saveRichWatch,
3115
+ ensureWatchApply,
3116
+ printWatchTickNext,
3117
+ printWatchAddNext,
3118
+ printWatchSearchNext,
3119
+ isPlaylistUrl,
3120
+ parseNotesArgs,
3121
+ notesWorkDir,
3122
+ expandNotesTargets,
3123
+ runYoutubeNotesBatch,
3124
+ runSingleYoutubeNotes,
3125
+ parseSearchArgs,
3126
+ parseSearchStdout,
3127
+ formatSearchResults,
3128
+ printYoutubeTeachNext,
3129
+ printSearchOutcome,
3130
+ readFreshLocalSearchCache,
3131
+ paidSearchPayload,
3132
+ paidSearchVideos,
3133
+ paidSearchCredits,
3134
+ formatCreditsLines,
3135
+ formatPaidSearchResults,
3136
+ youtubeSearchFailureError,
3137
+ searchExperimentSlug,
3138
+ searchApplyRel,
3139
+ searchApplyNow,
3140
+ gateSearchLearner,
3141
+ searchRowsFromResult,
3142
+ finishSuccessfulSearch,
3143
+ defaultSearchRunner,
3144
+ searchRunnerDetail,
3145
+ isLocalSearchRateLimited,
3146
+ searchRunnerStatus,
3147
+ waitLocalSearchBackoff,
3148
+ isMissingSearchBinary,
3149
+ parseTeachArgs,
3150
+ parseCaptionCues,
3151
+ normalizeChapters,
3152
+ sliceCuesForChapter,
3153
+ teachLessonFromCues,
3154
+ formatTeachLesson,
3155
+ extractTeachNumbers,
3156
+ extractTeachMechanisms,
3157
+ extractTeachSource,
3158
+ readLocalCaptionText,
3159
+ oneTeachCheck,
3160
+ learnerCheckFromLesson,
3161
+ scoreLearnerNeedles,
3162
+ printLearnerCheckGate,
3163
+ proveSavedLearnerBaseline,
3164
+ isThinTeachLesson,
3165
+ teachExperimentSlug,
3166
+ readTeachOwedStore,
3167
+ rememberTeachOwed,
3168
+ previousTeachUnlocked,
3169
+ printTeachWatchTickNext,
3170
+ printTeachOwed,
3171
+ applyTeachRecap,
3172
+ applyTeachNext,
3173
+ applyTeachResume,
3174
+ fileTeachExperiment,
3175
+ fileTeachBrief,
3176
+ ensureTeachApply,
3177
+ };
3178
+