atris 3.61.0 → 3.63.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.
Files changed (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
@@ -1,11 +1,16 @@
1
1
  #!/bin/bash
2
2
  # ytnotes <youtube-url> [engine]: local YouTube notes, tweet-feed one-screen.
3
3
  # Zero Atris credits. yt-dlp pulls captions, a fast engine writes the notes.
4
- # Engines: haiku (default), atris-fast, gemini, grok, codex, cursor.
4
+ # No captions: if mlx_whisper is installed (uv tool install mlx-whisper, Apple
5
+ # silicon), the audio is transcribed on this machine instead. Otherwise the
6
+ # script prints the paid command. ATRIS_YTNOTES_LOCAL_TRANSCRIBE=0 turns it off.
7
+ # Engines: auto (default: fastest installed writer that answers: Gemini
8
+ # Flash through agy, then Haiku, Codex, Cursor),
9
+ # gemini, haiku, atris-fast, grok, codex, cursor. ATRIS_YTNOTES_ENGINE sets the default.
5
10
  # Outputs: $WORK/yt_${ID}.clean.txt (transcript), $WORK/yt_${ID}.md (notes).
6
11
  set -euo pipefail
7
12
 
8
- USAGE="usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]"
13
+ USAGE="usage: ytnotes <youtube-url> [auto|gemini|haiku|atris-fast|grok|codex|cursor]"
9
14
 
10
15
  case "${1:-}" in
11
16
  *youtube.com*|*youtu.be*|*youtube-nocookie.com*) ;;
@@ -13,30 +18,60 @@ case "${1:-}" in
13
18
  esac
14
19
 
15
20
  URL="$1"
16
- ENGINE="${2:-haiku}"
21
+ ENGINE="${2:-${ATRIS_YTNOTES_ENGINE:-auto}}"
17
22
  CALLER_CWD="$PWD"
18
23
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
24
  WORK="${TMPDIR:-/tmp}/ytnotes"
25
+ # uv tool installs (yt-dlp, mlx_whisper) live here; appended so it never
26
+ # shadows anything already on PATH.
27
+ PATH="$PATH:$HOME/.local/bin"
28
+ WHISPER_MODEL="${ATRIS_YTNOTES_WHISPER_MODEL:-mlx-community/whisper-large-v3-turbo}"
20
29
  mkdir -p "$WORK"
21
30
  cd "$WORK"
22
31
 
23
32
  # Printed metadata and a written English VTT are a hit even when yt-dlp exits 429.
24
33
  # A leaked WARNING|ERROR print line is not the video id.
25
34
  # Manual English is --write-subs. Auto English is often en-orig / en-US / en-GB.
35
+ # Plain auto "en" is YouTube's machine translation and returns HTTP 429, which
36
+ # made yt-dlp quit before writing en-orig. Ask for en-orig first, keep going.
26
37
  # Teach parses leftover yt_<id>.clean.txt stamps as cues.
27
38
  # Empty --print still keeps a VTT written in this run (id from the watch /
28
39
  # shorts / embed / live / e / nocookie embed URL or the new filename). A copied
29
40
  # #t= timestamp is stripped so leftover yt_<id> files still match. When VTT is
30
41
  # gone, leftover yt_<id>.clean.txt from a prior notes run is still a keep.
31
42
  # Another video's leftover clean.txt is not invent-keep.
