@animalabs/connectome-host 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.0 — 2026-07-26
6
+
7
+ ### Added
8
+
9
+ - **Health panel: recent LLM call stats, split main vs compression.** Aggregates
10
+ the call ledger the client already receives — fresh input, cache read/write,
11
+ **cached share** (cacheRead ÷ input+cacheRead: what fraction of the prompt was
12
+ reused rather than re-read), prefix-reuse rate, output, average cache
13
+ breakpoints, cost, and errors/refusals — separately for `turn~` (main) and
14
+ `aux~` (compression/summarizer). Includes the last 8 fresh-input values per
15
+ group, so a budget descent can be seen trending down.
16
+ - The `~` is honest: origin is inferred from stream-vs-complete (turns stream,
17
+ compression uses `complete()`), not a definitive tag. Stated in the panel.
18
+ - **Health panel: context composition of the last compile** — head / raw middle /
19
+ summaries by level / tail, with shares and bars. Sourced from `/healthz`, which
20
+ now carries the strategy's in-process render stats: unlike
21
+ `/debug/context/makeup` this costs nothing and makes no `count_tokens` network
22
+ call, so it is safe on the 15s health poll.
23
+
24
+ ## Unreleased
25
+
5
26
  ## 0.6.1 — 2026-07-26
6
27
 
7
28
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1118,6 +1118,28 @@ export class WebUiModule implements Module {
1118
1118
  } catch {
1119
1119
  // Health reads never throw.
1120
1120
  }
1121
+ // Rendered context COMPOSITION per agent — head / raw middle / summaries
1122
+ // by level / tail, as actually emitted by the last compile.
1123
+ //
1124
+ // Sourced from the strategy's own render stats, which are already
1125
+ // computed in-process: unlike /debug/context/makeup this costs nothing
1126
+ // and makes no count_tokens network call, so it is safe on the 15s
1127
+ // /healthz poll. Answers "how much of what was actually sent" without
1128
+ // recompiling.
1129
+ try {
1130
+ const composition: Record<string, unknown> = {};
1131
+ for (const agent of app.framework.getAllAgents()) {
1132
+ const name = (agent as unknown as { name: string }).name;
1133
+ const cm = agent.getContextManager() as unknown as {
1134
+ getRenderStats?: () => unknown;
1135
+ };
1136
+ const rs = cm.getRenderStats?.();
1137
+ if (rs) composition[name] = rs;
1138
+ }
1139
+ (snapshot as Record<string, unknown>).contextComposition = composition;
1140
+ } catch {
1141
+ // Health reads never throw.
1142
+ }
1121
1143
  // Per-agent runtime settings (context budget, tail, transition pace +
1122
1144
  // convergence state) — the same numbers `agent_settings get` returns,
1123
1145
  // exposed externally so the fleet hub / connectome-doctor can watch
