atris 3.49.0 → 3.51.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');
@@ -26,9 +26,16 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
26
26
  output('');
27
27
  output(`Usage: ${commandName} notes <youtube-url> [engine]`);
28
28
  output(` ${commandName} process <youtube-url> [options]`);
29
+ output(` ${commandName} digest [--days N]`);
30
+ output(` ${commandName} watch add <channel-url-or-@handle>`);
31
+ output(` ${commandName} watch list`);
32
+ output(` ${commandName} watch remove <number>`);
33
+ output(` ${commandName} watch tick`);
29
34
  output(` ${commandName} <youtube-url> [options]`);
30
35
  output('');
31
36
  output('notes = free local notes, process = 5 credits cloud knowledge');
37
+ output('digest = one decision page from this week\'s video briefs');
38
+ output('watch = subscribed channels turn into briefs without a human');
32
39
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
33
40
  output('Falls back to cloud video processing when local captions are unavailable.');
34
41
  output('');
@@ -47,6 +54,10 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
47
54
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
48
55
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
49
56
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
57
+ output(` ${commandName} digest`);
58
+ output(` ${commandName} digest --days 14`);
59
+ output(` ${commandName} watch add @veritasium`);
60
+ output(` ${commandName} watch tick`);
50
61
  output('');
51
62
  }
52
63
 
@@ -82,7 +93,7 @@ function parseYoutubeArgs(argv = []) {
82
93
  return options;
83
94
  }
84
95
 
85
- if (['process', 'analyze', 'watch'].includes(args[0])) {
96
+ if (['process', 'analyze'].includes(args[0])) {
86
97
  args.shift();
87
98
  }
88
99
 
@@ -506,6 +517,504 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
506
517
  }
507
518
  }
508
519
 
