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.
- package/README.md +37 -5
- package/atris/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +96 -0
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +12 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/buy.js +242 -0
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +68 -14
- package/commands/mission.js +116 -4
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/task.js +7 -0
- package/commands/team.js +82 -10
- package/commands/transactions.js +115 -0
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +58 -26
- package/lib/commerce.js +75 -0
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +300 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +25 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +39 -0
- package/lib/member-type.js +27 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/runner-command.js +37 -2
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +169 -23
- package/scripts/det/ytquote-repair.js +18 -4
- package/scripts/det/ytrail-bench.js +214 -0
- package/scripts/det/ytrail-eval.js +128 -56
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- package/templates/members/host/skills/host/SKILL.md +64 -16
package/scripts/det/ytnotes
CHANGED
|
@@ -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
|
-
#
|
|
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|
|
|
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:-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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/>/>/g; s/&/\&/g; s/"/"/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
|
|
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|
|
|
294
|
-
|
|
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)
|
|
303
|
-
cursor)
|
|
304
|
-
*) echo "unknown engine: $ENGINE (haiku|atris-fast|
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
};
|