@animalabs/connectome-host 0.7.3 → 0.7.4

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 (50) hide show
  1. package/CHANGELOG.md +156 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +12 -1
  4. package/docs/AGENT-ONBOARDING.md +1 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +2 -2
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/src/framework-strategy.ts +13 -4
  11. package/src/headless.ts +14 -0
  12. package/src/index.ts +12 -9
  13. package/src/modules/fleet-module.ts +60 -1
  14. package/src/modules/fleet-types.ts +30 -1
  15. package/src/modules/mcpl-admin-module.ts +33 -4
  16. package/src/modules/retrieval-module.ts +249 -51
  17. package/src/modules/retrieval-trace-page.ts +254 -0
  18. package/src/modules/retrieval-trace.ts +904 -0
  19. package/src/modules/tts-relay-module.ts +33 -18
  20. package/src/modules/web-ui-module.ts +445 -894
  21. package/src/recipe.ts +55 -4
  22. package/src/retrieval-config.ts +39 -0
  23. package/src/strategies/frontdesk-strategy.ts +34 -125
  24. package/src/tui.ts +325 -54
  25. package/src/web/panel-data.ts +1187 -0
  26. package/src/web/protocol.ts +75 -10
  27. package/test/audit-module-optins.test.ts +167 -0
  28. package/test/fleet-panel-request.test.ts +90 -0
  29. package/test/framework-strategy-defaults.test.ts +22 -0
  30. package/test/frontdesk-strategy.test.ts +25 -37
  31. package/test/headless-panel-request.test.ts +201 -0
  32. package/test/mcpl-admin-module.test.ts +23 -0
  33. package/test/mock-headless-child.ts +14 -0
  34. package/test/retrieval-auth-loopback.test.ts +49 -0
  35. package/test/retrieval-config.test.ts +74 -0
  36. package/test/retrieval-module.test.ts +821 -0
  37. package/test/tui-format.test.ts +106 -0
  38. package/test/web-ui-context-coverage.test.ts +1 -1
  39. package/test/web-ui-module.test.ts +189 -3
  40. package/test/web-ui-observers.test.ts +8 -5
  41. package/test/web-ui-protocol.test.ts +0 -0
  42. package/web/src/App.tsx +159 -44
  43. package/web/src/Context.tsx +35 -8
  44. package/web/src/ContextDocument.tsx +20 -5
  45. package/web/src/Files.tsx +2 -8
  46. package/web/src/Lessons.tsx +2 -38
  47. package/web/src/Mcpl.tsx +80 -14
  48. package/web/src/Pins.tsx +5 -0
  49. package/web/src/Settings.tsx +5 -0
  50. package/web/vite.config.ts +8 -2
