atris 3.56.0 → 3.56.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.
@@ -1,5 +1,6 @@
1
1
  const { apiRequestJson } = require('../utils/api');
2
2
  const { ensureBilledCommandAuth } = require('./auth');
3
+ const applyGate = require('../lib/apply-gate');
3
4
  const { spawnSync } = require('child_process');
4
5
  const fs = require('fs');
5
6
  const os = require('os');
@@ -28,7 +29,9 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
28
29
  output('');
29
30
  output(`Usage: ${commandName} search "<query>" [--limit N] [--json]`);
30
31
  output(` ${commandName} search --paid "<query>" [--limit N] [--json]`);
31
- output(` ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine]`);
32
+ output(` ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine] [--save]`);
33
+ output(` ${commandName} teach <youtube-url> [--section N] [--save]`);
34
+ output(` ${commandName} unsave <url-or-id>`);
32
35
  output(` ${commandName} process <youtube-url> [options]`);
33
36
  output(` ${commandName} digest [--days N]`);
34
37
  output(` ${commandName} watch add <channel-url-or-@handle>`);
@@ -39,8 +42,9 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
39
42
  output('');
40
43
  output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links');
41
44
  output('search --paid = 5 credits, watch permalinks + titles from Atris');
42
- output('notes = free local notes for one url, several urls, or a playlist');
43
- output('process = 5 credits cloud knowledge');
45
+ output('notes = free local notes to stdout; ephemeral unless --save');
46
+ output('teach = one chapter from local captions; ephemeral unless --save');
47
+ output('process = 5 credits cloud knowledge (needs a filled Apply)');
44
48
  output('digest = one decision page from this week\'s video briefs');
45
49
  output('watch = subscribed channels turn into briefs without a human');
46
50
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
@@ -49,6 +53,9 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
49
53
  output('Options:');
50
54
  output(' --limit <n> Max search results (default: 5)');
51
55
  output(' --paid Bill 5 credits for watch permalinks (search only)');
56
+ output(' --save File brief, journal line, and apply stub (notes and teach)');
57
+ output(' --section <n> Chapter to teach, 1-based (teach only, default: 1)');
58
+ output(' --unsave Delete filed brief and apply stub (no paid calls)');
52
59
  output(' --query, -q <text> Focus question for the analysis');
53
60
  output(' --agent <id> Agent id to store knowledge against');
54
61
  output(' --store Save as agent knowledge (requires --agent)');
@@ -64,6 +71,11 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
64
71
  output(` ${commandName} search "MCP agents" --limit 10`);
65
72
  output(` ${commandName} search --paid "MCP agents 2026"`);
66
73
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
74
+ output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID --save`);
75
+ output(` ${commandName} teach "https://www.youtube.com/watch?v=VIDEO_ID"`);
76
+ output(` ${commandName} teach "https://www.youtube.com/watch?v=VIDEO_ID" --section 2`);
77
+ output(` ${commandName} notes --unsave VIDEO_ID`);
78
+ output(` ${commandName} unsave VIDEO_ID`);
67
79
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID https://youtu.be/OTHER_ID`);
68
80
  output(` ${commandName} notes https://www.youtube.com/playlist?list=PLAYLIST_ID`);
69
81
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
@@ -300,14 +312,14 @@ function fetchCaptionText(urlString, redirects = 0) {
300
312
  });
301
313
  }
302
314
 