520
+ const DIGEST_ENGINE_TIMEOUT_MS = 240000;
521
+ const DEFAULT_DIGEST_DAYS = 7;
522
+
523
+ function addUtcDays(stamp, delta) {
524
+ const [year, month, day] = String(stamp || '').split('-').map(Number);
525
+ const value = new Date(Date.UTC(year, month - 1, day + Number(delta || 0)));
526
+ return value.toISOString().slice(0, 10);
527
+ }
528
+
529
+ function parseBriefDate(text) {
530
+ const match = String(text || '').match(/^date:\s*(\d{4}-\d{2}-\d{2})\s*$/m);
531
+ return match ? match[1] : null;
532
+ }
533
+
534
+ function isVideoBriefText(text) {
535
+ return /^source:\s*http/m.test(String(text || ''));
536
+ }
537
+
538
+ function briefTitleLine(text) {
539
+ const heading = firstHeading(text);
540
+ if (heading) return heading;
541
+ const first = String(text || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
542
+ return first || '';
543
+ }
544
+
545
+ function dateInDigestWindow(dateStr, now, days) {
546
+ if (!dateStr) return false;
547
+ const today = dateStamp(now);
548
+ const start = addUtcDays(today, -(Number(days) - 1));
549
+ return dateStr >= start && dateStr <= today;
550
+ }
551
+
552
+ function parseDigestArgs(argv = []) {
553
+ const args = [...argv];
554
+ const options = { help: false, days: DEFAULT_DIGEST_DAYS };
555
+ for (let i = 0; i < args.length; i++) {
556
+ const arg = args[i];
557
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
558
+ options.help = true;
559
+ } else if (arg === '--days') {
560
+ const raw = args[i + 1];
561
+ const value = Number(raw);
562
+ if (raw == null || String(raw).startsWith('--') || !Number.isInteger(value) || value <= 0) {
563
+ throw new Error('--days must be a positive integer');
564
+ }
565
+ options.days = value;
566
+ i += 1;
567
+ } else if (arg.startsWith('--days=')) {
568
+ const value = Number(arg.slice('--days='.length));
569
+ if (!Number.isInteger(value) || value <= 0) {
570
+ throw new Error('--days must be a positive integer');
571
+ }
572
+ options.days = value;
573
+ } else if (arg.startsWith('-')) {
574
+ throw new Error(`Unknown option: ${arg}`);
575
+ } else {
576
+ throw new Error(`Unexpected argument: ${arg}`);
577
+ }
578
+ }
579
+ return options;
580
+ }
581
+
582
+ function collectVideoBriefs({ cwd, now, days } = {}) {
583
+ const root = cwd || process.cwd();
584
+ const briefsDir = path.join(root, 'atris', 'wiki', 'briefs');
585
+ if (!fs.existsSync(briefsDir)) return [];
586
+
587
+ const rows = [];
588
+ for (const name of fs.readdirSync(briefsDir).sort()) {
589
+ if (!name.endsWith('.md') || name.startsWith('digest-')) continue;
590
+ const abs = path.join(briefsDir, name);
591
+ let body = '';
592
+ try {
593
+ if (!fs.statSync(abs).isFile()) continue;
594
+ body = fs.readFileSync(abs, 'utf8');
595
+ } catch {
596
+ continue;
597
+ }
598
+ if (!isVideoBriefText(body)) continue;
599
+ const date = parseBriefDate(body);
600
+ if (!dateInDigestWindow(date, now, days)) continue;
601
+ rows.push({
602
+ name,
603
+ relPath: `atris/wiki/briefs/${name}`,
604
+ title: briefTitleLine(body),
605
+ date,
606
+ body,
607
+ });
608
+ }
609
+ rows.sort((a, b) => (a.date === b.date ? a.name.localeCompare(b.name) : a.date.localeCompare(b.date)));
610
+ return rows;
611
+ }
612
+
613
+ function buildDigestPrompt(briefs = []) {
614
+ const blocks = briefs.map((row) => [
615
+ `filename: ${row.name}`,
616
+ `title: ${row.title}`,
617
+ `path: ${row.relPath}`,
618
+ '',
619
+ row.body,
620
+ ].join('\n'));
621
+ return [
622
+ 'Turn these video briefs into one decision-focused page.',
623
+ 'Write a heading exactly: # what this week\'s videos changed',
624
+ 'Then 3-6 decision-shaped findings. Each finding must name the brief it came from as a path.',
625
+ 'Then one contradictions or tensions paragraph if any exist.',
626
+ 'Then a 3-item do next list.',
627
+ 'Use plain prose. Do not use em dashes.',
628
+ '',
629
+ 'Briefs:',
630
+ '',
631
+ blocks.join('\n\n'),
632
+ ].join('\n');
633
+ }
634
+
635
+ function defaultDigestRunner(prompt, deps = {}) {
636
+ const spawn = deps.spawnSync || spawnSync;
637
+ return spawn('claude', ['-p', prompt, '--model', 'claude-haiku-4-5'], {
638
+ encoding: 'utf8',
639
+ timeout: DIGEST_ENGINE_TIMEOUT_MS,
640
+ maxBuffer: 10 * 1024 * 1024,
641
+ });
642
+ }
643
+
644
+ function invokeDigestEngine(prompt, deps = {}) {
645
+ const runner = deps.runner || ((nextPrompt) => defaultDigestRunner(nextPrompt, deps));
646
+ const result = runner(prompt, deps);
647
+ if (typeof result === 'string') return result.trim();
648
+ if (!result || result.error || (result.status != null && result.status !== 0)) {
649
+ const detail = String(result?.stderr || result?.error?.message || 'digest engine failed').trim();
650
+ throw new Error(detail || 'digest engine failed');
651
+ }
652
+ return String(result.stdout || '').trim();
653
+ }
654
+
655
+ function appendDigestJournal({ cwd, date, relDigest }) {
656
+ const year = date.slice(0, 4);
657
+ const journalPath = path.join(cwd, 'atris', 'logs', year, `${date}.md`);
658
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
659
+ let existing = '';
660
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
661
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
662
+ const line = `- [claimable] digest: what this week's videos changed -> ${relDigest}`;
663
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
664
+ }
665
+
666
+ function runYoutubeDigest(args = [], deps = {}) {
667
+ const output = deps.output || ((line = '') => console.log(line));
668
+ let options;
669
+ try {
670
+ options = parseDigestArgs(args);
671
+ } catch (err) {
672
+ output(err.message);
673
+ return 2;
674
+ }
675
+ if (options.help) {
676
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
677
+ return 0;
678
+ }
679
+
680
+ const cwd = deps.cwd || process.cwd();
681
+ const now = deps.now || new Date();
682
+ const briefs = collectVideoBriefs({ cwd, now, days: options.days });
683
+ if (!briefs.length) {
684
+ output(`no video briefs in the last ${options.days} days`);
685
+ return 0;
686
+ }
687
+
688
+ const prompt = buildDigestPrompt(briefs);
689
+ let text;
690
+ try {
691
+ text = invokeDigestEngine(prompt, deps);
692
+ } catch (err) {
693
+ output(err.message || 'digest engine failed');
694
+ return 1;
695
+ }
696
+ if (!text) {
697
+ output('digest engine returned no text');
698
+ return 1;
699
+ }
700
+
701
+ const date = dateStamp(now);
702
+ const relDigest = `atris/wiki/briefs/digest-${date}.md`;
703
+ const header = [
704
+ `date: ${date}`,
705
+ `window: ${options.days} days`,
706
+ `sources: ${briefs.map((row) => row.relPath).join(', ')}`,
707
+ ].join('\n');
708
+ fs.mkdirSync(path.join(cwd, 'atris', 'wiki', 'briefs'), { recursive: true });
709
+ fs.writeFileSync(path.join(cwd, relDigest), `${header}\n\n${text}\n`);
710
+ appendDigestJournal({ cwd, date, relDigest });
711
+ output(`digest filed: ${relDigest} (${briefs.length} briefs)`);
712
+ return 0;
713
+ }
714
+
715
+ function watchStatePath(cwd = process.cwd()) {
716
+ return path.join(cwd, '.atris', 'state', 'youtube_watch.json');
717
+ }
718
+
719
+ function emptyWatchState() {
720
+ return { channels: [], seen: {}, seeded: {}, seenByChannel: {} };
721
+ }
722
+
723
+ function asObject(value) {
724
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
725
+ }
726
+
727
+ function loadWatchState(statePath) {
728
+ try {
729
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
730
+ const channels = Array.isArray(parsed?.channels) ? parsed.channels : [];
731
+ return {
732
+ channels,
733
+ seen: asObject(parsed?.seen),
734
+ seeded: asObject(parsed?.seeded),
735
+ seenByChannel: asObject(parsed?.seenByChannel),
736
+ };
737
+ } catch {
738
+ return emptyWatchState();
739
+ }
740
+ }
741
+
742
+ function saveWatchState(statePath, state) {
743
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
744
+ const payload = {
745
+ channels: (state.channels || []).map((row) => ({
746
+ channel: row.channel,
747
+ added: row.added,
748
+ })),
749
+ seen: asObject(state.seen),
750
+ seeded: asObject(state.seeded),
751
+ seenByChannel: asObject(state.seenByChannel),
752
+ };
753
+ fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`);
754
+ }
755
+
756
+ function stampNow(now) {
757
+ if (typeof now === 'function') now = now();
758
+ if (typeof now === 'string' && now) return now;
759
+ const value = now instanceof Date ? now : new Date(now || Date.now());
760
+ if (Number.isNaN(value.getTime())) return new Date().toISOString();
761
+ return value.toISOString();
762
+ }
763
+
764
+ function normalizeWatchChannel(input) {
765
+ const raw = String(input || '').trim();
766
+ if (!raw) throw new Error('Missing channel url or @handle. Run "atris youtube watch --help".');
767
+
768
+ let text = raw;
769
+ if (text.startsWith('@')) {
770
+ text = `https://www.youtube.com/${text}`;
771
+ } else if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text)) {
772
+ if (/^(www\.)?youtube\.com\//i.test(text) || /^youtu\.be\//i.test(text)) {
773
+ text = `https://${text}`;
774
+ } else if (/^@?[\w.-]+$/.test(text)) {
775
+ text = `https://www.youtube.com/@${text.replace(/^@/, '')}`;
776
+ } else {
777
+ throw new Error(`Invalid channel: ${raw}`);
778
+ }
779
+ }
780
+
781
+ let parsed;
782
+ try {
783
+ parsed = new URL(text);
784
+ } catch {
785
+ throw new Error(`Invalid channel: ${raw}`);
786
+ }
787
+
788
+ parsed.hash = '';
789
+ parsed.search = '';
790
+ let href = parsed.toString().replace(/\/+$/, '');
791
+ href = href.replace(/\/(videos|featured|streams|shorts)$/i, '');
792
+ return href;
793
+ }
794
+
795
+ function channelVideosUrl(channel) {
796
+ const base = String(channel || '').replace(/\/+$/, '');
797
+ if (/\/videos$/i.test(base)) return base;
798
+ return `${base}/videos`;
799
+ }
800
+
801
+ function parseFlatPlaylist(stdout) {
802
+ const videos = [];
803
+ for (const line of String(stdout || '').split(/\r?\n/)) {
804
+ const trimmed = line.trim();
805
+ if (!trimmed || !trimmed.includes('|')) continue;
806
+ const idx = trimmed.indexOf('|');
807
+ const id = trimmed.slice(0, idx).trim();
808
+ const title = trimmed.slice(idx + 1).trim();
809
+ if (id && id !== 'NA') videos.push({ id, title });
810
+ }
811
+ return videos;
812
+ }
813
+
814
+ function defaultChannelFetcher(videosUrl, deps = {}) {
815
+ const spawn = deps.spawnSync || spawnSync;
816
+ const result = spawn('yt-dlp', [
817
+ '--no-update',
818
+ '--flat-playlist',
819
+ '--playlist-end',
820
+ '3',
821
+ '--print',
822
+ '%(id)s|%(title)s',
823
+ videosUrl,
824
+ ], {
825
+ encoding: 'utf8',
826
+ timeout: 60000,
827
+ maxBuffer: 2 * 1024 * 1024,
828
+ });
829
+ if (result.error || result.status !== 0) {
830
+ const detail = String(result.stderr || result.error?.message || 'fetch failed').trim();
831
+ throw new Error(detail || 'fetch failed');
832
+ }
833
+ return parseFlatPlaylist(result.stdout);
834
+ }
835
+
836
+ function defaultNotesRunner(url, deps = {}) {
837
+ const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
838
+ const spawn = deps.spawnSync || spawnSync;
839
+ return spawn(script, [url], { stdio: 'inherit' });
840
+ }
841
+
842
+ function resolveWatchStatePath(deps = {}) {
843
+ if (deps.statePath) return deps.statePath;
844
+ return watchStatePath(deps.cwd || process.cwd());
845
+ }
846
+
847
+ function channelSeenMap(state, channel) {
848
+ return asObject(asObject(state.seenByChannel)[channel]);
849
+ }
850
+
851
+ function markSeen(state, channel, id, timestamp) {
852
+ if (!state.seen || typeof state.seen !== 'object') state.seen = {};
853
+ if (!state.seenByChannel || typeof state.seenByChannel !== 'object') state.seenByChannel = {};
854
+ if (!state.seenByChannel[channel] || typeof state.seenByChannel[channel] !== 'object') {
855
+ state.seenByChannel[channel] = {};
856
+ }
857
+ state.seen[id] = timestamp;
858
+ state.seenByChannel[channel][id] = timestamp;
859
+ }
860
+
861
+ function addWatchChannel(channelInput, deps = {}) {
862
+ const output = deps.output || ((line = '') => console.log(line));
863
+ if (!channelInput) {
864
+ output('usage: atris youtube watch add <channel-url-or-@handle>');
865
+ return 2;
866
+ }
867
+
868
+ let channel;
869
+ try {
870
+ channel = normalizeWatchChannel(channelInput);
871
+ } catch (err) {
872
+ output(err.message);
873
+ return 2;
874
+ }
875
+
876
+ const statePath = resolveWatchStatePath(deps);
877
+ const state = loadWatchState(statePath);
878
+ if (state.channels.some((row) => row.channel === channel)) {
879
+ output(`already watching ${channel}`);
880
+ return 0;
881
+ }
882
+
883
+ state.channels.push({
884
+ channel,
885
+ added: stampNow(deps.now),
886
+ });
887
+ saveWatchState(statePath, state);
888
+ output(`watching ${channel}`);
889
+ return 0;
890
+ }
891
+
892
+ function listWatchChannels(deps = {}) {
893
+ const output = deps.output || ((line = '') => console.log(line));
894
+ const state = loadWatchState(resolveWatchStatePath(deps));
895
+ if (!state.channels.length) {
896
+ output('no channels watched');
897
+ return 0;
898
+ }
899
+
900
+ state.channels.forEach((row, index) => {
901
+ const count = Object.keys(channelSeenMap(state, row.channel)).length;
902
+ output(`${index + 1}. ${row.channel} (${count} seen)`);
903
+ });
904
+ return 0;
905
+ }
906
+
907
+ function removeWatchChannel(rawNumber, deps = {}) {
908
+ const output = deps.output || ((line = '') => console.log(line));
909
+ const index = Number(rawNumber);
910
+ const statePath = resolveWatchStatePath(deps);
911
+ const state = loadWatchState(statePath);
912
+ if (!Number.isInteger(index) || index < 1 || index > state.channels.length) {
913
+ output('usage: atris youtube watch remove <number>');
914
+ return 2;
915
+ }
916
+
917
+ const [removed] = state.channels.splice(index - 1, 1);
918
+ saveWatchState(statePath, state);
919
+ output(`removed ${removed.channel}`);
920
+ return 0;
921
+ }
922
+
923
+ async function tickWatch(deps = {}) {
924
+ const output = deps.output || ((line = '') => console.log(line));
925
+ const statePath = resolveWatchStatePath(deps);
926
+ const fetcher = deps.fetcher || ((videosUrl) => defaultChannelFetcher(videosUrl, deps));
927
+ const runner = deps.runner || ((url) => defaultNotesRunner(url, deps));
928
+ const briefFiler = deps.briefFiler || fileBriefFromNotes;
929
+ const cwd = deps.cwd || process.cwd();
930
+ const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
931
+ const timestamp = stampNow(deps.now);
932
+ const now = deps.now || timestamp;
933
+
934
+ const state = loadWatchState(statePath);
935
+ let totalNew = 0;
936
+ let totalBriefed = 0;
937
+
938
+ for (const row of state.channels) {
939
+ const videosUrl = channelVideosUrl(row.channel);
940
+ let videos;
941
+ try {
942
+ videos = await Promise.resolve(fetcher(videosUrl, row));
943
+ } catch {
944
+ output(`warning: channel ${row.channel} fetch failed`);
945
+ continue;
946
+ }
947
+ if (!Array.isArray(videos)) {
948
+ output(`warning: channel ${row.channel} fetch failed`);
949
+ continue;
950
+ }
951
+
952
+ const localSeen = channelSeenMap(state, row.channel);
953
+ const isFresh = !state.seeded[row.channel];
954
+ const unseen = videos.filter((video) => video?.id && !localSeen[video.id] && !state.seen[video.id]);
955
+ const newest = videos[0];
956
+ const toBrief = isFresh
957
+ ? (newest?.id ? [newest] : [])
958
+ : unseen;
959
+
960
+ if (isFresh) {
961
+ state.seeded[row.channel] = true;
962
+ for (const video of videos) {
963
+ if (video?.id) markSeen(state, row.channel, video.id, timestamp);
964
+ }
965
+ }
966
+
967
+ let briefed = 0;
968
+ for (const video of toBrief) {
969
+ if (!video?.id) continue;
970
+ const url = `https://www.youtube.com/watch?v=${video.id}`;
971
+ try {
972
+ runner(url, deps);
973
+ } catch {
974
+ // notes failure must not stop the rest of the tick
975
+ }
976
+ try {
977
+ briefFiler({ cwd, url, workDir, now });
978
+ } catch {
979
+ // brief filing must never break the watch tick
980
+ }
981
+ markSeen(state, row.channel, video.id, timestamp);
982
+ briefed += 1;
983
+ }
984
+
985
+ if (!isFresh) {
986
+ for (const video of unseen) {
987
+ if (video?.id) markSeen(state, row.channel, video.id, timestamp);
988
+ }
989
+ }
990
+
991
+ const newCount = isFresh ? (newest?.id ? 1 : 0) : unseen.length;
992
+ output(`channel ${row.channel}: ${newCount} new, ${briefed} briefed`);
993
+ totalNew += newCount;
994
+ totalBriefed += briefed;
995
+ saveWatchState(statePath, state);
996
+ }
997
+
998
+ output(`total: ${totalNew} new, ${totalBriefed} briefed`);
999
+ saveWatchState(statePath, state);
1000
+ return 0;
1001
+ }
1002
+
1003
+ async function watchCommand(args = [], deps = {}) {
1004
+ const output = deps.output || ((line = '') => console.log(line));
1005
+ const sub = args[0];
1006
+ if (!sub || ['help', '--help', '-h'].includes(sub)) {
1007
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
1008
+ return sub ? 0 : 2;
1009
+ }
1010
+ if (sub === 'add') return addWatchChannel(args[1], deps);
1011
+ if (sub === 'list') return listWatchChannels(deps);
1012
+ if (sub === 'remove') return removeWatchChannel(args[1], deps);
1013
+ if (sub === 'tick') return tickWatch(deps);
1014
+ output(`unknown watch command: ${sub}`);
1015
+ return 2;
1016
+ }
1017
+
509
1018
  function runYoutubeNotes(args = [], deps = {}) {
510
1019
  const output = deps.output || ((line = '') => console.error(line));
511
1020
  const url = args[0];
@@ -539,6 +1048,16 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
539
1048
  if (!deps.output && !deps.spawnSync) process.exit(code);
540
1049
  return code;
541
1050
  }
