sprag-cli 3.52.0 → 3.54.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.ko.md +46 -3
- package/README.md +50 -4
- package/bin/cli.js +35 -4
- package/docs/CODEX.ko.md +172 -0
- package/docs/CODEX.md +485 -0
- package/package.json +11 -3
- package/presets/delegation/bounds.md +29 -0
- package/presets/ratchet-rules.json +4 -1
- package/src/agent.js +33 -0
- package/src/codex-brief.js +89 -0
- package/src/codex-budget.js +73 -0
- package/src/codex-cache-policy.js +203 -0
- package/src/codex-cache.js +46 -0
- package/src/codex-delegation.js +154 -0
- package/src/codex-doc2md-ledger.js +45 -0
- package/src/codex-doc2md.js +99 -0
- package/src/codex-doctor.js +53 -0
- package/src/codex-harness.js +200 -0
- package/src/codex-hooks.js +131 -0
- package/src/codex-installer.js +79 -0
- package/src/codex-ledger.js +143 -0
- package/src/codex-panel-launcher.js +48 -0
- package/src/codex-panel-runner.js +74 -0
- package/src/codex-panel-session.js +35 -0
- package/src/codex-panel-shell.js +51 -0
- package/src/codex-panel-state.js +40 -0
- package/src/codex-panel.js +448 -0
- package/src/codex-parser.js +397 -0
- package/src/codex-route-scan.js +171 -0
- package/src/commands/brief.js +8 -1
- package/src/commands/codex-delegate.js +59 -0
- package/src/commands/codex.js +557 -0
- package/src/commands/delegate.js +133 -0
- package/src/commands/seed.js +11 -10
- package/src/delegation-guard.js +278 -0
- package/src/doc2md.cjs +72 -28
- package/src/handoff.js +27 -7
- package/src/harness.js +26 -17
- package/src/installer.js +168 -0
- package/src/seed-rules.js +58 -34
- package/src/session-paths.js +242 -0
- package/src/update-check.js +37 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { codexUserDir } from './agent.js';
|
|
5
|
+
import { userDataDir } from './paths.js';
|
|
6
|
+
import { readCodexSnapshot } from './codex-parser.js';
|
|
7
|
+
|
|
8
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
const stateDir = (dir) => join(dir, 'codex', hash(codexUserDir()), 'sessions');
|
|
10
|
+
const fresh = (value, now) => {
|
|
11
|
+
const timestamp = Date.parse(value);
|
|
12
|
+
return Number.isFinite(timestamp) && timestamp <= now + 60000 && now - timestamp < 300000;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function codexIssues(snapshot, { now = Date.now() } = {}) {
|
|
16
|
+
if (!snapshot) return [];
|
|
17
|
+
const issues = [];
|
|
18
|
+
const fraction = snapshot.contextWindow > 0 && Number.isFinite(snapshot.lastContextTokens)
|
|
19
|
+
? snapshot.lastContextTokens / snapshot.contextWindow : null;
|
|
20
|
+
if (fraction >= 0.8 && fresh(snapshot.contextAt, now)) {
|
|
21
|
+
const tier = fraction >= 0.95 ? 2 : 1;
|
|
22
|
+
issues.push({ key: 'context', tier, signature: `context:${tier}`, level: tier === 2 ? 'critical' : 'warning',
|
|
23
|
+
message: `Recorded input context is ${Math.round(fraction * 100)}% (${snapshot.lastContextTokens}/${snapshot.contextWindow} tokens).`,
|
|
24
|
+
advice: 'Preserve decisions, verification results, and next steps before compaction. Use sprag handoff --agent codex when a handoff is needed.' });
|
|
25
|
+
}
|
|
26
|
+
if (fresh(snapshot.rateLimitsAt, now)) {
|
|
27
|
+
for (const key of ['primary', 'secondary']) {
|
|
28
|
+
const win = snapshot.rateLimits?.[key];
|
|
29
|
+
if (!Number.isFinite(win?.used_percent) || win.used_percent < 90) continue;
|
|
30
|
+
if (Number.isFinite(win.resets_at) && win.resets_at * 1000 <= now) continue;
|
|
31
|
+
const label = Number.isFinite(win.window_minutes) && win.window_minutes > 0 ? `${win.window_minutes}m` : key;
|
|
32
|
+
issues.push({ key: `limit:${key}`, tier: 1, signature: `limit:${key}:${win.resets_at ?? 'unknown'}`,
|
|
33
|
+
level: 'warning', message: `Recorded ${label} rate limit is ${win.used_percent}% used.`,
|
|
34
|
+
advice: 'Finish the current step and preserve the remaining work with sprag handoff --agent codex. This is a usage snapshot, not a billing estimate.' });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return issues;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readState(file) {
|
|
41
|
+
try {
|
|
42
|
+
const state = JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
+
return state && Array.isArray(state.events) ? state : { events: [] };
|
|
44
|
+
} catch { return { events: [] }; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One file per home/session keeps unrelated sessions out of the same dedup state. */
|
|
48
|
+
export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now(), dir = userDataDir() } = {}) {
|
|
49
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
50
|
+
const snapshot = readCodexSnapshot(transcriptPath);
|
|
51
|
+
if (!snapshot || (snapshot.sessionId && snapshot.sessionId !== sessionId)) return null;
|
|
52
|
+
const issues = codexIssues(snapshot, { now });
|
|
53
|
+
const directory = stateDir(dir);
|
|
54
|
+
const file = join(directory, `${hash(sessionId)}.json`);
|
|
55
|
+
const state = readState(file);
|
|
56
|
+
const active = Array.isArray(state.active) ? state.active.filter((sig) => typeof sig === 'string') : [];
|
|
57
|
+
const next = issues.filter((issue) => !active.includes(issue.signature) &&
|
|
58
|
+
!(issue.key === 'context' && issue.tier === 1 && active.includes('context:2')));
|
|
59
|
+
// Missing or stale measurements do not resolve an earlier warning. A fresh
|
|
60
|
+
// lower measurement does, so a refill after compaction can warn again.
|
|
61
|
+
const observed = new Set();
|
|
62
|
+
if (fresh(snapshot.contextAt, now) && snapshot.contextWindow > 0 && Number.isFinite(snapshot.lastContextTokens)) observed.add('context');
|
|
63
|
+
if (fresh(snapshot.rateLimitsAt, now)) {
|
|
64
|
+
for (const key of ['primary', 'secondary']) {
|
|
65
|
+
if (Number.isFinite(snapshot.rateLimits?.[key]?.used_percent)) observed.add(`limit:${key}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const remaining = active.filter((sig) => ![...observed].some((key) => sig.startsWith(`${key}:`)));
|
|
69
|
+
const nextActive = [...new Set([...remaining, ...issues.map((issue) => issue.signature)])];
|
|
70
|
+
if (!next.length && JSON.stringify(nextActive) === JSON.stringify(active)) return null;
|
|
71
|
+
const at = new Date(now).toISOString();
|
|
72
|
+
const events = [...state.events, ...next.map((issue) => ({ ...issue, at, sessionId, project: cwd || snapshot.projectDir || '' }))].slice(-200);
|
|
73
|
+
mkdirSync(directory, { recursive: true });
|
|
74
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
75
|
+
writeFileSync(tmp, JSON.stringify({ active: nextActive, events }) + '\n', { mode: 0o600 });
|
|
76
|
+
renameSync(tmp, file);
|
|
77
|
+
return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readCodexHistory({ days = 7, sessionId, project, dir = userDataDir(), now = Date.now() } = {}) {
|
|
81
|
+
const directory = stateDir(dir);
|
|
82
|
+
let files;
|
|
83
|
+
try { files = readdirSync(directory); } catch { return []; }
|
|
84
|
+
const cutoff = now - days * 86400000;
|
|
85
|
+
return files.filter((file) => /^[a-f0-9]{64}\.json$/.test(file)).flatMap((file) => readState(join(directory, file)).events)
|
|
86
|
+
.filter((event) => event && typeof event.message === 'string' && Date.parse(event.at) >= cutoff && Date.parse(event.at) <= now &&
|
|
87
|
+
(!sessionId || event.sessionId === sessionId) && (!project || (typeof event.project === 'string' && event.project.includes(project))))
|
|
88
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
89
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { parse } from 'smol-toml';
|
|
6
|
+
import { codexUserDir } from './agent.js';
|
|
7
|
+
import { pickBudgetSource } from './litellm-budget.js';
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export function codexBudgetProvider({ home = codexUserDir(), env = process.env, providerName } = {}) {
|
|
12
|
+
try {
|
|
13
|
+
const cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8'));
|
|
14
|
+
const profile = cfg.profiles?.[cfg.profile] || {};
|
|
15
|
+
const name = providerName || profile.model_provider || cfg.model_provider;
|
|
16
|
+
const provider = cfg.model_providers?.[name];
|
|
17
|
+
if (!provider || !/litellm/i.test(`${name} ${provider.name || ''}`)) return null;
|
|
18
|
+
const url = new URL(provider.base_url);
|
|
19
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return null;
|
|
20
|
+
url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, '');
|
|
21
|
+
url.search = ''; url.hash = '';
|
|
22
|
+
return { name, base: url.toString().replace(/\/$/, ''),
|
|
23
|
+
key: provider.env_key ? env[provider.env_key] : null,
|
|
24
|
+
command: provider.auth?.command,
|
|
25
|
+
args: provider.auth?.args || [] };
|
|
26
|
+
} catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function codexProviderKey(provider, { execute = run } = {}) {
|
|
30
|
+
let key = provider.key;
|
|
31
|
+
if (!key && typeof provider.command === 'string') {
|
|
32
|
+
try {
|
|
33
|
+
const result = await execute(provider.command, provider.args, { encoding: 'utf8', timeout: 10000, maxBuffer: 64 * 1024 });
|
|
34
|
+
key = result.stdout.trim().split(/\r?\n/).filter(Boolean).pop();
|
|
35
|
+
} catch { throw new Error('Codex provider authentication helper failed'); }
|
|
36
|
+
}
|
|
37
|
+
if (!key) throw new Error('Codex provider credentials unavailable');
|
|
38
|
+
return key;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchCodexBudget(provider, { fetchImpl = fetch, execute = run, now = Date.now() } = {}) {
|
|
42
|
+
const key = await codexProviderKey(provider, { execute });
|
|
43
|
+
// Tokens stay in memory and headers, never in argv, state files or errors.
|
|
44
|
+
const headers = { authorization: `Bearer ${key}`, accept: 'application/json' };
|
|
45
|
+
const get = async (path) => {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetchImpl(`${provider.base}${path}`, { headers, signal: AbortSignal.timeout(5000), redirect: 'error' });
|
|
48
|
+
return res.ok ? await res.json() : null;
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
};
|
|
51
|
+
const [keyResponse, userResponse] = await Promise.all([get('/key/info'), get('/user/info')]);
|
|
52
|
+
if (!keyResponse && !userResponse) throw new Error('LiteLLM budget endpoints unavailable');
|
|
53
|
+
const budget = pickBudgetSource(keyResponse?.info, userResponse);
|
|
54
|
+
return budget ? { ...budget, checkedAt: now } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Refresh between frames without delaying terminal paint. Cache no credentials.
|
|
58
|
+
export function createCodexBudgetReader({ provider = codexBudgetProvider, fetchBudget = fetchCodexBudget, now = Date.now } = {}) {
|
|
59
|
+
let base, snapshot = null, pending = false, checked = -Infinity;
|
|
60
|
+
return function read() {
|
|
61
|
+
const cfg = provider();
|
|
62
|
+
if (!cfg) { base = null; snapshot = null; return null; }
|
|
63
|
+
if (cfg.base !== base) { base = cfg.base; snapshot = null; checked = -Infinity; }
|
|
64
|
+
if (!pending && now() - checked >= 300000) {
|
|
65
|
+
checked = now(); pending = true;
|
|
66
|
+
const requestedBase = base;
|
|
67
|
+
Promise.resolve().then(() => fetchBudget(cfg)).then((value) => {
|
|
68
|
+
if (base === requestedBase) snapshot = value;
|
|
69
|
+
}).catch(() => {}).finally(() => { pending = false; });
|
|
70
|
+
}
|
|
71
|
+
return snapshot;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { parse } from 'smol-toml';
|
|
5
|
+
import { codexUserDir } from './agent.js';
|
|
6
|
+
import { userDataDir } from './paths.js';
|
|
7
|
+
import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
|
|
8
|
+
import { codexCachePolicy } from './codex-cache.js';
|
|
9
|
+
|
|
10
|
+
export const CACHE_POLICY_MAX_AGE_MS = 300000;
|
|
11
|
+
const OPENAI_POLICY = 'https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime';
|
|
12
|
+
const BEDROCK_POLICY = 'https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html#prompt-caching-openai';
|
|
13
|
+
|
|
14
|
+
function deploymentPolicy(row) {
|
|
15
|
+
const params = row?.litellm_params || {}, info = row?.model_info || {};
|
|
16
|
+
const route = typeof params.model === 'string' ? params.model : '';
|
|
17
|
+
const base = typeof info.base_model === 'string' ? info.base_model : route;
|
|
18
|
+
const backend = /^bedrock(?:\/|_mantle\/)/.test(route) ? 'Bedrock'
|
|
19
|
+
: /^(?:openai\/)?gpt-/.test(route) ? 'OpenAI' : null;
|
|
20
|
+
if (!backend || info.supports_prompt_caching === false) return null;
|
|
21
|
+
// An OpenAI-compatible server is not necessarily OpenAI, even with a GPT alias.
|
|
22
|
+
if (backend === 'OpenAI' && params.api_base) {
|
|
23
|
+
try { if (new URL(params.api_base).hostname !== 'api.openai.com') return null; }
|
|
24
|
+
catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
const model = backend === 'Bedrock'
|
|
27
|
+
? base.replace(/^bedrock(?:\/converse)?\//, '').replace(/^bedrock_mantle\//, '').replace(/^(?:(?:global|us|eu|apac)\.)?openai\./, '')
|
|
28
|
+
: route.replace(/^openai\//, '');
|
|
29
|
+
const seconds = codexCachePolicy(model);
|
|
30
|
+
if (!seconds) return null;
|
|
31
|
+
const options = params.prompt_cache_options;
|
|
32
|
+
if (options != null && (typeof options !== 'object' || Array.isArray(options))) return null;
|
|
33
|
+
if (options?.ttl != null && options.ttl !== '30m') return null;
|
|
34
|
+
if (options?.mode != null && !['implicit', 'explicit'].includes(options.mode)) return null;
|
|
35
|
+
if (params.prompt_cache_retention != null) return null;
|
|
36
|
+
return { backend, model, seconds, mode: options?.mode || 'implicit',
|
|
37
|
+
reference: backend === 'Bedrock' ? BEDROCK_POLICY : OPENAI_POLICY };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function gatewayCachePolicies(payload) {
|
|
41
|
+
if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
|
|
42
|
+
const groups = new Map();
|
|
43
|
+
for (const row of payload.data) {
|
|
44
|
+
if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
|
|
45
|
+
const group = groups.get(row.model_name) || [];
|
|
46
|
+
group.push(deploymentPolicy(row));
|
|
47
|
+
groups.set(row.model_name, group);
|
|
48
|
+
}
|
|
49
|
+
// All deployments behind an alias must agree; load balancing must not change TTL.
|
|
50
|
+
return Object.fromEntries([...groups].map(([name, policies]) => [name,
|
|
51
|
+
policies[0] && policies.every((p) => JSON.stringify(p) === JSON.stringify(policies[0])) ? policies[0] : null]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const PRICE_MAX_AGE_MS = 7 * 86400000;
|
|
55
|
+
const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
|
|
56
|
+
|
|
57
|
+
function deploymentPrice(row) {
|
|
58
|
+
const info = row?.model_info || {};
|
|
59
|
+
const mode = typeof info.mode === 'string' ? info.mode : null;
|
|
60
|
+
if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
|
|
61
|
+
const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
|
|
62
|
+
if (input === null || output === null) return null;
|
|
63
|
+
return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
|
|
67
|
+
export function gatewayPrices(payload) {
|
|
68
|
+
if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
|
|
69
|
+
const groups = new Map();
|
|
70
|
+
for (const row of payload.data) {
|
|
71
|
+
if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
|
|
72
|
+
groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
|
|
73
|
+
}
|
|
74
|
+
return Object.fromEntries([...groups].map(([name, prices]) => [name,
|
|
75
|
+
prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* USD for a token mix at one model's prices, or null when a needed rate is
|
|
80
|
+
* missing. Cached input without a cache-read rate is unknown, not free.
|
|
81
|
+
*/
|
|
82
|
+
export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
|
|
83
|
+
if (!price || price.input === null || price.output === null) return null;
|
|
84
|
+
if (cached > 0 && price.cacheRead === null) return null;
|
|
85
|
+
// cache_write_input_tokens is billed at the creation rate when one is published, else as input.
|
|
86
|
+
const uncached = Math.max(0, input - cached - cacheWrite);
|
|
87
|
+
return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cacheFile(provider, { home = codexUserDir(), dir = userDataDir() } = {}) {
|
|
91
|
+
const key = createHash('sha256').update(JSON.stringify([resolve(home), provider.name, provider.base])).digest('hex').slice(0, 24);
|
|
92
|
+
return join(dir, 'codex-cache-policy', `${key}.json`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function gatewayProvider(session, opts) {
|
|
96
|
+
if (!session?.provider || session.provider === 'openai') return null;
|
|
97
|
+
return codexBudgetProvider({ ...opts, providerName: session.provider });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
|
|
101
|
+
if (session?.provider !== 'openai' || !codexCachePolicy(session.model)) return null;
|
|
102
|
+
try {
|
|
103
|
+
let cfg = {};
|
|
104
|
+
try { cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8')); }
|
|
105
|
+
catch (e) { if (e.code !== 'ENOENT') return null; }
|
|
106
|
+
if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return null;
|
|
107
|
+
} catch { return null; }
|
|
108
|
+
return { backend: 'OpenAI', model: session.model, seconds: codexCachePolicy(session.model),
|
|
109
|
+
requestedModel: session.model, provider: session.provider, checkedAt: now, reference: OPENAI_POLICY };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function selectPolicy(snapshot, session, now) {
|
|
113
|
+
if (!Number.isFinite(snapshot?.checkedAt) || snapshot.checkedAt > now || now - snapshot.checkedAt >= CACHE_POLICY_MAX_AGE_MS) return null;
|
|
114
|
+
const policy = Object.hasOwn(snapshot.models || {}, session.model) ? snapshot.models[session.model] : null;
|
|
115
|
+
if (!policy || !['OpenAI', 'Bedrock'].includes(policy.backend) || policy.seconds !== codexCachePolicy(policy.model) || !policy.seconds) return null;
|
|
116
|
+
return { ...policy, requestedModel: session.model, provider: session.provider, checkedAt: snapshot.checkedAt };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Offline read for statuslines and diagnostics; no auth helper or network calls. */
|
|
120
|
+
export function readCodexCachePolicy(session, opts = {}) {
|
|
121
|
+
const direct = directPolicy(session, opts);
|
|
122
|
+
if (direct) return direct;
|
|
123
|
+
const provider = gatewayProvider(session, opts);
|
|
124
|
+
if (!provider) return null;
|
|
125
|
+
try { return selectPolicy(JSON.parse(readFileSync(cacheFile(provider, opts), 'utf8')), session, opts.now ?? Date.now()); }
|
|
126
|
+
catch { return null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Offline price table for the configured gateway, from the snapshot the cache
|
|
131
|
+
* policy refresh writes. Prices change rarely, so a week-old table still counts;
|
|
132
|
+
* anything older reads as unknown rather than silently current.
|
|
133
|
+
*/
|
|
134
|
+
export function readCodexPrices({ provider, now = Date.now(), ...opts } = {}) {
|
|
135
|
+
provider ??= codexBudgetProvider(opts);
|
|
136
|
+
if (!provider) return null;
|
|
137
|
+
try {
|
|
138
|
+
const snapshot = JSON.parse(readFileSync(cacheFile(provider, opts), 'utf8'));
|
|
139
|
+
if (!Number.isFinite(snapshot?.checkedAt) || snapshot.checkedAt > now || now - snapshot.checkedAt > PRICE_MAX_AGE_MS) return null;
|
|
140
|
+
if (!snapshot.prices || typeof snapshot.prices !== 'object') return null;
|
|
141
|
+
return { checkedAt: snapshot.checkedAt, provider: provider.name, base: provider.base, prices: snapshot.prices };
|
|
142
|
+
} catch { return null; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Fetch prices for the configured gateway without needing a session. */
|
|
146
|
+
export async function refreshCodexPrices({ provider = codexBudgetProvider(), fetchImpl = fetch, execute, ...opts } = {}) {
|
|
147
|
+
if (!provider) throw new Error('No configured LiteLLM endpoint for Codex');
|
|
148
|
+
await writeGatewaySnapshot(provider, { fetchImpl, execute, ...opts });
|
|
149
|
+
return readCodexPrices({ provider, ...opts });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function writeGatewaySnapshot(provider, { fetchImpl = fetch, execute, ...opts } = {}) {
|
|
153
|
+
const key = await codexProviderKey(provider, { execute });
|
|
154
|
+
let payload;
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetchImpl(`${provider.base}/model/info`, {
|
|
157
|
+
headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
|
|
158
|
+
signal: AbortSignal.timeout(5000), redirect: 'error',
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) throw new Error();
|
|
161
|
+
payload = await res.json();
|
|
162
|
+
} catch { throw new Error('LiteLLM model/info unavailable; cache policy was not verified'); }
|
|
163
|
+
const now = opts.now ?? Date.now();
|
|
164
|
+
let prices = null;
|
|
165
|
+
try { prices = gatewayPrices(payload); } catch { /* Prices are optional; the policy stands alone. */ }
|
|
166
|
+
const snapshot = { checkedAt: now, models: gatewayCachePolicies(payload), prices };
|
|
167
|
+
const file = cacheFile(provider, opts);
|
|
168
|
+
// Persist only policy and price fields, never raw model/info (which can contain secrets).
|
|
169
|
+
mkdirSync(join(opts.dir || userDataDir(), 'codex-cache-policy'), { recursive: true, mode: 0o700 });
|
|
170
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
171
|
+
writeFileSync(tmp, JSON.stringify(snapshot) + '\n', { mode: 0o600 });
|
|
172
|
+
renameSync(tmp, file);
|
|
173
|
+
return snapshot;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function refreshCodexCachePolicy(session, { fetchImpl = fetch, execute, ...opts } = {}) {
|
|
177
|
+
const direct = directPolicy(session, opts);
|
|
178
|
+
if (direct) return direct;
|
|
179
|
+
const provider = gatewayProvider(session, opts);
|
|
180
|
+
if (!provider) throw new Error('No configured LiteLLM endpoint for this session provider');
|
|
181
|
+
const snapshot = await writeGatewaySnapshot(provider, { fetchImpl, execute, ...opts });
|
|
182
|
+
return selectPolicy(snapshot, session, snapshot.checkedAt);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Refresh between frames. Each provider/model has its own in-flight lookup. */
|
|
186
|
+
export function createCodexCachePolicyReader({ refresh = refreshCodexCachePolicy, now = Date.now, ...opts } = {}) {
|
|
187
|
+
const attempts = new Map();
|
|
188
|
+
return function read(session) {
|
|
189
|
+
const at = now();
|
|
190
|
+
const policy = readCodexCachePolicy(session, { ...opts, now: at });
|
|
191
|
+
if (policy || !session) return policy;
|
|
192
|
+
const provider = gatewayProvider(session, opts);
|
|
193
|
+
if (!provider) return null;
|
|
194
|
+
const key = JSON.stringify([provider.name, provider.base, session.model]);
|
|
195
|
+
const last = attempts.get(key);
|
|
196
|
+
if (!last || (!last.pending && at - last.at >= CACHE_POLICY_MAX_AGE_MS)) {
|
|
197
|
+
const attempt = { at, pending: true };
|
|
198
|
+
attempts.set(key, attempt);
|
|
199
|
+
Promise.resolve().then(() => refresh(session, { ...opts, now: at })).catch(() => {}).finally(() => { attempt.pending = false; });
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Minimum retention windows, not exact expiration timestamps. Gateway aliases
|
|
2
|
+
// require a resolved deployment before applying a provider's documented policy.
|
|
3
|
+
// https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime
|
|
4
|
+
export function codexCachePolicy(model) {
|
|
5
|
+
return /^gpt-(?:5\.6(?:-(?:sol|terra|luna))?|6-(?:astra|sol|luna))(?:-\d{4}-\d{2}-\d{2})?$/i.test(model || '') ? 1800 : null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseCodexCacheTtl(value) {
|
|
9
|
+
if (value === 'auto' || value === 'off') return value;
|
|
10
|
+
const match = /^(\d+)(m|h)$/.exec(value || '');
|
|
11
|
+
const seconds = match ? Number(match[1]) * (match[2] === 'h' ? 3600 : 60) : NaN;
|
|
12
|
+
if (!Number.isSafeInteger(seconds) || seconds < 60 || seconds > 86400) {
|
|
13
|
+
throw new Error('Cache TTL expects auto, off, or 1m through 24h (for example 30m). This changes the display estimate, not provider retention.');
|
|
14
|
+
}
|
|
15
|
+
return seconds;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function codexCacheTimer(session, { now = Date.now(), ttl = 'auto', policy } = {}) {
|
|
19
|
+
if (ttl === 'off') return null;
|
|
20
|
+
const activity = session?.cacheActivity;
|
|
21
|
+
const at = activity?.at ? new Date(activity.at).getTime() : NaN;
|
|
22
|
+
const sameModel = activity?.model === session?.model;
|
|
23
|
+
if (!Number.isFinite(at) || at > now || !sameModel || activity?.provider !== session?.provider) {
|
|
24
|
+
return { text: 'Cache timer n/a', tone: 90, source: 'unavailable' };
|
|
25
|
+
}
|
|
26
|
+
const elapsed = Math.floor((now - at) / 1000);
|
|
27
|
+
const configured = Number.isSafeInteger(ttl) && ttl >= 60 && ttl <= 86400;
|
|
28
|
+
const resolved = policy && policy.requestedModel === session.model && policy.provider === session.provider &&
|
|
29
|
+
['OpenAI', 'Bedrock'].includes(policy.backend) && policy.seconds === codexCachePolicy(policy.model) &&
|
|
30
|
+
Number.isFinite(policy.checkedAt) && now >= policy.checkedAt && now - policy.checkedAt < 300000;
|
|
31
|
+
const direct = policy === undefined && session.provider === 'openai' && codexCachePolicy(session.model);
|
|
32
|
+
const seconds = configured ? ttl : resolved ? policy.seconds : direct;
|
|
33
|
+
if (!seconds) return { text: `Cache age ${clock(elapsed)} / TTL unknown`, tone: 90, source: 'age', elapsed };
|
|
34
|
+
const remaining = Math.max(0, seconds - elapsed);
|
|
35
|
+
const source = configured ? 'configured estimate' : `${resolved ? policy.backend : 'OpenAI'} 30m`;
|
|
36
|
+
const text = remaining > 0 ? `Cache ${clock(remaining)} (${source})` : `Cache window elapsed (${source}; expiry unknown)`;
|
|
37
|
+
return { text, tone: remaining <= 0 ? 90 : remaining <= 60 ? 31 : remaining <= 300 ? 33 : 32,
|
|
38
|
+
source: configured ? 'configured' : 'provider-policy', seconds, elapsed, remaining };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clock(seconds) {
|
|
42
|
+
const h = Math.floor(seconds / 3600);
|
|
43
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
44
|
+
const s = seconds % 60;
|
|
45
|
+
return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parse } from 'smol-toml';
|
|
5
|
+
import { userDataDir } from './paths.js';
|
|
6
|
+
import { codexUserDir, codexProviderName } from './agent.js';
|
|
7
|
+
import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
8
|
+
import { looksPasted } from './route-inject.js';
|
|
9
|
+
import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
|
|
10
|
+
|
|
11
|
+
export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
|
|
12
|
+
const MARKER = '<!-- sprag:codex:delegation -->';
|
|
13
|
+
const safeId = (value) => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,255}$/.test(value);
|
|
14
|
+
const validEffort = (value) => value === undefined || ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(value);
|
|
15
|
+
|
|
16
|
+
export function validateCodexTarget(model, effort) {
|
|
17
|
+
if (!safeId(model)) throw new Error('Provide a literal Codex model ID.');
|
|
18
|
+
if (!validEffort(effort)) {
|
|
19
|
+
throw new Error('Invalid reasoning effort; choose a value supported by the target model.');
|
|
20
|
+
}
|
|
21
|
+
return { model, ...(effort === undefined ? {} : { effort }) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rulesFile = (dir) => join(dir, 'codex-model-rules.json');
|
|
25
|
+
export function loadCodexModelRules({ dir = userDataDir() } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(readFileSync(rulesFile(dir), 'utf8'));
|
|
28
|
+
if (data?.version !== 1 || !Array.isArray(data.rules) || data.rules.some((r) => !r ||
|
|
29
|
+
!CODEX_ROUTE_CATEGORIES.includes(r.category) || !safeId(r.from) || !safeId(r.model) || r.from === r.model ||
|
|
30
|
+
!validEffort(r.effort) || r.status !== 'active' || !['global', 'project'].includes(r.scope) ||
|
|
31
|
+
(r.provider != null && !safeId(r.provider)) ||
|
|
32
|
+
(r.scope === 'project' ? typeof r.targetRoot !== 'string' || resolve(r.targetRoot) !== r.targetRoot : r.targetRoot !== null))) throw new Error();
|
|
33
|
+
return data.rules;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e.code === 'ENOENT') return [];
|
|
36
|
+
throw new Error('Cannot parse Codex model rules; file left unchanged.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function saveRules(rules, dir) {
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
const file = rulesFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
43
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, rules }, null, 2) + '\n', { mode: 0o600 });
|
|
44
|
+
renameSync(tmp, file);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function addCodexModelRule({ category, model, effort, from, scope, root, provider }, { dir = userDataDir() } = {}) {
|
|
48
|
+
validateCodexTarget(model, effort);
|
|
49
|
+
if (!CODEX_ROUTE_CATEGORIES.includes(category)) throw new Error(`Category must be ${CODEX_ROUTE_CATEGORIES.join(', ')}`);
|
|
50
|
+
if (!safeId(from) || from === model) throw new Error('--from must name a different parent model.');
|
|
51
|
+
if (!['global', 'project'].includes(scope)) throw new Error('Choose --global or --project for the rule.');
|
|
52
|
+
if (provider != null && !safeId(provider)) throw new Error('Invalid Codex provider ID.');
|
|
53
|
+
const rules = loadCodexModelRules({ dir });
|
|
54
|
+
const targetRoot = scope === 'project' ? resolve(root) : null;
|
|
55
|
+
const rule = { category, model, effort, from, scope, targetRoot, ...(provider ? { provider } : {}), status: 'active', createdAt: new Date().toISOString() };
|
|
56
|
+
const old = rules.findIndex((r) => r.category === category && r.from === from && r.scope === scope && r.targetRoot === targetRoot && r.provider === provider);
|
|
57
|
+
if (old < 0) rules.push(rule); else rules[old] = rule;
|
|
58
|
+
saveRules(rules, dir);
|
|
59
|
+
return rule;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function removeCodexModelRule(index, { dir = userDataDir() } = {}) {
|
|
63
|
+
const rules = loadCodexModelRules({ dir });
|
|
64
|
+
if (!Number.isSafeInteger(index) || index < 1 || index > rules.length) throw new Error('Unknown Codex rule index.');
|
|
65
|
+
rules.splice(index - 1, 1);
|
|
66
|
+
saveRules(rules, dir);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The category a prompt can be routed under at runtime, or null. The route
|
|
71
|
+
* scan counts episodes with this same gate, so a rule it proposes is one the
|
|
72
|
+
* prompt hook can actually match.
|
|
73
|
+
*/
|
|
74
|
+
export function codexPromptCategory(text) {
|
|
75
|
+
const prompt = typeof text === 'string' ? text.trim() : '';
|
|
76
|
+
if (prompt.length < 8 || ESCALATE_RE.test(prompt) || EDIT_RE.test(prompt) || /\b(?:implement|modify|fix|delete|push|commit)\b/i.test(prompt)) return null;
|
|
77
|
+
const category = categorize(prompt, null);
|
|
78
|
+
if (!category || (category.id === 'paste' && !looksPasted(prompt))) return null;
|
|
79
|
+
return category;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function matchCodexModelRule(text, { model, root, provider = codexProviderName(), rules = loadCodexModelRules() } = {}) {
|
|
83
|
+
const category = codexPromptCategory(text);
|
|
84
|
+
if (!category) return null;
|
|
85
|
+
const usable = rules.filter((r) => r?.status === 'active' && r.category === category.id && r.from === model &&
|
|
86
|
+
(!r.provider || r.provider === provider) &&
|
|
87
|
+
safeId(r.model) && validEffort(r.effort) && r.model !== model && (r.scope === 'global' || (r.scope === 'project' && r.targetRoot === resolve(root || '.'))));
|
|
88
|
+
return usable.find((r) => r.scope === 'project') || usable[0] || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function codexRouteHint(payload, opts = {}) {
|
|
92
|
+
const rule = matchCodexModelRule(payload?.prompt, { provider: payload?.model_provider || codexProviderName(), ...opts, model: payload?.model, root: opts.root || payload?.cwd });
|
|
93
|
+
if (!rule) return null;
|
|
94
|
+
return `[Sprag model routing] This request matches the approved ${rule.category} rule: delegate the bounded task to model ${rule.model}`
|
|
95
|
+
+ (rule.effort ? ` with reasoning_effort ${rule.effort}` : '')
|
|
96
|
+
+ '. Use the available native Codex subagent tool and a built-in role. Wait for its result, verify it, and report unfinished work. '
|
|
97
|
+
+ 'Do not spawn another CLI process. If subagent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function customRole(role, root, home) {
|
|
101
|
+
// Custom role files override an explicit spawn model. Leave those calls alone.
|
|
102
|
+
const layers = new Set([home]);
|
|
103
|
+
for (let dir = resolve(root); ; dir = dirname(dir)) {
|
|
104
|
+
layers.add(join(dir, '.codex'));
|
|
105
|
+
if (dirname(dir) === dir) break;
|
|
106
|
+
}
|
|
107
|
+
for (const layer of layers) {
|
|
108
|
+
try {
|
|
109
|
+
const cfg = parse(readFileSync(join(layer, 'config.toml'), 'utf8'));
|
|
110
|
+
if (Object.hasOwn(cfg.agents || {}, role)) return true;
|
|
111
|
+
} catch (e) { if (e.code !== 'ENOENT') return true; }
|
|
112
|
+
const dir = join(layer, 'agents');
|
|
113
|
+
let names;
|
|
114
|
+
try { names = readdirSync(dir); } catch (e) { if (e.code === 'ENOENT') continue; return true; }
|
|
115
|
+
for (const name of names.filter((n) => n.endsWith('.toml'))) {
|
|
116
|
+
try { if (parse(readFileSync(join(dir, name), 'utf8')).name === role) return true; }
|
|
117
|
+
catch { return true; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
124
|
+
if (cfg.codex?.delegate !== true) return null;
|
|
125
|
+
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
|
126
|
+
if (!['spawn_agent', 'Agent'].includes(tool)) return null;
|
|
127
|
+
const input = payload.tool_input;
|
|
128
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || typeof input.message !== 'string' || !input.message.trim() || input.message.includes(MARKER)) return null;
|
|
129
|
+
const role = input.agent_type || 'default';
|
|
130
|
+
if (!['default', 'worker', 'explorer'].includes(role) || customRole(role, payload.cwd || root, home)) return null;
|
|
131
|
+
const explicit = Object.hasOwn(input, 'model');
|
|
132
|
+
const provider = payload.model_provider || codexProviderName({ home });
|
|
133
|
+
const rule = explicit ? null : matchCodexModelRule(input.message, { model: payload.model, provider, root, rules });
|
|
134
|
+
const target = rule || (!explicit && cfg.codex?.delegateTarget);
|
|
135
|
+
const updatedInput = { ...input };
|
|
136
|
+
let routeLine = '';
|
|
137
|
+
if (target) {
|
|
138
|
+
validateCodexTarget(target.model, target.effort);
|
|
139
|
+
updatedInput.model = target.model;
|
|
140
|
+
if (target.effort !== undefined && !Object.hasOwn(input, 'reasoning_effort')) updatedInput.reasoning_effort = target.effort;
|
|
141
|
+
// Only a route Sprag applied, with a known parent, can be credited later.
|
|
142
|
+
if (safeId(payload.model) && payload.model !== target.model) {
|
|
143
|
+
const id = newRouteId();
|
|
144
|
+
try {
|
|
145
|
+
record({ id, parentSessionId: typeof payload.session_id === 'string' ? payload.session_id : null,
|
|
146
|
+
source: rule ? 'rule' : 'default', category: rule?.category ?? null, scope: rule?.scope ?? null,
|
|
147
|
+
from: payload.model, to: target.model, provider, effort: updatedInput.reasoning_effort ?? null }, { dir });
|
|
148
|
+
routeLine = `\n<!-- sprag:codex:route id=${id} -->`;
|
|
149
|
+
} catch { /* Accounting is optional; the route itself still applies. */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
updatedInput.message += `\n\n${MARKER}${routeLine}\nStay within the assigned task and requested output shape. Back findings with file references or actual command results. State what you could not verify. Honor caller-provided limits; do not invent a budget or assume the parent's model tier. Stop and report partial work when a limit or unavailable dependency prevents completion. Do not recursively delegate this bounded task.`;
|
|
153
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput } };
|
|
154
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { extname, join, resolve } from 'node:path';
|
|
4
|
+
import { codexUserDir } from './agent.js';
|
|
5
|
+
import { userDataDir } from './paths.js';
|
|
6
|
+
|
|
7
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const ledgerDir = ({ home = codexUserDir(), dir = userDataDir() } = {}) => join(dir, 'codex-doc2md', hash(resolve(home)));
|
|
9
|
+
const finite = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
10
|
+
|
|
11
|
+
// One file per source avoids lost updates when parallel hooks prepare documents.
|
|
12
|
+
export function recordCodexDocument(source, result, opts) {
|
|
13
|
+
if (!result?.ok) return;
|
|
14
|
+
try {
|
|
15
|
+
const dir = ledgerDir(opts);
|
|
16
|
+
const file = join(dir, `${hash(realpathSync(source))}.json`);
|
|
17
|
+
const meta = result.meta || {};
|
|
18
|
+
const event = { version: 1, ext: extname(source).slice(1).toLowerCase(),
|
|
19
|
+
sourceBytes: finite(meta.size) ? meta.size : null,
|
|
20
|
+
markdownBytes: finite(meta.markdownBytes) ? meta.markdownBytes : null,
|
|
21
|
+
clipped: !!(meta.clipped || meta.truncated) };
|
|
22
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
23
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
24
|
+
writeFileSync(tmp, JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
25
|
+
renameSync(tmp, file);
|
|
26
|
+
} catch { /* Accounting must not prevent document conversion. */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function codexDocumentTotals(opts) {
|
|
30
|
+
const total = { docs: 0, byExt: [], scope: 'codex-total' };
|
|
31
|
+
const byExt = new Map();
|
|
32
|
+
try {
|
|
33
|
+
const dir = ledgerDir(opts);
|
|
34
|
+
for (const file of readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name))) {
|
|
35
|
+
try {
|
|
36
|
+
const event = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
37
|
+
if (event?.version !== 1 || !['pdf', 'pptx', 'xlsx', 'xls', 'docx', 'fig'].includes(event.ext)) continue;
|
|
38
|
+
total.docs++;
|
|
39
|
+
byExt.set(event.ext, (byExt.get(event.ext) || 0) + 1);
|
|
40
|
+
} catch { /* Ignore only the damaged record. */ }
|
|
41
|
+
}
|
|
42
|
+
} catch { /* No Codex document history yet. */ }
|
|
43
|
+
total.byExt = [...byExt].map(([ext, docs]) => ({ ext, docs })).sort((a, b) => b.docs - a.docs || a.ext.localeCompare(b.ext));
|
|
44
|
+
return total;
|
|
45
|
+
}
|