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.
- package/atris/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +96 -0
- package/bin/atris.js +11 -0
- package/commands/buy.js +242 -0
- package/commands/member.js +35 -6
- package/commands/mission.js +4 -1
- package/commands/task.js +7 -0
- package/commands/transactions.js +115 -0
- package/commands/youtube.js +22 -0
- package/lib/commerce.js +75 -0
- package/lib/engine-registry.js +17 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +15 -1
- package/lib/member-scaffold.js +2 -0
- package/lib/member-type.js +27 -0
- package/lib/runner-command.js +37 -2
- package/package.json +1 -1
- package/scripts/det/ytnotes +73 -13
- package/scripts/det/ytrail-bench.js +214 -0
- package/scripts/det/ytrail-eval.js +128 -56
|
@@ -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 };
|
package/commands/youtube.js
CHANGED
|
@@ -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
|
};
|
package/lib/commerce.js
ADDED
|
@@ -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 };
|
package/lib/engine-registry.js
CHANGED
|
@@ -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,
|
package/lib/known-commands.js
CHANGED
|
@@ -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
|
package/lib/member-engine.js
CHANGED
|
@@ -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);
|
package/lib/member-scaffold.js
CHANGED
|
@@ -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 };
|
package/lib/runner-command.js
CHANGED
|
@@ -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
|
-
|
|
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
package/scripts/det/ytnotes
CHANGED
|
@@ -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:
|
|
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,
|
|
362
|
-
# empty
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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)
|
|
389
|
-
cursor)
|
|
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'
|
|
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"
|