atris 3.58.5 → 3.58.6

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.
@@ -42,12 +42,12 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
42
42
  output(` ${commandName} watch tick`);
43
43
  output(` ${commandName} <youtube-url> [options]`);
44
44
  output('');
45
- output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links; rich free search prints one failing check; hands off to teach');
46
- output('search --paid = 5 credits, watch permalinks + titles from Atris; rich paid search prints one failing check; hands off to teach');
45
+ output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links; rich free search writes one apply and a failing keep/revert pack; thin hands off to teach');
46
+ output('search --paid = 5 credits, watch permalinks + titles from Atris; rich paid search writes one apply and a failing keep/revert pack; thin hands off to teach');
47
47
  output('notes = free local notes to stdout; ephemeral unless --save; hands off to teach');
48
48
  output('teach = one chapter from local captions; bare teach resumes unpaid checks, then the next chapter after recap or skip');
49
49
  output('rich ephemeral notes/teach print one apply next-step and one failing check (no files)');
50
- output('process = 5 credits cloud knowledge (needs a filled Apply); rich process prints one failing check');
50
+ output('process = 5 credits cloud knowledge (needs a filled Apply); rich process writes one apply and a failing keep/revert pack');
51
51
  output('digest = one decision page from this week\'s video briefs; rich digest writes one apply and a failing keep/revert pack');
52
52
  output('watch = subscribed channels turn into briefs without a human; add hands off to tick; tick hands off to teach when it briefed');
