claude-token-saver 3.44.0 → 3.55.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/LICENSE +202 -21
- package/NOTICE +35 -0
- package/README.ko.md +150 -48
- package/README.md +205 -192
- package/bin/cli.js +108 -39
- package/docs/CODEX.ko.md +184 -0
- package/docs/CODEX.md +497 -0
- package/examples/statusline-command.ps1 +11 -5
- package/examples/statusline-command.sh +9 -4
- package/package.json +20 -6
- package/presets/delegation/bounds.md +29 -0
- package/presets/model-rules.json +50 -9
- package/presets/ratchet-rules.json +4 -1
- package/src/agent.js +33 -0
- package/src/caps-cache.js +6 -2
- package/src/changelog.js +70 -0
- package/src/cli-name.js +25 -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 +138 -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-setup.js +98 -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 +24 -1
- package/src/commands/codex-delegate.js +59 -0
- package/src/commands/codex.js +579 -0
- package/src/commands/delegate.js +133 -0
- package/src/commands/harness.js +16 -3
- package/src/commands/install.js +109 -12
- package/src/commands/korean.js +2 -2
- package/src/commands/mode.js +22 -2
- package/src/commands/profile-map.js +164 -0
- package/src/commands/route-scan.js +74 -7
- package/src/commands/seed.js +16 -12
- package/src/commands/uninstall.js +28 -4
- package/src/commands/upgrade.js +13 -0
- package/src/compact-window.js +7 -1
- package/src/config.js +22 -3
- package/src/cost.js +34 -0
- package/src/delegation-guard.js +278 -0
- package/src/doc2md-ledger.cjs +3 -3
- package/src/doc2md.cjs +72 -28
- package/src/effort-palette.js +145 -0
- package/src/formatters/statusline.js +352 -123
- package/src/formatters/table.js +5 -4
- package/src/gateway-auth.js +86 -0
- package/src/glyphs.js +103 -0
- package/src/handoff.js +27 -7
- package/src/harness-analyzer.cjs +7 -2
- package/src/harness-templates.js +33 -1
- package/src/harness.js +51 -17
- package/src/hook-manager.js +42 -14
- package/src/hook.cjs +34 -2
- package/src/installer.js +300 -16
- package/src/litellm-budget.js +38 -67
- package/src/litellm-models.js +264 -0
- package/src/model-alias.js +170 -16
- package/src/model-rules.js +53 -17
- package/src/parser.js +89 -1
- package/src/preset-ratchet.js +133 -0
- package/src/route-inject.js +161 -0
- package/src/route-scan.js +68 -8
- package/src/seed-rules.js +117 -55
- package/src/session-paths.js +242 -0
- package/src/statusline-mode.js +106 -0
- package/src/stdin-payload.js +40 -0
- package/src/ultracode.js +202 -0
- package/src/update-check.js +217 -3
- package/src/window-labels.js +19 -8
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'smol-toml';
|
|
4
|
+
import { codexUserDir } from './agent.js';
|
|
5
|
+
import { codexHookStatus } from './codex-installer.js';
|
|
6
|
+
import { createPanelReader } from './codex-panel.js';
|
|
7
|
+
import { panelHealth } from './codex-panel-state.js';
|
|
8
|
+
|
|
9
|
+
export const CODEX_CAPABILITIES = [
|
|
10
|
+
{ feature: 'statusline', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Local terminal output; native footer accepts only Codex item identifiers.' },
|
|
11
|
+
{ feature: 'panel', support: 'supported', command: 'sprag panel --agent codex', note: 'Local logs; tmux inline layout; optional macOS window.' },
|
|
12
|
+
{ feature: 'reports', support: 'supported', command: 'sprag --agent codex --format json', note: 'Token usage, cached input, reasoning, session and project filters.' },
|
|
13
|
+
{ feature: 'harness', support: 'supported', command: 'sprag harness check --agent codex', note: 'AGENTS.md, overrides, explicit-scope ratchet rule management.' },
|
|
14
|
+
{ feature: 'brief', support: 'supported', command: 'sprag brief --agent codex', note: 'Fresh context and limit warnings; change-triggered prompt hook.' },
|
|
15
|
+
{ feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only session warning records; last shows the latest warning.' },
|
|
16
|
+
{ feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
|
|
17
|
+
{ feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
|
|
18
|
+
{ feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
|
|
19
|
+
{ feature: 'doc2md', support: 'supported', command: 'sprag doc2md status --agent codex', note: 'Shared converter, prompt paths, supported file reads, and recognized document/cache write guards; not arbitrary shell extraction.' },
|
|
20
|
+
{ feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Opt-in native spawn model routing, scoped category rules, and SubagentStart guidance. Explicit models/custom roles preserved; no enforced budget.' },
|
|
21
|
+
{ feature: 'routing-saved', support: 'partial', command: 'sprag route-scan savings --agent codex', note: 'Sprag-routed child usage with matching LiteLLM prices; counterfactual estimate, not billed savings. Unpriced runs stay unknown.' },
|
|
22
|
+
{ feature: 'cache-timer', support: 'partial', command: 'sprag cache status --refresh --agent codex', note: 'Documented minimum retention after recorded cache usage; LiteLLM deployments resolved through model/info. Unknown routes show age; exact expiry is not reported.' },
|
|
23
|
+
{ feature: 'mode', support: 'partial', command: 'sprag mode --agent codex', note: 'Labels, color, and timer apply to panel/statusline; Claude TTL buckets do not.' },
|
|
24
|
+
{ feature: 'upgrade', support: 'manual', command: 'sprag upgrade --print --agent codex', note: 'Skip the Claude npm postinstall; reinstall Codex hooks after upgrade.' },
|
|
25
|
+
{ feature: 'route-scan', support: 'supported', command: 'sprag route-scan --agent codex', note: 'Recurring simple turns grouped by project, provider, and parent model; explicit approval before routing.' },
|
|
26
|
+
{ feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Agent-compatible ratchet presets; separate decisions and explicit scope. No Claude tier presets.' },
|
|
27
|
+
{ feature: 'transcript ratchet', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Recent repeated tool failures become ratchet? candidates; no automatic rule registration.' },
|
|
28
|
+
{ feature: 'compact-window', support: 'native', note: 'Codex uses model_auto_compact_token_limit; no Claude autoCompactWindow heuristic.' },
|
|
29
|
+
{ feature: 'cost / exact cache expiry', support: 'unavailable', note: 'Not measured in rollout logs; never estimated with Anthropic prices.' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export async function codexDoctor({ root = process.cwd(), sessionId, days = 7 } = {}) {
|
|
33
|
+
const home = codexUserDir();
|
|
34
|
+
const file = join(home, 'config.toml');
|
|
35
|
+
const config = { file, present: existsSync(file) };
|
|
36
|
+
try {
|
|
37
|
+
const cfg = config.present ? parse(readFileSync(file, 'utf8')) : {};
|
|
38
|
+
// Only expose an allowlist. Provider auth and arbitrary config may hold secrets.
|
|
39
|
+
config.hooksDisabled = cfg.features?.hooks === false || (cfg.features?.hooks === undefined && cfg.features?.codex_hooks === false);
|
|
40
|
+
config.statusLine = Array.isArray(cfg.tui?.status_line) ? cfg.tui.status_line.filter((item) => typeof item === 'string') : null;
|
|
41
|
+
config.autoCompactTokenLimit = Number.isFinite(cfg.model_auto_compact_token_limit) ? cfg.model_auto_compact_token_limit : null;
|
|
42
|
+
config.note = 'User config only; active profile, project, managed policy, and runtime overrides may differ.';
|
|
43
|
+
} catch { config.error = 'Cannot parse user config.toml; file left unchanged.'; }
|
|
44
|
+
let hooks;
|
|
45
|
+
try { hooks = { file: join(home, 'hooks.json'), events: codexHookStatus(), trust: 'unknown; review in Codex /hooks' }; }
|
|
46
|
+
catch { hooks = { error: 'Cannot parse Codex hooks.json; file left unchanged.', events: [] }; }
|
|
47
|
+
const data = await createPanelReader({ root, sessionId, days })();
|
|
48
|
+
const session = data.session ? { id: data.session.sessionId, model: data.session.model,
|
|
49
|
+
lastActivity: data.session.lastActivity, usageUpdates: data.session.requestCount } : null;
|
|
50
|
+
return { agent: 'codex', home, root, config, hooks, harness: data.harness, rules: data.rules,
|
|
51
|
+
preferences: { korean: data.korean, lint: data.lint, cohesion: data.cohesion, doc2md: data.doc2md, brief: data.brief, delegate: data.delegate },
|
|
52
|
+
session, panel: panelHealth(root, { sessionId }), capabilities: CODEX_CAPABILITIES };
|
|
53
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { codexUserDir } from './agent.js';
|
|
5
|
+
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines } from './codex-parser.js';
|
|
6
|
+
|
|
7
|
+
export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
|
|
8
|
+
export const CODEX_END = '<!-- sprag:codex:harness:end -->';
|
|
9
|
+
const sections = [
|
|
10
|
+
['Ratchet', 'When a mistake recurs, propose a concise condition/action rule. Ask the user for global or project scope before registering it with `sprag harness promote --agent codex --global|--project "rule"`.'],
|
|
11
|
+
['Evidence', 'Support completion claims with actual test results, command output, a file diff, or a screenshot. State any verification you could not run.'],
|
|
12
|
+
['Plan, Execute, Verify', 'For multi-step work, plan the steps, execute them, and verify the result before reporting completion.'],
|
|
13
|
+
['Structured Task', 'Establish the goal, constraints, verification method, and completion criteria before substantial work.'],
|
|
14
|
+
['Default Safe Path', 'Ask before destructive or external actions unless already authorized. Proceed with reads, local edits, and tests within the granted permissions.'],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function codexHarnessPaths(root, scope = 'project') {
|
|
18
|
+
const dir = scope === 'global' ? codexUserDir() : root;
|
|
19
|
+
// Codex reads the override instead of AGENTS.md when it is non-empty.
|
|
20
|
+
const override = join(dir, 'AGENTS.override.md');
|
|
21
|
+
const file = existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
|
|
22
|
+
return { file, ratchet: scope === 'global' ? join(dir, 'ratchet.md') : join(dir, '.codex', 'ratchet.md') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function codexHarnessBlock() {
|
|
26
|
+
return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments.\n${CODEX_END}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function range(content) {
|
|
30
|
+
const begin = content.indexOf(CODEX_BEGIN);
|
|
31
|
+
const end = content.indexOf(CODEX_END);
|
|
32
|
+
if ((begin < 0) !== (end < 0) || (begin >= 0 && end < begin) || content.indexOf(CODEX_BEGIN, begin + CODEX_BEGIN.length) >= 0) {
|
|
33
|
+
throw new Error('Malformed Sprag Codex harness markers; repair them before updating this file.');
|
|
34
|
+
}
|
|
35
|
+
return begin < 0 ? null : [begin, end + CODEX_END.length];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function initCodexHarness({ root, scope = 'project' }) {
|
|
39
|
+
const paths = codexHarnessPaths(root, scope);
|
|
40
|
+
const existing = existsSync(paths.file) ? readFileSync(paths.file, 'utf8') : '';
|
|
41
|
+
const span = range(existing);
|
|
42
|
+
const block = codexHarnessBlock();
|
|
43
|
+
const next = span ? existing.slice(0, span[0]) + block.trimEnd() + existing.slice(span[1])
|
|
44
|
+
: existing + (existing ? '\n\n' : '') + block;
|
|
45
|
+
mkdirSync(dirname(paths.file), { recursive: true });
|
|
46
|
+
if (next !== existing) {
|
|
47
|
+
if (existing) writeFileSync(`${paths.file}.bak-${Date.now()}`, existing);
|
|
48
|
+
writeFileSync(paths.file, next);
|
|
49
|
+
}
|
|
50
|
+
if (!existsSync(paths.ratchet)) {
|
|
51
|
+
mkdirSync(dirname(paths.ratchet), { recursive: true });
|
|
52
|
+
writeFileSync(paths.ratchet, '# Codex Ratchet Rules\n\n## Rules\n\n');
|
|
53
|
+
}
|
|
54
|
+
return paths;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function uninitCodexHarness({ root, scope = 'project' }) {
|
|
58
|
+
const { file } = codexHarnessPaths(root, scope);
|
|
59
|
+
if (!existsSync(file)) return { file, removed: false };
|
|
60
|
+
const existing = readFileSync(file, 'utf8');
|
|
61
|
+
const span = range(existing);
|
|
62
|
+
if (!span) return { file, removed: false };
|
|
63
|
+
writeFileSync(`${file}.bak-${Date.now()}`, existing);
|
|
64
|
+
writeFileSync(file, existing.slice(0, span[0]) + existing.slice(span[1]));
|
|
65
|
+
return { file, removed: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function codexHarnessStatus({ root, scope = 'project' }) {
|
|
69
|
+
const { file } = codexHarnessPaths(root, scope);
|
|
70
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
71
|
+
const span = range(text);
|
|
72
|
+
const block = span ? text.slice(...span) : '';
|
|
73
|
+
const missing = sections.filter(([name], i) => !block.includes(`### ${i + 1}. ${name}`)).map(([name]) => name);
|
|
74
|
+
return { file, configured: sections.length - missing.length, total: sections.length, missing };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
78
|
+
const LOOKUP = new Set(['read', 'search', 'list_files']);
|
|
79
|
+
|
|
80
|
+
/** The script a `zsh -lc` argv runs, or the argv itself. */
|
|
81
|
+
function commandLine(argv) {
|
|
82
|
+
const i = argv.findIndex((a) => a === '-lc' || a === '-c');
|
|
83
|
+
return (i >= 0 ? argv[i + 1] : argv.join(' ')) || '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Shell words of a script's first command, honoring simple quotes. */
|
|
87
|
+
function firstWords(script, n) {
|
|
88
|
+
const words = [];
|
|
89
|
+
for (const m of String(script).matchAll(/'([^']*)'|"([^"]*)"|([^\s'"|;&]+)|([|;&])/g)) {
|
|
90
|
+
if (m[4]) break;
|
|
91
|
+
words.push(m[1] ?? m[2] ?? m[3]);
|
|
92
|
+
if (words.length === n) break;
|
|
93
|
+
}
|
|
94
|
+
return words;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Tools whose exit 1 is an answer, not an error: grep-style "no match" and
|
|
98
|
+
// diff/cmp-style "the inputs differ". Only exit 1 is exempt; 2+ still counts.
|
|
99
|
+
const ANSWER_EXIT_1 = /^(?:diff|cmp|grep|egrep|fgrep|rg|ag|test|\[|comm)$|^git (?:diff|grep)$/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A failure's identity across retries: the command's first two words plus the
|
|
103
|
+
* start of its output, with run-specific noise (paths, ids, positions) removed
|
|
104
|
+
* so the same failure in another temp dir still matches. Other numbers stay,
|
|
105
|
+
* since exit codes and counts carry meaning.
|
|
106
|
+
*/
|
|
107
|
+
export function codexErrorSignature(ev, { cwd, home = homedir() } = {}) {
|
|
108
|
+
let text = String(ev.output || '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
109
|
+
if (cwd) text = text.replace(new RegExp(escapeRe(cwd), 'g'), '.');
|
|
110
|
+
if (home) text = text.replace(new RegExp(escapeRe(home), 'g'), '~');
|
|
111
|
+
text = text.replace(/(?:\/private)?\/(?:tmp|var\/folders)\/[^\s:'"]+/g, '<tmp>')
|
|
112
|
+
.replace(/\b[0-9a-f]{8,}(?:-[0-9a-f]{4,}){0,4}\b/gi, '<id>')
|
|
113
|
+
.replace(/:\d+:\d+/g, ':N:N')
|
|
114
|
+
.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
115
|
+
const head = ev.kind === 'patch' ? 'apply_patch'
|
|
116
|
+
: firstWords(commandLine(ev.argv || []), 2).map((w) => w.split('/').pop()).join(' ');
|
|
117
|
+
return `${head}: ${text}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether a failure is worth remembering, rather than a probe or Sprag's own feedback. */
|
|
121
|
+
function countable(ev) {
|
|
122
|
+
if (!ev.failed) return false;
|
|
123
|
+
// Sprag's write lint rewrites the tool output; that is feedback, not a mistake to learn from.
|
|
124
|
+
if (/^\[(?:korean-style|claude-token-saver|sprag)/.test(String(ev.output || '').trim())) return false;
|
|
125
|
+
if (ev.kind === 'patch') return true;
|
|
126
|
+
// `rg` finding nothing and `ls` on an absent path are how exploration works.
|
|
127
|
+
const types = (ev.parsed || []).map((c) => c?.type);
|
|
128
|
+
if (types.length && types.every((t) => LOOKUP.has(t))) return false;
|
|
129
|
+
if (ev.exit === 1) {
|
|
130
|
+
const words = firstWords(commandLine(ev.argv || []), 2).map((w) => w.split('/').pop());
|
|
131
|
+
if (ANSWER_EXIT_1.test(words[0]) || ANSWER_EXIT_1.test(words.join(' '))) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Repeated failures in a rollout's recent turns, newest-first by count. Reads
|
|
138
|
+
* only the file tail, which is where the current session's recent work is.
|
|
139
|
+
*/
|
|
140
|
+
export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home } = {}) {
|
|
141
|
+
const lines = readCodexTailLines(filePath, tailBytes);
|
|
142
|
+
if (!lines) return [];
|
|
143
|
+
const order = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const failures = [];
|
|
146
|
+
const calls = new Map();
|
|
147
|
+
const outputs = [];
|
|
148
|
+
let cwd = null;
|
|
149
|
+
let current = null;
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
// Cheap prefilter: these candidate lines are a small share of a rollout.
|
|
152
|
+
if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
|
|
153
|
+
let e;
|
|
154
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
155
|
+
const p = e?.payload;
|
|
156
|
+
if (e?.type === 'session_meta' && typeof p?.cwd === 'string') cwd ??= p.cwd;
|
|
157
|
+
if (e?.type === 'turn_context' && typeof p?.cwd === 'string') cwd = p.cwd;
|
|
158
|
+
if (e?.type === 'event_msg' && p?.type === 'task_started') current = p.turn_id || current;
|
|
159
|
+
for (const ev of codexToolEvents(e)) {
|
|
160
|
+
if (ev.kind === 'call') { if (ev.callId) calls.set(ev.callId, ev); continue; }
|
|
161
|
+
if (ev.kind === 'output') {
|
|
162
|
+
if (ev.exit !== null && ev.callId) outputs.push({ ...ev, turn: current, cwd });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ev.kind !== 'command' && ev.kind !== 'patch') continue;
|
|
166
|
+
if (ev.callId) { if (seen.has(ev.callId)) continue; seen.add(ev.callId); }
|
|
167
|
+
const turn = ev.turnId || current;
|
|
168
|
+
if (turn && !order.includes(turn)) order.push(turn);
|
|
169
|
+
if (countable(ev)) failures.push({ ...ev, turn, cwd: ev.cwd || cwd });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const out of outputs) {
|
|
173
|
+
if (seen.has(out.callId)) continue;
|
|
174
|
+
const ev = codexLegacyToolEvent(calls.get(out.callId), out);
|
|
175
|
+
if (!ev) continue;
|
|
176
|
+
seen.add(out.callId);
|
|
177
|
+
if (ev.turn && !order.includes(ev.turn)) order.push(ev.turn);
|
|
178
|
+
if (countable(ev)) failures.push(ev);
|
|
179
|
+
}
|
|
180
|
+
const recent = new Set(order.slice(-window));
|
|
181
|
+
const groups = new Map();
|
|
182
|
+
for (const ev of failures) {
|
|
183
|
+
if (!recent.has(ev.turn) || !Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
|
|
184
|
+
const pattern = codexErrorSignature(ev, { cwd: ev.cwd, home });
|
|
185
|
+
const g = groups.get(pattern) || { pattern, count: 0, lastAt: 0 };
|
|
186
|
+
g.count++;
|
|
187
|
+
g.lastAt = Math.max(g.lastAt, ev.at);
|
|
188
|
+
groups.set(pattern, g);
|
|
189
|
+
}
|
|
190
|
+
return [...groups.values()].filter((g) => g.count >= 2)
|
|
191
|
+
.sort((a, b) => b.count - a.count || b.lastAt - a.lastAt).slice(0, 5)
|
|
192
|
+
.map((g, i) => ({ id: i + 1, ...g }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The ratchet text a candidate promotes to; the user replaces the TODO. */
|
|
196
|
+
export function codexCandidateRule(cand, lang) {
|
|
197
|
+
return lang === 'ko'
|
|
198
|
+
? `반복 감지 ×${cand.count}: ${cand.pattern} · TODO: 원인과 예방책을 한 줄로 적습니다`
|
|
199
|
+
: `Repeated failure x${cand.count}: ${cand.pattern} · TODO: write the cause and prevention in one line`;
|
|
200
|
+
}
|