atris 3.58.5 → 3.58.7

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 (42) hide show
  1. package/README.md +8 -0
  2. package/atris/policies/engineering-principles.md +129 -0
  3. package/atris/policies/genesis.md +112 -0
  4. package/atris/policies/product-design-principles.md +100 -0
  5. package/atris/skills/design/SKILL.md +3 -1
  6. package/atris/skills/engines/SKILL.md +3 -3
  7. package/atris/skills/x-search/SKILL.md +2 -2
  8. package/atris/skills/youtube/SKILL.md +44 -28
  9. package/bin/atris.js +56 -3
  10. package/commands/auth.js +58 -24
  11. package/commands/brain.js +1 -0
  12. package/commands/design.js +362 -0
  13. package/commands/doc-health.js +329 -0
  14. package/commands/drive.js +32 -0
  15. package/commands/improve.js +67 -1
  16. package/commands/land.js +144 -4
  17. package/commands/learn.js +211 -40
  18. package/commands/member.js +65 -11
  19. package/commands/mission.js +37 -7
  20. package/commands/pulse.js +38 -0
  21. package/commands/rsi.js +156 -0
  22. package/commands/task.js +41 -1
  23. package/commands/workflow.js +15 -14
  24. package/commands/x-search.js +9 -10
  25. package/commands/youtube.js +518 -107
  26. package/lib/apply-gate.js +22 -4
  27. package/lib/daily-log.js +88 -0
  28. package/lib/design-api.js +130 -0
  29. package/lib/engine-ask.js +1 -1
  30. package/lib/first-minute.js +1 -6
  31. package/lib/known-commands.js +3 -3
  32. package/lib/member-context.js +42 -0
  33. package/lib/rsi-record.js +335 -0
  34. package/lib/state-detection.js +8 -8
  35. package/lib/task-db.js +71 -51
  36. package/lib/task-list-keeper.js +192 -0
  37. package/lib/todo-fallback.js +9 -3
  38. package/lib/todo.js +22 -10
  39. package/mcp/atris-mcp/index.mjs +174 -0
  40. package/package.json +8 -3
  41. package/scripts/det/ytnotes +122 -10
  42. package/utils/auth.js +109 -13