@@ -0,0 +1,1187 @@
1
+ /**
2
+ * Panel data layer — process-agnostic builders and mutation appliers behind
3
+ * every operator inspection surface (MCPL, settings, pins, health, context
4
+ * debug). Extracted from web-ui-module.ts so the SAME code answers a panel
5
+ * query whether the process is the WebUI host itself or a headless fleet
6
+ * child asked over the fleet IPC:
7
+ *
8
+ * WebUiModule (scope 'local') ──┐
9
+ * ├──> runPanelOp(app, op, params)
10
+ * headless.ts ('panel-request') ─┘
11
+ *
12
+ * Everything here operates on a minimal `PanelAppRef` (framework + recipe +
13
+ * optional call ledger), NOT on the full AppContext — keeps the layer free of
14
+ * import cycles and callable from both runtimes.
15
+ *
16
+ * Results are wire-shaped JSON. Errors carry an optional HTTP-ish `status`
17
+ * so the WebUI's HTTP proxy routes can answer faithfully (404 unknown agent,
18
+ * 429 preview cooldown, 501 unsupported build).
19
+ */
20
+
21
+ import type { AgentFramework } from '@animalabs/agent-framework';
22
+ import type { Recipe } from '../recipe.js';
23
+ import type { CallLedger } from '../call-ledger.js';
24
+ import {
25
+ readMcplServersFile,
26
+ DEFAULT_CONFIG_PATH,
27
+ } from '../mcpl-config.js';
28
+
29
+ /** Minimal slice of AppContext the panel layer needs. Both the WebUI host
30
+ * and the headless child runtime satisfy this structurally. */
31
+ export interface PanelAppRef {
32
+ framework: AgentFramework;
33
+ recipe: Recipe;
34
+ /** Content-free recent provider-call ledger, when the host wired one. */
35
+ callLedger?: CallLedger | null;
36
+ }
37
+
38
+ /** Panel operations servable by any conhost process. Kept as a const list so
39
+ * fleet-types.ts and tests can enumerate without importing the handlers. */
40
+ export const PANEL_OPS = [
41
+ 'mcpl',
42
+ 'settings',
43
+ 'settings-update',
44
+ 'settings-reset',
45
+ 'settings-cancel-transition',
46
+ 'pins',
47
+ 'pin-add',
48
+ 'pin-remove',
49
+ 'health',
50
+ 'context-makeup',
51
+ 'context-coverage',
52
+ 'context-curve',
53
+ 'context-preview',
54
+ 'context-maintenance',
55
+ 'debug-context',
56
+ ] as const;
57
+ export type PanelOp = (typeof PANEL_OPS)[number];
58
+
59
+ export type PanelResult =
60
+ | { ok: true; data: unknown }
61
+ | { ok: false; error: string; status?: number };
62
+
63
+ /** Error carrying an HTTP-ish status through the shared layer. */
64
+ export class PanelError extends Error {
65
+ constructor(message: string, readonly status: number = 500) {
66
+ super(message);
67
+ }
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Dispatch
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Run one panel op. Never throws — all failures come back as
76
+ * `{ok:false, error, status}` so both callers (WS handler, IPC handler)
77
+ * forward them without their own try/catch choreography.
78
+ */
79
+ export async function runPanelOp(
80
+ app: PanelAppRef,
81
+ op: string,
82
+ params: Record<string, unknown> = {},
83
+ ): Promise<PanelResult> {
84
+ try {
85
+ switch (op as PanelOp) {
86
+ case 'mcpl':
87
+ return { ok: true, data: buildMcplSnapshot(app) };
88
+ case 'settings':
89
+ return { ok: true, data: requireSettingsState(app, resolveAgent(app, params.agent)) };
90
+ case 'settings-update': {
91
+ const agent = resolveAgent(app, params.agent);
92
+ applySettingsUpdate(app, agent, params);
93
+ if (params.notify === true) notifyAgentOfSettingsChange(app, agent, 'update');
94
+ return { ok: true, data: requireSettingsState(app, agent) };
95
+ }
96
+ case 'settings-reset': {
97
+ const agent = resolveAgent(app, params.agent);
98
+ applySettingsReset(app, agent, params);
99
+ if (params.notify === true) notifyAgentOfSettingsChange(app, agent, 'reset');
100
+ return { ok: true, data: requireSettingsState(app, agent) };
101
+ }
102
+ case 'settings-cancel-transition': {
103
+ const agent = resolveAgent(app, params.agent);
104
+ applySettingsCancelTransition(app, agent);
105
+ return { ok: true, data: requireSettingsState(app, agent) };
106
+ }
107
+ case 'pins':
108
+ return {
109
+ ok: true,
110
+ data: buildPinsSnapshot(app, resolveAgent(app, params.agent), {
111
+ withCandidates: params.withCandidates === true,
112
+ }),
113
+ };
114
+ case 'pin-add': {
115
+ const agent = resolveAgent(app, params.agent);
116
+ applyPinAdd(app, agent, params);
117
+ return { ok: true, data: buildPinsSnapshot(app, agent, { withCandidates: params.withCandidates === true }) };
118
+ }
119
+ case 'pin-remove': {
120
+ const agent = resolveAgent(app, params.agent);
121
+ const removed = applyPinRemove(app, agent, String(params.pinId ?? ''));
122
+ const data = buildPinsSnapshot(app, agent, { withCandidates: params.withCandidates === true }) as unknown as Record<string, unknown>;
123
+ // A stale panel can ask twice; report it without failing the refresh.
124
+ if (!removed) data.warning = `no such pin: ${String(params.pinId ?? '')}`;
125
+ return { ok: true, data };
126
+ }
127
+ case 'health':
128
+ return { ok: true, data: buildHealthSnapshot(app) };
129
+ case 'context-makeup':
130
+ return { ok: true, data: await buildContextMakeup(app, resolveAgent(app, params.agent)) };
131
+ case 'context-coverage':
132
+ return { ok: true, data: buildContextCoverage(app, resolveAgent(app, params.agent)) };
133
+ case 'context-curve':
134
+ return { ok: true, data: await buildContextCurve(app, resolveAgent(app, params.agent)) };
135
+ case 'context-preview':
136
+ return { ok: true, data: runContextPreview(app, resolveAgent(app, params.agent), params) };
137
+ case 'context-maintenance':
138
+ return { ok: true, data: buildContextMaintenance(app) };
139
+ case 'debug-context':
140
+ return { ok: true, data: await buildDebugContext(app, resolveAgent(app, params.agent), params) };
141
+ default:
142
+ return { ok: false, error: `unknown panel op: ${op}`, status: 400 };
143
+ }
144
+ } catch (err) {
145
+ if (err instanceof PanelError) return { ok: false, error: err.message, status: err.status };
146
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
147
+ }
148
+ }
149
+
150
+ /** Default to the recipe's primary agent when the caller omits a name. */
151
+ export function resolveAgent(app: PanelAppRef, name?: unknown): string {
152
+ if (typeof name === 'string' && name.length > 0) return name;
153
+ return app.recipe.agent.name ?? app.framework.getAllAgents()[0]?.name ?? 'agent';
154
+ }
155
+
156
+ function requireAgent(app: PanelAppRef, agentName: string): NonNullable<ReturnType<AgentFramework['getAgent']>> {
157
+ const agent = app.framework.getAgent(agentName);
158
+ if (!agent) throw new PanelError(`Agent not found: ${agentName}`, 404);
159
+ return agent;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // MCPL
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** Live MCPL server state as this process's framework sees it. */
167
+ export interface McplLiveServer {
168
+ id: string;
169
+ connected: boolean;
170
+ toolCount: number;
171
+ toolPrefix?: string;
172
+ /** command or url — whatever the transport targets. */
173
+ target?: string;
174
+ }
175
+
176
+ /**
177
+ * MCPL snapshot: the shared file registry (what the panel can edit) PLUS the
178
+ * live servers this process actually loaded (recipe opt-in + agent overlay),
179
+ * with connection status. The live list is what a fleet child meaningfully
180
+ * differs on — the file is shared cwd-wide, but each recipe opts into its
181
+ * own subset.
182
+ */
183
+ export function buildMcplSnapshot(app: PanelAppRef): Record<string, unknown> {
184
+ let servers: ReturnType<typeof readMcplServersFile> = {};
185
+ try { servers = readMcplServersFile(DEFAULT_CONFIG_PATH); }
186
+ catch { /* missing or malformed file → empty list */ }
187
+
188
+ let live: McplLiveServer[] = [];
189
+ try {
190
+ const fw = app.framework as unknown as {
191
+ listMcplServers?: () => Array<{
192
+ id: string; connected?: boolean; toolCount?: number; toolPrefix?: string;
193
+ command?: string; url?: string;
194
+ }>;
195
+ };
196
+ if (typeof fw.listMcplServers === 'function') {
197
+ live = fw.listMcplServers().map((s) => ({
198
+ id: s.id,
199
+ connected: s.connected === true,
200
+ toolCount: s.toolCount ?? 0,
201
+ ...(s.toolPrefix ? { toolPrefix: s.toolPrefix } : {}),
202
+ ...(s.command || s.url ? { target: s.command ?? s.url } : {}),
203
+ }));
204
+ }
205
+ } catch { /* live view is best-effort; the file registry still renders */ }
206
+
207
+ return {
208
+ configPath: DEFAULT_CONFIG_PATH,
209
+ servers: Object.entries(servers).map(([id, entry]) => ({
210
+ id,
211
+ command: entry.command,
212
+ ...(entry.args ? { args: entry.args } : {}),
213
+ ...(entry.env ? { env: entry.env } : {}),
214
+ ...(entry.toolPrefix ? { toolPrefix: entry.toolPrefix } : {}),
215
+ ...(entry.reconnect !== undefined ? { reconnect: entry.reconnect } : {}),
216
+ ...(entry.enabledFeatureSets ? { enabledFeatureSets: entry.enabledFeatureSets } : {}),
217
+ ...(entry.disabledFeatureSets ? { disabledFeatureSets: entry.disabledFeatureSets } : {}),
218
+ })),
219
+ live,
220
+ };
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Settings
225
+ // ---------------------------------------------------------------------------
226
+
227
+ export interface SettingsStateData {
228
+ agent: string;
229
+ settings: Record<string, unknown>;
230
+ overrides: string[];
231
+ hotKeys: string[];
232
+ hotConfigurable: boolean;
233
+ previewAvailable: boolean;
234
+ }
235
+
236
+ /**
237
+ * Build the settings snapshot. Returns null when the framework build has no
238
+ * runtime-settings surface at all: that is a legitimate state for the panel
239
+ * to render read-only, not an error to surface.
240
+ */
241
+ export function buildSettingsState(app: PanelAppRef, agentName: string): SettingsStateData | null {
242
+ const fw = app.framework as unknown as {
243
+ getAgentRuntimeSettings?: (n: string) => Record<string, unknown>;
244
+ };
245
+ if (typeof fw.getAgentRuntimeSettings !== 'function') return null;
246
+
247
+ let settings: Record<string, unknown>;
248
+ try {
249
+ settings = fw.getAgentRuntimeSettings(agentName);
250
+ } catch {
251
+ return null;
252
+ }
253
+
254
+ // Which knobs this build can apply live, probed rather than assumed: the
255
+ // hot set is a property of the ACTIVE strategy, not of the host version.
256
+ let hotConfigurable = false;
257
+ let previewAvailable = false;
258
+ try {
259
+ const agent = app.framework.getAgent(agentName);
260
+ const cm = agent?.getContextManager() as unknown as {
261
+ getHotContextSettings?: () => unknown;
262
+ previewContext?: unknown;
263
+ } | undefined;
264
+ hotConfigurable = !!cm && typeof cm.getHotContextSettings === 'function'
265
+ && cm.getHotContextSettings() !== null;
266
+ previewAvailable = !!cm && typeof cm.previewContext === 'function';
267
+ } catch { /* leave both false — read-only panel */ }
268
+
269
+ const overrides: string[] = [];
270
+ try {
271
+ const agent = app.framework.getAgent(agentName) as unknown as {
272
+ getRuntimeSettingsOverrides?: () => Record<string, unknown>;
273
+ } | undefined;
274
+ const ov = agent?.getRuntimeSettingsOverrides?.() ?? {};
275
+ for (const [k, val] of Object.entries(ov)) if (val !== undefined) overrides.push(k);
276
+ } catch { /* informational only */ }
277
+
278
+ return {
279
+ agent: agentName,
280
+ settings,
281
+ overrides,
282
+ // contextBudgetTokens is applied by the Agent itself; the other three are
283
+ // forwarded into the strategy's hot-settings channel, so they need a
284
+ // hot-configurable strategy to mean anything.
285
+ hotKeys: hotConfigurable
286
+ ? ['contextBudgetTokens', 'tailTokens', 'transitionPaceTokens', 'sameRoundThinkTextPolicy']
287
+ : ['contextBudgetTokens'],
288
+ hotConfigurable,
289
+ previewAvailable,
290
+ };
291
+ }
292
+
293
+ function requireSettingsState(app: PanelAppRef, agentName: string): SettingsStateData {
294
+ const state = buildSettingsState(app, agentName);
295
+ if (!state) throw new PanelError('runtime settings unavailable on this build', 501);
296
+ return state;
297
+ }
298
+
299
+ /** Apply a live settings patch. Throws with the framework's own message on
300
+ * rejection (budget ≤ max response tokens, non-hot strategy, ...). */
301
+ export function applySettingsUpdate(
302
+ app: PanelAppRef,
303
+ agentName: string,
304
+ params: Record<string, unknown>,
305
+ ): void {
306
+ const patch: Record<string, number | boolean> = {};
307
+ if (typeof params.contextBudgetTokens === 'number') patch.contextBudgetTokens = params.contextBudgetTokens;
308
+ if (typeof params.tailTokens === 'number') patch.tailTokens = params.tailTokens;
309
+ if (typeof params.transitionPaceTokens === 'number') patch.transitionPaceTokens = params.transitionPaceTokens;
310
+ if (typeof params.immediate === 'boolean') patch.immediate = params.immediate;
311
+ const fw = app.framework as unknown as {
312
+ updateAgentRuntimeSettings: (n: string, p: unknown, o?: { persist?: boolean }) => unknown;
313
+ };
314
+ if (typeof fw.updateAgentRuntimeSettings !== 'function') {
315
+ throw new PanelError('runtime settings unavailable on this build', 501);
316
+ }
317
+ fw.updateAgentRuntimeSettings(agentName, patch, { persist: params.persist !== false });
318
+ }
319
+
320
+ export function applySettingsReset(
321
+ app: PanelAppRef,
322
+ agentName: string,
323
+ params: Record<string, unknown>,
324
+ ): void {
325
+ const fw = app.framework as unknown as {
326
+ resetAgentRuntimeSettings: (n: string, k?: string[], o?: { persist?: boolean }) => unknown;
327
+ };
328
+ if (typeof fw.resetAgentRuntimeSettings !== 'function') {
329
+ throw new PanelError('runtime settings unavailable on this build', 501);
330
+ }
331
+ const keys = Array.isArray(params.keys) ? (params.keys as string[]) : undefined;
332
+ fw.resetAgentRuntimeSettings(agentName, keys, { persist: params.persist !== false });
333
+ }
334
+
335
+ export function applySettingsCancelTransition(app: PanelAppRef, agentName: string): void {
336
+ const fw = app.framework as unknown as {
337
+ cancelAgentRuntimeSettingsTransition: (n: string) => unknown;
338
+ };
339
+ if (typeof fw.cancelAgentRuntimeSettingsTransition !== 'function') {
340
+ throw new PanelError('runtime settings unavailable on this build', 501);
341
+ }
342
+ fw.cancelAgentRuntimeSettingsTransition(agentName);
343
+ }
344
+
345
+ /**
346
+ * Opt-in push notice to the agent that an operator changed its context
347
+ * settings. OFF by default at the protocol level, because this injects text
348
+ * into the very context being tuned: it invalidates the KV prefix and is
349
+ * itself classifier-visible. The zero-cost alternative the agent always has
350
+ * is to PULL via its `agent_settings` tool.
351
+ */
352
+ export function notifyAgentOfSettingsChange(
353
+ app: PanelAppRef,
354
+ agentName: string,
355
+ kind: 'update' | 'reset',
356
+ ): void {
357
+ try {
358
+ const s = buildSettingsState(app, agentName);
359
+ const budget = s?.settings.contextBudgetTokens;
360
+ const tail = s?.settings.tailTokens;
361
+ const transition = s?.settings.transition;
362
+ const text = kind === 'reset'
363
+ ? `[operator] context settings reset to recipe defaults`
364
+ : `[operator] context settings changed`
365
+ + (budget !== undefined ? ` — budget ${budget}` : '')
366
+ + (tail !== undefined ? `, tail ${tail}` : '')
367
+ + (transition === 'converging' ? ' (converging gradually)' : '');
368
+ const cm = app.framework.getAgent(agentName)?.getContextManager();
369
+ cm?.addMessage('Context Manager', [{ type: 'text', text }], { system: true });
370
+ } catch (err) {
371
+ // A failed notice must never fail the apply that already succeeded.
372
+ console.warn('[settings] notify failed (change still applied):', err);
373
+ }
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Pins
378
+ // ---------------------------------------------------------------------------
379
+
380
+ export interface PinsSnapshotData {
381
+ agent: string;
382
+ pins: Array<{
383
+ id: string;
384
+ firstMessageId: string;
385
+ lastMessageId: string;
386
+ kind: 'pin' | 'document';
387
+ name?: string;
388
+ created: number;
389
+ level?: number;
390
+ maxLevel?: number;
391
+ }>;
392
+ pinsSupported: boolean;
393
+ levelHonored: boolean;
394
+ deepestLevel?: number;
395
+ /** Recent pinnable messages (real store ids), when requested. Lets a
396
+ * remote panel offer its picker without holding this process's
397
+ * conversation window. */
398
+ candidates?: Array<{ id: string; index: number; participant: string; text: string }>;
399
+ }
400
+
401
+ /** Duck-typed pin surface. Throws with a clear reason rather than returning a
402
+ * half-usable object, so callers can report it to the operator verbatim. */
403
+ function pinnableCm(app: PanelAppRef, agentName: string): {
404
+ pinRange?: (a: string, b: string, o?: unknown) => string;
405
+ markDocument?: (a: string, o?: unknown) => string;
406
+ unpin?: (id: string) => boolean;
407
+ listPins?: () => ReadonlyArray<Record<string, unknown>>;
408
+ } {
409
+ const agent = requireAgent(app, agentName);
410
+ const cm = agent.getContextManager() as unknown as {
411
+ pinRange?: (a: string, b: string, o?: unknown) => string;
412
+ markDocument?: (a: string, o?: unknown) => string;
413
+ unpin?: (id: string) => boolean;
414
+ listPins?: () => ReadonlyArray<Record<string, unknown>>;
415
+ };
416
+ if (typeof cm.listPins !== 'function' || typeof cm.pinRange !== 'function') {
417
+ throw new PanelError('the active context strategy does not support pins', 501);
418
+ }
419
+ return cm;
420
+ }
421
+
422
+ /** How many recent messages a pins snapshot ships as picker candidates. */
423
+ const PIN_CANDIDATE_LIMIT = 200;
424
+ const PIN_CANDIDATE_TEXT_CAP = 160;
425
+
426
+ /**
427
+ * Pin snapshot. Never throws for an unsupported strategy — that's a
428
+ * legitimate read-only state for the panel, not an error.
429
+ *
430
+ * `levelHonored` matters: pin-AT-level is implemented only by the kv-stable
431
+ * controller. Elsewhere it degrades to raw, which is a safe superset but not
432
+ * what the operator asked for, so the UI needs to be able to say so.
433
+ */
434
+ export function buildPinsSnapshot(
435
+ app: PanelAppRef,
436
+ agentName: string,
437
+ opts: { withCandidates?: boolean } = {},
438
+ ): PinsSnapshotData {
439
+ let pins: PinsSnapshotData['pins'] = [];
440
+ let supported = false;
441
+ try {
442
+ const cm = pinnableCm(app, agentName);
443
+ pins = (cm.listPins!() ?? []).map((p) => ({
444
+ id: String(p.id),
445
+ firstMessageId: String(p.firstMessageId),
446
+ lastMessageId: String(p.lastMessageId),
447
+ kind: p.kind === 'document' ? 'document' : 'pin',
448
+ ...(typeof p.name === 'string' ? { name: p.name } : {}),
449
+ created: typeof p.created === 'number' ? p.created : 0,
450
+ ...(typeof p.level === 'number' ? { level: p.level } : {}),
451
+ ...(typeof p.maxLevel === 'number' ? { maxLevel: p.maxLevel } : {}),
452
+ }));
453
+ supported = true;
454
+ } catch (err) {
455
+ // Unknown agent is a real error; unsupported strategy is a state.
456
+ if (err instanceof PanelError && err.status === 404) throw err;
457
+ supported = false;
458
+ }
459
+
460
+ let levelHonored = false;
461
+ let deepestLevel: number | undefined;
462
+ try {
463
+ const strategyCfg = (app.recipe.agent as unknown as {
464
+ strategy?: { foldingStrategy?: string };
465
+ }).strategy;
466
+ levelHonored = strategyCfg?.foldingStrategy === 'kv-stable';
467
+ const agent = app.framework.getAgent(agentName);
468
+ const cm = agent?.getContextManager() as unknown as {
469
+ getSummaries?: () => Array<{ level?: number }>;
470
+ } | undefined;
471
+ const sums = cm?.getSummaries?.() ?? [];
472
+ for (const s of sums) {
473
+ if (typeof s.level === 'number' && (deepestLevel === undefined || s.level > deepestLevel)) {
474
+ deepestLevel = s.level;
475
+ }
476
+ }
477
+ } catch { /* informational only */ }
478
+
479
+ return {
480
+ agent: agentName,
481
+ pins,
482
+ pinsSupported: supported,
483
+ levelHonored,
484
+ ...(deepestLevel !== undefined ? { deepestLevel } : {}),
485
+ ...(opts.withCandidates ? { candidates: buildPinCandidates(app, agentName) } : {}),
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Recent pinnable messages with REAL store ids. Mirrors the client-side
491
+ * candidate list the local panel builds from its own message window — a
492
+ * remote (fleet-child) panel has no such window, so the snapshot carries one.
493
+ */
494
+ function buildPinCandidates(
495
+ app: PanelAppRef,
496
+ agentName: string,
497
+ ): Array<{ id: string; index: number; participant: string; text: string }> {
498
+ try {
499
+ const agent = app.framework.getAgent(agentName);
500
+ const cm = agent?.getContextManager() as unknown as {
501
+ getMessageCount?: () => number;
502
+ getMessageWindow?: (
503
+ offset: number, limit: number, opts?: { resolveBlobs?: boolean },
504
+ ) => { messages: Array<{ id?: string; participant?: string; content?: unknown }>; startIndex: number };
505
+ } | undefined;
506
+ if (!cm || typeof cm.getMessageCount !== 'function' || typeof cm.getMessageWindow !== 'function') {
507
+ return [];
508
+ }
509
+ const total = cm.getMessageCount();
510
+ const start = Math.max(0, total - PIN_CANDIDATE_LIMIT);
511
+ const win = cm.getMessageWindow(start, total - start, { resolveBlobs: false });
512
+ const out: Array<{ id: string; index: number; participant: string; text: string }> = [];
513
+ win.messages.forEach((m, i) => {
514
+ if (!m.id) return;
515
+ let text = '';
516
+ if (Array.isArray(m.content)) {
517
+ for (const b of m.content as Array<Record<string, unknown>>) {
518
+ if (b?.type === 'text') text += String(b.text ?? '');
519
+ }
520
+ } else if (typeof m.content === 'string') {
521
+ text = m.content;
522
+ }
523
+ out.push({
524
+ id: String(m.id),
525
+ index: win.startIndex + i,
526
+ participant: String(m.participant ?? '?'),
527
+ text: text.slice(0, PIN_CANDIDATE_TEXT_CAP),
528
+ });
529
+ });
530
+ return out;
531
+ } catch {
532
+ return [];
533
+ }
534
+ }
535
+
536
+ export function applyPinAdd(app: PanelAppRef, agentName: string, params: Record<string, unknown>): void {
537
+ const cm = pinnableCm(app, agentName);
538
+ const firstMessageId = String(params.firstMessageId ?? '');
539
+ if (!firstMessageId) throw new PanelError('firstMessageId required', 400);
540
+ const opts: Record<string, unknown> = {};
541
+ if (params.name !== undefined) opts.name = params.name;
542
+ if (params.level !== undefined) opts.level = params.level;
543
+ if (params.maxLevel !== undefined) opts.maxLevel = params.maxLevel;
544
+ if (params.kind === 'document') {
545
+ cm.markDocument!(firstMessageId, opts);
546
+ } else {
547
+ // A single-message pin is a range of one; the strategy takes both ends.
548
+ cm.pinRange!(firstMessageId, String(params.lastMessageId ?? firstMessageId), opts);
549
+ }
550
+ }
551
+
552
+ /** Returns false when no such pin existed (stale panel double-click). */
553
+ export function applyPinRemove(app: PanelAppRef, agentName: string, pinId: string): boolean {
554
+ const cm = pinnableCm(app, agentName);
555
+ if (!pinId) throw new PanelError('pinId required', 400);
556
+ return cm.unpin!(pinId);
557
+ }
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // Health
561
+ // ---------------------------------------------------------------------------
562
+
563
+ /**
564
+ * Liveness/health snapshot — the /healthz assembly. framework.healthSnapshot
565
+ * plus compression quarantine, rendered context composition, per-agent
566
+ * runtime settings, and (when a ledger is wired) recent provider calls.
567
+ * Everything is read-only and cheap: no compile, no count_tokens.
568
+ */
569
+ export function buildHealthSnapshot(app: PanelAppRef): Record<string, unknown> {
570
+ const fw = app.framework as unknown as { healthSnapshot?: () => Record<string, unknown> };
571
+ if (typeof fw.healthSnapshot !== 'function') {
572
+ throw new PanelError('framework lacks healthSnapshot()', 501);
573
+ }
574
+ const snapshot = fw.healthSnapshot();
575
+ // Compression quarantine is a guaranteed-eventual-outage state (raw
576
+ // spans accumulate until the picker cannot fit the window). Surface it
577
+ // here so the fleet hub and connectome-doctor can alarm on it — it
578
+ // must never be observable only in agent.log.
579
+ try {
580
+ const quarantine: Record<string, unknown> = {};
581
+ for (const agent of app.framework.getAllAgents()) {
582
+ const strategy = (agent.getContextManager() as unknown as {
583
+ getStrategy?: () => { getCompressionQuarantineStatus?: () => unknown };
584
+ }).getStrategy?.();
585
+ const status = strategy?.getCompressionQuarantineStatus?.();
586
+ if (status) quarantine[(agent as unknown as { name: string }).name] = status;
587
+ }
588
+ (snapshot as Record<string, unknown>).compressionQuarantine = quarantine;
589
+ } catch {
590
+ // Health reads never throw.
591
+ }
592
+ // Rendered context COMPOSITION per agent — head / raw middle / summaries
593
+ // by level / tail, as actually emitted by the last compile. Sourced from
594
+ // the strategy's own render stats (already computed in-process), so it is
595
+ // safe on the 15s /healthz poll.
596
+ try {
597
+ const composition: Record<string, unknown> = {};
598
+ for (const agent of app.framework.getAllAgents()) {
599
+ const name = (agent as unknown as { name: string }).name;
600
+ const cm = agent.getContextManager() as unknown as {
601
+ getRenderStats?: () => unknown;
602
+ };
603
+ const rs = cm.getRenderStats?.();
604
+ if (rs) composition[name] = rs;
605
+ }
606
+ (snapshot as Record<string, unknown>).contextComposition = composition;
607
+ } catch {
608
+ // Health reads never throw.
609
+ }
610
+ // Per-agent runtime settings (context budget, tail, transition pace +
611
+ // convergence state) — the same numbers `agent_settings get` returns,
612
+ // exposed externally so the fleet hub / connectome-doctor can watch
613
+ // budget convergence without an agent turn.
614
+ try {
615
+ const fw2 = app.framework as unknown as {
616
+ getAgentRuntimeSettings?: (name: string) => unknown;
617
+ };
618
+ if (typeof fw2.getAgentRuntimeSettings === 'function') {
619
+ const settings: Record<string, unknown> = {};
620
+ for (const agent of app.framework.getAllAgents()) {
621
+ const name = (agent as unknown as { name: string }).name;
622
+ settings[name] = fw2.getAgentRuntimeSettings(name);
623
+ }
624
+ (snapshot as Record<string, unknown>).runtimeSettings = settings;
625
+ }
626
+ } catch {
627
+ // Health reads never throw.
628
+ }
629
+ // Recent provider calls. The WebUI host streams these over its own WS;
630
+ // for a fleet child this snapshot is the only path, so ship them here.
631
+ try {
632
+ const ledger = app.callLedger?.snapshot();
633
+ if (ledger) (snapshot as Record<string, unknown>).callLedger = ledger;
634
+ } catch {
635
+ // Health reads never throw.
636
+ }
637
+ return snapshot;
638
+ }
639
+
640
+ /**
641
+ * Counts-only state and bounded history for periodic context maintenance.
642
+ * The framework snapshot deliberately contains no message or summary text.
643
+ */
644
+ export function buildContextMaintenance(app: PanelAppRef): Record<string, unknown> {
645
+ const framework = app.framework as unknown as {
646
+ getContextMaintenanceSnapshot?: () => Record<string, unknown>;
647
+ };
648
+ if (typeof framework.getContextMaintenanceSnapshot !== 'function') {
649
+ throw new PanelError('framework lacks context-maintenance diagnostics', 501);
650
+ }
651
+ return framework.getContextMaintenanceSnapshot();
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // Context debug (makeup / coverage / curve / preview / raw request)
656
+ // ---------------------------------------------------------------------------
657
+
658
+ interface CoverageSummary {
659
+ id: string;
660
+ level: number;
661
+ tokens?: number;
662
+ sourceIds?: string[];
663
+ mergedInto?: string;
664
+ }
665
+
666
+ interface CoverageChunk {
667
+ index: number;
668
+ tokens?: number;
669
+ compressed?: boolean;
670
+ summaryId?: string;
671
+ messages?: Array<{ id?: string }>;
672
+ }
673
+
674
+ interface CoverageStrategy {
675
+ summaries?: CoverageSummary[];
676
+ chunks?: CoverageChunk[];
677
+ compressionQueue?: number[];
678
+ mergeQueue?: Array<{ level: number; sourceIds: string[] }>;
679
+ resolutions?: Map<string, number>;
680
+ pendingCompression?: Promise<void> | null;
681
+ }
682
+
683
+ export interface ContextCoverageSnapshot {
684
+ agent: string;
685
+ branch: string;
686
+ generatedAt: string;
687
+ supported: boolean;
688
+ totals: {
689
+ chunks: number;
690
+ compressedChunks: number;
691
+ coveredMessages: number;
692
+ coveredTokens: number;
693
+ summaries: number;
694
+ };
695
+ levels: Array<{
696
+ level: number;
697
+ summaries: number;
698
+ frontier: number;
699
+ tokens: number;
700
+ coveredChunks: number;
701
+ coveredMessages: number;
702
+ coveredTokens: number;
703
+ }>;
704
+ chunks: Array<{
705
+ index: number;
706
+ messages: number;
707
+ tokens: number;
708
+ compressed: boolean;
709
+ summaryId: string | null;
710
+ maxLevel: number;
711
+ selectedMin: number;
712
+ selectedMax: number;
713
+ queued: boolean;
714
+ }>;
715
+ queue: {
716
+ inFlight: boolean;
717
+ pending: string | null;
718
+ l1: number[];
719
+ merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
720
+ };
721
+ }
722
+
723
+ /** Build a text-free projection of the autobiographical summary pyramid. */
724
+ export function buildContextCoverageSnapshot(
725
+ agentName: string,
726
+ cm: {
727
+ currentBranch: () => { name: string };
728
+ getStrategy: () => unknown;
729
+ getPendingWork?: () => { description?: string } | null;
730
+ },
731
+ ): ContextCoverageSnapshot {
732
+ const strategy = cm.getStrategy() as CoverageStrategy;
733
+ const summaries = Array.isArray(strategy.summaries) ? strategy.summaries : [];
734
+ const chunks = Array.isArray(strategy.chunks) ? strategy.chunks : [];
735
+ const compressionQueue = Array.isArray(strategy.compressionQueue) ? strategy.compressionQueue : [];
736
+ const mergeQueue = Array.isArray(strategy.mergeQueue) ? strategy.mergeQueue : [];
737
+ const resolutions = strategy.resolutions instanceof Map ? strategy.resolutions : new Map<string, number>();
738
+ const summaryById = new Map(summaries.map(summary => [summary.id, summary]));
739
+ const queuedChunks = new Set(compressionQueue);
740
+
741
+ const projectedChunks = chunks.map((chunk) => {
742
+ let maxLevel = 0;
743
+ let current = chunk.summaryId ? summaryById.get(chunk.summaryId) : undefined;
744
+ const seen = new Set<string>();
745
+ while (current && !seen.has(current.id)) {
746
+ seen.add(current.id);
747
+ maxLevel = Math.max(maxLevel, current.level);
748
+ current = current.mergedInto ? summaryById.get(current.mergedInto) : undefined;
749
+ }
750
+
751
+ const selected = (chunk.messages ?? [])
752
+ .map(message => typeof message.id === 'string' ? (resolutions.get(message.id) ?? 0) : 0);
753
+ return {
754
+ index: chunk.index,
755
+ messages: chunk.messages?.length ?? 0,
756
+ tokens: Math.max(0, chunk.tokens ?? 0),
757
+ compressed: chunk.compressed === true,
758
+ summaryId: chunk.summaryId ?? null,
759
+ maxLevel,
760
+ selectedMin: selected.length > 0 ? Math.min(...selected) : 0,
761
+ selectedMax: selected.length > 0 ? Math.max(...selected) : 0,
762
+ queued: queuedChunks.has(chunk.index),
763
+ };
764
+ });
765
+
766
+ const levelNumbers = [...new Set(summaries.map(summary => summary.level))]
767
+ .filter(level => Number.isFinite(level) && level > 0)
768
+ .sort((a, b) => a - b);
769
+ const levels = levelNumbers.map((level) => {
770
+ const atLevel = summaries.filter(summary => summary.level === level);
771
+ const covered = projectedChunks.filter(chunk => chunk.maxLevel >= level);
772
+ return {
773
+ level,
774
+ summaries: atLevel.length,
775
+ frontier: atLevel.filter(summary => !summary.mergedInto).length,
776
+ tokens: atLevel.reduce((total, summary) => total + Math.max(0, summary.tokens ?? 0), 0),
777
+ coveredChunks: covered.length,
778
+ coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
779
+ coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
780
+ };
781
+ });
782
+ const covered = projectedChunks.filter(chunk => chunk.maxLevel > 0);
783
+ const pending = cm.getPendingWork?.()?.description ?? null;
784
+
785
+ return {
786
+ agent: agentName,
787
+ branch: cm.currentBranch().name,
788
+ generatedAt: new Date().toISOString(),
789
+ supported: Array.isArray(strategy.summaries) && Array.isArray(strategy.chunks),
790
+ totals: {
791
+ chunks: projectedChunks.length,
792
+ compressedChunks: projectedChunks.filter(chunk => chunk.compressed).length,
793
+ coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
794
+ coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
795
+ summaries: summaries.length,
796
+ },
797
+ levels,
798
+ chunks: projectedChunks,
799
+ queue: {
800
+ inFlight: strategy.pendingCompression != null,
801
+ pending,
802
+ l1: [...compressionQueue],
803
+ merges: mergeQueue.map(merge => ({
804
+ targetLevel: merge.level,
805
+ sourceCount: merge.sourceIds.length,
806
+ firstSource: merge.sourceIds[0] ?? null,
807
+ lastSource: merge.sourceIds[merge.sourceIds.length - 1] ?? null,
808
+ })),
809
+ },
810
+ };
811
+ }
812
+
813
+ /** Summary-tree coverage and queued work, with no message or summary text. */
814
+ export function buildContextCoverage(app: PanelAppRef, agentName: string): ContextCoverageSnapshot {
815
+ const agent = requireAgent(app, agentName);
816
+ return buildContextCoverageSnapshot(agentName, agent.getContextManager());
817
+ }
818
+
819
+ /**
820
+ * Context makeup: the segment breakdown of the agent's current compiled
821
+ * context — head window, raw middle, summaries by level (L1/L2/L3), and the
822
+ * recent verbatim tail — from the strategy's RenderStats, plus an exact
823
+ * total token count via the model's count_tokens endpoint. Transparent:
824
+ * previewActivation + count_tokens only; no inference, no Chronicle writes.
825
+ */
826
+ export async function buildContextMakeup(app: PanelAppRef, agentName: string): Promise<Record<string, unknown>> {
827
+ const agent = requireAgent(app, agentName);
828
+ // Populates the strategy's render stats as a side-effect of compiling.
829
+ const request = await app.framework.previewActivation(agentName);
830
+ const cm = (agent as unknown as { getContextManager: () => { getRenderStats: () => unknown } }).getContextManager();
831
+ const stats = cm.getRenderStats();
832
+
833
+ // Build an Anthropic-faithful payload for an exact count_tokens: map
834
+ // participants to roles (the agent's own -> assistant, others -> user
835
+ // with a "Name:" prefix) and merge consecutive same-role runs, mirroring
836
+ // what the NativeFormatter sends.
837
+ const textOf = (c: unknown): string =>
838
+ Array.isArray(c)
839
+ ? c.map((b) => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? (b as { text: string }).text : '')).join('')
840
+ : String(c ?? '');
841
+ const merged: Array<{ role: 'user' | 'assistant'; text: string }> = [];
842
+ for (const m of ((request as { messages?: Array<{ participant?: string; role?: string; content: unknown }> }).messages ?? [])) {
843
+ const who = m.participant ?? m.role ?? 'user';
844
+ const role: 'user' | 'assistant' = who === agentName ? 'assistant' : 'user';
845
+ let t = textOf(m.content);
846
+ if (role === 'user' && who && who !== 'user') t = `${who}: ${t}`;
847
+ const last = merged[merged.length - 1];
848
+ if (last && last.role === role) last.text += '\n' + t;
849
+ else merged.push({ role, text: t });
850
+ }
851
+ const anthMessages = merged.filter((m) => m.text.trim().length > 0).map((m) => ({ role: m.role, content: m.text }));
852
+ const sysRaw = (request as { system?: unknown }).system;
853
+ const systemStr = Array.isArray(sysRaw)
854
+ ? sysRaw.map((b) => (b && typeof b === 'object' ? (b as { text?: string }).text ?? '' : String(b))).join('\n')
855
+ : (typeof sysRaw === 'string' ? sysRaw : undefined);
856
+
857
+ let exactTotalTokens: number | null = null;
858
+ const countModel = process.env.COUNT_TOKENS_MODEL || 'anthropic/claude-opus-4.5';
859
+ let countSource = 'count_tokens';
860
+ try {
861
+ const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
862
+ const res = await fetch(base + '/v1/messages/count_tokens', {
863
+ method: 'POST',
864
+ headers: {
865
+ // Mirror the main adapter's auth: OAuth Bearer (subscription) when
866
+ // ANTHROPIC_AUTH_TOKEN is set, x-api-key otherwise.
867
+ ...(process.env.ANTHROPIC_AUTH_TOKEN
868
+ ? {
869
+ authorization: `Bearer ${process.env.ANTHROPIC_AUTH_TOKEN}`,
870
+ 'anthropic-beta': 'oauth-2025-04-20',
871
+ }
872
+ : { 'x-api-key': process.env.ANTHROPIC_API_KEY ?? '' }),
873
+ 'anthropic-version': '2023-06-01',
874
+ 'content-type': 'application/json',
875
+ 'user-agent': 'conhost/1.0',
876
+ },
877
+ body: JSON.stringify({ model: countModel, ...(systemStr ? { system: systemStr } : {}), messages: anthMessages }),
878
+ });
879
+ if (res.ok) {
880
+ const j = (await res.json()) as { input_tokens?: number };
881
+ exactTotalTokens = j.input_tokens ?? null;
882
+ } else {
883
+ countSource = `count_tokens_failed_${res.status}`;
884
+ }
885
+ } catch {
886
+ countSource = 'count_tokens_error';
887
+ }
888
+
889
+ return { agent: agentName, stats, exactTotalTokens, countModel, countSource };
890
+ }
891
+
892
+ /**
893
+ * Context curve: compile the agent's window and return one record per
894
+ * compiled entry with its provenance — kind (raw / L1..Ln summary), rendered
895
+ * token estimate, the raw-history tokens it covers (leaf messages,
896
+ * recursively through the summary tree), date span, and full text.
897
+ *
898
+ * Same side-effect class as previewActivation / makeup: the compile may
899
+ * commit resolution updates, exactly as the agent's own next turn would.
900
+ * No inference, no message writes.
901
+ */
902
+ export async function buildContextCurve(app: PanelAppRef, agentName: string): Promise<Record<string, unknown>> {
903
+ requireAgent(app, agentName);
904
+ const agent = app.framework.getAgent(agentName)!;
905
+ const cm = (agent as unknown as { getContextManager: () => any }).getContextManager();
906
+ // Use the LIVE budget, not the recipe's. Runtime overrides persist in the
907
+ // `framework/state` Chronicle slot and win over the recipe, so reading
908
+ // app.recipe here plotted the wrong curve on any agent whose budget had
909
+ // ever been changed at runtime. Fall back to the recipe only if the live
910
+ // read is unavailable.
911
+ let maxTokens = app.recipe.agent.contextBudgetTokens ?? 200_000;
912
+ try {
913
+ const live = (app.framework as unknown as {
914
+ getAgentRuntimeSettings?: (n: string) => { contextBudgetTokens?: number };
915
+ }).getAgentRuntimeSettings?.(agentName)?.contextBudgetTokens;
916
+ if (typeof live === 'number' && live > 0) maxTokens = live;
917
+ } catch { /* keep the recipe fallback */ }
918
+ const reserveForResponse = app.recipe.agent.maxTokens ?? 16_384;
919
+ const compiled = await cm.compile({ maxTokens, reserveForResponse });
920
+
921
+ // Curve inspection only needs text and source metadata. Resolving every
922
+ // historical blob here re-inlines all base64 media and can expand a
923
+ // few-hundred-MB Chronicle into several GB of JS heap. Use the windowed
924
+ // reader with blob resolution disabled so production diagnostics stay
925
+ // bounded by text history rather than the media archive.
926
+ const messageCount = cm.getMessageCount();
927
+ const messages: Array<{ id: string; timestamp?: unknown; content?: unknown[] }> =
928
+ cm.getMessageWindow(0, messageCount, { resolveBlobs: false }).messages;
929
+ const msgById = new Map(messages.map((mm) => [mm.id, mm]));
930
+ const estimate = (mm: { content?: unknown[] }): number => {
931
+ let t = 0;
932
+ for (const b of (mm.content ?? []) as Array<Record<string, unknown>>) {
933
+ if (b?.type === 'text') t += Math.ceil(String(b.text ?? '').length / 4);
934
+ else if (b?.type === 'image') t += 1600;
935
+ else if (b?.type === 'tool_result') t += Math.ceil(JSON.stringify(b.content ?? '').length / 4);
936
+ else if (b?.type === 'tool_use') t += Math.ceil(JSON.stringify(b.input ?? {}).length / 4);
937
+ else if (b?.type === 'thinking') t += Math.ceil(String(b.thinking ?? '').length / 4);
938
+ }
939
+ return t;
940
+ };
941
+
942
+ type Summary = { id: string; level: number; content: string; sourceLevel: number; sourceIds: string[] };
943
+ const strategy = cm.getStrategy() as { summaries?: Summary[] };
944
+ const sums: Summary[] = strategy.summaries ?? [];
945
+ const sumById = new Map(sums.map((x) => [x.id, x]));
946
+ const headOf = (txt: string): string => txt.replace(/\s+/g, ' ').slice(0, 100);
947
+ const byHead = new Map(sums.map((x) => [headOf(x.content), x]));
948
+ const leaves = (x: Summary, seen = new Set<string>()): string[] => {
949
+ if (seen.has(x.id)) return [];
950
+ seen.add(x.id);
951
+ if (x.sourceLevel === 0) return x.sourceIds;
952
+ const out: string[] = [];
953
+ for (const cid of x.sourceIds) {
954
+ const c = sumById.get(cid);
955
+ if (c) out.push(...leaves(c, seen));
956
+ }
957
+ return out;
958
+ };
959
+
960
+ const entries = [];
961
+ let i = 0;
962
+ for (const e of compiled.messages as Array<{ participant: string; content?: unknown[]; sourceMessageId?: string }>) {
963
+ const blocks = (e.content ?? []) as Array<Record<string, unknown>>;
964
+ const text = blocks.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n');
965
+ const nImages = blocks.filter((b) => b?.type === 'image').length;
966
+ const rendered = Math.ceil(text.length / 4) + nImages * 1600 +
967
+ blocks.filter((b) => b?.type === 'tool_result' || b?.type === 'tool_use')
968
+ .reduce((a, b) => a + Math.ceil(JSON.stringify(b.input ?? b.content ?? '').length / 4), 0);
969
+ const sum = byHead.get(headOf(text));
970
+ if (sum) {
971
+ const leafIds = leaves(sum).filter((id) => msgById.has(id));
972
+ const rawCovered = leafIds.reduce((a, id) => a + estimate(msgById.get(id)!), 0);
973
+ const dates = leafIds.map((id) => msgById.get(id)!.timestamp).filter(Boolean).sort();
974
+ entries.push({
975
+ i: i++, kind: `L${sum.level}`, id: sum.id, participant: e.participant,
976
+ rendered, rawCovered, msgCount: leafIds.length, nImages,
977
+ dateFirst: dates[0] ?? null, dateLast: dates[dates.length - 1] ?? null, text,
978
+ });
979
+ } else {
980
+ const src = e.sourceMessageId ? msgById.get(e.sourceMessageId) : null;
981
+ entries.push({
982
+ i: i++, kind: 'raw', id: e.sourceMessageId ?? null, participant: e.participant,
983
+ rendered, rawCovered: src ? estimate(src) : rendered, msgCount: 1, nImages,
984
+ dateFirst: src?.timestamp ?? null, dateLast: src?.timestamp ?? null, text,
985
+ });
986
+ }
987
+ }
988
+ return {
989
+ agent: agentName,
990
+ generatedAt: new Date().toISOString(),
991
+ branch: cm.currentBranch().name,
992
+ budget: { maxTokens, reserveForResponse },
993
+ totals: {
994
+ entries: entries.length,
995
+ rendered: entries.reduce((a, e) => a + e.rendered, 0),
996
+ rawCovered: entries.reduce((a, e) => a + e.rawCovered, 0),
997
+ },
998
+ entries,
999
+ };
1000
+ }
1001
+
1002
+ /**
1003
+ * Single-flight + cooldown for preview, PER PROCESS.
1004
+ *
1005
+ * A preview is a real compile: ~8s on a large store, and `select()` is
1006
+ * synchronous so it BLOCKS the agent's event loop for that whole time (no
1007
+ * heartbeat, no Discord, no MCPL). Overlapping or rapid-fire previews
1008
+ * therefore don't just queue — they stack agent stalls. Reject instead.
1009
+ * Module-level state on purpose: the guard protects this process's agent,
1010
+ * regardless of which surface (HTTP handler, fleet IPC) asked.
1011
+ */
1012
+ let previewInFlight = false;
1013
+ let previewLastAt = 0;
1014
+ const PREVIEW_COOLDOWN_MS = 3_000;
1015
+
1016
+ /**
1017
+ * Replace the dry run's full rendered entries with a compact display
1018
+ * projection.
1019
+ *
1020
+ * Shipping the entries verbatim cost ~110s of BLOCKED AGENT on Mythos (353
1021
+ * entries, megabytes of content plus any inlined media) against ~8s for the
1022
+ * numbers-only path — and select() builds those entries either way, so the
1023
+ * extra ~100s was pure serialization of data the UI never shows in full: the
1024
+ * pane truncates every body past 600 chars anyway.
1025
+ *
1026
+ * So: keep identity, size and a bounded text preview; drop content blocks and
1027
+ * never inline media.
1028
+ */
1029
+ function projectDryEntries(result: unknown): unknown {
1030
+ const r = result as { entries?: unknown[] } & Record<string, unknown>;
1031
+ if (!Array.isArray(r.entries)) return result;
1032
+ const MAX_TEXT = 1_200;
1033
+ const projected = r.entries.map((e, i) => {
1034
+ const o = (e ?? {}) as { participant?: string; role?: string; content?: unknown };
1035
+ let text = '';
1036
+ let media = 0;
1037
+ const blocks = Array.isArray(o.content) ? o.content : [];
1038
+ for (const b of blocks) {
1039
+ if (!b || typeof b !== 'object') { text += String(b ?? ''); continue; }
1040
+ const t = (b as { type?: string }).type;
1041
+ if (t === 'text') text += (b as { text?: string }).text ?? '';
1042
+ else if (t === 'image') { media++; text += '[image]'; }
1043
+ else if (t === 'thinking' || t === 'redacted_thinking') text += '[thinking]';
1044
+ else if (t === 'tool_use') text += `[tool_use ${(b as { name?: string }).name ?? ''}]`;
1045
+ else if (t === 'tool_result') text += '[tool_result]';
1046
+ }
1047
+ if (typeof o.content === 'string') text = o.content;
1048
+ return {
1049
+ i,
1050
+ who: o.participant ?? o.role ?? '?',
1051
+ chars: text.length,
1052
+ media,
1053
+ truncated: text.length > MAX_TEXT,
1054
+ text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text,
1055
+ };
1056
+ });
1057
+ return { ...r, entries: projected };
1058
+ }
1059
+
1060
+ /**
1061
+ * Preview the fold plan at a HYPOTHETICAL budget / tail, without applying it.
1062
+ * Commits nothing — no fold resolutions persisted, no compression enqueued,
1063
+ * no transition bookkeeping advanced. An infeasible budget is reported as
1064
+ * diagnostics, NOT as an error: learning that a budget can't work is the
1065
+ * reason to preview instead of applying and taking the outage.
1066
+ *
1067
+ * params: { budget: number; tail?: number; render?: boolean }
1068
+ */
1069
+ export function runContextPreview(
1070
+ app: PanelAppRef,
1071
+ agentName: string,
1072
+ params: Record<string, unknown>,
1073
+ ): Record<string, unknown> {
1074
+ requireAgent(app, agentName);
1075
+
1076
+ const budget = Number(params.budget);
1077
+ if (!Number.isSafeInteger(budget) || budget <= 0) {
1078
+ throw new PanelError('budget must be a positive integer', 400);
1079
+ }
1080
+ const overrides: Record<string, unknown> = {};
1081
+ if (params.tail !== undefined) {
1082
+ const tail = Number(params.tail);
1083
+ if (!Number.isSafeInteger(tail) || tail < 0) {
1084
+ throw new PanelError('tail must be a non-negative integer', 400);
1085
+ }
1086
+ // The strategy knob behind "tail" is recentWindowTokens.
1087
+ overrides.recentWindowTokens = tail;
1088
+ }
1089
+ const wantRender = params.render === true || params.render === '1' || params.render === 1;
1090
+
1091
+ if (previewInFlight) {
1092
+ throw new PanelError(
1093
+ 'a preview is already running — it blocks the agent, so they are serialized', 429,
1094
+ );
1095
+ }
1096
+ const sinceLast = Date.now() - previewLastAt;
1097
+ if (sinceLast < PREVIEW_COOLDOWN_MS) {
1098
+ throw new PanelError(
1099
+ `preview cooling down — ${Math.ceil((PREVIEW_COOLDOWN_MS - sinceLast) / 1000)}s left. `
1100
+ + 'Each run is a full compile and briefly pauses the agent.', 429,
1101
+ );
1102
+ }
1103
+
1104
+ const fw = app.framework as unknown as {
1105
+ previewContextSettings?: (
1106
+ n: string, b: number, o?: Record<string, unknown>, x?: { render?: boolean },
1107
+ ) => unknown;
1108
+ };
1109
+ if (typeof fw.previewContextSettings !== 'function') {
1110
+ throw new PanelError(
1111
+ 'preview unsupported: this agent-framework build has no previewContextSettings', 501,
1112
+ );
1113
+ }
1114
+ previewInFlight = true;
1115
+ const startedAt = Date.now();
1116
+ try {
1117
+ const result = fw.previewContextSettings(
1118
+ agentName,
1119
+ budget,
1120
+ Object.keys(overrides).length > 0 ? overrides : undefined,
1121
+ wantRender ? { render: true } : undefined,
1122
+ );
1123
+ if (result === null || result === undefined) {
1124
+ throw new PanelError(
1125
+ 'preview unavailable: the resolved context-manager has no dry-run support, '
1126
+ + 'or the active strategy has no fold plan (non-adaptive)', 501,
1127
+ );
1128
+ }
1129
+ // Honest budget accounting. context-manager's `budgetTokens` is the
1130
+ // REJECTION budget: (requested - reserve) * (1 + overBudgetGraceRatio),
1131
+ // i.e. the threshold above which a compile throws. Its `fits` therefore
1132
+ // means "would not hard-fail", NOT "fits the budget you asked for".
1133
+ const r = result as { finalTokens?: number; budgetTokens?: number; exhausted?: boolean };
1134
+ const reserve = app.recipe.agent.maxTokens ?? 16_384;
1135
+ const effectiveBudget = Math.max(0, budget - reserve);
1136
+ const finalTokens = typeof r.finalTokens === 'number' ? r.finalTokens : NaN;
1137
+ const fitsRequested = Number.isFinite(finalTokens) && finalTokens <= effectiveBudget;
1138
+ const withinGrace = Number.isFinite(finalTokens) && typeof r.budgetTokens === 'number'
1139
+ ? finalTokens <= r.budgetTokens
1140
+ : undefined;
1141
+ return {
1142
+ agent: agentName,
1143
+ budget,
1144
+ ...(overrides as object),
1145
+ accounting: {
1146
+ requestedBudgetTokens: budget,
1147
+ reserveForResponseTokens: reserve,
1148
+ /** What the picker actually targets. */
1149
+ effectiveBudgetTokens: effectiveBudget,
1150
+ /** Hard-fail ceiling — requested minus reserve, plus grace. */
1151
+ rejectionBudgetTokens: r.budgetTokens,
1152
+ /** Fits the budget the operator asked for. */
1153
+ fitsRequested,
1154
+ /** Merely tolerated by the grace margin — over budget, but no throw. */
1155
+ withinGrace,
1156
+ /** Exhausted AND over the request => this budget is UNREACHABLE. */
1157
+ unreachable: r.exhausted === true && !fitsRequested,
1158
+ },
1159
+ /** How long the agent was blocked, so the operator sees the real cost. */
1160
+ elapsedMs: Date.now() - startedAt,
1161
+ preview: wantRender ? projectDryEntries(result) : result,
1162
+ };
1163
+ } finally {
1164
+ previewInFlight = false;
1165
+ previewLastAt = Date.now();
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * The membrane-normalized request the framework would hand to the model if
1171
+ * the agent were activated right now. Delegates to `framework.previewActivation`.
1172
+ *
1173
+ * Transparent by default: no inference, no Chronicle writes, no external
1174
+ * MCPL calls. Pass `injections: true` to gather dynamic injections
1175
+ * (lessons/retrieval/MCPL context) for full fidelity, which is NOT
1176
+ * transparent: it can run inference and fire MCPL `beforeInference` hooks.
1177
+ */
1178
+ export async function buildDebugContext(
1179
+ app: PanelAppRef,
1180
+ agentName: string,
1181
+ params: Record<string, unknown>,
1182
+ ): Promise<Record<string, unknown>> {
1183
+ requireAgent(app, agentName);
1184
+ const injections = params.injections === true;
1185
+ const request = await app.framework.previewActivation(agentName, { injections });
1186
+ return { agent: agentName, injections, transparent: !injections, request };
1187
+ }