@animalabs/connectome-host 0.3.5 → 0.3.8

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 (51) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/ci.yml +52 -0
  3. package/.github/workflows/publish.yml +2 -2
  4. package/README.md +5 -0
  5. package/bun.lock +14 -6
  6. package/docs/AGENT-ONBOARDING.md +6 -1
  7. package/package.json +6 -4
  8. package/scripts/import-codex-rollout.ts +288 -0
  9. package/src/call-ledger.ts +371 -0
  10. package/src/call-pricing.ts +119 -0
  11. package/src/framework-agent-config.ts +51 -0
  12. package/src/framework-strategy.ts +70 -0
  13. package/src/index.ts +130 -105
  14. package/src/logging-adapter.ts +105 -12
  15. package/src/mcpl-config.ts +1 -0
  16. package/src/modules/channel-mode-module.ts +14 -16
  17. package/src/modules/fleet-module.ts +17 -7
  18. package/src/modules/mcpl-admin-module.ts +6 -15
  19. package/src/modules/settings-module.ts +83 -59
  20. package/src/modules/subscription-gc-module.ts +17 -20
  21. package/src/modules/time-module.ts +15 -9
  22. package/src/modules/web-ui-module.ts +88 -10
  23. package/src/modules/web-ui-observers.ts +35 -9
  24. package/src/recipe.ts +133 -6
  25. package/src/state/agent-tree-reducer.ts +17 -3
  26. package/src/strategies/frontdesk-strategy.ts +15 -9
  27. package/src/web/protocol.ts +89 -0
  28. package/test/agent-tree-reducer.test.ts +35 -3
  29. package/test/autobio-progress-snapshot.test.ts +27 -12
  30. package/test/call-ledger.test.ts +91 -0
  31. package/test/call-pricing.test.ts +69 -0
  32. package/test/framework-fkm-composition.test.ts +160 -0
  33. package/test/frontdesk-strategy.test.ts +10 -0
  34. package/test/import-codex-rollout.test.ts +36 -0
  35. package/test/logging-adapter-refusal.test.ts +57 -0
  36. package/test/logging-adapter.test.ts +58 -1
  37. package/test/recipe-cache-ttl.test.ts +7 -3
  38. package/test/recipe-compression-fallback.test.ts +36 -0
  39. package/test/recipe-primary-summary-fallback.test.ts +40 -0
  40. package/test/recipe-provider.test.ts +36 -0
  41. package/test/recipe-think-policy.test.ts +39 -0
  42. package/test/recipe-timezone.test.ts +20 -0
  43. package/test/subscription-gc-module.test.ts +7 -5
  44. package/test/time-module.test.ts +13 -0
  45. package/test/web-ui-observers.test.ts +70 -2
  46. package/web/package-lock.json +425 -302
  47. package/web/src/App.tsx +9 -0
  48. package/web/src/ObserverGate.tsx +21 -11
  49. package/web/src/Usage.tsx +116 -2
  50. package/web/src/wire.ts +5 -2
  51. package/web/bun.lock +0 -357
package/web/src/App.tsx CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type MessageBlock,
23
23
  type TokenUsage,
24
24
  type PerAgentCost,
25
+ type CallLedgerSnapshot,
25
26
  } from '@conhost/web/protocol';
26
27
 
27
28
  /** Client-side block: the wire MessageBlock plus live-stream bookkeeping. */