@@ -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) {
@@ -275,6 +283,51 @@ function parseVttTimestampMs(value) {
275
283
  return ((hours * 3600) + (minutes * 60) + seconds) * 1000 + millis;
276
284
  }
277
285
 
286
+ function parseCleanTimestampMs(value) {
287
+ const match = String(value || '').trim().match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/);
288
+ if (!match) return null;
289
+ const hours = Number(match[1] || 0);
290
+ const minutes = Number(match[2] || 0);
291
+ const seconds = Number(match[3] || 0);
292
+ if (![hours, minutes, seconds].every(Number.isFinite)) return null;
293
+ if (minutes > 59 || seconds > 59) return null;
294
+ return ((hours * 3600) + (minutes * 60) + seconds) * 1000;
295
+ }
296
+
297
+ function parseCleanTranscriptCues(raw) {
298
+ const cues = [];
299
+ let startMs = 0;
300
+ let sawStamp = false;
301
+ const pending = [];
302
+ const flush = () => {
303
+ const text = pending.join(' ').replace(/\s+/g, ' ').trim();
304
+ pending.length = 0;
305
+ if (!text) return;
306
+ const cue = { startMs, text };
307
+ if (cues.length && cues[cues.length - 1].text === cue.text && cues[cues.length - 1].startMs === cue.startMs) {
308
+ return;
309
+ }
310
+ cues.push(cue);
311
+ };
312
+ for (const line of String(raw).split(/\r?\n/)) {
313
+ const stripped = line.trim();
314
+ if (!stripped) continue;
315
+ const stamp = stripped.match(/^\[(\d{1,2}:\d{2}(?::\d{2})?)\]$/);
316
+ if (stamp) {
317
+ flush();
318
+ const parsed = parseCleanTimestampMs(stamp[1]);
319
+ if (parsed != null) {
320
+ startMs = parsed;
321
+ sawStamp = true;
322
+ }
323
+ continue;
324
+ }
325
+ pending.push(stripped.replace(/<[^>]+>/g, ''));
326
+ }
327
+ flush();
328
+ return sawStamp && cues.length ? cues : [];
329
+ }
330
+
278
331
  function fetchCaptionText(urlString, redirects = 0) {
279
332
  if (!captionHostAllowed(urlString)) {
280
333
  return Promise.resolve(null);
@@ -372,7 +425,7 @@ function parseCaptionCues(raw) {
372
425
  return cues;
373
426
  }
374
427
 
375
- return [];
428
+ return parseCleanTranscriptCues(raw);
376
429
  }
377
430
 
378
431
  function parseCaptionText(raw) {
@@ -406,33 +459,117 @@ function parseCaptionText(raw) {
406
459
  return segments.join(' ');
407
460
  }
408
461
 
462
+ function parseYtDlpInfoJson(result) {
463
+ const kept = [];
464
+ for (const line of String((result && result.stdout) || '').split(/\r?\n/)) {
465
+ const trimmed = line.trim();
466
+ if (!trimmed) continue;
467
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
468
+ kept.push(trimmed);
469
+ }
470
+ const raw = kept.join('\n').trim();
471
+ if (!raw) return null;
472
+ try {
473
+ const info = JSON.parse(raw);
474
+ return info && typeof info === 'object' && !Array.isArray(info) ? info : null;
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
479
+
480
+ function localCaptionNames(id) {
481
+ // scripts/det/ytnotes keeps these VTT names plus leftover yt_<id>.clean.txt.
482
+ return [
483
+ `yt_${id}.en.vtt`,
484
+ `yt_${id}.en-orig.vtt`,
485
+ `yt_${id}.en-US.vtt`,
486
+ `yt_${id}.en-GB.vtt`,
487
+ `yt_${id}.clean.txt`,
488
+ ];
489
+ }
490
+
491
+ function captionLookupIds({ url, id } = {}) {
492
+ const ids = [];
493
+ const seen = new Set();
494
+ const add = (value) => {
495
+ const text = String(value || '').trim();
496
+ if (!text || seen.has(text)) return;
497
+ seen.add(text);
498
+ ids.push(text);
499
+ };
500
+ add(id);
501
+ add(videoIdFromUrl(url));
502
+ return ids;
503
+ }
504
+
505
+ function readLocalCaptionText({ url, id, workDir } = {}) {
506
+ if (!workDir) return '';
507
+ for (const videoId of captionLookupIds({ url, id })) {
508
+ for (const name of localCaptionNames(videoId)) {
509
+ const abs = path.join(workDir, name);
510
+ try {
511
+ if (!fs.existsSync(abs)) continue;
512
+ const text = fs.readFileSync(abs, 'utf8');
513
+ if (String(text).trim()) return text;
514
+ } catch {
515
+ // try the next written caption file
516
+ }
517
+ }
518
+ }
519
+ return '';
520
+ }
521
+
522
+ async function loadCaptionRaw(info, youtubeUrl, deps = {}) {
523
+ const payload = info && typeof info === 'object' ? info : {};
524
+ const selected = chooseCaptionTrack(payload);
525
+ let raw = '';
526
+ if (selected?.track?.url) {
527
+ raw = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
528
+ }
529
+ if (String(raw || '').trim()) {
530
+ return { raw, language: selected?.language || 'unknown' };
531
+ }
532
+ const local = readLocalCaptionText({
533
+ url: youtubeUrl,
534
+ id: payload.id,
535
+ workDir: deps.workDir,
536
+ });
537
+ if (!String(local || '').trim()) return null;
538
+ return { raw: local, language: selected?.language || 'en' };
539
+ }
540
+
541
+ function ytDlpInfoArgs(youtubeUrl) {
542
+ return ['-J', '--skip-download', '--no-warnings', '--no-playlist', youtubeUrl];
543
+ }
544
+
545
+ function teachSourceVideoId(info, youtubeUrl) {
546
+ const fromUrl = videoIdFromUrl(youtubeUrl);
547
+ const fromInfo = info && info.id;
548
+ const entries = info && Array.isArray(info.entries) ? info.entries : [];
549
+ const fromEntry = entries[0] && entries[0].id;
550
+ if (info && (info._type === 'playlist' || entries.length)) {
551
+ return fromUrl || fromEntry || fromInfo;
552
+ }
553
+ return fromInfo || fromUrl;
554
+ }
555
+
409
556
  async function extractLocalTranscript(youtubeUrl, deps = {}) {
410
557
  if (process.env.ATRIS_YOUTUBE_LOCAL_TRANSCRIPT === '0') return null;
411
558
  const runner = deps.spawnSync || spawnSync;
412
- const result = runner('yt-dlp', ['-J', '--skip-download', '--no-warnings', youtubeUrl], {
559
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
413
560
  encoding: 'utf8',
414
561
  timeout: 20000,
415
562
  maxBuffer: 10 * 1024 * 1024,
416
563
  });
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);
564
+ const info = parseYtDlpInfoJson(result);
565
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
566
+ const transcript = parseCaptionText(loaded && loaded.raw);
430
567
  if (!transcript) return null;
431
568
 
432
569
  return {
433
570
  transcriptText: transcript.slice(0, LOCAL_TRANSCRIPT_MAX_CHARS),
434
- language: selected.language || 'unknown',
435
- durationSeconds: Number(info.duration || 0) || undefined,
571
+ language: loaded.language || 'unknown',
572
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
436
573
  };
437
574
  }
438
575
 
@@ -468,6 +605,12 @@ async function processYoutube(options, deps = {}) {
468
605
  if (!result.ok && result.status === 401 && !auth.minted) {
469
606
  const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
470
607
  if (remint?.ok && remint.token) {
608
+ if (!options.json) {
609
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
610
+ for (const line of formatCreditsLines(paidSearchCredits(result.data))) {
611
+ print(line);
612
+ }
613
+ }
471
614
  auth = remint;
472
615
  result = await apiFn('/agent/process_youtube', {
473
616
  method: 'POST',
@@ -484,7 +627,10 @@ async function processYoutube(options, deps = {}) {
484
627
  const localExtractor = deps.extractLocalTranscript || extractLocalTranscript;
485
628
  let localTranscript = null;
486
629
  try {
487
- localTranscript = await localExtractor(options.youtubeUrl, deps);
630
+ localTranscript = await localExtractor(options.youtubeUrl, {
631
+ ...deps,
632
+ workDir: deps.workDir || notesWorkDir(deps),
633
+ });
488
634
  } catch {
489
635
  localTranscript = null;
490
636
  }
@@ -501,6 +647,12 @@ async function processYoutube(options, deps = {}) {
501
647
  if (transcriptResult.status === 401 || transcriptResult.status === 402 || transcriptResult.status === 400) {
502
648
  throw youtubeFailureError(transcriptResult);
503
649
  }
650
+ if (!options.json) {
651
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
652
+ for (const line of formatCreditsLines(paidSearchCredits(transcriptResult.data))) {
653
+ print(line);
654
+ }
655
+ }
504
656
  }
505
657
 
506
658
  const result = await requestYoutube(buildYoutubePayload(options));
@@ -537,11 +689,8 @@ function formatYoutubeResult(data) {
537
689
  : '';
538
690
  lines.push(`Processing: ${method}${source}`);
539
691
  }
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
- }
692
+ const creditLines = formatCreditsLines(paidSearchCredits(data));
693
+ if (creditLines.length) lines.push(...creditLines);
545
694
  const analysis = processAnalysisText(data);
546
695
  if (analysis) {
547
696
  lines.push('');
@@ -551,11 +700,13 @@ function formatYoutubeResult(data) {
551
700
  }
552
701
 
553
702
  function videoIdFromUrl(url) {
554
- const text = String(url || '');
703
+ const text = String(url || '').split('#')[0];
555
704
  const watch = text.match(/[?&]v=([^&]+)/);
556
705
  if (watch) return watch[1];
557
706
  const short = text.match(/youtu\.be\/([^?&/]+)/);
558
- return short ? short[1] : null;
707
+ if (short) return short[1];
708
+ const pathId = text.match(/\/(?:shorts|embed|live|v|e)\/([^?&/]+)/i);
709
+ return pathId ? pathId[1] : null;
559
710
  }
560
711
 
561
712
  function videoIdFromArg(arg) {
@@ -569,7 +720,7 @@ function videoIdFromArg(arg) {
569
720
  function looksLikeYoutubeUrl(arg) {
570
721
  const text = String(arg || '').trim();
571
722
  if (!text || text.startsWith('-')) return false;
572
- return /youtube\.com|youtu\.be/i.test(text);
723
+ return /youtube\.com|youtu\.be|youtube-nocookie\.com/i.test(text);
573
724
  }
574
725
 
575
726
  function isPlaylistUrl(url) {
@@ -666,6 +817,10 @@ function applySidecarRel(id) {
666
817
  return applyGate.applySidecarRel('youtube', id);
667
818
  }
668
819
 
820
+ function notesApplyRel(id) {
821
+ return applyGate.applySidecarRel('notes', experimentIdToken(id));
822
+ }
823
+
669
824
  function notesExperimentSlug(id) {
670
825
  return `notes-${experimentIdToken(id)}`;
671
826
  }
@@ -694,6 +849,14 @@ function readNotesText({ url, workDir } = {}) {
694
849
  }
695
850
  }
696
851
 
852
+ function notesWorkDir(deps = {}) {
853
+ return deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
854
+ }
855
+
856
+ function keptPrintedNotes({ url, workDir } = {}) {
857
+ return Boolean(String(readNotesText({ url, workDir }) || '').trim());
858
+ }
859
+
697
860
  function saveRichNotes(url, deps = {}) {
698
861
  const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
699
862
  const lesson = notesLessonFromText(readNotesText({ url, workDir }));
@@ -707,7 +870,7 @@ function saveRichNotes(url, deps = {}) {
707
870
  url,
708
871
  lesson,
709
872
  slug: id ? notesExperimentSlug(id) : null,
710
- applyRel: id ? applySidecarRel(id) : null,
873
+ applyRel: id ? notesApplyRel(id) : null,
711
874
  });
712
875
  return { thin: false, brief, packRel, lesson };
713
876
  }
@@ -719,7 +882,7 @@ function ensureNotesApply({ cwd, url, packRel, now, output } = {}) {
719
882
  return applyGate.ensureApply({
720
883
  cwd,
721
884
  source: url,
722
- rel: id ? applySidecarRel(id) : null,
885
+ rel: id ? notesApplyRel(id) : null,
723
886
  now,
724
887
  output,
725
888
  incompleteMessage: slug
@@ -800,12 +963,15 @@ function unsaveYoutubeNotes(target, deps = {}) {
800
963
  };
801
964
  add(briefRel);
802
965
  add(applyRel);
966
+ add(notesApplyRel(id));
803
967
  for (const section of sections) {
804
968
  add(teachBriefRel(id, section));
805
969
  add(applySidecarRel(`${id}-s${section}`));
806
970
  }
807
971
  for (const rel of listTeachSidecarRels(cwd, id)) add(rel);
808
972
  add(notesExperimentRel(id));
973
+ add(processApplyRel(id));
974
+ add(processExperimentRel(id));
809
975
  for (const section of sections) add(teachExperimentRel(id, section));
810
976
 
811
977
  const removed = [];
@@ -850,9 +1016,89 @@ function ensureProcessApply({ cwd, url, now, output } = {}) {
850
1016
  output,
851
1017
  incompleteMessage: PROCESS_APPLY_MESSAGE,
852
1018
  required: true,
1019
+ human: true,
1020
+ });
1021
+ }
1022
+
1023
+ function processExperimentSlug(id) {
1024
+ return `process-${experimentIdToken(id)}`;
1025
+ }
1026
+
1027
+ function processExperimentRel(id) {
1028
+ return `atris/experiments/${processExperimentSlug(id)}`;
1029
+ }
1030
+
1031
+ function processApplyRel(id) {
1032
+ return applyGate.applySidecarRel('process', experimentIdToken(id));
1033
+ }
1034
+
1035
+ function saveRichProcess({ cwd, url, lesson } = {}) {
1036
+ if (isThinTeachLesson(lesson)) {
1037
+ return { thin: true, packRel: null, lesson };
1038
+ }
1039
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
1040
+ const id = videoIdFromUrl(url);
1041
+ const packRel = fileTeachExperiment({
1042
+ cwd,
1043
+ url,
1044
+ lesson,
1045
+ slug: id ? processExperimentSlug(id) : null,
1046
+ applyRel: id ? processApplyRel(id) : null,
1047
+ });
1048
+ return { thin: false, packRel, lesson, source: url };
1049
+ }
1050
+
1051
+ function ensureProcessLearnerApply({ cwd, url, packRel, now, output, source } = {}) {
1052
+ const id = videoIdFromUrl(url);
1053
+ const pack = packRel || (id ? processExperimentRel(id) : null);
1054
+ const slug = pack ? path.basename(pack) : null;
1055
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
1056
+ return applyGate.ensureApply({
1057
+ cwd,
1058
+ source: source || url || (id ? `process:${id}` : 'process'),
1059
+ rel: id ? processApplyRel(id) : null,
1060
+ now,
1061
+ output,
1062
+ incompleteMessage: slug
1063
+ ? `next: atris experiments keep ${slug}`
1064
+ : applyGate.ephemeralApplyMessage('process'),
1065
+ required: false,
1066
+ change: pack ? `apply ${pack}` : undefined,
1067
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1068
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
853
1069
  });
854
1070
  }
855
1071
 
1072
+ function mintRichProcess({ cwd, url, data, now, output, ensureApply, json } = {}) {
1073
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
1074
+ const lesson = notesLessonFromText(processAnalysisText(data));
1075
+ if (isThinTeachLesson(lesson)) {
1076
+ printProcessLearnerGate(data, { json }, print);
1077
+ return 0;
1078
+ }
1079
+ const saved = saveRichProcess({ cwd, url, lesson });
1080
+ const applyFn = ensureApply || ensureProcessLearnerApply;
1081
+ const applyCode = applyFn({
1082
+ cwd,
1083
+ url,
1084
+ packRel: saved.packRel,
1085
+ now,
1086
+ output: print,
1087
+ source: url,
1088
+ });
1089
+ if (ensureApply) return applyCode;
1090
+ const id = videoIdFromUrl(url);
1091
+ const baseline = proveSavedLearnerBaseline({
1092
+ cwd,
1093
+ applyRel: id ? processApplyRel(id) : null,
1094
+ lesson: saved.lesson,
1095
+ output: print,
1096
+ json,
1097
+ });
1098
+ if (baseline !== 0) return baseline;
1099
+ return applyCode;
1100
+ }
1101
+
856
1102
  const DIGEST_ENGINE_TIMEOUT_MS = 240000;
857
1103
  const DEFAULT_DIGEST_DAYS = 7;
858
1104
 
@@ -1082,6 +1328,15 @@ function ensureWatchApply({ cwd, url, packRel, now, output, source } = {}) {
1082
1328
  });
1083
1329
  }
1084
1330
 
1331
+ function firstRichWatchLesson(urls, workDir) {
1332
+ for (const url of Array.isArray(urls) ? urls : []) {
1333
+ if (!url) continue;
1334
+ const lesson = notesLessonFromText(readNotesText({ url, workDir }));
1335
+ if (!isThinTeachLesson(lesson)) return { url, lesson };
1336
+ }
1337
+ return null;
1338
+ }
1339
+
1085
1340
  function saveRichWatch({ cwd, url, lesson } = {}) {
1086
1341
  if (isThinTeachLesson(lesson)) {
1087
1342
  return { thin: true, packRel: null, lesson };
@@ -1258,15 +1513,22 @@ function channelVideosUrl(channel) {
1258
1513
  return `${base}/videos`;
1259
1514
  }
1260
1515
 
1516
+ function looksLikeFlatVideoId(id) {
1517
+ const text = String(id || '');
1518
+ if (/^(NA|None)$/i.test(text)) return false;
1519
+ return /^[A-Za-z0-9_-]+$/.test(text);
1520
+ }
1521
+
1261
1522
  function parseFlatPlaylist(stdout) {
1262
1523
  const videos = [];
1263
1524
  for (const line of String(stdout || '').split(/\r?\n/)) {
1264
1525
  const trimmed = line.trim();
1265
1526
  if (!trimmed || !trimmed.includes('|')) continue;
1527
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
1266
1528
  const idx = trimmed.indexOf('|');
1267
1529
  const id = trimmed.slice(0, idx).trim();
1268
1530
  const title = trimmed.slice(idx + 1).trim();
1269
- if (id && id !== 'NA') videos.push({ id, title });
1531
+ if (looksLikeFlatVideoId(id)) videos.push({ id, title });
1270
1532
  }
1271
1533
  return videos;
1272
1534
  }
@@ -1276,6 +1538,7 @@ function defaultChannelFetcher(videosUrl, deps = {}) {
1276
1538
  const result = spawn('yt-dlp', [
1277
1539
  '--no-update',
1278
1540
  '--flat-playlist',
1541
+ '--no-warnings',
1279
1542
  '--playlist-end',
1280
1543
  '3',
1281
1544
  '--print',
@@ -1286,11 +1549,13 @@ function defaultChannelFetcher(videosUrl, deps = {}) {
1286
1549
  timeout: 60000,
1287
1550
  maxBuffer: 2 * 1024 * 1024,
1288
1551
  });
1552
+ const videos = parseFlatPlaylist(result && result.stdout);
1553
+ if (videos.length) return videos;
1289
1554
  if (result.error || result.status !== 0) {
1290
1555
  const detail = String(result.stderr || result.error?.message || 'fetch failed').trim();
1291
1556
  throw new Error(detail || 'fetch failed');
1292
1557
  }
1293
- return parseFlatPlaylist(result.stdout);
1558
+ return videos;
1294
1559
  }
1295
1560
 
1296
1561
  function defaultNotesRunner(url, deps = {}) {
@@ -1420,6 +1685,7 @@ async function tickWatch(deps = {}) {
1420
1685
  let totalNew = 0;
1421
1686
  let totalBriefed = 0;
1422
1687
  let firstBriefedUrl = null;
1688
+ const briefedUrls = [];
1423
1689
 
1424
1690
  for (const row of state.channels) {
1425
1691
  const videosUrl = channelVideosUrl(row.channel);
@@ -1466,6 +1732,7 @@ async function tickWatch(deps = {}) {
1466
1732
  }
1467
1733
  markSeen(state, row.channel, video.id, timestamp);
1468
1734
  briefed += 1;
1735
+ briefedUrls.push(url);
1469
1736
  if (!firstBriefedUrl) firstBriefedUrl = url;
1470
1737
  }
1471
1738
 
@@ -1485,34 +1752,35 @@ async function tickWatch(deps = {}) {
1485
1752
  output(`total: ${totalNew} new, ${totalBriefed} briefed`);
1486
1753
  saveWatchState(statePath, state);
1487
1754
  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 });
1755
+ const rich = firstRichWatchLesson(briefedUrls, workDir);
1756
+ if (rich) {
1757
+ const saved = saveRichWatch({ cwd, url: rich.url, lesson: rich.lesson });
1496
1758
  const ensureApply = deps.ensureApply || ensureWatchApply;
1497
1759
  const applyCode = ensureApply({
1498
1760
  cwd,
1499
- url: firstBriefedUrl,
1761
+ url: rich.url,
1500
1762
  packRel: saved.packRel,
1501
1763
  now,
1502
1764
  output,
1503
- source: firstBriefedUrl,
1765
+ source: rich.url,
1504
1766
  });
1505
1767
  if (deps.ensureApply) return applyCode;
1506
- const id = videoIdFromUrl(firstBriefedUrl);
1768
+ const id = videoIdFromUrl(rich.url);
1507
1769
  const baseline = proveSavedLearnerBaseline({
1508
1770
  cwd,
1509
1771
  applyRel: id ? watchApplyRel(id) : null,
1510
- lesson,
1772
+ lesson: rich.lesson,
1511
1773
  output,
1512
1774
  });
1513
1775
  if (baseline !== 0) return baseline;
1514
1776
  return applyCode;
1515
1777
  }
1778
+ if (firstBriefedUrl) {
1779
+ const lesson = notesLessonFromText(readNotesText({ url: firstBriefedUrl, workDir }));
1780
+ printLearnerCheckGate(output, lesson, { includeCheck: true });
1781
+ printYoutubeTeachNext(firstBriefedUrl, {}, output);
1782
+ return 0;
1783
+ }
1516
1784
  printYoutubeTeachNext(firstBriefedUrl, {}, output);
1517
1785
  return 0;
1518
1786
  }
@@ -1541,6 +1809,7 @@ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1541
1809
  const result = spawn('yt-dlp', [
1542
1810
  '--no-update',
1543
1811
  '--flat-playlist',
1812
+ '--no-warnings',
1544
1813
  '--print',
1545
1814
  '%(id)s|%(title)s',
1546
1815
  playlistUrl,
@@ -1549,11 +1818,13 @@ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1549
1818
  timeout: 60000,
1550
1819
  maxBuffer: 2 * 1024 * 1024,
1551
1820
  });
1821
+ const videos = parseFlatPlaylist(result && result.stdout);
1822
+ if (videos.length) return videos;
1552
1823
  if (result.error || (result.status != null && result.status !== 0)) {
1553
1824
  const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
1554
1825
  throw new Error(detail || 'playlist expand failed');
1555
1826
  }
1556
- return parseFlatPlaylist(result.stdout);
1827
+ return videos;
1557
1828
  }
1558
1829
 
1559
1830
  function defaultNotesItemRunner(url, engine, deps = {}) {
@@ -1647,15 +1918,20 @@ function runOneNotesItem(item, engine, deps = {}) {
1647
1918
  }
1648
1919
 
1649
1920
  const started = readNowMs(deps);
1650
- let status = 1;
1921
+ let result = { status: 1 };
1651
1922
  try {
1652
- status = readRunnerStatus(invokeNotesRunner(item.url, engine, deps));
1653
- } catch {
1654
- status = 1;
1923
+ result = invokeNotesRunner(item.url, engine, deps);
1924
+ } catch (err) {
1925
+ result = { status: 1, stderr: String((err && err.message) || err || '') };
1655
1926
  }
1927
+ const status = readRunnerStatus(result);
1656
1928
  let brief = null;
1657
1929
  let lesson = null;
1658
- let ok = status === 0;
1930
+ let ok = status === 0 || keptPrintedNotes({
1931
+ url: item.url,
1932
+ workDir: notesWorkDir(deps),
1933
+ result,
1934
+ });
1659
1935
  if (ok && deps.save) {
1660
1936
  const saved = saveRichNotes(item.url, deps);
1661
1937
  if (saved.thin) {
@@ -1728,7 +2004,7 @@ function runYoutubeNotesBatch({ urls, engine, save, json } = {}, deps = {}) {
1728
2004
  }));
1729
2005
  const baseline = proveSavedLearnerBaseline({
1730
2006
  cwd: deps.cwd || process.cwd(),
1731
- applyRel: id ? applySidecarRel(id) : null,
2007
+ applyRel: id ? notesApplyRel(id) : null,
1732
2008
  lesson,
1733
2009
  output,
1734
2010
  json: asJson,
@@ -1743,10 +2019,16 @@ function runSingleYoutubeNotes(url, engine, deps = {}) {
1743
2019
  try {
1744
2020
  result = invokeNotesRunner(url, engine, deps);
1745
2021
  } catch {
1746
- return 1;
2022
+ result = { status: 1 };
1747
2023
  }
1748
2024
  const status = readRunnerStatus(result);
1749
- if (status !== 0) return status;
2025
+ if (status !== 0 && !keptPrintedNotes({
2026
+ url,
2027
+ workDir: notesWorkDir(deps),
2028
+ result,
2029
+ })) {
2030
+ return status == null ? 1 : status;
2031
+ }
1750
2032
  const output = deps.output || ((line = '') => console.error(line));
1751
2033
  if (!deps.save) {
1752
2034
  const json = deps.json === true;
@@ -1773,7 +2055,7 @@ function runSingleYoutubeNotes(url, engine, deps = {}) {
1773
2055
  const id = videoIdFromUrl(url);
1774
2056
  const baseline = proveSavedLearnerBaseline({
1775
2057
  cwd,
1776
- applyRel: id ? applySidecarRel(id) : null,
2058
+ applyRel: id ? notesApplyRel(id) : null,
1777
2059
  lesson: saved.lesson,
1778
2060
  output,
1779
2061
  json: deps.json === true,
@@ -1827,14 +2109,14 @@ function showYoutubeSearchHelp(output = console.log, commandName = 'atris youtub
1827
2109
  output('');
1828
2110
  output('Free local discovery. Uses ytsearch on PATH when present, else the');
1829
2111
  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.');
2112
+ output('Does not bill credits. A thin hit prints check: fill this and one next: atris youtube teach <first-url>.');
2113
+ output('A rich hit writes one apply and a failing keep/revert pack (score 0).');
1832
2114
  output('');
1833
2115
  output(`--paid buys watch permalinks from Atris (${PAID_SEARCH_COST_HINT}).`);
1834
2116
  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.');
2117
+ output('A rich hit writes one apply and a failing keep/revert pack (score 0).');
2118
+ output('A thin hit prints check: fill this and one next: atris youtube teach <first-url>.');
2119
+ output('Empty or failed paid search prints credits refunded only when the server marks a refund.');
1838
2120
  output('');
1839
2121
  output('Options:');
1840
2122
  output(` --limit <n> Max results (default: ${DEFAULT_SEARCH_LIMIT})`);
@@ -1913,10 +2195,12 @@ function parseSearchStdout(stdout = '') {
1913
2195
  for (const line of String(stdout || '').split(/\r?\n/)) {
1914
2196
  const trimmed = line.trim();
1915
2197
  if (!trimmed || !trimmed.includes('|')) continue;
2198
+ if (/^(WARNING|ERROR|INFO)\b/i.test(trimmed)) continue;
1916
2199
  const parts = trimmed.split(/\s*\|\s*/).map((part) => part.trim());
1917
2200
  if (parts.length < 5) continue;
1918
2201
  const url = parts[parts.length - 1];
1919
2202
  if (!/^https?:\/\/(?:www\.)?(?:youtube\.com\/|youtu\.be\/)/i.test(url)) continue;
2203
+ if (!looksLikeFlatVideoId(videoIdFromUrl(url))) continue;
1920
2204
  const row = {
1921
2205
  title: parts[0] || '',
1922
2206
  channel: parts[1] || '',
@@ -2079,9 +2363,8 @@ function paidSearchCredits(data) {
2079
2363
  return { used, remaining, refunded };
2080
2364
  }
2081
2365
 
2082
- function creditsWereRefunded(credits) {
2366
+ function creditsRefundedExplicitly(credits) {
2083
2367
  if (!credits) return false;
2084
- if (credits.used === 0) return true;
2085
2368
  if (credits.refunded === true) return true;
2086
2369
  return typeof credits.refunded === 'number' && credits.refunded > 0;
2087
2370
  }
@@ -2091,7 +2374,7 @@ function formatCreditsLines(credits) {
2091
2374
  if (credits.used !== undefined || credits.remaining !== undefined) {
2092
2375
  lines.push(`Credits: ${credits.used !== undefined ? credits.used : '?'} used, ${credits.remaining !== undefined ? credits.remaining : '?'} remaining`);
2093
2376
  }
2094
- if (creditsWereRefunded(credits)) {
2377
+ if (creditsRefundedExplicitly(credits)) {
2095
2378
  lines.push('credits refunded');
2096
2379
  }
2097
2380
  return lines;
@@ -2118,7 +2401,7 @@ function youtubeSearchFailureError(result) {
2118
2401
  ? ' YouTube search is unavailable; retry in a few seconds.'
2119
2402
  : '';
2120
2403
  const credits = paidSearchCredits(result.data);
2121
- const refundHint = result.status === 502 && creditsWereRefunded(credits)
2404
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
2122
2405
  ? ' credits refunded.'
2123
2406
  : '';
2124
2407
  const lines = [`YouTube search failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
@@ -2147,6 +2430,12 @@ async function requestPaidYoutubeSearch(options, deps = {}) {
2147
2430
  if (!result.ok && result.status === 401 && !auth.minted) {
2148
2431
  const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
2149
2432
  if (remint?.ok && remint.token) {
2433
+ if (!options.json) {
2434
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
2435
+ for (const line of formatCreditsLines(paidSearchCredits(result.data))) {
2436
+ print(line);
2437
+ }
2438
+ }
2150
2439
  auth = remint;
2151
2440
  result = await call(auth.token);
2152
2441
  }
@@ -2178,9 +2467,7 @@ async function runPaidYoutubeSearch(options, deps = {}) {
2178
2467
  return 2;
2179
2468
  }
2180
2469
  output(rendered);
2181
- printSearchLearnerGate(videos, options, output);
2182
- printSearchTeachNext(videos, options, output);
2183
- return 0;
2470
+ return gateSearchLearner(videos, options, output, deps);
2184
2471
  }
2185
2472
 
2186
2473
  function searchLessonText(rows) {
@@ -2190,6 +2477,93 @@ function searchLessonText(rows) {
2190
2477
  .join('\n');
2191
2478
  }
2192
2479
 
2480
+ function searchExperimentSlug(query) {
2481
+ return `search-${applyGate.applySlug(query)}`;
2482
+ }
2483
+
2484
+ function searchExperimentRel(query) {
2485
+ return `atris/experiments/${searchExperimentSlug(query)}`;
2486
+ }
2487
+
2488
+ function searchApplyRel(query) {
2489
+ return applyGate.applySidecarRel('search', applyGate.applySlug(query));
2490
+ }
2491
+
2492
+ function searchApplyNow(now) {
2493
+ if (typeof now === 'function') {
2494
+ const value = now();
2495
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) return value;
2496
+ const ms = Number(value);
2497
+ if (Number.isFinite(ms) && ms > 0) return new Date(ms).toISOString().slice(0, 10);
2498
+ return undefined;
2499
+ }
2500
+ return now;
2501
+ }
2502
+
2503
+ function saveRichSearch({ cwd, query, lesson } = {}) {
2504
+ if (isThinTeachLesson(lesson)) {
2505
+ return { thin: true, packRel: null, lesson };
2506
+ }
2507
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
2508
+ const packRel = fileTeachExperiment({
2509
+ cwd,
2510
+ lesson,
2511
+ slug: query ? searchExperimentSlug(query) : null,
2512
+ applyRel: query ? searchApplyRel(query) : null,
2513
+ });
2514
+ return { thin: false, packRel, lesson, source: query };
2515
+ }
2516
+
2517
+ function ensureSearchApply({ cwd, query, packRel, now, output, source } = {}) {
2518
+ const pack = packRel || (query ? searchExperimentRel(query) : null);
2519
+ const slug = pack ? path.basename(pack) : null;
2520
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
2521
+ return applyGate.ensureApply({
2522
+ cwd,
2523
+ source: source || query || 'search',
2524
+ rel: query ? searchApplyRel(query) : null,
2525
+ now,
2526
+ output,
2527
+ incompleteMessage: slug
2528
+ ? `next: atris experiments keep ${slug}`
2529
+ : applyGate.ephemeralApplyMessage('search'),
2530
+ required: false,
2531
+ change: pack ? `apply ${pack}` : undefined,
2532
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
2533
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
2534
+ });
2535
+ }
2536
+
2537
+ function mintRichSearch({ cwd, query, rows, now, output, ensureApply, json } = {}) {
2538
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
2539
+ const lesson = notesLessonFromText(searchLessonText(rows));
2540
+ if (json) return { thin: false, code: 0, lesson };
2541
+ if (isThinTeachLesson(lesson)) {
2542
+ printSearchLearnerGate(rows, { json: false }, print);
2543
+ return { thin: true, code: 0, lesson };
2544
+ }
2545
+ const saved = saveRichSearch({ cwd, query, lesson });
2546
+ const applyFn = ensureApply || ensureSearchApply;
2547
+ const applyCode = applyFn({
2548
+ cwd,
2549
+ query,
2550
+ packRel: saved.packRel,
2551
+ now: searchApplyNow(now),
2552
+ output: print,
2553
+ source: query,
2554
+ });
2555
+ if (ensureApply) return { thin: false, code: applyCode, lesson: saved.lesson };
2556
+ const baseline = proveSavedLearnerBaseline({
2557
+ cwd,
2558
+ applyRel: query ? searchApplyRel(query) : null,
2559
+ lesson: saved.lesson,
2560
+ output: print,
2561
+ json,
2562
+ });
2563
+ if (baseline !== 0) return { thin: false, code: baseline, lesson: saved.lesson };
2564
+ return { thin: false, code: applyCode, lesson: saved.lesson };
2565
+ }
2566
+
2193
2567
  function printSearchLearnerGate(rows, options, output) {
2194
2568
  printLearnerCheckGate(output, notesLessonFromText(searchLessonText(rows)), {
2195
2569
  includeCheck: true,
@@ -2197,6 +2571,40 @@ function printSearchLearnerGate(rows, options, output) {
2197
2571
  });
2198
2572
  }
2199
2573
 
2574
+ function gateSearchLearner(rows, options, output, deps = {}) {
2575
+ if (options && options.json) return 0;
2576
+ const minted = mintRichSearch({
2577
+ cwd: deps.cwd || process.cwd(),
2578
+ query: options && options.query,
2579
+ rows,
2580
+ now: deps.now,
2581
+ output,
2582
+ ensureApply: deps.ensureApply,
2583
+ json: false,
2584
+ });
2585
+ if (minted.thin) printSearchTeachNext(rows, options, output);
2586
+ return minted.code;
2587
+ }
2588
+
2589
+ function printSearchOutcome(rows, options, output, deps = {}) {
2590
+ printSearchRows(rows, options, output);
2591
+ return gateSearchLearner(rows, options, output, deps);
2592
+ }
2593
+
2594
+ function resultStdout(result) {
2595
+ if (typeof result === 'string') return result;
2596
+ return String((result && result.stdout) || '');
2597
+ }
2598
+
2599
+ function searchRowsFromResult(result) {
2600
+ return parseSearchStdout(resultStdout(result));
2601
+ }
2602
+
2603
+ function finishSuccessfulSearch(rows, options, output, deps) {
2604
+ writeLocalSearchCache(options.query, rows, deps);
2605
+ return printSearchOutcome(rows, options, output, deps);
2606
+ }
2607
+
2200
2608
  function commandOnPath(name, deps = {}) {
2201
2609
  const spawn = deps.spawnSync || spawnSync;
2202
2610
  const result = spawn('sh', ['-c', `command -v ${shellSingleQuote(name)}`], {
@@ -2313,6 +2721,9 @@ async function runYoutubeSearch(args = [], deps = {}) {
2313
2721
  return 2;
2314
2722
  }
2315
2723
 
2724
+ let rows = searchRowsFromResult(result);
2725
+ if (rows.length) return finishSuccessfulSearch(rows, options, output, deps);
2726
+
2316
2727
  let status = searchRunnerStatus(result);
2317
2728
  if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
2318
2729
  await waitLocalSearchBackoff(deps);
@@ -2326,15 +2737,16 @@ async function runYoutubeSearch(args = [], deps = {}) {
2326
2737
  output('ytsearch and yt-dlp not found. Install yt-dlp or put ytsearch on PATH.');
2327
2738
  return 2;
2328
2739
  }
2740
+ rows = searchRowsFromResult(result);
2741
+ if (rows.length) return finishSuccessfulSearch(rows, options, output, deps);
2329
2742
  status = searchRunnerStatus(result);
2330
2743
  if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
2331
2744
  const cached = readFreshLocalSearchCache(options.query, deps);
2332
2745
  if (cached) {
2333
- const rows = cached.rows.slice(0, options.limit);
2334
- printSearchRows(rows, options, output);
2335
- printSearchTeachNext(rows, options, output);
2746
+ const cachedRows = cached.rows.slice(0, options.limit);
2747
+ const code = printSearchOutcome(cachedRows, options, output, deps);
2336
2748
  output(LOCAL_SEARCH_CACHE_NOTE);
2337
- return 0;
2749
+ return code;
2338
2750
  }
2339
2751
  output(LOCAL_SEARCH_RATE_LIMIT_MESSAGE);
2340
2752
  return status == null ? 1 : status;
@@ -2347,19 +2759,9 @@ async function runYoutubeSearch(args = [], deps = {}) {
2347
2759
  return status == null ? 1 : status;
2348
2760
  }
2349
2761
 
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;
2762
+ output('no videos found');
2763
+ if (!options.json) printWatchTickNext(output);
2764
+ return 2;
2363
2765
  }
2364
2766
 
2365
2767
  const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip] | owed | next';
@@ -3292,33 +3694,23 @@ async function extractTeachSource(youtubeUrl, deps = {}) {
3292
3694
  return deps.extractTeachSource(youtubeUrl, deps);
3293
3695
  }
3294
3696
  const runner = deps.spawnSync || spawnSync;
3295
- const result = runner('yt-dlp', ['-J', '--skip-download', '--no-warnings', youtubeUrl], {
3697
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
3296
3698
  encoding: 'utf8',
3297
3699
  timeout: 20000,
3298
3700
  maxBuffer: 10 * 1024 * 1024,
3299
3701
  });
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);
3702
+ const info = parseYtDlpInfoJson(result);
3703
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
3704
+ const cues = parseCaptionCues(loaded && loaded.raw);
3313
3705
  if (!cues.length) return null;
3314
3706
 
3315
3707
  return {
3316
- id: info.id || videoIdFromUrl(youtubeUrl),
3317
- title: info.title || '',
3708
+ id: teachSourceVideoId(info, youtubeUrl),
3709
+ title: (info && info.title) || '',
3318
3710
  url: youtubeUrl,
3319
- durationSeconds: Number(info.duration || 0) || undefined,
3320
- language: selected.language || 'unknown',
3321
- chapters: normalizeChapters(info.chapters, info.duration),
3711
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
3712
+ language: loaded.language || 'unknown',
3713
+ chapters: normalizeChapters(info && info.chapters, info && info.duration),
3322
3714
  cues,
3323
3715
  };
3324
3716
  }
@@ -3368,7 +3760,10 @@ async function runYoutubeTeach(args = [], deps = {}) {
3368
3760
  }
3369
3761
  }
3370
3762
 
3371
- const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, deps);
3763
+ const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, {
3764
+ ...deps,
3765
+ workDir: deps.workDir || notesWorkDir(deps),
3766
+ });
3372
3767
  if (!source || !Array.isArray(source.cues) || !source.cues.length) {
3373
3768
  output('no english captions for this url. teach stays local and will not call process.');
3374
3769
  return 2;
@@ -3492,7 +3887,15 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
3492
3887
  output(JSON.stringify(data, null, 2));
3493
3888
  } else {
3494
3889
  output(formatYoutubeResult(data));
3495
- printProcessLearnerGate(data, {}, output);
3890
+ const mintCode = mintRichProcess({
3891
+ cwd: deps.cwd || process.cwd(),
3892
+ url: options.youtubeUrl,
3893
+ data,
3894
+ now: deps.now,
3895
+ output,
3896
+ ensureApply: deps.ensureApply,
3897
+ });
3898
+ if (mintCode !== 0) status = mintCode;
3496
3899
  }
3497
3900
  } catch (err) {
3498
3901
  if (!err.applyRequired) output(err.message);
@@ -3510,10 +3913,12 @@ module.exports = {
3510
3913
  parseYoutubeArgs,
3511
3914
  buildYoutubePayload,
3512
3915
  extractLocalTranscript,
3916
+ parseYtDlpInfoJson,
3513
3917
  processYoutube,
3514
3918
  shouldRetryWithLocalTranscript,
3515
3919
  formatYoutubeResult,
3516
3920
  fileBriefFromNotes,
3921
+ keptPrintedNotes,
3517
3922
  ensureNotesApply,
3518
3923
  unsaveYoutubeNotes,
3519
3924
  APPLY_NEXT_MESSAGE,
@@ -3541,6 +3946,7 @@ module.exports = {
3541
3946
  extractTeachNumbers,
3542
3947
  extractTeachMechanisms,
3543
3948
  extractTeachSource,
3949
+ readLocalCaptionText,
3544
3950
  oneTeachCheck,
3545
3951
  learnerCheckFromLesson,
3546
3952
  scoreLearnerNeedles,
@@ -3556,6 +3962,11 @@ module.exports = {
3556
3962
  notesExperimentSlug,
3557
3963
  digestExperimentSlug,
3558
3964
  watchExperimentSlug,
3965
+ processExperimentSlug,
3966
+ processApplyRel,
3967
+ searchExperimentSlug,
3968
+ searchApplyRel,
3969
+ firstRichWatchLesson,
3559
3970
  fileTeachExperiment,
3560
3971
  youtubeCommand,
3561
3972
  };