sprag-cli 3.53.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.
@@ -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
+ }
@@ -0,0 +1,99 @@
1
+ import { createRequire } from 'node:module';
2
+ import { realpathSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { basename, dirname, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { recordCodexDocument } from './codex-doc2md-ledger.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const doc2md = require('./doc2md.cjs');
9
+ const lint = require('./korean-lint.cjs');
10
+ const shellTools = new Set(['Bash', 'exec_command', 'shell_command', 'shell']);
11
+
12
+ function canonicalPath(file) {
13
+ const missing = [];
14
+ for (let current = file; ; current = dirname(current)) {
15
+ try { return resolve(realpathSync(current), ...missing.reverse()); }
16
+ catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code) || dirname(current) === current) return file; }
17
+ missing.push(basename(current));
18
+ }
19
+ }
20
+
21
+ export function codexDocumentNote() {
22
+ return '[Sprag Doc2md]\nBefore reading PDF, Office, or Figma documents, run sprag doc2md <path> --agent codex and read the resulting Markdown. '
23
+ + 'Conversions are one-way: never edit the cached Markdown. To modify a document, copy the original and edit the copy with a format-aware library, then reconvert it to verify the text. '
24
+ + 'Text extraction does not verify charts, images, or layout. Report conversion failures and do not claim visual verification from text alone.';
25
+ }
26
+
27
+ export function convertCodexDocument(source, { convert = doc2md.convert, ...opts } = {}) {
28
+ let result;
29
+ try { result = convert(source, { agent: 'codex' }); }
30
+ catch { return { ok: false, reason: 'convert-failed' }; }
31
+ if (!result?.ok) return result || { ok: false, reason: 'convert-failed' };
32
+ let tmp;
33
+ try {
34
+ // Existing shared caches can carry Claude attachment baselines and prices.
35
+ // Keep their body, but give Codex a separate view with neutral provenance.
36
+ const original = readFileSync(result.cacheFile, 'utf8');
37
+ const end = original.startsWith('<!--\nsprag doc2md') ? original.indexOf('\n-->\n') : -1;
38
+ const body = end >= 0 ? original.slice(end + 5) : original;
39
+ const cacheFile = result.cacheFile.replace(/\.md$/, '.codex.md');
40
+ const text = `<!--\nsprag doc2md: generated Markdown; do not edit.\nSource: ${JSON.stringify(resolve(source))}\nText extraction does not verify images or layout.\n-->\n${body}`;
41
+ let previous;
42
+ try { previous = readFileSync(cacheFile, 'utf8'); } catch { /* First Codex use. */ }
43
+ if (previous !== text) {
44
+ tmp = `${cacheFile}.${randomUUID()}.tmp`;
45
+ writeFileSync(tmp, text, { mode: 0o600 });
46
+ renameSync(tmp, cacheFile);
47
+ tmp = null;
48
+ }
49
+ const { savedUsd, baselineTokens, ...meta } = result.meta || {};
50
+ const prepared = { ...result, cacheFile, meta };
51
+ recordCodexDocument(source, prepared, opts);
52
+ return prepared;
53
+ } catch {
54
+ return { ok: false, reason: 'cache-unavailable' };
55
+ } finally {
56
+ if (tmp) { try { rmSync(tmp, { force: true }); } catch { /* Best-effort temporary file cleanup. */ } }
57
+ }
58
+ }
59
+
60
+ /** Adapt supported Codex calls to the shared converter and write guard. */
61
+ export function codexDocumentTool(payload, { convert = convertCodexDocument, guard = doc2md.decideForWrite } = {}) {
62
+ const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
63
+ const input = payload?.tool_input;
64
+ const command = typeof input === 'string' ? input : input?.command ?? input?.cmd ?? input?.content ?? input?.patch;
65
+ const cwd = resolve(payload?.cwd || process.cwd(), typeof input?.workdir === 'string' ? input.workdir : '.');
66
+ let writes = [];
67
+ if (tool === 'apply_patch' && typeof command === 'string' && command.startsWith('*** Begin Patch')) {
68
+ writes = [...command.matchAll(/^\*\*\* (?:Add File|Update File|Move to): (.+)\r?$/gm)].map((m) => m[1].trimEnd());
69
+ } else if (['Edit', 'Write'].includes(tool) && typeof input?.file_path === 'string') writes = [input.file_path];
70
+ else if (shellTools.has(tool)) writes = lint.writtenPathsOfBash(command);
71
+ for (const path of writes) {
72
+ const file = resolve(cwd, path);
73
+ const canonical = canonicalPath(file);
74
+ for (const target of new Set([file, canonical])) {
75
+ const decision = guard({ tool_name: 'Write', tool_input: { file_path: target } }, { agent: 'codex' });
76
+ if (decision?.deny) return { hookSpecificOutput: { hookEventName: 'PreToolUse',
77
+ permissionDecision: 'deny', permissionDecisionReason: decision.reason } };
78
+ }
79
+ }
80
+
81
+ let file;
82
+ if (['Read', 'read_file', 'mcp__filesystem__read_file', 'mcp__filesystem__read_text_file'].includes(tool)) {
83
+ file = input?.file_path ?? input?.path;
84
+ } else if (shellTools.has(tool) && typeof command === 'string') {
85
+ // Only literal, single-file reads. Compound commands and expansions stay untouched.
86
+ const match = /^(?:cat\s+(?:--\s+)?|(?:head|tail)\s+(?:-n\s+\d+\s+)?)(?:"([^"$`\n]+)"|'([^'\n]+)'|([^\s'"`$;&|<>*?(){}\\]+))\s*$/.exec(command.trim());
87
+ if (match) file = match[1] || match[2] || match[3];
88
+ }
89
+ if (typeof file !== 'string' || !doc2md.isTargetPath(file)) return null;
90
+ const result = convert(resolve(cwd, file));
91
+ if (!result.ok) return { hookSpecificOutput: { hookEventName: 'PreToolUse',
92
+ ...(result.reason === 'unsafe-archive' ? { permissionDecision: 'deny', permissionDecisionReason: '[doc2md] Unsafe archive; do not extract it.' }
93
+ : { additionalContext: `[doc2md] Conversion failed (${result.reason}). Do not claim this document was converted. ${codexDocumentNote()}` }) } };
94
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
95
+ permissionDecisionReason: `[doc2md] Read the converted Markdown at ${JSON.stringify(result.cacheFile)} instead of extracting ${JSON.stringify(file)} again. `
96
+ + `Source ${result.meta?.size ?? 'unknown'} bytes; Markdown ${result.meta?.markdownBytes ?? 'unknown'} bytes.`
97
+ + (result.meta?.clipped ? ' The conversion is clipped; it is not the complete document.'
98
+ : result.meta?.truncated ? ' Text extraction is incomplete; it is not the complete document.' : '') } };
99
+ }