atris 3.62.0 → 3.64.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/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +120 -0
- package/atris/skills/x-search/SKILL.md +3 -1
- package/ax +4 -3
- package/bin/atris.js +22 -2
- package/cli/atris_code.py +25 -1
- package/commands/auth.js +79 -10
- package/commands/autoland.js +11 -1
- package/commands/browse.js +2 -2
- package/commands/business.js +3 -3
- package/commands/buy.js +242 -0
- package/commands/deck.js +2 -2
- package/commands/engine.js +122 -13
- package/commands/feedback.js +2 -2
- package/commands/fork.js +2 -2
- package/commands/gm.js +2 -1
- package/commands/improve.js +142 -8
- package/commands/integrations.js +4 -3
- package/commands/loops.js +2 -1
- package/commands/member.js +82 -15
- package/commands/mission.js +4 -1
- package/commands/pack-craft.js +2 -2
- package/commands/publish.js +2 -2
- package/commands/radar.js +3 -2
- package/commands/router.js +2 -2
- package/commands/task.js +91 -2
- package/commands/transactions.js +115 -0
- package/commands/truth.js +2 -2
- package/commands/x-search.js +81 -10
- package/commands/youtube.js +24 -1
- package/lib/autoland.js +3 -2
- package/lib/ax-auto-lane.js +2 -2
- package/lib/brief-rules.js +11 -0
- package/lib/chat-log-scan.js +2 -1
- package/lib/ci-runner.js +3 -2
- package/lib/codex-flight.js +2 -1
- package/lib/commerce.js +90 -0
- package/lib/deck-history.js +2 -2
- package/lib/deck-review.js +3 -3
- package/lib/design-api.js +2 -2
- package/lib/engine-registry.js +102 -18
- package/lib/first-minute.js +2 -2
- package/lib/fleet.js +8 -36
- package/lib/git-spawn.js +5 -2
- package/lib/ignored-cache.js +51 -0
- package/lib/known-commands.js +1 -1
- package/lib/manifest.js +2 -2
- package/lib/map-refs.js +70 -4
- package/lib/member-engine.js +15 -1
- package/lib/member-scaffold.js +2 -0
- package/lib/member-type.js +27 -0
- package/lib/pack-capabilities.js +2 -1
- package/lib/permission-grants.js +2 -2
- package/lib/pulse.js +5 -5
- package/lib/roster-runs.js +13 -2
- package/lib/runner-command.js +54 -3
- package/lib/sandbox-pids.js +113 -0
- package/lib/secret-gateway.js +3 -13
- package/lib/state-home.js +106 -0
- package/lib/task-db.js +4 -3
- package/lib/task-prune.js +462 -0
- package/lib/x-post-check.js +497 -0
- package/package.json +1 -1
- package/scripts/det/data/xsearch-answer.json +24 -0
- package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
- package/scripts/det/xsearch-bench.js +307 -0
- package/scripts/det/ytnotes +122 -16
- package/scripts/det/ytrail-bench.js +215 -0
- package/scripts/det/ytrail-eval.js +128 -56
- package/utils/auth.js +3 -3
- package/utils/update-check.js +2 -2
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Bench what users get from `atris x-search`, end to end.
|
|
5
|
+
// Usage: node scripts/det/xsearch-bench.js [--quick|--free] [--case <name>]
|
|
6
|
+
// default: every case, 2 paid searches (10 credits)
|
|
7
|
+
// --quick: topic plus the free cases (5 credits)
|
|
8
|
+
// --free: free cases only (0 credits)
|
|
9
|
+
// Prints one line per case and a final "bench: X/Y passed in Ns"; exits 1 if
|
|
10
|
+
// any case fails. Rows (with a `case` field) go to $XSEARCH_OUT_DIR/xsearch.jsonl,
|
|
11
|
+
// default ~/.atris/benchmarks, outside any git repo. A failed paid case also
|
|
12
|
+
// keeps its full output there, since it cost credits to get.
|
|
13
|
+
// bin/atris.js is resolved next to this file, so an installed copy benches itself.
|
|
14
|
+
|
|
15
|
+
const { spawn } = require('node:child_process');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const http = require('node:http');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { checkXPosts } = require('../../lib/x-post-check');
|
|
21
|
+
|
|
22
|
+
const ATRIS = path.join(__dirname, '..', '..', 'bin', 'atris.js');
|
|
23
|
+
const FIXTURE = path.join(__dirname, 'data', 'xsearch-answer.json');
|
|
24
|
+
const PAID_TIMEOUT_MS = 180000;
|
|
25
|
+
const STRANGER_TIMEOUT_MS = 60000;
|
|
26
|
+
const CHECKED_MIN = 0.6;
|
|
27
|
+
|
|
28
|
+
const CASES = [
|
|
29
|
+
{ name: 'topic', kind: 'paid', args: ['x-search', 'AI agent startups launch', '--limit', '5', '--days', '2'], minPosts: 3, quick: true },
|
|
30
|
+
{ name: 'person', kind: 'paid', args: ['x-search', 'person', '--name', 'Garry Tan', '--handle', 'garrytan'], minPosts: 1 },
|
|
31
|
+
{ name: 'stranger-logged-out', kind: 'stranger', args: ['x-search', 'AI agent startups launch', '--limit', '5', '--days', '2'], quick: true, free: true },
|
|
32
|
+
{ name: 'offline-check', kind: 'offline', quick: true, free: true },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const opts = { quick: false, free: false, only: null };
|
|
37
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
38
|
+
if (argv[i] === '--quick') opts.quick = true;
|
|
39
|
+
else if (argv[i] === '--free') opts.free = true;
|
|
40
|
+
else if (argv[i] === '--case') opts.only = argv[++i] || '';
|
|
41
|
+
else if (argv[i].startsWith('--case=')) opts.only = argv[i].slice('--case='.length);
|
|
42
|
+
else throw new Error(`unknown option: ${argv[i]}`);
|
|
43
|
+
}
|
|
44
|
+
return opts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function selectCases(opts, cases = CASES) {
|
|
48
|
+
if (opts.only !== null) {
|
|
49
|
+
const found = cases.filter((c) => c.name === opts.only);
|
|
50
|
+
if (!found.length) {
|
|
51
|
+
throw new Error(`unknown case: ${opts.only} (cases: ${cases.map((c) => c.name).join(', ')})`);
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
if (opts.free) return cases.filter((c) => c.free);
|
|
56
|
+
return opts.quick ? cases.filter((c) => c.quick) : cases.slice();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// x-search prints "posts: N checked, N unverified, N unknown" at the end.
|
|
60
|
+
function parseTally(stdout) {
|
|
61
|
+
const match = String(stdout || '').match(/posts: (\d+) checked, (\d+) unverified, (\d+) unknown/);
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
return { checked: Number(match[1]), unverified: Number(match[2]), unknown: Number(match[3]) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Pure pass/fail rules. Each takes a finished run and returns { pass, note }.
|
|
67
|
+
// Posts = checked citations plus unverified quotes. Unknown (X could not be
|
|
68
|
+
// reached) counts neither way.
|
|
69
|
+
function postCount(tally) {
|
|
70
|
+
return tally ? tally.checked + tally.unverified : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function judgePaid({ status, tally, minPosts }) {
|
|
74
|
+
const posts = postCount(tally);
|
|
75
|
+
const share = posts ? tally.checked / posts : 0;
|
|
76
|
+
if (status !== 0) return { pass: false, posts, note: 'search failed' };
|
|
77
|
+
if (!tally) return { pass: false, posts, note: 'no posts found to check' };
|
|
78
|
+
if (posts < minPosts) return { pass: false, posts, note: `only ${posts} posts, wanted ${minPosts}` };
|
|
79
|
+
if (share < CHECKED_MIN) return { pass: false, posts, note: `only ${Math.round(share * 100)}% checked` };
|
|
80
|
+
return { pass: true, posts, note: `${Math.round(share * 100)}% checked` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function judgeStranger({ status, text, hits }) {
|
|
84
|
+
const told = /not signed in|atris login/i.test(String(text || ''));
|
|
85
|
+
const pass = told && typeof status === 'number' && status !== 0 && hits === 0;
|
|
86
|
+
let note = 'says to log in, nothing charged';
|
|
87
|
+
if (!told) note = 'did not say to log in';
|
|
88
|
+
else if (hits) note = `reached the server ${hits} time(s)`;
|
|
89
|
+
else if (status === 0) note = 'exited 0 without logging in';
|
|
90
|
+
return { pass, note };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fixtureFetch(embeds, downId = null) {
|
|
94
|
+
return async (id) => {
|
|
95
|
+
if (id === downId) return { id, ok: false, error: 'timeout' };
|
|
96
|
+
if (!(id in embeds)) return { id, ok: false, error: 'not in fixture' };
|
|
97
|
+
const post = embeds[id];
|
|
98
|
+
return post ? { id, ok: true, post: { id, handle: post.handle, text: post.text, createdAt: null } } : { id, ok: true, post: null };
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Run the checker on the saved answer twice: once with X answering every
|
|
103
|
+
// lookup, once with one lookup timing out. No network.
|
|
104
|
+
async function runOfflineCheck(fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8'))) {
|
|
105
|
+
const up = await checkXPosts({ content: fixture.content, citations: fixture.citations, fetchPost: fixtureFetch(fixture.embeds) });
|
|
106
|
+
const down = await checkXPosts({
|
|
107
|
+
content: fixture.content,
|
|
108
|
+
citations: fixture.citations,
|
|
109
|
+
fetchPost: fixtureFetch(fixture.embeds, fixture.expect.one_down.down_id),
|
|
110
|
+
});
|
|
111
|
+
const want = fixture.expect;
|
|
112
|
+
const upOk = Boolean(up)
|
|
113
|
+
&& up.tally.checked === want.all_up.checked
|
|
114
|
+
&& up.tally.unverified === want.all_up.unverified
|
|
115
|
+
&& up.tally.unknown === want.all_up.unknown
|
|
116
|
+
&& up.otherSources.length === want.all_up.other_sources;
|
|
117
|
+
const downOk = Boolean(down)
|
|
118
|
+
&& down.tally.checked === want.one_down.checked
|
|
119
|
+
&& down.tally.unverified === want.one_down.unverified
|
|
120
|
+
&& down.tally.unknown === want.one_down.unknown;
|
|
121
|
+
return {
|
|
122
|
+
pass: upOk && downOk,
|
|
123
|
+
tally: up ? up.tally : null,
|
|
124
|
+
note: !upOk ? 'wrong result with X answering' : !downOk ? 'wrong result with X down' : 'checked, unverified, and unknown all land right',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Count every request so the stranger case can prove it never reached the server.
|
|
129
|
+
function startCountingServer() {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const state = { hits: 0 };
|
|
132
|
+
const server = http.createServer((req, res) => {
|
|
133
|
+
state.hits += 1;
|
|
134
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
135
|
+
res.end('{"detail":"bench server"}');
|
|
136
|
+
});
|
|
137
|
+
server.on('error', reject);
|
|
138
|
+
server.listen(0, '127.0.0.1', () => {
|
|
139
|
+
state.url = `http://127.0.0.1:${server.address().port}`;
|
|
140
|
+
state.close = () => new Promise((done) => server.close(() => done()));
|
|
141
|
+
resolve(state);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function runAtris(args, { env, cwd, timeoutMs }) {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
const started = Date.now();
|
|
149
|
+
const child = spawn(process.execPath, [ATRIS, ...args], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
150
|
+
let stdout = '';
|
|
151
|
+
let stderr = '';
|
|
152
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
153
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
154
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
155
|
+
child.on('close', (status) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
resolve({ status, stdout, stderr, seconds: Number(((Date.now() - started) / 1000).toFixed(1)) });
|
|
158
|
+
});
|
|
159
|
+
child.on('error', (err) => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
resolve({ status: null, stdout, stderr: `${stderr}${err.message}`, seconds: Number(((Date.now() - started) / 1000).toFixed(1)) });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Run x-search as a stranger would: a bare HOME with no login, and the backend
|
|
167
|
+
// pointed at a local server that counts requests.
|
|
168
|
+
async function runStranger(args) {
|
|
169
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xsearch-stranger-'));
|
|
170
|
+
const server = await startCountingServer();
|
|
171
|
+
try {
|
|
172
|
+
const run = await runAtris(args, {
|
|
173
|
+
cwd: tmp,
|
|
174
|
+
env: {
|
|
175
|
+
HOME: tmp,
|
|
176
|
+
PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`,
|
|
177
|
+
TMPDIR: tmp,
|
|
178
|
+
ATRIS_API_URL: `${server.url}/api`,
|
|
179
|
+
ATRIS_BACKEND_URL: server.url,
|
|
180
|
+
ATRIS_APP_URL: server.url,
|
|
181
|
+
CI: '1',
|
|
182
|
+
},
|
|
183
|
+
timeoutMs: STRANGER_TIMEOUT_MS,
|
|
184
|
+
});
|
|
185
|
+
return { ...run, hits: server.hits };
|
|
186
|
+
} finally {
|
|
187
|
+
await server.close();
|
|
188
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function lastLines(text, n = 3) {
|
|
193
|
+
return String(text || '').trim().split('\n').filter(Boolean).slice(-n).map((line) => ` ${line}`).join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function tallyText(t) {
|
|
197
|
+
return t ? `posts=${postCount(t)} checked=${t.checked} unverified=${t.unverified} unknown=${t.unknown}` : 'posts=0';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Keep what a failed paid search printed, so the miss can be read later
|
|
201
|
+
// without paying for another search.
|
|
202
|
+
function saveFailedOutput(dir, name, ts, text) {
|
|
203
|
+
try {
|
|
204
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
+
const file = path.join(dir, `xsearch-${name}-${ts.replace(/[:.]/g, '-')}.txt`);
|
|
206
|
+
fs.writeFileSync(file, text);
|
|
207
|
+
return file;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function runCase(c, outDir) {
|
|
214
|
+
const ts = new Date().toISOString();
|
|
215
|
+
if (c.kind === 'offline') {
|
|
216
|
+
const started = Date.now();
|
|
217
|
+
const verdict = await runOfflineCheck();
|
|
218
|
+
const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
|
|
219
|
+
const t = verdict.tally;
|
|
220
|
+
return {
|
|
221
|
+
pass: verdict.pass,
|
|
222
|
+
line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${seconds}s (${verdict.note}) ${tallyText(t)}`,
|
|
223
|
+
row: {
|
|
224
|
+
ts, case: c.name, seconds, pass: verdict.pass, exit: null,
|
|
225
|
+
posts: postCount(t),
|
|
226
|
+
checked: t ? t.checked : 0, unverified: t ? t.unverified : 0, unknown: t ? t.unknown : 0,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (c.kind === 'stranger') {
|
|
232
|
+
const run = await runStranger(c.args);
|
|
233
|
+
const verdict = judgeStranger({ status: run.status, text: `${run.stdout}\n${run.stderr}`, hits: run.hits });
|
|
234
|
+
return {
|
|
235
|
+
pass: verdict.pass,
|
|
236
|
+
line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${run.seconds}s exit=${run.status === null ? 'none' : run.status} (${verdict.note})`,
|
|
237
|
+
detail: verdict.pass ? '' : lastLines(`${run.stdout}\n${run.stderr}`),
|
|
238
|
+
row: { ts, case: c.name, seconds: run.seconds, pass: verdict.pass, exit: run.status, posts: 0, checked: 0, unverified: 0, unknown: 0, hits: run.hits },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const run = await runAtris(c.args, { cwd: os.tmpdir(), env: process.env, timeoutMs: PAID_TIMEOUT_MS });
|
|
243
|
+
const tally = parseTally(run.stdout);
|
|
244
|
+
const verdict = judgePaid({ status: run.status, tally, minPosts: c.minPosts });
|
|
245
|
+
const saved = verdict.pass ? null : saveFailedOutput(outDir, c.name, ts, `${run.stdout}\n${run.stderr}`);
|
|
246
|
+
return {
|
|
247
|
+
pass: verdict.pass,
|
|
248
|
+
line: `${c.name} ${verdict.pass ? 'pass' : 'fail'} ${run.seconds}s exit=${run.status === null ? 'none' : run.status} (${verdict.note}) ${tallyText(tally)}`,
|
|
249
|
+
detail: verdict.pass ? '' : [lastLines(`${run.stdout}\n${run.stderr}`), saved ? ` full output: ${saved}` : null].filter(Boolean).join('\n'),
|
|
250
|
+
row: {
|
|
251
|
+
ts, case: c.name, seconds: run.seconds, pass: verdict.pass, exit: run.status,
|
|
252
|
+
posts: verdict.posts,
|
|
253
|
+
checked: tally ? tally.checked : 0, unverified: tally ? tally.unverified : 0, unknown: tally ? tally.unknown : 0,
|
|
254
|
+
...(saved ? { output: saved } : {}),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function summary(results, seconds) {
|
|
260
|
+
const passed = results.filter((r) => r.pass).length;
|
|
261
|
+
return `bench: ${passed}/${results.length} passed in ${seconds}s`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function appendRow(dir, row) {
|
|
265
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
266
|
+
fs.appendFileSync(path.join(dir, 'xsearch.jsonl'), `${JSON.stringify(row)}\n`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function main() {
|
|
270
|
+
let cases;
|
|
271
|
+
try {
|
|
272
|
+
cases = selectCases(parseArgs(process.argv.slice(2)));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(err.message);
|
|
275
|
+
console.error('usage: xsearch-bench [--quick|--free] [--case <name>]');
|
|
276
|
+
process.exit(2);
|
|
277
|
+
}
|
|
278
|
+
const outDir = process.env.XSEARCH_OUT_DIR || path.join(os.homedir(), '.atris', 'benchmarks');
|
|
279
|
+
const started = Date.now();
|
|
280
|
+
const results = [];
|
|
281
|
+
for (const c of cases) {
|
|
282
|
+
const result = await runCase(c, outDir);
|
|
283
|
+
results.push(result);
|
|
284
|
+
console.log(result.line);
|
|
285
|
+
if (result.detail) console.log(result.detail);
|
|
286
|
+
appendRow(outDir, result.row);
|
|
287
|
+
}
|
|
288
|
+
console.log(summary(results, Math.round((Date.now() - started) / 1000)));
|
|
289
|
+
process.exit(results.some((r) => !r.pass) ? 1 : 0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (require.main === module) main();
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
CASES,
|
|
296
|
+
FIXTURE,
|
|
297
|
+
parseArgs,
|
|
298
|
+
selectCases,
|
|
299
|
+
parseTally,
|
|
300
|
+
postCount,
|
|
301
|
+
judgePaid,
|
|
302
|
+
judgeStranger,
|
|
303
|
+
fixtureFetch,
|
|
304
|
+
runOfflineCheck,
|
|
305
|
+
saveFailedOutput,
|
|
306
|
+
summary,
|
|
307
|
+
};
|
package/scripts/det/ytnotes
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
# No captions: if mlx_whisper is installed (uv tool install mlx-whisper, Apple
|
|
5
5
|
# silicon), the audio is transcribed on this machine instead. Otherwise the
|
|
6
6
|
# script prints the paid command. ATRIS_YTNOTES_LOCAL_TRANSCRIBE=0 turns it off.
|
|
7
|
-
# Engines: auto (default:
|
|
7
|
+
# Engines: auto (default: the team's notes writer from the roster, then the
|
|
8
|
+
# fastest installed writer that answers: Gemini Flash through agy, then Haiku,
|
|
9
|
+
# Codex, Cursor),
|
|
8
10
|
# gemini, haiku, atris-fast, grok, codex, cursor. ATRIS_YTNOTES_ENGINE sets the default.
|
|
9
11
|
# Outputs: $WORK/yt_${ID}.clean.txt (transcript), $WORK/yt_${ID}.md (notes).
|
|
10
12
|
set -euo pipefail
|
|
@@ -39,6 +41,30 @@ cd "$WORK"
|
|
|
39
41
|
# #t= timestamp is stripped so leftover yt_<id> files still match. When VTT is
|
|
40
42
|
# gone, leftover yt_<id>.clean.txt from a prior notes run is still a keep.
|
|
41
43
|
# Another video's leftover clean.txt is not invent-keep.
|
|
44
|
+
if ! command -v yt-dlp >/dev/null 2>&1; then
|
|
45
|
+
echo "This computer is missing yt-dlp, the free tool that reads YouTube captions." >&2
|
|
46
|
+
echo "Install it, then run the same command again:" >&2
|
|
47
|
+
echo " Mac: brew install yt-dlp Any computer: pipx install yt-dlp" >&2
|
|
48
|
+
exit 2
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# YouTube blocks old yt-dlp copies, and the failure looks like "no captions".
|
|
52
|
+
# Warn when the version date (YYYY.MM.DD, maybe .N) is too old; keep going.
|
|
53
|
+
# A version that does not parse stays silent.
|
|
54
|
+
YTDLP_VERSION=$(yt-dlp --version 2>/dev/null) || true
|
|
55
|
+
YTDLP_AGE=$(perl -MTime::Local -e '
|
|
56
|
+
my ($v, $max) = @ARGV;
|
|
57
|
+
exit 0 unless $max =~ /\A\d+\z/;
|
|
58
|
+
my ($y, $m, $d) = $v =~ /\A\s*(\d{4})\.(\d{1,2})\.(\d{1,2})(?:\.\d+)?\s*\z/ or exit 0;
|
|
59
|
+
my $t = eval { timelocal(0, 0, 12, $d, $m - 1, $y) };
|
|
60
|
+
exit 0 unless defined $t;
|
|
61
|
+
my $age = int((time - $t) / 86400);
|
|
62
|
+
print $age if $age > $max;
|
|
63
|
+
' "$YTDLP_VERSION" "${ATRIS_YTNOTES_YTDLP_MAX_AGE_DAYS:-60}" 2>/dev/null) || true
|
|
64
|
+
if [ -n "$YTDLP_AGE" ]; then
|
|
65
|
+
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
|
|
66
|
+
fi
|
|
67
|
+
|
|
42
68
|
existing=""
|
|
43
69
|
for name in yt_*.en-orig.vtt yt_*.en.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
|
|
44
70
|
[ -e "$name" ] || continue
|
|
@@ -262,7 +288,7 @@ $TRANSCRIPT"
|
|
|
262
288
|
run_with_timeout() {
|
|
263
289
|
local seconds="$1"
|
|
264
290
|
shift
|
|
265
|
-
perl -e '$seconds = shift @ARGV; alarm $seconds; exec @ARGV' "$seconds" "$@"
|
|
291
|
+
perl -e '$seconds = shift @ARGV; alarm $seconds; exec @ARGV or do { print STDERR "$ARGV[0]: not installed\n"; exit 127 }' "$seconds" "$@"
|
|
266
292
|
}
|
|
267
293
|
|
|
268
294
|
run_atris_fast() {
|
|
@@ -347,27 +373,97 @@ run_grok() {
|
|
|
347
373
|
}
|
|
348
374
|
|
|
349
375
|
run_haiku() {
|
|
350
|
-
run_with_timeout 240 claude -p "$PROMPT" --model claude-haiku-4-5
|
|
376
|
+
run_with_timeout 240 claude -p "$PROMPT" --model "${1:-claude-haiku-4-5}"
|
|
351
377
|
}
|
|
352
378
|
|
|
353
379
|
# Gemini Flash through the Antigravity CLI (agy). On an 85-minute transcript it
|
|
354
380
|
# writes notes in about 8s; Haiku takes 30-40s. The old gemini CLI is retired.
|
|
381
|
+
# Low effort is the floor for notes: high effort is slower and not sharper here.
|
|
355
382
|
run_gemini() {
|
|
356
383
|
command -v agy >/dev/null 2>&1 || { echo "agy is not installed" >&2; return 127; }
|
|
357
|
-
run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-gemini-3.8-flash}" \
|
|
358
|
-
--effort low --print-timeout 110s </dev/null
|
|
384
|
+
run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-${1:-gemini-3.8-flash}}" \
|
|
385
|
+
--effort "${2:-low}" --print-timeout 110s </dev/null
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# Fastest installed writer that answers: Gemini, Haiku, Codex, Cursor.
|
|
389
|
+
# Entries are cmd|model|effort|label; an empty model or effort means the default.
|
|
390
|
+
# Buffered so a failed or empty answer never reaches the notes file.
|
|
391
|
+
AUTO_WRITERS="agy|||Gemini claude|||Haiku codex|||Codex cursor-agent|||Cursor"
|
|
392
|
+
|
|
393
|
+
# The team's "notes" writer, from `atris engine resolve notes --json`, run from
|
|
394
|
+
# the caller's project with this same package. It only reorders: any failure,
|
|
395
|
+
# a slow answer (2s), or a writer that is not installed falls back to the order
|
|
396
|
+
# above. ATRIS_YTNOTES_ROSTER=0 skips it.
|
|
397
|
+
resolve_roster_writers() {
|
|
398
|
+
[ "${ATRIS_YTNOTES_ROSTER:-1}" = "0" ] && return 0
|
|
399
|
+
local json=""
|
|
400
|
+
if [ -n "${ATRIS_YTNOTES_RESOLVE_CMD:-}" ]; then
|
|
401
|
+
json=$(cd "$CALLER_CWD" && run_with_timeout 2 sh -c "$ATRIS_YTNOTES_RESOLVE_CMD" 2>/dev/null) || return 0
|
|
402
|
+
else
|
|
403
|
+
[ -f "$SCRIPT_DIR/../../bin/atris.js" ] || return 0
|
|
404
|
+
json=$(cd "$CALLER_CWD" && ATRIS_AUTO_UPDATE=off run_with_timeout 2 node "$SCRIPT_DIR/../../bin/atris.js" engine resolve notes --json 2>/dev/null) || return 0
|
|
405
|
+
fi
|
|
406
|
+
printf '%s' "$json" | node -e '
|
|
407
|
+
const s = require("fs").readFileSync(0, "utf8");
|
|
408
|
+
const d = JSON.parse(s.slice(s.indexOf("{"), s.lastIndexOf("}") + 1));
|
|
409
|
+
const cmds = { agy: "agy", gemini: "agy", haiku: "claude", claude: "claude", codex: "codex", cursor: "cursor-agent", "cursor-agent": "cursor-agent" };
|
|
410
|
+
const clean = (v) => (v == null ? "" : String(v).replace(/[^A-Za-z0-9._:\/-]/g, ""));
|
|
411
|
+
const label = (cmd, model) => ({ agy: "Gemini", codex: "Codex", "cursor-agent": "Cursor" }[cmd]
|
|
412
|
+
|| (/haiku/i.test(model || "haiku") ? "Haiku" : "Claude"));
|
|
413
|
+
const out = [];
|
|
414
|
+
for (const w of [d, ...(Array.isArray(d.backup) ? d.backup : [])]) {
|
|
415
|
+
const cmd = cmds[String((w && w.engine) || "").toLowerCase()];
|
|
416
|
+
if (!cmd) continue;
|
|
417
|
+
const model = clean(w.model);
|
|
418
|
+
out.push([cmd, model, clean(w.effort), label(cmd, model)].join("|"));
|
|
419
|
+
}
|
|
420
|
+
process.stdout.write(out.join(" "));
|
|
421
|
+
' 2>/dev/null || true
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
# Roster writers first, then the rest of the built-in order, one entry per cmd.
|
|
425
|
+
writer_list() {
|
|
426
|
+
local entry cmd seen=" " list=""
|
|
427
|
+
for entry in $(resolve_roster_writers) $AUTO_WRITERS; do
|
|
428
|
+
cmd="${entry%%|*}"
|
|
429
|
+
case "$seen" in *" $cmd "*) continue ;; esac
|
|
430
|
+
seen="$seen$cmd "
|
|
431
|
+
list="$list $entry"
|
|
432
|
+
done
|
|
433
|
+
printf '%s' "$list"
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
run_writer() {
|
|
437
|
+
local model="${2:-}"
|
|
438
|
+
case "$1" in
|
|
439
|
+
agy) run_gemini "$model" "${3:-}" ;;
|
|
440
|
+
claude) run_haiku "$model" ;;
|
|
441
|
+
codex) run_with_timeout 240 codex exec ${model:+-m "$model"} --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
|
|
442
|
+
cursor-agent) run_with_timeout 240 cursor-agent --trust ${model:+--model "$model"} -p "$PROMPT" ;;
|
|
443
|
+
esac
|
|
359
444
|
}
|
|
360
445
|
|
|
361
|
-
# Fastest writer that answers: Gemini, then Haiku. Buffered so a failed or
|
|
362
|
-
# empty Gemini answer never reaches the notes file.
|
|
363
446
|
run_auto() {
|
|
364
|
-
local out=""
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
447
|
+
local entry cmd model effort label out tried=""
|
|
448
|
+
for entry in $(writer_list); do
|
|
449
|
+
IFS='|' read -r cmd model effort label <<< "$entry"
|
|
450
|
+
command -v "$cmd" >/dev/null 2>&1 || continue
|
|
451
|
+
if [ -n "$tried" ]; then
|
|
452
|
+
echo "$tried did not answer; writing the notes with $label instead." >&2
|
|
453
|
+
fi
|
|
454
|
+
if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
|
|
455
|
+
printf '%s\n' "$out"
|
|
456
|
+
echo "notes by $label" >&2
|
|
457
|
+
return 0
|
|
458
|
+
fi
|
|
459
|
+
tried="$label"
|
|
460
|
+
done
|
|
461
|
+
if [ -n "$tried" ]; then
|
|
462
|
+
echo "$tried did not answer, and no other AI writer is installed." >&2
|
|
463
|
+
else
|
|
464
|
+
echo "No AI writer is installed on this computer. Install one of: Claude Code (claude), Antigravity (agy), Codex (codex), Cursor (cursor-agent)." >&2
|
|
368
465
|
fi
|
|
369
|
-
|
|
370
|
-
run_haiku
|
|
466
|
+
return 3
|
|
371
467
|
}
|
|
372
468
|
|
|
373
469
|
{
|
|
@@ -385,10 +481,20 @@ run_auto() {
|
|
|
385
481
|
run_gemini
|
|
386
482
|
;;
|
|
387
483
|
grok) run_grok ;;
|
|
388
|
-
codex)
|
|
389
|
-
cursor)
|
|
484
|
+
codex) run_writer codex ;;
|
|
485
|
+
cursor) run_writer cursor-agent ;;
|
|
390
486
|
*) echo "unknown engine: $ENGINE (auto|gemini|haiku|atris-fast|grok|codex|cursor)" >&2; exit 1 ;;
|
|
391
487
|
esac
|
|
392
|
-
} | perl -0pe 's/\A.*?(?=# )//s'
|
|
488
|
+
} | perl -0pe 's/\A.*?(?=# )//s' > "yt_${ID}.md" || true
|
|
489
|
+
|
|
490
|
+
if ! grep -q '[[:alnum:]]' "yt_${ID}.md"; then
|
|
491
|
+
rm -f "yt_${ID}.md"
|
|
492
|
+
echo "No notes were written. The transcript is saved at $WORK/yt_${ID}.clean.txt" >&2
|
|
493
|
+
echo "Paid notes, 5 credits, refunded if it fails: atris youtube process \"$URL\"" >&2
|
|
494
|
+
exit 3
|
|
495
|
+
fi
|
|
496
|
+
# auto names its writer above; a pinned engine is named here.
|
|
497
|
+
[ "$ENGINE" = "auto" ] || echo "notes by $ENGINE" >&2
|
|
498
|
+
cat "yt_${ID}.md"
|
|
393
499
|
|
|
394
500
|
node "$SCRIPT_DIR/ytquote-repair.js" "yt_${ID}.md" "yt_${ID}.clean.txt"
|