sprag-cli 3.54.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 +17 -5
- package/README.md +23 -6
- package/bin/cli.js +6 -6
- package/docs/CODEX.ko.md +57 -10
- package/docs/CODEX.md +89 -20
- package/package.json +6 -3
- 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 +114 -19
- package/src/codex-ledger.js +94 -16
- package/src/codex-panel-setup.js +98 -0
- 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 +41 -11
- package/src/commands/harness.js +13 -2
- package/src/commands/install.js +106 -10
- package/src/commands/route-scan.js +8 -3
- package/src/commands/seed.js +7 -4
- package/src/commands/uninstall.js +23 -0
- package/src/commands/upgrade.js +13 -0
- 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/harness-templates.js +33 -1
- package/src/harness.js +26 -1
- 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/preset-ratchet.js +133 -0
- package/src/route-scan.js +16 -8
- package/src/savings-ledger.js +10 -6
- package/src/seed-rules.js +32 -7
package/src/litellm-models.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { gatewayBase } from './gateway-auth.js';
|
|
28
28
|
import { loadProfileMap, updateProfileMap, resetModelAliasCache, profileIdFrom, isGatewayModelId } from './model-alias.js';
|
|
29
29
|
import { isRecognizedModelId } from './cost.js';
|
|
30
|
+
import { deploymentPrice, PRICE_MAX_AGE_MS } from './gateway-prices.js';
|
|
30
31
|
import { debug } from './debug.js';
|
|
31
32
|
|
|
32
33
|
/** 게이트웨이 모델 맵의 신선도 기준. 배포 구성은 하루에 여러 번 바뀌지 않습니다. */
|
|
@@ -70,6 +71,23 @@ function normalizeCandidate(id) {
|
|
|
70
71
|
return s.replace(/\[[^\]]*\]$/, '');
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* 배포 항목 하나의 별칭을 고릅니다. model_name → model_info.base_model →
|
|
76
|
+
* litellm_params.model 순으로 정규화해 isRecognizedModelId()를 통과하는 첫
|
|
77
|
+
* 후보이며, 없으면 null입니다. 별칭 추출과 가격 키 등록이 같은 판정을 쓰도록
|
|
78
|
+
* 한 곳에 둡니다.
|
|
79
|
+
*/
|
|
80
|
+
function pickAlias(item) {
|
|
81
|
+
const modelName = typeof item.model_name === 'string' ? item.model_name : '';
|
|
82
|
+
const baseModel = typeof item.model_info?.base_model === 'string' ? item.model_info.base_model : '';
|
|
83
|
+
const rawModel = typeof item.litellm_params?.model === 'string' ? item.litellm_params.model : '';
|
|
84
|
+
for (const candidate of [modelName, baseModel, rawModel]) {
|
|
85
|
+
const normalized = normalizeCandidate(candidate);
|
|
86
|
+
if (normalized && isRecognizedModelId(normalized)) return normalized;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
/**
|
|
74
92
|
* `/model/info` 응답에서 프로파일 ID(또는 계열명 없는 사내 별칭) → 모델 계열
|
|
75
93
|
* 매핑을 뽑아냅니다. 순수 함수입니다: 네트워크·파일 I/O가 없고, 잘못된 입력에도
|
|
@@ -99,17 +117,8 @@ export function deriveAliasesFromModelInfo(payload) {
|
|
|
99
117
|
for (const item of data) {
|
|
100
118
|
if (!item || typeof item !== 'object') continue;
|
|
101
119
|
const modelName = typeof item.model_name === 'string' ? item.model_name : '';
|
|
102
|
-
const baseModel = typeof item.model_info?.base_model === 'string' ? item.model_info.base_model : '';
|
|
103
120
|
const rawModel = typeof item.litellm_params?.model === 'string' ? item.litellm_params.model : '';
|
|
104
|
-
|
|
105
|
-
let alias = null;
|
|
106
|
-
for (const candidate of [modelName, baseModel, rawModel]) {
|
|
107
|
-
const normalized = normalizeCandidate(candidate);
|
|
108
|
-
if (normalized && isRecognizedModelId(normalized)) {
|
|
109
|
-
alias = normalized;
|
|
110
|
-
break;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
121
|
+
const alias = pickAlias(item);
|
|
113
122
|
|
|
114
123
|
if (!alias) {
|
|
115
124
|
if (modelName) skipped.push(modelName);
|
|
@@ -134,6 +143,58 @@ export function deriveAliasesFromModelInfo(payload) {
|
|
|
134
143
|
return { aliases, skipped, count: data.length };
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
/**
|
|
147
|
+
* `/model/info` 응답에서 모델 키 → 토큰당 가격 표를 만듭니다. 순수 함수입니다.
|
|
148
|
+
*
|
|
149
|
+
* 한 배포 항목의 가격을 model_name, 정규화한 model_name·base_model·
|
|
150
|
+
* litellm_params.model, 그리고 별칭 추출이 고른 별칭 아래에 모두 등록합니다.
|
|
151
|
+
* 트랜스크립트의 모델 문자열이 어느 모양으로 오든 찾을 수 있게 하려는 것입니다.
|
|
152
|
+
* 같은 키에 서로 다른 가격이 들어오면 어느 쪽이 맞는지 알 수 없으므로 null로
|
|
153
|
+
* 둡니다. 값은 숫자뿐이고, 키는 normalizeCandidate를 거쳐 ARN이 리소스 부분으로
|
|
154
|
+
* 줄어 있으므로 AWS 계정 ID가 파일에 남지 않습니다.
|
|
155
|
+
*
|
|
156
|
+
* @returns {Record<string, object|null>}
|
|
157
|
+
*/
|
|
158
|
+
export function deriveGatewayPrices(payload) {
|
|
159
|
+
const data = Array.isArray(payload?.data) ? payload.data : [];
|
|
160
|
+
const prices = new Map();
|
|
161
|
+
for (const item of data) {
|
|
162
|
+
if (!item || typeof item !== 'object') continue;
|
|
163
|
+
const price = deploymentPrice(item);
|
|
164
|
+
const keys = new Set([
|
|
165
|
+
// 원문 model_name이 ARN이면 계정 ID가 있으므로 원문 키는 쓰지 않습니다.
|
|
166
|
+
typeof item.model_name === 'string' && !isGatewayModelId(item.model_name) ? item.model_name : '',
|
|
167
|
+
normalizeCandidate(item.model_name),
|
|
168
|
+
normalizeCandidate(item.model_info?.base_model),
|
|
169
|
+
normalizeCandidate(item.litellm_params?.model),
|
|
170
|
+
pickAlias(item) || '',
|
|
171
|
+
]);
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
if (!key) continue;
|
|
174
|
+
if (!prices.has(key)) prices.set(key, price);
|
|
175
|
+
else if (JSON.stringify(prices.get(key)) !== JSON.stringify(price)) prices.set(key, null);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Object.fromEntries(prices);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* LiteLLM 경로로 나간 호출의 모델 가격(토큰당 USD). 게이트웨이 표에 없거나,
|
|
183
|
+
* 표가 PRICE_MAX_AGE_MS보다 오래됐거나, 키가 모호하면 null입니다. 정가표로
|
|
184
|
+
* 대신 채우지 않습니다. 호출이 실제로 지난 경로의 가격만 씁니다.
|
|
185
|
+
*/
|
|
186
|
+
export function gatewayPriceFor(model, env = process.env, now = Date.now()) {
|
|
187
|
+
const gateway = readGatewayModelMap(env);
|
|
188
|
+
if (!gateway?.prices || typeof gateway.prices !== 'object') return null;
|
|
189
|
+
const fetchedAt = Date.parse(gateway.fetchedAt);
|
|
190
|
+
if (!Number.isFinite(fetchedAt) || fetchedAt > now || now - fetchedAt > PRICE_MAX_AGE_MS) return null;
|
|
191
|
+
if (typeof model !== 'string' || !model) return null;
|
|
192
|
+
for (const key of [model, normalizeCandidate(model)]) {
|
|
193
|
+
if (key && Object.hasOwn(gateway.prices, key)) return gateway.prices[key] || null;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
137
198
|
/**
|
|
138
199
|
* profile-map.json의 gateway 절을 읽습니다. base가 현재 gatewayBase(env)와
|
|
139
200
|
* 다르면 다른 게이트웨이의 캐시이므로 null을 돌려줍니다. 예산 캐시(budgetWindow)
|
|
@@ -240,6 +301,7 @@ export async function refreshGatewayModelMap({ base, key, fetchImpl = fetch } =
|
|
|
240
301
|
base,
|
|
241
302
|
fetchedAt: new Date().toISOString(),
|
|
242
303
|
aliases: result.aliases,
|
|
304
|
+
prices: deriveGatewayPrices(payload),
|
|
243
305
|
skipped: result.skipped,
|
|
244
306
|
count: result.count,
|
|
245
307
|
},
|
package/src/model-rules.js
CHANGED
|
@@ -34,6 +34,7 @@ import { homedir } from 'node:os';
|
|
|
34
34
|
import { userDataDir } from './paths.js';
|
|
35
35
|
import { userLanguage } from './config.js';
|
|
36
36
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
37
|
+
import { signedUsd } from './money.js';
|
|
37
38
|
|
|
38
39
|
// Post-promotion delegated-category error rate above this flags the rule
|
|
39
40
|
// for review (rule-health). Calibrated against local T0 avg error incidence.
|
|
@@ -356,7 +357,7 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
|
356
357
|
? `preset (curated), registered ${r.promotedAt}`
|
|
357
358
|
: `×${r.count || 0}, err ${pct(r.errRate)}%, seen ${r.lastSeen || r.promotedAt}`;
|
|
358
359
|
if (!r.delegatedRuns) return base;
|
|
359
|
-
const saved = r.savedUsd ? `, saved
|
|
360
|
+
const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
|
|
360
361
|
return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
|
|
361
362
|
};
|
|
362
363
|
// Merged T2+T1 rules carry two caps, so they state both once rather than
|
package/src/money.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A signed dollar amount for text output: "-$0.12" for a loss, "$0.12" for a
|
|
3
|
+
* saving. Routing savings are recorded signed, and "~$-0.12" misreads as a
|
|
4
|
+
* formatting glitch rather than a loss.
|
|
5
|
+
*/
|
|
6
|
+
export function signedUsd(usd, digits = 2) {
|
|
7
|
+
const fixed = Math.abs(usd).toFixed(digits);
|
|
8
|
+
return usd < 0 && Number(fixed) !== 0 ? `-$${fixed}` : `$${fixed}`;
|
|
9
|
+
}
|
package/src/month-spend.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* LiteLLM·Bedrock처럼 5h/7d cap 이 아예 없는 게이트웨이 사용자도
|
|
5
5
|
* "이번 달에 얼마나 썼는지"는 늘 궁금하므로, 세션 로그를 기반으로
|
|
6
6
|
* 달력 월 단위 지출을 통계선에 상시 노출합니다. 모델 단가는 세션마다
|
|
7
|
-
* 다르므로 세션별로
|
|
7
|
+
* 다르므로 세션별로 호출 경로에 맞는 가격(sessionCost)을 적용해 합산합니다.
|
|
8
|
+
* LiteLLM 게이트웨이 세션은 게이트웨이 가격표로만 계산하고, 표에 없는 모델은
|
|
9
|
+
* 합계에서 빼고 unpriced 로 셉니다. 정가표로 대신 채우지 않습니다.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
|
-
import {
|
|
12
|
+
import { sessionCost } from './claude-price.js';
|
|
11
13
|
|
|
12
14
|
/** 이번 달 1일 00:00(로컬)의 epoch ms. */
|
|
13
15
|
export function monthStartMs(now = new Date()) {
|
|
@@ -27,21 +29,24 @@ export function monthLabel(now = new Date()) {
|
|
|
27
29
|
*
|
|
28
30
|
* @param {Array} sessions parseAllSessions 결과
|
|
29
31
|
* @param {Date} [now]
|
|
30
|
-
* @returns {{usd:number, sessions:number, sinceMs:number, label:string}}
|
|
32
|
+
* @returns {{usd:number, sessions:number, unpriced:number, sinceMs:number, label:string}}
|
|
31
33
|
*/
|
|
32
34
|
export function monthSpend(sessions, now = new Date()) {
|
|
33
35
|
const since = monthStartMs(now);
|
|
34
36
|
let usd = 0;
|
|
35
37
|
let count = 0;
|
|
38
|
+
let unpriced = 0;
|
|
36
39
|
for (const s of sessions || []) {
|
|
37
40
|
if (!s || !s.endTime || s.endTime.getTime() < since) continue;
|
|
38
41
|
if (!s.totals) continue;
|
|
39
42
|
try {
|
|
40
|
-
|
|
43
|
+
const c = sessionCost(s.totals, s.model);
|
|
44
|
+
if (!c) { unpriced += 1; continue; }
|
|
45
|
+
usd += c.actual;
|
|
41
46
|
count += 1;
|
|
42
47
|
} catch {
|
|
43
48
|
// 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
|
-
return { usd, sessions: count, sinceMs: since, label: monthLabel(now) };
|
|
51
|
+
return { usd, sessions: count, unpriced, sinceMs: since, label: monthLabel(now) };
|
|
47
52
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// OpenAI list prices for Codex calls that go straight to OpenAI (ChatGPT/OpenAI login).
|
|
2
|
+
// Source: https://developers.openai.com/api/docs/pricing , "Standard" tier, short-context
|
|
3
|
+
// rates, checked 2026-10-01. Long-context surcharges are not modeled, so requests past the
|
|
4
|
+
// long-context threshold are under-priced. Calls through a LiteLLM gateway never use this table.
|
|
5
|
+
export const OPENAI_PRICES_CHECKED_AT = Date.parse('2026-10-01T00:00:00Z');
|
|
6
|
+
|
|
7
|
+
// Per-1M-token numbers as listed on the page; null where the page shows "-".
|
|
8
|
+
const PER_MILLION = {
|
|
9
|
+
'gpt-6-astra': [10, 1, 12.5, 50],
|
|
10
|
+
'gpt-6.1-sol': [2, 0.1, 2.5, 10],
|
|
11
|
+
'gpt-6-luna': [0.1, 0.01, 0.125, 0.5],
|
|
12
|
+
'gpt-6-sol': [2, 0.2, 2.5, 10],
|
|
13
|
+
'gpt-5.6-sol': [4, 0.4, 5, 20],
|
|
14
|
+
'gpt-5.6-terra': [2, 0.2, 2.5, 12],
|
|
15
|
+
'gpt-5.6-luna': [0.2, 0.02, 0.25, 1.2],
|
|
16
|
+
'gpt-5.5': [5, 0.5, null, 30],
|
|
17
|
+
'gpt-5.5-pro': [30, null, null, 180],
|
|
18
|
+
'gpt-5.4': [2.5, 0.25, null, 15],
|
|
19
|
+
'gpt-5.4-pro': [30, null, null, 180],
|
|
20
|
+
'gpt-5.4-mini': [0.75, 0.075, null, 4.5],
|
|
21
|
+
'gpt-5.4-nano': [0.2, 0.02, null, 1.25],
|
|
22
|
+
'gpt-5.2': [1.75, 0.175, null, 14],
|
|
23
|
+
'gpt-5.2-pro': [21, null, null, 168],
|
|
24
|
+
'gpt-5.1': [1.25, 0.125, null, 10],
|
|
25
|
+
'gpt-5': [1.25, 0.125, null, 10],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Parse "<n>e-6" instead of dividing so 0.1 per 1M is exactly 1e-7, without float noise.
|
|
29
|
+
const perToken = (n) => (n === null ? null : Number(`${n}e-6`));
|
|
30
|
+
|
|
31
|
+
/** model -> per-token USD `{ input, cacheRead, cacheWrite, output }`. */
|
|
32
|
+
export const OPENAI_LIST_PRICES = Object.fromEntries(Object.entries(PER_MILLION).map(([model, [input, cacheRead, cacheWrite, output]]) =>
|
|
33
|
+
[model, { input: perToken(input), cacheRead: perToken(cacheRead), cacheWrite: perToken(cacheWrite), output: perToken(output) }]));
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preset-ratchet — the bundled ratchet presets the user accepted globally,
|
|
3
|
+
* kept in a file of their own.
|
|
4
|
+
*
|
|
5
|
+
* Presets used to be appended to ratchet.md next to the user's own rules. Once
|
|
6
|
+
* there, an upgrade could not reword a preset, drop one that turned out wrong,
|
|
7
|
+
* or tell which lines were ours: every line looked like the user's. This file
|
|
8
|
+
* holds only presets and is regenerated whole from the recorded answers in
|
|
9
|
+
* seed-state, the same way ratchet-model.md is regenerated from the rule
|
|
10
|
+
* registry. So an upgrade updates it by itself, `seed skip` removes one, and
|
|
11
|
+
* ratchet.md stays the user's.
|
|
12
|
+
*
|
|
13
|
+
* Claude Code ~/.claude/ratchet-preset.md (imported by the global harness block)
|
|
14
|
+
* Codex <CODEX_HOME>/ratchet-preset.md (injected by the SessionStart hook)
|
|
15
|
+
*
|
|
16
|
+
* Presets accepted into a project scope still go to that project's ratchet.md:
|
|
17
|
+
* a project rule is the user's placement decision, not a tool-wide default.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { codexUserDir } from './agent.js';
|
|
24
|
+
import { userLanguage } from './config.js';
|
|
25
|
+
import {
|
|
26
|
+
HARNESS_BLOCK_BEGIN,
|
|
27
|
+
HARNESS_BLOCK_END,
|
|
28
|
+
PRESET_RATCHET_IMPORT_RE,
|
|
29
|
+
presetRatchetImportLine,
|
|
30
|
+
renderPresetRatchet,
|
|
31
|
+
} from './harness-templates.js';
|
|
32
|
+
import { loadSeedState, ratchetPresets, saveSeedState } from './seed-rules.js';
|
|
33
|
+
|
|
34
|
+
export function presetRatchetPath({ agent = 'claude' } = {}) {
|
|
35
|
+
return agent === 'codex'
|
|
36
|
+
? join(codexUserDir(), 'ratchet-preset.md')
|
|
37
|
+
: join(homedir(), '.claude', 'ratchet-preset.md');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function globalRatchetPath(agent) {
|
|
41
|
+
return agent === 'codex' ? join(codexUserDir(), 'ratchet.md') : join(homedir(), '.claude', 'ratchet.md');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeIfChanged(file, text) {
|
|
45
|
+
let before = null;
|
|
46
|
+
try { before = readFileSync(file, 'utf8'); } catch { /* first write */ }
|
|
47
|
+
if (before === text) return false;
|
|
48
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
49
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
50
|
+
writeFileSync(tmp, text);
|
|
51
|
+
renameSync(tmp, file);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The accepted-global presets, in the bundled order, with this version's wording. */
|
|
56
|
+
export function acceptedGlobalPresets({ agent = 'claude', lang = userLanguage() } = {}) {
|
|
57
|
+
const { decided } = loadSeedState({ agent });
|
|
58
|
+
return ratchetPresets(lang, { agent })
|
|
59
|
+
.filter((p) => decided[p.id]?.action === 'accepted' && decided[p.id]?.scope === 'global')
|
|
60
|
+
.map((p) => ({ id: p.id, text: p.text }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Rewrite the preset file from the recorded answers. A preset that a newer
|
|
65
|
+
* release removed simply stops being rendered; its answer stays in seed-state
|
|
66
|
+
* so it is not offered again if it comes back under the same text.
|
|
67
|
+
*/
|
|
68
|
+
export function syncPresetRatchet({ agent = 'claude', lang = userLanguage() } = {}) {
|
|
69
|
+
const file = presetRatchetPath({ agent });
|
|
70
|
+
const rules = acceptedGlobalPresets({ agent, lang });
|
|
71
|
+
const changed = writeIfChanged(file, renderPresetRatchet(rules, { lang, agent }));
|
|
72
|
+
return { file, rules: rules.length, changed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Move presets that earlier versions appended to the global ratchet.md into
|
|
77
|
+
* the preset file. A line counts as a preset only when its text, without the
|
|
78
|
+
* date stamp, equals a bundled rule in either language, so nothing the user
|
|
79
|
+
* wrote themselves is touched. The original is backed up once before the
|
|
80
|
+
* first change.
|
|
81
|
+
*/
|
|
82
|
+
export function migrateLegacyPresetRules({ agent = 'claude' } = {}) {
|
|
83
|
+
const file = globalRatchetPath(agent);
|
|
84
|
+
if (!existsSync(file)) return { file, moved: 0 };
|
|
85
|
+
const byText = new Map();
|
|
86
|
+
for (const lang of ['ko', 'en']) {
|
|
87
|
+
for (const p of ratchetPresets(lang, { agent })) byText.set(p.text.trim(), p.id);
|
|
88
|
+
}
|
|
89
|
+
const stamp = /^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/;
|
|
90
|
+
const original = readFileSync(file, 'utf8');
|
|
91
|
+
const moved = [];
|
|
92
|
+
const kept = original.split('\n').filter((line) => {
|
|
93
|
+
const m = line.match(/^\s*-\s+(.*)$/);
|
|
94
|
+
if (!m) return true;
|
|
95
|
+
const id = byText.get(m[1].replace(stamp, '').trim());
|
|
96
|
+
if (!id) return true;
|
|
97
|
+
moved.push(id);
|
|
98
|
+
return false;
|
|
99
|
+
});
|
|
100
|
+
if (!moved.length) return { file, moved: 0 };
|
|
101
|
+
writeFileSync(`${file}.bak-preset-migration`, original);
|
|
102
|
+
writeIfChanged(file, kept.join('\n'));
|
|
103
|
+
const state = loadSeedState({ agent });
|
|
104
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
105
|
+
for (const id of moved) state.decided[id] = { action: 'accepted', at: today, scope: 'global', migrated: true };
|
|
106
|
+
saveSeedState(state, { agent });
|
|
107
|
+
return { file, moved: moved.length };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add the preset import to a global harness block written before the file
|
|
112
|
+
* existed. Only the tool's own marked block is edited; a CLAUDE.md without the
|
|
113
|
+
* block is left alone, since the user chose not to install the harness there.
|
|
114
|
+
*/
|
|
115
|
+
export function ensurePresetImport() {
|
|
116
|
+
const file = join(homedir(), '.claude', 'CLAUDE.md');
|
|
117
|
+
let text;
|
|
118
|
+
try { text = readFileSync(file, 'utf8'); } catch { return { file, added: false }; }
|
|
119
|
+
const begin = text.indexOf(HARNESS_BLOCK_BEGIN);
|
|
120
|
+
const end = text.indexOf(HARNESS_BLOCK_END);
|
|
121
|
+
if (begin < 0 || end < begin) return { file, added: false };
|
|
122
|
+
if (PRESET_RATCHET_IMPORT_RE.test(text.slice(begin, end))) return { file, added: false };
|
|
123
|
+
writeFileSync(file, `${text.slice(0, end)}${presetRatchetImportLine()}\n${text.slice(end)}`);
|
|
124
|
+
return { file, added: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Everything a session start or an install does: migrate, render, and wire the import. */
|
|
128
|
+
export function preparePresetRatchet({ agent = 'claude', lang = userLanguage() } = {}) {
|
|
129
|
+
const migrated = migrateLegacyPresetRules({ agent });
|
|
130
|
+
const synced = syncPresetRatchet({ agent, lang });
|
|
131
|
+
const imported = agent === 'codex' ? { added: false } : ensurePresetImport();
|
|
132
|
+
return { ...synced, moved: migrated.moved, importAdded: imported.added };
|
|
133
|
+
}
|
package/src/route-scan.js
CHANGED
|
@@ -25,7 +25,8 @@ import { collectSessionRecords } from './session-records.js';
|
|
|
25
25
|
import {
|
|
26
26
|
collectSubagentRuns, indexRuns, exactRunsForEpisode, fallbackRunsForEpisode,
|
|
27
27
|
} from './subagent-records.js';
|
|
28
|
-
import {
|
|
28
|
+
import { modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
|
|
29
|
+
import { sessionCost } from './claude-price.js';
|
|
29
30
|
import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
|
|
30
31
|
import { modelRuleBaseText } from './model-rules.js';
|
|
31
32
|
|
|
@@ -115,7 +116,8 @@ const CATEGORIES = [
|
|
|
115
116
|
label: '탐색·조회 (파일/값 찾기)',
|
|
116
117
|
labelEn: 'lookup (finding files/values)',
|
|
117
118
|
agent: 'haiku-explore',
|
|
118
|
-
kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1]
|
|
119
|
+
kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
|
|
120
|
+
[/정의(?:돼|되어)\s*있|어느\s*파일|어떤\s*파일에|\bwhich\s+file\b|\bwhere\s+(?:is|are)\b[^?\n]*\bdefined\b/i, 1]],
|
|
119
121
|
},
|
|
120
122
|
{
|
|
121
123
|
id: 'read',
|
|
@@ -333,9 +335,13 @@ export function worthDelegating(tier, rank) {
|
|
|
333
335
|
* Approximation, deliberately stated as one: it holds token counts constant,
|
|
334
336
|
* which a cheaper model would not reproduce exactly. Directionally right and
|
|
335
337
|
* enough to rank rules by value, so it is rendered as "~$X".
|
|
338
|
+
*
|
|
339
|
+
* Signed: a run that moved to a pricier model is a loss and comes back
|
|
340
|
+
* negative. null means a side has no price (a gateway session whose /model/info
|
|
341
|
+
* table lacks the model), which is unpriced, not zero.
|
|
336
342
|
*/
|
|
337
343
|
export function runSaving(run, mainModel) {
|
|
338
|
-
if (!run.model || !mainModel) return
|
|
344
|
+
if (!run.model || !mainModel) return null;
|
|
339
345
|
const totals = {
|
|
340
346
|
input: run.input,
|
|
341
347
|
cacheCreation: run.cacheCreation,
|
|
@@ -344,9 +350,10 @@ export function runSaving(run, mainModel) {
|
|
|
344
350
|
ephemeral1h: run.ephemeral1h,
|
|
345
351
|
output: run.out,
|
|
346
352
|
};
|
|
347
|
-
const actual =
|
|
348
|
-
const counterfactual =
|
|
349
|
-
|
|
353
|
+
const actual = sessionCost(totals, run.model);
|
|
354
|
+
const counterfactual = sessionCost(totals, mainModel);
|
|
355
|
+
if (!actual || !counterfactual) return null;
|
|
356
|
+
return counterfactual.actual - actual.actual;
|
|
350
357
|
}
|
|
351
358
|
|
|
352
359
|
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
|
|
@@ -549,7 +556,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
549
556
|
d.runs += 1;
|
|
550
557
|
if (isFailedRun(run)) d.errRuns += 1;
|
|
551
558
|
d.outTokens += run.out || 0;
|
|
552
|
-
d.savedUsd += saved;
|
|
559
|
+
if (Number.isFinite(saved)) d.savedUsd += saved; // unpriced runs add nothing
|
|
553
560
|
delegatedStats.set(key, d);
|
|
554
561
|
};
|
|
555
562
|
// Ledger events feed the statusline's weekly/monthly "Routing saved"
|
|
@@ -759,7 +766,8 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
759
766
|
// profile-map.json's `modelAliases` to bring these runs back in.
|
|
760
767
|
if (!baseline || !isRecognizedModelId(baseline)) continue;
|
|
761
768
|
const usd = runSaving(e.run, baseline);
|
|
762
|
-
|
|
769
|
+
// A loss is recorded too; only an unpriced run (null) has nothing to record.
|
|
770
|
+
if (!Number.isFinite(usd)) continue;
|
|
763
771
|
priced.push({
|
|
764
772
|
key: e.run.path,
|
|
765
773
|
ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
|
package/src/savings-ledger.js
CHANGED
|
@@ -29,6 +29,9 @@ import { userDataDir } from './paths.js';
|
|
|
29
29
|
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
|
30
30
|
const MONTH_MS = 30 * 24 * 3600 * 1000;
|
|
31
31
|
|
|
32
|
+
/** Stored USD is kept to 4 decimals so re-scans compare equal and files stay small. */
|
|
33
|
+
export const roundUsd = (usd) => Math.round(Number(usd) * 10000) / 10000;
|
|
34
|
+
|
|
32
35
|
export function ledgerPath() {
|
|
33
36
|
return join(userDataDir(), 'delegation-ledger.json');
|
|
34
37
|
}
|
|
@@ -55,8 +58,9 @@ export function loadLedger() {
|
|
|
55
58
|
* Upsert saving events. `events` is an array of
|
|
56
59
|
* { key, ts, usd, rule, from, to } where `key` is the run's transcript path
|
|
57
60
|
* (unique per subagent run) and `from`/`to` name the models the routing
|
|
58
|
-
* decision moved between.
|
|
59
|
-
*
|
|
61
|
+
* decision moved between. `usd` is signed: a run on a pricier model is a loss
|
|
62
|
+
* and is recorded as a negative amount so it reduces the totals. Only
|
|
63
|
+
* non-finite amounts (unpriced runs) are skipped.
|
|
60
64
|
*/
|
|
61
65
|
export function recordDelegationEvents(events) {
|
|
62
66
|
if (!Array.isArray(events) || events.length === 0) return;
|
|
@@ -64,9 +68,9 @@ export function recordDelegationEvents(events) {
|
|
|
64
68
|
data.version = LEDGER_VERSION;
|
|
65
69
|
let changed = false;
|
|
66
70
|
for (const e of events) {
|
|
67
|
-
if (!e || !e.key || !
|
|
71
|
+
if (!e || !e.key || typeof e.usd !== 'number' || !Number.isFinite(e.usd) || !Number.isFinite(e.ts)) continue;
|
|
68
72
|
const prev = data.events[e.key];
|
|
69
|
-
const usd =
|
|
73
|
+
const usd = roundUsd(e.usd);
|
|
70
74
|
if (prev && prev.ts === e.ts && prev.usd === usd) continue;
|
|
71
75
|
data.events[e.key] = {
|
|
72
76
|
ts: e.ts,
|
|
@@ -115,8 +119,8 @@ export function delegationSavedTotals(now = Date.now()) {
|
|
|
115
119
|
const byPair = new Map();
|
|
116
120
|
try {
|
|
117
121
|
for (const e of Object.values(loadLedger().events)) {
|
|
118
|
-
const usd = Number(e.usd)
|
|
119
|
-
if (usd
|
|
122
|
+
const usd = Number(e.usd);
|
|
123
|
+
if (!Number.isFinite(usd)) continue;
|
|
120
124
|
totals.total += usd;
|
|
121
125
|
if (Number.isFinite(e.ts)) {
|
|
122
126
|
if (now - e.ts <= WEEK_MS) totals.week += usd;
|
package/src/seed-rules.js
CHANGED
|
@@ -177,6 +177,15 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
|
|
|
177
177
|
if (!seed) return null;
|
|
178
178
|
|
|
179
179
|
if (seed.kind === 'ratchet') {
|
|
180
|
+
// A global preset goes to the tool-owned preset file, which is rendered
|
|
181
|
+
// from the recorded answer; a project one is the user's placement and is
|
|
182
|
+
// appended to that project's ratchet.md as before.
|
|
183
|
+
if (scope === 'global') {
|
|
184
|
+
recordDecision(id, 'accepted', { scope }, { agent });
|
|
185
|
+
const { syncPresetRatchet } = await import('./preset-ratchet.js');
|
|
186
|
+
const res = syncPresetRatchet({ agent, lang });
|
|
187
|
+
return { id, kind: 'ratchet', scope, path: res.file, rule: seed.ruleText };
|
|
188
|
+
}
|
|
180
189
|
const res = harnessPromote(seed.ruleText, { root, scope, agent });
|
|
181
190
|
recordDecision(id, 'accepted', { scope }, { agent });
|
|
182
191
|
return { id, kind: 'ratchet', scope, path: res.path, rule: seed.ruleText };
|
|
@@ -218,23 +227,39 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
|
|
|
218
227
|
lastSeen: today,
|
|
219
228
|
});
|
|
220
229
|
const written = syncAllFiles();
|
|
221
|
-
recordDecision(id, 'accepted', { scope });
|
|
230
|
+
recordDecision(id, 'accepted', { scope }, { agent });
|
|
222
231
|
return { id, kind: 'model', scope, paths: written, rule: composeRuleText(entry.rule, entry, lang) };
|
|
223
232
|
}
|
|
224
233
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Record a "no" so the rule is never offered again (until `seed reset`).
|
|
236
|
+
*
|
|
237
|
+
* A preset already accepted globally can be skipped too: that is how one comes
|
|
238
|
+
* out of the preset file, which is rendered from these answers.
|
|
239
|
+
*/
|
|
240
|
+
export async function skipSeed(id, opts = {}) {
|
|
227
241
|
const seed = findSeed(id, opts);
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
242
|
+
if (seed) {
|
|
243
|
+
recordDecision(id, 'skipped', {}, { agent: opts.agent });
|
|
244
|
+
return { id, kind: seed.kind, rule: seed.ruleText };
|
|
245
|
+
}
|
|
246
|
+
const { decided } = loadSeedState({ agent: opts.agent });
|
|
247
|
+
const preset = ratchetPresets(opts.lang, { agent: opts.agent }).find((p) => p.id === id);
|
|
248
|
+
if (!preset || decided[id]?.action !== 'accepted' || decided[id]?.scope !== 'global') return null;
|
|
249
|
+
recordDecision(id, 'skipped', { was: 'accepted' }, { agent: opts.agent });
|
|
250
|
+
const { syncPresetRatchet } = await import('./preset-ratchet.js');
|
|
251
|
+
syncPresetRatchet({ agent: opts.agent, lang: opts.lang });
|
|
252
|
+
return { id, kind: 'ratchet', rule: preset.text, removed: true };
|
|
231
253
|
}
|
|
232
254
|
|
|
233
255
|
/** Forget every recorded answer — the full set becomes pending again. */
|
|
234
|
-
export function resetSeeds({ agent = 'claude' } = {}) {
|
|
256
|
+
export async function resetSeeds({ agent = 'claude' } = {}) {
|
|
235
257
|
const state = loadSeedState({ agent });
|
|
236
258
|
const count = Object.keys(state.decided).length;
|
|
237
259
|
saveSeedState({ decided: {} }, { agent });
|
|
260
|
+
// The preset file mirrors the answers, so forgetting them empties it.
|
|
261
|
+
const { syncPresetRatchet } = await import('./preset-ratchet.js');
|
|
262
|
+
syncPresetRatchet({ agent });
|
|
238
263
|
return count;
|
|
239
264
|
}
|
|
240
265
|
|