@animalabs/connectome-host 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.2 — 2026-07-26
6
+
7
+ ### Fixed
8
+
9
+ - **Settings preview reported unreachable budgets as fitting.** context-manager's
10
+ `PreviewResult.budgetTokens` is the *rejection* budget —
11
+ `(requested - reserve) * (1 + overBudgetGraceRatio)` — and its `fits` means
12
+ "would not throw `OverBudgetError`", not "fits the budget you asked for". On
13
+ Mythos (`overBudgetGraceRatio: 0.35`) those differ by a third: previewing
14
+ 250k reported `fits: true` at 273,828 tokens, a budget the picker had in fact
15
+ exhausted trying to reach. The endpoint now returns an `accounting` block
16
+ separating `fitsRequested` / `withinGrace` / `unreachable`, and the panel
17
+ renders three distinct verdicts (fits / over-requested-but-graced /
18
+ would-hard-fail) plus the full budget derivation.
19
+
20
+ ## 0.5.1
21
+
22
+ ### Added
23
+
24
+ - **llm-calls logging for every provider**: `LoggingProviderAdapter`, a
25
+ provider-agnostic decorator over any `ProviderAdapter`, wraps the
26
+ openai-codex, openrouter, and openai-responses transports — which
27
+ previously had NO wire visibility (found post-deploy on Mica: zero
28
+ llm-calls files, requests undiagnosable). Full raw request + response
29
+ summary + usage + timing + error per call, size-guarded against
30
+ pathological payloads. Anthropic/Bedrock keep their purpose-built
31
+ logging classes.
32
+
5
33
  ## 0.5.0 — 2026-07-26
6
34
 
7
35
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/connectome-host",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "General-purpose agent TUI host with recipe-based configuration",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  OpenRouterAdapter,
26
26
  } from '@animalabs/membrane';
27
27
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
28
+ import { LoggingProviderAdapter } from './logging-provider-wrapper.js';
28
29
  import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
29
30
  import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
30
31
  import { CallLedger } from './call-ledger.js';