43
+ if ! command -v yt-dlp >/dev/null 2>&1; then
44
+ echo "This computer is missing yt-dlp, the free tool that reads YouTube captions." >&2
45
+ echo "Install it, then run the same command again:" >&2
46
+ echo " Mac: brew install yt-dlp Any computer: pipx install yt-dlp" >&2
47
+ exit 2
48
+ fi
49
+
50
+ # YouTube blocks old yt-dlp copies, and the failure looks like "no captions".
51
+ # Warn when the version date (YYYY.MM.DD, maybe .N) is too old; keep going.
52
+ # A version that does not parse stays silent.
53
+ YTDLP_VERSION=$(yt-dlp --version 2>/dev/null) || true
54
+ YTDLP_AGE=$(perl -MTime::Local -e '
55
+ my ($v, $max) = @ARGV;
56
+ exit 0 unless $max =~ /\A\d+\z/;
57
+ my ($y, $m, $d) = $v =~ /\A\s*(\d{4})\.(\d{1,2})\.(\d{1,2})(?:\.\d+)?\s*\z/ or exit 0;
58
+ my $t = eval { timelocal(0, 0, 12, $d, $m - 1, $y) };
59
+ exit 0 unless defined $t;
60
+ my $age = int((time - $t) / 86400);
61
+ print $age if $age > $max;
62
+ ' "$YTDLP_VERSION" "${ATRIS_YTNOTES_YTDLP_MAX_AGE_DAYS:-60}" 2>/dev/null) || true
63
+ if [ -n "$YTDLP_AGE" ]; then
64
+ echo "Your yt-dlp is $YTDLP_AGE days old, and YouTube often blocks old copies. Update it: brew upgrade yt-dlp (Mac) or pipx upgrade yt-dlp" >&2
65
+ fi
66
+
32
67
  existing=""
33
- for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
68
+ for name in yt_*.en-orig.vtt yt_*.en.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
34
69
  [ -e "$name" ] || continue
35
70
  existing="$existing $name"
36
71
  done
37
72
 
38
73
  META=$(yt-dlp --no-update --quiet --no-warnings --skip-download \
39
- --write-subs --write-auto-subs --sub-format vtt --sub-langs "en,en-orig,en-US,en-GB" \
74
+ --write-subs --write-auto-subs --sub-format vtt --sub-langs "en-orig,en,en-US,en-GB" --ignore-errors \
40
75
  --no-simulate \
41
76
  -o "yt_%(id)s" \
42
77
  --print "%(id)s|%(title)s|%(channel)s|%(duration_string)s" \
@@ -114,7 +149,7 @@ fi
114
149
 
115
150
  VTT=""
116
151
  if [ -n "$ID" ]; then
117
- for name in "yt_${ID}.en.vtt" "yt_${ID}.en-orig.vtt" "yt_${ID}.en-US.vtt" "yt_${ID}.en-GB.vtt"; do
152
+ for name in "yt_${ID}.en-orig.vtt" "yt_${ID}.en.vtt" "yt_${ID}.en-US.vtt" "yt_${ID}.en-GB.vtt" "yt_${ID}.local.vtt"; do
118
153
  if [ -s "$name" ]; then
119
154
  VTT="$name"
120
155
  break
@@ -122,7 +157,7 @@ if [ -n "$ID" ]; then
122
157
  done
123
158
  fi
124
159
  if [ -z "$VTT" ]; then
125
- for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
160
+ for name in yt_*.en-orig.vtt yt_*.en.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
126
161
  [ -s "$name" ] || continue
127
162
  case " $existing " in
128
163
  *" $name "*) continue ;;
@@ -135,11 +170,67 @@ if [ -z "$VTT" ]; then
135
170
  break
136
171
  done
137
172
  fi
173
+ # No captions: transcribe the audio on this machine. Writes yt_<id>.local.vtt
174
+ # and deletes the audio. Returns non-zero, with the reason in LOCAL_FAIL, when
175
+ # the model is missing or a step fails.
176
+ LOCAL_FAIL=""
177
+ transcribe_locally() {
178
+ if [ "${ATRIS_YTNOTES_LOCAL_TRANSCRIBE:-1}" = "0" ]; then
179
+ LOCAL_FAIL="off"; return 1
180
+ fi
181
+ if ! command -v mlx_whisper >/dev/null 2>&1; then
182
+ LOCAL_FAIL="no-model"; return 1
183
+ fi
184
+ echo "No captions on this video. Transcribing the audio on this computer (free, about 1 minute per 15 minutes of video)." >&2
185
+ rm -f "yt_${ID}".audio.*
186
+ # YouTube refuses some media requests at random (HTTP 403) and allows the
187
+ # next one, so try the download three times before giving up.
188
+ local attempt downloaded=0
189
+ for attempt in 1 2 3; do
190
+ if yt-dlp --no-update --quiet --no-warnings --no-playlist \
191
+ -f "bestaudio[ext=m4a]/bestaudio/best" -o "yt_${ID}.audio.%(ext)s" "$URL" 2>"yt_${ID}.audio.err"; then
192
+ downloaded=1; break
193
+ fi
194
+ [ "$attempt" = 3 ] || sleep "$((attempt * ${ATRIS_YTNOTES_RETRY_SECONDS:-3}))"
195
+ done
196
+ if [ "$downloaded" != 1 ]; then
197
+ LOCAL_FAIL="download: $(grep -m1 ERROR "yt_${ID}.audio.err" 2>/dev/null || echo "yt-dlp failed")"
198
+ rm -f "yt_${ID}".audio.*
199
+ return 1
200
+ fi
201
+ local audio=""
202
+ for f in "yt_${ID}".audio.*; do
203
+ case "$f" in *.err|*.part) continue ;; esac
204
+ [ -s "$f" ] && audio="$f" && break
205
+ done
206
+ if [ -z "$audio" ]; then
207
+ LOCAL_FAIL="download: no audio file"; rm -f "yt_${ID}".audio.*; return 1
208
+ fi
209
+ if ! mlx_whisper "$audio" --model "$WHISPER_MODEL" --output-format vtt \
210
+ --output-dir "$WORK" --output-name "whisper_${ID}" --verbose False >"yt_${ID}.whisper.log" 2>&1; then
211
+ LOCAL_FAIL="transcribe: $(tail -n 1 "yt_${ID}.whisper.log")"
212
+ rm -f "yt_${ID}".audio.* "whisper_${ID}.vtt"
213
+ return 1
214
+ fi
215
+ # mlx_whisper drops anything after a dot in --output-name, so write a
216
+ # dot-free name and rename it.
217
+ mv -f "whisper_${ID}.vtt" "yt_${ID}.local.vtt" 2>/dev/null || true
218
+ rm -f "yt_${ID}".audio.* "yt_${ID}.whisper.log"
219
+ [ -s "yt_${ID}.local.vtt" ] || { LOCAL_FAIL="transcribe: empty transcript"; return 1; }
220
+ }
221
+
138
222
  if [ -z "$VTT" ]; then
