atris 3.50.0 → 3.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/atris.js CHANGED
@@ -572,7 +572,7 @@ function showHelp() {
572
572
  console.log(' router - inspect ax lane outcomes and promote gated reflex overrides');
573
573
  console.log(' sign - Co-author trailer on every commit in an atris workspace (on/off/status)');
574
574
  console.log(' visualize - Generate a Slack/deck-ready visual from a prompt');
575
- console.log(' youtube - Free local notes or 5-credit cloud process for YouTube videos');
575
+ console.log(' youtube - Free local notes, weekly digest, or 5-credit cloud process for YouTube videos');
576
576
  console.log('');
577
577
  console.log('Experiments:');
578
578
  console.log(' experiments init [slug] - Prepare atris/experiments/ or scaffold a pack');
@@ -5,8 +5,9 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const https = require('https');
7
7
 
8
- const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]';
8
+ const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [haiku|atris-fast|gemini|grok|codex|cursor]';
9
9
  const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
10
+ const NOTES_PLAYLIST_CAP = 10;
10
11
 
11
12
  const DEFAULT_QUERY = [
12
13
  'Create a timestamped YouTube brief for Atris.',
@@ -24,15 +25,18 @@ const ALLOWED_CAPTION_HOST_SUFFIXES = [
24
25
 
25
26
  function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
26
27
  output('');
27
- output(`Usage: ${commandName} notes <youtube-url> [engine]`);
28
+ output(`Usage: ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine]`);
28
29
  output(` ${commandName} process <youtube-url> [options]`);
30
+ output(` ${commandName} digest [--days N]`);
29
31
  output(` ${commandName} watch add <channel-url-or-@handle>`);
30
32
  output(` ${commandName} watch list`);
31
33
  output(` ${commandName} watch remove <number>`);
32
34
  output(` ${commandName} watch tick`);
33
35
  output(` ${commandName} <youtube-url> [options]`);
34
36
  output('');
35
- output('notes = free local notes, process = 5 credits cloud knowledge');
37
+ output('notes = free local notes for one url, several urls, or a playlist');
38
+ output('process = 5 credits cloud knowledge');
39
+ output('digest = one decision page from this week\'s video briefs');
36
40
  output('watch = subscribed channels turn into briefs without a human');
37
41
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
38
42
  output('Falls back to cloud video processing when local captions are unavailable.');
@@ -50,8 +54,12 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
50
54
  output('');
51
55
  output('Examples:');
52
56
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
57
+ output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID https://youtu.be/OTHER_ID`);
58
+ output(` ${commandName} notes https://www.youtube.com/playlist?list=PLAYLIST_ID`);
53
59
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
54
60
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
61
+ output(` ${commandName} digest`);
62
+ output(` ${commandName} digest --days 14`);
55
63
  output(` ${commandName} watch add @veritasium`);
56
64
  output(` ${commandName} watch tick`);
57
65
  output('');
@@ -460,6 +468,34 @@ function videoIdFromUrl(url) {
460
468
  return short ? short[1] : null;
461
469
  }
462
470
 
471
+ function looksLikeYoutubeUrl(arg) {
472
+ const text = String(arg || '').trim();
473
+ if (!text || text.startsWith('-')) return false;
474
+ return /youtube\.com|youtu\.be/i.test(text);
475
+ }
476
+
477
+ function isPlaylistUrl(url) {
478
+ const text = String(url || '');
479
+ return /[?&]list=/.test(text) || /\/playlist(?:\?|$|\/)/i.test(text);
480
+ }
481
+
482
+ function parseNotesArgs(argv = []) {
483
+ const urls = [];
484
+ let engine = null;
485
+ let help = false;
486
+ for (const raw of argv) {
487
+ const arg = String(raw);
488
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
489
+ help = true;
490
+ continue;
491
+ }
492
+ if (arg.startsWith('-')) continue;
493
+ if (looksLikeYoutubeUrl(arg)) urls.push(arg);
494
+ else engine = arg;
495
+ }
496
+ return { urls, engine, help };
497
+ }
498
+
463
499
  function dateStamp(now) {
464
500
  if (typeof now === 'string' && /^\d{4}-\d{2}-\d{2}/.test(now)) {
465
501
  return now.slice(0, 10);
@@ -508,11 +544,207 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
508
544
  fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
509
545
 
510
546
  console.log(`brief filed: ${relBrief}`);
547
+ return relBrief;
511
548
  } catch {
512
549
  // notes filing must never break the youtube command
513
550
  }
514
551
  }
515
552
 
553
+ const DIGEST_ENGINE_TIMEOUT_MS = 240000;
554
+ const DEFAULT_DIGEST_DAYS = 7;
555
+
556
+ function addUtcDays(stamp, delta) {
557
+ const [year, month, day] = String(stamp || '').split('-').map(Number);
558
+ const value = new Date(Date.UTC(year, month - 1, day + Number(delta || 0)));
559
+ return value.toISOString().slice(0, 10);
560
+ }
561
+
562
+ function parseBriefDate(text) {
563
+ const match = String(text || '').match(/^date:\s*(\d{4}-\d{2}-\d{2})\s*$/m);
564
+ return match ? match[1] : null;
565
+ }
566
+
567
+ function isVideoBriefText(text) {
568
+ return /^source:\s*http/m.test(String(text || ''));
569
+ }
570
+
571
+ function briefTitleLine(text) {
572
+ const heading = firstHeading(text);
573
+ if (heading) return heading;
574
+ const first = String(text || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
575
+ return first || '';
576
+ }
577
+
578
+ function dateInDigestWindow(dateStr, now, days) {
579
+ if (!dateStr) return false;
580
+ const today = dateStamp(now);
581
+ const start = addUtcDays(today, -(Number(days) - 1));
582
+ return dateStr >= start && dateStr <= today;
583
+ }
584
+
585
+ function parseDigestArgs(argv = []) {
586
+ const args = [...argv];
587
+ const options = { help: false, days: DEFAULT_DIGEST_DAYS };
588
+ for (let i = 0; i < args.length; i++) {
589
+ const arg = args[i];
590
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
591
+ options.help = true;
592
+ } else if (arg === '--days') {
593
+ const raw = args[i + 1];
594
+ const value = Number(raw);
595
+ if (raw == null || String(raw).startsWith('--') || !Number.isInteger(value) || value <= 0) {
596
+ throw new Error('--days must be a positive integer');
597
+ }
598
+ options.days = value;
599
+ i += 1;
600
+ } else if (arg.startsWith('--days=')) {
601
+ const value = Number(arg.slice('--days='.length));
602
+ if (!Number.isInteger(value) || value <= 0) {
603
+ throw new Error('--days must be a positive integer');
604
+ }
605
+ options.days = value;
606
+ } else if (arg.startsWith('-')) {
607
+ throw new Error(`Unknown option: ${arg}`);
608
+ } else {
609
+ throw new Error(`Unexpected argument: ${arg}`);
610
+ }
611
+ }
612
+ return options;
613
+ }
614
+
615
+ function collectVideoBriefs({ cwd, now, days } = {}) {
616
+ const root = cwd || process.cwd();
617
+ const briefsDir = path.join(root, 'atris', 'wiki', 'briefs');
618
+ if (!fs.existsSync(briefsDir)) return [];
619
+
620
+ const rows = [];
621
+ for (const name of fs.readdirSync(briefsDir).sort()) {
622
+ if (!name.endsWith('.md') || name.startsWith('digest-')) continue;
623
+ const abs = path.join(briefsDir, name);
624
+ let body = '';
625
+ try {
626
+ if (!fs.statSync(abs).isFile()) continue;
627
+ body = fs.readFileSync(abs, 'utf8');
628
+ } catch {
629
+ continue;
630
+ }
631
+ if (!isVideoBriefText(body)) continue;
632
+ const date = parseBriefDate(body);
633
+ if (!dateInDigestWindow(date, now, days)) continue;
634
+ rows.push({
635
+ name,
636
+ relPath: `atris/wiki/briefs/${name}`,
637
+ title: briefTitleLine(body),
638
+ date,
639
+ body,
640
+ });
641
+ }
642
+ rows.sort((a, b) => (a.date === b.date ? a.name.localeCompare(b.name) : a.date.localeCompare(b.date)));
643
+ return rows;
644
+ }
645
+
646
+ function buildDigestPrompt(briefs = []) {
647
+ const blocks = briefs.map((row) => [
648
+ `filename: ${row.name}`,
649
+ `title: ${row.title}`,
650
+ `path: ${row.relPath}`,
651
+ '',
652
+ row.body,
653
+ ].join('\n'));
654
+ return [
655
+ 'Turn these video briefs into one decision-focused page.',
656
+ 'Write a heading exactly: # what this week\'s videos changed',
657
+ 'Then 3-6 decision-shaped findings. Each finding must name the brief it came from as a path.',
658
+ 'Then one contradictions or tensions paragraph if any exist.',
659
+ 'Then a 3-item do next list.',
660
+ 'Use plain prose. Do not use em dashes.',
661
+ '',
662
+ 'Briefs:',
663
+ '',
664
+ blocks.join('\n\n'),
665
+ ].join('\n');
666
+ }
667
+
668
+ function defaultDigestRunner(prompt, deps = {}) {
669
+ const spawn = deps.spawnSync || spawnSync;
670
+ return spawn('claude', ['-p', prompt, '--model', 'claude-haiku-4-5'], {
671
+ encoding: 'utf8',
672
+ timeout: DIGEST_ENGINE_TIMEOUT_MS,
673
+ maxBuffer: 10 * 1024 * 1024,
674
+ });
675
+ }
676
+
677
+ function invokeDigestEngine(prompt, deps = {}) {
678
+ const runner = deps.runner || ((nextPrompt) => defaultDigestRunner(nextPrompt, deps));
679
+ const result = runner(prompt, deps);
680
+ if (typeof result === 'string') return result.trim();
681
+ if (!result || result.error || (result.status != null && result.status !== 0)) {
682
+ const detail = String(result?.stderr || result?.error?.message || 'digest engine failed').trim();
683
+ throw new Error(detail || 'digest engine failed');
684
+ }
685
+ return String(result.stdout || '').trim();
686
+ }
687
+
688
+ function appendDigestJournal({ cwd, date, relDigest }) {
689
+ const year = date.slice(0, 4);
690
+ const journalPath = path.join(cwd, 'atris', 'logs', year, `${date}.md`);
691
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
692
+ let existing = '';
693
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
694
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
695
+ const line = `- [claimable] digest: what this week's videos changed -> ${relDigest}`;
696
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
697
+ }
698
+
699
+ function runYoutubeDigest(args = [], deps = {}) {
700
+ const output = deps.output || ((line = '') => console.log(line));
701
+ let options;
702
+ try {
703
+ options = parseDigestArgs(args);
704
+ } catch (err) {
705
+ output(err.message);
706
+ return 2;
707
+ }
708
+ if (options.help) {
709
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
710
+ return 0;
711
+ }
712
+
713
+ const cwd = deps.cwd || process.cwd();
714
+ const now = deps.now || new Date();
715
+ const briefs = collectVideoBriefs({ cwd, now, days: options.days });
716
+ if (!briefs.length) {
717
+ output(`no video briefs in the last ${options.days} days`);
718
+ return 0;
719
+ }
720
+
721
+ const prompt = buildDigestPrompt(briefs);
722
+ let text;
723
+ try {
724
+ text = invokeDigestEngine(prompt, deps);
725
+ } catch (err) {
726
+ output(err.message || 'digest engine failed');
727
+ return 1;
728
+ }
729
+ if (!text) {
730
+ output('digest engine returned no text');
731
+ return 1;
732
+ }
733
+
734
+ const date = dateStamp(now);
735
+ const relDigest = `atris/wiki/briefs/digest-${date}.md`;
736
+ const header = [
737
+ `date: ${date}`,
738
+ `window: ${options.days} days`,
739
+ `sources: ${briefs.map((row) => row.relPath).join(', ')}`,
740
+ ].join('\n');
741
+ fs.mkdirSync(path.join(cwd, 'atris', 'wiki', 'briefs'), { recursive: true });
742
+ fs.writeFileSync(path.join(cwd, relDigest), `${header}\n\n${text}\n`);
743
+ appendDigestJournal({ cwd, date, relDigest });
744
+ output(`digest filed: ${relDigest} (${briefs.length} briefs)`);
745
+ return 0;
746
+ }
747
+
516
748
  function watchStatePath(cwd = process.cwd()) {
517
749
  return path.join(cwd, '.atris', 'state', 'youtube_watch.json');
518
750
  }
@@ -816,37 +1048,194 @@ async function watchCommand(args = [], deps = {}) {
816
1048
  return 2;
817
1049
  }
818
1050
 
819
- function runYoutubeNotes(args = [], deps = {}) {
820
- const output = deps.output || ((line = '') => console.error(line));
821
- const url = args[0];
822
- const engine = args[1];
823
- if (!url) {
824
- output(YTNOTES_USAGE);
825
- output(YTNOTES_HINT);
826
- return 2;
1051
+ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1052
+ const spawn = deps.spawnSync || spawnSync;
1053
+ const result = spawn('yt-dlp', [
1054
+ '--no-update',
1055
+ '--flat-playlist',
1056
+ '--print',
1057
+ '%(id)s|%(title)s',
1058
+ playlistUrl,
1059
+ ], {
1060
+ encoding: 'utf8',
1061
+ timeout: 60000,
1062
+ maxBuffer: 2 * 1024 * 1024,
1063
+ });
1064
+ if (result.error || (result.status != null && result.status !== 0)) {
1065
+ const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
1066
+ throw new Error(detail || 'playlist expand failed');
827
1067
  }
1068
+ return parseFlatPlaylist(result.stdout);
1069
+ }
828
1070
 
1071
+ function defaultNotesItemRunner(url, engine, deps = {}) {
829
1072
  const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
830
1073
  const spawn = deps.spawnSync || spawnSync;
831
1074
  const childArgs = engine ? [url, engine] : [url];
832
- const result = spawn(script, childArgs, { stdio: 'inherit' });
833
- if (result.status == null) return 1;
834
- if (result.status === 0) {
835
- fileBriefFromNotes({
1075
+ return spawn(script, childArgs, { stdio: 'inherit' });
1076
+ }
1077
+
1078
+ function readNowMs(deps = {}) {
1079
+ if (typeof deps.nowMs === 'function') return Number(deps.nowMs()) || 0;
1080
+ if (Number.isFinite(deps.nowMs)) return Number(deps.nowMs);
1081
+ return Date.now();
1082
+ }
1083
+
1084
+ function notesItemLabel(item = {}) {
1085
+ return item.id || item.url || '';
1086
+ }
1087
+
1088
+ function expandNotesTargets(urls = [], deps = {}) {
1089
+ const output = deps.output || ((line = '') => console.error(line));
1090
+ const expander = deps.expander || ((playlistUrl) => defaultPlaylistExpander(playlistUrl, deps));
1091
+ const items = [];
1092
+ for (const url of urls) {
1093
+ if (!isPlaylistUrl(url)) {
1094
+ items.push({ url, id: videoIdFromUrl(url) });
1095
+ continue;
1096
+ }
1097
+ let videos = [];
1098
+ try {
1099
+ videos = expander(url, deps);
1100
+ } catch {
1101
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1102
+ continue;
1103
+ }
1104
+ if (!Array.isArray(videos) || videos.length === 0) {
1105
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1106
+ continue;
1107
+ }
1108
+ if (videos.length > NOTES_PLAYLIST_CAP) {
1109
+ output(`playlist capped at ${NOTES_PLAYLIST_CAP} videos (${videos.length} found)`);
1110
+ videos = videos.slice(0, NOTES_PLAYLIST_CAP);
1111
+ }
1112
+ for (const video of videos) {
1113
+ if (!video?.id) continue;
1114
+ items.push({
1115
+ url: `https://www.youtube.com/watch?v=${video.id}`,
1116
+ id: video.id,
1117
+ title: video.title,
1118
+ });
1119
+ }
1120
+ }
1121
+ return items;
1122
+ }
1123
+
1124
+ function invokeNotesRunner(url, engine, deps = {}) {
1125
+ const runner = deps.runner;
1126
+ if (runner) return runner(url, engine, deps);
1127
+ return defaultNotesItemRunner(url, engine, deps);
1128
+ }
1129
+
1130
+ function readRunnerStatus(result) {
1131
+ if (typeof result === 'number') return result;
1132
+ if (result && typeof result === 'object') {
1133
+ return result.status == null ? 1 : result.status;
1134
+ }
1135
+ return 1;
1136
+ }
1137
+
1138
+ function fileNotesBrief(url, deps = {}) {
1139
+ const briefFiler = deps.briefFiler || fileBriefFromNotes;
1140
+ try {
1141
+ const filed = briefFiler({
836
1142
  cwd: deps.cwd || process.cwd(),
837
1143
  url,
838
1144
  workDir: deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes'),
839
1145
  now: deps.now || new Date(),
840
1146
  });
1147
+ return typeof filed === 'string' && filed ? filed : null;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+
1153
+ function runOneNotesItem(item, engine, deps = {}) {
1154
+ const output = deps.output || ((line = '') => console.error(line));
1155
+ const label = notesItemLabel(item);
1156
+ if (item.failed) {
1157
+ output(`${label} 0s FAILED`);
1158
+ return { url: item.url, id: item.id, seconds: 0, ok: false, brief: null };
1159
+ }
1160
+
1161
+ const started = readNowMs(deps);
1162
+ let status = 1;
1163
+ try {
1164
+ status = readRunnerStatus(invokeNotesRunner(item.url, engine, deps));
1165
+ } catch {
1166
+ status = 1;
1167
+ }
1168
+ const brief = status === 0 ? fileNotesBrief(item.url, deps) : null;
1169
+ const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
1170
+ const ok = status === 0;
1171
+ output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
1172
+ return { url: item.url, id: item.id, seconds, ok, brief };
1173
+ }
1174
+
1175
+ function formatNotesSummary(rows = []) {
1176
+ const lines = ['url or id seconds result'];
1177
+ for (const row of rows) {
1178
+ const result = row.ok ? (row.brief || 'ok') : 'FAILED';
1179
+ lines.push(`${notesItemLabel(row)} ${row.seconds}s ${result}`);
1180
+ }
1181
+ return lines.join('\n');
1182
+ }
1183
+
1184
+ function runYoutubeNotesBatch({ urls, engine } = {}, deps = {}) {
1185
+ const output = deps.output || ((line = '') => console.error(line));
1186
+ const items = expandNotesTargets(urls || [], deps);
1187
+ const rows = [];
1188
+ for (const item of items) {
1189
+ rows.push(runOneNotesItem(item, engine, deps));
1190
+ }
1191
+ if (rows.length) {
1192
+ output('');
1193
+ output(formatNotesSummary(rows));
841
1194
  }
842
- return result.status;
1195
+ if (!rows.length) return 2;
1196
+ return rows.some((row) => row.ok) ? 0 : 2;
1197
+ }
1198
+
1199
+ function runSingleYoutubeNotes(url, engine, deps = {}) {
1200
+ let result;
1201
+ try {
1202
+ result = invokeNotesRunner(url, engine, deps);
1203
+ } catch {
1204
+ return 1;
1205
+ }
1206
+ const status = readRunnerStatus(result);
1207
+ if (status === 0) fileNotesBrief(url, deps);
1208
+ return status;
1209
+ }
1210
+
1211
+ function runYoutubeNotes(args = [], deps = {}) {
1212
+ const output = deps.output || ((line = '') => console.error(line));
1213
+ const parsed = parseNotesArgs(args);
1214
+ if (parsed.help) {
1215
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
1216
+ return 0;
1217
+ }
1218
+ if (!parsed.urls.length) {
1219
+ output(YTNOTES_USAGE);
1220
+ output(YTNOTES_HINT);
1221
+ return 2;
1222
+ }
1223
+ if (parsed.urls.length === 1 && !isPlaylistUrl(parsed.urls[0])) {
1224
+ return runSingleYoutubeNotes(parsed.urls[0], parsed.engine, deps);
1225
+ }
1226
+ return runYoutubeNotesBatch(parsed, deps);
843
1227
  }
844
1228
 
845
1229
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
846
1230
  const output = deps.output || ((line = '') => console.log(line));
847
1231
  if (argv[0] === 'notes') {
848
1232
  const code = runYoutubeNotes(argv.slice(1), deps);
849
- if (!deps.output && !deps.spawnSync) process.exit(code);
1233
+ if (!deps.output && !deps.spawnSync && !deps.runner && !deps.expander) process.exit(code);
1234
+ return code;
1235
+ }
1236
+ if (argv[0] === 'digest') {
1237
+ const code = runYoutubeDigest(argv.slice(1), { ...deps, output });
1238
+ if (!deps.output && !deps.runner) process.exit(code);
850
1239
  return code;
851
1240
  }
852
1241
  if (argv[0] === 'watch') {
@@ -874,6 +1263,16 @@ module.exports = {
874
1263
  shouldRetryWithLocalTranscript,
875
1264
  formatYoutubeResult,
876
1265
  fileBriefFromNotes,
1266
+ looksLikeYoutubeUrl,
1267
+ isPlaylistUrl,
1268
+ parseNotesArgs,
1269
+ expandNotesTargets,
1270
+ runYoutubeNotesBatch,
1271
+ runYoutubeNotes,
1272
+ parseDigestArgs,
1273
+ collectVideoBriefs,
1274
+ buildDigestPrompt,
1275
+ runYoutubeDigest,
877
1276
  normalizeWatchChannel,
878
1277
  channelVideosUrl,
879
1278
  parseFlatPlaylist,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.50.0",
3
+ "version": "3.52.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -163,7 +163,7 @@ process.stdin.on("end", () => {
163
163
 
164
164
  run_grok() {
165
165
  run_with_timeout 240 grok --verbatim --no-memory --no-plan --no-subagents \
166
- --disable-web-search --max-turns 1 -p "$PROMPT"
166
+ --disable-web-search --max-turns 3 -p "$PROMPT"
167
167
  }
168
168
 
169
169
  run_haiku() {
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Race graded ytnotes engines. Reports only; never edits ytnotes.
5
+ // Usage: node scripts/det/ytrail-race.js [url] [engines-csv]
6
+ // Default url: https://www.youtube.com/watch?v=Z3JyAqh4ixg
7
+ // Default engines: haiku,grok,cursor,atris-fast
8
+
9
+ const { spawnSync } = require('node:child_process');
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const DEFAULT_URL = 'https://www.youtube.com/watch?v=Z3JyAqh4ixg';
14
+ const DEFAULT_ENGINES = ['haiku', 'grok', 'cursor', 'atris-fast'];
15
+ const DEFAULT_TIMEOUT_MS = 300000;
16
+ const DEFAULT_ROOT = path.resolve(__dirname, '..', '..');
17
+ const EVAL_LINE = /ytrail (pass|fail) (\S+) ([\d.]+)s words=\d+ quotes=(\d+)\/(\d+) heading=(?:yes|no)/;
18
+
19
+ function parseArgs(argv) {
20
+ const url = argv[0] || DEFAULT_URL;
21
+ const engines = argv[1]
22
+ ? String(argv[1]).split(',').map((name) => name.trim()).filter(Boolean)
23
+ : DEFAULT_ENGINES.slice();
24
+ return { url, engines };
25
+ }
26
+
27
+ function parseEvalLine(text) {
28
+ const match = String(text || '').match(EVAL_LINE);
29
+ if (!match) return null;
30
+ return {
31
+ engine: match[2],
32
+ seconds: Number(match[3]),
33
+ pass: match[1] === 'pass',
34
+ quotesVerified: Number(match[4]),
35
+ quotesTotal: Number(match[5]),
36
+ };
37
+ }
38
+
39
+ function failedResult(engine, seconds) {
40
+ return {
41
+ engine,
42
+ seconds: Number(seconds) || 0,
43
+ pass: false,
44
+ quotesVerified: 0,
45
+ quotesTotal: 0,
46
+ };
47
+ }
48
+
49
+ function createDefaultRunner(options = {}) {
50
+ const root = options.root || DEFAULT_ROOT;
51
+ const evalPath = options.evalPath || path.join(root, 'scripts', 'det', 'ytrail-eval.js');
52
+ const cwd = options.cwd || root;
53
+ const env = options.env || process.env;
54
+ return function defaultRunner({ url, engine, timeoutMs }) {
55
+ const run = spawnSync(process.execPath, [evalPath, url, engine], {
56
+ encoding: 'utf8',
57
+ cwd,
58
+ env,
59
+ timeout: timeoutMs,
60
+ });
61
+ return {
62
+ stdout: String(run.stdout || ''),
63
+ stderr: String(run.stderr || ''),
64
+ status: run.status,
65
+ error: run.error || null,
66
+ timedOut: Boolean(run.error && run.error.code === 'ETIMEDOUT'),
67
+ };
68
+ };
69
+ }
70
+
71
+ function runOneEngine(engine, ctx) {
72
+ let run;
73
+ try {
74
+ run = ctx.runner({
75
+ url: ctx.url,
76
+ engine,
77
+ timeoutMs: ctx.timeoutMs,
78
+ });
79
+ } catch {
80
+ return failedResult(engine, 0);
81
+ }
82
+ if (!run || run.timedOut) {
83
+ return failedResult(engine, ctx.timeoutMs / 1000);
84
+ }
85
+ const parsed = parseEvalLine(run.stdout);
86
+ if (!parsed) return failedResult(engine, 0);
87
+ return {
88
+ engine,
89
+ seconds: parsed.seconds,
90
+ pass: parsed.pass,
91
+ quotesVerified: parsed.quotesVerified,
92
+ quotesTotal: parsed.quotesTotal,
93
+ };
94
+ }
95
+
96
+ function pickWinner(results) {
97
+ return results
98
+ .filter((row) => row.pass)
99
+ .slice()
100
+ .sort((a, b) => a.seconds - b.seconds)[0] || null;
101
+ }
102
+
103
+ function pad(value, width) {
104
+ const text = String(value);
105
+ return text.length >= width ? text : text + ' '.repeat(width - text.length);
106
+ }
107
+
108
+ function formatTable(results) {
109
+ const engineWidth = Math.max(6, ...results.map((row) => String(row.engine).length));
110
+ const lines = [
111
+ `${pad('engine', engineWidth)} seconds pass quotes`,
112
+ ];
113
+ for (const row of results) {
114
+ lines.push(
115
+ `${pad(row.engine, engineWidth)} ${pad(row.seconds, 7)} ${pad(row.pass ? 'yes' : 'no', 4)} ${row.quotesVerified}/${row.quotesTotal}`
116
+ );
117
+ }
118
+ return lines.join('\n');
119
+ }
120
+
121
+ function formatVerdict(winner) {
122
+ if (!winner) return 'race winner: none';
123
+ return `race winner: ${winner.engine} (${winner.seconds}s, quotes ${winner.quotesVerified}/${winner.quotesTotal})`;
124
+ }
125
+
126
+ function writeLatest(root, report) {
127
+ const outDir = path.join(root, 'atris', 'benchmarks');
128
+ fs.mkdirSync(outDir, { recursive: true });
129
+ const payload = {
130
+ ts: report.ts,
131
+ url: report.url,
132
+ results: report.results,
133
+ winner: report.winner,
134
+ };
135
+ fs.writeFileSync(
136
+ path.join(outDir, 'ytrail-race-latest.json'),
137
+ `${JSON.stringify(payload, null, 2)}\n`
138
+ );
139
+ }
140
+
141
+ function runRace(options = {}) {
142
+ const url = options.url || DEFAULT_URL;
143
+ const engines = options.engines && options.engines.length
144
+ ? options.engines.slice()
145
+ : DEFAULT_ENGINES.slice();
146
+ const timeoutMs = options.timeoutMs == null ? DEFAULT_TIMEOUT_MS : options.timeoutMs;
147
+ const root = options.root || DEFAULT_ROOT;
148
+ const runner = options.runner || createDefaultRunner({ root });
149
+ const log = options.log || console.log;
150
+ const ts = options.ts || new Date().toISOString();
151
+
152
+ const results = [];
153
+ for (const engine of engines) {
154
+ results.push(runOneEngine(engine, { url, runner, timeoutMs }));
155
+ }
156
+
157
+ const winner = pickWinner(results);
158
+ const report = { ts, url, results, winner };
159
+ writeLatest(root, report);
160
+
161
+ log(formatTable(results));
162
+ log(formatVerdict(winner));
163
+
164
+ return {
165
+ ...report,
166
+ exitCode: winner ? 0 : 1,
167
+ };
168
+ }
169
+
170
+ function main(argv = process.argv.slice(2), options = {}) {
171
+ const parsed = parseArgs(argv);
172
+ const report = runRace({ ...options, ...parsed });
173
+ return report.exitCode;
174
+ }
175
+
176
+ if (require.main === module) {
177
+ process.exit(main());
178
+ }
179
+
180
+ module.exports = {
181
+ DEFAULT_URL,
182
+ DEFAULT_ENGINES,
183
+ DEFAULT_TIMEOUT_MS,
184
+ parseArgs,
185
+ parseEvalLine,
186
+ createDefaultRunner,
187
+ pickWinner,
188
+ formatTable,
189
+ formatVerdict,
190
+ runRace,
191
+ main,
192
+ };