atris 3.46.1 → 3.48.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/atris/skills/design/SKILL.md +2 -1
- package/atris/skills/youtube/SKILL.md +5 -3
- package/bin/atris.js +3 -1
- package/commands/ci.js +1 -1
- package/commands/close.js +167 -6
- package/commands/mission.js +1 -22
- package/commands/youtube.js +31 -1
- package/lib/ci-runner.js +215 -28
- package/lib/engine-ask.js +15 -1
- package/lib/engine-registry.js +22 -0
- package/lib/fleet.js +15 -0
- package/package.json +2 -1
- package/scripts/det/README.md +162 -0
- package/scripts/det/ax-lane-eval.js +116 -0
- package/scripts/det/changelog.js +148 -0
- package/scripts/det/codex-watchdog.js +217 -0
- package/scripts/det/commit-msg.js +153 -0
- package/scripts/det/data/ax-lane-gold.jsonl +81 -0
- package/scripts/det/data/ax-lane-holdout.jsonl +20 -0
- package/scripts/det/date.js +91 -0
- package/scripts/det/det.js +149 -0
- package/scripts/det/extract.js +93 -0
- package/scripts/det/hash.js +79 -0
- package/scripts/det/hunk-filter.js +73 -0
- package/scripts/det/json.js +120 -0
- package/scripts/det/pr-description.js +213 -0
- package/scripts/det/test.js +296 -0
- package/scripts/det/text.js +102 -0
- package/scripts/det/voice.js +76 -0
- package/scripts/det/ytnotes +196 -0
- package/scripts/det/ytquote-repair.js +181 -0
- package/scripts/det/ytrail-eval.js +124 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// det/text.js — deterministic line-and-word chores an LLM gets asked to eyeball
|
|
3
|
+
// (and miscounts). Reads text on stdin, writes stdout.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// cat list.txt | node text.js dedupe # drop duplicate lines, keep first order
|
|
7
|
+
// node text.js sort < list.txt # sort lines (byte order)
|
|
8
|
+
// node text.js rsort < list.txt # reverse sort
|
|
9
|
+
// node text.js count < list.txt # lines / words / chars, one metric per line
|
|
10
|
+
// node text.js slug < title.txt # each line -> url slug
|
|
11
|
+
// node text.js trim < messy.txt # strip trailing ws, drop blank lines
|
|
12
|
+
//
|
|
13
|
+
// Modes: dedupe | sort | rsort | count | slug | trim
|
|
14
|
+
// Exit 0 on success, 2 on bad mode.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
function splitLines(input) {
|
|
19
|
+
// Normalize CRLF, drop a single trailing newline so "a\nb\n" is 2 lines not 3.
|
|
20
|
+
const t = input.replace(/\r\n/g, '\n').replace(/\n$/, '');
|
|
21
|
+
return t === '' ? [] : t.split('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function slugify(s) {
|
|
25
|
+
return s
|
|
26
|
+
.normalize('NFKD')
|
|
27
|
+
.replace(/[̀-ͯ]/g, '') // strip accents
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric -> hyphen
|
|
30
|
+
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Pure core: returns { text } or { error }. Unit-testable without process I/O.
|
|
34
|
+
function run(mode, input) {
|
|
35
|
+
const lines = splitLines(input);
|
|
36
|
+
switch (mode) {
|
|
37
|
+
case 'dedupe': {
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const l of lines) {
|
|
41
|
+
if (!seen.has(l)) {
|
|
42
|
+
seen.add(l);
|
|
43
|
+
out.push(l);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { text: out.join('\n') };
|
|
47
|
+
}
|
|
48
|
+
case 'sort':
|
|
49
|
+
return { text: [...lines].sort().join('\n') };
|
|
50
|
+
case 'rsort':
|
|
51
|
+
return { text: [...lines].sort().reverse().join('\n') };
|
|
52
|
+
case 'count': {
|
|
53
|
+
const words = (input.match(/\S+/g) || []).length;
|
|
54
|
+
const chars = input.replace(/\n$/, '').length;
|
|
55
|
+
return { text: `lines\t${lines.length}\nwords\t${words}\nchars\t${chars}` };
|
|
56
|
+
}
|
|
57
|
+
case 'slug':
|
|
58
|
+
return { text: lines.map(slugify).join('\n') };
|
|
59
|
+
case 'trim':
|
|
60
|
+
return {
|
|
61
|
+
text: lines
|
|
62
|
+
.map((l) => l.replace(/\s+$/, ''))
|
|
63
|
+
.filter((l) => l.trim() !== '')
|
|
64
|
+
.join('\n'),
|
|
65
|
+
};
|
|
66
|
+
default:
|
|
67
|
+
return { error: `unknown mode: ${mode}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readStdin() {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let data = '';
|
|
74
|
+
process.stdin.setEncoding('utf8');
|
|
75
|
+
process.stdin.on('data', (c) => (data += c));
|
|
76
|
+
process.stdin.on('end', () => resolve(data));
|
|
77
|
+
if (process.stdin.isTTY) resolve('');
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const MODES = ['dedupe', 'sort', 'rsort', 'count', 'slug', 'trim'];
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
const mode = process.argv.slice(2).find((a) => !a.startsWith('-'));
|
|
85
|
+
if (!mode || !MODES.includes(mode)) {
|
|
86
|
+
process.stderr.write(`unknown mode: ${mode || '(none)'}\nmodes: ${MODES.join(' | ')}\n`);
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
const input = await readStdin();
|
|
90
|
+
const res = run(mode, input);
|
|
91
|
+
if (res.error) {
|
|
92
|
+
process.stderr.write(res.error + '\n');
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
if (res.text.length) process.stdout.write(res.text + '\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (require.main === module) {
|
|
99
|
+
main();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { run, slugify, MODES };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// det/voice.js - score a chat reply against the way of talking (atris.md ## voice).
|
|
3
|
+
// Deterministic: same input, same verdict. Used by the engine voice exam and any
|
|
4
|
+
// agent that wants to check its own reply before sending it to a human.
|
|
5
|
+
//
|
|
6
|
+
// node det.js voice scan < reply.txt # PASS, or FAIL with one finding per line
|
|
7
|
+
// node det.js voice json < reply.txt # machine-readable findings
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const MODES = ['scan', 'json'];
|
|
12
|
+
|
|
13
|
+
// Code fences, inline code, and markdown link targets stay literal on purpose:
|
|
14
|
+
// the standard allows one copyable command and real references inside backticks.
|
|
15
|
+
function stripLiterals(text) {
|
|
16
|
+
return String(text || '')
|
|
17
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
18
|
+
.replace(/`[^`\n]*`/g, ' ')
|
|
19
|
+
.replace(/\]\([^)]*\)/g, '] ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const RULES = [
|
|
23
|
+
['em-dash', /—/g],
|
|
24
|
+
['task-code', /\b[A-Z]{2,10}-\d{1,6}\b/g],
|
|
25
|
+
['raw-id', /\b[0-9A-HJKMNP-TV-Z]{26}\b/g],
|
|
26
|
+
['system-noun', /\b(?:task plane|mission spine|second reviewer|worktrees?|verifiers?|orchestrat\w*|subagents?|ULIDs?|projections?)\b/gi],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function isHexHash(word) {
|
|
30
|
+
return /^[0-9a-f]{7,40}$/.test(word) && /\d/.test(word) && /[a-f]/.test(word);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scanReply(text) {
|
|
34
|
+
const clean = stripLiterals(text);
|
|
35
|
+
const findings = [];
|
|
36
|
+
for (const [rule, pattern] of RULES) {
|
|
37
|
+
for (const match of clean.match(pattern) || []) {
|
|
38
|
+
findings.push({ rule, snippet: match === '—' ? 'U+2014' : match });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const match of clean.match(/\b[0-9a-f]{7,40}\b/g) || []) {
|
|
42
|
+
if (isHexHash(match)) findings.push({ rule: 'commit-hash', snippet: match });
|
|
43
|
+
}
|
|
44
|
+
const bullets = (clean.match(/^\s*[-*•] /gm) || []).length;
|
|
45
|
+
if (bullets > 3) findings.push({ rule: 'bullet-stack', snippet: `${bullets} bullets` });
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
return findings.filter((f) => {
|
|
48
|
+
const key = `${f.rule}:${f.snippet}`;
|
|
49
|
+
if (seen.has(key)) return false;
|
|
50
|
+
seen.add(key);
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function run(mode, input) {
|
|
56
|
+
if (!MODES.includes(mode)) return { error: `unknown mode: ${mode || '(none)'}` };
|
|
57
|
+
const findings = scanReply(input);
|
|
58
|
+
if (mode === 'json') return { text: JSON.stringify({ pass: findings.length === 0, findings }) };
|
|
59
|
+
if (!findings.length) return { text: 'PASS' };
|
|
60
|
+
return {
|
|
61
|
+
text: [`FAIL (${findings.length})`, ...findings.map((f) => `- ${f.rule}: "${f.snippet}"`)].join('\n'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (require.main === module) {
|
|
66
|
+
let data = '';
|
|
67
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
68
|
+
process.stdin.on('end', () => {
|
|
69
|
+
const result = run(process.argv[2] || 'scan', data);
|
|
70
|
+
if (result.error) { process.stderr.write(`${result.error}\n`); process.exit(2); }
|
|
71
|
+
process.stdout.write(`${result.text}\n`);
|
|
72
|
+
process.exit(result.text.startsWith('PASS') || result.text.startsWith('{"pass":true') ? 0 : 1);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { run, scanReply, MODES };
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ytnotes <youtube-url> [engine]: local YouTube notes, tweet-feed one-screen.
|
|
3
|
+
# Zero Atris credits. yt-dlp pulls captions, a fast engine writes the notes.
|
|
4
|
+
# Engines: haiku (default), atris-fast, gemini, grok, codex, cursor.
|
|
5
|
+
# Outputs: $WORK/yt_${ID}.clean.txt (transcript), $WORK/yt_${ID}.md (notes).
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
USAGE="usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]"
|
|
9
|
+
|
|
10
|
+
case "${1:-}" in
|
|
11
|
+
*youtube.com*|*youtu.be*) ;;
|
|
12
|
+
*) echo "$USAGE" >&2; exit 2 ;;
|
|
13
|
+
esac
|
|
14
|
+
|
|
15
|
+
URL="$1"
|
|
16
|
+
ENGINE="${2:-haiku}"
|
|
17
|
+
CALLER_CWD="$PWD"
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
19
|
+
WORK="${TMPDIR:-/tmp}/ytnotes"
|
|
20
|
+
mkdir -p "$WORK"
|
|
21
|
+
cd "$WORK"
|
|
22
|
+
|
|
23
|
+
META=$(yt-dlp --no-update --quiet --no-warnings --skip-download \
|
|
24
|
+
--write-auto-subs --sub-format vtt --sub-langs en \
|
|
25
|
+
--no-simulate \
|
|
26
|
+
-o "yt_%(id)s" \
|
|
27
|
+
--print "%(id)s|%(title)s|%(channel)s|%(duration_string)s" \
|
|
28
|
+
"$URL" 2>/dev/null)
|
|
29
|
+
ID="${META%%|*}"
|
|
30
|
+
INFO="${META#*|}"
|
|
31
|
+
|
|
32
|
+
VTT="yt_${ID}.en.vtt"
|
|
33
|
+
if [ ! -s "$VTT" ]; then
|
|
34
|
+
echo "No English captions found for $URL. Falling back is manual (atris youtube process, 5 credits)." >&2
|
|
35
|
+
exit 2
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Strip VTT cruft, drop only nearby rolling-caption duplicates, and keep one
|
|
39
|
+
# time anchor every 30 seconds. Raw auto-captions are mostly timestamp spam:
|
|
40
|
+
# the 21-minute canary is 210 KB raw, 95 KB with the old cleaner, and 22 KB here.
|
|
41
|
+
sed -E 's/<[^>]*>//g; s/>/>/g; s/&/\&/g; s/"/"/g' "$VTT" | awk '
|
|
42
|
+
function seconds(ts, parts) {
|
|
43
|
+
split(ts, parts, ":")
|
|
44
|
+
return (parts[1] * 3600) + (parts[2] * 60) + int(parts[3])
|
|
45
|
+
}
|
|
46
|
+
/^(WEBVTT|Kind:|Language:)/ { next }
|
|
47
|
+
/-->/ { cue_seconds = seconds($1); next }
|
|
48
|
+
{
|
|
49
|
+
line = $0
|
|
50
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
|
|
51
|
+
if (line == "") next
|
|
52
|
+
|
|
53
|
+
duplicate = 0
|
|
54
|
+
for (i = 0; i < 6; i++) if (recent[i] == line) duplicate = 1
|
|
55
|
+
if (duplicate) next
|
|
56
|
+
|
|
57
|
+
bucket = int(cue_seconds / 30)
|
|
58
|
+
if (bucket != last_bucket) {
|
|
59
|
+
total = int(cue_seconds)
|
|
60
|
+
hours = int(total / 3600)
|
|
61
|
+
minutes = int((total % 3600) / 60)
|
|
62
|
+
secs = total % 60
|
|
63
|
+
if (hours > 0) printf "[%02d:%02d:%02d]\n", hours, minutes, secs
|
|
64
|
+
else printf "[%02d:%02d]\n", minutes, secs
|
|
65
|
+
last_bucket = bucket
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
print line
|
|
69
|
+
for (i = 5; i > 0; i--) recent[i] = recent[i - 1]
|
|
70
|
+
recent[0] = line
|
|
71
|
+
}' > "yt_${ID}.clean.txt"
|
|
72
|
+
|
|
73
|
+
TRANSCRIPT=$(<"yt_${ID}.clean.txt")
|
|
74
|
+
|
|
75
|
+
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."
|
|
76
|
+
|
|
77
|
+
PROMPT="$STYLE_PROMPT
|
|
78
|
+
|
|
79
|
+
Transcript:
|
|
80
|
+
$TRANSCRIPT"
|
|
81
|
+
|
|
82
|
+
run_with_timeout() {
|
|
83
|
+
local seconds="$1"
|
|
84
|
+
shift
|
|
85
|
+
perl -e '$seconds = shift @ARGV; alarm $seconds; exec @ARGV' "$seconds" "$@"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
run_atris_fast() {
|
|
89
|
+
if [ ! -d "$CALLER_CWD/atris" ]; then
|
|
90
|
+
echo "atris-fast requires ytnotes to run from an initialized Atris workspace" >&2
|
|
91
|
+
return 2
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
local jobs_file="$WORK/yt_${ID}.jobs.json"
|
|
95
|
+
local map_raw
|
|
96
|
+
|
|
97
|
+
node - "$INFO" "$WORK/yt_${ID}.clean.txt" "$jobs_file" <<'NODE'
|
|
98
|
+
const fs = require('fs')
|
|
99
|
+
const info = process.argv[2]
|
|
100
|
+
const transcriptPath = process.argv[3]
|
|
101
|
+
const jobsPath = process.argv[4]
|
|
102
|
+
const lines = fs.readFileSync(transcriptPath, 'utf8').trim().split('\n')
|
|
103
|
+
const targetBytes = 11000
|
|
104
|
+
const chunks = []
|
|
105
|
+
let current = []
|
|
106
|
+
let bytes = 0
|
|
107
|
+
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const lineBytes = Buffer.byteLength(`${line}\n`)
|
|
110
|
+
if (current.length && bytes + lineBytes > targetBytes) {
|
|
111
|
+
chunks.push(current.join('\n'))
|
|
112
|
+
current = []
|
|
113
|
+
bytes = 0
|
|
114
|
+
}
|
|
115
|
+
current.push(line)
|
|
116
|
+
bytes += lineBytes
|
|
117
|
+
}
|
|
118
|
+
if (current.length) chunks.push(current.join('\n'))
|
|
119
|
+
|
|
120
|
+
const jobs = chunks.map((chunk, index) => ({
|
|
121
|
+
engine: 'atris-fast',
|
|
122
|
+
label: `chunk-${String(index + 1).padStart(2, '0')}`,
|
|
123
|
+
prompt: `Read-only transcript analysis. Do not call tools. This is ordered chunk ${index + 1} of ${chunks.length} from ${info}. Return only valid JSON with this exact shape: {"beats":[{"time":"mm:ss","hook":"max 8 words","detail":"max 18 words"}],"action":"max 14 words"}. Pick exactly 3 critical beats. Prefer concrete names, numbers, mechanisms, and honest uncertainty; paraphrase instead of quoting. No markdown or preamble.\n\n${chunk}`,
|
|
124
|
+
}))
|
|
125
|
+
|
|
126
|
+
fs.writeFileSync(jobsPath, JSON.stringify(jobs))
|
|
127
|
+
NODE
|
|
128
|
+
|
|
129
|
+
map_raw=$(cd "$CALLER_CWD" && atris engine ask --jobs "$jobs_file" \
|
|
130
|
+
--concurrency 3 --timeout 15 --json)
|
|
131
|
+
printf '%s' "$map_raw" | node -e '
|
|
132
|
+
let raw = ""
|
|
133
|
+
process.stdin.on("data", (chunk) => { raw += chunk })
|
|
134
|
+
process.stdin.on("end", () => {
|
|
135
|
+
const receipt = JSON.parse(raw)
|
|
136
|
+
const answers = [...(receipt.answers || [])].sort((a, b) => a.label.localeCompare(b.label))
|
|
137
|
+
if (!answers.length || answers.some((answer) => !answer.ok)) process.exit(2)
|
|
138
|
+
|
|
139
|
+
const titleParts = process.argv[1].split("|")
|
|
140
|
+
const beats = []
|
|
141
|
+
const actions = []
|
|
142
|
+
for (const answer of answers) {
|
|
143
|
+
let output = answer.stdout
|
|
144
|
+
try { output = JSON.parse(answer.stdout).output || output } catch {}
|
|
145
|
+
const clean = String(output).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "")
|
|
146
|
+
let parsed
|
|
147
|
+
try { parsed = JSON.parse(clean) } catch { process.exit(2) }
|
|
148
|
+
for (const beat of (parsed.beats || []).slice(0, 3)) beats.push(beat)
|
|
149
|
+
if (parsed.action) actions.push(String(parsed.action))
|
|
150
|
+
}
|
|
151
|
+
if (beats.length < 4) process.exit(2)
|
|
152
|
+
|
|
153
|
+
const lines = [`# ${titleParts[0]}`, `${titleParts[1] || "YouTube"} · ${titleParts[2] || ""}`]
|
|
154
|
+
for (const beat of beats.slice(0, 6)) {
|
|
155
|
+
const hook = String(beat.hook || "Key idea").replace(/[.]+$/, "")
|
|
156
|
+
const time = String(beat.time || "").replace(/^\[|\]$/g, "")
|
|
157
|
+
lines.push("", `**${hook}.**`, `${String(beat.detail || "").trim()}${time ? ` [${time}]` : ""}`)
|
|
158
|
+
}
|
|
159
|
+
lines.push("", "**Takeaway**", "", `1. ${actions[0] || "Apply the strongest mechanism to one current Atris mission."}`, `2. ${actions[1] || "Require one proof artifact before that mission advances."}`)
|
|
160
|
+
process.stdout.write(`${lines.join("\n").trim()}\n`)
|
|
161
|
+
})' "$INFO"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
run_grok() {
|
|
165
|
+
run_with_timeout 240 grok --verbatim --no-memory --no-plan --no-subagents \
|
|
166
|
+
--disable-web-search --max-turns 1 -p "$PROMPT"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
run_haiku() {
|
|
170
|
+
run_with_timeout 240 claude -p "$PROMPT" --model claude-haiku-4-5
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
case "$ENGINE" in
|
|
175
|
+
haiku)
|
|
176
|
+
run_haiku
|
|
177
|
+
;;
|
|
178
|
+
atris-fast|fast|atris)
|
|
179
|
+
run_atris_fast
|
|
180
|
+
;;
|
|
181
|
+
gemini|gemini-3.7-flash)
|
|
182
|
+
run_with_timeout 120 gemini -m gemini-3.7-flash --skip-trust \
|
|
183
|
+
--approval-mode plan -o text -p "$PROMPT"
|
|
184
|
+
;;
|
|
185
|
+
gemini-3.6-flash)
|
|
186
|
+
run_with_timeout 120 gemini -m gemini-3.6-flash --skip-trust \
|
|
187
|
+
--approval-mode plan -o text -p "$PROMPT"
|
|
188
|
+
;;
|
|
189
|
+
grok) run_grok ;;
|
|
190
|
+
codex) run_with_timeout 240 codex exec --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
|
|
191
|
+
cursor) run_with_timeout 240 cursor-agent --trust -p "$PROMPT" ;;
|
|
192
|
+
*) echo "unknown engine: $ENGINE (haiku|atris-fast|gemini|grok|codex|cursor)" >&2; exit 1 ;;
|
|
193
|
+
esac
|
|
194
|
+
} | perl -0pe 's/\A.*?(?=# )//s' | tee "yt_${ID}.md"
|
|
195
|
+
|
|
196
|
+
node "$SCRIPT_DIR/ytquote-repair.js" "yt_${ID}.md" "yt_${ID}.clean.txt"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Replace paraphrased ytnotes quotes with nearby caption words, or drop them.
|
|
5
|
+
// Usage: node ytquote-repair.js <notes.md> <clean-transcript.txt>
|
|
6
|
+
// Always exits 0. Summary goes to stderr.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
|
|
10
|
+
const QUOTE_LINE = /^(\s*>\s*)(["“])(.+?)(["”])(\s*\[(\d{1,2}:\d{2}(?::\d{2})?)\])\s*$/;
|
|
11
|
+
const ANCHOR_LINE = /^\[(\d{1,2}:\d{2}(?::\d{2})?)\]\s*$/;
|
|
12
|
+
const NEAR_SEC = 60;
|
|
13
|
+
const OVERLAP_MIN = 0.6;
|
|
14
|
+
const QUOTE_MAX = 240;
|
|
15
|
+
|
|
16
|
+
function normalizeText(s) {
|
|
17
|
+
return String(s)
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[‘’“”'"]/g, '')
|
|
20
|
+
.replace(/[^a-z0-9 ]/g, ' ')
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseTimestamp(ts) {
|
|
26
|
+
const parts = String(ts).split(':').map(Number);
|
|
27
|
+
if (!parts.length || parts.some((n) => Number.isNaN(n))) return null;
|
|
28
|
+
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
29
|
+
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function probeHits(quote, flatTranscript) {
|
|
34
|
+
const words = normalizeText(quote).split(' ').filter(Boolean);
|
|
35
|
+
const probe = words.slice(0, Math.min(6, words.length)).join(' ');
|
|
36
|
+
return Boolean(probe && flatTranscript.includes(probe));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseTranscript(text) {
|
|
40
|
+
const segments = [];
|
|
41
|
+
let current = null;
|
|
42
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
43
|
+
const anchor = line.match(ANCHOR_LINE);
|
|
44
|
+
if (anchor) {
|
|
45
|
+
if (current) segments.push(current);
|
|
46
|
+
current = { time: parseTimestamp(anchor[1]), words: [] };
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!current) continue;
|
|
50
|
+
for (const word of line.trim().split(/\s+/).filter(Boolean)) {
|
|
51
|
+
current.words.push(word);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (current) segments.push(current);
|
|
55
|
+
return segments;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nearbyWords(segments, time) {
|
|
59
|
+
const words = [];
|
|
60
|
+
for (const seg of segments) {
|
|
61
|
+
if (seg.time == null || Math.abs(seg.time - time) > NEAR_SEC) continue;
|
|
62
|
+
words.push(...seg.words);
|
|
63
|
+
}
|
|
64
|
+
return words;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function wordOverlap(quoteWords, windowWords) {
|
|
68
|
+
const windowSet = new Set(windowWords.filter(Boolean));
|
|
69
|
+
let hit = 0;
|
|
70
|
+
for (const word of quoteWords) {
|
|
71
|
+
if (word && windowSet.has(word)) hit += 1;
|
|
72
|
+
}
|
|
73
|
+
return quoteWords.length ? hit / quoteWords.length : 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bestWindow(quoteWords, sourceExact) {
|
|
77
|
+
const n = quoteWords.length;
|
|
78
|
+
if (!n || !sourceExact.length) return null;
|
|
79
|
+
|
|
80
|
+
const sourceNorm = sourceExact.map((word) => normalizeText(word));
|
|
81
|
+
const limit = Math.max(1, sourceExact.length - n + 1);
|
|
82
|
+
let bestScore = -1;
|
|
83
|
+
let bestExact = '';
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < limit; i += 1) {
|
|
86
|
+
const size = Math.min(n, sourceExact.length - i);
|
|
87
|
+
const score = wordOverlap(quoteWords, sourceNorm.slice(i, i + size));
|
|
88
|
+
if (score > bestScore) {
|
|
89
|
+
bestScore = score;
|
|
90
|
+
bestExact = sourceExact.slice(i, i + size).join(' ');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (bestScore < OVERLAP_MIN) return null;
|
|
95
|
+
return bestExact.length > QUOTE_MAX ? bestExact.slice(0, QUOTE_MAX).trim() : bestExact;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function repairNotes(notes, transcript) {
|
|
99
|
+
const flat = normalizeText(transcript);
|
|
100
|
+
const segments = parseTranscript(transcript);
|
|
101
|
+
const nl = String(notes).includes('\r\n') ? '\r\n' : '\n';
|
|
102
|
+
const raw = String(notes);
|
|
103
|
+
const hadTrailing = /[\r\n]$/.test(raw);
|
|
104
|
+
const lines = raw.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
|
|
105
|
+
if (lines.length === 1 && lines[0] === '' && raw === '') {
|
|
106
|
+
return { text: '', kept: 0, repaired: 0, dropped: 0 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const out = [];
|
|
110
|
+
let kept = 0;
|
|
111
|
+
let repaired = 0;
|
|
112
|
+
let dropped = 0;
|
|
113
|
+
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
const match = line.match(QUOTE_LINE);
|
|
116
|
+
if (!match) {
|
|
117
|
+
out.push(line);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const quote = match[3];
|
|
122
|
+
if (probeHits(quote, flat)) {
|
|
123
|
+
out.push(line);
|
|
124
|
+
kept += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const time = parseTimestamp(match[6]);
|
|
129
|
+
const quoteWords = normalizeText(quote).split(' ').filter(Boolean);
|
|
130
|
+
const repairedText = time == null ? null : bestWindow(quoteWords, nearbyWords(segments, time));
|
|
131
|
+
if (!repairedText) {
|
|
132
|
+
dropped += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
out.push(`${match[1]}"${repairedText}"${match[5]}`);
|
|
137
|
+
repaired += 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let text = out.join(nl);
|
|
141
|
+
if (hadTrailing || raw.length) text += nl;
|
|
142
|
+
return { text, kept, repaired, dropped };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function main() {
|
|
146
|
+
const notesPath = process.argv[2];
|
|
147
|
+
const transcriptPath = process.argv[3];
|
|
148
|
+
let kept = 0;
|
|
149
|
+
let repaired = 0;
|
|
150
|
+
let dropped = 0;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (notesPath && transcriptPath && fs.existsSync(notesPath)) {
|
|
154
|
+
const notes = fs.readFileSync(notesPath, 'utf8');
|
|
155
|
+
const transcript = fs.existsSync(transcriptPath)
|
|
156
|
+
? fs.readFileSync(transcriptPath, 'utf8')
|
|
157
|
+
: '';
|
|
158
|
+
const result = repairNotes(notes, transcript);
|
|
159
|
+
fs.writeFileSync(notesPath, result.text);
|
|
160
|
+
kept = result.kept;
|
|
161
|
+
repaired = result.repaired;
|
|
162
|
+
dropped = result.dropped;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
kept = 0;
|
|
166
|
+
repaired = 0;
|
|
167
|
+
dropped = 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.error(`quotes: ${kept} kept, ${repaired} repaired, ${dropped} dropped`);
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (require.main === module) {
|
|
175
|
+
main();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
normalizeText,
|
|
180
|
+
repairNotes,
|
|
181
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Score one ytnotes run. Usage:
|
|
5
|
+
// node scripts/det/ytrail-eval.js [url] [engine]
|
|
6
|
+
// Default url: https://www.youtube.com/watch?v=Z3JyAqh4ixg
|
|
7
|
+
// Default engine: haiku
|
|
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_ENGINE = 'haiku';
|
|
15
|
+
|
|
16
|
+
function videoId(url) {
|
|
17
|
+
const watch = String(url).match(/[?&]v=([^&]+)/);
|
|
18
|
+
if (watch) return watch[1];
|
|
19
|
+
const short = String(url).match(/youtu\.be\/([^?&/]+)/);
|
|
20
|
+
return short ? short[1] : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function wordCount(text) {
|
|
24
|
+
return String(text).trim().split(/\s+/).filter(Boolean).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function norm(s) {
|
|
28
|
+
return String(s)
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[‘’“”'"]/g, '')
|
|
31
|
+
.replace(/[^a-z0-9 ]/g, ' ')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scoreQuotes(notes, transcript) {
|
|
37
|
+
const flat = norm(transcript);
|
|
38
|
+
const spans = [...String(notes).matchAll(/[“"]([^“”"]{15,240})[”"]/g)]
|
|
39
|
+
.map((m) => m[1])
|
|
40
|
+
.slice(0, 8);
|
|
41
|
+
let ok = 0;
|
|
42
|
+
for (const q of spans) {
|
|
43
|
+
const words = norm(q).split(' ').filter(Boolean);
|
|
44
|
+
const probe = words.slice(0, Math.min(6, words.length)).join(' ');
|
|
45
|
+
if (probe && flat.includes(probe)) ok += 1;
|
|
46
|
+
}
|
|
47
|
+
const needed = spans.length / 2;
|
|
48
|
+
return {
|
|
49
|
+
spans: spans.length,
|
|
50
|
+
verified: ok,
|
|
51
|
+
pass: spans.length >= 2 && ok >= needed,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function firstLine(text) {
|
|
56
|
+
return String(text).replace(/^\uFEFF/, '').split(/\r?\n/, 1)[0] || '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function main() {
|
|
60
|
+
const url = process.argv[2] || DEFAULT_URL;
|
|
61
|
+
const engine = process.argv[3] || DEFAULT_ENGINE;
|
|
62
|
+
const root = path.resolve(__dirname, '..', '..');
|
|
63
|
+
const ytnotes = path.join(root, 'scripts', 'det', 'ytnotes');
|
|
64
|
+
const workDir = path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
|
|
65
|
+
const id = videoId(url);
|
|
66
|
+
const transcriptPath = id ? path.join(workDir, `yt_${id}.clean.txt`) : '';
|
|
67
|
+
const notesPath = id ? path.join(workDir, `yt_${id}.md`) : '';
|
|
68
|
+
|
|
69
|
+
const started = Date.now();
|
|
70
|
+
const run = spawnSync(ytnotes, [url, engine], {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
cwd: root,
|
|
73
|
+
env: process.env,
|
|
74
|
+
timeout: 180000,
|
|
75
|
+
});
|
|
76
|
+
const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
|
|
77
|
+
|
|
78
|
+
const stdoutNotes = String(run.stdout || '');
|
|
79
|
+
let fileNotes = '';
|
|
80
|
+
if (notesPath && fs.existsSync(notesPath)) {
|
|
81
|
+
fileNotes = fs.readFileSync(notesPath, 'utf8');
|
|
82
|
+
}
|
|
83
|
+
const notes = fileNotes || stdoutNotes;
|
|
84
|
+
|
|
85
|
+
let transcript = '';
|
|
86
|
+
if (transcriptPath && fs.existsSync(transcriptPath)) {
|
|
87
|
+
transcript = fs.readFileSync(transcriptPath, 'utf8');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const quotes = scoreQuotes(notes, transcript);
|
|
91
|
+
const checks = {
|
|
92
|
+
exit0: run.status === 0,
|
|
93
|
+
transcriptWords: wordCount(transcript) >= 1000,
|
|
94
|
+
notesExist: notes.trim().length > 0,
|
|
95
|
+
notesHeading: firstLine(notes).startsWith('#'),
|
|
96
|
+
quoteHonesty: quotes.pass,
|
|
97
|
+
};
|
|
98
|
+
const pass = Object.values(checks).every(Boolean);
|
|
99
|
+
|
|
100
|
+
const row = {
|
|
101
|
+
ts: new Date().toISOString(),
|
|
102
|
+
url,
|
|
103
|
+
engine,
|
|
104
|
+
seconds,
|
|
105
|
+
pass,
|
|
106
|
+
checks,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const outDir = path.join(root, 'atris', 'benchmarks');
|
|
110
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
111
|
+
fs.appendFileSync(path.join(outDir, 'ytrail.jsonl'), `${JSON.stringify(row)}\n`);
|
|
112
|
+
|
|
113
|
+
const words = wordCount(transcript);
|
|
114
|
+
console.log(
|
|
115
|
+
`ytrail ${pass ? 'pass' : 'fail'} ${engine} ${seconds}s words=${words} quotes=${quotes.verified}/${quotes.spans} heading=${checks.notesHeading ? 'yes' : 'no'}`
|
|
116
|
+
);
|
|
117
|
+
if (run.status !== 0 && run.stderr) {
|
|
118
|
+
console.log(String(run.stderr).trim().split('\n').slice(-8).join('\n'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
process.exit(pass ? 0 : 1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main();
|