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.
Files changed (72) hide show
  1. package/atris/features/README.md +8 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/commerce/SKILL.md +120 -0
  4. package/atris/skills/x-search/SKILL.md +3 -1
  5. package/ax +4 -3
  6. package/bin/atris.js +22 -2
  7. package/cli/atris_code.py +25 -1
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +242 -0
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +122 -13
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/improve.js +142 -8
  19. package/commands/integrations.js +4 -3
  20. package/commands/loops.js +2 -1
  21. package/commands/member.js +82 -15
  22. package/commands/mission.js +4 -1
  23. package/commands/pack-craft.js +2 -2
  24. package/commands/publish.js +2 -2
  25. package/commands/radar.js +3 -2
  26. package/commands/router.js +2 -2
  27. package/commands/task.js +91 -2
  28. package/commands/transactions.js +115 -0
  29. package/commands/truth.js +2 -2
  30. package/commands/x-search.js +81 -10
  31. package/commands/youtube.js +24 -1
  32. package/lib/autoland.js +3 -2
  33. package/lib/ax-auto-lane.js +2 -2
  34. package/lib/brief-rules.js +11 -0
  35. package/lib/chat-log-scan.js +2 -1
  36. package/lib/ci-runner.js +3 -2
  37. package/lib/codex-flight.js +2 -1
  38. package/lib/commerce.js +90 -0
  39. package/lib/deck-history.js +2 -2
  40. package/lib/deck-review.js +3 -3
  41. package/lib/design-api.js +2 -2
  42. package/lib/engine-registry.js +102 -18
  43. package/lib/first-minute.js +2 -2
  44. package/lib/fleet.js +8 -36
  45. package/lib/git-spawn.js +5 -2
  46. package/lib/ignored-cache.js +51 -0
  47. package/lib/known-commands.js +1 -1
  48. package/lib/manifest.js +2 -2
  49. package/lib/map-refs.js +70 -4
  50. package/lib/member-engine.js +15 -1
  51. package/lib/member-scaffold.js +2 -0
  52. package/lib/member-type.js +27 -0
  53. package/lib/pack-capabilities.js +2 -1
  54. package/lib/permission-grants.js +2 -2
  55. package/lib/pulse.js +5 -5
  56. package/lib/roster-runs.js +13 -2
  57. package/lib/runner-command.js +54 -3
  58. package/lib/sandbox-pids.js +113 -0
  59. package/lib/secret-gateway.js +3 -13
  60. package/lib/state-home.js +106 -0
  61. package/lib/task-db.js +4 -3
  62. package/lib/task-prune.js +462 -0
  63. package/lib/x-post-check.js +497 -0
  64. package/package.json +1 -1
  65. package/scripts/det/data/xsearch-answer.json +24 -0
  66. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  67. package/scripts/det/xsearch-bench.js +307 -0
  68. package/scripts/det/ytnotes +122 -16
  69. package/scripts/det/ytrail-bench.js +215 -0
  70. package/scripts/det/ytrail-eval.js +128 -56
  71. package/utils/auth.js +3 -3
  72. 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
+ };
@@ -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: Gemini Flash through agy, Haiku if that fails),
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
- if out=$(run_gemini 2>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
366
- printf '%s\n' "$out"
367
- return 0
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
- echo "Gemini did not answer; writing the notes with Haiku instead." >&2
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) run_with_timeout 240 codex exec --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
389
- cursor) run_with_timeout 240 cursor-agent --trust -p "$PROMPT" ;;
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' | tee "yt_${ID}.md"
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"