sprag-cli 3.40.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 +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-alias — restore a usable model name when the transcript records a
|
|
3
|
+
* gateway identifier instead of a Claude model id.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: behind a Bedrock / LiteLLM gateway, `message.model` in the
|
|
6
|
+
* transcript is an inference-profile ARN:
|
|
7
|
+
*
|
|
8
|
+
* converse/arn:aws:bedrock:<region>:<account>:application-inference-profile/<id>
|
|
9
|
+
*
|
|
10
|
+
* Nothing in that string says "opus" or "haiku", so `detectPricingTier()`
|
|
11
|
+
* falls through to its Sonnet default. Everything downstream then reads the
|
|
12
|
+
* session as Sonnet: `worthDelegating('T1', 1)` is false, so every T1 rule is
|
|
13
|
+
* rejected, delegation savings aggregate to zero, and cost is under-counted.
|
|
14
|
+
*
|
|
15
|
+
* The fix is a single normalization point rather than a change to the pricing
|
|
16
|
+
* table — plain aliases (`ap-northeast-2.anthropic.claude-opus-5[1m]`) are
|
|
17
|
+
* already classified correctly, region prefix and `[1m]` suffix included. So
|
|
18
|
+
* all that is missing is ARN → alias.
|
|
19
|
+
*
|
|
20
|
+
* Resolution order, cheapest first:
|
|
21
|
+
* 1. not an ARN → return the input unchanged (direct-API users
|
|
22
|
+
* must keep their existing behaviour)
|
|
23
|
+
* 2. user override → `modelAliases` in profile-map.json
|
|
24
|
+
* 3. learned mapping → profile id → role, learned from transcripts
|
|
25
|
+
* 4. otherwise → 'unknown' (never a silent Sonnet guess)
|
|
26
|
+
*
|
|
27
|
+
* A profile id is never hardcoded here. Ids differ per account and change
|
|
28
|
+
* with gateway config, and the ARN embeds a 12-digit AWS account id — this
|
|
29
|
+
* package is published to npm, so neither may live in the source.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, createReadStream } from 'node:fs';
|
|
33
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
34
|
+
import { createInterface } from 'node:readline';
|
|
35
|
+
import { join, dirname, basename } from 'node:path';
|
|
36
|
+
import { userDataDir, claudeUserDir } from './paths.js';
|
|
37
|
+
|
|
38
|
+
/** Marker returned when a gateway id could not be resolved to a model. */
|
|
39
|
+
export const UNKNOWN_MODEL = 'unknown';
|
|
40
|
+
|
|
41
|
+
/** File holding user overrides plus the learned profile→role votes. */
|
|
42
|
+
export function profileMapPath() {
|
|
43
|
+
return join(userDataDir(), 'profile-map.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `converse/` (LiteLLM) or a bare ARN, foundation- or application-scoped.
|
|
47
|
+
// `foundation-model` ARNs carry the model id directly in the resource part
|
|
48
|
+
// (`foundation-model/anthropic.claude-haiku-…`), so they are captured too.
|
|
49
|
+
const ARN_RE =
|
|
50
|
+
/arn:aws:bedrock:[^:]*:[^:]*:(?:foundation-model|(?:application-)?inference-profile)\/([A-Za-z0-9._:-]+)/;
|
|
51
|
+
|
|
52
|
+
/** True when the id came from a Bedrock gateway rather than the Claude API. */
|
|
53
|
+
export function isGatewayModelId(model) {
|
|
54
|
+
return typeof model === 'string' && model.includes('arn:aws:bedrock:');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The inference-profile id inside an ARN, or null when there is none. */
|
|
58
|
+
export function profileIdFrom(model) {
|
|
59
|
+
if (typeof model !== 'string') return null;
|
|
60
|
+
const m = ARN_RE.exec(model);
|
|
61
|
+
return m ? m[1] : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Roles a profile id can carry. 'main' is the session's own model, which the
|
|
66
|
+
* env declares separately from the per-tier subagent overrides.
|
|
67
|
+
*/
|
|
68
|
+
const ROLES = ['main', 'opus', 'sonnet', 'haiku', 'fable'];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Alias for a role, taken from the environment Claude Code itself uses to
|
|
72
|
+
* pick subagent models. Returns null when the variable is absent, or when it
|
|
73
|
+
* is an opaque ARN that names no model (resolving one of those to another ARN
|
|
74
|
+
* would loop).
|
|
75
|
+
*
|
|
76
|
+
* A `foundation-model` ARN is not opaque: it spells the model out in its
|
|
77
|
+
* resource part, so it is unwrapped rather than rejected. Users who point
|
|
78
|
+
* these variables straight at an ARN — a normal way to configure a private
|
|
79
|
+
* gateway — used to get no delegation stats at all, and no hint as to why.
|
|
80
|
+
*/
|
|
81
|
+
export function aliasForRole(role, env = process.env) {
|
|
82
|
+
const candidates = {
|
|
83
|
+
main: [env.ANTHROPIC_MODEL, env.ANTHROPIC_DEFAULT_MODEL, env.ANTHROPIC_DEFAULT_OPUS_MODEL],
|
|
84
|
+
opus: [env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_MODEL],
|
|
85
|
+
sonnet: [env.ANTHROPIC_DEFAULT_SONNET_MODEL],
|
|
86
|
+
haiku: [env.ANTHROPIC_DEFAULT_HAIKU_MODEL],
|
|
87
|
+
fable: [env.ANTHROPIC_DEFAULT_FABLE_MODEL],
|
|
88
|
+
}[role] || [];
|
|
89
|
+
for (const v of candidates) {
|
|
90
|
+
if (typeof v !== 'string' || !v) continue;
|
|
91
|
+
if (!isGatewayModelId(v)) return v;
|
|
92
|
+
// `arn:…:foundation-model/anthropic.claude-haiku-4-5-…` → the model id.
|
|
93
|
+
// An `application-inference-profile` id is a random string and stays
|
|
94
|
+
// rejected: guessing at it is how wrong prices get into the ledger.
|
|
95
|
+
const inner = profileIdFrom(v);
|
|
96
|
+
if (inner && /claude/i.test(inner)) return inner;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── override / learned map storage ───────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
let cached = null;
|
|
104
|
+
|
|
105
|
+
/** Read profile-map.json (memoized). Missing or corrupt file → empty map. */
|
|
106
|
+
export function loadProfileMap() {
|
|
107
|
+
if (cached) return cached;
|
|
108
|
+
let data = {};
|
|
109
|
+
try {
|
|
110
|
+
data = JSON.parse(readFileSync(profileMapPath(), 'utf8'));
|
|
111
|
+
} catch { /* absent on first run, and unreadable is not fatal */ }
|
|
112
|
+
cached = {
|
|
113
|
+
version: 1,
|
|
114
|
+
modelAliases: data.modelAliases && typeof data.modelAliases === 'object' ? data.modelAliases : {},
|
|
115
|
+
learned: data.learned && typeof data.learned === 'object' ? data.learned : {},
|
|
116
|
+
learnedAt: data.learnedAt || null,
|
|
117
|
+
scannedSessions: data.scannedSessions || 0,
|
|
118
|
+
};
|
|
119
|
+
return cached;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function saveProfileMap(map) {
|
|
123
|
+
const dir = userDataDir();
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
writeFileSync(profileMapPath(), JSON.stringify(map, null, 2));
|
|
126
|
+
cached = map;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Drop the memoized map. Tests use this after pointing paths elsewhere. */
|
|
130
|
+
export function resetModelAliasCache() {
|
|
131
|
+
cached = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Glob match for override keys, so a user can write one entry that hides the
|
|
136
|
+
* account id and region: `arn:aws:bedrock:*:*:application-inference-profile/x`.
|
|
137
|
+
*/
|
|
138
|
+
function globMatch(pattern, value) {
|
|
139
|
+
const rx = new RegExp(
|
|
140
|
+
'^' + pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$',
|
|
141
|
+
);
|
|
142
|
+
return rx.test(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function overrideAlias(model, map) {
|
|
146
|
+
for (const [pattern, alias] of Object.entries(map.modelAliases || {})) {
|
|
147
|
+
if (globMatch(pattern, model)) return alias;
|
|
148
|
+
// Overrides are usually written without the LiteLLM `converse/` prefix.
|
|
149
|
+
// Only meaningful for ARNs — on a plain id indexOf returns -1 and the
|
|
150
|
+
// slice would hand the matcher a single trailing character.
|
|
151
|
+
const at = model.indexOf('arn:aws:bedrock:');
|
|
152
|
+
if (at > 0 && globMatch(pattern, model.slice(at))) return alias;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── resolution ───────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Normalize one transcript model id.
|
|
161
|
+
* Non-gateway ids pass through untouched; gateway ids resolve to an alias, or
|
|
162
|
+
* to 'unknown' when the mapping is not confident yet.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveModelAlias(rawModel, { env = process.env } = {}) {
|
|
165
|
+
if (!rawModel) return UNKNOWN_MODEL;
|
|
166
|
+
const model = String(rawModel);
|
|
167
|
+
|
|
168
|
+
const map = loadProfileMap();
|
|
169
|
+
|
|
170
|
+
// Overrides are consulted for EVERY id, not just ARNs. A gateway can be
|
|
171
|
+
// configured to report a house alias (`prod-large`, `team-fast`) that names
|
|
172
|
+
// no Claude family at all; those never reach the ARN branch below, and left
|
|
173
|
+
// alone they price as Sonnet by default. One `modelAliases` entry maps them
|
|
174
|
+
// back. Ids that already name a family are left untouched — an override
|
|
175
|
+
// pattern has to match before anything changes.
|
|
176
|
+
const override = overrideAlias(model, map);
|
|
177
|
+
if (override) return override;
|
|
178
|
+
|
|
179
|
+
if (!isGatewayModelId(model)) return model;
|
|
180
|
+
|
|
181
|
+
const pid = profileIdFrom(model);
|
|
182
|
+
if (!pid) return UNKNOWN_MODEL;
|
|
183
|
+
|
|
184
|
+
// Self-describing resource ids need no learning: foundation-model ARNs and
|
|
185
|
+
// system cross-region inference profiles both embed the model id
|
|
186
|
+
// (`anthropic.claude-haiku-4-5-…` / `us.anthropic.claude-haiku-4-5-…`).
|
|
187
|
+
// detectPricingTier() already classifies those strings, region prefix
|
|
188
|
+
// included — only opaque application-profile ids (random hex) fall through
|
|
189
|
+
// to the override / learned paths. Without this, a fresh gateway machine
|
|
190
|
+
// reported every run as 'unknown' until enough sessions accumulated to
|
|
191
|
+
// learn a mapping that the string had spelled out all along.
|
|
192
|
+
if (/claude/i.test(pid)) return pid;
|
|
193
|
+
|
|
194
|
+
const entry = map.learned?.[pid];
|
|
195
|
+
if (entry?.role) {
|
|
196
|
+
const alias = aliasForRole(entry.role, env);
|
|
197
|
+
if (alias) return alias;
|
|
198
|
+
}
|
|
199
|
+
return UNKNOWN_MODEL;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── learning ─────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
// A single observation can be wrong: the time-adjacent parent records leak
|
|
205
|
+
// into a naive join, and a mis-set agent definition mislabels one run. Require
|
|
206
|
+
// a few observations that mostly agree before trusting a mapping.
|
|
207
|
+
export const MIN_VOTES = 3;
|
|
208
|
+
export const MIN_AGREEMENT = 0.8;
|
|
209
|
+
|
|
210
|
+
/** Role named directly by a Task call's `model` parameter. */
|
|
211
|
+
function roleFromModelParam(value) {
|
|
212
|
+
if (typeof value !== 'string') return null;
|
|
213
|
+
const v = value.toLowerCase();
|
|
214
|
+
return ROLES.find((r) => r !== 'main' && v.includes(r)) || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Role declared in a subagent definition's frontmatter (`model: haiku`). */
|
|
218
|
+
function roleFromAgentType(agentType, cache) {
|
|
219
|
+
if (!agentType) return null;
|
|
220
|
+
if (cache.has(agentType)) return cache.get(agentType);
|
|
221
|
+
let role = null;
|
|
222
|
+
for (const dir of [join(claudeUserDir(), 'agents'), join(process.cwd(), '.claude', 'agents')]) {
|
|
223
|
+
const file = join(dir, `${agentType}.md`);
|
|
224
|
+
if (!existsSync(file)) continue;
|
|
225
|
+
try {
|
|
226
|
+
const head = readFileSync(file, 'utf8').slice(0, 2000);
|
|
227
|
+
const m = /^model:\s*([A-Za-z0-9._-]+)/m.exec(head);
|
|
228
|
+
if (m) role = roleFromModelParam(m[1]);
|
|
229
|
+
} catch { /* unreadable definition just yields no vote */ }
|
|
230
|
+
if (role) break;
|
|
231
|
+
}
|
|
232
|
+
cache.set(agentType, role);
|
|
233
|
+
return role;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Record one observation.
|
|
238
|
+
*
|
|
239
|
+
* `kind` matters more than the count. An 'explicit' vote comes from a stated
|
|
240
|
+
* model — a `Task(model: "haiku")` parameter or an agent definition's
|
|
241
|
+
* frontmatter. An 'inferred' vote is circumstantial: the record carried no
|
|
242
|
+
* sidechain flag, so it is *probably* the parent session's own model. Mixing
|
|
243
|
+
* the two by volume let 4 inferred votes outrank 1 explicit one and stamped a
|
|
244
|
+
* haiku profile as 'main' — see tallyVotes.
|
|
245
|
+
*/
|
|
246
|
+
function addVote(votes, pid, role, kind) {
|
|
247
|
+
if (!pid || !role) return;
|
|
248
|
+
const v = (votes[pid] ||= { explicit: {}, inferred: {} });
|
|
249
|
+
const bucket = v[kind] || (v[kind] = {});
|
|
250
|
+
bucket[role] = (bucket[role] || 0) + 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read one main transcript: which profile id the session itself ran on, and
|
|
255
|
+
* which role each Task/Agent tool_use asked for (joined later by tool_use id).
|
|
256
|
+
*/
|
|
257
|
+
async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCache) {
|
|
258
|
+
let sawGateway = false;
|
|
259
|
+
const rl = createInterface({
|
|
260
|
+
input: createReadStream(path, { encoding: 'utf8' }),
|
|
261
|
+
crlfDelay: Infinity,
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
for await (const line of rl) {
|
|
265
|
+
if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
let entry;
|
|
269
|
+
try {
|
|
270
|
+
entry = JSON.parse(line);
|
|
271
|
+
} catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const msg = entry.message;
|
|
275
|
+
if (!msg) continue;
|
|
276
|
+
|
|
277
|
+
// The session's own model: the parent side of the transcript.
|
|
278
|
+
if (msg.model && entry.isSidechain !== true) {
|
|
279
|
+
const pid = profileIdFrom(msg.model);
|
|
280
|
+
if (pid) {
|
|
281
|
+
sawGateway = true;
|
|
282
|
+
// Circumstantial: a subagent's records also land in the parent file
|
|
283
|
+
// without the flag often enough to outvote real evidence.
|
|
284
|
+
addVote(votes, pid, 'main', 'inferred');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!Array.isArray(msg.content)) continue;
|
|
289
|
+
for (const block of msg.content) {
|
|
290
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
291
|
+
if (block.name !== 'Task' && block.name !== 'Agent') continue;
|
|
292
|
+
const input = block.input || {};
|
|
293
|
+
const role = roleFromModelParam(input.model)
|
|
294
|
+
|| roleFromAgentType(input.subagent_type, agentTypeCache);
|
|
295
|
+
if (block.id && role) requestedByToolUse.set(block.id, role);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
rl.close();
|
|
300
|
+
}
|
|
301
|
+
return sawGateway;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** First profile id used by a subagent transcript (a run uses exactly one). */
|
|
305
|
+
async function subagentProfileId(path) {
|
|
306
|
+
const rl = createInterface({
|
|
307
|
+
input: createReadStream(path, { encoding: 'utf8' }),
|
|
308
|
+
crlfDelay: Infinity,
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
for await (const line of rl) {
|
|
312
|
+
if (!line.includes('arn:aws:bedrock:')) continue;
|
|
313
|
+
let entry;
|
|
314
|
+
try {
|
|
315
|
+
entry = JSON.parse(line);
|
|
316
|
+
} catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const pid = profileIdFrom(entry.message?.model);
|
|
320
|
+
if (pid) return pid;
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
rl.close();
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Subagent transcripts a session spawned (mirrors subagent-records layout). */
|
|
329
|
+
async function subagentFiles(sessionPath) {
|
|
330
|
+
const dir = join(dirname(sessionPath), basename(sessionPath, '.jsonl'), 'subagents');
|
|
331
|
+
try {
|
|
332
|
+
return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
|
|
333
|
+
} catch {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Whether two roles describe the same model. 'main' and 'opus' routinely do:
|
|
340
|
+
* the session model is the opus alias on a default setup, so a Task that asked
|
|
341
|
+
* for opus does not contradict "this is the parent's own profile".
|
|
342
|
+
*/
|
|
343
|
+
function rolesAgree(a, b) {
|
|
344
|
+
if (!a || !b) return false;
|
|
345
|
+
if (a === b) return true;
|
|
346
|
+
const pair = new Set([a, b]);
|
|
347
|
+
return pair.has('main') && pair.has('opus');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Winner of one bucket, with the counts needed to judge confidence. */
|
|
351
|
+
function topRole(tally) {
|
|
352
|
+
let role = null;
|
|
353
|
+
let top = 0;
|
|
354
|
+
let total = 0;
|
|
355
|
+
for (const [r, n] of Object.entries(tally || {})) {
|
|
356
|
+
total += n;
|
|
357
|
+
if (n > top) { top = n; role = r; }
|
|
358
|
+
}
|
|
359
|
+
return { role, top, total };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Decide a role per profile id.
|
|
364
|
+
*
|
|
365
|
+
* Explicit evidence decides alone whenever there is enough of it; inferred
|
|
366
|
+
* evidence is only consulted when the explicit bucket is too thin. Counting
|
|
367
|
+
* both together is what produced the original mis-classification: a haiku
|
|
368
|
+
* profile appeared 16 times in parent transcripts without a sidechain flag
|
|
369
|
+
* against 1,702 times as a subagent, and those 16 inferred 'main' votes beat
|
|
370
|
+
* the single explicit 'haiku' one at exactly the 80% line.
|
|
371
|
+
*
|
|
372
|
+
* When a thin explicit bucket vetoes a decisive inference, the explicit role
|
|
373
|
+
* is adopted ('explicit-veto'): a stated `Task(model: ...)` is the stronger
|
|
374
|
+
* evidence, and it is the only evidence left once the inference is rejected.
|
|
375
|
+
*
|
|
376
|
+
* When neither bucket says anything usable the id stays unresolved. An
|
|
377
|
+
* 'unknown' that drops out of the aggregate beats a confident wrong answer
|
|
378
|
+
* that silently re-tiers every run on that profile.
|
|
379
|
+
*/
|
|
380
|
+
export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGREEMENT } = {}) {
|
|
381
|
+
const learned = {};
|
|
382
|
+
for (const [pid, buckets] of Object.entries(votes)) {
|
|
383
|
+
// Legacy flat shape (`{ haiku: 3 }`) is read as explicit evidence.
|
|
384
|
+
const split = buckets && (buckets.explicit || buckets.inferred)
|
|
385
|
+
? { explicit: buckets.explicit || {}, inferred: buckets.inferred || {} }
|
|
386
|
+
: { explicit: buckets || {}, inferred: {} };
|
|
387
|
+
|
|
388
|
+
const explicit = topRole(split.explicit);
|
|
389
|
+
const inferred = topRole(split.inferred);
|
|
390
|
+
|
|
391
|
+
let role = null;
|
|
392
|
+
let source = null;
|
|
393
|
+
if (explicit.total >= minVotes && explicit.top / explicit.total >= minAgreement) {
|
|
394
|
+
role = explicit.role;
|
|
395
|
+
source = 'explicit';
|
|
396
|
+
} else if (inferred.total >= minVotes
|
|
397
|
+
&& inferred.top / inferred.total >= minAgreement
|
|
398
|
+
&& (explicit.total === 0 || rolesAgree(explicit.role, inferred.role))) {
|
|
399
|
+
// Thin explicit evidence still vetoes a contradicting inference: one
|
|
400
|
+
// stated `Task(model: "haiku")` outweighs any number of "no sidechain
|
|
401
|
+
// flag, so probably the session model" observations.
|
|
402
|
+
role = inferred.role;
|
|
403
|
+
source = 'inferred';
|
|
404
|
+
} else if (explicit.total > 0
|
|
405
|
+
&& explicit.top / explicit.total >= minAgreement
|
|
406
|
+
&& inferred.role
|
|
407
|
+
&& !rolesAgree(explicit.role, inferred.role)) {
|
|
408
|
+
// The veto above is only coherent if we then believe what did the
|
|
409
|
+
// vetoing. Leaving the id unresolved instead drops every delegated run
|
|
410
|
+
// on that profile out of the aggregate, which is how a haiku profile
|
|
411
|
+
// that doubles as somebody's session model reported zero delegations.
|
|
412
|
+
role = explicit.role;
|
|
413
|
+
source = 'explicit-veto';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
learned[pid] = {
|
|
417
|
+
role,
|
|
418
|
+
source,
|
|
419
|
+
votes: split,
|
|
420
|
+
total: explicit.total + inferred.total,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return learned;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Learn profile id → role from transcripts and persist the result.
|
|
428
|
+
*
|
|
429
|
+
* The join is exact rather than time-windowed: `.meta.json` carries the
|
|
430
|
+
* `toolUseId` of the Task block that spawned the run, and that block names the
|
|
431
|
+
* model tier. A subagent transcript uses exactly one profile id, so the run's
|
|
432
|
+
* id and the requested role identify each other.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} opts
|
|
435
|
+
* @param {string[]} opts.sessionPaths transcripts to read, newest first
|
|
436
|
+
* @param {number} opts.maxSessions cap on files read (learning is a scan)
|
|
437
|
+
* @returns {Promise<{learned: object, scannedSessions: number, gateway: boolean}>}
|
|
438
|
+
*/
|
|
439
|
+
export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40 } = {}) {
|
|
440
|
+
const votes = {};
|
|
441
|
+
const agentTypeCache = new Map();
|
|
442
|
+
let scanned = 0;
|
|
443
|
+
let gateway = false;
|
|
444
|
+
|
|
445
|
+
for (const sessionPath of sessionPaths.slice(0, maxSessions)) {
|
|
446
|
+
const requestedByToolUse = new Map();
|
|
447
|
+
let sawGateway = false;
|
|
448
|
+
try {
|
|
449
|
+
sawGateway = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache);
|
|
450
|
+
} catch {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
scanned += 1;
|
|
454
|
+
|
|
455
|
+
for (const jsonl of await subagentFiles(sessionPath)) {
|
|
456
|
+
let meta = null;
|
|
457
|
+
try {
|
|
458
|
+
meta = JSON.parse(await readFile(jsonl.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
|
|
459
|
+
} catch { /* pre-toolUseId runs simply cast no vote */ }
|
|
460
|
+
const role = (meta?.toolUseId && requestedByToolUse.get(meta.toolUseId))
|
|
461
|
+
|| roleFromAgentType(meta?.agentType, agentTypeCache);
|
|
462
|
+
if (!role) continue;
|
|
463
|
+
const pid = await subagentProfileId(jsonl);
|
|
464
|
+
if (!pid) continue;
|
|
465
|
+
sawGateway = true;
|
|
466
|
+
// Stated evidence: the Task call named this tier, or the agent
|
|
467
|
+
// definition it used did.
|
|
468
|
+
addVote(votes, pid, role, 'explicit');
|
|
469
|
+
}
|
|
470
|
+
if (sawGateway) gateway = true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const learned = tallyVotes(votes);
|
|
474
|
+
const map = loadProfileMap();
|
|
475
|
+
const next = {
|
|
476
|
+
...map,
|
|
477
|
+
learned,
|
|
478
|
+
learnedAt: new Date().toISOString(),
|
|
479
|
+
scannedSessions: scanned,
|
|
480
|
+
};
|
|
481
|
+
// Nothing to record on a non-gateway machine — do not create the file there.
|
|
482
|
+
if (gateway || Object.keys(map.learned || {}).length) saveProfileMap(next);
|
|
483
|
+
return { learned, scannedSessions: scanned, gateway };
|
|
484
|
+
}
|