sprag-cli 3.55.0 → 3.56.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 +4 -0
- package/README.md +4 -0
- package/bin/cli.js +2 -2
- package/docs/CODEX.ko.md +40 -5
- package/docs/CODEX.md +69 -12
- package/package.json +1 -1
- package/src/claude-price.js +28 -0
- package/src/codex-brief.js +20 -0
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +45 -5
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +20 -9
- package/src/codex-hooks.js +106 -18
- package/src/codex-ledger.js +94 -16
- package/src/codex-panel.js +12 -7
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +18 -0
- package/src/commands/codex-delegate.js +12 -3
- package/src/commands/codex.js +18 -10
- package/src/commands/route-scan.js +4 -3
- package/src/cost.js +57 -3
- package/src/formatters/statusline.js +18 -7
- package/src/formatters/table.js +1 -0
- package/src/gateway-prices.js +39 -0
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +2 -1
- package/src/money.js +9 -0
- package/src/month-spend.js +10 -5
- package/src/openai-prices.js +33 -0
- package/src/route-scan.js +16 -8
- package/src/savings-ledger.js +10 -6
package/README.ko.md
CHANGED
|
@@ -209,6 +209,10 @@ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로
|
|
|
209
209
|
| [한국어 문체 지침](https://github.com/rootstudioyaml/sprag/blob/main/docs/KOREAN-STYLE.ko.md) | 주입 내용, 쓰기 시점 검사, 적용 전후 비교 |
|
|
210
210
|
| [라우터가 아닌 이유](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.ko.md) | 실시간 라우팅이 캐시를 깨서 비용을 키우는 구조 |
|
|
211
211
|
| [게이트웨이와 환경](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.ko.md) | Bedrock·Vertex·LiteLLM, 가격표, FAQ, 동작 원리 |
|
|
212
|
+
| [클로드 코드 토큰 줄이는 법](https://sprag.io/docs/ko/reduce-claude-code-tokens/) | 압축·초기화 타이밍, 서브에이전트 탐색, 문서 변환, 캐시 TTL |
|
|
213
|
+
| [클로드 코드 사용 한도](https://sprag.io/docs/ko/claude-code-usage-limits/) | 5시간·7일 윈도우, 함께 지켜보는 법, 턴당 비용 줄이기 |
|
|
214
|
+
| [코덱스 토큰 사용량](https://sprag.io/docs/ko/codex-token-usage/) | 세션 범위 나누기, 네이티브 서브에이전트 위임, 코덱스 문서 변환 |
|
|
215
|
+
| [도구 비교](https://sprag.io/docs/ko/compare/) | ccusage·claude-usage·CCometixLine 같은 스테이터스라인 도구와 Sprag의 차이 |
|
|
212
216
|
|
|
213
217
|
LiteLLM 게이트웨이 환경에서는 `sprag profile-map --refresh` 가 게이트웨이의 `GET /model/info` 에서 모델 별칭을 바로 읽어 오므로, route-scan 이 위임 투표가 쌓이길 기다리거나 `profile-map.json` 을 손으로 편집할 필요가 없어졌습니다. 이 갱신은 LiteLLM 예산 게이지와 같은 5분 점검·24시간 캐시 주기를 씁니다. 인증 토큰은 저장하지 않으며, 호출 시점마다 `apiKeyHelper` 에서 읽어 그 헬퍼의 수명 그대로 만료되게 둡니다. 자세한 내용은 [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md) 문서에 있습니다.
|
|
214
218
|
|
package/README.md
CHANGED
|
@@ -254,6 +254,10 @@ Every subcommand, flag and the output-language setting: [command reference](http
|
|
|
254
254
|
| [Korean style](https://github.com/rootstudioyaml/sprag/blob/main/docs/KOREAN-STYLE.md) | What gets injected, the write-time check, before/after |
|
|
255
255
|
| [Not a router](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.md) | Why realtime routing breaks the cache and costs more |
|
|
256
256
|
| [Gateways & environment](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.md) | Bedrock, Vertex, LiteLLM budgets, pricing table, FAQ, how it works |
|
|
257
|
+
| [Reduce Claude Code tokens](https://sprag.io/docs/reduce-claude-code-tokens/) | Compact/clear timing, subagent exploration, document conversion, cache TTL |
|
|
258
|
+
| [Claude Code usage limits](https://sprag.io/docs/claude-code-usage-limits/) | The 5-hour and 7-day windows, watching both, cutting per-turn cost |
|
|
259
|
+
| [Codex token usage](https://sprag.io/docs/codex-token-usage/) | Session scoping, native subagent delegation, document conversion for Codex |
|
|
260
|
+
| [Compare](https://sprag.io/docs/compare/) | Sprag vs ccusage, claude-usage, and statusline tools like CCometixLine |
|
|
257
261
|
|
|
258
262
|
Behind a LiteLLM gateway, `sprag profile-map --refresh` now reads model aliases straight from the gateway's own `GET /model/info`, so route-scan no longer needs to wait on learned votes or a hand-edited `profile-map.json`. The refresh uses the same 5-minute check and 24-hour cache as the LiteLLM budget gauge. It never stores your auth token: each refresh reads it from `apiKeyHelper` at call time and lets it expire on the helper's own schedule. Details: [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md).
|
|
259
263
|
|
package/bin/cli.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
sessionMetrics,
|
|
53
53
|
diagnoseSession,
|
|
54
54
|
} from '../src/stats.js';
|
|
55
|
-
import {
|
|
55
|
+
import { sessionCostAcross } from '../src/claude-price.js';
|
|
56
56
|
import { chipForIssues } from '../src/advice.js';
|
|
57
57
|
import { debug } from '../src/debug.js';
|
|
58
58
|
import { createArgs } from '../src/cli-args.js';
|
|
@@ -592,7 +592,7 @@ async function main() {
|
|
|
592
592
|
const ttl = ttlBreakdown(sessions);
|
|
593
593
|
const sum = summary(sessions);
|
|
594
594
|
const anomalies = detectAnomalies(trend);
|
|
595
|
-
const cost =
|
|
595
|
+
const cost = sessionCostAcross(sessions);
|
|
596
596
|
const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
|
|
597
597
|
const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
|
|
598
598
|
|
package/docs/CODEX.ko.md
CHANGED
|
@@ -51,7 +51,7 @@ macOS zsh 환경에서는 설치가 tmux 설치 여부를 먼저 확인합니다
|
|
|
51
51
|
| 한국어 검사 | 파일 쓰기 후 문체 위반을 검사하며 block·warn·off를 지원합니다 |
|
|
52
52
|
| 상태 표시 | 별도 패널, tmux 패널, 일회성 상태줄을 제공합니다 |
|
|
53
53
|
| 브리핑·이력 | 최신 컨텍스트와 사용량 경고를 알리고 세션별로 저장합니다 |
|
|
54
|
-
| 위임 원장 | Sprag가 위임한 하위
|
|
54
|
+
| 위임 원장 | Sprag가 위임한 하위 실행의 토큰에 호출 경로의 단가를 적용해 예상 차액을 계산합니다. 직접 호출은 OpenAI 정가를, LiteLLM 공급자는 그 게이트웨이의 단가를 씁니다 |
|
|
55
55
|
|
|
56
56
|
## 패널과 보고서
|
|
57
57
|
|
|
@@ -108,8 +108,34 @@ sprag seed reset --agent codex
|
|
|
108
108
|
등록할 때 `--model ID`를 요구합니다. 모델 사용 가능 여부는 실제 공급자 설정에 달려 있습니다.
|
|
109
109
|
명시한 모델과 사용자 정의 역할은 유지하며, 사용할 수 없는 하위 에이전트 도구를 만들어 내지 않습니다.
|
|
110
110
|
|
|
111
|
+
Codex 0.159.2에서는 `spawn_agent`가 `PreToolUse`를 거치지 않습니다(기록용 훅을 붙여 직접
|
|
112
|
+
확인한 결과입니다). 그래서 실제로 동작하는 경로는 프롬프트 시점입니다. 규칙에 맞는 프롬프트가
|
|
113
|
+
오면 `[Sprag model routing]` 안내가 붙고, `harness init`이 넣는 AGENTS.md 블록이 이 안내를
|
|
114
|
+
"사용자가 위임을 요청했다"는 뜻으로 읽게 합니다. 자식은 `fork_turns "none"`으로 뜨고, 부모는
|
|
115
|
+
같은 일을 직접 하지 않고 `wait_agent`(제한 시간 120초 이상)로 자식이 끝나기를 기다립니다. 귀속은
|
|
116
|
+
자식이 route id를 되받아 적는 것에 기대지 않습니다. 대신 `SubagentStart`가 대기 중인 기록(같은
|
|
117
|
+
부모 세션, 같은 목표 모델, 30분 이내)에 새로 뜬 자식을 매칭해 묶습니다.
|
|
118
|
+
|
|
119
|
+
이 안내는 부모 세션의 컨텍스트가 `codex.delegateMinContext`(기본 60000 토큰) 아래면 붙지
|
|
120
|
+
않습니다. 2026-09-30 실측에서 작은 세션은 스폰·대기·검증이라는 조율 비용이 자식이 아끼는
|
|
121
|
+
비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다. 아직 전용 CLI 명령은
|
|
122
|
+
없고, `sprag mode` 실행 시 "Stored config file"로 나오는 설정 파일(맥은
|
|
123
|
+
`~/Library/Application Support/claude-token-saver/config.json`)에서 `codex.delegateMinContext`
|
|
124
|
+
값을 직접 고치면 됩니다. `PreToolUse` 재작성은 spawn을 훅으로 보내는 Codex 버전을 위해 남아
|
|
125
|
+
있을 뿐, 0.159.2에서는 호출되지 않습니다. 위임 원장은 자식이 쓴 토큰을 부모 모델 단가로 환산한
|
|
126
|
+
추정치이며, 부모가 조율에 쓴 턴은 빼지 않습니다. 끝나기 전에 중단된 자식은 부모의 일을 대신하지
|
|
127
|
+
못했으므로, 그 비용 전액을 손실로 기록하고 항목에 `aborted`를 표시합니다.
|
|
128
|
+
|
|
111
129
|
SessionStart는 캐시된 후보를 읽고 필요할 때 별도 프로세스에서 갱신합니다. 명시적인 `--refresh`는
|
|
112
|
-
대기 주기를 건너뜁니다.
|
|
130
|
+
대기 주기를 건너뜁니다.
|
|
131
|
+
|
|
132
|
+
Codex 0.159.2는 훅 하나가 넘긴 컨텍스트를 약 2,450토큰까지만 보존하고, 그보다 길면 가운데를
|
|
133
|
+
잘라 냅니다. 그래서 SessionStart는 추정치 2,000토큰 안에 들어가는 항목만 잘리지 않은 상태로
|
|
134
|
+
넣습니다. 우선순위는 ratchet 룰, 한국어 지침, 응집성 지침, 문서 변환 안내, 제안 순입니다. 들어가지
|
|
135
|
+
못한 룰과 지침은 Sprag 데이터 디렉터리의 프로젝트별 파일에 쓰고, 컨텍스트 첫머리에서 그 파일을
|
|
136
|
+
먼저 읽으라고 안내합니다. rollout의 `originator`로 `codex exec` 실행을 알아보며, 답할 사람이
|
|
137
|
+
없으므로 seed 제안과 route 안내를 넣지 않습니다.
|
|
138
|
+
`seed`는 Claude의 Haiku·Sonnet 프리셋을 가져오지 않으며,
|
|
113
139
|
Codex의 수락·거절 기록도 Claude 기록과 분리합니다.
|
|
114
140
|
|
|
115
141
|
## 래칫 규칙
|
|
@@ -158,9 +184,18 @@ sprag capabilities --agent codex
|
|
|
158
184
|
하위 실행의 토큰을 고정하고 부모·자식 단가를 각각 적용한 차이이므로, 실제 청구서의 절감액은
|
|
159
185
|
아닙니다. 손해는 음수로 남기며 단가나 사용량이 없으면 0 대신 미상으로 표시합니다.
|
|
160
186
|
|
|
161
|
-
단가는
|
|
162
|
-
|
|
163
|
-
|
|
187
|
+
단가는 호출이 실제로 거친 경로로 정하며, 두 출처는 서로를 대신하지 않습니다.
|
|
188
|
+
|
|
189
|
+
- ChatGPT·OpenAI 로그인이나 `api.openai.com`으로 바로 가는 API 키는 내장된 OpenAI 정가를
|
|
190
|
+
씁니다. 정가는 Standard 등급의 짧은 컨텍스트 단가이고 2026-10-01에 확인했습니다. 긴 컨텍스트
|
|
191
|
+
할증은 반영하지 않으므로, 그 기준을 넘는 요청은 실제보다 낮게 계산됩니다.
|
|
192
|
+
- LiteLLM 공급자는 그 게이트웨이의 `/model/info` 단가를 씁니다. 같은 별칭 아래 배포 단가가
|
|
193
|
+
다르면 계산하지 않고, 단가 캐시는 7일 동안 사용합니다. 게이트웨이가 단가를 주지 않는 모델은
|
|
194
|
+
정가로 채우지 않고 미상으로 둡니다.
|
|
195
|
+
|
|
196
|
+
공급자가 다른 동명 모델은 섞지 않으며, 이미 계산한 실행은 단가 사본을 보존합니다.
|
|
197
|
+
`sprag delegate rules --agent codex`는 룰마다 실측 위임 건수와 실패율, 절감액을 보여 줍니다.
|
|
198
|
+
실패율의 95% 신뢰 하한이 20%를 넘으면(실측 5건 이상) 조건을 좁히거나 룰을 지우라고 알립니다.
|
|
164
199
|
위임 후 `--refresh`로 원장을 갱신할 수 있고, 아직 일치하지 않은 기록은 7일 동안 찾습니다.
|
|
165
200
|
|
|
166
201
|
캐시 타이머는 확인된 공급자 정책과 기록된 캐시 사용 시각을 보여 줍니다. 정확한 만료 시각이나
|
package/docs/CODEX.md
CHANGED
|
@@ -76,14 +76,14 @@ shared hooks needed by other features.
|
|
|
76
76
|
| Token report | Table, JSON, or CSV from local Codex rollout logs, with time and project filters |
|
|
77
77
|
| Statusline | `--statusline` or `--format statusline` renders the compact panel once, with wrapping and label/color preferences |
|
|
78
78
|
| Briefing | UserPromptSubmit reports fresh context/limit threshold crossings once per session and threshold cycle |
|
|
79
|
-
| Warning history | `last` and `history` read Codex-only, per-session
|
|
79
|
+
| Warning history | `last` and `history` read Codex-only, per-session records of context (80%/95%) and rate-limit (90%) warnings and handoffs; cache and TTL chips are not recorded |
|
|
80
80
|
| Handoff | Git state, session/model, recorded context/tokens/limits, and fillable remaining-work sections |
|
|
81
81
|
| Subagents | Opt-in native spawn model routing, scoped category rules, and SubagentStart guidance |
|
|
82
82
|
| Cache timer | Resolved provider retention policy after recorded cache usage; exact expiry is unknown |
|
|
83
83
|
| Route discovery | Repeated simple turns grouped by project, provider, and parent model; no automatic registration |
|
|
84
84
|
| Ratchet detection | Recent repeated command/patch failures produce `ratchet?` candidates |
|
|
85
85
|
| Seed presets | Compatible ratchet rules, separate decisions, explicit registration scope |
|
|
86
|
-
| Routing saved | Sprag-attributed child tokens priced
|
|
86
|
+
| Routing saved | Sprag-attributed child tokens priced by the path the call took (OpenAI list prices for direct calls, the gateway's `/model/info` prices for a LiteLLM provider); signed, so a pricier child is a loss; an estimate, not billed savings |
|
|
87
87
|
| Diagnostics | `doctor` checks user config, hook registration, harness, logs, panel activity, and preferences |
|
|
88
88
|
| Capability matrix | `capabilities` lists supported, partial, native, and unsupported areas without invoking a model |
|
|
89
89
|
|
|
@@ -151,12 +151,39 @@ warning records. A handoff file is never overwritten and identifies quota
|
|
|
151
151
|
values as recorded snapshots, not live responses. Fill in its remaining-work
|
|
152
152
|
sections before using it to continue in another session.
|
|
153
153
|
|
|
154
|
-
Delegation is independently opt-in via `delegate on`.
|
|
155
|
-
`spawn_agent`
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
Delegation is independently opt-in via `delegate on`. As of Codex 0.159.2,
|
|
155
|
+
`spawn_agent` never reaches `PreToolUse` (measured directly: a recording hook
|
|
156
|
+
saw only `Bash` calls while a spawn happened), so the rewrite below cannot see
|
|
157
|
+
or steer it. The live path instead runs at prompt time: `UserPromptSubmit`
|
|
158
|
+
checks the prompt against your category rules and, on a match, appends a
|
|
159
|
+
`[Sprag model routing]` note naming the target model, effort, and a route id.
|
|
160
|
+
That note by itself changes nothing, because Codex's own instructions tell it not to
|
|
161
|
+
spawn subagents unless something explicitly asked for it. So `harness init`'s
|
|
162
|
+
AGENTS.md block adds the missing half: a `[Sprag model routing]` note *is* the
|
|
163
|
+
user's delegation request, and the agent should spawn exactly what it describes
|
|
164
|
+
with `fork_turns "none"`, then wait for it (`wait_agent` with a timeout of at least
|
|
165
|
+
120 seconds) instead of doing the same task itself. Attribution does not depend on the child echoing the
|
|
166
|
+
route id back: `SubagentStart` binds the id to whichever spawned child matches
|
|
167
|
+
the pending route (same parent thread, same target model, within 30 minutes),
|
|
168
|
+
so a route is credited even when the child's own first message never repeats it.
|
|
169
|
+
|
|
170
|
+
The hint is withheld below `codex.delegateMinContext` (default 60000 input
|
|
171
|
+
tokens, read from the parent's own rollout). Measured 2026-09-30: a small
|
|
172
|
+
parent session spends more on the coordinating turn (spawning, waiting,
|
|
173
|
+
verifying) than a delegated child saves, so under the floor Sprag leaves the
|
|
174
|
+
request on the main agent instead of proposing a loss. There is no CLI flag for
|
|
175
|
+
it yet; set it by editing `codex.delegateMinContext` (an integer) in the config
|
|
176
|
+
file `sprag mode` prints under "Stored config file" (`~/Library/Application
|
|
177
|
+
Support/claude-token-saver/config.json` on macOS, `$XDG_CONFIG_HOME/claude-token-saver/config.json`
|
|
178
|
+
or `~/.config/claude-token-saver/config.json` on Linux).
|
|
179
|
+
|
|
180
|
+
The `PreToolUse` rewrite (native `spawn_agent` arguments using Codex's
|
|
181
|
+
`message`, `model`, and `reasoning_effort` fields, preserving explicit model
|
|
182
|
+
choices and custom roles) is kept for Codex versions that do route spawns
|
|
183
|
+
through hooks; on 0.159.2 it simply never fires. `SubagentStart` injects scoped
|
|
184
|
+
ratchets and enabled writing guidance regardless of which path routed the
|
|
185
|
+
child. Bounds are instructions, not an enforced tool-call budget. Normal Codex
|
|
186
|
+
sandbox and approval rules still apply. Review new hooks in `/hooks`.
|
|
160
187
|
|
|
161
188
|
```sh
|
|
162
189
|
sprag delegate on --agent codex
|
|
@@ -174,6 +201,13 @@ exact parent model, and prefer project rules over global rules. Matching prompts
|
|
|
174
201
|
request native delegation; unavailable subagent tools leave work on the main
|
|
175
202
|
agent. Claude tier presets and history are not imported.
|
|
176
203
|
|
|
204
|
+
The routing ledger (`Routing Saved` below) prices a child's own tokens at the
|
|
205
|
+
parent model's rate as the counterfactual; it does not charge or subtract the
|
|
206
|
+
parent's coordination turn, so a delegation that barely clears the context
|
|
207
|
+
floor can still show a positive but overstated saving. A child that was
|
|
208
|
+
interrupted before it finished replaced no parent work, so its whole cost is
|
|
209
|
+
booked as a loss and the entry is marked `aborted`.
|
|
210
|
+
|
|
177
211
|
## Harness Commands
|
|
178
212
|
|
|
179
213
|
```sh
|
|
@@ -231,6 +265,15 @@ SessionStart reads cached candidates and schedules an eligible refresh outside
|
|
|
231
265
|
the hook. Small log changes refresh at most daily; a 5 MiB increase can refresh
|
|
232
266
|
after an hour. `--refresh` bypasses that schedule.
|
|
233
267
|
|
|
268
|
+
Codex 0.159.2 keeps about 2,450 tokens of one hook's context and cuts the
|
|
269
|
+
middle of anything longer. SessionStart therefore fits its parts whole within a
|
|
270
|
+
2,000-token estimate, in priority order: ratchet rules, Korean guidance,
|
|
271
|
+
cohesion guidance, the document note, then offers. Rules and guidance that do
|
|
272
|
+
not fit are written to a per-project file under the Sprag data directory, and
|
|
273
|
+
the context opens with an instruction to read it. `codex exec` runs, recognized
|
|
274
|
+
from the rollout's `originator`, get no seed offer or route notice, since no one
|
|
275
|
+
can answer them.
|
|
276
|
+
|
|
234
277
|
`seed` offers only compatible ratchet presets, not Claude model-tier presets.
|
|
235
278
|
Accept and skip decisions are separate from Claude. Both seed acceptance and
|
|
236
279
|
route approval require the user's choice of global or project scope. A project
|
|
@@ -306,10 +349,24 @@ subagent earns no credit. The estimate holds the child's measured tokens
|
|
|
306
349
|
constant and prices them at the parent and child rates. Negative differences
|
|
307
350
|
remain negative; this is not a comparison of actual billed totals.
|
|
308
351
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
352
|
+
Prices follow the path each call took, and the two sources never fall back to
|
|
353
|
+
each other:
|
|
354
|
+
|
|
355
|
+
- ChatGPT/OpenAI login or an API key straight to `api.openai.com` uses the
|
|
356
|
+
built-in OpenAI list prices (Standard tier, short-context rates, checked
|
|
357
|
+
2026-10-01). Long-context surcharges are not modeled, so requests past the
|
|
358
|
+
long-context threshold are under-priced.
|
|
359
|
+
- A LiteLLM provider uses that gateway's `/model/info` prices, with agreement
|
|
360
|
+
required across deployments behind an alias. Price snapshots last seven days.
|
|
361
|
+
A model the gateway does not price stays unpriced; it is not filled in from
|
|
362
|
+
the list prices.
|
|
363
|
+
|
|
364
|
+
`sprag delegate rules --agent codex` shows each rule's measured runs, failure
|
|
365
|
+
rate and signed saving. A rule is flagged for review when the 95% lower bound
|
|
366
|
+
of its failure rate exceeds 20% over at least five runs, as on the Claude side.
|
|
367
|
+
|
|
368
|
+
Providers are kept separate. Negative values are losses: the child ran on a
|
|
369
|
+
pricier model than the parent, and the loss reduces the totals. Missing rates or usage produce unpriced entries, never invented zeroes. A priced
|
|
313
370
|
entry keeps its rate snapshot across later outages. Pending route records are
|
|
314
371
|
matched for seven days; saved ledger entries remain afterward. Run `--refresh`
|
|
315
372
|
after delegation to update the ledger without waiting for the next route scan.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.56.0",
|
|
4
4
|
"description": "Cut the tokens Claude Code and Codex spend: routes simple work to cheaper models, converts documents to compact Markdown, and shows spend and savings live. Ratchet rules and Korean writing checks included.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude-side cost by the path the call took. A session on a LiteLLM gateway
|
|
3
|
+
* (gatewayBase(env) set) is billed by the gateway, so it is priced from its
|
|
4
|
+
* /model/info table and stays unpriced (null) when the table has no entry;
|
|
5
|
+
* the Anthropic list table is never a fallback there. Every other session is a
|
|
6
|
+
* direct call and uses the list table. Kept apart from cost.js because
|
|
7
|
+
* litellm-models.js imports cost.js, and this needs both.
|
|
8
|
+
*/
|
|
9
|
+
import { estimateCost, estimateCostAcross } from './cost.js';
|
|
10
|
+
import { gatewayPriceFor } from './litellm-models.js';
|
|
11
|
+
import { gatewayBase } from './gateway-auth.js';
|
|
12
|
+
|
|
13
|
+
export function sessionCost(totals, model, { env = process.env, now } = {}) {
|
|
14
|
+
if (!gatewayBase(env)) return estimateCost(totals, model);
|
|
15
|
+
const price = gatewayPriceFor(model, env, now);
|
|
16
|
+
return price ? estimateCost(totals, model, { price }) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Like estimateCostAcross, plus how many sessions had no price and were left out. */
|
|
20
|
+
export function sessionCostAcross(sessions, opts = {}) {
|
|
21
|
+
let unpriced = 0;
|
|
22
|
+
const total = estimateCostAcross(sessions, (totals, model) => {
|
|
23
|
+
const c = sessionCost(totals, model, opts);
|
|
24
|
+
if (!c) unpriced++;
|
|
25
|
+
return c;
|
|
26
|
+
});
|
|
27
|
+
return { ...total, unpriced };
|
|
28
|
+
}
|
package/src/codex-brief.js
CHANGED
|
@@ -77,6 +77,26 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now()
|
|
|
77
77
|
return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Append one event outside the warning dedup, such as a handoff the user
|
|
82
|
+
* wrote. Claude's history records handoffs too, so both agents' timelines
|
|
83
|
+
* show when work was preserved.
|
|
84
|
+
*/
|
|
85
|
+
export function recordCodexHistoryEvent({ sessionId, project = '', key, level = 'info', message, advice = '', now = Date.now(), dir = userDataDir() } = {}) {
|
|
86
|
+
if (typeof message !== 'string' || !message) return false;
|
|
87
|
+
const owner = typeof sessionId === 'string' && sessionId ? sessionId : 'no-session';
|
|
88
|
+
const directory = stateDir(dir);
|
|
89
|
+
const file = join(directory, `${hash(owner)}.json`);
|
|
90
|
+
const state = readState(file);
|
|
91
|
+
const events = [...state.events, { key, tier: 0, signature: key, level, message, advice,
|
|
92
|
+
at: new Date(now).toISOString(), sessionId: owner, project }].slice(-200);
|
|
93
|
+
mkdirSync(directory, { recursive: true });
|
|
94
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
95
|
+
writeFileSync(tmp, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
|
|
96
|
+
renameSync(tmp, file);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
export function readCodexHistory({ days = 7, sessionId, project, dir = userDataDir(), now = Date.now() } = {}) {
|
|
81
101
|
const directory = stateDir(dir);
|
|
82
102
|
let files;
|
|
@@ -6,6 +6,10 @@ import { codexUserDir } from './agent.js';
|
|
|
6
6
|
import { userDataDir } from './paths.js';
|
|
7
7
|
import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
|
|
8
8
|
import { codexCachePolicy } from './codex-cache.js';
|
|
9
|
+
import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
|
|
10
|
+
import { OPENAI_LIST_PRICES, OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
|
|
11
|
+
|
|
12
|
+
export { PRICE_MAX_AGE_MS, rate, deploymentPrice, gatewayPrices, codexRunCost } from './gateway-prices.js';
|
|
9
13
|
|
|
10
14
|
export const CACHE_POLICY_MAX_AGE_MS = 300000;
|
|
11
15
|
const OPENAI_POLICY = 'https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime';
|
|
@@ -51,42 +55,6 @@ export function gatewayCachePolicies(payload) {
|
|
|
51
55
|
policies[0] && policies.every((p) => JSON.stringify(p) === JSON.stringify(policies[0])) ? policies[0] : null]));
|
|
52
56
|
}
|
|
53
57
|
|
|
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
58
|
function cacheFile(provider, { home = codexUserDir(), dir = userDataDir() } = {}) {
|
|
91
59
|
const key = createHash('sha256').update(JSON.stringify([resolve(home), provider.name, provider.base])).digest('hex').slice(0, 24);
|
|
92
60
|
return join(dir, 'codex-cache-policy', `${key}.json`);
|
|
@@ -97,14 +65,31 @@ function gatewayProvider(session, opts) {
|
|
|
97
65
|
return codexBudgetProvider({ ...opts, providerName: session.provider });
|
|
98
66
|
}
|
|
99
67
|
|
|
100
|
-
|
|
101
|
-
|
|
68
|
+
/** True when Codex's OpenAI provider talks to api.openai.com itself, not to a proxy. No config = direct. */
|
|
69
|
+
export function isDirectOpenAI(provider, { home = codexUserDir() } = {}) {
|
|
70
|
+
if (provider !== 'openai') return false;
|
|
102
71
|
try {
|
|
103
72
|
let cfg = {};
|
|
104
73
|
try { cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8')); }
|
|
105
|
-
catch (e) { if (e.code !== 'ENOENT') return
|
|
106
|
-
if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return
|
|
107
|
-
} catch { return
|
|
74
|
+
catch (e) { if (e.code !== 'ENOENT') return false; }
|
|
75
|
+
if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return false;
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The price table a Codex provider's calls are billed by: OpenAI list prices for
|
|
82
|
+
* direct calls, the gateway's own /model/info prices for a LiteLLM provider.
|
|
83
|
+
* Never a fallback chain, so a model the gateway does not price stays unpriced.
|
|
84
|
+
*/
|
|
85
|
+
export function codexPriceBook(provider, litellm, opts = {}) {
|
|
86
|
+
if (isDirectOpenAI(provider, opts)) return { source: 'list', provider: 'openai', checkedAt: OPENAI_PRICES_CHECKED_AT, prices: OPENAI_LIST_PRICES };
|
|
87
|
+
if (litellm?.provider === provider) return { ...litellm, source: 'litellm' };
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
|
|
92
|
+
if (!codexCachePolicy(session?.model) || !isDirectOpenAI(session?.provider, { home })) return null;
|
|
108
93
|
return { backend: 'OpenAI', model: session.model, seconds: codexCachePolicy(session.model),
|
|
109
94
|
requestedModel: session.model, provider: session.provider, checkedAt: now, reference: OPENAI_POLICY };
|
|
110
95
|
}
|
package/src/codex-delegation.js
CHANGED
|
@@ -7,6 +7,7 @@ import { codexUserDir, codexProviderName } from './agent.js';
|
|
|
7
7
|
import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
8
8
|
import { looksPasted } from './route-inject.js';
|
|
9
9
|
import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
|
|
10
|
+
import { readCodexTailLines } from './codex-parser.js';
|
|
10
11
|
|
|
11
12
|
export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
|
|
12
13
|
const MARKER = '<!-- sprag:codex:delegation -->';
|
|
@@ -88,13 +89,50 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
|
|
|
88
89
|
return usable.find((r) => r.scope === 'project') || usable[0] || null;
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Input tokens from the rollout's last token_count record, or 0 when the
|
|
94
|
+
* transcript is missing or unreadable. Only the tail is read: the newest
|
|
95
|
+
* measurement is always near the end of the file.
|
|
96
|
+
*/
|
|
97
|
+
export function codexContextTokens(transcriptPath) {
|
|
98
|
+
const lines = readCodexTailLines(transcriptPath, 512 * 1024);
|
|
99
|
+
if (!lines) return 0;
|
|
100
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
101
|
+
const line = lines[i];
|
|
102
|
+
if (!line || !line.includes('token_count')) continue;
|
|
103
|
+
let e;
|
|
104
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
105
|
+
const n = e?.payload?.type === 'token_count' ? e.payload?.info?.last_token_usage?.input_tokens : undefined;
|
|
106
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
|
|
113
|
+
* never reaches PreToolUse (see codexDelegationTool below). Gated on parent
|
|
114
|
+
* context size: a small parent's own coordination overhead outweighs the
|
|
115
|
+
* savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
|
|
116
|
+
*/
|
|
91
117
|
export function codexRouteHint(payload, opts = {}) {
|
|
92
|
-
const
|
|
118
|
+
const provider = payload?.model_provider || codexProviderName();
|
|
119
|
+
const rule = matchCodexModelRule(payload?.prompt, { provider, ...opts, model: payload?.model, root: opts.root || payload?.cwd });
|
|
93
120
|
if (!rule) return null;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
121
|
+
const minContext = opts.minContext ?? 60000;
|
|
122
|
+
const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
|
|
123
|
+
if (ctx < minContext) return null;
|
|
124
|
+
const id = newRouteId();
|
|
125
|
+
const record = opts.record ?? recordCodexDelegation;
|
|
126
|
+
try {
|
|
127
|
+
record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
|
|
128
|
+
source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
|
|
129
|
+
provider, effort: rule.effort ?? null, contextTokens: ctx }, { dir: opts.dir });
|
|
130
|
+
} catch { /* Accounting is optional; the hint still applies. */ }
|
|
131
|
+
return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
|
|
132
|
+
+ `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
|
|
133
|
+
+ `Give it a self-contained task message that ends with the line <!-- sprag:codex:route id=${id} -->. `
|
|
134
|
+
+ 'Wait for it to finish (call wait_agent with timeout_ms of at least 120000, and again if it times out) and do not do the delegated task yourself while it runs; then verify its result and report unfinished work. Do not spawn another CLI process. '
|
|
135
|
+
+ 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
98
136
|
}
|
|
99
137
|
|
|
100
138
|
function customRole(role, root, home) {
|
|
@@ -120,6 +158,8 @@ function customRole(role, root, home) {
|
|
|
120
158
|
return false;
|
|
121
159
|
}
|
|
122
160
|
|
|
161
|
+
// Kept for Codex versions that do send spawn_agent through PreToolUse; on
|
|
162
|
+
// 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
|
|
123
163
|
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
124
164
|
if (cfg.codex?.delegate !== true) return null;
|
|
125
165
|
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
package/src/codex-doctor.js
CHANGED
|
@@ -12,7 +12,7 @@ export const CODEX_CAPABILITIES = [
|
|
|
12
12
|
{ feature: 'reports', support: 'supported', command: 'sprag --agent codex --format json', note: 'Token usage, cached input, reasoning, session and project filters.' },
|
|
13
13
|
{ feature: 'harness', support: 'supported', command: 'sprag harness check --agent codex', note: 'AGENTS.md, overrides, explicit-scope ratchet rule management.' },
|
|
14
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
|
|
15
|
+
{ feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only records of context (80%/95%) and 5h/weekly rate-limit (90%) warnings, plus handoffs. Cache and TTL chips are not recorded; last shows the latest event.' },
|
|
16
16
|
{ feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
|
|
17
17
|
{ feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
|
|
18
18
|
{ feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
|
package/src/codex-harness.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
|
-
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines } from './codex-parser.js';
|
|
5
|
+
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './codex-parser.js';
|
|
6
6
|
|
|
7
7
|
export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
|
|
8
8
|
export const CODEX_END = '<!-- sprag:codex:harness:end -->';
|
|
@@ -23,7 +23,7 @@ export function codexHarnessPaths(root, scope = 'project') {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
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`;
|
|
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. A [Sprag model routing] note on a request means the user has asked for that request to be delegated: spawn the sub-agent it describes (its model, reasoning_effort, fork_turns "none", and a self-contained message ending with the route line it gives), wait for it to finish instead of doing the same task yourself, then verify the result before replying.\n${CODEX_END}\n`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function range(content) {
|
|
@@ -133,11 +133,8 @@ function countable(ev) {
|
|
|
133
133
|
return true;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
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 } = {}) {
|
|
136
|
+
/** Countable failures in one rollout's last `window` turns. Reads only the file tail. */
|
|
137
|
+
function rolloutFailures(filePath, { window, tailBytes }) {
|
|
141
138
|
const lines = readCodexTailLines(filePath, tailBytes);
|
|
142
139
|
if (!lines) return [];
|
|
143
140
|
const order = [];
|
|
@@ -147,11 +144,14 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
|
|
|
147
144
|
const outputs = [];
|
|
148
145
|
let cwd = null;
|
|
149
146
|
let current = null;
|
|
147
|
+
const copied = forkedCopyFilter();
|
|
150
148
|
for (const line of lines) {
|
|
151
149
|
// 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;
|
|
150
|
+
if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|thread_settings_applied|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
|
|
153
151
|
let e;
|
|
154
152
|
try { e = JSON.parse(line); } catch { continue; }
|
|
153
|
+
// A forked child's copy of the parent history holds the parent's failures, not its own.
|
|
154
|
+
if (copied(e)) continue;
|
|
155
155
|
const p = e?.payload;
|
|
156
156
|
if (e?.type === 'session_meta' && typeof p?.cwd === 'string') cwd ??= p.cwd;
|
|
157
157
|
if (e?.type === 'turn_context' && typeof p?.cwd === 'string') cwd = p.cwd;
|
|
@@ -178,9 +178,20 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
|
|
|
178
178
|
if (countable(ev)) failures.push(ev);
|
|
179
179
|
}
|
|
180
180
|
const recent = new Set(order.slice(-window));
|
|
181
|
+
return failures.filter((ev) => recent.has(ev.turn));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Repeated failures in recent turns, newest-first by count. `siblings` are
|
|
186
|
+
* other rollouts of the same project: every `codex exec` run writes its own
|
|
187
|
+
* file, so a mistake repeated across runs only shows up when they are read
|
|
188
|
+
* together. The TTL applies to all of them.
|
|
189
|
+
*/
|
|
190
|
+
export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home, siblings = [] } = {}) {
|
|
191
|
+
const failures = [filePath, ...siblings].filter(Boolean).flatMap((file) => rolloutFailures(file, { window, tailBytes }));
|
|
181
192
|
const groups = new Map();
|
|
182
193
|
for (const ev of failures) {
|
|
183
|
-
if (!
|
|
194
|
+
if (!Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
|
|
184
195
|
const pattern = codexErrorSignature(ev, { cwd: ev.cwd, home });
|
|
185
196
|
const g = groups.get(pattern) || { pattern, count: 0, lastAt: 0 };
|
|
186
197
|
g.count++;
|