1051
+ if (argv[0] === 'digest') {
1052
+ const code = runYoutubeDigest(argv.slice(1), { ...deps, output });
1053
+ if (!deps.output && !deps.runner) process.exit(code);
1054
+ return code;
1055
+ }
1056
+ if (argv[0] === 'watch') {
1057
+ const code = await watchCommand(argv.slice(1), { ...deps, output });
1058
+ if (!deps.output && !deps.fetcher && !deps.runner && !deps.briefFiler) process.exit(code);
1059
+ return code;
1060
+ }
542
1061
  const options = parseYoutubeArgs(argv);
543
1062
  if (options.help) {
544
1063
  showYoutubeHelp(output, deps.commandName || 'atris youtube');
@@ -559,5 +1078,19 @@ module.exports = {
559
1078
  shouldRetryWithLocalTranscript,
560
1079
  formatYoutubeResult,
561
1080
  fileBriefFromNotes,
1081
+ parseDigestArgs,
1082
+ collectVideoBriefs,
1083
+ buildDigestPrompt,
1084
+ runYoutubeDigest,
1085
+ normalizeWatchChannel,
1086
+ channelVideosUrl,
1087
+ parseFlatPlaylist,
1088
+ loadWatchState,
1089
+ saveWatchState,
1090
+ addWatchChannel,
1091
+ listWatchChannels,
1092
+ removeWatchChannel,
1093
+ tickWatch,
1094
+ watchCommand,
562
1095
  youtubeCommand,
563
1096
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.49.0",
3
+ "version": "3.51.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
+ };