303
- function parseCaptionText(raw) {
315
+ function parseCaptionCues(raw) {
304
316
  const trimmed = String(raw || '').trimStart();
305
- if (!trimmed) return '';
317
+ if (!trimmed) return [];
306
318
 
307
319
  if (trimmed.startsWith('{')) {
308
320
  try {
309
321
  const payload = JSON.parse(trimmed);
310
- const segments = [];
322
+ const cues = [];
311
323
  for (const event of payload.events || []) {
312
324
  const text = (event.segs || [])
313
325
  .map((piece) => piece.utf8 || '')
@@ -315,18 +327,21 @@ function parseCaptionText(raw) {
315
327
  .replace(/\s+/g, ' ')
316
328
  .trim();
317
329
  if (!text) continue;
318
- const line = timestampedCaptionLine(text, Number(event.tStartMs));
319
- if (segments[segments.length - 1] === line) continue;
320
- segments.push(line);
330
+ const startMs = Number(event.tStartMs);
331
+ const cue = { startMs: Number.isFinite(startMs) ? startMs : 0, text };
332
+ if (cues.length && cues[cues.length - 1].text === cue.text && cues[cues.length - 1].startMs === cue.startMs) {
333
+ continue;
334
+ }
335
+ cues.push(cue);
321
336
  }
322
- return segments.join('\n');
337
+ return cues;
323
338
  } catch {
324
- return '';
339
+ return [];
325
340
  }
326
341
  }
327
342
 
328
343
  if (/^WEBVTT/i.test(trimmed) || trimmed.includes('-->')) {
329
- const segments = [];
344
+ const cues = [];
330
345
  for (const block of String(raw).split(/\r?\n\r?\n+/)) {
331
346
  const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
332
347
  const timeLine = lines.find((line) => line.includes('-->'));
@@ -339,7 +354,28 @@ function parseCaptionText(raw) {
339
354
  .filter((line, index, all) => index === 0 || line !== all[index - 1])
340
355
  .join(' ')
341
356
  .trim();
342
- const captionLine = timestampedCaptionLine(text, startMs);
357
+ if (!text) continue;
358
+ const cue = { startMs: startMs == null ? 0 : startMs, text };
359
+ if (cues.length && cues[cues.length - 1].text === cue.text && cues[cues.length - 1].startMs === cue.startMs) {
360
+ continue;
361
+ }
362
+ cues.push(cue);
363
+ }
364
+ return cues;
365
+ }
366
+
367
+ return [];
368
+ }
369
+
370
+ function parseCaptionText(raw) {
371
+ const trimmed = String(raw || '').trimStart();
372
+ if (!trimmed) return '';
373
+
374
+ const cues = parseCaptionCues(raw);
375
+ if (cues.length) {
376
+ const segments = [];
377
+ for (const cue of cues) {
378
+ const captionLine = timestampedCaptionLine(cue.text, cue.startMs);
343
379
  if (!captionLine || segments[segments.length - 1] === captionLine) continue;
344
380
  segments.push(captionLine);
345
381
  }
@@ -393,6 +429,19 @@ async function extractLocalTranscript(youtubeUrl, deps = {}) {
393
429
  }
394
430
 
395
431
  async function processYoutube(options, deps = {}) {
432
+ const applyStatus = (deps.ensureProcessApply || ensureProcessApply)({
433
+ cwd: deps.cwd || process.cwd(),
434
+ url: options.youtubeUrl,
435
+ now: deps.now,
436
+ output: deps.output,
437
+ });
438
+ if (applyStatus !== 0) {
439
+ const err = new Error(PROCESS_APPLY_MESSAGE);
440
+ err.exitCode = applyStatus;
441
+ err.applyRequired = true;
442
+ throw err;
443
+ }
444
+
396
445
  const apiFn = deps.apiRequestJson || apiRequestJson;
397
446
  const ensureBilled = deps.ensureBilledCommandAuth || ensureBilledCommandAuth;
398
447
  let auth = await ensureBilled('youtube', deps);
@@ -490,6 +539,14 @@ function videoIdFromUrl(url) {
490
539
  return short ? short[1] : null;
491
540
  }
492
541
 
542
+ function videoIdFromArg(arg) {
543
+ const fromUrl = videoIdFromUrl(arg);
544
+ if (fromUrl) return fromUrl;
545
+ const text = String(arg || '').trim();
546
+ if (/^[A-Za-z0-9_-]{6,}$/.test(text)) return text;
547
+ return null;
548
+ }
549
+
493
550
  function looksLikeYoutubeUrl(arg) {
494
551
  const text = String(arg || '').trim();
495
552
  if (!text || text.startsWith('-')) return false;
@@ -505,17 +562,24 @@ function parseNotesArgs(argv = []) {
505
562
  const urls = [];
506
563
  let engine = null;
507
564
  let help = false;
565
+ let save = false;
566
+ let unsave = false;
508
567
  for (const raw of argv) {
509
568
  const arg = String(raw);
510
- if (arg === '--help' || arg === '-h' || arg === 'help') {
511
- help = true;
512
- continue;
513
- }
569
+ if (arg === '--help' || arg === '-h' || arg === 'help') help = true;
570
+ else if (arg === '--save') save = true;
571
+ else if (arg === '--unsave') unsave = true;
572
+ }
573
+ for (const raw of argv) {
574
+ const arg = String(raw);
575
+ if (arg === '--help' || arg === '-h' || arg === 'help') continue;
576
+ if (arg === '--save' || arg === '--unsave') continue;
514
577
  if (arg.startsWith('-')) continue;
515
578
  if (looksLikeYoutubeUrl(arg)) urls.push(arg);
579
+ else if (unsave && videoIdFromArg(arg)) urls.push(arg);
516
580
  else engine = arg;
517
581
  }
518
- return { urls, engine, help };
582
+ return { urls, engine, help, save, unsave };
519
583
  }
520
584
 
521
585
  function dateStamp(now) {
@@ -572,6 +636,94 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
572
636
  }
573
637
  }
574
638
 
639
+ const APPLY_NEXT_MESSAGE =
640
+ 'next: write one apply (change + receipt) before process.';
641
+ const PROCESS_APPLY_MESSAGE =
642
+ 'write one apply (change + receipt) before process.';
643
+
644
+ function applySidecarRel(id) {
645
+ return applyGate.applySidecarRel('youtube', id);
646
+ }
647
+
648
+ function ensureNotesApply({ cwd, url, now, output } = {}) {
649
+ const id = videoIdFromUrl(url);
650
+ return applyGate.ensureApply({
651
+ cwd,
652
+ source: url,
653
+ rel: id ? applySidecarRel(id) : null,
654
+ now,
655
+ output,
656
+ incompleteMessage: APPLY_NEXT_MESSAGE,
657
+ required: false,
658
+ });
659
+ }
660
+
661
+ function youtubeBriefRel(id) {
662
+ return `atris/wiki/briefs/youtube-${id}.md`;
663
+ }
664
+
665
+ function unsaveYoutubeNotes(target, deps = {}) {
666
+ const output = deps.output || ((line = '') => console.log(line));
667
+ const cwd = deps.cwd || process.cwd();
668
+ const id = videoIdFromArg(target);
669
+ if (!id) {
670
+ output('usage: atris youtube unsave <url-or-id>');
671
+ return 2;
672
+ }
673
+ const briefRel = youtubeBriefRel(id);
674
+ const applyRel = applySidecarRel(id);
675
+ const removed = [];
676
+ for (const rel of [briefRel, applyRel]) {
677
+ const abs = path.join(cwd, rel);
678
+ try {
679
+ if (fs.existsSync(abs)) {
680
+ fs.unlinkSync(abs);
681
+ removed.push(rel);
682
+ }
683
+ } catch {
684
+ // already gone or unreadable: do not error
685
+ }
686
+ }
687
+ if (!removed.length) {
688
+ output(`already gone: ${briefRel} and ${applyRel}`);
689
+ return 0;
690
+ }
691
+ output(`removed ${removed.join(' and ')}`);
692
+ return 0;
693
+ }
694
+
695
+ function runYoutubeUnsave(args = [], deps = {}) {
696
+ const output = deps.output || ((line = '') => console.log(line));
697
+ const parsed = parseNotesArgs(['--unsave', ...args]);
698
+ if (parsed.help) {
699
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
700
+ return 0;
701
+ }
702
+ if (!parsed.urls.length) {
703
+ output('usage: atris youtube unsave <url-or-id>');
704
+ return 2;
705
+ }
706
+ let code = 0;
707
+ for (const target of parsed.urls) {
708
+ const status = unsaveYoutubeNotes(target, deps);
709
+ if (status !== 0) code = status;
710
+ }
711
+ return code;
712
+ }
713
+
714
+ function ensureProcessApply({ cwd, url, now, output } = {}) {
715
+ const id = videoIdFromUrl(url);
716
+ return applyGate.ensureApply({
717
+ cwd,
718
+ source: url,
719
+ rel: id ? applySidecarRel(id) : null,
720
+ now,
721
+ output,
722
+ incompleteMessage: PROCESS_APPLY_MESSAGE,
723
+ required: true,
724
+ });
725
+ }
726
+
575
727
  const DIGEST_ENGINE_TIMEOUT_MS = 240000;
576
728
  const DEFAULT_DIGEST_DAYS = 7;
577
729
 
@@ -1187,7 +1339,7 @@ function runOneNotesItem(item, engine, deps = {}) {
1187
1339
  } catch {
1188
1340
  status = 1;
1189
1341
  }
1190
- const brief = status === 0 ? fileNotesBrief(item.url, deps) : null;
1342
+ const brief = status === 0 && deps.save ? fileNotesBrief(item.url, deps) : null;
1191
1343
  const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
1192
1344
  const ok = status === 0;
1193
1345
  output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
@@ -1203,7 +1355,8 @@ function formatNotesSummary(rows = []) {
1203
1355
  return lines.join('\n');
1204
1356
  }
1205
1357
 
1206
- function runYoutubeNotesBatch({ urls, engine } = {}, deps = {}) {
1358
+ function runYoutubeNotesBatch({ urls, engine, save } = {}, deps = {}) {
1359
+ deps = { ...deps, save: save === true || deps.save === true };
1207
1360
  const output = deps.output || ((line = '') => console.error(line));
1208
1361
  const items = expandNotesTargets(urls || [], deps);
1209
1362
  const rows = [];
@@ -1226,8 +1379,16 @@ function runSingleYoutubeNotes(url, engine, deps = {}) {
1226
1379
  return 1;
1227
1380
  }
1228
1381
  const status = readRunnerStatus(result);
1229
- if (status === 0) fileNotesBrief(url, deps);
1230
- return status;
1382
+ if (status !== 0) return status;
1383
+ if (!deps.save) return 0;
1384
+ fileNotesBrief(url, deps);
1385
+ const ensureApply = deps.ensureApply || ensureNotesApply;
1386
+ return ensureApply({
1387
+ cwd: deps.cwd || process.cwd(),
1388
+ url,
1389
+ now: deps.now,
1390
+ output: deps.output,
1391
+ });
1231
1392
  }
1232
1393
 
1233
1394
  function runYoutubeNotes(args = [], deps = {}) {
@@ -1237,15 +1398,19 @@ function runYoutubeNotes(args = [], deps = {}) {
1237
1398
  showYoutubeHelp(output, deps.commandName || 'atris youtube');
1238
1399
  return 0;
1239
1400
  }
1401
+ if (parsed.unsave) {
1402
+ return runYoutubeUnsave(args, deps);
1403
+ }
1240
1404
  if (!parsed.urls.length) {
1241
1405
  output(YTNOTES_USAGE);
1242
1406
  output(YTNOTES_HINT);
1243
1407
  return 2;
1244
1408
  }
1409
+ const nextDeps = { ...deps, save: parsed.save };
1245
1410
  if (parsed.urls.length === 1 && !isPlaylistUrl(parsed.urls[0])) {
1246
- return runSingleYoutubeNotes(parsed.urls[0], parsed.engine, deps);
1411
+ return runSingleYoutubeNotes(parsed.urls[0], parsed.engine, nextDeps);
1247
1412
  }
1248
- return runYoutubeNotesBatch(parsed, deps);
1413
+ return runYoutubeNotesBatch(parsed, nextDeps);
1249
1414
  }
1250
1415
 
1251
1416
  const DEFAULT_SEARCH_LIMIT = 5;
@@ -1261,6 +1426,8 @@ const LOCAL_SEARCH_CACHE_TTL_MS = 60 * 60 * 1000;
1261
1426
  const LOCAL_SEARCH_CACHE_FILE = 'youtube-search-cache.json';
1262
1427
  const LOCAL_SEARCH_CACHE_NOTE =
1263
1428
  'cached because youtube rate-limited local search.';
1429
+ const PAID_SEARCH_FRESH_CACHE_REFUSE =
1430
+ 'free cache still has results for this query. drop --paid or wait until the cache expires.';
1264
1431
 
1265
1432
  function showYoutubeSearchHelp(output = console.log, commandName = 'atris youtube') {
1266
1433
  output('');
@@ -1489,24 +1656,50 @@ function paidSearchVideos(data) {
1489
1656
  return rows;
1490
1657
  }
1491
1658
 
1492
- function creditsLine(data) {
1493
- const payload = unwrapSearchPayload(data);
1494
- const used = data?.credits_used !== undefined ? data.credits_used : payload.credits_used;
1495
- const remaining = data?.credits_remaining !== undefined
1659
+ function paidSearchCredits(data) {
1660
+ if (!data || typeof data !== 'object') {
1661
+ return { used: undefined, remaining: undefined, refunded: undefined };
1662
+ }
1663
+ const payload = unwrapSearchPayload(data) || {};
1664
+ const used = data.credits_used !== undefined ? data.credits_used : payload.credits_used;
1665
+ const remaining = data.credits_remaining !== undefined
1496
1666
  ? data.credits_remaining
1497
1667
  : payload.credits_remaining;
1498
- if (used === undefined && remaining === undefined) return '';
1499
- return `Credits: ${used !== undefined ? used : '?'} used, ${remaining !== undefined ? remaining : '?'} remaining`;
1668
+ let refunded = data.credits_refunded !== undefined
1669
+ ? data.credits_refunded
1670
+ : payload.credits_refunded;
1671
+ if (refunded === undefined && (data.refunded === true || payload.refunded === true)) {
1672
+ refunded = true;
1673
+ }
1674
+ return { used, remaining, refunded };
1675
+ }
1676
+
1677
+ function creditsWereRefunded(credits) {
1678
+ if (!credits) return false;
1679
+ if (credits.used === 0) return true;
1680
+ if (credits.refunded === true) return true;
1681
+ return typeof credits.refunded === 'number' && credits.refunded > 0;
1682
+ }
1683
+
1684
+ function formatCreditsLines(credits) {
1685
+ const lines = [];
1686
+ if (credits.used !== undefined || credits.remaining !== undefined) {
1687
+ lines.push(`Credits: ${credits.used !== undefined ? credits.used : '?'} used, ${credits.remaining !== undefined ? credits.remaining : '?'} remaining`);
1688
+ }
1689
+ if (creditsWereRefunded(credits)) {
1690
+ lines.push('credits refunded');
1691
+ }
1692
+ return lines;
1500
1693
  }
1501
1694
 
1502
1695
  function formatPaidSearchResults(data) {
1503
1696
  const lines = paidSearchVideos(data).map((row) => (
1504
1697
  row.title ? `${row.title} | ${row.url}` : row.url
1505
1698
  ));
1506
- const credits = creditsLine(data);
1507
- if (credits) {
1699
+ const creditLines = formatCreditsLines(paidSearchCredits(data));
1700
+ if (creditLines.length) {
1508
1701
  if (lines.length) lines.push('');
1509
- lines.push(credits);
1702
+ lines.push(...creditLines);
1510
1703
  }
1511
1704
  return lines.join('\n');
1512
1705
  }
@@ -1519,7 +1712,13 @@ function youtubeSearchFailureError(result) {
1519
1712
  : result.status === 502
1520
1713
  ? ' YouTube search is unavailable; retry in a few seconds.'
1521
1714
  : '';
1522
- return new Error(`YouTube search failed (${result.status}): ${resultErrorText(result)}.${hint}`);
1715
+ const credits = paidSearchCredits(result.data);
1716
+ const refundHint = result.status === 502 && creditsWereRefunded(credits)
1717
+ ? ' credits refunded.'
1718
+ : '';
1719
+ const lines = [`YouTube search failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
1720
+ lines.push(...formatCreditsLines(credits));
1721
+ return new Error(lines.join('\n'));
1523
1722
  }
1524
1723
 
1525
1724
  async function requestPaidYoutubeSearch(options, deps = {}) {
@@ -1556,6 +1755,10 @@ async function requestPaidYoutubeSearch(options, deps = {}) {
1556
1755
 
1557
1756
  async function runPaidYoutubeSearch(options, deps = {}) {
1558
1757
  const output = deps.output || ((line = '') => console.log(line));
1758
+ if (readFreshLocalSearchCache(options.query, deps)) {
1759
+ output(PAID_SEARCH_FRESH_CACHE_REFUSE);
1760
+ return 2;
1761
+ }
1559
1762
  const data = await requestPaidYoutubeSearch(options, deps);
1560
1763
  if (options.json) {
1561
1764
  output(JSON.stringify(data, null, 2));
@@ -1733,6 +1936,384 @@ async function runYoutubeSearch(args = [], deps = {}) {
1733
1936
  return 0;
1734
1937
  }
1735
1938
 
1939
+ const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save]';
1940
+ const TEACH_PAID_REFUSE = 'teach is free local captions. drop --paid.';
1941
+ const TEACH_APPLY_NEXT_MESSAGE = APPLY_NEXT_MESSAGE;
1942
+ const MECHANISM_STOP = new Set([
1943
+ 'the', 'this', 'that', 'and', 'but', 'for', 'with', 'from', 'you', 'we', 'they',
1944
+ 'what', 'when', 'how', 'why', 'there', 'here', 'then', 'just', 'also', 'very',
1945
+ 'really', 'about', 'into', 'over', 'after', 'before', 'because', 'while', 'where',
1946
+ 'which', 'their', 'your', 'our', 'its', 'not', 'all', 'any', 'some', 'more', 'most',
1947
+ 'other', 'first', 'second', 'next', 'last', 'new', 'old', 'good', 'bad', 'big',
1948
+ 'small', 'long', 'short', 'video', 'chapter', 'section', 'youtube', 'transcript',
1949
+ 'yeah', 'okay', 'ok', 'so', 'well', 'like', 'right', 'now', 'one', 'two',
1950
+ 'who', 'every', 'holy', 'want', 'operating',
1951
+ ]);
1952
+ const NUMBER_UNITS = 'percent|million|billion|thousand|people|hours?|minutes?|seconds?|years?|months?|weeks?|days?|cycles?';
1953
+ const NUMBER_CLAIM_VERBS = 'install|ship|hire|raise|cut|save|cost|last|weigh|span|spend';
1954
+ const MECHANISM_HEADS = 'window|model|principle|pattern|loop|cycle|method|rule|doctrine|framework|heuristic';
1955
+ const TEACH_SWEAR_RE = /\b(fuck(?:ing)?|shit|damn|ass|bitch|crap)\b/i;
1956
+ const GENERIC_MECHANISM_LEFT = new Set([
1957
+ ...MECHANISM_STOP,
1958
+ 'a', 'an', 'in', 'of', 'to', 'my', 'i', 'im', "i'm",
1959
+ ]);
1960
+
1961
+ function parseTeachArgs(argv = []) {
1962
+ const args = [...argv];
1963
+ const options = {
1964
+ help: false,
1965
+ save: false,
1966
+ url: null,
1967
+ section: 1,
1968
+ };
1969
+
1970
+ if (args.length === 0 || ['help', '--help', '-h'].includes(args[0])) {
1971
+ options.help = true;
1972
+ return options;
1973
+ }
1974
+
1975
+ for (let i = 0; i < args.length; i += 1) {
1976
+ const arg = String(args[i]);
1977
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
1978
+ options.help = true;
1979
+ } else if (arg === '--save') {
1980
+ options.save = true;
1981
+ } else if (arg === '--paid') {
1982
+ throw new Error(TEACH_PAID_REFUSE);
1983
+ } else if (arg === '--section') {
1984
+ const raw = args[i + 1];
1985
+ const value = Number.parseInt(raw, 10);
1986
+ if (raw == null || String(raw).startsWith('--') || !Number.isInteger(value) || value < 1) {
1987
+ throw new Error('--section must be a positive integer');
1988
+ }
1989
+ options.section = value;
1990
+ i += 1;
1991
+ } else if (arg.startsWith('--section=')) {
1992
+ const value = Number.parseInt(arg.slice('--section='.length), 10);
1993
+ if (!Number.isInteger(value) || value < 1) {
1994
+ throw new Error('--section must be a positive integer');
1995
+ }
1996
+ options.section = value;
1997
+ } else if (arg.startsWith('-')) {
1998
+ throw new Error(`Unknown option: ${arg}`);
1999
+ } else if (!options.url && looksLikeYoutubeUrl(arg)) {
2000
+ options.url = arg;
2001
+ } else {
2002
+ throw new Error(`Unexpected argument: ${arg}`);
2003
+ }
2004
+ }
2005
+
2006
+ if (options.help) return options;
2007
+ if (!options.url) throw new Error('Missing YouTube URL. Run "atris youtube teach --help".');
2008
+ return options;
2009
+ }
2010
+
2011
+ function chapterStartSeconds(chapter) {
2012
+ if (!chapter) return NaN;
2013
+ if (chapter.startSeconds != null) return Number(chapter.startSeconds);
2014
+ return Number(chapter.start_time);
2015
+ }
2016
+
2017
+ function chapterEndSeconds(chapter) {
2018
+ if (!chapter) return NaN;
2019
+ if (chapter.endSeconds != null) return Number(chapter.endSeconds);
2020
+ return Number(chapter.end_time);
2021
+ }
2022
+
2023
+ function normalizeChapters(rawChapters, durationSeconds) {
2024
+ const duration = Number(durationSeconds) || 0;
2025
+ const list = Array.isArray(rawChapters) ? rawChapters.filter(Boolean) : [];
2026
+ if (!list.length) {
2027
+ return [{
2028
+ index: 1,
2029
+ title: 'full video',
2030
+ startSeconds: 0,
2031
+ endSeconds: duration || Infinity,
2032
+ }];
2033
+ }
2034
+ return list.map((chapter, index) => {
2035
+ const start = chapterStartSeconds(chapter);
2036
+ const startSeconds = Number.isFinite(start) ? start : 0;
2037
+ const nextStart = chapterStartSeconds(list[index + 1]);
2038
+ const explicitEnd = chapterEndSeconds(chapter);
2039
+ const endSeconds = Number.isFinite(explicitEnd)
2040
+ ? explicitEnd
2041
+ : (Number.isFinite(nextStart) ? nextStart : (duration || Infinity));
2042
+ const title = String(chapter.title || `section ${index + 1}`).trim() || `section ${index + 1}`;
2043
+ return { index: index + 1, title, startSeconds, endSeconds };
2044
+ });
2045
+ }
2046
+
2047
+ function sliceCuesForChapter(cues = [], chapter) {
2048
+ if (!chapter) return [];
2049
+ const startMs = Number(chapter.startSeconds) * 1000;
2050
+ const endMs = Number(chapter.endSeconds) * 1000;
2051
+ return (cues || []).filter((cue) => {
2052
+ const at = Number(cue.startMs);
2053
+ if (!Number.isFinite(at)) return false;
2054
+ if (!Number.isFinite(startMs)) return true;
2055
+ if (at < startMs) return false;
2056
+ if (Number.isFinite(endMs) && at >= endMs) return false;
2057
+ return true;
2058
+ });
2059
+ }
2060
+
2061
+ function teachCaptionWords(text) {
2062
+ return String(text || '')
2063
+ .replace(/\[\d{1,2}:\d{2}(?::\d{2})?\]/g, ' ')
2064
+ .replace(/\b\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\b/g, ' ')
2065
+ .replace(/[“”]/g, '"')
2066
+ .split(/\s+/)
2067
+ .map((word) => word.replace(/^[^A-Za-z0-9$%]+|[^A-Za-z0-9%]+$/g, ''))
2068
+ .filter(Boolean);
2069
+ }
2070
+
2071
+ function extractTeachNumbers(text) {
2072
+ // Keep a number only with its unit or a nearby claim. Bare 20/60 are crumbs.
2073
+ const words = teachCaptionWords(text);
2074
+ const found = [];
2075
+ const seen = new Set();
2076
+ const numberRe = /^(?:\$)?(\d[\d,]*(?:\.\d+)?)(%?)$/;
2077
+ const unitRe = new RegExp(`^(?:${NUMBER_UNITS})$`, 'i');
2078
+ const verbRe = new RegExp(`\\b(?:${NUMBER_CLAIM_VERBS})\\b`, 'i');
2079
+
2080
+ for (let i = 0; i < words.length; i += 1) {
2081
+ const match = words[i].match(numberRe);
2082
+ if (!match) continue;
2083
+ const nearby = words.slice(i + 1, i + 3);
2084
+ const unit = nearby.find((word) => unitRe.test(word));
2085
+ const hasPercent = match[2] === '%' || /%/.test(words[i]);
2086
+ if (!unit && !hasPercent) continue;
2087
+
2088
+ const windowText = words.slice(Math.max(0, i - 6), Math.min(words.length, i + 7)).join(' ');
2089
+ const verbMatch = windowText.match(verbRe);
2090
+ const number = match[1];
2091
+ let claim = hasPercent ? `${number}%` : `${number} ${String(unit).toLowerCase()}`;
2092
+ if (verbMatch) claim += ` to ${verbMatch[0].toLowerCase()}`;
2093
+ const key = claim.toLowerCase();
2094
+ if (seen.has(key)) continue;
2095
+ seen.add(key);
2096
+ found.push(claim);
2097
+ }
2098
+ return found.slice(0, 8);
2099
+ }
2100
+
2101
+ function extractTeachMechanisms(text) {
2102
+ // Named only: "Overton window", "omakase model". Drop quotes, swears, crumbs.
2103
+ const found = [];
2104
+ const seen = new Set();
2105
+ const add = (value) => {
2106
+ const token = String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
2107
+ if (!token || TEACH_SWEAR_RE.test(token)) return;
2108
+ if (seen.has(token) || MECHANISM_STOP.has(token) || GENERIC_MECHANISM_LEFT.has(token)) return;
2109
+ if (token.length < 4 && !/\d/.test(token)) return;
2110
+ seen.add(token);
2111
+ found.push(token);
2112
+ };
2113
+
2114
+ const raw = String(text || '');
2115
+ const namedRe = new RegExp(`\\b([A-Za-z][A-Za-z0-9+-]{2,})\\s+(${MECHANISM_HEADS})\\b`, 'gi');
2116
+ for (const match of raw.matchAll(namedRe)) {
2117
+ const left = String(match[1] || '').toLowerCase();
2118
+ if (GENERIC_MECHANISM_LEFT.has(left) || MECHANISM_STOP.has(left)) continue;
2119
+ add(`${match[1]} ${match[2]}`);
2120
+ }
2121
+ for (const match of raw.matchAll(/\b(\d+[A-Za-z][A-Za-z0-9]+)\b/g)) add(match[1]);
2122
+ return found.slice(0, 8);
2123
+ }
2124
+
2125
+ function oneTeachCheck(mechanisms, numbers, title) {
2126
+ if (mechanisms[0]) {
2127
+ const name = mechanisms[0];
2128
+ const named = new RegExp(`\\b(?:${MECHANISM_HEADS})\\b`, 'i').test(name);
2129
+ if (named && !/^(the|a|an)\s/i.test(name)) return `what is the ${name}?`;
2130
+ return `what is ${name}?`;
2131
+ }
2132
+ if (numbers[0]) return `what does ${numbers[0]} measure in this chapter?`;
2133
+ return `what is the point of ${title || 'this chapter'}?`;
2134
+ }
2135
+
2136
+ function quoteYoutubeUrl(url) {
2137
+ return `"${String(url || '').replace(/"/g, '')}"`;
2138
+ }
2139
+
2140
+ function formatTeachLesson({ url, section, chapters, chapter, cues, title } = {}) {
2141
+ const total = Array.isArray(chapters) && chapters.length ? chapters.length : 1;
2142
+ const heading = String(chapter?.title || 'full video').trim().toLowerCase();
2143
+ const videoTitle = String(title || '').trim().toLowerCase();
2144
+ const body = (cues || []).map((cue) => cue.text).join(' ');
2145
+ const numbers = extractTeachNumbers(body);
2146
+ const mechanisms = extractTeachMechanisms(body);
2147
+ const lines = [
2148
+ `section ${section}/${total} ${heading}`,
2149
+ ];
2150
+ if (videoTitle) lines.push(videoTitle);
2151
+ lines.push('');
2152
+ lines.push('numbers');
2153
+ if (numbers.length) lines.push(...numbers);
2154
+ else lines.push('none');
2155
+ lines.push('');
2156
+ lines.push('mechanisms');
2157
+ if (mechanisms.length) lines.push(...mechanisms);
2158
+ else lines.push('none');
2159
+ lines.push('');
2160
+ lines.push('check');
2161
+ lines.push(oneTeachCheck(mechanisms, numbers, heading));
2162
+ if (section < total) {
2163
+ lines.push('');
2164
+ lines.push(`next: atris youtube teach ${quoteYoutubeUrl(url)} --section ${section + 1}`);
2165
+ } else {
2166
+ lines.push('');
2167
+ lines.push('next: last section');
2168
+ }
2169
+ return lines.join('\n');
2170
+ }
2171
+
2172
+ function teachBriefRel(id, section) {
2173
+ return `atris/wiki/briefs/youtube-${id}-s${section}.md`;
2174
+ }
2175
+
2176
+ function fileTeachBrief({ cwd, url, section, lesson, now } = {}) {
2177
+ try {
2178
+ const id = videoIdFromUrl(url);
2179
+ if (!id || !cwd) return null;
2180
+ const wikiDir = path.join(cwd, 'atris', 'wiki');
2181
+ if (!fs.existsSync(wikiDir)) return null;
2182
+ const rel = teachBriefRel(id, section);
2183
+ const briefsDir = path.join(cwd, 'atris', 'wiki', 'briefs');
2184
+ fs.mkdirSync(briefsDir, { recursive: true });
2185
+ const date = dateStamp(now);
2186
+ const header = [
2187
+ String(lesson || '').split('\n')[0] || `teach section ${section}`,
2188
+ '',
2189
+ `date: ${date}`,
2190
+ `source: ${url}`,
2191
+ `section: ${section}`,
2192
+ 'rail: atris youtube teach, one chapter from local captions',
2193
+ ].join('\n');
2194
+ fs.writeFileSync(path.join(cwd, rel), `${header}\n\n${lesson}\n`);
2195
+
2196
+ const journalPath = path.join(cwd, 'atris', 'logs', date.slice(0, 4), `${date}.md`);
2197
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
2198
+ let existing = '';
2199
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
2200
+ const line = `- [claimable] taught: section ${section} -> ${rel}`;
2201
+ if (!existing.includes(line)) {
2202
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
2203
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
2204
+ }
2205
+ return rel;
2206
+ } catch {
2207
+ return null;
2208
+ }
2209
+ }
2210
+
2211
+ function ensureTeachApply({ cwd, url, section, now, output } = {}) {
2212
+ const id = videoIdFromUrl(url);
2213
+ return applyGate.ensureApply({
2214
+ cwd,
2215
+ source: url,
2216
+ rel: id ? applySidecarRel(`${id}-s${section}`) : null,
2217
+ now,
2218
+ output,
2219
+ incompleteMessage: TEACH_APPLY_NEXT_MESSAGE,
2220
+ required: false,
2221
+ });
2222
+ }
2223
+
2224
+ async function extractTeachSource(youtubeUrl, deps = {}) {
2225
+ if (typeof deps.extractTeachSource === 'function') {
2226
+ return deps.extractTeachSource(youtubeUrl, deps);
2227
+ }
2228
+ const runner = deps.spawnSync || spawnSync;
2229
+ const result = runner('yt-dlp', ['-J', '--skip-download', '--no-warnings', youtubeUrl], {
2230
+ encoding: 'utf8',
2231
+ timeout: 20000,
2232
+ maxBuffer: 10 * 1024 * 1024,
2233
+ });
2234
+ if (result.error || result.status !== 0 || !result.stdout) return null;
2235
+
2236
+ let info;
2237
+ try {
2238
+ info = JSON.parse(result.stdout);
2239
+ } catch {
2240
+ return null;
2241
+ }
2242
+
2243
+ const selected = chooseCaptionTrack(info);
2244
+ if (!selected?.track?.url) return null;
2245
+ const rawCaption = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
2246
+ const cues = parseCaptionCues(rawCaption);
2247
+ if (!cues.length) return null;
2248
+
2249
+ return {
2250
+ id: info.id || videoIdFromUrl(youtubeUrl),
2251
+ title: info.title || '',
2252
+ url: youtubeUrl,
2253
+ durationSeconds: Number(info.duration || 0) || undefined,
2254
+ language: selected.language || 'unknown',
2255
+ chapters: normalizeChapters(info.chapters, info.duration),
2256
+ cues,
2257
+ };
2258
+ }
2259
+
2260
+ async function runYoutubeTeach(args = [], deps = {}) {
2261
+ const output = deps.output || ((line = '') => console.log(line));
2262
+ let parsed;
2263
+ try {
2264
+ parsed = parseTeachArgs(args);
2265
+ } catch (err) {
2266
+ output(err.message || YTTEACH_USAGE);
2267
+ return 2;
2268
+ }
2269
+ if (parsed.help) {
2270
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
2271
+ return 0;
2272
+ }
2273
+
2274
+ const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, deps);
2275
+ if (!source || !Array.isArray(source.cues) || !source.cues.length) {
2276
+ output('no english captions for this url. teach stays local and will not call process.');
2277
+ return 2;
2278
+ }
2279
+
2280
+ const chapters = normalizeChapters(source.chapters, source.durationSeconds);
2281
+ if (parsed.section > chapters.length) {
2282
+ output(`section ${parsed.section} is past ${chapters.length} chapters. try --section ${chapters.length}`);
2283
+ return 2;
2284
+ }
2285
+
2286
+ const chapter = chapters[parsed.section - 1];
2287
+ const cues = sliceCuesForChapter(source.cues, chapter);
2288
+ const lesson = formatTeachLesson({
2289
+ url: parsed.url,
2290
+ section: parsed.section,
2291
+ chapters,
2292
+ chapter,
2293
+ cues,
2294
+ title: source.title,
2295
+ });
2296
+ output(lesson);
2297
+
2298
+ if (!parsed.save) return 0;
2299
+
2300
+ fileTeachBrief({
2301
+ cwd: deps.cwd || process.cwd(),
2302
+ url: parsed.url,
2303
+ section: parsed.section,
2304
+ lesson,
2305
+ now: deps.now,
2306
+ });
2307
+ const ensureApply = deps.ensureApply || ensureTeachApply;
2308
+ return ensureApply({
2309
+ cwd: deps.cwd || process.cwd(),
2310
+ url: parsed.url,
2311
+ section: parsed.section,
2312
+ now: deps.now,
2313
+ output,
2314
+ });
2315
+ }
2316
+
1736
2317
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
1737
2318
  const output = deps.output || ((line = '') => console.log(line));
1738
2319
  if (argv[0] === 'search') {
@@ -1742,11 +2323,21 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
1742
2323
  }
1743
2324
  return code;
1744
2325
  }
2326
+ if (argv[0] === 'unsave') {
2327
+ const code = runYoutubeUnsave(argv.slice(1), deps);
2328
+ if (!deps.output && !deps.spawnSync && !deps.runner && !deps.expander) process.exit(code);
2329
+ return code;
2330
+ }
1745
2331
  if (argv[0] === 'notes') {
1746
2332
  const code = runYoutubeNotes(argv.slice(1), deps);
1747
2333
  if (!deps.output && !deps.spawnSync && !deps.runner && !deps.expander) process.exit(code);
1748
2334
  return code;
1749
2335
  }
2336
+ if (argv[0] === 'teach') {
2337
+ const code = await runYoutubeTeach(argv.slice(1), { ...deps, output });
2338
+ if (!deps.output && !deps.extractTeachSource && !deps.spawnSync && !deps.runner) process.exit(code);
2339
+ return code;
2340
+ }
1750
2341
  if (argv[0] === 'digest') {
1751
2342
  const code = runYoutubeDigest(argv.slice(1), { ...deps, output });
1752
2343
  if (!deps.output && !deps.runner) process.exit(code);
@@ -1767,8 +2358,8 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
1767
2358
  const data = await processYoutube(options, deps);
1768
2359
  output(options.json ? JSON.stringify(data, null, 2) : formatYoutubeResult(data));
1769
2360
  } catch (err) {
1770
- output(err.message);
1771
- status = 1;
2361
+ if (!err.applyRequired) output(err.message);
2362
+ status = Number.isInteger(err.exitCode) ? err.exitCode : 1;
1772
2363
  }
1773
2364
  if (!deps.output && !deps.apiRequestJson && !deps.ensureValidCredentials && !deps.ensureBilledCommandAuth && !deps.extractLocalTranscript) {
1774
2365
  process.exit(status);
@@ -1786,6 +2377,10 @@ module.exports = {
1786
2377
  shouldRetryWithLocalTranscript,
1787
2378
  formatYoutubeResult,
1788
2379
  fileBriefFromNotes,
2380
+ ensureNotesApply,
2381
+ unsaveYoutubeNotes,
2382
+ APPLY_NEXT_MESSAGE,
2383
+ PROCESS_APPLY_MESSAGE,
1789
2384
  isPlaylistUrl,
1790
2385
  parseNotesArgs,
1791
2386
  expandNotesTargets,
@@ -1801,5 +2396,13 @@ module.exports = {
1801
2396
  parseSearchArgs,
1802
2397
  parseSearchStdout,
1803
2398
  formatSearchResults,
2399
+ parseTeachArgs,
2400
+ parseCaptionCues,
2401
+ normalizeChapters,
2402
+ sliceCuesForChapter,
2403
+ formatTeachLesson,
2404
+ extractTeachNumbers,
2405
+ extractTeachMechanisms,
2406
+ extractTeachSource,
1804
2407
  youtubeCommand,
1805
2408
  };