@animalabs/connectome-host 0.3.1 → 0.3.7

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 (41) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/publish.yml +9 -4
  3. package/README.md +5 -0
  4. package/bun.lock +8 -4
  5. package/docs/AGENT-ONBOARDING.md +6 -1
  6. package/package.json +5 -5
  7. package/scripts/import-codex-rollout.ts +288 -0
  8. package/src/call-ledger.ts +371 -0
  9. package/src/call-pricing.ts +119 -0
  10. package/src/commands.ts +57 -3
  11. package/src/index.ts +87 -27
  12. package/src/logging-adapter.ts +72 -9
  13. package/src/modules/fleet-module.ts +21 -9
  14. package/src/modules/mcpl-admin-module.ts +6 -0
  15. package/src/modules/observers-module.ts +180 -0
  16. package/src/modules/time-module.ts +15 -9
  17. package/src/modules/web-ui-module.ts +415 -16
  18. package/src/modules/web-ui-observers.ts +322 -0
  19. package/src/recipe.ts +48 -3
  20. package/src/strategies/frontdesk-strategy.ts +10 -4
  21. package/src/web/protocol.ts +141 -0
  22. package/test/call-ledger.test.ts +91 -0
  23. package/test/call-pricing.test.ts +69 -0
  24. package/test/fleet-subscribe-union-e2e.test.ts +5 -0
  25. package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
  26. package/test/frontdesk-strategy.test.ts +10 -0
  27. package/test/import-codex-rollout.test.ts +36 -0
  28. package/test/logging-adapter.test.ts +58 -1
  29. package/test/recipe-cache-ttl.test.ts +7 -3
  30. package/test/recipe-provider.test.ts +36 -0
  31. package/test/recipe-timezone.test.ts +20 -0
  32. package/test/time-module.test.ts +13 -0
  33. package/test/web-ui-context-coverage.test.ts +61 -0
  34. package/test/web-ui-observers.test.ts +344 -0
  35. package/web/package-lock.json +2446 -0
  36. package/web/src/App.tsx +24 -0
  37. package/web/src/Context.tsx +207 -7
  38. package/web/src/ObserverGate.tsx +78 -0
  39. package/web/src/Usage.tsx +116 -2
  40. package/web/src/observer-identity.ts +110 -0
  41. package/web/src/wire.ts +60 -1
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Observer identity for the webui — the M2 layer of connectome
3
+ * docs/observability.md.
4
+ *
5
+ * An observer is an Archipelago layer-0 principal: an ed25519 keypair,
6
+ * human device or agent or service. Authorization is a per-agent grant file
7
+ * (data/observers.json) that is hot-reloaded on mtime (the proven
8
+ * discord-filters pattern: atomic tmp+rename writes, parse errors keep the
9
+ * previous state) and editable by the agent itself through the observers
10
+ * module's tools — interiority access is a consent question, so the agent
11
+ * holds the pen.
12
+ *
13
+ * The handshake is challenge-less: the client signs a statement binding the
14
+ * Host it connected to + a fresh timestamp (replay/relay-proof, no extra
15
+ * round trip):
16
+ *
17
+ * sign( "connectome-observer|v1|<host>|<timestamp>" )
18
+ *
19
+ * The server verifies signature, freshness (±5 min), and that the key has a
20
+ * live grant — then the connection carries that grant's scope mask.
21
+ */
22
+ import { readFileSync, renameSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { createPublicKey, verify as cryptoVerify, randomBytes } from 'node:crypto';
25
+
26
+ /** Scope = event-family mask. See docs/observability.md §4. */
27
+ export const OBSERVER_SCOPES = ['health', 'ops', 'messages', 'tools', 'thinking', 'debug'] as const;
28
+ export type ObserverScope = typeof OBSERVER_SCOPES[number];
29
+
30
+ export interface ObserverGrant {
31
+ /** `ed25519:<base64url of the raw 32-byte public key>` */
32
+ key: string;
33
+ /** Human-readable label. Testimony, not identity — never treat as verified naming. */
34
+ label: string;
35
+ scopes: ObserverScope[];
36
+ /** ISO expiry; null/absent = no expiry. */
37
+ expires?: string | null;
38
+ /** Protected grants cannot be revoked via the agent-facing tools
39
+ * (recipe-designated operator baseline). File edits can still remove them. */
40
+ protected?: boolean;
41
+ }
42
+
43
+ export interface ObserversFile {
44
+ observers: ObserverGrant[];
45
+ }
46
+
47
+ /** The identity envelope a client presents in its observer-hello frame. */
48
+ export interface ObserverHelloIdentity {
49
+ scheme: 'ed25519';
50
+ /** `ed25519:<base64url raw pubkey>` — must match a grant. */
51
+ id: string;
52
+ /** base64url ed25519 signature over the bound statement. */
53
+ proof: string;
54
+ /** ISO timestamp the statement was signed at (freshness-checked). */
55
+ timestamp: string;
56
+ displayName?: string;
57
+ }
58
+
59
+ const FRESHNESS_MS = 5 * 60_000;
60
+
61
+ export function observerStatement(host: string, timestamp: string): string {
62
+ return `connectome-observer|v1|${host}|${timestamp}`;
63
+ }
64
+
65
+ function b64urlToBuf(s: string): Buffer | null {
66
+ try {
67
+ return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** Raw 32-byte ed25519 public key → node KeyObject (SPKI DER wrap). */
74
+ export function ed25519PublicKey(raw: Buffer) {
75
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
76
+ return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // File ops — the discord-mcpl filters.ts pattern, verbatim mechanics.
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export function loadObserversFile(path: string): ObserversFile | null {
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as ObserversFile;
86
+ if (!parsed || !Array.isArray(parsed.observers)) return null;
87
+ for (const g of parsed.observers) {
88
+ if (typeof g.key !== 'string' || !g.key.startsWith('ed25519:')) return null;
89
+ if (!Array.isArray(g.scopes)) return null;
90
+ if (!g.scopes.every((s) => (OBSERVER_SCOPES as readonly string[]).includes(s))) return null;
91
+ }
92
+ return parsed;
93
+ } catch {
94
+ return null; // parse/validation error → caller keeps previous (fail-safe)
95
+ }
96
+ }
97
+
98
+ export function saveObserversFile(path: string, file: ObserversFile): void {
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ const tmp = `${path}.tmp`;
101
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n');
102
+ renameSync(tmp, path); // atomic — mtime pollers never read a half-write
103
+ }
104
+
105
+ export function observersFileMtime(path: string): number | null {
106
+ try {
107
+ return statSync(path).mtimeMs;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Registry
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export interface VerifyResult {
118
+ grant: ObserverGrant;
119
+ scopes: Set<ObserverScope>;
120
+ }
121
+
122
+ export class ObserverRegistry {
123
+ private file: ObserversFile | null = null;
124
+ private lastMtime: number | null = null;
125
+ private poll: ReturnType<typeof setInterval> | null = null;
126
+
127
+ constructor(private readonly path: string) {}
128
+
129
+ start(): void {
130
+ this.lastMtime = observersFileMtime(this.path);
131
+ if (this.lastMtime !== null) {
132
+ this.file = loadObserversFile(this.path);
133
+ if (!this.file) console.error(`[webui-observers] ${this.path} unparseable — observers disabled until fixed`);
134
+ }
135
+ this.poll = setInterval(() => {
136
+ const m = observersFileMtime(this.path);
137
+ if (m === null || m === this.lastMtime) return; // missing/mid-rename or unchanged
138
+ this.lastMtime = m;
139
+ const next = loadObserversFile(this.path);
140
+ if (!next) {
141
+ console.error(`[webui-observers] ${this.path} unparseable — keeping previous grants`);
142
+ return;
143
+ }
144
+ this.file = next;
145
+ console.error(`[webui-observers] reloaded ${next.observers.length} grant(s)`);
146
+ }, 3000);
147
+ this.poll.unref();
148
+ }
149
+
150
+ stop(): void {
151
+ if (this.poll) clearInterval(this.poll);
152
+ }
153
+
154
+ /** True when at least one live grant exists — gates the whole feature:
155
+ * no grants ⇒ webui behaves exactly as before this layer existed. */
156
+ active(): boolean {
157
+ return (this.file?.observers.length ?? 0) > 0;
158
+ }
159
+
160
+ grants(): ObserverGrant[] {
161
+ return this.file?.observers ?? [];
162
+ }
163
+
164
+ /**
165
+ * Verify an observer-hello identity envelope against the live grants.
166
+ * Returns the grant + scope set, or null (with a stderr reason — auth
167
+ * failures on a headless box must be diagnosable).
168
+ */
169
+ verifyHello(identity: ObserverHelloIdentity, hostHeader: string, now = Date.now()): VerifyResult | null {
170
+ const fail = (why: string): null => {
171
+ console.error(`[webui-observers] hello rejected (${identity?.id ?? 'no-id'}): ${why}`);
172
+ return null;
173
+ };
174
+ if (!identity || identity.scheme !== 'ed25519') return fail('unsupported scheme');
175
+ if (typeof identity.id !== 'string' || !identity.id.startsWith('ed25519:')) return fail('bad key id');
176
+
177
+ const ts = Date.parse(identity.timestamp ?? '');
178
+ if (!Number.isFinite(ts)) return fail('bad timestamp');
179
+ if (Math.abs(now - ts) > FRESHNESS_MS) return fail('stale timestamp');
180
+
181
+ const grant = this.grants().find((g) => g.key === identity.id);
182
+ if (!grant) return fail('no grant for key');
183
+ if (grant.expires && Date.parse(grant.expires) < now) return fail('grant expired');
184
+
185
+ const raw = b64urlToBuf(identity.id.slice('ed25519:'.length));
186
+ if (!raw || raw.length !== 32) return fail('malformed public key');
187
+ const sig = b64urlToBuf(identity.proof ?? '');
188
+ if (!sig || sig.length !== 64) return fail('malformed signature');
189
+
190
+ try {
191
+ const okSig = cryptoVerify(
192
+ null,
193
+ Buffer.from(observerStatement(hostHeader, identity.timestamp), 'utf8'),
194
+ ed25519PublicKey(raw),
195
+ sig,
196
+ );
197
+ if (!okSig) return fail('signature verify failed');
198
+ } catch (err) {
199
+ return fail(`verify error: ${err instanceof Error ? err.message : err}`);
200
+ }
201
+ return { grant, scopes: new Set(grant.scopes) };
202
+ }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Sessions — short-lived bearer tokens minted after WS auth so the SPA can
207
+ // hit /debug/* and /healthz over plain HTTP. The WS is the authentication
208
+ // event; HTTP rides it.
209
+ // ---------------------------------------------------------------------------
210
+
211
+ const SESSION_TTL_MS = 12 * 60 * 60_000;
212
+
213
+ export class ObserverSessions {
214
+ private sessions = new Map<string, { scopes: Set<ObserverScope>; expiresAt: number }>();
215
+
216
+ mint(scopes: Set<ObserverScope>): string {
217
+ // Opportunistic sweep — the map stays tiny (one entry per WS auth).
218
+ const now = Date.now();
219
+ for (const [t, s] of this.sessions) if (s.expiresAt < now) this.sessions.delete(t);
220
+ const token = randomBytes(32).toString('hex');
221
+ this.sessions.set(token, { scopes, expiresAt: now + SESSION_TTL_MS });
222
+ return token;
223
+ }
224
+
225
+ lookup(token: string | null | undefined): Set<ObserverScope> | null {
226
+ if (!token) return null;
227
+ const s = this.sessions.get(token);
228
+ if (!s || s.expiresAt < Date.now()) return null;
229
+ return s.scopes;
230
+ }
231
+ }
232
+
233
+ /** Extract the observer session token from a request (cookie `fkm_obs`). */
234
+ export function sessionTokenFromRequest(req: Request): string | null {
235
+ const cookie = req.headers.get('cookie');
236
+ if (!cookie) return null;
237
+ const m = /(?:^|;\s*)fkm_obs=([a-f0-9]{64})/.exec(cookie);
238
+ return m ? m[1]! : null;
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Scope filtering — pure projections of wire data through a grant's mask
243
+ // (docs/observability.md §4). Kept here (not in the module) so they are
244
+ // unit-testable without the webui's process-level server singleton.
245
+ // ---------------------------------------------------------------------------
246
+
247
+ import type { WelcomeMessage, WelcomeMessageEntry } from '../web/protocol.js';
248
+
249
+ /** Which scope a trace event requires. Token deltas map by blockType;
250
+ * ops/mcpl lifecycle → 'ops'; usage → 'health'; the rest is conversation
251
+ * lifecycle → 'messages'. */
252
+ export function traceRequiredScope(event: { type: string }): ObserverScope {
253
+ const t = event.type;
254
+ if (t.startsWith('ops:') || t.startsWith('mcpl:')) return 'ops';
255
+ if (t === 'usage:updated') return 'health';
256
+ if (t === 'inference:tokens' || t === 'inference:content_block') {
257
+ const bt = (event as { blockType?: string }).blockType;
258
+ if (bt === 'thinking') return 'thinking';
259
+ if (bt === 'tool_call' || bt === 'tool_result') return 'tools';
260
+ return 'messages';
261
+ }
262
+ if (t.startsWith('tool:')) return 'tools';
263
+ return 'messages';
264
+ }
265
+
266
+ /** Project a wire entry through a scope mask: null when the observer may not
267
+ * see messages at all; otherwise thinking/tool blocks are elided per scope.
268
+ * Filtering, not rewriting — the wire is already typed blocks. */
269
+ export function filterEntryForScopes(
270
+ entry: WelcomeMessageEntry,
271
+ scopes: Set<ObserverScope>,
272
+ ): WelcomeMessageEntry | null {
273
+ if (!scopes.has('messages')) return null;
274
+ if (scopes.has('thinking') && scopes.has('tools')) return entry;
275
+ const blocks = entry.blocks.filter((b) => {
276
+ if ((b.kind === 'thinking' || b.kind === 'redacted_thinking') && !scopes.has('thinking')) return false;
277
+ if ((b.kind === 'tool_use' || b.kind === 'tool_result') && !scopes.has('tools')) return false;
278
+ return true;
279
+ });
280
+ return { ...entry, blocks };
281
+ }
282
+
283
+ /** Project a welcome through an observer's scope mask. Without 'messages'
284
+ * the entire conversation payload (entries + agent trees, which carry
285
+ * streaming buffers) is emptied — a health/ops observer like the fleet hub
286
+ * gets structure and usage, never content. Without 'health' the telemetry
287
+ * fields (usage / perAgentCost / callLedger) are masked too: their live
288
+ * frames (`usage`, `call-ledger`) are gated on 'health', and the welcome
289
+ * must refuse exactly what the stream refuses — otherwise a messages-only
290
+ * observer reads model IDs, per-call costs, and raw provider errors on
291
+ * connect that it is denied a second later. */
292
+ export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>): WelcomeMessage {
293
+ const emptyTree = { asOfTs: Date.now(), nodes: [], callIdIndex: {} };
294
+ const scoped: WelcomeMessage = { ...welcome };
295
+ if (!scopes.has('health')) {
296
+ delete scoped.callLedger;
297
+ delete scoped.perAgentCost;
298
+ // `usage` is required by the wire type, so it is zeroed rather than
299
+ // dropped (matches the emptied-not-deleted style of the fields below).
300
+ scoped.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
301
+ }
302
+ if (!scopes.has('messages')) {
303
+ return {
304
+ ...scoped,
305
+ messages: [],
306
+ history: { startIndex: welcome.history.totalCount, totalCount: welcome.history.totalCount },
307
+ localTree: emptyTree,
308
+ childTrees: [],
309
+ };
310
+ }
311
+ return {
312
+ ...scoped,
313
+ messages: welcome.messages
314
+ .map((e) => filterEntryForScopes(e, scopes))
315
+ .filter((e): e is WelcomeMessageEntry => e !== null),
316
+ // Agent trees replay tool calls + streaming thinking; only full
317
+ // interiority scopes get them.
318
+ ...(scopes.has('thinking') && scopes.has('tools')
319
+ ? {}
320
+ : { localTree: emptyTree, childTrees: [] }),
321
+ };
322
+ }
package/src/recipe.ts CHANGED
@@ -75,6 +75,10 @@ export interface RecipeStrategy {
75
75
  export interface RecipeAgent {
76
76
  name?: string;
77
77
  model?: string;
78
+ /** IANA zone used when rendering wall-clock times to the agent. */
79
+ timezone?: string;
80
+ /** Provider transport. Omitted preserves the historical Anthropic default. */
81
+ provider?: 'anthropic' | 'openai-responses';
78
82
  systemPrompt: string;
79
83
  maxTokens?: number;
80
84
  /**
@@ -86,9 +90,8 @@ export interface RecipeAgent {
86
90
  /** Per-agent context compile budget (input tokens). When unset, the
87
91
  * ContextManager default (100k) applies. Raise for large-context models. */
88
92
  contextBudgetTokens?: number;
89
- /** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Set '1h' for
90
- * agents whose reply cadence is slower than 5 minutes — avoids re-writing
91
- * the full context to cache after every gap. Unset: provider default (5m). */
93
+ /** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
94
+ * '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads. */
92
95
  cacheTtl?: '5m' | '1h';
93
96
  strategy?: RecipeStrategy;
94
97
  /**
@@ -100,6 +103,13 @@ export interface RecipeAgent {
100
103
  enabled: boolean;
101
104
  budgetTokens?: number;
102
105
  };
106
+ /** Stateless OpenAI Responses settings. Only used with
107
+ * `provider: "openai-responses"`. */
108
+ responses?: {
109
+ reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
110
+ reasoningContext?: 'current_turn' | 'all_turns';
111
+ compactThreshold?: number;
112
+ };
103
113
  /**
104
114
  * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
105
115
  * turn triggers an automatic rewind of the triggering turn + retry (keeping
@@ -500,6 +510,7 @@ export const DEFAULT_RECIPE: Recipe = {
500
510
  description: 'General-purpose assistant with tool access',
501
511
  agent: {
502
512
  name: 'agent',
513
+ cacheTtl: '1h',
503
514
  systemPrompt: [
504
515
  'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
505
516
  'Use them to help the user with their tasks.',
@@ -678,6 +689,39 @@ export function validateRecipe(raw: unknown): Recipe {
678
689
  throw new Error('Recipe agent must have a "systemPrompt" string');
679
690
  }
680
691
 
692
+ if (agent.provider !== undefined && agent.provider !== 'anthropic' && agent.provider !== 'openai-responses') {
693
+ throw new Error(`Recipe agent.provider must be 'anthropic' or 'openai-responses', got ${JSON.stringify(agent.provider)}.`);
694
+ }
695
+
696
+ if (agent.timezone !== undefined) {
697
+ if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
698
+ throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
699
+ }
700
+ try {
701
+ new Intl.DateTimeFormat('en-US', { timeZone: agent.timezone }).format(new Date(0));
702
+ } catch {
703
+ throw new Error(`Recipe agent.timezone is not a valid IANA time zone: ${JSON.stringify(agent.timezone)}.`);
704
+ }
705
+ }
706
+
707
+ if (agent.responses !== undefined) {
708
+ if (!agent.responses || typeof agent.responses !== 'object' || Array.isArray(agent.responses)) {
709
+ throw new Error('Recipe agent.responses must be an object.');
710
+ }
711
+ const responses = agent.responses as Record<string, unknown>;
712
+ const efforts = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
713
+ if (responses.reasoningEffort !== undefined && !efforts.includes(String(responses.reasoningEffort))) {
714
+ throw new Error(`Invalid agent.responses.reasoningEffort ${JSON.stringify(responses.reasoningEffort)}.`);
715
+ }
716
+ if (responses.reasoningContext !== undefined && responses.reasoningContext !== 'current_turn' && responses.reasoningContext !== 'all_turns') {
717
+ throw new Error(`Invalid agent.responses.reasoningContext ${JSON.stringify(responses.reasoningContext)}.`);
718
+ }
719
+ if (responses.compactThreshold !== undefined &&
720
+ (typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
721
+ throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
722
+ }
723
+ }
724
+
681
725
  if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
682
726
  throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
683
727
  }
@@ -687,6 +731,7 @@ export function validateRecipe(raw: unknown): Recipe {
687
731
  if (agent.cacheTtl !== undefined && agent.cacheTtl !== '5m' && agent.cacheTtl !== '1h') {
688
732
  throw new Error(`Recipe agent.cacheTtl must be '5m' or '1h', got ${JSON.stringify(agent.cacheTtl)}.`);
689
733
  }
734
+ agent.cacheTtl ??= '1h';
690
735
 
691
736
  // Validate agent.thinking if present. Catches typos and constraint
692
737
  // violations (notably max_tokens > budget_tokens) at recipe-load time
@@ -9,6 +9,7 @@ import type {
9
9
  SummaryEntry,
10
10
  } from '@animalabs/context-manager';
11
11
  import type { ContentBlock } from '@animalabs/membrane';
12
+ import { formatZonedTime, resolveTimeZone } from '@animalabs/agent-framework';
12
13
 
13
14
  // Structural mirror of AutobiographicalStrategy's internal Chunk.
14
15
  // Kept inline because @animalabs/context-manager does not currently export it.
@@ -24,7 +25,7 @@ interface Chunk {
24
25
  phaseType?: string;
25
26
  }
26
27
 
27
- export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig>;
28
+ export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig> & { timeZone?: string };
28
29
 
29
30
  /**
30
31
  * Chatbot-flavoured context strategy for agents that receive messages via
@@ -43,6 +44,13 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
43
44
  override readonly name: string = 'frontdesk';
44
45
 
45
46
  private salientSourceIds: Set<string> = new Set();
47
+ private readonly timeZone: string;
48
+
49
+ constructor(options: FrontdeskStrategyOptions = {}) {
50
+ const { timeZone, ...strategyOptions } = options;
51
+ super(strategyOptions);
52
+ this.timeZone = resolveTimeZone(timeZone);
53
+ }
46
54
 
47
55
  override select(
48
56
  store: MessageStoreView,
@@ -146,9 +154,7 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
146
154
  }
147
155
  if (!d && msgTs instanceof Date && !isNaN(msgTs.getTime())) d = msgTs;
148
156
  if (!d) return null;
149
- const hh = String(d.getHours()).padStart(2, '0');
150
- const mm = String(d.getMinutes()).padStart(2, '0');
151
- return `${hh}:${mm}`;
157
+ return formatZonedTime(d, this.timeZone);
152
158
  }
153
159
 
154
160
  // ==========================================================================
@@ -93,6 +93,9 @@ export interface WelcomeMessage {
93
93
  /** Per-agent cost breakdown for the parent process, present when the
94
94
  * framework's usage tracker has data. Empty during cold start. */
95
95
  perAgentCost?: PerAgentCost[];
96
+ /** Recent provider calls with cache verdicts. Present when the host's
97
+ * provider adapter exposes the call ledger. */
98
+ callLedger?: CallLedgerSnapshot;
96
99
  }
97
100
 
98
101
  /**
@@ -164,6 +167,91 @@ export interface UsageMessage {
164
167
  perAgentCost?: PerAgentCost[];
165
168
  }
166
169
 
170
+ /** Live replacement snapshot emitted after each provider call. */
171
+ export interface CallLedgerMessage {
172
+ type: 'call-ledger';
173
+ ledger: CallLedgerSnapshot;
174
+ }
175
+
176
+ export type CallLedgerVerdict =
177
+ | 'HIT'
178
+ | 'hit+extend'
179
+ | 'uncached'
180
+ | 'rewrite:expired'
181
+ | 'rewrite:prefix-mutated'
182
+ | 'rewrite:prefix-truncated'
183
+ | 'rewrite:unexplained'
184
+ | 'first-write'
185
+ | 'ERROR'
186
+ | 'empty'
187
+ | 'unknown';
188
+
189
+ export interface CallLedgerRow {
190
+ id: string;
191
+ timestamp: string;
192
+ kind: 'complete' | 'stream';
193
+ /** Honest call-class estimate; `~` means the trigger plumbing did not
194
+ * provide a definitive origin. */
195
+ originEstimate: 'turn~' | 'aux~';
196
+ model: string;
197
+ messages: number;
198
+ durationMs: number;
199
+ tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
200
+ cost?: CallCostBreakdown;
201
+ cache: {
202
+ /** Undefined for older compact logs that predate marker summaries. */
203
+ breakpoints?: number;
204
+ ttls: string[];
205
+ effectiveTtl: '5m' | '1h';
206
+ };
207
+ verdict: CallLedgerVerdict;
208
+ cause: string;
209
+ stopReason?: string;
210
+ error?: string;
211
+ }
212
+
213
+ export interface CallCostBreakdown {
214
+ input: number;
215
+ cacheWrite5m: number;
216
+ cacheWrite1h: number;
217
+ cacheRead: number;
218
+ output: number;
219
+ total: number;
220
+ currency: 'USD';
221
+ /** `billing` means every charged usage bucket and pricing modifier was
222
+ * known. Unknown/mixed buckets are left unpriced rather than estimated. */
223
+ grade: 'billing';
224
+ pricingVersion: string;
225
+ rates: {
226
+ inputPerMillion: number;
227
+ outputPerMillion: number;
228
+ cacheWrite5mPerMillion: number;
229
+ cacheWrite1hPerMillion: number;
230
+ cacheReadPerMillion: number;
231
+ };
232
+ }
233
+
234
+ export interface CallLedgerSnapshot {
235
+ /** Oldest to newest; clients may reverse for display. */
236
+ rows: CallLedgerRow[];
237
+ summary: {
238
+ calls: number;
239
+ input: number;
240
+ output: number;
241
+ cacheRead: number;
242
+ cacheWrite: number;
243
+ cacheHitRatio: number;
244
+ cost?: {
245
+ total: number;
246
+ currency: 'USD';
247
+ pricedCalls: number;
248
+ unpricedCalls: number;
249
+ pricingVersion: string;
250
+ };
251
+ byVerdict: Partial<Record<CallLedgerVerdict, number>>;
252
+ };
253
+ }
254
+
167
255
  export interface TokenUsage {
168
256
  input: number;
169
257
  output: number;
@@ -352,6 +440,7 @@ export type WebUiServerMessage =
352
440
  | ChildEventMessage
353
441
  | CommandResultMessage
354
442
  | UsageMessage
443
+ | CallLedgerMessage
355
444
  | BranchChangedMessage
356
445
  | SessionChangedMessage
357
446
  | PeekMessage
@@ -364,8 +453,35 @@ export type WebUiServerMessage =
364
453
  | WorkspaceFileMessage
365
454
  | HistoryPageMessage
366
455
  | MessageAppendedMessage
456
+ | ObserverAuthRequiredMessage
457
+ | ObserverAckMessage
367
458
  | ErrorMessage;
368
459
 
460
+ // ---------------------------------------------------------------------------
461
+ // Observer identity (docs/observability.md M2) — additive to protocol v1.
462
+ // Basic-auth clients never see these frames; key-authenticated observers
463
+ // authenticate in-band over the WS before any data flows.
464
+ // ---------------------------------------------------------------------------
465
+
466
+ /** Sent to a connection that upgraded without basic auth (allowed only when
467
+ * observer grants exist): the client must reply with observer-hello before
468
+ * anything else is sent. `host` is what the server will verify the signed
469
+ * statement against — the Host header of the upgrade request. */
470
+ export interface ObserverAuthRequiredMessage {
471
+ type: 'observer-auth-required';
472
+ host: string;
473
+ }
474
+
475
+ /** Successful observer authentication. `sessionToken` is a short-lived
476
+ * bearer for HTTP routes (set as the `fkm_obs` cookie by the SPA);
477
+ * `scopes` is the grant's mask — the SPA adapts its UI to it. */
478
+ export interface ObserverAckMessage {
479
+ type: 'observer-ack';
480
+ scopes: string[];
481
+ sessionToken: string;
482
+ label: string;
483
+ }
484
+
369
485
  // ---------------------------------------------------------------------------
370
486
  // Client → Server
371
487
  // ---------------------------------------------------------------------------
@@ -522,8 +638,26 @@ export interface RequestHistoryMessage {
522
638
  limit?: number;
523
639
  }
524
640
 
641
+ /** In-band observer authentication (docs/observability.md §3): the client
642
+ * signs `connectome-observer|v1|<host>|<timestamp>` with its ed25519 key.
643
+ * Challenge-less — the Host binding + freshness window replace a nonce. */
644
+ export interface ObserverHelloMessage {
645
+ type: 'observer-hello';
646
+ identity: {
647
+ scheme: 'ed25519';
648
+ /** `ed25519:<base64url raw 32-byte public key>` */
649
+ id: string;
650
+ /** base64url signature over the bound statement. */
651
+ proof: string;
652
+ /** ISO timestamp used in the statement (±5 min freshness). */
653
+ timestamp: string;
654
+ displayName?: string;
655
+ };
656
+ }
657
+
525
658
  export type WebUiClientMessage =
526
659
  | UserMessageMessage
660
+ | ObserverHelloMessage
527
661
  | RequestHistoryMessage
528
662
  | CommandMessage
529
663
  | RouteToChildMessage
@@ -565,6 +699,13 @@ export function isClientMessage(value: unknown): value is WebUiClientMessage {
565
699
  return true;
566
700
  case 'user-message':
567
701
  return typeof v.content === 'string';
702
+ case 'observer-hello': {
703
+ const id = v.identity as Record<string, unknown> | undefined;
704
+ return !!id && id.scheme === 'ed25519'
705
+ && typeof id.id === 'string'
706
+ && typeof id.proof === 'string'
707
+ && typeof id.timestamp === 'string';
708
+ }
568
709
  case 'command':
569
710
  return typeof v.command === 'string'
570
711
  && (v.corrId === undefined || typeof v.corrId === 'string');