139
223
  if [ -n "$ID" ] && [ -s "yt_${ID}.clean.txt" ]; then
140
224
  : # leftover cleaned transcript from a prior notes run
225
+ elif [ -n "$ID" ] && transcribe_locally; then
226
+ VTT="yt_${ID}.local.vtt"
141
227
  else
142
- echo "No English captions found for $URL. Falling back is manual (atris youtube process, 5 credits)." >&2
228
+ echo "No English captions found for $URL." >&2
229
+ case "$LOCAL_FAIL" in
230
+ no-model) echo "This computer has no local speech model to transcribe it." >&2 ;;
231
+ download:*|transcribe:*) echo "Local transcription failed (${LOCAL_FAIL})." >&2 ;;
232
+ esac
233
+ echo "Paid notes, 5 credits, refunded if it fails: atris youtube process \"$URL\"" >&2
143
234
  exit 2
144
235
  fi
145
236
  fi
@@ -150,10 +241,12 @@ fi
150
241
  # Leftover clean.txt is already cleaned; do not overwrite it with an empty VTT pass.
151
242
  if [ -n "$VTT" ]; then
152
243
  sed -E 's/<[^>]*>//g; s/&gt;/>/g; s/&amp;/\&/g; s/&quot;/"/g' "$VTT" | awk '
