atris 3.62.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.
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ // atris transactions: every order, fee, trade, and wallet send, newest first.
4
+ // Read only. Backend: GET /api/transactions.
5
+
6
+ const { apiRequestJson } = require('../utils/api');
7
+ const { loadCredentials } = require('../utils/auth');
8
+ const { NOT_LOGGED_IN, tokenFrom, oneLine, printResult } = require('../lib/developer-api');
9
+ const { parseFlags, formatCents, errorFrom } = require('../lib/commerce');
10
+
11
+ const TX_KINDS = ['ticket', 'shop', 'flight', 'test_purchase', 'fee', 'trade', 'wallet_send'];
12
+
13
+ function showTransactionsHelp(log = console.log) {
14
+ log(`usage: atris transactions [--since YYYY-MM-DD] [--kind k[,k]] [--limit n] [--include-quotes] [--json]
15
+
16
+ list what you bought, paid in fees, traded, or sent, newest first. read only.
17
+
18
+ --since only rows on or after this date, like 2026-09-01
19
+ --kind ${TX_KINDS.join(', ')}
20
+ --limit 1 to 200 (default 50)
21
+ --include-quotes also list quotes nobody approved`);
22
+ }
23
+
24
+ function clip(text, width) {
25
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
26
+ return s.length > width ? `${s.slice(0, width - 1)}…` : s;
27
+ }
28
+
29
+ function whenText(value) {
30
+ const s = String(value || '');
31
+ const m = s.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/);
32
+ return m ? `${m[1]} ${m[2]}` : clip(s, 16);
33
+ }
34
+
35
+ function renderTransactions(data = {}) {
36
+ const rows = Array.isArray(data.transactions) ? data.transactions : [];
37
+ const lines = [];
38
+ if (!rows.length) lines.push('No transactions yet.');
39
+ else {
40
+ const table = rows.map((r) => [
41
+ whenText(r.when),
42
+ clip(r.kind, 12),
43
+ clip(r.what, 40),
44
+ formatCents(r.amount_cents, r.currency),
45
+ clip(r.state, 16),
46
+ ]);
47
+ const head = ['WHEN', 'KIND', 'WHAT', 'AMOUNT', 'STATE'];
48
+ const widths = head.map((h, i) => Math.max(h.length, ...table.map((row) => row[i].length)));
49
+ const fmt = (row) => row.map((cell, i) => (i === 3 ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
50
+ .join(' ').trimEnd();
51
+ lines.push(fmt(head));
52
+ for (const row of table) lines.push(fmt(row));
53
+ }
54
+ const down = Object.entries(data.sources || {}).filter(([, v]) => v !== 'ok').map(([k]) => k);
55
+ if (down.length) lines.push('', `Partial list: could not load ${down.join(', ')}.`);
56
+ return lines.join('\n');
57
+ }
58
+
59
+ function buildQuery(flags) {
60
+ const params = new URLSearchParams();
61
+ if (flags.since != null) {
62
+ const since = String(flags.since).trim();
63
+ if (!/^\d{4}-\d{2}-\d{2}/.test(since)) return { ok: false, error: '--since must be a date like 2026-09-01' };
64
+ params.set('since', since);
65
+ }
66
+ const rawKind = flags.kind != null ? flags.kind : flags.kinds;
67
+ if (rawKind != null) {
68
+ const kinds = String(rawKind).split(',').map((k) => k.trim().toLowerCase()).filter(Boolean);
69
+ const bad = kinds.filter((k) => !TX_KINDS.includes(k));
70
+ if (bad.length || !kinds.length) return { ok: false, error: `--kind must be one of ${TX_KINDS.join(', ')}` };
71
+ params.set('kinds', kinds.join(','));
72
+ }
73
+ if (flags.limit != null) {
74
+ const limit = Number(flags.limit);
75
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) return { ok: false, error: '--limit must be a number from 1 to 200' };
76
+ params.set('limit', String(limit));
77
+ }
78
+ if (flags.include_quotes === true) params.set('include_quotes', 'true');
79
+ const qs = params.toString();
80
+ return { ok: true, path: qs ? `/transactions?${qs}` : '/transactions' };
81
+ }
82
+
83
+ async function transactionsCommand(args = [], deps = {}) {
84
+ const io = { log: deps.log || console.log, err: deps.err || console.error };
85
+ const { flags, pos } = parseFlags(args);
86
+ if (flags.help || pos[0] === 'help') {
87
+ showTransactionsHelp(io.log);
88
+ return 0;
89
+ }
90
+ const json = flags.json === true;
91
+ const fail = (error, status) => {
92
+ printResult({ json, ok: false, error, payload: { ok: false, error, ...(status ? { status } : {}) } }, io);
93
+ return 1;
94
+ };
95
+
96
+ const query = buildQuery(flags);
97
+ if (!query.ok) return fail(query.error);
98
+
99
+ const load = deps.loadCredentials || loadCredentials;
100
+ const request = deps.apiRequestJson || apiRequestJson;
101
+ const token = tokenFrom(load());
102
+ if (!token) return fail(NOT_LOGGED_IN);
103
+
104
+ try {
105
+ const result = await request(query.path, { method: 'GET', token });
106
+ if (!result || !result.ok) return fail(errorFrom(result), result && result.status);
107
+ const data = result.data || {};
108
+ printResult({ json, ok: true, text: renderTransactions(data), payload: { ok: true, ...data } }, io);
109
+ return 0;
110
+ } catch (error) {
111
+ return fail(oneLine(error && error.message));
112
+ }
113
+ }
114
+
115
+ module.exports = { transactionsCommand };
@@ -40,6 +40,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
40
40
  output(` ${commandName} watch list`);
41
41
  output(` ${commandName} watch remove <number>`);
42
42
  output(` ${commandName} watch tick`);
43
+ output(` ${commandName} bench [--quick] [--case <name>]`);
43
44
  output(` ${commandName} <youtube-url> [options]`);
44
45
  output('');
45
46
  output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links; rich free search writes one apply and a failing keep/revert pack; thin hands off to teach');
@@ -50,6 +51,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
50
51
  output('process = 5 credits cloud knowledge (needs a filled Apply); rich process writes one apply and a failing keep/revert pack');
51
52
  output('digest = one decision page from this week\'s video briefs; rich digest writes one apply and a failing keep/revert pack');
52
53
  output('watch = subscribed channels turn into briefs without a human; add hands off to tick; tick hands off to teach when it briefed');
54
+ output('bench = run notes on real videos, uncached, and grade what a user gets; --quick runs the short video and the stranger checks');
53
55
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
54
56
  output('Falls back to cloud video processing when local captions are unavailable.');
55
57
  output('');
@@ -3851,6 +3853,20 @@ async function runYoutubeTeach(args = [], deps = {}) {
3851
3853
  return applyCode;
3852
3854
  }
3853
3855
 
3856
+ // Bench the notes feature end to end. The script prints its own lines.
3857
+ function runYoutubeBench(argv, deps = {}) {
3858
+ const run = (deps.spawnSync || spawnSync)(
3859
+ process.execPath,
3860
+ [path.join(__dirname, '..', 'scripts', 'det', 'ytrail-bench.js'), ...argv],
3861
+ { stdio: 'inherit' },
3862
+ );
3863
+ if (run.error) {
3864
+ (deps.output || console.error)(`bench did not start: ${run.error.message}`);
3865
+ return 1;
3866
+ }
3867
+ return typeof run.status === 'number' ? run.status : 1;
3868
+ }
3869
+
3854
3870
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
3855
3871
  const output = deps.output || ((line = '') => console.log(line));
3856
3872
  if (argv[0] === 'search') {
@@ -3880,6 +3896,11 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
3880
3896
  if (!deps.output && !deps.runner) process.exit(code);
3881
3897
  return code;
3882
3898
  }
3899
+ if (argv[0] === 'bench') {
3900
+ const code = runYoutubeBench(argv.slice(1), deps);
3901
+ if (!deps.output && !deps.spawnSync) process.exit(code);
3902
+ return code;
3903
+ }
3883
3904
  if (argv[0] === 'watch') {
3884
3905
  const code = await watchCommand(argv.slice(1), { ...deps, output });
3885
3906
  if (!deps.output && !deps.fetcher && !deps.runner && !deps.briefFiler) process.exit(code);
@@ -3978,5 +3999,6 @@ module.exports = {
3978
3999
  searchApplyRel,
3979
4000
  firstRichWatchLesson,
3980
4001
  fileTeachExperiment,
4002
+ runYoutubeBench,
3981
4003
  youtubeCommand,
3982
4004
  };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // Shared helpers for `atris buy` and `atris transactions`.
4
+ // Backend: /api/commerce/* and /api/transactions (commerce_router.py).
5
+
6
+ const { ROLLOUT_MESSAGE, oneLine } = require('./developer-api');
7
+
8
+ // Flags whose values the backend reads as numbers.
9
+ const NUMERIC_KEYS = new Set(['quantity', 'dollars', 'limit_price', 'min_each_usd', 'max_each_usd', 'limit']);
10
+
11
+ function snake(key) {
12
+ return String(key).replace(/-/g, '_');
13
+ }
14
+
15
+ function coerce(key, value) {
16
+ if (value === true) return true;
17
+ const text = String(value);
18
+ if (/^[[{]/.test(text.trim())) {
19
+ try { return JSON.parse(text); } catch { /* keep as text */ }
20
+ }
21
+ if (NUMERIC_KEYS.has(key) && text.trim() !== '' && Number.isFinite(Number(text))) return Number(text);
22
+ return text;
23
+ }
24
+
25
+ // --event-url x --quantity 2 --accept-extras -> { event_url: 'x', quantity: 2, accept_extras: true }
26
+ function parseFlags(argv = []) {
27
+ const flags = {};
28
+ const pos = [];
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ const arg = String(argv[i]);
31
+ if (arg === '-h') { flags.help = true; continue; }
32
+ if (arg.startsWith('--')) {
33
+ const eq = arg.indexOf('=');
34
+ const rawKey = eq > 0 ? arg.slice(2, eq) : arg.slice(2);
35
+ const key = snake(rawKey);
36
+ if (eq > 0) { flags[key] = coerce(key, arg.slice(eq + 1)); continue; }
37
+ const next = argv[i + 1];
38
+ if (next != null && !String(next).startsWith('--')) { flags[key] = coerce(key, next); i += 1; } else flags[key] = true;
39
+ continue;
40
+ }
41
+ pos.push(arg);
42
+ }
43
+ return { flags, pos };
44
+ }
45
+
46
+ function formatCents(cents, currency = 'usd') {
47
+ if (cents == null || cents === '' || !Number.isFinite(Number(cents))) return '-';
48
+ const n = Number(cents) / 100;
49
+ const sign = n < 0 ? '-' : '';
50
+ const body = `$${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
51
+ const code = String(currency || 'usd').toLowerCase();
52
+ return code === 'usd' ? `${sign}${body}` : `${sign}${body} ${code.toUpperCase()}`;
53
+ }
54
+
55
+ // "2026-09-29T18:04:00Z" -> "in 30 min" / "expired"
56
+ function untilText(iso, now = Date.now()) {
57
+ const at = Date.parse(iso);
58
+ if (!Number.isFinite(at)) return '';
59
+ const mins = Math.round((at - now) / 60000);
60
+ if (mins <= 0) return 'expired';
61
+ if (mins < 90) return `in ${mins} min`;
62
+ return `in ${Math.round(mins / 60)} h`;
63
+ }
64
+
65
+ // The backend's error envelope is {"error": {"message", "type"}, "atris": {...}}.
66
+ // A bare 404 with no envelope means the route itself is not deployed yet.
67
+ function errorFrom(result) {
68
+ const data = result && result.data;
69
+ const envelope = data && typeof data === 'object' && data.error && typeof data.error === 'object';
70
+ if (result && Number(result.status) === 404 && !envelope) return ROLLOUT_MESSAGE;
71
+ if (envelope) return oneLine(data.error.message || data.error);
72
+ return oneLine(result && result.error);
73
+ }
74
+
75
+ module.exports = { parseFlags, formatCents, untilText, errorFrom };
@@ -860,6 +860,22 @@ function resolveEngineModelWords(text) {
860
860
  throw new Error(`"${clean}" is not an engine or a model atris knows`);
861
861
  }
862
862
 
863
+ // The engine a model name belongs to, read the same way a roster line is,
864
+ // or '' when the name is not one atris can place. The claude cli's
865
+ // own aliases ("default", "opusplan", with or without [1m]) count as claude.
866
+ function modelEngineName(model) {
867
+ const base = String(model || '').trim().replace(CLAUDE_LONG_CONTEXT, '').trim();
868
+ try {
869
+ return resolveEngineModelWords(base).engine || '';
870
+ } catch {
871
+ return CLAUDE_MODEL_ALIASES.includes(base.toLowerCase()) ? 'claude' : '';
872
+ }
873
+ }
874
+
875
+ function isClaudeFamilyModel(model) {
876
+ return CLAUDE_FAMILY_ENGINES.includes(modelEngineName(model));
877
+ }
878
+
863
879
  // What an engine can do by its seed, used to check hand-written lines before
864
880
  // any registry is read.
865
881
  function engineCanTake(engineId, role) {
@@ -2107,6 +2123,7 @@ module.exports = {
2107
2123
  setRosterPick,
2108
2124
  confirmRoster,
2109
2125
  normalizeRosterModel,
2126
+ isClaudeFamilyModel,
2110
2127
  rosterModelLabel,
2111
2128
  parseRosterUntil,
2112
2129
  rosterPickExpired,
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'buy', 'transactions', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
@@ -8,6 +8,7 @@
8
8
  // words ("judge: opus 5.5"). The job's pick then comes from the roster.
9
9
 
10
10
  const fs = require('fs');
11
+ const { memberRefusalReason } = require('./member-type');
11
12
  const path = require('path');
12
13
  const {
13
14
  ENGINE_JOBS,
@@ -204,6 +205,7 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
204
205
  const name = String(memberName || '').trim();
205
206
  if (!name || name.includes('/') || name.includes('\\')) return null;
206
207
  try {
208
+ if (!memberCardRunnable(root, name)) return null;
207
209
  const picked = resolveEngineForMember(name, root, options);
208
210
  if (!picked.found && options.requireMember !== false) return null;
209
211
  return picked.roster_backed && picked.engine ? picked : null;
@@ -212,6 +214,18 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
212
214
  }
213
215
  }
214
216
 
217
+ // A person or odd-typed card never runs on an engine.
218
+ function memberCardRunnable(root, name) {
219
+ const card = findMemberCard(root, name);
220
+ if (!card) return true;
221
+ try {
222
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(fs.readFileSync(card, 'utf8'));
223
+ const match = block ? /^type:\s*(.*)$/m.exec(block[1]) : null;
224
+ const value = match ? match[1].trim().replace(/^["']|["']$/g, '') : '';
225
+ return !memberRefusalReason(name, value ? { type: value } : {});
226
+ } catch { return true; }
227
+ }
228
+
215
229
  // A parked card stays on disk and still runs by name; this view (the roster
216
230
  // team block and the boot lineup) just leaves it out.
217
231
  function memberCardParked(root, name) {
@@ -232,7 +246,7 @@ function teamRosterView(root = process.cwd(), options = {}) {
232
246
  else if (layer.scope === 'project') warnings.push({ file: line.file, line: line.line, text: line.text, message: `there is no team member named ${line.member}, so the line does nothing` });
233
247
  }
234
248
  }
235
- for (const name of [...names]) if (memberCardParked(root, name)) names.delete(name);
249
+ for (const name of [...names]) if (memberCardParked(root, name) || !memberCardRunnable(root, name)) names.delete(name);
236
250
  const rows = [...names].sort().map((name) => {
237
251
  const picked = resolveEngineForMember(name, root, { ...options, rosterState: state });
238
252
  const line = teamLineFor(state, name);
@@ -26,6 +26,8 @@ name: ${name}
26
26
  role: ${title}
27
27
  description: ${mission}
28
28
  version: 1.0.0
29
+ # ai or human; a human member is a person and Atris never acts as them
30
+ type: ai
29
31
 
30
32
  skills: []
31
33
 
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ // MEMBER.md `type:` says who a member is. Missing means ai and `agent` is an
4
+ // alias for ai. `human` is a person: Atris can prepare work for them but never
5
+ // acts as them. Any other value keeps the file readable but is never run.
6
+
7
+ function memberTypeOf(frontmatter) {
8
+ const raw = frontmatter && frontmatter.type;
9
+ const value = typeof raw === 'string' ? raw.trim() : '';
10
+ if (!value) return { type: 'ai', raw: '', runnable: true };
11
+ const lowered = value.toLowerCase();
12
+ if (lowered === 'ai' || lowered === 'agent') return { type: 'ai', raw: value, runnable: true };
13
+ if (lowered === 'human') return { type: 'human', raw: value, runnable: false };
14
+ return { type: 'other', raw: value, runnable: false };
15
+ }
16
+
17
+ // Plain sentence for why a member will not run; '' when it can run.
18
+ function memberRefusalReason(name, frontmatter) {
19
+ const info = memberTypeOf(frontmatter);
20
+ if (info.runnable) return '';
21
+ if (info.type === 'human') {
22
+ return `${name} is a person, not an AI teammate; Atris can prepare work for them but cannot act as them`;
23
+ }
24
+ return `${name} has type '${info.raw}', which is neither ai nor human, so Atris won't run it`;
25
+ }
26
+
27
+ module.exports = { memberTypeOf, memberRefusalReason };
@@ -38,10 +38,12 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
38
38
  efforts: CLAUDE_EFFORTS,
39
39
  }),
40
40
  // No -m or effort unless the roster pins one: codex then reads its own
41
- // ~/.codex/config.toml, which is what the roster view reports.
41
+ // ~/.codex/config.toml, which is what the roster view reports. Codex runs
42
+ // only OpenAI models, so a claude model name never reaches it.
42
43
  codex: Object.freeze({
43
44
  bin: 'codex',
44
45
  model: '',
46
+ ownModelsOnly: true,
45
47
  commandTemplate: '{bin} exec {pinnedModelFlag} {pinnedEffortFlag} {prompt}',
46
48
  effortFlag: '-c model_reasoning_effort=',
47
49
  efforts: Object.freeze(['low', 'medium', 'high', 'xhigh']),
@@ -211,6 +213,19 @@ function resolvePinnedRunnerModel(mission) {
211
213
  return explicit || runnerProfileValue('model');
212
214
  }
213
215
 
216
+ // The model an engine is launched with. An engine that runs only its own
217
+ // vendor's models drops a claude model name (a mission's stored default, a
218
+ // claude env pin) and rides its own default instead.
219
+ function runnerModelFor(engineId, model) {
220
+ const value = String(model || '').trim();
221
+ const name = String(engineId || '').trim().toLowerCase();
222
+ const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
223
+ if (!value || !def || !def.ownModelsOnly) return value;
224
+ // Required here, not at the top: engine-registry requires this file.
225
+ const { isClaudeFamilyModel } = require('./engine-registry');
226
+ return isClaudeFamilyModel(value) ? '' : value;
227
+ }
228
+
214
229
  function resolveClaudeRunnerBin() {
215
230
  const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
216
231
  if (env) return env;
@@ -265,6 +280,14 @@ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model
265
280
  // The pinned flags take their leading space with them, so an unpinned
266
281
  // launch reads exactly like the template without the placeholders.
267
282
  template = template.replace(/\s*\{pinnedModelFlag\}/g, pinnedModel ? ` --model ${shellWord(pinnedModel)}` : '');
283
+ // A model dropped for an engine that cannot run it leaves no gap either,
284
+ // and a literal "--model {model}", "-m{model}" or a quoted "{model}" goes
285
+ // with it, quotes and all.
286
+ if (!model) {
287
+ template = template
288
+ .replace(/\s*\{modelFlag\}/g, '')
289
+ .replace(/\s*(?:--model|-m)(?:\s+|=)?(["']?)\{model\}\1/g, '');
290
+ }
268
291
  // Checked even when the template has no slot, so an engine that cannot
269
292
  // take effort refuses instead of running at its default.
270
293
  const effortFlag = runnerEffortFlag(effort);
@@ -299,7 +322,18 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
299
322
  const resolved = resolveClaudeRunnerModel({ model });
300
323
  const template = resolveClaudeRunnerCommandTemplate();
301
324
  if (template) {
302
- return renderRunnerCommandTemplate(template, { promptFile, allowedTools, model: resolved, pinnedModel: resolvePinnedRunnerModel({ model }), effort });
325
+ // Both the profile and the program that actually runs count: a runner
326
+ // bin named codex (or a path ending in it) is codex under any profile.
327
+ const profile = resolveRunnerProfileName();
328
+ const binName = String(resolveClaudeRunnerBin()).split(/[\\/]/).pop();
329
+ const modelFor = (value) => runnerModelFor(binName, runnerModelFor(profile, value));
330
+ return renderRunnerCommandTemplate(template, {
331
+ promptFile,
332
+ allowedTools,
333
+ model: modelFor(resolved),
334
+ pinnedModel: modelFor(resolvePinnedRunnerModel({ model })),
335
+ effort,
336
+ });
303
337
  }
304
338
  const safePath = String(promptFile).replace(/'/g, "'\\''");
305
339
  let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')" --model ${shellWord(resolved)}`;
@@ -339,6 +373,7 @@ module.exports = {
339
373
  resolveRunnerProfile,
340
374
  resolveClaudeRunnerModel,
341
375
  resolvePinnedRunnerModel,
376
+ runnerModelFor,
342
377
  resolveClaudeRunnerBin,
343
378
  resolveClaudeRunnerCommandTemplate,
344
379
  buildRunnerAvailabilityCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.62.0",
3
+ "version": "3.63.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -4,7 +4,8 @@
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: fastest installed writer that answers: Gemini
8
+ # Flash through agy, then Haiku, Codex, Cursor),
8
9
  # gemini, haiku, atris-fast, grok, codex, cursor. ATRIS_YTNOTES_ENGINE sets the default.
9
10
  # Outputs: $WORK/yt_${ID}.clean.txt (transcript), $WORK/yt_${ID}.md (notes).
10
11
  set -euo pipefail
@@ -39,6 +40,30 @@ cd "$WORK"
39
40
  # #t= timestamp is stripped so leftover yt_<id> files still match. When VTT is
40
41
  # gone, leftover yt_<id>.clean.txt from a prior notes run is still a keep.
41
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
+
42
67
  existing=""
43
68
  for name in yt_*.en-orig.vtt yt_*.en.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
44
69
  [ -e "$name" ] || continue
@@ -262,7 +287,7 @@ $TRANSCRIPT"
262
287
  run_with_timeout() {
263
288
  local seconds="$1"
264
289
  shift
265
- 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" "$@"
266
291
  }
267
292
 
268
293
  run_atris_fast() {
@@ -358,16 +383,41 @@ run_gemini() {
358
383
  --effort low --print-timeout 110s </dev/null
359
384
  }
360
385
 
361
- # Fastest writer that answers: Gemini, then Haiku. Buffered so a failed or
362
- # empty Gemini answer never reaches the notes file.
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
+
363
399
  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
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
368
419
  fi
369
- echo "Gemini did not answer; writing the notes with Haiku instead." >&2
370
- run_haiku
420
+ return 3
371
421
  }
372
422
 
373
423
  {
@@ -385,10 +435,20 @@ run_auto() {
385
435
  run_gemini
386
436
  ;;
387
437
  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" ;;
438
+ codex) run_writer codex ;;
439
+ cursor) run_writer cursor-agent ;;
390
440
  *) echo "unknown engine: $ENGINE (auto|gemini|haiku|atris-fast|grok|codex|cursor)" >&2; exit 1 ;;
391
441
  esac
392
- } | perl -0pe 's/\A.*?(?=# )//s' | tee "yt_${ID}.md"
442
+ } | perl -0pe 's/\A.*?(?=# )//s' > "yt_${ID}.md" || true
443
+
444
+ if ! grep -q '[[:alnum:]]' "yt_${ID}.md"; then
445
+ rm -f "yt_${ID}.md"
446
+ echo "No notes were written. The transcript is saved at $WORK/yt_${ID}.clean.txt" >&2
447
+ echo "Paid notes, 5 credits, refunded if it fails: atris youtube process \"$URL\"" >&2
448
+ exit 3
449
+ fi
450
+ # auto names its writer above; a pinned engine is named here.
451
+ [ "$ENGINE" = "auto" ] || echo "notes by $ENGINE" >&2
452
+ cat "yt_${ID}.md"
393
453
 
394
454
  node "$SCRIPT_DIR/ytquote-repair.js" "yt_${ID}.md" "yt_${ID}.clean.txt"