pantheon-opencode 1.4.3 → 1.5.0-beta.2
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/.pantheon/code-mode/README.md +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/README.md +161 -1101
- package/README.pt-BR.md +222 -0
- package/bin/pantheon-init.mjs +30 -11
- package/opencode.json +1 -0
- package/package.json +14 -3
- package/plugin.json +15 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +33 -2
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +135 -127
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +8 -1
- package/scripts/install/config-migration.mjs +401 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +261 -88
- package/scripts/install/plugin.mjs +112 -42
- package/scripts/install/shared.mjs +13 -158
- package/scripts/install-mcp.mjs +44 -232
- package/scripts/mcp_persistence_server.py +382 -16
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +304 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/sync-tui.mjs +32 -7
- package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/zeus.md +25 -14
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +33 -2
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +382 -16
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/background-job-board.ts +67 -1
- package/src/pantheon/cost-command.ts +60 -39
- package/src/pantheon/delegate-manager.ts +669 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +374 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1120 -0
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/token-opt.ts +781 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +131 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +236 -42
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +10 -7
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +11 -8
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +22 -16
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +28 -24
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS3 Token-Opt (PR #94) — measurable net token reduction, quality floor.
|
|
3
|
+
*
|
|
4
|
+
* Five levers, each with before/after metering and a NET delta (gross
|
|
5
|
+
* saving minus retrieval/measurement overhead). A lever with net <= 0 is
|
|
6
|
+
* DISABLED by flag (never removed) via `applyGate()`.
|
|
7
|
+
*
|
|
8
|
+
* 1. C9 pre-retrieval Mode-1 — top-k (default 3) per category BEFORE
|
|
9
|
+
* task()/delegate, relevance cutoff (default 0.3) per category; only
|
|
10
|
+
* auditable chunks (id + score + text) are injected; injection cost
|
|
11
|
+
* is debited in `injectedTokens`.
|
|
12
|
+
* 2. 4 meta-tools ceiling + lazy — `memory_search`, `kv_search`,
|
|
13
|
+
* `code_query` (codemap) and `pantheon://resources` are always on
|
|
14
|
+
* (real repo names verified); everything else resolves on demand via
|
|
15
|
+
* pantheon-code-mode `execute_code_script` (search-first discovery).
|
|
16
|
+
* 3. Response filtering + truncation — keep only used MCP fields;
|
|
17
|
+
* per-turn cap preserving summary (first) + tail (last).
|
|
18
|
+
* 4. TOON encoding — see `toon-codec.ts` (board signals, checkpoints,
|
|
19
|
+
* KV payloads; per-class savings ~11% content-dominated board records
|
|
20
|
+
* up to ~49% large tabular checkpoints — see
|
|
21
|
+
* `docs/ws3-token-opt-measurements.md` — JSON fallback).
|
|
22
|
+
* 5. Schema compression — slim MCP tool descriptions for familiar
|
|
23
|
+
* tools, full detail on demand.
|
|
24
|
+
*
|
|
25
|
+
* Constraints (PLAN GATE 1): tokens-only accounting (NO monetary layer —
|
|
26
|
+
* this extends WS0 `pantheon_cost`/cost-tracker WITHOUT touching the
|
|
27
|
+
* legacy USD ledger); deterministic metering (NO LLM calls, NO external
|
|
28
|
+
* gateway, NO generative embedding). `estimateTokens` is a fixed
|
|
29
|
+
* chars/4 ceiling so before/after numbers are directly comparable.
|
|
30
|
+
*
|
|
31
|
+
* Kill-switch: `PANTHEON_TOKEN_OPT=off` (see `isTokenOptEnabled`).
|
|
32
|
+
*
|
|
33
|
+
* @module token-opt
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { ToonValue } from './toon-codec.ts'
|
|
37
|
+
|
|
38
|
+
// ─── Deterministic token estimator (WS0 extension, tokens-only) ──────────
|
|
39
|
+
|
|
40
|
+
/** Nominal chars-per-token ratio (fixed so measurements are comparable). */
|
|
41
|
+
export const CHARS_PER_TOKEN = 4
|
|
42
|
+
|
|
43
|
+
function charsToTokens(chars: number): number {
|
|
44
|
+
if (chars <= 0) return 0
|
|
45
|
+
return Math.ceil(chars / CHARS_PER_TOKEN)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deterministic token estimate for a text (no LLM, no I/O).
|
|
50
|
+
* Pure function of length: `ceil(len / 4)`, `0` for empty.
|
|
51
|
+
*/
|
|
52
|
+
export function estimateTokens(text: string): number {
|
|
53
|
+
return charsToTokens(text.length)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Lever names + feature flags ─────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** The five WS3 levers. */
|
|
59
|
+
export type LeverName = 'c9' | 'tools-ceiling' | 'filter-truncate' | 'toon' | 'schema'
|
|
60
|
+
|
|
61
|
+
/** Per-lever measurement with NET delta (gross minus overhead). */
|
|
62
|
+
export interface LeverMeasurement {
|
|
63
|
+
name: LeverName
|
|
64
|
+
before: number
|
|
65
|
+
after: number
|
|
66
|
+
overhead: number
|
|
67
|
+
gross: number
|
|
68
|
+
net: number
|
|
69
|
+
enabled: boolean
|
|
70
|
+
/** Itemized overhead (present when measured via `measureLeverFull`). */
|
|
71
|
+
overheadBreakdown?: OverheadBreakdown
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Default flags: every lever enabled until measurement gates it. */
|
|
75
|
+
export const DEFAULT_FLAGS: Record<LeverName, boolean> = {
|
|
76
|
+
c9: true,
|
|
77
|
+
'tools-ceiling': true,
|
|
78
|
+
'filter-truncate': true,
|
|
79
|
+
toon: true,
|
|
80
|
+
schema: true,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Measure one lever in tokens from char counts (deterministic).
|
|
84
|
+
* `enabled` is `net > 0` — the caller persists the verdict via flags.
|
|
85
|
+
* `overheadChars` here is the TOTAL overhead (retrieval + discovery +
|
|
86
|
+
* detail-on-demand + markers); use `measureLeverFull` with an explicit
|
|
87
|
+
* `OverheadBreakdown` so no component is silently omitted.
|
|
88
|
+
*/
|
|
89
|
+
export function measureLever(
|
|
90
|
+
name: LeverName,
|
|
91
|
+
beforeChars: number,
|
|
92
|
+
afterChars: number,
|
|
93
|
+
overheadChars: number,
|
|
94
|
+
): LeverMeasurement {
|
|
95
|
+
return measureLeverFull(name, beforeChars, afterChars, {
|
|
96
|
+
...EMPTY_OVERHEAD,
|
|
97
|
+
retrievalChars: overheadChars,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Overhead components debited against a lever's gross saving (chars). */
|
|
102
|
+
export interface OverheadBreakdown {
|
|
103
|
+
/** Pre-retrieval injection cost (C9 context chars actually injected). */
|
|
104
|
+
retrievalChars: number
|
|
105
|
+
/** Search-first discovery probes for lazy tools (`discoveryOverheadChars`). */
|
|
106
|
+
discoveryChars: number
|
|
107
|
+
/** Full detail fetched on demand (`fullDetailChars`). */
|
|
108
|
+
detailChars: number
|
|
109
|
+
/** Truncation markers kept in the output (`TruncateResult.markerChars`). */
|
|
110
|
+
markerChars: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Zero overhead (a lever that replaces bytes in place, e.g. TOON). */
|
|
114
|
+
export const EMPTY_OVERHEAD: OverheadBreakdown = {
|
|
115
|
+
retrievalChars: 0,
|
|
116
|
+
discoveryChars: 0,
|
|
117
|
+
detailChars: 0,
|
|
118
|
+
markerChars: 0,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Sum of all overhead components (chars). */
|
|
122
|
+
export function totalOverheadChars(breakdown: OverheadBreakdown): number {
|
|
123
|
+
return (
|
|
124
|
+
breakdown.retrievalChars +
|
|
125
|
+
breakdown.discoveryChars +
|
|
126
|
+
breakdown.detailChars +
|
|
127
|
+
breakdown.markerChars
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Measure one lever with an explicit TOTAL overhead breakdown.
|
|
133
|
+
* Net = gross − (retrieval + discovery + detail + markers); net <= 0
|
|
134
|
+
* disables the lever via `applyGate` (flag kept, never removed).
|
|
135
|
+
*/
|
|
136
|
+
export function measureLeverFull(
|
|
137
|
+
name: LeverName,
|
|
138
|
+
beforeChars: number,
|
|
139
|
+
afterChars: number,
|
|
140
|
+
breakdown: OverheadBreakdown,
|
|
141
|
+
): LeverMeasurement {
|
|
142
|
+
const before = charsToTokens(beforeChars)
|
|
143
|
+
const after = charsToTokens(afterChars)
|
|
144
|
+
const overheadChars = totalOverheadChars(breakdown)
|
|
145
|
+
const overhead = charsToTokens(overheadChars)
|
|
146
|
+
const gross = before - after
|
|
147
|
+
const net = gross - overhead
|
|
148
|
+
return {
|
|
149
|
+
name,
|
|
150
|
+
before,
|
|
151
|
+
after,
|
|
152
|
+
overhead,
|
|
153
|
+
gross,
|
|
154
|
+
net,
|
|
155
|
+
enabled: net > 0,
|
|
156
|
+
overheadBreakdown: breakdown,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Gate flags by measurement: a lever with net <= 0 is DISABLED (set to
|
|
162
|
+
* false) but KEPT (never removed) so it can be re-enabled later.
|
|
163
|
+
*/
|
|
164
|
+
export function applyGate(
|
|
165
|
+
flags: Record<LeverName, boolean>,
|
|
166
|
+
measurements: LeverMeasurement[],
|
|
167
|
+
): Record<LeverName, boolean> {
|
|
168
|
+
const gated: Record<LeverName, boolean> = { ...flags }
|
|
169
|
+
for (const m of measurements) {
|
|
170
|
+
if (m.net <= 0) gated[m.name] = false
|
|
171
|
+
}
|
|
172
|
+
return gated
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Kill-switch: `PANTHEON_TOKEN_OPT=off` disables the whole WS3 layer. */
|
|
176
|
+
export function isTokenOptEnabled(env: Readonly<Record<string, string | undefined>> = {}): boolean {
|
|
177
|
+
return env.PANTHEON_TOKEN_OPT !== 'off'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Lever 1: C9 pre-retrieval Mode-1 ────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/** Default top-k per category (before task()/delegate). */
|
|
183
|
+
export const C9_DEFAULT_TOP_K = 3
|
|
184
|
+
|
|
185
|
+
/** Default relevance cutoff per category (chunks below are dropped). */
|
|
186
|
+
export const C9_DEFAULT_CUTOFF = 0.3
|
|
187
|
+
|
|
188
|
+
/** One scored retrieval candidate (auditable: id + category + score). */
|
|
189
|
+
export interface C9Chunk {
|
|
190
|
+
id: string
|
|
191
|
+
category: string
|
|
192
|
+
score: number
|
|
193
|
+
text: string
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Immutable input batch for the safe C9 context-preparation contract. */
|
|
197
|
+
export interface C9CandidateBatch {
|
|
198
|
+
query: string
|
|
199
|
+
candidates: readonly C9Chunk[]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Per-category override of top-k / cutoff. */
|
|
203
|
+
export interface C9CategoryConfig {
|
|
204
|
+
topK?: number
|
|
205
|
+
cutoff?: number
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Optional query used only to activate the explicit low-score fallback. */
|
|
209
|
+
export interface C9FilterOptions {
|
|
210
|
+
perCategory?: Readonly<Record<string, C9CategoryConfig>>
|
|
211
|
+
query?: string
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Result of C9 filtering with debited injection cost. */
|
|
215
|
+
export interface C9FilterResult {
|
|
216
|
+
selected: C9Chunk[]
|
|
217
|
+
dropped: C9Chunk[]
|
|
218
|
+
injectedChars: number
|
|
219
|
+
injectedTokens: number
|
|
220
|
+
/**
|
|
221
|
+
* Quality-floor signal (never silent on empty):
|
|
222
|
+
* - `ok` — at least one chunk selected;
|
|
223
|
+
* - `empty-input` — no chunks retrieved at all (fallback: proceed without
|
|
224
|
+
* injected context, caller should log `notice`);
|
|
225
|
+
* - `all-dropped` — chunks existed but none survived cutoff/top-k and no
|
|
226
|
+
* lexical query fallback was available;
|
|
227
|
+
* - `fallback-relative` — the absolute cutoff rejected every chunk, but a
|
|
228
|
+
* query-matching lexical/relative ranking selected auditable low-score
|
|
229
|
+
* chunks;
|
|
230
|
+
* - `nonsense` — a supplied query had no lexical overlap, so nothing is
|
|
231
|
+
* injected.
|
|
232
|
+
*/
|
|
233
|
+
signal: 'ok' | 'empty-input' | 'all-dropped' | 'fallback-relative' | 'nonsense'
|
|
234
|
+
/** Human-readable alert for the non-`ok` signals (null when `ok`). */
|
|
235
|
+
notice: string | null
|
|
236
|
+
/**
|
|
237
|
+
* Recall over cutoff-eligible chunks: selected-eligible / eligible.
|
|
238
|
+
* Top-k overflow lowers recall honestly (e.g. 3 kept of 5 eligible = 0.6).
|
|
239
|
+
* 1 when nothing was eligible (vacuous — read with `signal`).
|
|
240
|
+
* Precision is N/A by construction: the cutoff IS the relevance proxy,
|
|
241
|
+
* so every selected chunk is eligible by definition.
|
|
242
|
+
*/
|
|
243
|
+
recall: number
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Filter scored chunks per category: drop below cutoff, keep top-k by
|
|
248
|
+
* score desc. An optional query enables only the explicit low-score fallback.
|
|
249
|
+
* The injected context cost is debited (`injectedTokens`).
|
|
250
|
+
*/
|
|
251
|
+
export function c9Filter(
|
|
252
|
+
chunks: readonly C9Chunk[],
|
|
253
|
+
perCategoryOrOptions?: Readonly<Record<string, C9CategoryConfig>> | C9FilterOptions | string,
|
|
254
|
+
query?: string,
|
|
255
|
+
): C9FilterResult {
|
|
256
|
+
const isOptions = isC9FilterOptions(perCategoryOrOptions)
|
|
257
|
+
const perCategory =
|
|
258
|
+
typeof perCategoryOrOptions === 'string'
|
|
259
|
+
? undefined
|
|
260
|
+
: isOptions
|
|
261
|
+
? perCategoryOrOptions.perCategory
|
|
262
|
+
: perCategoryOrOptions
|
|
263
|
+
const filterQuery =
|
|
264
|
+
typeof perCategoryOrOptions === 'string'
|
|
265
|
+
? (query ?? perCategoryOrOptions)
|
|
266
|
+
: isOptions
|
|
267
|
+
? (query ?? perCategoryOrOptions.query)
|
|
268
|
+
: query
|
|
269
|
+
const byCategory = new Map<string, C9Chunk[]>()
|
|
270
|
+
for (const chunk of chunks) {
|
|
271
|
+
const group = byCategory.get(chunk.category)
|
|
272
|
+
if (group !== undefined) group.push(chunk)
|
|
273
|
+
else byCategory.set(chunk.category, [chunk])
|
|
274
|
+
}
|
|
275
|
+
const selected: C9Chunk[] = []
|
|
276
|
+
const dropped: C9Chunk[] = []
|
|
277
|
+
let eligibleTotal = 0
|
|
278
|
+
let eligibleSelected = 0
|
|
279
|
+
for (const [category, group] of byCategory) {
|
|
280
|
+
const config = perCategory?.[category]
|
|
281
|
+
const topK = config?.topK ?? C9_DEFAULT_TOP_K
|
|
282
|
+
const cutoff = config?.cutoff ?? C9_DEFAULT_CUTOFF
|
|
283
|
+
const eligible = group.filter((c) => c.score >= cutoff).sort((a, b) => b.score - a.score)
|
|
284
|
+
const kept = eligible.slice(0, topK)
|
|
285
|
+
eligibleTotal += eligible.length
|
|
286
|
+
eligibleSelected += kept.length
|
|
287
|
+
selected.push(...kept)
|
|
288
|
+
dropped.push(...group.filter((c) => c.score < cutoff))
|
|
289
|
+
dropped.push(...eligible.slice(topK))
|
|
290
|
+
}
|
|
291
|
+
let signal: C9FilterResult['signal'] =
|
|
292
|
+
chunks.length === 0 ? 'empty-input' : selected.length === 0 ? 'all-dropped' : 'ok'
|
|
293
|
+
let notice =
|
|
294
|
+
signal === 'empty-input'
|
|
295
|
+
? 'C9: empty input — no chunks retrieved; fallback: proceed without injected context'
|
|
296
|
+
: signal === 'all-dropped'
|
|
297
|
+
? 'C9: all chunks dropped by cutoff/top-k; fallback: relax cutoff/top-k or proceed without context'
|
|
298
|
+
: null
|
|
299
|
+
|
|
300
|
+
// Keep the absolute cutoff intact. This fallback is deliberately entered
|
|
301
|
+
// only after it rejects every chunk, and only lexical overlap can authorize
|
|
302
|
+
// injecting one of those low-score chunks.
|
|
303
|
+
if (chunks.length > 0 && selected.length === 0 && filterQuery !== undefined) {
|
|
304
|
+
const queryTerms = lexicalTerms(filterQuery)
|
|
305
|
+
const fallbackSelected =
|
|
306
|
+
queryTerms.length > 0 ? relativeLexicalSelection(byCategory, perCategory, queryTerms) : []
|
|
307
|
+
if (fallbackSelected.length > 0) {
|
|
308
|
+
selected.push(...fallbackSelected)
|
|
309
|
+
signal = 'fallback-relative'
|
|
310
|
+
notice =
|
|
311
|
+
`C9: absolute cutoff ${C9_DEFAULT_CUTOFF} yielded no eligible chunks; ` +
|
|
312
|
+
`lexical/relative fallback selected ${fallbackSelected.length} auditable ` +
|
|
313
|
+
`low-score chunk(s) (${queryTerms.length} query term(s)); selected scores remain below cutoff`
|
|
314
|
+
} else if (filterQuery.trim().length > 0) {
|
|
315
|
+
signal = 'nonsense'
|
|
316
|
+
notice =
|
|
317
|
+
'C9: query has no lexical overlap with low-score chunks; fallback returned zero and no context was injected'
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (selected.length > 0 && signal === 'fallback-relative') {
|
|
322
|
+
const selectedSet = new Set(selected)
|
|
323
|
+
dropped.length = 0
|
|
324
|
+
dropped.push(...chunks.filter((chunk) => !selectedSet.has(chunk)))
|
|
325
|
+
}
|
|
326
|
+
const context = buildC9Context(selected)
|
|
327
|
+
return {
|
|
328
|
+
selected,
|
|
329
|
+
dropped,
|
|
330
|
+
injectedChars: context.length,
|
|
331
|
+
injectedTokens: estimateTokens(context),
|
|
332
|
+
signal,
|
|
333
|
+
notice,
|
|
334
|
+
recall: eligibleTotal === 0 ? 1 : eligibleSelected / eligibleTotal,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Configuration for the pure C9 context-preparation boundary. */
|
|
339
|
+
export interface C9ContextOptions {
|
|
340
|
+
env?: Readonly<Record<string, string | undefined>>
|
|
341
|
+
perCategory?: Readonly<Record<string, C9CategoryConfig>>
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Metrics emitted by C9; deliberately contains no candidate or context text. */
|
|
345
|
+
export interface C9Telemetry {
|
|
346
|
+
signal: C9FilterResult['signal'] | 'disabled' | 'empty-query'
|
|
347
|
+
counts: {
|
|
348
|
+
candidates: number
|
|
349
|
+
selected: number
|
|
350
|
+
dropped: number
|
|
351
|
+
}
|
|
352
|
+
recall: number
|
|
353
|
+
injectedChars: number
|
|
354
|
+
injectedTokens: number
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** C9 result with the kill-switch state available as a type discriminator. */
|
|
358
|
+
export type C9ContextResult =
|
|
359
|
+
| {
|
|
360
|
+
enabled: true
|
|
361
|
+
context: string
|
|
362
|
+
telemetry: C9Telemetry
|
|
363
|
+
}
|
|
364
|
+
| {
|
|
365
|
+
enabled: false
|
|
366
|
+
context: ''
|
|
367
|
+
telemetry: C9Telemetry
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function telemetry(
|
|
371
|
+
signal: C9Telemetry['signal'],
|
|
372
|
+
candidates: number,
|
|
373
|
+
selected: number,
|
|
374
|
+
dropped: number,
|
|
375
|
+
recall: number,
|
|
376
|
+
context = '',
|
|
377
|
+
): C9Telemetry {
|
|
378
|
+
return {
|
|
379
|
+
signal,
|
|
380
|
+
counts: { candidates, selected, dropped },
|
|
381
|
+
recall,
|
|
382
|
+
injectedChars: context.length,
|
|
383
|
+
injectedTokens: estimateTokens(context),
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function hasLexicalOverlap(query: string, chunks: readonly C9Chunk[]): boolean {
|
|
388
|
+
const queryTerms = lexicalTerms(query)
|
|
389
|
+
if (queryTerms.length === 0) return false
|
|
390
|
+
return chunks.some((chunk) => {
|
|
391
|
+
const chunkTerms = new Set(lexicalTerms(chunk.text))
|
|
392
|
+
return queryTerms.some((term) => chunkTerms.has(term))
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Prepare auditable context without performing retrieval or side effects.
|
|
398
|
+
*
|
|
399
|
+
* The caller supplies the query and candidates already retrieved. The
|
|
400
|
+
* kill-switch is read only from the supplied environment object, so this
|
|
401
|
+
* boundary remains deterministic and testable. C9 always receives the batch
|
|
402
|
+
* query explicitly; no option can replace it with an implicit query.
|
|
403
|
+
*/
|
|
404
|
+
export function prepareC9Context(
|
|
405
|
+
batch: C9CandidateBatch,
|
|
406
|
+
options: C9ContextOptions = {},
|
|
407
|
+
): C9ContextResult {
|
|
408
|
+
const candidateCount = batch.candidates.length
|
|
409
|
+
if (!isTokenOptEnabled(options.env)) {
|
|
410
|
+
return {
|
|
411
|
+
enabled: false,
|
|
412
|
+
context: '',
|
|
413
|
+
telemetry: telemetry('disabled', candidateCount, 0, 0, 0),
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const filtered = c9Filter(batch.candidates, options.perCategory, batch.query)
|
|
418
|
+
if (batch.query.trim().length === 0) {
|
|
419
|
+
return {
|
|
420
|
+
enabled: true,
|
|
421
|
+
context: '',
|
|
422
|
+
telemetry: telemetry(
|
|
423
|
+
'empty-query',
|
|
424
|
+
candidateCount,
|
|
425
|
+
0,
|
|
426
|
+
candidateCount,
|
|
427
|
+
candidateCount === 0 ? filtered.recall : 0,
|
|
428
|
+
),
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const context =
|
|
433
|
+
filtered.signal === 'nonsense' ||
|
|
434
|
+
(filtered.signal === 'ok' && !hasLexicalOverlap(batch.query, filtered.selected))
|
|
435
|
+
? ''
|
|
436
|
+
: buildC9Context(filtered.selected)
|
|
437
|
+
const signal =
|
|
438
|
+
context.length === 0 && filtered.signal === 'ok' ? ('nonsense' as const) : filtered.signal
|
|
439
|
+
const selectedCount = context.length === 0 ? 0 : filtered.selected.length
|
|
440
|
+
const droppedCount = context.length === 0 ? candidateCount : filtered.dropped.length
|
|
441
|
+
const recall = context.length === 0 && filtered.signal === 'ok' ? 0 : filtered.recall
|
|
442
|
+
return {
|
|
443
|
+
enabled: true,
|
|
444
|
+
context,
|
|
445
|
+
telemetry: telemetry(signal, candidateCount, selectedCount, droppedCount, recall, context),
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Normalize query/text terms without introducing a semantic or runtime dependency. */
|
|
450
|
+
function lexicalTerms(value: string): string[] {
|
|
451
|
+
return [
|
|
452
|
+
...new Set(
|
|
453
|
+
value
|
|
454
|
+
.normalize('NFKD')
|
|
455
|
+
.replace(/\p{M}/gu, '')
|
|
456
|
+
.toLowerCase()
|
|
457
|
+
.match(/[\p{L}\p{N}]+/gu)
|
|
458
|
+
?.filter((term) => term.length > 1) ?? [],
|
|
459
|
+
),
|
|
460
|
+
]
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Select only query-matching low-score chunks, ranked lexically then relatively. */
|
|
464
|
+
function relativeLexicalSelection(
|
|
465
|
+
byCategory: Map<string, C9Chunk[]>,
|
|
466
|
+
perCategory: Readonly<Record<string, C9CategoryConfig>> | undefined,
|
|
467
|
+
queryTerms: string[],
|
|
468
|
+
): C9Chunk[] {
|
|
469
|
+
const selected: C9Chunk[] = []
|
|
470
|
+
for (const [category, group] of byCategory) {
|
|
471
|
+
const config = perCategory?.[category]
|
|
472
|
+
const topK = config?.topK ?? C9_DEFAULT_TOP_K
|
|
473
|
+
const maxScore = Math.max(...group.map((chunk) => chunk.score), 0)
|
|
474
|
+
const ranked = group
|
|
475
|
+
.map((chunk, index) => {
|
|
476
|
+
const textSet = new Set(lexicalTerms(chunk.text))
|
|
477
|
+
const overlap = queryTerms.filter((term) => textSet.has(term)).length
|
|
478
|
+
const lexicalScore = overlap / queryTerms.length
|
|
479
|
+
const relativeScore = maxScore > 0 ? chunk.score / maxScore : 0
|
|
480
|
+
return { chunk, index, lexicalScore, relativeScore }
|
|
481
|
+
})
|
|
482
|
+
.filter((entry) => entry.lexicalScore > 0)
|
|
483
|
+
.sort(
|
|
484
|
+
(left, right) =>
|
|
485
|
+
right.lexicalScore - left.lexicalScore ||
|
|
486
|
+
right.relativeScore - left.relativeScore ||
|
|
487
|
+
right.chunk.score - left.chunk.score ||
|
|
488
|
+
left.index - right.index,
|
|
489
|
+
)
|
|
490
|
+
selected.push(...ranked.slice(0, topK).map((entry) => entry.chunk))
|
|
491
|
+
}
|
|
492
|
+
return selected
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isC9FilterOptions(
|
|
496
|
+
value: Readonly<Record<string, C9CategoryConfig>> | C9FilterOptions | string | undefined,
|
|
497
|
+
): value is C9FilterOptions {
|
|
498
|
+
return (
|
|
499
|
+
value !== undefined &&
|
|
500
|
+
typeof value !== 'string' &&
|
|
501
|
+
('perCategory' in value || ('query' in value && typeof value.query === 'string'))
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Build the injected context: only auditable chunks (id + score + text). */
|
|
506
|
+
export function buildC9Context(selected: readonly C9Chunk[]): string {
|
|
507
|
+
return selected.map((c) => `[${c.category}:${c.id}|${c.score}] ${c.text}`).join('\n')
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── Lever 2: 4 meta-tools ceiling + lazy ────────────────────────────────
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Always-on meta-tools (real repo names, verified):
|
|
514
|
+
* - `memory_search` (memory server: hybrid FTS5 + vector),
|
|
515
|
+
* - `kv_search` (persistence server: FTS5),
|
|
516
|
+
* - `code_query` (codemap: FTS5 code entities),
|
|
517
|
+
* - `pantheon://resources` (resources server URI scheme).
|
|
518
|
+
*/
|
|
519
|
+
export const ALWAYS_ON_TOOLS: readonly string[] = [
|
|
520
|
+
'memory_search',
|
|
521
|
+
'kv_search',
|
|
522
|
+
'code_query',
|
|
523
|
+
'pantheon://resources',
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
/** Lazy route for non-ceiling tools (on demand, search-first). */
|
|
527
|
+
export interface LazyRoute {
|
|
528
|
+
via: 'execute_code_script'
|
|
529
|
+
tool: string
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Classify a tool: ceiling (always on) or lazy (via pantheon-code-mode). */
|
|
533
|
+
export function classifyTool(name: string): 'always-on' | 'lazy' {
|
|
534
|
+
return (ALWAYS_ON_TOOLS as readonly string[]).includes(name) ? 'always-on' : 'lazy'
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Resolve a non-ceiling tool to its on-demand code-mode route.
|
|
539
|
+
* Verifies the tool EXISTS first (known description or ceiling entry) —
|
|
540
|
+
* unknown names throw instead of being silently misrouted.
|
|
541
|
+
*/
|
|
542
|
+
export function lazyRoute(name: string): LazyRoute {
|
|
543
|
+
if (!isKnownTool(name)) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
`lazyRoute: unknown tool ${JSON.stringify(name)} — register it in descriptions/ALWAYS_ON_TOOLS before routing`,
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
return { via: 'execute_code_script', tool: name }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Whether a tool name is known (has a description or is always-on). */
|
|
552
|
+
export function isKnownTool(name: string): boolean {
|
|
553
|
+
return name in FULL_DESCRIPTIONS || (ALWAYS_ON_TOOLS as readonly string[]).includes(name)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ─── Lever 3: response filtering + truncation ────────────────────────────
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Keep only the MCP fields the caller actually uses (drops embeddings,
|
|
560
|
+
* result_info and other unused payload weight).
|
|
561
|
+
* `required` names fields that MUST be present — missing ones throw
|
|
562
|
+
* instead of being silently dropped (quality floor).
|
|
563
|
+
*/
|
|
564
|
+
export function filterFields(
|
|
565
|
+
raw: Record<string, unknown>,
|
|
566
|
+
allowed: readonly string[],
|
|
567
|
+
required: readonly string[] = [],
|
|
568
|
+
): Record<string, unknown> {
|
|
569
|
+
const missing = required.filter((key) => !(key in raw))
|
|
570
|
+
if (missing.length > 0) {
|
|
571
|
+
throw new Error(`filterFields: missing required fields: ${missing.join(', ')}`)
|
|
572
|
+
}
|
|
573
|
+
const out: Record<string, unknown> = {}
|
|
574
|
+
for (const key of allowed) {
|
|
575
|
+
if (key in raw) out[key] = raw[key]
|
|
576
|
+
}
|
|
577
|
+
return out
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Result of per-turn truncation. */
|
|
581
|
+
export interface TruncateResult {
|
|
582
|
+
blocks: string[]
|
|
583
|
+
truncated: boolean
|
|
584
|
+
/** Chars of the truncation marker kept in the output (0 when untruncated — debit via `OverheadBreakdown.markerChars`). */
|
|
585
|
+
markerChars: number
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Fit text into a char budget, slicing with `…` when over (hard cap). */
|
|
589
|
+
function fitBudget(text: string, budget: number): string {
|
|
590
|
+
if (text.length <= budget) return text
|
|
591
|
+
if (budget <= 0) return ''
|
|
592
|
+
if (budget === 1) return '…'
|
|
593
|
+
return `${text.slice(0, budget - 1)}…`
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Cap a turn's blocks to `maxChars` preserving summary (first) + tail
|
|
598
|
+
* (last); middle blocks are replaced by a bounded marker.
|
|
599
|
+
*
|
|
600
|
+
* HARD CAP (margin 0): the joined output NEVER exceeds `maxChars` — head
|
|
601
|
+
* and tail are sliced with `…` when over budget. The marker itself counts
|
|
602
|
+
* toward the cap and its length is reported in `markerChars` for overhead
|
|
603
|
+
* debiting. When `maxChars` is smaller than the marker, the marker alone
|
|
604
|
+
* (sliced) is returned.
|
|
605
|
+
*/
|
|
606
|
+
export function truncateTurn(blocks: readonly string[], maxChars: number): TruncateResult {
|
|
607
|
+
const joined = blocks.join('\n')
|
|
608
|
+
if (joined.length <= maxChars) return { blocks: [...blocks], truncated: false, markerChars: 0 }
|
|
609
|
+
const head = blocks[0] ?? ''
|
|
610
|
+
const tail = blocks.length > 1 ? (blocks[blocks.length - 1] as string) : ''
|
|
611
|
+
if (blocks.length === 1) {
|
|
612
|
+
return { blocks: [fitBudget(head, maxChars)], truncated: true, markerChars: 0 }
|
|
613
|
+
}
|
|
614
|
+
if (blocks.length === 2) {
|
|
615
|
+
const tailOut = fitBudget(tail, maxChars)
|
|
616
|
+
const headOut = fitBudget(head, Math.max(0, maxChars - tailOut.length - 1))
|
|
617
|
+
return { blocks: [headOut, tailOut], truncated: true, markerChars: 0 }
|
|
618
|
+
}
|
|
619
|
+
const marker = `…[+${blocks.length - 2} truncated]…`
|
|
620
|
+
const budget = maxChars - marker.length - 2
|
|
621
|
+
if (budget < 0) {
|
|
622
|
+
return { blocks: [fitBudget(marker, maxChars)], truncated: true, markerChars: marker.length }
|
|
623
|
+
}
|
|
624
|
+
const headOut = fitBudget(head, budget)
|
|
625
|
+
const tailOut = fitBudget(tail, budget - headOut.length)
|
|
626
|
+
return { blocks: [headOut, marker, tailOut], truncated: true, markerChars: marker.length }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ─── Lever 5: schema compression ─────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
/** Full descriptions for familiar tools (detail on demand). */
|
|
632
|
+
const FULL_DESCRIPTIONS: Record<string, string> = {
|
|
633
|
+
memory_search:
|
|
634
|
+
'memory_search: hybrid semantic search across memories. Combines vector cosine ' +
|
|
635
|
+
'similarity and FTS5 BM25 keyword search via Reciprocal Rank Fusion (RRF). ' +
|
|
636
|
+
'Optional decay_days applies a freshness half-life so recent entries rank higher.',
|
|
637
|
+
kv_search:
|
|
638
|
+
'kv_search: full-text search across keys and values using FTS5. ' +
|
|
639
|
+
'Optionally filter by namespace. Returns matching entries with rank order.',
|
|
640
|
+
code_query:
|
|
641
|
+
'code_query: search code entities via FTS5 (with LIKE fallback) and optional ' +
|
|
642
|
+
'type filter. Returns entity id, file path, signature and docstring.',
|
|
643
|
+
'pantheon://resources':
|
|
644
|
+
'pantheon://resources: MCP resource URI scheme (agents, skills, routing, ' +
|
|
645
|
+
'deepwork plans, memory-bank files, code-mode scripts). Read-only discovery.',
|
|
646
|
+
kv_get: 'kv_get: retrieve a value by namespace and key. Returns None if not found or expired.',
|
|
647
|
+
kv_store:
|
|
648
|
+
'kv_store: store a key-value pair in a namespace with optional TTL (seconds). ' +
|
|
649
|
+
'INSERT OR REPLACE on duplicate (namespace, key).',
|
|
650
|
+
context_save:
|
|
651
|
+
'context_save: save a context checkpoint for a session/phase. Stores structured ' +
|
|
652
|
+
'JSON in persistence KV with auto-TTL of 4h. Also updates the "latest" pointer.',
|
|
653
|
+
context_get:
|
|
654
|
+
'context_get: retrieve a context checkpoint by session slug and key. ' +
|
|
655
|
+
'Returns the raw content string or null if expired/not found.',
|
|
656
|
+
memory_recall:
|
|
657
|
+
'memory_recall: recall a specific memory entry by its key within a namespace. ' +
|
|
658
|
+
'Returns the full entry including parsed metadata.',
|
|
659
|
+
memory_list:
|
|
660
|
+
'memory_list: list memory entries chronologically with optional namespace ' +
|
|
661
|
+
'and key-prefix filters.',
|
|
662
|
+
code_neighbors:
|
|
663
|
+
'code_neighbors: get neighbors of a code entity via relations graph (BFS depth 1-3).',
|
|
664
|
+
execute_code_script:
|
|
665
|
+
'execute_code_script: run a .sh/.py script from .pantheon/code-mode/ with ' +
|
|
666
|
+
'optional args. On-demand route for non-ceiling tools.',
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Slim descriptions for familiar tools (default; saves >= 40% chars). */
|
|
670
|
+
const SLIM_DESCRIPTIONS: Record<string, string> = {
|
|
671
|
+
memory_search: 'memory_search: hybrid memory search (vector + FTS5).',
|
|
672
|
+
kv_search: 'kv_search: text search over KV keys/values.',
|
|
673
|
+
code_query: 'code_query: FTS5 code-entity search.',
|
|
674
|
+
'pantheon://resources': 'pantheon://resources: read-only discovery URIs.',
|
|
675
|
+
kv_get: 'kv_get: read one KV entry.',
|
|
676
|
+
kv_store: 'kv_store: write one KV entry (upsert).',
|
|
677
|
+
context_save: 'context_save: save a session checkpoint.',
|
|
678
|
+
context_get: 'context_get: read a session checkpoint.',
|
|
679
|
+
memory_recall: 'memory_recall: recall one memory by key.',
|
|
680
|
+
memory_list: 'memory_list: list memories.',
|
|
681
|
+
code_neighbors: 'code_neighbors: code graph neighbors.',
|
|
682
|
+
execute_code_script: 'execute_code_script: on-demand lazy-tool route.',
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Describe a tool: slim by default, full detail on demand.
|
|
687
|
+
* Unknown tools fall back to their own name (never throws).
|
|
688
|
+
*/
|
|
689
|
+
export function describeTool(name: string, detail = false): string {
|
|
690
|
+
if (detail) return FULL_DESCRIPTIONS[name] ?? SLIM_DESCRIPTIONS[name] ?? name
|
|
691
|
+
return SLIM_DESCRIPTIONS[name] ?? FULL_DESCRIPTIONS[name] ?? name
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Detail-on-demand overhead for a tool (chars): fetching the full
|
|
696
|
+
* description costs `fullDetailChars(name)` — debit via
|
|
697
|
+
* `OverheadBreakdown.detailChars`. 0 for unknown tools (nothing to fetch).
|
|
698
|
+
*/
|
|
699
|
+
export function fullDetailChars(name: string): number {
|
|
700
|
+
return FULL_DESCRIPTIONS[name]?.length ?? 0
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Nominal search-first discovery probe for one lazy tool
|
|
705
|
+
* (`code_query <tool>` + resource read). `discoveryOverheadChars` is its
|
|
706
|
+
* char length — debit once per lazily-resolved tool via
|
|
707
|
+
* `OverheadBreakdown.discoveryChars`.
|
|
708
|
+
*/
|
|
709
|
+
export function discoveryProbe(tool: string): string {
|
|
710
|
+
return `code_query ${tool} + read pantheon://resources`
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Discovery overhead for one lazily-resolved tool (chars). */
|
|
714
|
+
export function discoveryOverheadChars(tool: string): number {
|
|
715
|
+
return discoveryProbe(tool).length
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ─── Tokens-only metering (WS0 extension, NO monetary layer) ─────────────
|
|
719
|
+
|
|
720
|
+
/** Token totals for one phase (input/output only — never USD). */
|
|
721
|
+
export interface PhaseTokens {
|
|
722
|
+
input: number
|
|
723
|
+
output: number
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Deterministic per-phase/per-agent token ledger (no LLM, no I/O).
|
|
728
|
+
* Extends WS0 `pantheon_cost`/cost-tracker with phase accounting while
|
|
729
|
+
* keeping the tokens-only mandate (no `costUsd` field anywhere).
|
|
730
|
+
*/
|
|
731
|
+
export class TokenMeter {
|
|
732
|
+
private readonly chars = new Map<string, { input: number; output: number }>()
|
|
733
|
+
|
|
734
|
+
/** Record one delegation's char counts under a phase + agent. */
|
|
735
|
+
record(phase: string, agent: string, inputChars: number, outputChars: number): void {
|
|
736
|
+
const key = `${phase}\0${agent}`
|
|
737
|
+
const entry = this.chars.get(key) ?? { input: 0, output: 0 }
|
|
738
|
+
entry.input += Math.max(0, inputChars)
|
|
739
|
+
entry.output += Math.max(0, outputChars)
|
|
740
|
+
this.chars.set(key, entry)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Totals for one phase across agents (tokens, converted once). */
|
|
744
|
+
phaseTotals(phase: string): PhaseTokens {
|
|
745
|
+
const chars = this.phaseChars(phase)
|
|
746
|
+
return { input: charsToTokens(chars.inputChars), output: charsToTokens(chars.outputChars) }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Totals across all phases. */
|
|
750
|
+
totals(): PhaseTokens {
|
|
751
|
+
const chars = this.charsTotals()
|
|
752
|
+
return { input: charsToTokens(chars.inputChars), output: charsToTokens(chars.outputChars) }
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Raw char counts for one phase (honest basis behind the token totals). */
|
|
756
|
+
phaseChars(phase: string): { inputChars: number; outputChars: number } {
|
|
757
|
+
let inputChars = 0
|
|
758
|
+
let outputChars = 0
|
|
759
|
+
for (const [key, entry] of this.chars) {
|
|
760
|
+
if (key.startsWith(`${phase}\0`)) {
|
|
761
|
+
inputChars += entry.input
|
|
762
|
+
outputChars += entry.output
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return { inputChars, outputChars }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Raw char counts across all phases. */
|
|
769
|
+
charsTotals(): { inputChars: number; outputChars: number } {
|
|
770
|
+
let inputChars = 0
|
|
771
|
+
let outputChars = 0
|
|
772
|
+
for (const entry of this.chars.values()) {
|
|
773
|
+
inputChars += entry.input
|
|
774
|
+
outputChars += entry.output
|
|
775
|
+
}
|
|
776
|
+
return { inputChars, outputChars }
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Re-export for callers that thread TOON payloads through metering. */
|
|
781
|
+
export type { ToonValue }
|