153
- function seconds(ts, parts) {
154
- split(ts, parts, ":")
244
+ function seconds(ts, parts, n) {
245
+ n = split(ts, parts, ":")
246
+ if (n == 2) return (parts[1] * 60) + int(parts[2])
155
247
  return (parts[1] * 3600) + (parts[2] * 60) + int(parts[3])
156
248
  }
249
+ BEGIN { last_bucket = -1 }
157
250
  /^(WEBVTT|Kind:|Language:)/ { next }
158
251
  /-->/ { cue_seconds = seconds($1); next }
159
252
  {
@@ -184,7 +277,7 @@ fi
184
277
 
185
278
  TRANSCRIPT=$(<"yt_${ID}.clean.txt")
186
279
 
187
- STYLE_PROMPT="Write one-screen tweet-feed notes on this YouTube transcript ($INFO). Markdown only, start with # title then a 'Channel · Duration' line, 6-8 beats each opening with a **Bold hook.**, 4-6 verbatim quotes in double quotes with [mm:ss] timestamps on their own '> ' lines, end with a 2-item **Takeaway**. No preamble, no em dashes, fits one screen."
280
+ STYLE_PROMPT="Write one-screen tweet-feed notes on this YouTube transcript ($INFO). Markdown only, start with # title then a 'Channel · Duration' line, 6-8 beats each opening with a **Bold hook.**, 4-6 verbatim quotes, each on its own line shaped exactly > \"quote\" [mm:ss], end with a 2-item **Takeaway**. No preamble, no em dashes, fits one screen."
188
281
 
189
282
  PROMPT="$STYLE_PROMPT
190
283
 
@@ -194,7 +287,7 @@ $TRANSCRIPT"
194
287
  run_with_timeout() {
195
288
  local seconds="$1"
196
289
  shift
197
- perl -e '$seconds = shift @ARGV; alarm $seconds; exec @ARGV' "$seconds" "$@"
290
+ perl -e '$seconds = shift @ARGV; alarm $seconds; exec @ARGV or do { print STDERR "$ARGV[0]: not installed\n"; exit 127 }' "$seconds" "$@"
198
291
  }
199
292
 
200
293
  run_atris_fast() {
@@ -282,27 +375,80 @@ run_haiku() {
282
375
  run_with_timeout 240 claude -p "$PROMPT" --model claude-haiku-4-5
283
376
  }
284
377
 
378
+ # Gemini Flash through the Antigravity CLI (agy). On an 85-minute transcript it
379
+ # writes notes in about 8s; Haiku takes 30-40s. The old gemini CLI is retired.
380
+ run_gemini() {
381
+ command -v agy >/dev/null 2>&1 || { echo "agy is not installed" >&2; return 127; }
382
+ run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-gemini-3.8-flash}" \
383
+ --effort low --print-timeout 110s </dev/null
384
+ }
385
+
386
+ # Fastest installed writer that answers: Gemini, Haiku, Codex, Cursor.
387
+ # Buffered so a failed or empty answer never reaches the notes file.
388
+ AUTO_WRITERS="agy:Gemini claude:Haiku codex:Codex cursor-agent:Cursor"
389
+
390
+ run_writer() {
391
+ case "$1" in
392
+ agy) run_gemini ;;
393
+ claude) run_haiku ;;
394
+ codex) run_with_timeout 240 codex exec --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
395
+ cursor-agent) run_with_timeout 240 cursor-agent --trust -p "$PROMPT" ;;
396
+ esac
397
+ }
398
+
399
+ run_auto() {
400
+ local entry cmd label out tried=""
401
+ for entry in $AUTO_WRITERS; do
402
+ cmd="${entry%%:*}"
403
+ label="${entry#*:}"
404
+ command -v "$cmd" >/dev/null 2>&1 || continue
405
+ if [ -n "$tried" ]; then
406
+ echo "$tried did not answer; writing the notes with $label instead." >&2
407
+ fi
408
+ if out=$(run_writer "$cmd" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
409
+ printf '%s\n' "$out"
410
+ echo "notes by $label" >&2
411
+ return 0
412
+ fi
413
+ tried="$label"
414
+ done
415
+ if [ -n "$tried" ]; then
416
+ echo "$tried did not answer, and no other AI writer is installed." >&2
417
+ else
418
+ echo "No AI writer is installed on this computer. Install one of: Claude Code (claude), Antigravity (agy), Codex (codex), Cursor (cursor-agent)." >&2
419
+ fi
420
+ return 3
421
+ }
422
+
285
423
  {
286
424
  case "$ENGINE" in
425
+ auto)
426
+ run_auto
427
+ ;;
287
428
  haiku)
