@guenbankhai/tools 0.0.0-stage → 0.1.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/README.md +53 -2
- package/README.vi.md +54 -0
- package/agentmoni/lib/agents/claude/billing.js +13 -0
- package/agentmoni/lib/agents/claude/home.js +4 -0
- package/agentmoni/lib/agents/claude/index.js +181 -0
- package/agentmoni/lib/agents/claude/sources/oauth-usage.js +74 -0
- package/agentmoni/lib/agents/claude/timeline.js +26 -0
- package/agentmoni/lib/agents/codex/billing.js +9 -0
- package/agentmoni/lib/agents/codex/index.js +280 -0
- package/agentmoni/lib/agents/codex/sources/rollout.js +26 -0
- package/agentmoni/lib/agents/codex/timeline.js +22 -0
- package/agentmoni/lib/agents/index.js +13 -0
- package/agentmoni/lib/catalog.js +96 -0
- package/agentmoni/lib/catalog.json +493 -0
- package/agentmoni/lib/collect.js +11 -0
- package/agentmoni/lib/colors.js +35 -0
- package/agentmoni/lib/cost.js +87 -0
- package/agentmoni/lib/format.js +18 -0
- package/agentmoni/lib/graph.js +106 -0
- package/agentmoni/lib/history.js +50 -0
- package/agentmoni/lib/json-fields.js +58 -0
- package/agentmoni/lib/jsonl.js +43 -0
- package/agentmoni/lib/launch.js +118 -0
- package/agentmoni/lib/limits.js +11 -0
- package/agentmoni/lib/links.js +139 -0
- package/agentmoni/lib/list.js +71 -0
- package/agentmoni/lib/log.js +226 -0
- package/agentmoni/lib/monitor.js +58 -0
- package/agentmoni/lib/page.html +591 -0
- package/agentmoni/lib/present.js +184 -0
- package/agentmoni/lib/procs.js +22 -0
- package/agentmoni/lib/quota.js +34 -0
- package/agentmoni/lib/repokey.js +44 -0
- package/agentmoni/lib/share.js +106 -0
- package/agentmoni/lib/state.js +62 -0
- package/agentmoni/lib/terminal.js +143 -0
- package/agentmoni/lib/timeline.js +40 -0
- package/agentmoni/lib/web.js +75 -0
- package/agentmoni/monitor.js +98 -0
- package/package.json +22 -4
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Codex adapter: rollout files under ~/.codex/sessions and archived_sessions.
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { files, scan, cached } = require('../../jsonl.js');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const { launchKind, launchOf, launchCwd, resultJob, resultSessions } = require('../../launch.js');
|
|
7
|
+
const { readBilling } = require('./billing.js');
|
|
8
|
+
const declaration = require('../../catalog.json').agents.codex;
|
|
9
|
+
|
|
10
|
+
const codexHome = () => process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
11
|
+
const WANT = ['"type":"session_meta"', '"type":"turn_context"', '"type":"task_started"', '"type":"task_complete', '"type":"turn_aborted"',
|
|
12
|
+
'"type":"token_usage_record"', '"type":"token_count"', '"type":"user_message"', '"type":"thread_settings_applied"', '"role":"user"'];
|
|
13
|
+
// Tool results are many and large: parse only those that can carry a launch job id.
|
|
14
|
+
const wantLine = l => WANT.some(w => l.includes(w)) || (l.includes('"function_call_output"') && (l.includes('task-') || l.includes('session_id'))) || l.includes('"function_call"') || l.includes('"custom_tool_call"');
|
|
15
|
+
const validUsage = u => u && typeof u === 'object' &&
|
|
16
|
+
['input_tokens', 'output_tokens'].every(k => Number.isSafeInteger(u[k]) && u[k] >= 0) &&
|
|
17
|
+
['cached_input_tokens', 'cache_write_input_tokens', 'reasoning_output_tokens', 'total_tokens'].every(k => u[k] === undefined || (Number.isSafeInteger(u[k]) && u[k] >= 0)) &&
|
|
18
|
+
(u.cached_input_tokens ?? 0) <= u.input_tokens;
|
|
19
|
+
// Injected context ("<...>" tags, the AGENTS.md preamble) is not what the user typed.
|
|
20
|
+
const clip = text => typeof text === 'string' && !/^\s*(<|# AGENTS\.md)/.test(text) ? text.replace(/\s+/g, ' ').trim().slice(0, 80) || null : null;
|
|
21
|
+
const firstText = content => Array.isArray(content) ? content.find(c => typeof c?.text === 'string')?.text : null;
|
|
22
|
+
|
|
23
|
+
function parseRollout(file) {
|
|
24
|
+
const s = { provider: 'codex', id: path.basename(file, '.jsonl'), parentId: null, model: null, effort: null, cwd: null, name: null,
|
|
25
|
+
startedAt: null, lastActivityAt: null, kind: 'main', tokens: null, context: null, turn: null, launches: [], usage: [], billing: 'unknown', source: { file } };
|
|
26
|
+
const records = [], fallback = [];
|
|
27
|
+
let hasRecords = false, previousTotal;
|
|
28
|
+
const seen = new Set(), calls = new Map();
|
|
29
|
+
let historyStart = null, skipping = false, fallbackName = null;
|
|
30
|
+
function addUsage(u, at, entries, costUSD) {
|
|
31
|
+
if (!validUsage(u) || !Number.isFinite(at)) return false;
|
|
32
|
+
const cached = u.cached_input_tokens ?? 0;
|
|
33
|
+
entries.push({ at, model: s.model, tokens: { input: u.input_tokens - cached, cacheRead: cached,
|
|
34
|
+
cacheWrite: u.cache_write_input_tokens ?? null, output: u.output_tokens },
|
|
35
|
+
...(Number.isFinite(costUSD) && costUSD >= 0 ? { costUSD } : {}) });
|
|
36
|
+
s.context = u.input_tokens;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
s.bad = scan(file, wantLine, r => {
|
|
40
|
+
const p = r.payload ?? {};
|
|
41
|
+
if (r.type === 'session_meta') {
|
|
42
|
+
if (s.startedAt != null) return; // a forked rollout repeats its parent meta; the first one is ours
|
|
43
|
+
s.id = p.id ?? p.session_id ?? s.id; s.cwd = typeof p.cwd === 'string' ? p.cwd : null;
|
|
44
|
+
s.originator = p.originator ?? null; s.agentPath = typeof p.agent_path === 'string' ? p.agent_path : null;
|
|
45
|
+
s.hostSource = typeof p.source === 'string' ? p.source : p.source?.subagent ? 'subagent' : null;
|
|
46
|
+
s.parentId = p.parent_thread_id ?? p.source?.subagent?.thread_spawn?.parent_thread_id ?? null;
|
|
47
|
+
historyStart = Number.isSafeInteger(p.subagent_history_start_ordinal) ? p.subagent_history_start_ordinal : null;
|
|
48
|
+
// Forked threads begin with a copy of the parent history; skip it until the fork's own first turn.
|
|
49
|
+
skipping = historyStart != null || !!p.forked_from_id;
|
|
50
|
+
if (s.parentId) s.kind = 'sub';
|
|
51
|
+
else if (s.hostSource === 'exec') s.kind = 'standalone';
|
|
52
|
+
else if (declaration.pluginOrigins.includes(s.originator)) s.kind = 'sub'; // started by the Claude Code Codex plugin; parent is linked later
|
|
53
|
+
const at = Date.parse(p.timestamp ?? r.timestamp);
|
|
54
|
+
if (Number.isFinite(at)) { s.startedAt = at; s.lastActivityAt = at; }
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const isRecord = r.type === 'token_usage_record' || p.type === 'token_usage_record';
|
|
58
|
+
const record = r.payload ?? r;
|
|
59
|
+
const hasThread = isRecord && record.thread_id != null;
|
|
60
|
+
if (hasThread && record.thread_id !== s.id) return;
|
|
61
|
+
if (!hasThread && historyStart != null && (!Number.isSafeInteger(r.ordinal) || r.ordinal < historyStart)) return;
|
|
62
|
+
if (skipping && !hasThread) {
|
|
63
|
+
if (historyStart != null && Number.isSafeInteger(r.ordinal)) { if (r.ordinal >= historyStart) skipping = false; }
|
|
64
|
+
else if (p.type === 'thread_settings_applied' || p.type === 'task_started') skipping = false;
|
|
65
|
+
if (skipping) return;
|
|
66
|
+
}
|
|
67
|
+
const at = Date.parse(r.timestamp);
|
|
68
|
+
if (Number.isFinite(at)) { s.startedAt ??= at; s.lastActivityAt = at; }
|
|
69
|
+
if (r.type === 'turn_context') {
|
|
70
|
+
if (typeof p.model === 'string') s.model = p.model;
|
|
71
|
+
s.effort = p.effort ?? p.reasoning_effort ?? s.effort;
|
|
72
|
+
s.cwd ??= typeof p.cwd === 'string' ? p.cwd : null;
|
|
73
|
+
} else if (isRecord) {
|
|
74
|
+
hasRecords = true;
|
|
75
|
+
const id = record.response_id ?? r.response_id;
|
|
76
|
+
if (typeof id !== 'string' || seen.has(id)) return;
|
|
77
|
+
if (addUsage(record.usage ?? r.usage, at, records, record.costUSD ?? r.costUSD)) seen.add(id);
|
|
78
|
+
} else if (r.type === 'event_msg') {
|
|
79
|
+
if (p.type === 'task_started') s.turn = 'open';
|
|
80
|
+
else if (p.type === 'task_complete' || p.type === 'task_completed') s.turn = 'done';
|
|
81
|
+
else if (p.type === 'turn_aborted') s.turn = 'aborted';
|
|
82
|
+
else if (p.type === 'user_message') s.name ??= clip(p.message);
|
|
83
|
+
else if (p.type === 'token_count') {
|
|
84
|
+
// Other limit ids (e.g. premium) are separate meters.
|
|
85
|
+
const quota = quotaFromRecord(p, at);
|
|
86
|
+
if (quota) s.quota = quota;
|
|
87
|
+
const key = p.info?.total_token_usage?.total_tokens;
|
|
88
|
+
const repeated = key != null && key === previousTotal;
|
|
89
|
+
previousTotal = key;
|
|
90
|
+
if (!repeated) addUsage(p.info?.last_token_usage, at, fallback);
|
|
91
|
+
}
|
|
92
|
+
} else if (r.type === 'response_item') {
|
|
93
|
+
if (p.type === 'message' && p.role === 'user') fallbackName ??= clip(firstText(p.content));
|
|
94
|
+
else if (p.type === 'function_call' || p.type === 'custom_tool_call') {
|
|
95
|
+
for (const cmd of codexCommands(p)) {
|
|
96
|
+
const kind = launchKind(cmd);
|
|
97
|
+
if (kind) {
|
|
98
|
+
const l = launchOf(at, kind, cmd);
|
|
99
|
+
let args; try { args = JSON.parse(p.arguments); } catch {}
|
|
100
|
+
l.cwd = launchCwd(cmd, args?.workdir ?? s.cwd, kind === 'codex plugin' ? ['--cwd'] : ['-C', '--cd']);
|
|
101
|
+
l.toolUseId = p.call_id;
|
|
102
|
+
s.launches.push(l);
|
|
103
|
+
if (typeof p.call_id === 'string') calls.set(p.call_id, l);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (p.type === 'function_call_output' && calls.has(p.call_id)) {
|
|
108
|
+
const l = calls.get(p.call_id);
|
|
109
|
+
l.job = resultJob(p.output);
|
|
110
|
+
if (l.kind === 'claude -p') l.sessionIds = resultSessions(p.output);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
if (s.startedAt == null) return null;
|
|
115
|
+
// Sub-agent task text is encrypted in the rollout, so the agent path is the best label.
|
|
116
|
+
s.prompt = s.name ?? fallbackName; // the thread title (session_index.jsonl) replaces name in readSessions
|
|
117
|
+
s.name = s.prompt ?? s.agentPath?.split('/').filter(Boolean).at(-1) ?? null;
|
|
118
|
+
s.usage = hasRecords ? records : fallback;
|
|
119
|
+
s.tokens = { input: 0, cacheRead: 0, cacheWrite: null, output: 0 };
|
|
120
|
+
for (const e of s.usage) for (const k of ['input', 'cacheRead', 'cacheWrite', 'output']) {
|
|
121
|
+
if (e.tokens[k] != null) s.tokens[k] = (s.tokens[k] ?? 0) + e.tokens[k];
|
|
122
|
+
}
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// shortcut: a changed rollout is re-read whole (unchanged ones come from the cache); upgrade to offset reads if one gets huge.
|
|
127
|
+
function readSessions({ codexHome: home = codexHome(), dirs = ['sessions', 'archived_sessions'].map(d => path.join(home, d)), since = 0, index = path.join(home, 'session_index.jsonl'), ...options } = {}) {
|
|
128
|
+
const billing = readBilling({ ...options, codexHome: home });
|
|
129
|
+
const titles = readTitles(index);
|
|
130
|
+
return dirs.flatMap(d => files(d, since)).map(f => cached(f, parseRollout)).filter(Boolean).map(s => ({ ...s, billing, ...(titles.has(s.id) ? { name: titles.get(s.id) } : {}) }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The thread names Codex gives its sessions: id -> thread_name (the last line of an id wins).
|
|
134
|
+
function readTitles(file) {
|
|
135
|
+
const titles = new Map();
|
|
136
|
+
let text = '';
|
|
137
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { /* no index yet */ }
|
|
138
|
+
for (const line of text.split('\n')) {
|
|
139
|
+
try { const r = JSON.parse(line); if (typeof r.id === 'string' && typeof r.thread_name === 'string' && r.thread_name.trim()) titles.set(r.id, r.thread_name.replace(/\s+/g, ' ').trim().slice(0, 120)); } catch { /* blank or partial line */ }
|
|
140
|
+
}
|
|
141
|
+
return titles;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The companion records the exact Claude session and Codex thread, including jobs still running.
|
|
145
|
+
// Read both copies: state.json may omit old jobs or be mid-write while jobs/<id>.json is available.
|
|
146
|
+
function pluginDataDir() {
|
|
147
|
+
const host = Object.values(require('../../catalog.json').agents).find(a => a.originators?.some(o => declaration.pluginOrigins.includes(o)) && a.pluginData)?.pluginData;
|
|
148
|
+
if (!host) return null;
|
|
149
|
+
return path.join(process.env[host.homeEnv] ?? path.join(os.homedir(), host.home), host.relative);
|
|
150
|
+
}
|
|
151
|
+
function readPluginJobs({ dir, dataDir = pluginDataDir(), tempDir = path.join(os.tmpdir(), 'codex-companion') } = {}) {
|
|
152
|
+
const read = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
153
|
+
const entries = dir => { try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; } };
|
|
154
|
+
const roots = dir ? [dir] : [...new Set([...(dataDir ? [path.join(dataDir, 'codex-openai-codex/state'), ...entries(dataDir).filter(e => e.isDirectory() && /codex/i.test(e.name)).map(e => path.join(dataDir, e.name, 'state'))] : []), tempDir])];
|
|
155
|
+
return roots.flatMap(dir => entries(dir).filter(e => e.isDirectory()).flatMap(e => {
|
|
156
|
+
const root = path.join(dir, e.name), stateFile = path.join(root, 'state.json'), state = cached(stateFile, read);
|
|
157
|
+
const jobs = (Array.isArray(state?.jobs) ? state.jobs : []).map(job => ({ job, file: stateFile }));
|
|
158
|
+
for (const f of entries(path.join(root, 'jobs')).filter(f => f.isFile() && f.name.endsWith('.json'))) {
|
|
159
|
+
const file = path.join(root, 'jobs', f.name);
|
|
160
|
+
jobs.push({ job: cached(file, read), file });
|
|
161
|
+
}
|
|
162
|
+
return jobs.filter(({ job }) => job && ['id', 'threadId'].every(k => typeof job[k] === 'string' && job[k])
|
|
163
|
+
&& (job.sessionId == null || (typeof job.sessionId === 'string' && job.sessionId))
|
|
164
|
+
&& ['startedAt', 'createdAt'].every(k => job[k] == null || typeof job[k] === 'string')
|
|
165
|
+
&& Number.isFinite(Date.parse(job.startedAt ?? job.createdAt)))
|
|
166
|
+
.map(({ job, file }) => ({ id: job.id, threadId: job.threadId, sessionId: job.sessionId, at: Date.parse(job.startedAt ?? job.createdAt), file }));
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isCodex = p => p.name === 'codex.exe' || p.name === 'codex';
|
|
171
|
+
|
|
172
|
+
// First positional argument of a codex command line (after the exe), skipping options and the values of -c/-m/...
|
|
173
|
+
const VALUE_FLAGS = new Set(['-c', '--config', '-m', '--model', '-p', '--profile', '-C', '--cd', '-s', '--sandbox', '-a', '--ask-for-approval', '-i', '--image']);
|
|
174
|
+
function subcommand(p) {
|
|
175
|
+
const args = (p.cmd.match(/"[^"]*"|\S+/g) ?? []).slice(1).map(a => a.replace(/^"|"$/g, ''));
|
|
176
|
+
for (let i = 0; i < args.length; i++) {
|
|
177
|
+
if (VALUE_FLAGS.has(args[i])) i++;
|
|
178
|
+
else if (!args[i].startsWith('-')) return args[i];
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const isAppServer = p => isCodex(p) && subcommand(p) === 'app-server';
|
|
183
|
+
const isExec = p => isCodex(p) && subcommand(p) === 'exec';
|
|
184
|
+
// Only the interactive TUI; mcp-server, proxy, sandbox helpers and the like are not sessions.
|
|
185
|
+
const isTui = p => isCodex(p) && [null, 'resume', 'fork'].includes(subcommand(p));
|
|
186
|
+
|
|
187
|
+
// Hosted in the shared Codex app-server: no per-thread process, so the rollout is the evidence.
|
|
188
|
+
// A host is valid only if an app-server was already running when the thread last wrote; a newer one cannot hold it.
|
|
189
|
+
// shortcut: a thread reopened after a restart that has not written yet looks ended; no log shows it until it writes.
|
|
190
|
+
function hostedState(s, servers) {
|
|
191
|
+
if (!servers.some(p => p.startMs <= s.lastActivityAt)) return 'ended';
|
|
192
|
+
if (s.turn === 'open') return 'running';
|
|
193
|
+
return s.kind === 'main' ? 'waiting' : 'ended';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function liveness({ sessions, procs, pidFiles, now, owners = new Map(), claim }) {
|
|
197
|
+
const out = new Map(), claimed = new Set(), key = s => `${s.provider}:${s.id}`;
|
|
198
|
+
const byId = new Map(sessions.map(s => [key(s), s]));
|
|
199
|
+
const servers = procs.filter(isAppServer);
|
|
200
|
+
// A nested sub-thread follows the state of the topmost Codex session found on disk above it.
|
|
201
|
+
const rootOf = s => {
|
|
202
|
+
let cur = byId.get(`codex:${s.parentId}`), seen = new Set();
|
|
203
|
+
while (cur && !seen.has(cur) && byId.has(`codex:${cur.parentId}`)) { seen.add(cur); cur = byId.get(`codex:${cur.parentId}`); }
|
|
204
|
+
return cur ?? null;
|
|
205
|
+
};
|
|
206
|
+
const ownProcess = s => s.kind === 'standalone' || s.hostSource === 'cli';
|
|
207
|
+
const set = (s, state, pid = null) => out.set(key(s), { state, pid });
|
|
208
|
+
// Newest first, so when two sessions compete for one process the live one wins.
|
|
209
|
+
const order = [...sessions].sort((a, b) => b.startedAt - a.startedAt);
|
|
210
|
+
|
|
211
|
+
for (const s of order.filter(x => x.provider === 'codex')) {
|
|
212
|
+
let p = null;
|
|
213
|
+
if (s.kind === 'standalone') p = claim(procs.filter(isExec), s, claimed, owners);
|
|
214
|
+
else if (s.hostSource === 'cli') {
|
|
215
|
+
// A TUI is its own process; "codex resume <id>" names the thread, otherwise match by start time.
|
|
216
|
+
const tui = procs.filter(isTui);
|
|
217
|
+
p = tui.find(x => x.cmd.includes(s.id) && !claimed.has(x.pid)) ?? claim(tui, s, claimed, owners);
|
|
218
|
+
if (p) claimed.add(p.pid);
|
|
219
|
+
}
|
|
220
|
+
if (p) set(s, s.turn === 'open' ? 'running' : 'waiting', p.pid);
|
|
221
|
+
else if (ownProcess(s)) set(s, 'ended'); // own process is gone
|
|
222
|
+
else {
|
|
223
|
+
const top = rootOf(s);
|
|
224
|
+
if (!top || !ownProcess(top)) set(s, hostedState(s, servers)); // else: decided below from the parent
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A Codex sub-thread under a TUI/exec root lives and dies with that root, like a Claude sub-agent.
|
|
229
|
+
for (const s of order.filter(x => x.provider === 'codex' && !out.has(key(x)))) {
|
|
230
|
+
set(s, s.turn === 'open' && out.get(key(rootOf(s))).state !== 'ended' ? 'running' : 'ended');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
237
|
+
|
|
238
|
+
// Window ids and lengths are declared, independent of primary/secondary ordering.
|
|
239
|
+
const { quotaFromRecord, quotaFromLimits, codexQuota } = require('./sources/rollout.js');
|
|
240
|
+
|
|
241
|
+
function loadLimits({ codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex') } = {}) {
|
|
242
|
+
const table = new Map();
|
|
243
|
+
const models = readJson(path.join(codexHome, 'models_cache.json'))?.models;
|
|
244
|
+
for (const m of Array.isArray(models) ? models : []) if (typeof m?.slug === 'string' && Number.isSafeInteger(m.context_window) && m.context_window > 0) table.set(m.slug, m.context_window);
|
|
245
|
+
return table;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Commands inside a Codex tool call: JSON arguments (cmd/command) or a custom tool's JS text (cmd:"...").
|
|
249
|
+
function codexCommands(payload) {
|
|
250
|
+
const out = [];
|
|
251
|
+
if (typeof payload.arguments === 'string') {
|
|
252
|
+
try {
|
|
253
|
+
const a = JSON.parse(payload.arguments), c = a?.cmd ?? a?.command;
|
|
254
|
+
out.push(Array.isArray(c) ? String(c.at(-1)) : c);
|
|
255
|
+
} catch { /* not JSON */ }
|
|
256
|
+
}
|
|
257
|
+
if (typeof payload.input === 'string') {
|
|
258
|
+
for (const m of payload.input.matchAll(/\bcmd:\s*"((?:[^"\\]|\\.)*)"/g)) {
|
|
259
|
+
try { out.push(JSON.parse(`"${m[1]}"`)); } catch { /* bad escape */ }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out.filter(c => typeof c === 'string');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const launchPatterns = [
|
|
266
|
+
['codex plugin', /^(?:node\s+)?["']?\S*codex-companion[^\s"']*["']?\s+(?:task|review|adversarial-review)(?=\s|$)/], // not task-resume-candidate, task-status ...
|
|
267
|
+
['codex exec', /^["']?\S*\bcodex(?:\.exe|\.cmd)?["']?\s+(?:\S+\s+)*?exec\b/],
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
module.exports = { readTimeline: require('./timeline.js').readTimeline, readSessions, parseRollout, codexCommands, codexHome, readPluginJobs, codexQuota, quotaFromLimits, readBilling,
|
|
271
|
+
processNames: ['codex.exe', 'codex'], liveness, loadLimits, launchPatterns,
|
|
272
|
+
launchLine: line => line.includes('"function_call"') || line.includes('"custom_tool_call"'),
|
|
273
|
+
launchCalls: r => ['function_call', 'custom_tool_call'].includes(r.payload?.type) ? [[r.payload.call_id, codexCommands(r.payload)]] : [],
|
|
274
|
+
readJobs: readPluginJobs,
|
|
275
|
+
parentLabel: 'Codex agent',
|
|
276
|
+
launchKindOf: s => s.parentId ? null : declaration.pluginOrigins.includes(s.originator) ? 'codex plugin' : s.hostSource === 'exec' ? 'codex exec' : null,
|
|
277
|
+
launchPolicy: (kind, s) => kind === 'codex plugin' ? { process: false, parentOrigin: s.originator } : { process: true },
|
|
278
|
+
provenLinks: (s, jobs) => jobs.filter(j => j.threadId === s.id && typeof j.sessionId === 'string' && j.sessionId).map(j => ({ parentId: j.sessionId, parentOrigin: s.originator, evidence: { jobId: j.id, file: j.file, at: j.at } })),
|
|
279
|
+
launchProofs: (s, jobs, sessions) => sessions.filter(a => a.provider !== s.provider || a.id !== s.id).flatMap(a => (a.launches ?? []).filter(l => l.kind === 'codex plugin' && l.job && jobs.some(j => j.id === l.job && j.threadId === s.id)).map(l => ({ parent: `${a.provider}:${a.id}`, label: 'codex plugin', evidence: { job: l.job, toolUseId: l.toolUseId } })))
|
|
280
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const declaration = require('../../../catalog.json').agents.codex;
|
|
2
|
+
const validPercent = v => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
3
|
+
const afterReset = (at, now) => at != null && at <= now;
|
|
4
|
+
function quotaFromLimits(limits, at) {
|
|
5
|
+
const windows = [];
|
|
6
|
+
for (const w of [limits.primary, limits.secondary]) {
|
|
7
|
+
if (!Number.isFinite(w?.window_minutes) || !validPercent(w.used_percent)) continue;
|
|
8
|
+
const declared = declaration.windows.find(d => d.minutes === w.window_minutes);
|
|
9
|
+
if (!declared) continue;
|
|
10
|
+
const resetsAt = Number.isFinite(w.resets_at) ? w.resets_at * 1000 : null;
|
|
11
|
+
windows.push({ id: declared.id, label: declared.label, usedPercent: w.used_percent, resetsAt });
|
|
12
|
+
}
|
|
13
|
+
return { windows, readAt: at, source: 'log' };
|
|
14
|
+
}
|
|
15
|
+
function quotaFromRecord(payload, at) {
|
|
16
|
+
const limits = payload?.rate_limits;
|
|
17
|
+
return Number.isFinite(at) && (limits?.primary || limits?.secondary) && (limits.limit_id ?? 'codex') === 'codex' ? quotaFromLimits(limits, at) : null;
|
|
18
|
+
}
|
|
19
|
+
function codexQuota(sessions, now = Date.now()) {
|
|
20
|
+
let latest = null;
|
|
21
|
+
for (const s of sessions) if (s.provider === 'codex' && s.quota && (!latest || s.quota.readAt > latest.readAt)) latest = s.quota;
|
|
22
|
+
if (!latest) return { windows: [], readAt: null, source: 'log', error: 'no Codex rate limits in the rollouts read' };
|
|
23
|
+
return { ...latest, windows: latest.windows.map(w => ({ ...w, usedPercent: afterReset(w.resetsAt, now) ? null : w.usedPercent })) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { quotaFromRecord, quotaFromLimits, codexQuota, createQuota: () => ({ read: codexQuota }) };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const { read, clip, typed, toolText, n } = require('../../timeline.js');
|
|
2
|
+
function readTimeline(file) {
|
|
3
|
+
return read(file, (r, { out, add, lastOf, seen }) => {
|
|
4
|
+
const p = r.payload ?? {};
|
|
5
|
+
if (r.type === 'response_item') {
|
|
6
|
+
const first = Array.isArray(p.content) ? p.content.find(c => typeof c?.text === 'string')?.text : null;
|
|
7
|
+
if (p.type === 'message' && p.role === 'user' && typed(first)) add(r, 'user', clip(first));
|
|
8
|
+
else if (p.type === 'message' && p.role === 'assistant') add(r, 'assistant', clip(first));
|
|
9
|
+
else if (p.type === 'function_call' || p.type === 'custom_tool_call') add(r, 'tool', toolText(p.name, p.arguments ?? p.input));
|
|
10
|
+
} else if (r.type === 'event_msg') {
|
|
11
|
+
if (p.type === 'task_started') add(r, 'turn', 'turn started');
|
|
12
|
+
else if (p.type === 'task_complete' || p.type === 'task_completed') add(r, 'turn', 'turn done');
|
|
13
|
+
else if (p.type === 'turn_aborted') add(r, 'turn', 'turn aborted');
|
|
14
|
+
else if (p.type === 'token_count' && p.info?.last_token_usage && out.length) {
|
|
15
|
+
const last = p.info.last_token_usage, total = p.info.total_token_usage?.total_tokens, e = out[out.length - 1];
|
|
16
|
+
if (total != null) { if (seen.has(total)) return; seen.add(total); }
|
|
17
|
+
if (e.kind !== 'user' && !e.tokens) e.tokens = { input: Math.max(0, n(last.input_tokens) - n(last.cached_input_tokens)), cacheRead: n(last.cached_input_tokens), cacheWrite: null, output: n(last.output_tokens) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
module.exports = { readTimeline };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The core imports only this registry. Identity, windows and models come from catalog.json.
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { load } = require('../catalog.js');
|
|
4
|
+
function discover(catalog = load(), directory = __dirname) {
|
|
5
|
+
return Object.values(catalog.agents).map(a => ({
|
|
6
|
+
...require(path.join(directory, a.id, 'index.js')),
|
|
7
|
+
...a, hue: a.color,
|
|
8
|
+
createQuota: options => require('../quota.js').orderedSources(a.quotaSources.map(source => require(path.join(directory, a.id, 'sources', source + '.js')).createQuota(options))),
|
|
9
|
+
models: Object.entries(catalog.models).filter(([, m]) => m.agent === a.id).map(([id, m]) => ({ id, ...m }))
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
module.exports = discover();
|
|
13
|
+
module.exports.discover = discover;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Loads catalog.json (hand-edited, so every field is checked before use).
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const SOURCES = ['measured', 'community', 'doc', 'estimated', 'assumed', 'unmeasured'];
|
|
6
|
+
|
|
7
|
+
const fail = (file, where, why) => { throw new Error(`${file}: ${where}: ${why}`); };
|
|
8
|
+
const isObject = v => v != null && typeof v === 'object' && !Array.isArray(v);
|
|
9
|
+
const isNumber = v => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
10
|
+
const isDate = v => typeof v === 'string' && Number.isFinite(Date.parse(v));
|
|
11
|
+
|
|
12
|
+
// Dated entries: values change over time, and old records must be priced with the values valid then.
|
|
13
|
+
function checkHistory(file, where, list, checkValue) {
|
|
14
|
+
if (!Array.isArray(list) || list.length === 0) fail(file, where, 'must be a non-empty array of dated entries');
|
|
15
|
+
list.forEach((e, i) => {
|
|
16
|
+
const at = `${where}[${i}]`;
|
|
17
|
+
if (!isObject(e)) fail(file, at, 'must be an object');
|
|
18
|
+
if (!SOURCES.includes(e.source)) fail(file, `${at}.source`, `must be one of ${SOURCES.join(', ')}`);
|
|
19
|
+
if (!isDate(e.at)) fail(file, `${at}.at`, 'must be a date');
|
|
20
|
+
if (!isDate(e.validFrom)) fail(file, `${at}.validFrom`, 'must be a date');
|
|
21
|
+
checkValue(e, at);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function checkCatalog(c, file = 'catalog.json') {
|
|
26
|
+
if (!isObject(c)) fail(file, '(root)', 'must be an object');
|
|
27
|
+
if (!(isNumber(c.contextWarn) && c.contextWarn > 0 && c.contextWarn <= 1)) fail(file, 'contextWarn', 'must be a fraction in (0, 1]');
|
|
28
|
+
if (!Array.isArray(c.tokenTypes) || !c.tokenTypes.length || c.tokenTypes.some(t => typeof t !== 'string') || new Set(c.tokenTypes).size !== c.tokenTypes.length) fail(file, 'tokenTypes', 'must list distinct token fields');
|
|
29
|
+
if (!Array.isArray(c.priceSchema?.fields) || c.priceSchema.fields.some(t => typeof t !== 'string')) fail(file, 'priceSchema.fields', 'must list price fields');
|
|
30
|
+
if (!isObject(c.agents) || Object.keys(c.agents).length === 0) fail(file, 'agents', 'must be a non-empty object');
|
|
31
|
+
for (const [name, p] of Object.entries(c.agents)) {
|
|
32
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(name) || p?.id !== name) fail(file, name, "id must match the agent folder name");
|
|
33
|
+
if (typeof p.name !== "string" || !p.name || !isNumber(p.color)) fail(file, name, "must declare name and color hue");
|
|
34
|
+
if (!Array.isArray(p.efforts) || !p.efforts.length || p.efforts.some(e => !isObject(e) || typeof e.id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(e.id) || !(e.color === null || (isNumber(e.color) && e.color < 360))) || new Set(p.efforts.map(e => e.id)).size !== p.efforts.length) fail(file, name + '.efforts', 'must list distinct efforts with id and color hue (or null for grey)');
|
|
35
|
+
if (!Array.isArray(p.windows) || p.windows.some(w => !isObject(w) || typeof w.id !== "string" || !w.id || typeof w.label !== "string" || !w.label || !isNumber(w.durationMs) || w.durationMs <= 0) || new Set(p.windows.map(w => w.id)).size !== p.windows.length) fail(file, name, "must list distinct windows with id, label and durationMs");
|
|
36
|
+
if (!Array.isArray(p.quotaSources) || p.quotaSources.some(x => typeof x !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(x)) || new Set(p.quotaSources).size !== p.quotaSources.length) fail(file, `${name}.quotaSources`, 'must list distinct source names');
|
|
37
|
+
for (const source of p.quotaSources) if (!fs.existsSync(path.join(__dirname, 'agents', name, 'sources', `${source}.js`))) fail(file, `${name}.quotaSources`, `missing source file: ${source}`);
|
|
38
|
+
if (!p.plan && !p.tokenWeights && !p.plans) continue;
|
|
39
|
+
const at = `agents.${name}`;
|
|
40
|
+
if (!isObject(p?.plans?.[p.plan])) fail(file, `${at}.plan`, `must name an entry of ${at}.plans`);
|
|
41
|
+
for (const [plan, body] of Object.entries(p.plans)) for (const { id: w } of p.windows)
|
|
42
|
+
checkHistory(file, `${at}.plans.${plan}.windows.${w}`, body?.windows?.[w], (e, a) => { if (!(isNumber(e.capacity) && e.capacity > 0)) fail(file, `${a}.capacity`, 'must be a positive number'); });
|
|
43
|
+
checkHistory(file, `${at}.tokenWeights`, p.tokenWeights, (e, a) => { for (const t of c.tokenTypes) if (!isNumber(e[t])) fail(file, `${a}.${t}`, 'must be a non-negative number'); });
|
|
44
|
+
}
|
|
45
|
+
if (!isObject(c.models)) fail(file, 'models', 'must be an object');
|
|
46
|
+
for (const [id, m] of Object.entries(c.models)) {
|
|
47
|
+
const at = `models.${id}`;
|
|
48
|
+
if (!c.agents[m?.agent]) fail(file, `${at}.agent`, `must be one of ${Object.keys(c.agents).join(', ')}`);
|
|
49
|
+
if (!isNumber(m.tier) || !isNumber(m.color)) fail(file, at, "must declare tier and color hue");
|
|
50
|
+
if (!(Number.isSafeInteger(m.context) && m.context > 0)) fail(file, `${at}.context`, 'must be a positive token count');
|
|
51
|
+
checkHistory(file, `${at}.weights`, m.weights, (e, a) => {
|
|
52
|
+
if (e.value === null ? e.source !== 'unmeasured' : !(isNumber(e.value) && e.value > 0)) fail(file, `${a}.value`, 'must be a positive number, or null with source "unmeasured"');
|
|
53
|
+
});
|
|
54
|
+
if (m.prices !== undefined) {
|
|
55
|
+
if (!Array.isArray(m.prices)) fail(file, `${at}.prices`, 'must be an array');
|
|
56
|
+
for (const [i, e] of m.prices.entries()) {
|
|
57
|
+
const a = `${at}.prices[${i}]`;
|
|
58
|
+
if (!isObject(e) || typeof e.source !== 'string' || !isDate(e.at) || !isDate(e.validFrom)) fail(file, a, 'must have source, at and validFrom');
|
|
59
|
+
for (const t of c.priceSchema.fields) if (!(isNumber(e[t]) || (e[t] === null && c.priceSchema.nullable?.includes(t)))) fail(file, `${a}.${t}`, 'must be a non-negative number');
|
|
60
|
+
if (e.fastMultiplier !== undefined && (!isNumber(e.fastMultiplier) || e.fastMultiplier <= 0)) fail(file, a, 'invalid fastMultiplier');
|
|
61
|
+
if (e.geoMultipliers !== undefined && (!isObject(e.geoMultipliers) || Object.values(e.geoMultipliers).some(v => !isNumber(v) || v <= 0))) fail(file, a, 'invalid geoMultipliers');
|
|
62
|
+
if (e.long !== undefined) {
|
|
63
|
+
if (!isObject(e.long) || !isNumber(e.long.above)) fail(file, `${a}.long.above`, 'must be a token count');
|
|
64
|
+
for (const t of c.priceSchema.fields) if (!(isNumber(e.long[t]) || (e.long[t] === null && c.priceSchema.nullable?.includes(t)))) fail(file, `${a}.long.${t}`, 'must be a non-negative number');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return c;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function load(file = path.join(__dirname, 'catalog.json')) {
|
|
73
|
+
let text;
|
|
74
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch (e) { throw new Error(`${file}: cannot read (${e.code ?? e.message})`); }
|
|
75
|
+
try { return checkCatalog(JSON.parse(text), file); } catch (e) { if (e instanceof SyntaxError) throw new Error(`${file}: invalid JSON (${e.message})`); throw e; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The newest entry whose validFrom is not after `at`; falls back to the oldest entry for earlier dates.
|
|
79
|
+
function pick(list, at = Date.now()) {
|
|
80
|
+
const sorted = [...list].sort((a, b) => Date.parse(a.validFrom) - Date.parse(b.validFrom));
|
|
81
|
+
return sorted.filter(e => Date.parse(e.validFrom) <= at).pop() ?? sorted[0];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// An unmeasured model is priced like the costliest measured model of the same provider, and flagged.
|
|
85
|
+
// With no such peer the weight stays unknown (null): guessing low would hide real cost.
|
|
86
|
+
function modelWeight(catalog, id, at = Date.now()) {
|
|
87
|
+
const m = catalog.models[id];
|
|
88
|
+
if (!m) throw new Error(`Unknown model: ${id}`);
|
|
89
|
+
const own = pick(m.weights, at);
|
|
90
|
+
if (own.value != null) return { value: own.value, assumed: false, source: own.source };
|
|
91
|
+
const peers = Object.values(catalog.models).filter(o => o.agent === m.agent).map(o => pick(o.weights, at).value).filter(v => v != null);
|
|
92
|
+
return { value: peers.length ? Math.max(...peers) : null, assumed: true, source: 'unmeasured' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const windowIds = (c, agent) => (c.agents[agent]?.windows ?? []).map(w => w.id);
|
|
96
|
+
module.exports = { load, checkCatalog, pick, modelWeight, windowIds };
|