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.
Files changed (99) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/session-end-save.py +186 -0
  12. package/.pantheon/code-mode/session-end-save.sh +18 -0
  13. package/.pantheon/code-mode/test-allowed.sh +5 -0
  14. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  15. package/.pantheon/code-mode/test-env.sh +7 -0
  16. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  17. package/.pantheon/code-mode/test-timeout.sh +6 -0
  18. package/README.md +161 -1101
  19. package/README.pt-BR.md +222 -0
  20. package/bin/pantheon-init.mjs +30 -11
  21. package/opencode.json +1 -0
  22. package/package.json +14 -3
  23. package/plugin.json +15 -0
  24. package/scripts/_pantheon_paths.py +48 -7
  25. package/scripts/ci-validate-yaml.py +7 -2
  26. package/scripts/code_mode_server.py +33 -2
  27. package/scripts/cost.mjs +30 -14
  28. package/scripts/doctor.mjs +135 -127
  29. package/scripts/generate-preset-docs.mjs +127 -66
  30. package/scripts/generate-prompts.sh +2 -2
  31. package/scripts/install/cli-ui.mjs +8 -1
  32. package/scripts/install/config-migration.mjs +401 -0
  33. package/scripts/install/health-check.mjs +3 -3
  34. package/scripts/install/model-picker.mjs +72 -23
  35. package/scripts/install/opencode-version.mjs +47 -0
  36. package/scripts/install/opencode.mjs +261 -88
  37. package/scripts/install/plugin.mjs +112 -42
  38. package/scripts/install/shared.mjs +13 -158
  39. package/scripts/install-mcp.mjs +44 -232
  40. package/scripts/mcp_persistence_server.py +382 -16
  41. package/scripts/mcp_resources_server.py +144 -40
  42. package/scripts/memory_mcp_server.py +2 -2
  43. package/scripts/probe-context-rehydrate.mjs +240 -0
  44. package/scripts/probe-pantheon-cost.mjs +304 -0
  45. package/scripts/recover-zenodo-deposition.mjs +146 -0
  46. package/scripts/sync-tui.mjs +32 -7
  47. package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
  48. package/scripts/uninstall.mjs +298 -954
  49. package/scripts/validate-zenodo-release.mjs +154 -0
  50. package/scripts/zenodo-artifact.mjs +21 -0
  51. package/scripts/zenodo-release-state.mjs +53 -0
  52. package/src/agents/zeus.md +25 -14
  53. package/src/mcp/_pantheon_paths.py +48 -7
  54. package/src/mcp/code_mode_server.py +33 -2
  55. package/src/mcp/eval_store.py +1 -1
  56. package/src/mcp/mcp_persistence_server.py +382 -16
  57. package/src/mcp/mcp_resources_server.py +144 -40
  58. package/src/mcp/pantheon_vision_server.py +1 -1
  59. package/src/mcp/toon_codec.py +491 -0
  60. package/src/pantheon/background-job-board.ts +67 -1
  61. package/src/pantheon/cost-command.ts +60 -39
  62. package/src/pantheon/delegate-manager.ts +669 -0
  63. package/src/pantheon/delegation-classifier.ts +307 -0
  64. package/src/pantheon/delegation-enforce.ts +13 -12
  65. package/src/pantheon/delegation-finalize.ts +374 -0
  66. package/src/pantheon/delegation-notify.ts +212 -0
  67. package/src/pantheon/delegation-toast.ts +73 -0
  68. package/src/pantheon/delegation.ts +1120 -0
  69. package/src/pantheon/goal-loop.ts +2 -2
  70. package/src/pantheon/goal-store.ts +1 -1
  71. package/src/pantheon/hashline/tool.ts +2 -2
  72. package/src/pantheon/model-command.ts +2 -1
  73. package/src/pantheon/permission-eval.ts +284 -0
  74. package/src/pantheon/step-cap.ts +110 -0
  75. package/src/pantheon/token-opt.ts +781 -0
  76. package/src/pantheon/toon-codec.ts +481 -0
  77. package/src/pantheon/tui-version-check.ts +1 -3
  78. package/src/pantheon/v2-bridge.ts +129 -0
  79. package/src/pantheon/v2-events.ts +131 -0
  80. package/src/pantheon/v2-hooks.ts +287 -0
  81. package/src/pantheon/v2-tool-definitions.ts +331 -0
  82. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +824 -0
  85. package/src/plugin.ts +236 -42
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +28 -24
  98. package/src/skills/README.md +0 -2
  99. 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 }