288
429
  run_haiku
289
430
  ;;
290
431
  atris-fast|fast|atris)
291
432
  run_atris_fast
292
433
  ;;
293
- gemini|gemini-3.7-flash)
294
- run_with_timeout 120 gemini -m gemini-3.7-flash --skip-trust \
295
- --approval-mode plan -o text -p "$PROMPT"
296
- ;;
297
- gemini-3.6-flash)
298
- run_with_timeout 120 gemini -m gemini-3.6-flash --skip-trust \
299
- --approval-mode plan -o text -p "$PROMPT"
434
+ gemini|agy)
435
+ run_gemini
300
436
  ;;
301
437
  grok) run_grok ;;
302
- codex) run_with_timeout 240 codex exec --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
303
- cursor) run_with_timeout 240 cursor-agent --trust -p "$PROMPT" ;;
304
- *) echo "unknown engine: $ENGINE (haiku|atris-fast|gemini|grok|codex|cursor)" >&2; exit 1 ;;
438
+ codex) run_writer codex ;;
439
+ cursor) run_writer cursor-agent ;;
440
+ *) echo "unknown engine: $ENGINE (auto|gemini|haiku|atris-fast|grok|codex|cursor)" >&2; exit 1 ;;
305
441
  esac
306
- } | perl -0pe 's/\A.*?(?=# )//s' | tee "yt_${ID}.md"
442
+ } | perl -0pe 's/\A.*?(?=# )//s' > "yt_${ID}.md" || true
443
+
444
+ if ! grep -q '[[:alnum:]]' "yt_${ID}.md"; then
445
+ rm -f "yt_${ID}.md"
446
+ echo "No notes were written. The transcript is saved at $WORK/yt_${ID}.clean.txt" >&2
447
+ echo "Paid notes, 5 credits, refunded if it fails: atris youtube process \"$URL\"" >&2
448
+ exit 3
449
+ fi
450
+ # auto names its writer above; a pinned engine is named here.
451
+ [ "$ENGINE" = "auto" ] || echo "notes by $ENGINE" >&2
452
+ cat "yt_${ID}.md"
307
453
 
308
454
  node "$SCRIPT_DIR/ytquote-repair.js" "yt_${ID}.md" "yt_${ID}.clean.txt"
@@ -7,7 +7,9 @@
7
7
 
8
8
  const fs = require('node:fs');
9
9
 
10
+ // Models put the timestamp on either side: > "quote" [mm:ss] or > [mm:ss] "quote".
10
11
  const QUOTE_LINE = /^(\s*>\s*)(["“])(.+?)(["”])(\s*\[(\d{1,2}:\d{2}(?::\d{2})?)\])\s*$/;
12
+ const QUOTE_LINE_TIME_FIRST = /^(\s*>\s*\[(\d{1,2}:\d{2}(?::\d{2})?)\]\s*)(["“])(.+?)(["”])\s*$/;
11
13
  const ANCHOR_LINE = /^\[(\d{1,2}:\d{2}(?::\d{2})?)\]\s*$/;
12
14
  const NEAR_SEC = 60;
13
15
  const OVERLAP_MIN = 0.6;
@@ -30,6 +32,18 @@ function parseTimestamp(ts) {
30
32
  return null;
31
33
  }
32
34
 