53
53
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
@@ -211,8 +211,16 @@ function youtubeFailureError(result) {
211
211
  ? ' Run "atris login --force".'
212
212
  : result.status === 402
213
213
  ? ' Check Atris credits.'
214
- : '';
215
- return new Error(`YouTube processing failed (${result.status}): ${resultErrorText(result)}.${hint}`);
214
+ : result.status === 502
215
+ ? ' YouTube processing is unavailable; retry in a few seconds.'
216
+ : '';
217
+ const credits = paidSearchCredits(result.data);
218
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
219
+ ? ' credits refunded.'
220
+ : '';
221
+ const lines = [`YouTube processing failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
222
+ lines.push(...formatCreditsLines(credits));
223
+ return new Error(lines.join('\n'));
216
224
  }
217
225
 
218
226
  function captionHostAllowed(urlString) {
@@ -406,33 +414,110 @@ function parseCaptionText(raw) {
406
414
  return segments.join(' ');
407
415
  }
408
416
 
417
+ function parseYtDlpInfoJson(result) {
418
+ const raw = String((result && result.stdout) || '').trim();
419
+ if (!raw) return null;
420
+ try {
421
+ const info = JSON.parse(raw);
422
+ return info && typeof info === 'object' && !Array.isArray(info) ? info : null;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ function localCaptionNames(id) {
429
+ // scripts/det/ytnotes keeps the same VTT names (not clean.txt).
430
+ return [
431
+ `yt_${id}.en.vtt`,
432
+ `yt_${id}.en-orig.vtt`,
433
+ `yt_${id}.en-US.vtt`,
434
+ `yt_${id}.en-GB.vtt`,
435
+ `yt_${id}.clean.txt`,
436
+ ];
437
+ }
438
+
439
+ function captionLookupIds({ url, id } = {}) {
440
+ const ids = [];
441
+ const seen = new Set();
442
+ const add = (value) => {
443
+ const text = String(value || '').trim();
444
+ if (!text || seen.has(text)) return;
445
+ seen.add(text);
446
+ ids.push(text);
447
+ };
448
+ add(id);
449
+ add(videoIdFromUrl(url));
450
+ return ids;
451
+ }
452
+
453
+ function readLocalCaptionText({ url, id, workDir } = {}) {
454
+ if (!workDir) return '';
455
+ for (const videoId of captionLookupIds({ url, id })) {
456
+ for (const name of localCaptionNames(videoId)) {
457
+ const abs = path.join(workDir, name);
458
+ try {
459
+ if (!fs.existsSync(abs)) continue;
460
+ const text = fs.readFileSync(abs, 'utf8');
461
+ if (String(text).trim()) return text;
462
+ } catch {
463
+ // try the next written caption file
464
+ }
465
+ }
466
+ }
467
+ return '';
468
+ }
469
+
470
+ async function loadCaptionRaw(info, youtubeUrl, deps = {}) {
471
+ const payload = info && typeof info === 'object' ? info : {};
472
+ const selected = chooseCaptionTrack(payload);
473
+ let raw = '';
474
+ if (selected?.track?.url) {
475
+ raw = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
476
+ }
477
+ if (String(raw || '').trim()) {
478
+ return { raw, language: selected?.language || 'unknown' };
479
+ }
480
+ const local = readLocalCaptionText({
481
+ url: youtubeUrl,
482
+ id: payload.id,
483
+ workDir: deps.workDir,
484
+ });
485
+ if (!String(local || '').trim()) return null;
486
+ return { raw: local, language: selected?.language || 'en' };
487
+ }
488
+
489
+ function ytDlpInfoArgs(youtubeUrl) {
490
+ return ['-J', '--skip-download', '--no-warnings', '--no-playlist', youtubeUrl];
491
+ }
492
+
493
+ function teachSourceVideoId(info, youtubeUrl) {
494
+ const fromUrl = videoIdFromUrl(youtubeUrl);
495
+ const fromInfo = info && info.id;
496
+ const entries = info && Array.isArray(info.entries) ? info.entries : [];
497
+ const fromEntry = entries[0] && entries[0].id;
498
+ if (info && (info._type === 'playlist' || entries.length)) {
499
+ return fromUrl || fromEntry || fromInfo;
500
+ }
501
+ return fromInfo || fromUrl;
502
+ }
503
+
409
504
  async function extractLocalTranscript(youtubeUrl, deps = {}) {
410
505
  if (process.env.ATRIS_YOUTUBE_LOCAL_TRANSCRIPT === '0') return null;
411
506
  const runner = deps.spawnSync || spawnSync;
412
- const result = runner('yt-dlp', ['-J', '--skip-download', '--no-warnings', youtubeUrl], {
507
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
413
508
  encoding: 'utf8',
414
509
  timeout: 20000,
415
510
  maxBuffer: 10 * 1024 * 1024,
416
511
  });
417
- if (result.error || result.status !== 0 || !result.stdout) return null;
418
-
419
- let info;
420
- try {
421
- info = JSON.parse(result.stdout);
422
- } catch {
423
- return null;
424
- }
425
-
426
- const selected = chooseCaptionTrack(info);
427
- if (!selected?.track?.url) return null;
428
- const rawCaption = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
429
- const transcript = parseCaptionText(rawCaption);
512
+ const info = parseYtDlpInfoJson(result);
513
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
514
+ const transcript = parseCaptionText(loaded && loaded.raw);
430
515
  if (!transcript) return null;
431
516
 
432
517
  return {
433
518
  transcriptText: transcript.slice(0, LOCAL_TRANSCRIPT_MAX_CHARS),
434
- language: selected.language || 'unknown',
435
- durationSeconds: Number(info.duration || 0) || undefined,
519
+ language: loaded.language || 'unknown',
520
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
436
521
  };
437
522
  }
438
523
 
@@ -468,6 +553,12 @@ async function processYoutube(options, deps = {}) {
468
553
  if (!result.ok && result.status === 401 && !auth.minted) {
469
554
  const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
470
555
  if (remint?.ok && remint.token) {
556
+ if (!options.json) {
557
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
558
+ for (const line of formatCreditsLines(paidSearchCredits(result.data))) {
559
+ print(line);
560
+ }
561
+ }
471
562
  auth = remint;
472
563
  result = await apiFn('/agent/process_youtube', {
473
564
  method: 'POST',
@@ -484,7 +575,10 @@ async function processYoutube(options, deps = {}) {
484
575
  const localExtractor = deps.extractLocalTranscript || extractLocalTranscript;
485
576
  let localTranscript = null;
486
577
  try {
487
- localTranscript = await localExtractor(options.youtubeUrl, deps);
578
+ localTranscript = await localExtractor(options.youtubeUrl, {
579
+ ...deps,
580
+ workDir: deps.workDir || notesWorkDir(deps),
581
+ });
488
582
  } catch {
489
583
  localTranscript = null;
490
584
  }
@@ -501,6 +595,12 @@ async function processYoutube(options, deps = {}) {
501
595
  if (transcriptResult.status === 401 || transcriptResult.status === 402 || transcriptResult.status === 400) {
502
596
  throw youtubeFailureError(transcriptResult);
503
597
  }
598
+ if (!options.json) {
599
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
600
+ for (const line of formatCreditsLines(paidSearchCredits(transcriptResult.data))) {
601
+ print(line);
602
+ }
603
+ }
504
604
  }
505
605
 
506
606
  const result = await requestYoutube(buildYoutubePayload(options));
@@ -537,11 +637,8 @@ function formatYoutubeResult(data) {
537
637
  : '';
538
638
  lines.push(`Processing: ${method}${source}`);
539
639
  }
540
- if (data?.credits_used !== undefined || data?.credits_remaining !== undefined) {
541
- const used = data.credits_used !== undefined ? data.credits_used : '?';
542
- const remaining = data.credits_remaining !== undefined ? data.credits_remaining : '?';
543
- lines.push(`Credits: ${used} used, ${remaining} remaining`);
544
- }
640
+ const creditLines = formatCreditsLines(paidSearchCredits(data));
641
+ if (creditLines.length) lines.push(...creditLines);
545
642
  const analysis = processAnalysisText(data);
546
643
  if (analysis) {
547
644
  lines.push('');
@@ -551,11 +648,13 @@ function formatYoutubeResult(data) {
551
648
  }
552
649
 
553
650
  function videoIdFromUrl(url) {
554
- const text = String(url || '');
651
+ const text = String(url || '').split('#')[0];
555
652
  const watch = text.match(/[?&]v=([^&]+)/);
556
653
  if (watch) return watch[1];
557
654
  const short = text.match(/youtu\.be\/([^?&/]+)/);
558
- return short ? short[1] : null;
655
+ if (short) return short[1];
656
+ const pathId = text.match(/\/(?:shorts|embed|live|v|e)\/([^?&/]+)/i);
657
+ return pathId ? pathId[1] : null;
559
658
  }
560
659
 
561
660
  function videoIdFromArg(arg) {
@@ -569,7 +668,7 @@ function videoIdFromArg(arg) {
569
668
  function looksLikeYoutubeUrl(arg) {
570
669
  const text = String(arg || '').trim();
571
670
  if (!text || text.startsWith('-')) return false;
572
- return /youtube\.com|youtu\.be/i.test(text);
671
+ return /youtube\.com|youtu\.be|youtube-nocookie\.com/i.test(text);
573
672
  }
574
673
 
575
674
  function isPlaylistUrl(url) {
@@ -694,6 +793,14 @@ function readNotesText({ url, workDir } = {}) {
694
793
  }
695
794
  }
696
795
 
796
+ function notesWorkDir(deps = {}) {
797
+ return deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
798
+ }
799
+
800
+ function keptPrintedNotes({ url, workDir } = {}) {
801
+ return Boolean(String(readNotesText({ url, workDir }) || '').trim());
802
+ }
803
+
697
804
  function saveRichNotes(url, deps = {}) {
698
805
  const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
699
806
  const lesson = notesLessonFromText(readNotesText({ url, workDir }));
@@ -806,6 +913,8 @@ function unsaveYoutubeNotes(target, deps = {}) {
806
913
  }
807
914
  for (const rel of listTeachSidecarRels(cwd, id)) add(rel);
808
915
  add(notesExperimentRel(id));
916
+ add(processApplyRel(id));
917
+ add(processExperimentRel(id));
809
918
  for (const section of sections) add(teachExperimentRel(id, section));
810
919
 
811
920
  const removed = [];
@@ -853,6 +962,85 @@ function ensureProcessApply({ cwd, url, now, output } = {}) {
853
962
  });
854
963
  }
855
964
 
965
+ function processExperimentSlug(id) {
966
+ return `process-${experimentIdToken(id)}`;
967
+ }
968
+
969
+ function processExperimentRel(id) {
970
+ return `atris/experiments/${processExperimentSlug(id)}`;
971
+ }
972
+
973
+ function processApplyRel(id) {
974
+ return applyGate.applySidecarRel('process', experimentIdToken(id));
975
+ }
976
+
977
+ function saveRichProcess({ cwd, url, lesson } = {}) {
978
+ if (isThinTeachLesson(lesson)) {
979
+ return { thin: true, packRel: null, lesson };
980
+ }
981
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
982
+ const id = videoIdFromUrl(url);
983
+ const packRel = fileTeachExperiment({
984
+ cwd,
985
+ url,
986
+ lesson,
987
+ slug: id ? processExperimentSlug(id) : null,
988
+ applyRel: id ? processApplyRel(id) : null,
989
+ });
990
+ return { thin: false, packRel, lesson, source: url };
991
+ }
992
+
993
+ function ensureProcessLearnerApply({ cwd, url, packRel, now, output, source } = {}) {
994
+ const id = videoIdFromUrl(url);
995
+ const pack = packRel || (id ? processExperimentRel(id) : null);
996
+ const slug = pack ? path.basename(pack) : null;
997
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
998
+ return applyGate.ensureApply({
999
+ cwd,
1000
+ source: source || url || (id ? `process:${id}` : 'process'),
1001
+ rel: id ? processApplyRel(id) : null,
1002
+ now,
1003
+ output,
1004
+ incompleteMessage: slug
1005
+ ? `next: atris experiments keep ${slug}`
1006
+ : applyGate.ephemeralApplyMessage('process'),
1007
+ required: false,
1008
+ change: pack ? `apply ${pack}` : undefined,
1009
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1010
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
1011
+ });
1012
+ }
1013
+
1014
+ function mintRichProcess({ cwd, url, data, now, output, ensureApply, json } = {}) {
1015
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
1016
+ const lesson = notesLessonFromText(processAnalysisText(data));
1017
+ if (isThinTeachLesson(lesson)) {
1018
+ printProcessLearnerGate(data, { json }, print);
1019
+ return 0;
1020
+ }
1021
+ const saved = saveRichProcess({ cwd, url, lesson });
1022
+ const applyFn = ensureApply || ensureProcessLearnerApply;
1023
+ const applyCode = applyFn({
1024
+ cwd,
1025
+ url,
1026
+ packRel: saved.packRel,
1027
+ now,
1028
+ output: print,
1029
+ source: url,
1030
+ });
1031
+ if (ensureApply) return applyCode;
1032
+ const id = videoIdFromUrl(url);
1033
+ const baseline = proveSavedLearnerBaseline({
1034
+ cwd,
1035
+ applyRel: id ? processApplyRel(id) : null,
1036
+ lesson: saved.lesson,
1037
+ output: print,
1038
+ json,
1039
+ });
1040
+ if (baseline !== 0) return baseline;
1041
+ return applyCode;
1042
+ }
1043
+
856
1044
  const DIGEST_ENGINE_TIMEOUT_MS = 240000;
857
1045
  const DEFAULT_DIGEST_DAYS = 7;
858
1046
 
@@ -1082,6 +1270,15 @@ function ensureWatchApply({ cwd, url, packRel, now, output, source } = {}) {
1082
1270
  });
1083
1271
  }
1084
1272
 
1273
+ function firstRichWatchLesson(urls, workDir) {
1274
+ for (const url of Array.isArray(urls) ? urls : []) {
1275
+ if (!url) continue;
1276
+ const lesson = notesLessonFromText(readNotesText({ url, workDir }));
1277
+ if (!isThinTeachLesson(lesson)) return { url, lesson };
1278
+ }
1279
+ return null;
1280
+ }
1281
+
1085
1282
  function saveRichWatch({ cwd, url, lesson } = {}) {
1086
1283
  if (isThinTeachLesson(lesson)) {
1087
1284
  return { thin: true, packRel: null, lesson };
@@ -1276,6 +1473,7 @@ function defaultChannelFetcher(videosUrl, deps = {}) {
1276
1473
  const result = spawn('yt-dlp', [
1277
1474
  '--no-update',
1278
1475
  '--flat-playlist',
1476
+ '--no-warnings',
1279
1477
  '--playlist-end',
1280
1478
  '3',
1281
1479
  '--print',
@@ -1286,11 +1484,13 @@ function defaultChannelFetcher(videosUrl, deps = {}) {
1286
1484
  timeout: 60000,
1287
1485
  maxBuffer: 2 * 1024 * 1024,
1288
1486
  });
1487
+ const videos = parseFlatPlaylist(result && result.stdout);
1488
+ if (videos.length) return videos;
1289
1489
  if (result.error || result.status !== 0) {
1290
1490
  const detail = String(result.stderr || result.error?.message || 'fetch failed').trim();
1291
1491
  throw new Error(detail || 'fetch failed');
1292
1492
  }
1293
- return parseFlatPlaylist(result.stdout);
1493
+ return videos;
1294
1494
  }
1295
1495
 
1296
1496
  function defaultNotesRunner(url, deps = {}) {
@@ -1420,6 +1620,7 @@ async function tickWatch(deps = {}) {
1420
1620
  let totalNew = 0;
1421
1621
  let totalBriefed = 0;
1422
1622
  let firstBriefedUrl = null;
1623
+ const briefedUrls = [];
1423
1624
 
1424
1625
  for (const row of state.channels) {
1425
1626
  const videosUrl = channelVideosUrl(row.channel);
@@ -1466,6 +1667,7 @@ async function tickWatch(deps = {}) {
1466
1667
  }
1467
1668
  markSeen(state, row.channel, video.id, timestamp);
1468
1669
  briefed += 1;
1670
+ briefedUrls.push(url);
1469
1671
  if (!firstBriefedUrl) firstBriefedUrl = url;
1470
1672
  }
1471
1673
 
@@ -1485,34 +1687,35 @@ async function tickWatch(deps = {}) {
1485
1687
  output(`total: ${totalNew} new, ${totalBriefed} briefed`);
1486
1688
  saveWatchState(statePath, state);
1487
1689
  if (totalBriefed > 0) {
1488
- if (firstBriefedUrl) {
1489
- const lesson = notesLessonFromText(readNotesText({ url: firstBriefedUrl, workDir }));
1490
- if (isThinTeachLesson(lesson)) {
1491
- printLearnerCheckGate(output, lesson, { includeCheck: true });
1492
- printYoutubeTeachNext(firstBriefedUrl, {}, output);
1493
- return 0;
1494
- }
1495
- const saved = saveRichWatch({ cwd, url: firstBriefedUrl, lesson });
1690
+ const rich = firstRichWatchLesson(briefedUrls, workDir);
1691
+ if (rich) {
1692
+ const saved = saveRichWatch({ cwd, url: rich.url, lesson: rich.lesson });
1496
1693
  const ensureApply = deps.ensureApply || ensureWatchApply;
1497
1694
  const applyCode = ensureApply({
1498
1695
  cwd,
1499
- url: firstBriefedUrl,
1696
+ url: rich.url,
1500
1697
  packRel: saved.packRel,
1501
1698
  now,
1502
1699
  output,
1503
- source: firstBriefedUrl,
1700
+ source: rich.url,
1504
1701
  });
1505
1702
  if (deps.ensureApply) return applyCode;
1506
- const id = videoIdFromUrl(firstBriefedUrl);
1703
+ const id = videoIdFromUrl(rich.url);
1507
1704
  const baseline = proveSavedLearnerBaseline({
1508
1705
  cwd,
1509
1706
  applyRel: id ? watchApplyRel(id) : null,
1510
- lesson,
1707
+ lesson: rich.lesson,
1511
1708
  output,
1512
1709
  });
1513
1710
  if (baseline !== 0) return baseline;
1514
1711
  return applyCode;
1515
1712
  }
1713
+ if (firstBriefedUrl) {
1714
+ const lesson = notesLessonFromText(readNotesText({ url: firstBriefedUrl, workDir }));
1715
+ printLearnerCheckGate(output, lesson, { includeCheck: true });
1716
+ printYoutubeTeachNext(firstBriefedUrl, {}, output);
1717
+ return 0;
1718
+ }
1516
1719
  printYoutubeTeachNext(firstBriefedUrl, {}, output);
1517
1720
  return 0;
1518
1721
  }
@@ -1549,11 +1752,13 @@ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1549
1752
  timeout: 60000,
1550
1753
  maxBuffer: 2 * 1024 * 1024,
1551
1754
  });
1755
+ const videos = parseFlatPlaylist(result && result.stdout);
1756
+ if (videos.length) return videos;
1552
1757
  if (result.error || (result.status != null && result.status !== 0)) {
1553
1758
  const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
1554
1759
  throw new Error(detail || 'playlist expand failed');
1555
1760
  }
1556
- return parseFlatPlaylist(result.stdout);
1761
+ return videos;
1557
1762
  }
1558
1763
 
1559
1764
  function defaultNotesItemRunner(url, engine, deps = {}) {
@@ -1647,15 +1852,20 @@ function runOneNotesItem(item, engine, deps = {}) {
1647
1852
  }
1648
1853
 
1649
1854
  const started = readNowMs(deps);
1650
- let status = 1;
1855
+ let result = { status: 1 };
1651
1856
  try {
1652
- status = readRunnerStatus(invokeNotesRunner(item.url, engine, deps));
1653
- } catch {
1654
- status = 1;
1857
+ result = invokeNotesRunner(item.url, engine, deps);
1858
+ } catch (err) {
1859
+ result = { status: 1, stderr: String((err && err.message) || err || '') };
1655
1860
  }
1861
+ const status = readRunnerStatus(result);
1656
1862
  let brief = null;
1657
1863
  let lesson = null;
1658
- let ok = status === 0;
1864
+ let ok = status === 0 || keptPrintedNotes({
1865
+ url: item.url,
1866
+ workDir: notesWorkDir(deps),
1867
+ result,
1868
+ });
1659
1869
  if (ok && deps.save) {
1660
1870
  const saved = saveRichNotes(item.url, deps);
1661
1871
  if (saved.thin) {
@@ -1743,10 +1953,16 @@ function runSingleYoutubeNotes(url, engine, deps = {}) {
1743
1953
  try {
1744
1954
  result = invokeNotesRunner(url, engine, deps);
1745
1955
  } catch {
1746
- return 1;
1956
+ result = { status: 1 };
1747
1957
  }
1748
1958
  const status = readRunnerStatus(result);
1749
- if (status !== 0) return status;
1959
+ if (status !== 0 && !keptPrintedNotes({
1960
+ url,
1961
+ workDir: notesWorkDir(deps),
1962
+ result,
1963
+ })) {
1964
+ return status == null ? 1 : status;
1965
+ }
1750
1966
  const output = deps.output || ((line = '') => console.error(line));
1751
1967
  if (!deps.save) {
1752
1968
  const json = deps.json === true;
@@ -1827,14 +2043,14 @@ function showYoutubeSearchHelp(output = console.log, commandName = 'atris youtub
1827
2043
  output('');
1828
2044
  output('Free local discovery. Uses ytsearch on PATH when present, else the');
1829
2045
  output('bundled scripts/det/ytsearch, else yt-dlp ytsearchN with the same print contract.');
1830
- output('Does not bill credits. A hit prints one next: atris youtube teach <first-url>.');
1831
- output('A rich hit prints one failing check (score 0). A thin hit prints check: fill this.');
2046
+ output('Does not bill credits. A thin hit prints check: fill this and one next: atris youtube teach <first-url>.');
2047
+ output('A rich hit writes one apply and a failing keep/revert pack (score 0).');
1832
2048
  output('');
1833
2049
  output(`--paid buys watch permalinks from Atris (${PAID_SEARCH_COST_HINT}).`);
1834
2050
  output('Requires login. Same auth path as atris youtube process.');
1835
- output('A hit also prints one next: atris youtube teach <first-url>.');
1836
- output('A rich hit prints one failing check (score 0). A thin hit prints check: fill this.');
1837
- output('Empty or failed paid search refunds the credits.');
2051
+ output('A rich hit writes one apply and a failing keep/revert pack (score 0).');
2052
+ output('A thin hit prints check: fill this and one next: atris youtube teach <first-url>.');
2053
+ output('Empty or failed paid search prints credits refunded only when the server marks a refund.');
1838
2054
  output('');
1839
2055
  output('Options:');
1840
2056
  output(` --limit <n> Max results (default: ${DEFAULT_SEARCH_LIMIT})`);
@@ -2079,9 +2295,8 @@ function paidSearchCredits(data) {
2079
2295
  return { used, remaining, refunded };
2080
2296
  }
2081
2297
 
2082
- function creditsWereRefunded(credits) {
2298
+ function creditsRefundedExplicitly(credits) {
2083
2299
  if (!credits) return false;
2084
- if (credits.used === 0) return true;
2085
2300
  if (credits.refunded === true) return true;
2086
2301
  return typeof credits.refunded === 'number' && credits.refunded > 0;
2087
2302
  }
@@ -2091,7 +2306,7 @@ function formatCreditsLines(credits) {
2091
2306
  if (credits.used !== undefined || credits.remaining !== undefined) {
2092
2307
  lines.push(`Credits: ${credits.used !== undefined ? credits.used : '?'} used, ${credits.remaining !== undefined ? credits.remaining : '?'} remaining`);
2093
2308
  }
2094
- if (creditsWereRefunded(credits)) {
2309
+ if (creditsRefundedExplicitly(credits)) {
2095
2310
  lines.push('credits refunded');
2096
2311
  }
2097
2312
  return lines;
@@ -2118,7 +2333,7 @@ function youtubeSearchFailureError(result) {
2118
2333
  ? ' YouTube search is unavailable; retry in a few seconds.'
2119
2334
  : '';
2120
2335
  const credits = paidSearchCredits(result.data);
2121
- const refundHint = result.status === 502 && creditsWereRefunded(credits)
2336
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
2122
2337
  ? ' credits refunded.'
2123
2338
  : '';
2124
2339
  const lines = [`YouTube search failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
@@ -2147,6 +2362,12 @@ async function requestPaidYoutubeSearch(options, deps = {}) {
2147
2362
  if (!result.ok && result.status === 401 && !auth.minted) {
2148
2363
  const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
2149
2364
  if (remint?.ok && remint.token) {
2365
+ if (!options.json) {
2366
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
2367
+ for (const line of formatCreditsLines(paidSearchCredits(result.data))) {
2368
+ print(line);
2369
+ }
2370
+ }
2150
2371
  auth = remint;
2151
2372
  result = await call(auth.token);
2152
2373
  }
@@ -2178,9 +2399,7 @@ async function runPaidYoutubeSearch(options, deps = {}) {
2178
2399
  return 2;
2179
2400
  }
2180
2401
  output(rendered);
2181
- printSearchLearnerGate(videos, options, output);
2182
- printSearchTeachNext(videos, options, output);
2183
- return 0;
2402
+ return gateSearchLearner(videos, options, output, deps);
2184
2403
  }
2185
2404
 
2186
2405
  function searchLessonText(rows) {
@@ -2190,6 +2409,93 @@ function searchLessonText(rows) {
2190
2409
  .join('\n');
2191
2410
  }
2192
2411
 
2412
+ function searchExperimentSlug(query) {
2413
+ return `search-${applyGate.applySlug(query)}`;
2414
+ }
2415
+
2416
+ function searchExperimentRel(query) {
2417
+ return `atris/experiments/${searchExperimentSlug(query)}`;
2418
+ }
2419
+
2420
+ function searchApplyRel(query) {
2421
+ return applyGate.applySidecarRel('search', applyGate.applySlug(query));
2422
+ }
2423
+
2424
+ function searchApplyNow(now) {
2425
+ if (typeof now === 'function') {
2426
+ const value = now();
2427
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) return value;
2428
+ const ms = Number(value);
2429
+ if (Number.isFinite(ms) && ms > 0) return new Date(ms).toISOString().slice(0, 10);
2430
+ return undefined;
2431
+ }
2432
+ return now;
2433
+ }
2434
+
2435
+ function saveRichSearch({ cwd, query, lesson } = {}) {
2436
+ if (isThinTeachLesson(lesson)) {
2437
+ return { thin: true, packRel: null, lesson };
2438
+ }
2439
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
2440
+ const packRel = fileTeachExperiment({
2441
+ cwd,
2442
+ lesson,
2443
+ slug: query ? searchExperimentSlug(query) : null,
2444
+ applyRel: query ? searchApplyRel(query) : null,
2445
+ });
2446
+ return { thin: false, packRel, lesson, source: query };
2447
+ }
2448
+
2449
+ function ensureSearchApply({ cwd, query, packRel, now, output, source } = {}) {
2450
+ const pack = packRel || (query ? searchExperimentRel(query) : null);
2451
+ const slug = pack ? path.basename(pack) : null;
2452
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
2453
+ return applyGate.ensureApply({
2454
+ cwd,
2455
+ source: source || query || 'search',
2456
+ rel: query ? searchApplyRel(query) : null,
2457
+ now,
2458
+ output,
2459
+ incompleteMessage: slug
2460
+ ? `next: atris experiments keep ${slug}`
2461
+ : applyGate.ephemeralApplyMessage('search'),
2462
+ required: false,
2463
+ change: pack ? `apply ${pack}` : undefined,
2464
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
2465
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
2466
+ });
2467
+ }
2468
+
2469
+ function mintRichSearch({ cwd, query, rows, now, output, ensureApply, json } = {}) {
2470
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
2471
+ const lesson = notesLessonFromText(searchLessonText(rows));
2472
+ if (json) return { thin: false, code: 0, lesson };
2473
+ if (isThinTeachLesson(lesson)) {
2474
+ printSearchLearnerGate(rows, { json: false }, print);
2475
+ return { thin: true, code: 0, lesson };
2476
+ }
2477
+ const saved = saveRichSearch({ cwd, query, lesson });
2478
+ const applyFn = ensureApply || ensureSearchApply;
2479
+ const applyCode = applyFn({
2480
+ cwd,
2481
+ query,
2482
+ packRel: saved.packRel,
2483
+ now: searchApplyNow(now),
2484
+ output: print,
2485
+ source: query,
2486
+ });
2487
+ if (ensureApply) return { thin: false, code: applyCode, lesson: saved.lesson };
2488
+ const baseline = proveSavedLearnerBaseline({
2489
+ cwd,
2490
+ applyRel: query ? searchApplyRel(query) : null,
2491
+ lesson: saved.lesson,
2492
+ output: print,
2493
+ json,
2494
+ });
2495
+ if (baseline !== 0) return { thin: false, code: baseline, lesson: saved.lesson };
2496
+ return { thin: false, code: applyCode, lesson: saved.lesson };
2497
+ }
2498
+
2193
2499
  function printSearchLearnerGate(rows, options, output) {
2194
2500
  printLearnerCheckGate(output, notesLessonFromText(searchLessonText(rows)), {
2195
2501
  includeCheck: true,
@@ -2197,6 +2503,40 @@ function printSearchLearnerGate(rows, options, output) {
2197
2503
  });
2198
2504
  }
2199
2505
 
2506
+ function gateSearchLearner(rows, options, output, deps = {}) {
2507
+ if (options && options.json) return 0;
2508
+ const minted = mintRichSearch({
2509
+ cwd: deps.cwd || process.cwd(),
2510
+ query: options && options.query,
2511
+ rows,
2512
+ now: deps.now,
2513
+ output,
2514
+ ensureApply: deps.ensureApply,
2515
+ json: false,
2516
+ });
2517
+ if (minted.thin) printSearchTeachNext(rows, options, output);
2518
+ return minted.code;
2519
+ }
2520
+
2521
+ function printSearchOutcome(rows, options, output, deps = {}) {
2522
+ printSearchRows(rows, options, output);
2523
+ return gateSearchLearner(rows, options, output, deps);
2524
+ }
2525
+
2526
+ function resultStdout(result) {
2527
+ if (typeof result === 'string') return result;
2528
+ return String((result && result.stdout) || '');
2529
+ }
2530
+
2531
+ function searchRowsFromResult(result) {
2532
+ return parseSearchStdout(resultStdout(result));
2533
+ }
2534
+
2535
+ function finishSuccessfulSearch(rows, options, output, deps) {
2536
+ writeLocalSearchCache(options.query, rows, deps);
2537
+ return printSearchOutcome(rows, options, output, deps);
2538
+ }
2539
+
2200
2540
  function commandOnPath(name, deps = {}) {
2201
2541
  const spawn = deps.spawnSync || spawnSync;
2202
2542
  const result = spawn('sh', ['-c', `command -v ${shellSingleQuote(name)}`], {
@@ -2313,6 +2653,9 @@ async function runYoutubeSearch(args = [], deps = {}) {
2313
2653
  return 2;
2314
2654
  }
2315
2655
 
2656
+ let rows = searchRowsFromResult(result);
2657
+ if (rows.length) return finishSuccessfulSearch(rows, options, output, deps);
2658
+
2316
2659
  let status = searchRunnerStatus(result);
2317
2660
  if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
2318
2661
  await waitLocalSearchBackoff(deps);
@@ -2326,15 +2669,16 @@ async function runYoutubeSearch(args = [], deps = {}) {
2326
2669
  output('ytsearch and yt-dlp not found. Install yt-dlp or put ytsearch on PATH.');
2327
2670
  return 2;
2328
2671
  }
2672
+ rows = searchRowsFromResult(result);
2673
+ if (rows.length) return finishSuccessfulSearch(rows, options, output, deps);
2329
2674
  status = searchRunnerStatus(result);
2330
2675
  if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
2331
2676
  const cached = readFreshLocalSearchCache(options.query, deps);
2332
2677
  if (cached) {
2333
- const rows = cached.rows.slice(0, options.limit);
2334
- printSearchRows(rows, options, output);
2335
- printSearchTeachNext(rows, options, output);
2678
+ const cachedRows = cached.rows.slice(0, options.limit);
2679
+ const code = printSearchOutcome(cachedRows, options, output, deps);
2336
2680
  output(LOCAL_SEARCH_CACHE_NOTE);
2337
- return 0;
2681
+ return code;
2338
2682
  }
2339
2683
  output(LOCAL_SEARCH_RATE_LIMIT_MESSAGE);
2340
2684
  return status == null ? 1 : status;
@@ -2347,19 +2691,9 @@ async function runYoutubeSearch(args = [], deps = {}) {
2347
2691
  return status == null ? 1 : status;
2348
2692
  }
2349
2693
 
2350
- const stdout = typeof result === 'string' ? result : String((result && result.stdout) || '');
2351
- const rows = parseSearchStdout(stdout);
2352
- if (!rows.length) {
2353
- output('no videos found');
2354
- if (!options.json) printWatchTickNext(output);
2355
- return 2;
2356
- }
2357
-
2358
- writeLocalSearchCache(options.query, rows, deps);
2359
- printSearchRows(rows, options, output);
2360
- printSearchLearnerGate(rows, options, output);
2361
- printSearchTeachNext(rows, options, output);
2362
- return 0;
2694
+ output('no videos found');
2695
+ if (!options.json) printWatchTickNext(output);
2696
+ return 2;
2363
2697
  }
2364
2698
 
2365
2699
  const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip] | owed | next';
@@ -3292,33 +3626,23 @@ async function extractTeachSource(youtubeUrl, deps = {}) {
3292
3626
  return deps.extractTeachSource(youtubeUrl, deps);
3293
3627
  }
3294
3628
  const runner = deps.spawnSync || spawnSync;
3295
- const result = runner('yt-dlp', ['-J', '--skip-download', '--no-warnings', youtubeUrl], {
3629
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
3296
3630
  encoding: 'utf8',
3297
3631
  timeout: 20000,
3298
3632
  maxBuffer: 10 * 1024 * 1024,
3299
3633
  });
3300
- if (result.error || result.status !== 0 || !result.stdout) return null;
3301
-
3302
- let info;
3303
- try {
3304
- info = JSON.parse(result.stdout);
3305
- } catch {
3306
- return null;
3307
- }
3308
-
3309
- const selected = chooseCaptionTrack(info);
3310
- if (!selected?.track?.url) return null;
3311
- const rawCaption = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
3312
- const cues = parseCaptionCues(rawCaption);
3634
+ const info = parseYtDlpInfoJson(result);
3635
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
3636
+ const cues = parseCaptionCues(loaded && loaded.raw);
3313
3637
  if (!cues.length) return null;
3314
3638
 
3315
3639
  return {
3316
- id: info.id || videoIdFromUrl(youtubeUrl),
3317
- title: info.title || '',
3640
+ id: teachSourceVideoId(info, youtubeUrl),
3641
+ title: (info && info.title) || '',
3318
3642
  url: youtubeUrl,
3319
- durationSeconds: Number(info.duration || 0) || undefined,
3320
- language: selected.language || 'unknown',
3321
- chapters: normalizeChapters(info.chapters, info.duration),
3643
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
3644
+ language: loaded.language || 'unknown',
3645
+ chapters: normalizeChapters(info && info.chapters, info && info.duration),
3322
3646
  cues,
3323
3647
  };
3324
3648
  }
@@ -3368,7 +3692,10 @@ async function runYoutubeTeach(args = [], deps = {}) {
3368
3692
  }
3369
3693
  }
3370
3694
 
3371
- const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, deps);
3695
+ const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, {
3696
+ ...deps,
3697
+ workDir: deps.workDir || notesWorkDir(deps),
3698
+ });
3372
3699
  if (!source || !Array.isArray(source.cues) || !source.cues.length) {
3373
3700
  output('no english captions for this url. teach stays local and will not call process.');
3374
3701
  return 2;
@@ -3492,7 +3819,15 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
3492
3819
  output(JSON.stringify(data, null, 2));
3493
3820
  } else {
3494
3821
  output(formatYoutubeResult(data));
3495
- printProcessLearnerGate(data, {}, output);
3822
+ const mintCode = mintRichProcess({
3823
+ cwd: deps.cwd || process.cwd(),
3824
+ url: options.youtubeUrl,
3825
+ data,
3826
+ now: deps.now,
3827
+ output,
3828
+ ensureApply: deps.ensureApply,
3829
+ });
3830
+ if (mintCode !== 0) status = mintCode;
3496
3831
  }
3497
3832
  } catch (err) {
3498
3833
  if (!err.applyRequired) output(err.message);
@@ -3510,10 +3845,12 @@ module.exports = {
3510
3845
  parseYoutubeArgs,
3511
3846
  buildYoutubePayload,
3512
3847
  extractLocalTranscript,
3848
+ parseYtDlpInfoJson,
3513
3849
  processYoutube,
3514
3850
  shouldRetryWithLocalTranscript,
3515
3851
  formatYoutubeResult,
3516
3852
  fileBriefFromNotes,
3853
+ keptPrintedNotes,
3517
3854
  ensureNotesApply,
3518
3855
  unsaveYoutubeNotes,
3519
3856
  APPLY_NEXT_MESSAGE,
@@ -3541,6 +3878,7 @@ module.exports = {
3541
3878
  extractTeachNumbers,
3542
3879
  extractTeachMechanisms,
3543
3880
  extractTeachSource,
3881
+ readLocalCaptionText,
3544
3882
  oneTeachCheck,
3545
3883
  learnerCheckFromLesson,
3546
3884
  scoreLearnerNeedles,
@@ -3556,6 +3894,11 @@ module.exports = {
3556
3894
  notesExperimentSlug,
3557
3895
  digestExperimentSlug,
3558
3896
  watchExperimentSlug,
3897
+ processExperimentSlug,
3898
+ processApplyRel,
3899
+ searchExperimentSlug,
3900
+ searchApplyRel,
3901
+ firstRichWatchLesson,
3559
3902
  fileTeachExperiment,
3560
3903
  youtubeCommand,
3561
3904
  };