package/web/src/App.tsx CHANGED
@@ -1304,6 +1304,7 @@ export function App() {
1304
1304
  <HealthPanel
1305
1305
  health={health()}
1306
1306
  error={healthErr()}
1307
+ ledger={callLedger()?.rows}
1307
1308
  onRefresh={() => void loadHealth(true)}
1308
1309
  />
1309
1310
  </Show>
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { For, Show } from 'solid-js';
20
+ import type { CallLedgerRow } from '@conhost/web/protocol';
20
21
 
21
22
  /** One active operator alert, keyed `${agent}:${kind}`. `count` increments on
22
23
  * every re-fire of the same key so a repeating klaxon reads as one row. */
@@ -33,8 +34,22 @@ export interface OpsAlert {
33
34
  /** Shape of GET /healthz — framework healthSnapshot() plus the host's
34
35
  * compressionQuarantine / runtimeSettings extensions. All fields optional
35
36
  * and defensively read: health rendering must survive version skew. */
37
+ /** Rendered composition of the last compile — what was actually SENT. */
38
+ export interface ContextComposition {
39
+ head?: { messages: number; tokens: number };
40
+ tail?: { messages: number; tokens: number };
41
+ middleRaw?: { messages: number; tokens: number };
42
+ summaries?: Record<string, { count: number; tokens: number }>;
43
+ total?: { messages: number; tokens: number };
44
+ }
45
+
46
+ /** One provider call. Aliased to the wire type so the two cannot drift; only
47
+ * the fields this panel aggregates are read. */
48
+ export type LedgerRow = CallLedgerRow;
49
+
36
50
  export interface HealthSnapshot {
37
51
  at?: string;
52
+ contextComposition?: Record<string, ContextComposition>;
38
53
  uptimeSec?: number;
39
54
  gate?: Record<string, unknown> | null;
40
55
  pendingRequests?: number;
@@ -126,8 +141,185 @@ export function OpsAlertStrip(props: {
126
141
  );
127
142
  }
128
143
 
144
+
145
+ /**
146
+ * Aggregate the call ledger by origin.
147
+ *
148
+ * `originEstimate` is the main-vs-compression split, and the `~` is honest: it
149
+ * is derived from stream-vs-complete (agent turns stream; compression and
150
+ * summarizer calls use complete()), NOT from a definitive origin tag. It is a
151
+ * reliable proxy in practice, but it is an inference.
152
+ */
153
+ interface CallAgg {
154
+ calls: number;
155
+ input: number;
156
+ output: number;
157
+ cacheRead: number;
158
+ cacheWrite: number;
159
+ cost: number;
160
+ hits: number;
161
+ errors: number;
162
+ refusals: number;
163
+ breakpoints: number;
164
+ lastInputs: number[];
165
+ }
166
+
167
+ const EMPTY_AGG = (): CallAgg => ({
168
+ calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
169
+ cost: 0, hits: 0, errors: 0, refusals: 0, breakpoints: 0, lastInputs: [],
170
+ });
171
+
172
+ /** Verdicts that mean the prefix was actually reused. */
173
+ const HIT_VERDICTS = new Set(['HIT', 'hit+extend']);
174
+
175
+ function aggregate(rows: LedgerRow[], origin: 'turn~' | 'aux~'): CallAgg {
176
+ const a = EMPTY_AGG();
177
+ for (const r of rows) {
178
+ if (r.originEstimate !== origin) continue;
179
+ a.calls++;
180
+ a.input += r.tokens.input;
181
+ a.output += r.tokens.output;
182
+ a.cacheRead += r.tokens.cacheRead;
183
+ a.cacheWrite += r.tokens.cacheWrite;
184
+ a.cost += r.cost?.total ?? 0;
185
+ if (HIT_VERDICTS.has(r.verdict)) a.hits++;
186
+ if (r.error) a.errors++;
187
+ if (r.stopReason === 'refusal') a.refusals++;
188
+ a.breakpoints += r.cache.breakpoints ?? 0;
189
+ a.lastInputs.push(r.tokens.input);
190
+ }
191
+ a.lastInputs = a.lastInputs.slice(-8);
192
+ return a;
193
+ }
194
+
195
+ const n0 = (v: number) => v.toLocaleString();
196
+ const pct = (num: number, den: number) => (den > 0 ? `${Math.round((100 * num) / den)}%` : '—');
197
+
198
+ /** Cached share of what was sent — the number that tells you whether the prefix
199
+ * is being reused or re-read. */
200
+ const cachedShare = (a: CallAgg) => {
201
+ const sent = a.input + a.cacheRead;
202
+ return sent > 0 ? `${Math.round((100 * a.cacheRead) / sent)}%` : '—';
203
+ };
204
+
205
+ function CallStats(props: { rows: LedgerRow[] }) {
206
+ const main = () => aggregate(props.rows, 'turn~');
207
+ const aux = () => aggregate(props.rows, 'aux~');
208
+
209
+ const col = (label: string, a: () => CallAgg, tone: string) => (
210
+ <div class="flex-1 min-w-[9rem]">
211
+ <div class={`text-[10px] uppercase tracking-wider font-semibold mb-1 ${tone}`}>
212
+ {label} <span class="text-neutral-600">({a().calls})</span>
213
+ </div>
214
+ <table class="w-full font-mono text-[10px]">
215
+ <tbody>
216
+ <For each={[
217
+ ['fresh input', n0(a().input)],
218
+ ['cache read', n0(a().cacheRead)],
219
+ ['cache write', n0(a().cacheWrite)],
220
+ ['cached share', cachedShare(a())],
221
+ ['prefix reused', pct(a().hits, a().calls)],
222
+ ['output', n0(a().output)],
223
+ ['avg breakpoints', a().calls ? (a().breakpoints / a().calls).toFixed(1) : '—'],
224
+ ['cost', a().cost ? `$${a().cost.toFixed(4)}` : '—'],
225
+ ] as Array<[string, string]>}>
226
+ {([k, v]) => (
227
+ <tr>
228
+ <td class="text-neutral-500 pr-2">{k}</td>
229
+ <td class="text-neutral-200 text-right tabular-nums">{v}</td>
230
+ </tr>
231
+ )}
232
+ </For>
233
+ <Show when={a().errors > 0 || a().refusals > 0}>
234
+ <tr>
235
+ <td class="text-neutral-500 pr-2">errors / refusals</td>
236
+ <td class="text-right tabular-nums text-red-300">
237
+ {a().errors} / {a().refusals}
238
+ </td>
239
+ </tr>
240
+ </Show>
241
+ </tbody>
242
+ </table>
243
+ <Show when={a().lastInputs.length > 1}>
244
+ <div class="text-[9px] text-neutral-600 mt-1 font-mono break-all"
245
+ title="fresh input tokens, oldest → newest — a descent should trend down">
246
+ {a().lastInputs.map(n0).join(' → ')}
247
+ </div>
248
+ </Show>
249
+ </div>
250
+ );
251
+
252
+ return (
253
+ <div>
254
+ <div class="text-[10px] uppercase tracking-wider text-neutral-600 mb-1">
255
+ recent llm calls ({props.rows.length})
256
+ </div>
257
+ <div class="flex gap-4 flex-wrap">
258
+ {col('main turns', main, 'text-cyan-400')}
259
+ {col('compression', aux, 'text-orange-400')}
260
+ </div>
261
+ <div class="text-[9px] text-neutral-600 mt-1 leading-relaxed">
262
+ Origin is inferred from stream-vs-complete (turns stream; compression uses
263
+ complete) — a reliable proxy, not a definitive tag, hence <span class="font-mono">~</span>.
264
+ “cached share” is cacheRead ÷ (input+cacheRead) — what fraction of the prompt
265
+ was reused rather than re-read.
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function CompositionBlock(props: { c: ContextComposition }) {
272
+ const rows = () => {
273
+ const c = props.c;
274
+ const out: Array<[string, number]> = [
275
+ ['head (verbatim)', c.head?.tokens ?? 0],
276
+ ['middle raw', c.middleRaw?.tokens ?? 0],
277
+ ];
278
+ for (const [lvl, v] of Object.entries(c.summaries ?? {})) {
279
+ out.push([`summaries ${lvl.toUpperCase()}`, v?.tokens ?? 0]);
280
+ }
281
+ out.push(['tail (verbatim)', c.tail?.tokens ?? 0]);
282
+ return out;
283
+ };
284
+ const total = () => props.c.total?.tokens ?? rows().reduce((s, [, v]) => s + v, 0);
285
+
286
+ return (
287
+ <div>
288
+ <div class="text-[10px] uppercase tracking-wider text-neutral-600 mb-1">
289
+ context composition (last compile)
290
+ </div>
291
+ <table class="w-full font-mono text-[10px]">
292
+ <tbody>
293
+ <For each={rows()}>
294
+ {([k, v]) => (
295
+ <tr>
296
+ <td class="text-neutral-500 pr-2">{k}</td>
297
+ <td class="text-neutral-200 text-right tabular-nums">{n0(v)}</td>
298
+ <td class="text-neutral-600 text-right pl-2 w-10">
299
+ {total() > 0 ? `${Math.round((100 * v) / total())}%` : ''}
300
+ </td>
301
+ <td class="pl-2 w-1/3">
302
+ <span class="inline-block h-1.5 bg-cyan-800 rounded"
303
+ style={{ width: `${total() > 0 ? Math.round((100 * v) / total()) : 0}%` }} />
304
+ </td>
305
+ </tr>
306
+ )}
307
+ </For>
308
+ <tr class="border-t border-neutral-800">
309
+ <td class="text-neutral-400 pr-2">total rendered</td>
310
+ <td class="text-neutral-100 text-right tabular-nums">{n0(total())}</td>
311
+ <td colSpan={2} />
312
+ </tr>
313
+ </tbody>
314
+ </table>
315
+ </div>
316
+ );
317
+ }
318
+
129
319
  export function HealthPanel(props: {
130
320
  health: HealthSnapshot | null;
321
+ /** Recent provider calls. Already on the client via the call-ledger frame. */
322
+ ledger?: LedgerRow[];
131
323
  /** Non-null when the last /healthz fetch failed; '403' means scope-denied. */
132
324
  error: string | null;
133
325
  onRefresh(): void;
@@ -135,6 +327,7 @@ export function HealthPanel(props: {
135
327
  const agents = () => props.health?.agents ?? [];
136
328
  const quarantine = (name: string) => props.health?.compressionQuarantine?.[name];
137
329
  const settings = (name: string) => props.health?.runtimeSettings?.[name];
330
+ const composition = (name: string) => props.health?.contextComposition?.[name];
138
331
 
139
332
  const statusTone = (status?: string): string => {
140
333
  switch (status) {
@@ -262,6 +455,23 @@ export function HealthPanel(props: {
262
455
  </div>
263
456
  )}
264
457
  </Show>
458
+
459
+ {/* What was actually SENT, and how it split. Composition is
460
+ in-process render stats (free); call stats come from the ledger
461
+ the client already holds. */}
462
+ <Show when={composition(a.name)}>
463
+ {(c) => (
464
+ <div class="border-t border-neutral-900 pt-1.5">
465
+ <CompositionBlock c={c()} />
466
+ </div>
467
+ )}
468
+ </Show>
469
+
470
+ <Show when={(props.ledger?.length ?? 0) > 0}>
471
+ <div class="border-t border-neutral-900 pt-1.5">
472
+ <CallStats rows={props.ledger!} />
473
+ </div>
474
+ </Show>
265
475
  </section>
266
476
  )}</For>
267
477
  </Show>