@@ -116,6 +117,7 @@ export function App() {
116
117
  let pendingHistoryTimer: number | undefined;
117
118
  const [usage, setUsage] = createSignal<TokenUsage>({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
118
119
  const [perAgentCost, setPerAgentCost] = createSignal<PerAgentCost[]>([]);
120
+ const [callLedger, setCallLedger] = createSignal<CallLedgerSnapshot | null>(null);
119
121
  const [draft, setDraft] = createSignal('');
120
122
  /** Currently-focused tree node + the panel mode rendered on its behalf.
121
123
  * `mode` decides whether the side panel shows live stream events or a
@@ -705,6 +707,7 @@ export function App() {
705
707
  appendMessage: (m) => setMessages(produce(arr => arr.push(m))),
706
708
  setUsage,
707
709
  setPerAgentCost,
710
+ setCallLedger,
708
711
  appendStreamToken,
709
712
  appendToolUseBlocks,
710
713
  updateToolStatus,
@@ -967,6 +970,7 @@ export function App() {
967
970
  node={focusedNode()!}
968
971
  sessionUsage={usage()}
969
972
  perAgentCost={perAgentCost()}
973
+ callLedger={callLedger()}
970
974
  onClose={closePanel}
971
975
  />
972
976
  )}
@@ -1066,6 +1070,7 @@ interface HandlerHooks {
1066
1070
  appendMessage: (msg: Message) => void;
1067
1071
  setUsage: (u: TokenUsage) => void;
1068
1072
  setPerAgentCost: (c: PerAgentCost[]) => void;
1073
+ setCallLedger: (ledger: CallLedgerSnapshot | null) => void;
1069
1074
  appendStreamToken: (token: string, blockType?: string) => void;
1070
1075
  /** Attach yielded tool calls to the streaming assistant message. */
1071
1076
  appendToolUseBlocks: (calls: Array<{ id: string; name: string; input?: unknown }>) => void;
@@ -1099,6 +1104,7 @@ function handleServerMessage(
1099
1104
  hooks.applyWelcome(msg);
1100
1105
  hooks.setUsage(msg.usage);
1101
1106
  hooks.setPerAgentCost(msg.perAgentCost ?? []);
1107
+ hooks.setCallLedger(msg.callLedger ?? null);
1102
1108
  return;
1103
1109
  }
1104
1110
  case 'message-appended':
@@ -1111,6 +1117,9 @@ function handleServerMessage(
1111
1117
  hooks.setUsage(msg.usage);
1112
1118
  if (msg.perAgentCost) hooks.setPerAgentCost(msg.perAgentCost);
1113
1119
  return;
1120
+ case 'call-ledger':
1121
+ hooks.setCallLedger(msg.ledger);
1122
+ return;
1114
1123
  case 'trace': {
1115
1124
  const e = msg.event;
1116
1125
  switch (e.type) {
@@ -37,17 +37,27 @@ export function ObserverGateScreen(props: { state: 'denied' | 'unavailable' }) {
37
37
  This viewer shows an agent's interior — access is granted per device, by the agent or
38
38
  its operator. Share this device's key fingerprint with them:
39
39
  </p>
40
- <div class="flex items-center gap-2">
41
- <code class="font-mono text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 break-all flex-1">
42
- {fingerprint() ?? 'generating…'}
43
- </code>
44
- <button
45
- class="px-2 py-1.5 text-xs rounded border border-zinc-700 hover:bg-zinc-800"
46
- onClick={copy}
47
- >
48
- copy
49
- </button>
50
- </div>
40
+ <Show
41
+ when={!fingerprint.loading && fingerprint() === null}
42
+ fallback={
43
+ <div class="flex items-center gap-2">
44
+ <code class="font-mono text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 break-all flex-1">
45
+ {fingerprint.loading ? 'generating…' : fingerprint()}
46
+ </code>
47
+ <button
48
+ class="px-2 py-1.5 text-xs rounded border border-zinc-700 hover:bg-zinc-800"
49
+ onClick={copy}
50
+ >
51
+ copy
52
+ </button>
53
+ </div>
54
+ }
55
+ >
56
+ <p class="text-amber-300/90">
57
+ This origin can't generate a device key (WebCrypto needs https or localhost) —
58
+ use password sign-in below, or open the agent's https URL.
59
+ </p>
60
+ </Show>
51
61
  <p class="text-zinc-400">
52
62
  Grant (agent tool or operator edit of <code class="font-mono">data/observers.json</code>):{' '}
53
63
  <code class="font-mono text-xs">
package/web/src/Usage.tsx CHANGED
@@ -14,7 +14,13 @@
14
14
  import { For, Show } from 'solid-js';
15
15
  import type { UiNode } from './tree';
16
16
  import { aggregateTokens } from './tree';
17
- import type { TokenUsage, PerAgentCost } from '@conhost/web/protocol';
17
+ import type {
18
+ TokenUsage,
19
+ PerAgentCost,
20
+ CallLedgerSnapshot,
21
+ CallLedgerRow,
22
+ CallLedgerVerdict,
23
+ } from '@conhost/web/protocol';
18
24
 
19
25
  export function UsagePanel(props: {
20
26
  node: UiNode;
@@ -24,6 +30,10 @@ export function UsagePanel(props: {
24
30
  /** Per-agent cost slice (parent-process agents only). Used to label
25
31
  * per-agent cost in process / per-agent breakdown views. */
26
32
  perAgentCost: PerAgentCost[];
33
+ /** Recent provider calls. The ledger is process-local, so it is shown on
34
+ * the process usage view rather than pretending it can be split across
35
+ * fleet children. */
36
+ callLedger: CallLedgerSnapshot | null;
27
37
  onClose(): void;
28
38
  }) {
29
39
  const costFor = (agentName: string): { total: number; currency: string } | undefined => {
@@ -70,7 +80,7 @@ export function UsagePanel(props: {
70
80
  };
71
81
 
72
82
  return (
73
- <div class="border-l border-neutral-800 w-96 shrink-0 bg-neutral-950 flex flex-col h-full">
83
+ <div class="border-l border-neutral-800 w-[52rem] max-w-[68vw] shrink-0 bg-neutral-950 flex flex-col h-full">
74
84
  <div class="border-b border-neutral-800 px-3 py-2 flex items-center gap-2">
75
85
  <span class="text-[10px] uppercase tracking-wider text-neutral-500 font-semibold">usage</span>
76
86
  <span class="font-mono text-sm text-neutral-200 truncate">{props.node.label}</span>
@@ -99,6 +109,8 @@ export function UsagePanel(props: {
99
109
  </Show>
100
110
 
101
111
  <Show when={props.node.kind === 'process'}>
112
+ <CallLedgerSection ledger={props.callLedger} />
113
+
102
114
  <section class="text-[10px] text-neutral-600 italic leading-snug">
103
115
  Session total comes from the membrane's per-call usage stream.
104
116
  "By node" is the per-agent ledger from the tree reducer; the two
@@ -110,6 +122,108 @@ export function UsagePanel(props: {
110
122
  );
111
123
  }
112
124
 
125
+ function CallLedgerSection(props: { ledger: CallLedgerSnapshot | null }) {
126
+ const rows = (): CallLedgerRow[] => [...(props.ledger?.rows ?? [])].slice(-30).reverse();
127
+ return (
128
+ <section>
129
+ <div class="flex items-baseline gap-2 mb-1">
130
+ <div class="text-neutral-500 uppercase tracking-wider text-[10px]">recent calls · cache ledger</div>
131
+ <Show when={props.ledger}>
132
+ {(ledger) => (
133
+ <span class="ml-auto text-[10px] text-neutral-500">
134
+ ${fmtCost(ledger().summary.cost?.total ?? 0)} retained · {Math.round(ledger().summary.cacheHitRatio * 100)}% cached
135
+ <Show when={(ledger().summary.cost?.unpricedCalls ?? 0) > 0}>
136
+ {' '}· <span class="text-amber-400">{ledger().summary.cost!.unpricedCalls} unpriced</span>
137
+ </Show>
138
+ </span>
139
+ )}
140
+ </Show>
141
+ </div>
142
+
143
+ <Show
144
+ when={rows().length > 0}
145
+ fallback={<div class="border border-neutral-800 rounded px-2 py-2 text-neutral-600">No provider calls recorded yet.</div>}
146
+ >
147
+ <div class="border border-neutral-800 rounded overflow-hidden">
148
+ <div class="grid grid-cols-[4.7rem_3.5rem_3rem_4rem_4rem_4rem_4rem_4.7rem_minmax(8rem,1fr)] gap-x-2 px-2 py-1 bg-neutral-900 text-[9px] uppercase tracking-wider text-neutral-600">
149
+ <span>time</span><span>origin</span><span>msgs</span><span class="text-right">in</span>
150
+ <span class="text-right">read</span><span class="text-right">write</span><span class="text-right">out</span>
151
+ <span class="text-right">cost</span><span>verdict / cause</span>
152
+ </div>
153
+ <div class="max-h-80 overflow-y-auto divide-y divide-neutral-900">
154
+ <For each={rows()}>{(row) => <CallLedgerRowView row={row} />}</For>
155
+ </div>
156
+ </div>
157
+ <div class="mt-1 text-[9px] text-neutral-600">
158
+ Costs marked in USD use provider-reported token buckets and the versioned public rate card. Calls missing an exact cache-write split or known rate stay explicitly unpriced. origin~ remains an estimated call class.
159
+ </div>
160
+ </Show>
161
+ </section>
162
+ );
163
+ }
164
+
165
+ function CallLedgerRowView(props: { row: CallLedgerRow }) {
166
+ const time = (): string => {
167
+ const d = new Date(props.row.timestamp);
168
+ return Number.isNaN(d.getTime()) ? '?' : d.toLocaleTimeString([], { hour12: false });
169
+ };
170
+ const flags = (): string => {
171
+ const bp = props.row.cache.breakpoints;
172
+ if (bp === undefined) return 'flags unknown';
173
+ if (bp === 0) return 'no cache flags';
174
+ return `${bp}bp:${props.row.cache.effectiveTtl}`;
175
+ };
176
+ const costTitle = (): string => {
177
+ const c = props.row.cost;
178
+ if (!c) return 'Unpriced: an authoritative usage bucket or public rate was unavailable';
179
+ return [
180
+ `billing-grade ${c.currency} · ${c.pricingVersion}`,
181
+ `input $${c.input.toFixed(6)}`,
182
+ `read $${c.cacheRead.toFixed(6)}`,
183
+ `write 5m $${c.cacheWrite5m.toFixed(6)}`,
184
+ `write 1h $${c.cacheWrite1h.toFixed(6)}`,
185
+ `output $${c.output.toFixed(6)}`,
186
+ ].join(' · ');
187
+ };
188
+ return (
189
+ <div class="grid grid-cols-[4.7rem_3.5rem_3rem_4rem_4rem_4rem_4rem_4.7rem_minmax(8rem,1fr)] gap-x-2 px-2 py-1.5 items-start hover:bg-neutral-900/60">
190
+ <span class="text-neutral-400" title={`${props.row.timestamp} · ${Math.round(props.row.durationMs / 1000)}s`}>{time()}</span>
191
+ <span class="text-neutral-500">{props.row.originEstimate}</span>
192
+ <span class="text-neutral-400">{props.row.messages}</span>
193
+ <span class="text-right text-neutral-400">{fmt(props.row.tokens.input)}</span>
194
+ <span class="text-right text-sky-300">{fmt(props.row.tokens.cacheRead)}</span>
195
+ <span class="text-right text-amber-300">{fmt(props.row.tokens.cacheWrite)}</span>
196
+ <span class="text-right text-neutral-400">{fmt(props.row.tokens.output)}</span>
197
+ <span
198
+ class={`text-right ${props.row.cost ? 'text-emerald-300' : 'text-amber-500'}`}
199
+ title={costTitle()}
200
+ >
201
+ {props.row.cost ? `$${fmtCost(props.row.cost.total)}` : 'unpriced'}
202
+ </span>
203
+ <span class="min-w-0">
204
+ <span class={`font-semibold ${verdictColor(props.row.verdict)}`}>{props.row.verdict}</span>
205
+ <span class="ml-1 text-[9px] text-neutral-600">{flags()}</span>
206
+ <span class="block text-[9px] leading-snug text-neutral-500 break-words">{props.row.cause}</span>
207
+ </span>
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function verdictColor(verdict: CallLedgerVerdict): string {
213
+ switch (verdict) {
214
+ case 'HIT': return 'text-emerald-300';
215
+ case 'hit+extend': return 'text-lime-300';
216
+ case 'rewrite:expired': return 'text-orange-300';
217
+ case 'rewrite:prefix-mutated':
218
+ case 'rewrite:prefix-truncated': return 'text-red-300';
219
+ case 'rewrite:unexplained': return 'text-violet-300';
220
+ case 'ERROR': return 'text-red-400';
221
+ case 'first-write': return 'text-slate-300';
222
+ case 'uncached': return 'text-stone-400';
223
+ default: return 'text-neutral-400';
224
+ }
225
+ }
226
+
113
227
  function TotalsBlock(props: { totals: { input: number; output: number; cacheRead: number; cacheWrite: number; cost?: { total: number; currency: string } } }) {
114
228
  const t = props.totals;
115
229
  return (
package/web/src/wire.ts CHANGED
@@ -128,8 +128,11 @@ export function createWireClient(opts: WireOptions = {}): WireClient {
128
128
  }
129
129
  if (ev.code === 4401) {
130
130
  // Observer auth rejected — reconnect loops would spam the server;
131
- // hold in 'denied' until the user acts (grant lands / password).
132
- setObserverState('denied');
131
+ // hold until the user acts (grant lands / password). The server also
132
+ // closes never-authenticated connections with 4401 on timeout, so
133
+ // don't overwrite 'unavailable' (no WebCrypto — a hello was never
134
+ // possible) with 'denied' (a key exists but holds no grant).
135
+ if (observerState() !== 'unavailable') setObserverState('denied');
133
136
  setStatus('closed');
134
137
  return;
135
138
  }