@@ -835,11 +836,22 @@ async function main() {
835
836
  ? new LoggingBedrockAdapter({}, llmLogPath)
836
837
  : undefined;
837
838
  const adapter = provider === 'openai-responses'
838
- ? new OpenAIResponsesAPIAdapter({
839
- apiKey: config.openaiApiKey!,
840
- baseURL: process.env.OPENAI_BASE_URL || undefined,
841
- })
842
- : bedrockAdapter ?? openrouterAdapter ?? codexAdapter ?? new LoggingAnthropicAdapter(
839
+ ? new LoggingProviderAdapter(
840
+ new OpenAIResponsesAPIAdapter({
841
+ apiKey: config.openaiApiKey!,
842
+ baseURL: process.env.OPENAI_BASE_URL || undefined,
843
+ }),
844
+ llmLogPath,
845
+ )
846
+ // Codex/openrouter/openai adapters get the provider-agnostic logging
847
+ // decorator (llm-calls.jsonl); anthropic and bedrock keep their
848
+ // purpose-built logging classes below. The bare `codexAdapter` /
849
+ // `openrouterAdapter` instances stay un-wrapped for auth commands and
850
+ // dispose() — only the membrane sees the wrapper.
851
+ : bedrockAdapter
852
+ ?? (openrouterAdapter ? new LoggingProviderAdapter(openrouterAdapter, llmLogPath) : undefined)
853
+ ?? (codexAdapter ? new LoggingProviderAdapter(codexAdapter, llmLogPath) : undefined)
854
+ ?? new LoggingAnthropicAdapter(
843
855
  {
844
856
  // Anthropic OAuth wins over API-key auth when both are present.
845
857
  // Subscription tokens additionally require this beta header.
@@ -0,0 +1,165 @@
1
+ // Provider-agnostic ProviderAdapter decorator that appends each LLM call's
2
+ // request, response, usage, timing, and any error to the same
3
+ // `llm-calls.<iso>.jsonl` file the Anthropic and Bedrock paths use.
4
+ //
5
+ // Exists because llm-call logging grew adapter-by-adapter (Anthropic subclass,
6
+ // Bedrock wrapper) and the remaining providers — openai-codex (Mica),
7
+ // openrouter (K3) — had NO wire visibility at all: a post-deploy "did her
8
+ // requests actually work?" check on Mica (2026-07-26) found zero llm-calls
9
+ // files because the codex path never logged. This decorator wraps ANY
10
+ // ProviderAdapter, so a new provider gets logging by construction rather than
11
+ // by remembering to build a logging twin.
12
+ //
13
+ // Contents: full raw request and a summarized-but-complete response record
14
+ // (stop reason, usage, per-block shape, full text) on every call, plus the
15
+ // raw provider response. Retention/rotation/S3 shipping is the host box's
16
+ // llm-logs sync job's problem — this file just writes lines.
17
+ //
18
+ // OOM note (learned on the Anthropic path, which once logged raw+normalized+
19
+ // summary and contributed to production OOMs): we serialize the record ONCE,
20
+ // and a size guard drops the raw bodies for pathological payloads (giant
21
+ // image batches) rather than buffering multi-hundred-MB lines.
22
+ //
23
+ // Decorator safety: adapters whose complete() internally delegates to their
24
+ // own stream() (codex does) self-call the INNER method, not the wrapper —
25
+ // each membrane-level call logs exactly once.
26
+ //
27
+ // Each line: { type: 'call'|'error', provider, kind: 'complete'|'stream',
28
+ // timestamp, durationMs, requestSummary, response?, rawRequest?,
29
+ // rawResponse?, error?, truncated? }
30
+
31
+ import type {
32
+ ProviderAdapter,
33
+ ProviderRequest,
34
+ ProviderResponse,
35
+ ProviderRequestOptions,
36
+ StreamCallbacks,
37
+ } from '@animalabs/membrane';
38
+ import { appendFileSync } from 'node:fs';
39
+
40
+ /** Above this many serialized bytes, drop raw bodies and keep summaries. */
41
+ const MAX_RECORD_BYTES = 16 * 1024 * 1024;
42
+
43
+ function summarizeRequest(request: ProviderRequest): Record<string, unknown> {
44
+ const msgs = (request.messages ?? []) as Array<{ role?: string; content?: unknown }>;
45
+ const last = msgs[msgs.length - 1];
46
+ const lastPreview = typeof last?.content === 'string'
47
+ ? last.content.slice(0, 200)
48
+ : Array.isArray(last?.content)
49
+ ? (last.content as Array<{ type?: string; text?: string }>)
50
+ .map((b) => (b.type === 'text' ? (b.text ?? '').slice(0, 120) : `[${b.type}]`))
51
+ .join(' | ').slice(0, 300)
52
+ : undefined;
53
+ return {
54
+ model: request.model,
55
+ maxTokens: request.maxTokens,
56
+ messageCount: msgs.length,
57
+ systemChars: typeof request.system === 'string'
58
+ ? request.system.length
59
+ : Array.isArray(request.system)
60
+ ? JSON.stringify(request.system).length
61
+ : 0,
62
+ toolNames: (request.tools as Array<{ name?: string }> | undefined)?.map((t) => t.name) ?? null,
63
+ toolCount: (request.tools as unknown[] | undefined)?.length ?? 0,
64
+ lastMessageRole: last?.role,
65
+ lastMessagePreview: lastPreview,
66
+ };
67
+ }
68
+
69
+ function summarizeResponse(response: ProviderResponse): Record<string, unknown> {
70
+ const content = (response.content ?? []) as Array<{ type?: string; text?: string; name?: string }>;
71
+ return {
72
+ stopReason: response.stopReason
73
+ ?? (response.raw as { stop_reason?: string } | undefined)?.stop_reason
74
+ ?? null,
75
+ usage: response.usage ?? null,
76
+ blocks: content.map((b) => ({
77
+ type: b.type,
78
+ ...(b.type === 'text' ? { chars: (b.text ?? '').length, text: b.text } : {}),
79
+ ...(b.type === 'tool_use' ? { name: b.name } : {}),
80
+ })),
81
+ };
82
+ }
83
+
84
+ export class LoggingProviderAdapter implements ProviderAdapter {
85
+ readonly name: string;
86
+
87
+ constructor(
88
+ private readonly inner: ProviderAdapter,
89
+ private readonly logPath: string,
90
+ ) {
91
+ this.name = inner.name;
92
+ }
93
+
94
+ supportsModel(modelId: string): boolean {
95
+ return this.inner.supportsModel(modelId);
96
+ }
97
+
98
+ private log(record: Record<string, unknown>): void {
99
+ try {
100
+ let line = JSON.stringify(record);
101
+ if (line.length > MAX_RECORD_BYTES) {
102
+ const { rawRequest: _rq, rawResponse: _rr, ...rest } = record;
103
+ line = JSON.stringify({ ...rest, truncated: 'raw bodies dropped (record exceeded size guard)' });
104
+ }
105
+ appendFileSync(this.logPath, line + '\n');
106
+ } catch {
107
+ // Logging must never break inference.
108
+ }
109
+ }
110
+
111
+ private record(
112
+ kind: 'complete' | 'stream',
113
+ request: ProviderRequest,
114
+ started: number,
115
+ response?: ProviderResponse,
116
+ error?: unknown,
117
+ ): void {
118
+ this.log({
119
+ type: error === undefined ? 'call' : 'error',
120
+ provider: this.name,
121
+ kind,
122
+ timestamp: new Date(started).toISOString(),
123
+ durationMs: Date.now() - started,
124
+ requestSummary: summarizeRequest(request),
125
+ rawRequest: request,
126
+ ...(response !== undefined
127
+ ? { response: summarizeResponse(response), rawResponse: response.raw ?? null }
128
+ : {}),
129
+ ...(error !== undefined
130
+ ? { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) }
131
+ : {}),
132
+ });
133
+ }
134
+
135
+ async complete(
136
+ request: ProviderRequest,
137
+ options?: ProviderRequestOptions,
138
+ ): Promise<ProviderResponse> {
139
+ const started = Date.now();
140
+ try {
141
+ const response = await this.inner.complete(request, options);
142
+ this.record('complete', request, started, response);
143
+ return response;
144
+ } catch (error) {
145
+ this.record('complete', request, started, undefined, error);
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ async stream(
151
+ request: ProviderRequest,
152
+ callbacks: StreamCallbacks,
153
+ options?: ProviderRequestOptions,
154
+ ): Promise<ProviderResponse> {
155
+ const started = Date.now();
156
+ try {
157
+ const response = await this.inner.stream(request, callbacks, options);
158
+ this.record('stream', request, started, response);
159
+ return response;
160
+ } catch (error) {
161
+ this.record('stream', request, started, undefined, error);
162
+ throw error;
163
+ }
164
+ }
165
+ }
@@ -1249,7 +1249,43 @@ export class WebUiModule implements Module {
1249
1249
  { status: 501 },
1250
1250
  );
1251
1251
  }
1252
- return Response.json({ agent: agentName, budget, ...(overrides as object), preview: result });
1252
+ // Honest budget accounting. context-manager's `budgetTokens` is the
1253
+ // REJECTION budget: (requested - reserve) * (1 + overBudgetGraceRatio),
1254
+ // i.e. the threshold above which a compile throws. Its `fits` therefore
1255
+ // means "would not hard-fail", NOT "fits the budget you asked for".
1256
+ //
1257
+ // On a recipe with overBudgetGraceRatio 0.35 those differ by a third, so
1258
+ // reporting cm's `fits` verbatim told operators that a budget they
1259
+ // cannot actually reach was fine. Split the two questions apart and let
1260
+ // the panel say which one it means.
1261
+ const r = result as { finalTokens?: number; budgetTokens?: number; exhausted?: boolean };
1262
+ const reserve = app.recipe.agent.maxTokens ?? 16_384;
1263
+ const effectiveBudget = Math.max(0, budget - reserve);
1264
+ const finalTokens = typeof r.finalTokens === 'number' ? r.finalTokens : NaN;
1265
+ const fitsRequested = Number.isFinite(finalTokens) && finalTokens <= effectiveBudget;
1266
+ const withinGrace = Number.isFinite(finalTokens) && typeof r.budgetTokens === 'number'
1267
+ ? finalTokens <= r.budgetTokens
1268
+ : undefined;
1269
+ return Response.json({
1270
+ agent: agentName,
1271
+ budget,
1272
+ ...(overrides as object),
1273
+ accounting: {
1274
+ requestedBudgetTokens: budget,
1275
+ reserveForResponseTokens: reserve,
1276
+ /** What the picker actually targets. */
1277
+ effectiveBudgetTokens: effectiveBudget,
1278
+ /** Hard-fail ceiling — requested minus reserve, plus grace. */
1279
+ rejectionBudgetTokens: r.budgetTokens,
1280
+ /** Fits the budget the operator asked for. */
1281
+ fitsRequested,
1282
+ /** Merely tolerated by the grace margin — over budget, but no throw. */
1283
+ withinGrace,
1284
+ /** Exhausted AND over the request => this budget is UNREACHABLE. */
1285
+ unreachable: r.exhausted === true && !fitsRequested,
1286
+ },
1287
+ preview: result,
1288
+ });
1253
1289
  } catch (err) {
1254
1290
  return Response.json(
1255
1291
  { error: err instanceof Error ? err.message : String(err) },
@@ -36,6 +36,24 @@ export interface SettingsState {
36
36
  previewAvailable: boolean;
37
37
  }
38
38
 
39
+ /**
40
+ * Honest budget accounting from the server.
41
+ *
42
+ * `preview.fits` from context-manager means "would not throw OverBudgetError",
43
+ * which on a recipe with a grace ratio is NOT the same as "fits the budget you
44
+ * typed". These three flags keep the questions separate.
45
+ */
46
+ interface PreviewAccounting {
47
+ requestedBudgetTokens: number;
48
+ reserveForResponseTokens: number;
49
+ effectiveBudgetTokens: number;
50
+ rejectionBudgetTokens?: number;
51
+ fitsRequested: boolean;
52
+ withinGrace?: boolean;
53
+ /** Picker exhausted AND still over the request — this budget cannot be reached. */
54
+ unreachable: boolean;
55
+ }
56
+
39
57
  interface PreviewResult {
40
58
  finalTokens: number;
41
59
  budgetTokens: number;
@@ -91,6 +109,7 @@ export function SettingsPanel(props: {
91
109
  const [notify, setNotify] = createSignal(false);
92
110
 
93
111
  const [preview, setPreview] = createSignal<PreviewResult | null>(null);
112
+ const [acct, setAcct] = createSignal<PreviewAccounting | null>(null);
94
113
  const [previewErr, setPreviewErr] = createSignal<string | null>(null);
95
114
  const [previewing, setPreviewing] = createSignal(false);
96
115
 
@@ -133,12 +152,15 @@ export function SettingsPanel(props: {
133
152
  const body = await res.json();
134
153
  if (!res.ok) {
135
154
  setPreview(null);
155
+ setAcct(null);
136
156
  setPreviewErr(body?.error ?? `HTTP ${res.status}`);
137
157
  return;
138
158
  }
139
159
  setPreview(body.preview as PreviewResult);
160
+ setAcct((body.accounting ?? null) as PreviewAccounting | null);
140
161
  } catch (e) {
141
162
  setPreview(null);
163
+ setAcct(null);
142
164
  setPreviewErr(e instanceof Error ? e.message : String(e));
143
165
  } finally {
144
166
  setPreviewing(false);
@@ -357,20 +379,45 @@ export function SettingsPanel(props: {
357
379
  <Show when={preview()}>
358
380
  {(p) => (
359
381
  <div>
382
+ {/* Three distinct verdicts. Collapsing "fits your budget" into
383
+ "wouldn't throw" is how a 35%-grace recipe reports an
384
+ unreachable budget as fine — so they are kept apart. */}
360
385
  <div
361
386
  class={`px-2 py-1 rounded mb-1.5 text-[11px] border ${
362
- p().fits
387
+ acct()?.fitsRequested
363
388
  ? 'bg-emerald-950/40 border-emerald-800 text-emerald-300'
364
- : 'bg-red-950/40 border-red-800 text-red-300'
389
+ : acct()?.withinGrace
390
+ ? 'bg-amber-950/40 border-amber-800 text-amber-200'
391
+ : 'bg-red-950/40 border-red-800 text-red-300'
365
392
  }`}
366
393
  >
367
- {p().fits
368
- ? `fits — ${p().finalTokens.toLocaleString()} of ${p().budgetTokens.toLocaleString()} tok`
369
- : `DOES NOT FIT ${p().finalTokens.toLocaleString()} tok vs ${p().budgetTokens.toLocaleString()} budget`}
370
- <Show when={!p().fits && p().exhausted}>
394
+ <Show when={acct()?.fitsRequested}>
395
+ <div>
396
+ fits — {p().finalTokens.toLocaleString()} of{' '}
397
+ {acct()!.effectiveBudgetTokens.toLocaleString()} usable tok
398
+ </div>
399
+ </Show>
400
+ <Show when={!acct()?.fitsRequested && acct()?.withinGrace}>
401
+ <div>
402
+ OVER REQUESTED BUDGET — {p().finalTokens.toLocaleString()} tok vs{' '}
403
+ {acct()!.effectiveBudgetTokens.toLocaleString()} usable
404
+ </div>
405
+ <div class="text-[10px] opacity-90 mt-0.5">
406
+ it would not hard-fail, but only because the grace margin absorbs the
407
+ overshoot (ceiling {acct()!.rejectionBudgetTokens?.toLocaleString()}).
408
+ The context would run above your target indefinitely.
409
+ </div>
410
+ </Show>
411
+ <Show when={acct() && !acct()!.fitsRequested && acct()!.withinGrace === false}>
412
+ <div>
413
+ WOULD HARD-FAIL — {p().finalTokens.toLocaleString()} tok exceeds even the
414
+ grace ceiling {acct()!.rejectionBudgetTokens?.toLocaleString()}
415
+ </div>
416
+ </Show>
417
+ <Show when={acct()?.unreachable}>
371
418
  <div class="text-[10px] opacity-90 mt-0.5">
372
- picker exhausted: nothing further can be folded. Applying this would
373
- hard-fail the compile.
419
+ <b>unreachable:</b> the picker exhausted nothing further can be folded,
420
+ so this budget cannot be reached no matter how long you wait.
374
421
  </div>
375
422
  </Show>
376
423
  </div>
@@ -378,6 +425,10 @@ export function SettingsPanel(props: {
378
425
  <table class="w-full font-mono text-[10px]">
379
426
  <tbody>
380
427
  <For each={[
428
+ ['requested budget', acct()?.requestedBudgetTokens],
429
+ ['− reserve for response', acct()?.reserveForResponseTokens],
430
+ ['= usable for context', acct()?.effectiveBudgetTokens],
431
+ ['grace ceiling (hard fail above)', acct()?.rejectionBudgetTokens],
381
432
  ['head (verbatim)', p().headTokens],
382
433
  ['tail (verbatim)', p().tailTokens],
383
434
  ['middle (foldable)', p().middleTokens],
@@ -389,7 +440,11 @@ export function SettingsPanel(props: {
389
440
  <tr>
390
441
  <td class="text-neutral-500 pr-2">{k}</td>
391
442
  <td class="text-neutral-300 text-right tabular-nums">
392
- {typeof v === 'number' ? v.toLocaleString() : String(v)}
443
+ {typeof v === 'number'
444
+ ? v.toLocaleString()
445
+ : v === undefined || v === null
446
+ ? '–'
447
+ : String(v)}
393
448
  </td>
394
449
  </tr>
395
450
  )}