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 +1 -1
- package/commands/youtube.js +416 -17
- package/package.json +1 -1
- package/scripts/det/ytnotes +1 -1
- package/scripts/det/ytrail-race.js +192 -0
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');
|
package/commands/youtube.js
CHANGED
|
@@ -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,
|
|
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
|
|
820
|
-
const
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
|
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
package/scripts/det/ytnotes
CHANGED
|
@@ -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
|
|
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
|
+
};
|