35
+ function parseQuoteLine(line) {
36
+ const after = line.match(QUOTE_LINE);
37
+ if (after) {
38
+ return { quote: after[3], time: after[6], rebuild: (text) => `${after[1]}"${text}"${after[5]}` };
39
+ }
40
+ const first = line.match(QUOTE_LINE_TIME_FIRST);
41
+ if (first) {
42
+ return { quote: first[4], time: first[2], rebuild: (text) => `${first[1]}"${text}"` };
43
+ }
44
+ return null;
45
+ }
46
+
33
47
  function probeHits(quote, flatTranscript) {
34
48
  const words = normalizeText(quote).split(' ').filter(Boolean);
35
49
  const probe = words.slice(0, Math.min(6, words.length)).join(' ');
@@ -112,20 +126,20 @@ function repairNotes(notes, transcript) {
112
126
  let dropped = 0;
113
127
 
114
128
  for (const line of lines) {
115
- const match = line.match(QUOTE_LINE);
129
+ const match = parseQuoteLine(line);
116
130
  if (!match) {
117
131
  out.push(line);
118
132
  continue;
119
133
  }
120
134
 
121
- const quote = match[3];
135
+ const quote = match.quote;
122
136
  if (probeHits(quote, flat)) {
123
137
  out.push(line);
124
138
  kept += 1;
125
139
  continue;
126
140
  }
127
141
 
128
- const time = parseTimestamp(match[6]);
142
+ const time = parseTimestamp(match.time);
129
143
  const quoteWords = normalizeText(quote).split(' ').filter(Boolean);
130
144
  const repairedText = time == null ? null : bestWindow(quoteWords, nearbyWords(segments, time));
131
145
  if (!repairedText) {
@@ -133,7 +147,7 @@ function repairNotes(notes, transcript) {
133
147
  continue;
134
148
  }
135
149
 
136
- out.push(`${match[1]}"${repairedText}"${match[5]}`);
150
+ out.push(match.rebuild(repairedText));
137
151
  repaired += 1;
138
152
  }
139
153
 
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Bench what users get from `atris youtube notes`, end to end, uncached.
5
+ // Usage: node scripts/det/ytrail-bench.js [--quick] [--case <name>]
6
+ // Prints one line per case and a final "bench: X/Y passed in Ns"; exits 1 if
7
+ // any case fails. Rows (with a `case` field) go to $YTRAIL_OUT_DIR/ytrail.jsonl,
8
+ // default ~/.atris/benchmarks, outside any git repo.
9
+ // ytnotes is resolved next to this file, so an installed copy benches itself.
10
+
11
+ const { spawnSync } = require('node:child_process');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { evaluate, appendRow } = require('./ytrail-eval');
16
+
17
+ const YTNOTES = path.join(__dirname, 'ytnotes');
18
+ const STRANGER_TIMEOUT_MS = 300000;
19
+
20
+ const CASES = [
21
+ { name: 'captioned-short', kind: 'graded', url: 'https://www.youtube.com/watch?v=Z3JyAqh4ixg', quick: true },
22
+ { name: 'captioned-long', kind: 'graded', url: 'https://www.youtube.com/watch?v=Am7IWP8IpEc' },
23
+ { name: 'no-captions', kind: 'no-captions', url: 'https://www.youtube.com/watch?v=6DRlX5vIOE0', minWords: 50 },
24
+ { name: 'stranger-no-downloader', kind: 'no-downloader', url: 'https://www.youtube.com/watch?v=Am7IWP8IpEc', quick: true },
25
+ { name: 'stranger-no-writer', kind: 'no-writer', url: 'https://www.youtube.com/watch?v=Am7IWP8IpEc', quick: true },
26
+ ];
27
+
28
+ function parseArgs(argv) {
29
+ const opts = { quick: false, only: null };
30
+ for (let i = 0; i < argv.length; i += 1) {
31
+ if (argv[i] === '--quick') opts.quick = true;
32
+ else if (argv[i] === '--case') opts.only = argv[++i] || '';
33
+ else if (argv[i].startsWith('--case=')) opts.only = argv[i].slice('--case='.length);
34
+ else throw new Error(`unknown option: ${argv[i]}`);
35
+ }
36
+ return opts;
37
+ }
38
+
39
+ function selectCases(opts, cases = CASES) {
40
+ if (opts.only !== null) {
41
+ const found = cases.filter((c) => c.name === opts.only);
42
+ if (!found.length) {
43
+ throw new Error(`unknown case: ${opts.only} (cases: ${cases.map((c) => c.name).join(', ')})`);
44
+ }
45
+ return found;
46
+ }
47
+ return opts.quick ? cases.filter((c) => c.quick) : cases.slice();
48
+ }
49
+
50
+ // First executable named `name` on PATH, then in the extra dirs.
51
+ function findBinary(name, env = process.env, extraDirs = []) {
52
+ const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean).concat(extraDirs);
53
+ for (const dir of dirs) {
54
+ const file = path.join(dir, name);
55
+ try {
56
+ fs.accessSync(file, fs.constants.X_OK);
57
+ if (fs.statSync(file).isFile()) return file;
58
+ } catch {}
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function findWhisper(env = process.env) {
64
+ return findBinary('mlx_whisper', env, [path.join(os.homedir(), '.local', 'bin')]);
65
+ }
66
+
67
+ function findYtDlp(env = process.env) {
68
+ return findBinary('yt-dlp', env, [path.join(os.homedir(), '.local', 'bin'), '/opt/homebrew/bin']);
69
+ }
70
+
71
+ // Pure pass/fail rules. Each takes a finished run and returns { pass, note }.
72
+ function judgeNoCaptions({ whisper, row, stderr }) {
73
+ if (whisper) {
74
+ return { pass: Boolean(row.pass), note: 'local transcription' };
75
+ }
76
+ const pass = row.exit === 2 && /atris youtube process/.test(stderr || '');
77
+ return { pass, note: 'no speech model, paid command offered' };
78
+ }
79
+
80
+ function judgeNoDownloader({ status, stderr }) {
81
+ const text = String(stderr || '');
82
+ const pass = status === 2 && /missing yt-dlp/.test(text) && !/No English captions/.test(text);
83
+ return { pass, note: pass ? 'says to install yt-dlp' : 'did not say to install yt-dlp' };
84
+ }
85
+
86
+ function judgeNoWriter({ status, stderr, notesLeft }) {
87
+ const text = String(stderr || '');
88
+ const pass = status === 3 && /No AI writer is installed/.test(text) && !notesLeft;
89
+ return { pass, note: pass ? 'says to install an AI writer' : 'did not say to install an AI writer' };
90
+ }
91
+
92
+ // Run ytnotes as a stranger would: a bare HOME, and only node (plus yt-dlp
93
+ // when given) on top of the system dirs.
94
+ function runStranger(url, { ytDlp = null } = {}) {
95
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ytrail-stranger-'));
96
+ const bin = path.join(tmp, 'bin');
97
+ fs.mkdirSync(bin);
98
+ fs.symlinkSync(process.execPath, path.join(bin, 'node'));
99
+ if (ytDlp) fs.symlinkSync(ytDlp, path.join(bin, 'yt-dlp'));
100
+ try {
101
+ const started = Date.now();
102
+ const run = spawnSync(YTNOTES, [url], {
103
+ encoding: 'utf8',
104
+ cwd: tmp,
105
+ env: { HOME: tmp, PATH: `${bin}:/usr/bin:/bin`, TMPDIR: tmp },
106
+ timeout: STRANGER_TIMEOUT_MS,
107
+ });
108
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
109
+ const id = String(url).match(/[?&]v=([^&]+)/)[1];
110
+ const notesLeft = fs.existsSync(path.join(tmp, 'ytnotes', `yt_${id}.md`));
111
+ return { status: run.status, stderr: String(run.stderr || ''), seconds, notesLeft };
112
+ } finally {
113
+ fs.rmSync(tmp, { recursive: true, force: true });
114
+ }
115
+ }
116
+
117
+ function lastLines(text, n = 3) {
118
+ return String(text || '').trim().split('\n').filter(Boolean).slice(-n).map((line) => ` ${line}`).join('\n');
119
+ }
120
+
121
+ function runCase(c) {
122
+ if (c.kind === 'graded' || c.kind === 'no-captions') {
123
+ const whisper = c.kind === 'no-captions' ? findWhisper() : null;
124
+ const { row, stderr } = evaluate({ url: c.url, engine: 'auto', minWords: c.minWords });
125
+ let verdict = { pass: row.pass, note: null };
126
+ if (c.kind === 'no-captions') verdict = judgeNoCaptions({ whisper, row, stderr });
127
+ const q = row.quotes;
128
+ const parts = [
129
+ `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${row.seconds}s`,
130
+ verdict.note ? `(${verdict.note})` : null,
131
+ `exit=${row.exit === null ? 'none' : row.exit}`,
132
+ `words=${row.words}`,
133
+ `writer=${row.writer || 'none'}`,
134
+ q ? `quotes=${q.kept} kept/${q.repaired} repaired/${q.dropped} dropped` : null,
135
+ ].filter(Boolean);
136
+ return {
137
+ pass: verdict.pass,
138
+ line: parts.join(' '),
139
+ detail: verdict.pass ? '' : lastLines(stderr),
140
+ row: { ...row, case: c.name, pass: verdict.pass, ...(verdict.note ? { path: verdict.note } : {}) },
141
+ };
142
+ }
143
+
144
+ let ytDlp = null;
145
+ if (c.kind === 'no-writer') {
146
+ ytDlp = findYtDlp();
147
+ if (!ytDlp) {
148
+ return {
149
+ skipped: true,
150
+ line: `${c.name} skipped: no yt-dlp on this computer to test with`,
151
+ row: { ts: new Date().toISOString(), case: c.name, url: c.url, skipped: true },
152
+ };
153
+ }
154
+ }
155
+ const run = runStranger(c.url, { ytDlp });
156
+ const verdict = c.kind === 'no-writer' ? judgeNoWriter(run) : judgeNoDownloader(run);
157
+ return {
158
+ pass: verdict.pass,
159
+ line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${run.seconds}s exit=${run.status === null ? 'none' : run.status} (${verdict.note})`,
160
+ detail: verdict.pass ? '' : lastLines(run.stderr),
161
+ row: {
162
+ ts: new Date().toISOString(),
163
+ case: c.name,
164
+ url: c.url,
165
+ seconds: run.seconds,
166
+ pass: verdict.pass,
167
+ exit: run.status,
168
+ notesLeft: run.notesLeft,
169
+ },
170
+ };
171
+ }
172
+
173
+ function summary(results, seconds) {
174
+ const ran = results.filter((r) => !r.skipped);
175
+ const passed = ran.filter((r) => r.pass).length;
176
+ const skipped = results.length - ran.length;
177
+ return `bench: ${passed}/${ran.length} passed in ${seconds}s${skipped ? ` (${skipped} skipped)` : ''}`;
178
+ }
179
+
180
+ function main() {
181
+ let cases;
182
+ try {
183
+ cases = selectCases(parseArgs(process.argv.slice(2)));
184
+ } catch (err) {
185
+ console.error(err.message);
186
+ console.error('usage: ytrail-bench [--quick] [--case <name>]');
187
+ process.exit(2);
188
+ }
189
+ const outDir = process.env.YTRAIL_OUT_DIR || path.join(os.homedir(), '.atris', 'benchmarks');
190
+ const started = Date.now();
191
+ const results = [];
192
+ for (const c of cases) {
193
+ const result = runCase(c);
194
+ results.push(result);
195
+ console.log(result.line);
196
+ if (result.detail) console.log(result.detail);
197
+ appendRow(outDir, result.row);
198
+ }
199
+ console.log(summary(results, Math.round((Date.now() - started) / 1000)));
200
+ process.exit(results.some((r) => !r.skipped && !r.pass) ? 1 : 0);
201
+ }
202
+
203
+ if (require.main === module) main();
204
+
205
+ module.exports = {
206
+ CASES,
207
+ parseArgs,
208
+ selectCases,
209
+ findBinary,
210
+ judgeNoCaptions,
211
+ judgeNoDownloader,
212
+ judgeNoWriter,
213
+ summary,
214
+ };