@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
@@ -41,6 +41,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
41
41
  import type { Recipe } from '../recipe.js';
42
42
  import type { SessionManager } from '../session-manager.js';
43
43
  import type { BranchState } from '../commands.js';
44
+ import type { CallLedger } from '../call-ledger.js';
44
45
  import { handleCommand } from '../commands.js';
45
46
  import { AgentTreeReducer, type AgentTreeSnapshot } from '../state/agent-tree-reducer.js';
46
47
  import { FleetTreeAggregator } from '../state/fleet-tree-aggregator.js';
@@ -54,6 +55,7 @@ import {
54
55
  type WelcomeMessageEntry,
55
56
  type RequestHistoryMessage,
56
57
  type TokenUsage,
58
+ type CallLedgerSnapshot,
57
59
  type McplListMessage,
58
60
  type LessonsListMessage,
59
61
  } from '../web/protocol.js';
@@ -128,6 +130,8 @@ export interface WebUiModuleConfig {
128
130
  * scope mask. With no grants the webui behaves exactly as before.
129
131
  */
130
132
  observersPath?: string;
133
+ /** Content-free recent provider-call ledger for spend/cache diagnostics. */
134
+ callLedger?: CallLedger;
131
135
  }
132
136
 
133
137
  /** Data stashed on the Bun WS upgrade. */
@@ -389,6 +393,9 @@ interface SharedServerState {
389
393
  * every usage:updated event so the welcome and live UsageMessage frames
390
394
  * carry consistent values. */
391
395
  latestPerAgentCost: import('../web/protocol.js').PerAgentCost[];
396
+ /** Recent per-call cache diagnostics, updated directly by the adapter. */
397
+ latestCallLedger?: CallLedgerSnapshot;
398
+ callLedgerDetacher: (() => void) | null;
392
399
  /** Currently-bound app, refreshed on every setApp() call. WS handlers read
393
400
  * from here so the singleton always points at the live framework regardless
394
401
  * of which WebUiModule instance is "active". */
@@ -464,6 +471,8 @@ export class WebUiModule implements Module {
464
471
  parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
465
472
  childUsage: new Map(),
466
473
  latestPerAgentCost: [],
474
+ latestCallLedger: this.config.callLedger?.snapshot(),
475
+ callLedgerDetacher: null,
467
476
  pendingFleetRequests: new Map(),
468
477
  childRecipeCache: new Map(),
469
478
  app: null,
@@ -495,6 +504,19 @@ export class WebUiModule implements Module {
495
504
  }
496
505
  sharedServer = state;
497
506
 
507
+ // Provider calls include auxiliary compression requests that never emit a
508
+ // framework usage trace, so subscribe at the adapter ledger itself. This
509
+ // keeps the panel live for both conversational and memory-maintenance
510
+ // calls without polling the JSONL log.
511
+ state.callLedgerDetacher = this.config.callLedger?.onUpdate((ledger) => {
512
+ state.latestCallLedger = ledger;
513
+ const msg: WebUiServerMessage = { type: 'call-ledger', ledger };
514
+ for (const client of state.clients.values()) {
515
+ if (!client.welcomed) continue;
516
+ if (client.scopes === null || client.scopes.has('health')) this.send(client, msg);
517
+ }
518
+ }) ?? null;
519
+
498
520
  console.log(`[webui] listening on http://${host}:${boundPort}`);
499
521
  }
500
522
 
@@ -961,8 +983,14 @@ export class WebUiModule implements Module {
961
983
  // `new WebSocket(...)` the way they do on fetch, so without an
962
984
  // explicit check, any tab the operator opens could connect here.
963
985
  if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
964
- const basicOk = this.checkAuth(req);
965
- // Without basic auth the upgrade is allowed only when observer grants
986
+ // Full authority = basic auth OR a full session cookie (minted by
987
+ // /auth/basic). The cookie path exists because browsers reliably send
988
+ // cookies on WS upgrades but Chrome does NOT send cached basic-auth
989
+ // credentials — without it, password sign-in bounced back to the
990
+ // observer gate in a loop.
991
+ const wsSession = sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
992
+ const basicOk = this.checkAuth(req) || (wsSession?.full ?? false);
993
+ // Without full auth the upgrade is allowed only when observer grants
966
994
  // exist — and then NOTHING is sent until a signed observer-hello
967
995
  // verifies (in-band auth; see onWsOpen/onWsMessage).
968
996
  if (!basicOk && !observersActive) return this.unauthorized();
@@ -980,19 +1008,29 @@ export class WebUiModule implements Module {
980
1008
  // when observers are active the static app shell is public — it carries
981
1009
  // no data, and key-only devices must be able to load the SPA to
982
1010
  // authenticate at all.
983
- const basicOk = this.checkAuth(req);
984
- const sessionScopes = basicOk
985
- ? null
986
- : sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
1011
+ const session = sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
1012
+ const basicOk = this.checkAuth(req) || (session?.full ?? false);
1013
+ const sessionScopes = basicOk ? null : session?.scopes ?? null;
987
1014
  const httpAllowed = (scope: ObserverScope): boolean =>
988
1015
  basicOk || (sessionScopes?.has(scope) ?? false);
989
1016
  // /auth/basic: deliberate basic-auth challenge point. fetch() never
990
1017
  // triggers the browser's native credential prompt, but a top-level
991
1018
  // navigation here does — the SPA's "sign in with password" fallback for
992
- // devices without an observer grant. Once credentials are cached for
993
- // the realm, the WS upgrade carries them and the client is 'full'.
1019
+ // devices without an observer grant. On success it mints a FULL session
1020
+ // cookie: the subsequent WS upgrade authenticates via that cookie
1021
+ // (browsers send cookies on upgrades; Chrome does not send cached basic
1022
+ // credentials, which used to loop users back to the gate).
994
1023
  if (url.pathname === '/auth/basic') {
995
- if (basicOk) return new Response(null, { status: 302, headers: { location: '/' } });
1024
+ if (basicOk) {
1025
+ const token = sharedServer!.observerSessions.mint(new Set(), { full: true });
1026
+ return new Response(null, {
1027
+ status: 302,
1028
+ headers: {
1029
+ location: '/',
1030
+ 'set-cookie': `fkm_obs=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${12 * 3600}`,
1031
+ },
1032
+ });
1033
+ }
996
1034
  return this.unauthorized();
997
1035
  }
998
1036
  const isStatic = !url.pathname.startsWith('/debug/')
@@ -1055,7 +1093,44 @@ export class WebUiModule implements Module {
1055
1093
  if (typeof fw.healthSnapshot !== 'function') {
1056
1094
  return Response.json({ error: 'framework lacks healthSnapshot()' }, { status: 501 });
1057
1095
  }
1058
- return Response.json(fw.healthSnapshot());
1096
+ const snapshot = fw.healthSnapshot();
1097
+ // Compression quarantine is a guaranteed-eventual-outage state (raw
1098
+ // spans accumulate until the picker cannot fit the window). Surface it
1099
+ // here so the fleet hub and connectome-doctor can alarm on it — it
1100
+ // must never be observable only in agent.log.
1101
+ try {
1102
+ const quarantine: Record<string, unknown> = {};
1103
+ for (const agent of app.framework.getAllAgents()) {
1104
+ const strategy = (agent.getContextManager() as unknown as {
1105
+ getStrategy?: () => { getCompressionQuarantineStatus?: () => unknown };
1106
+ }).getStrategy?.();
1107
+ const status = strategy?.getCompressionQuarantineStatus?.();
1108
+ if (status) quarantine[(agent as unknown as { name: string }).name] = status;
1109
+ }
1110
+ (snapshot as Record<string, unknown>).compressionQuarantine = quarantine;
1111
+ } catch {
1112
+ // Health reads never throw.
1113
+ }
1114
+ // Per-agent runtime settings (context budget, tail, transition pace +
1115
+ // convergence state) — the same numbers `agent_settings get` returns,
1116
+ // exposed externally so the fleet hub / connectome-doctor can watch
1117
+ // budget convergence without an agent turn.
1118
+ try {
1119
+ const fw2 = app.framework as unknown as {
1120
+ getAgentRuntimeSettings?: (name: string) => unknown;
1121
+ };
1122
+ if (typeof fw2.getAgentRuntimeSettings === 'function') {
1123
+ const settings: Record<string, unknown> = {};
1124
+ for (const agent of app.framework.getAllAgents()) {
1125
+ const name = (agent as unknown as { name: string }).name;
1126
+ settings[name] = fw2.getAgentRuntimeSettings(name);
1127
+ }
1128
+ (snapshot as Record<string, unknown>).runtimeSettings = settings;
1129
+ }
1130
+ } catch {
1131
+ // Health reads never throw.
1132
+ }
1133
+ return Response.json(snapshot);
1059
1134
  }
1060
1135
 
1061
1136
  // Workspace file passthrough: /files/<mount>/<path...>
@@ -2486,6 +2561,9 @@ export class WebUiModule implements Module {
2486
2561
  ...(sharedServer!.latestPerAgentCost.length > 0
2487
2562
  ? { perAgentCost: sharedServer!.latestPerAgentCost }
2488
2563
  : {}),
2564
+ ...(sharedServer!.latestCallLedger
2565
+ ? { callLedger: sharedServer!.latestCallLedger }
2566
+ : {}),
2489
2567
  };
2490
2568
  }
2491
2569
 
@@ -210,23 +210,36 @@ export class ObserverRegistry {
210
210
 
211
211
  const SESSION_TTL_MS = 12 * 60 * 60_000;
212
212
 
213
+ export interface ObserverSession {
214
+ scopes: Set<ObserverScope>;
215
+ /**
216
+ * Full sessions carry OPERATOR authority (equivalent to basic auth): the
217
+ * WS upgrade treats the connection as a full client, not a read-only
218
+ * observer. Minted only by /auth/basic after a successful password
219
+ * challenge. Exists because browsers reliably attach cookies to WebSocket
220
+ * upgrades but (Chrome, notably) do NOT attach cached basic-auth
221
+ * credentials — without this, password sign-in loops back to the gate.
222
+ */
223
+ full: boolean;
224
+ }
225
+
213
226
  export class ObserverSessions {
214
- private sessions = new Map<string, { scopes: Set<ObserverScope>; expiresAt: number }>();
227
+ private sessions = new Map<string, ObserverSession & { expiresAt: number }>();
215
228
 
216
- mint(scopes: Set<ObserverScope>): string {
217
- // Opportunistic sweep — the map stays tiny (one entry per WS auth).
229
+ mint(scopes: Set<ObserverScope>, opts?: { full?: boolean }): string {
230
+ // Opportunistic sweep — the map stays tiny (one entry per auth event).
218
231
  const now = Date.now();
219
232
  for (const [t, s] of this.sessions) if (s.expiresAt < now) this.sessions.delete(t);
220
233
  const token = randomBytes(32).toString('hex');
221
- this.sessions.set(token, { scopes, expiresAt: now + SESSION_TTL_MS });
234
+ this.sessions.set(token, { scopes, full: opts?.full ?? false, expiresAt: now + SESSION_TTL_MS });
222
235
  return token;
223
236
  }
224
237
 
225
- lookup(token: string | null | undefined): Set<ObserverScope> | null {
238
+ lookup(token: string | null | undefined): ObserverSession | null {
226
239
  if (!token) return null;
227
240
  const s = this.sessions.get(token);
228
241
  if (!s || s.expiresAt < Date.now()) return null;
229
- return s.scopes;
242
+ return { scopes: s.scopes, full: s.full };
230
243
  }
231
244
  }
232
245
 
@@ -283,12 +296,25 @@ export function filterEntryForScopes(
283
296
  /** Project a welcome through an observer's scope mask. Without 'messages'
284
297
  * the entire conversation payload (entries + agent trees, which carry
285
298
  * streaming buffers) is emptied — a health/ops observer like the fleet hub
286
- * gets structure and usage, never content. */
299
+ * gets structure and usage, never content. Without 'health' the telemetry
300
+ * fields (usage / perAgentCost / callLedger) are masked too: their live
301
+ * frames (`usage`, `call-ledger`) are gated on 'health', and the welcome
302
+ * must refuse exactly what the stream refuses — otherwise a messages-only
303
+ * observer reads model IDs, per-call costs, and raw provider errors on
304
+ * connect that it is denied a second later. */
287
305
  export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>): WelcomeMessage {
288
306
  const emptyTree = { asOfTs: Date.now(), nodes: [], callIdIndex: {} };
307
+ const scoped: WelcomeMessage = { ...welcome };
308
+ if (!scopes.has('health')) {
309
+ delete scoped.callLedger;
310
+ delete scoped.perAgentCost;
311
+ // `usage` is required by the wire type, so it is zeroed rather than
312
+ // dropped (matches the emptied-not-deleted style of the fields below).
313
+ scoped.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
314
+ }
289
315
  if (!scopes.has('messages')) {
290
316
  return {
291
- ...welcome,
317
+ ...scoped,
292
318
  messages: [],
293
319
  history: { startIndex: welcome.history.totalCount, totalCount: welcome.history.totalCount },
294
320
  localTree: emptyTree,
@@ -296,7 +322,7 @@ export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>
296
322
  };
297
323
  }
298
324
  return {
299
- ...welcome,
325
+ ...scoped,
300
326
  messages: welcome.messages
301
327
  .map((e) => filterEntryForScopes(e, scopes))
302
328
  .filter((e): e is WelcomeMessageEntry => e !== null),
package/src/recipe.ts CHANGED
@@ -35,6 +35,13 @@ export interface RecipeStrategy {
35
35
  // for what the recipe loader accepts under `agent.strategy`.
36
36
  enforceBudget?: boolean;
37
37
  maxSpeculativeL1s?: number;
38
+ /** Number of coverage-equivalent recall curves to try after the canonical
39
+ * autobiographical compression request is explicitly refused. Zero disables
40
+ * fallback while retaining the canonical attempt. */
41
+ compressionRefusalCurveFallbacks?: number;
42
+ /** Complete provider-request admission ceiling for compression fallbacks,
43
+ * including the output reserve. */
44
+ compressionContextBudgetTokens?: number;
38
45
  positionedRecallPairs?: boolean;
39
46
  recallHeaderTemplate?: string;
40
47
  targetChunkTokens?: number;
@@ -75,6 +82,10 @@ export interface RecipeStrategy {
75
82
  export interface RecipeAgent {
76
83
  name?: string;
77
84
  model?: string;
85
+ /** IANA zone used when rendering wall-clock times to the agent. */
86
+ timezone?: string;
87
+ /** Provider transport. Omitted preserves the historical Anthropic default. */
88
+ provider?: 'anthropic' | 'openai-responses';
78
89
  systemPrompt: string;
79
90
  maxTokens?: number;
80
91
  /**
@@ -86,10 +97,14 @@ export interface RecipeAgent {
86
97
  /** Per-agent context compile budget (input tokens). When unset, the
87
98
  * ContextManager default (100k) applies. Raise for large-context models. */
88
99
  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). */
100
+ /** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
101
+ * '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads. */
92
102
  cacheTtl?: '5m' | '1h';
103
+ /**
104
+ * Same-round routing policy for ordinary text emitted beside think().
105
+ * Omitted preserves the compatibility carry-forward in Agent Framework.
106
+ */
107
+ sameRoundThinkTextPolicy?: 'public' | 'private';
93
108
  strategy?: RecipeStrategy;
94
109
  /**
95
110
  * Native extended thinking. When `enabled: true`, the agent's API requests
@@ -100,6 +115,14 @@ export interface RecipeAgent {
100
115
  enabled: boolean;
101
116
  budgetTokens?: number;
102
117
  };
118
+ /** Stateless OpenAI Responses settings. Only used with
119
+ * `provider: "openai-responses"`. */
120
+ responses?: {
121
+ reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
122
+ reasoningContext?: 'current_turn' | 'all_turns';
123
+ serviceTier?: string;
124
+ compactThreshold?: number;
125
+ };
103
126
  /**
104
127
  * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
105
128
  * turn triggers an automatic rewind of the triggering turn + retry (keeping
@@ -109,6 +132,11 @@ export interface RecipeAgent {
109
132
  autoRewind?: boolean;
110
133
  maxRewinds?: number;
111
134
  announceHumanTurns?: boolean;
135
+ primarySummaryFallback?: {
136
+ enabled?: boolean;
137
+ maxNewSummaries?: number;
138
+ requestBudgetTokens?: number;
139
+ };
112
140
  };
113
141
  }
114
142
 
@@ -144,9 +172,8 @@ export interface RecipeMcpServer {
144
172
  /** Ceiling for the exponential reconnect backoff. Default: 5 minutes. */
145
173
  reconnectMaxIntervalMs?: number;
146
174
  /**
147
- * Channel auto-open policy. 'auto' (default) opens everything the server
148
- * registers; 'manual' opens nothing (agent calls channel_open as needed);
149
- * a string[] is an allow-list of channel ids.
175
+ * @deprecated One-time migration input for legacy recipes. Runtime channel
176
+ * desired state is Chronicle-backed and changed with channel_open/close.
150
177
  */
151
178
  channelSubscription?: 'auto' | 'manual' | string[];
152
179
  /**
@@ -500,6 +527,7 @@ export const DEFAULT_RECIPE: Recipe = {
500
527
  description: 'General-purpose assistant with tool access',
501
528
  agent: {
502
529
  name: 'agent',
530
+ cacheTtl: '1h',
503
531
  systemPrompt: [
504
532
  'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
505
533
  'Use them to help the user with their tasks.',
@@ -678,6 +706,42 @@ export function validateRecipe(raw: unknown): Recipe {
678
706
  throw new Error('Recipe agent must have a "systemPrompt" string');
679
707
  }
680
708
 
709
+ if (agent.provider !== undefined && agent.provider !== 'anthropic' && agent.provider !== 'openai-responses') {
710
+ throw new Error(`Recipe agent.provider must be 'anthropic' or 'openai-responses', got ${JSON.stringify(agent.provider)}.`);
711
+ }
712
+
713
+ if (agent.timezone !== undefined) {
714
+ if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
715
+ throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
716
+ }
717
+ try {
718
+ new Intl.DateTimeFormat('en-US', { timeZone: agent.timezone }).format(new Date(0));
719
+ } catch {
720
+ throw new Error(`Recipe agent.timezone is not a valid IANA time zone: ${JSON.stringify(agent.timezone)}.`);
721
+ }
722
+ }
723
+
724
+ if (agent.responses !== undefined) {
725
+ if (!agent.responses || typeof agent.responses !== 'object' || Array.isArray(agent.responses)) {
726
+ throw new Error('Recipe agent.responses must be an object.');
727
+ }
728
+ const responses = agent.responses as Record<string, unknown>;
729
+ const efforts = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
730
+ if (responses.reasoningEffort !== undefined && !efforts.includes(String(responses.reasoningEffort))) {
731
+ throw new Error(`Invalid agent.responses.reasoningEffort ${JSON.stringify(responses.reasoningEffort)}.`);
732
+ }
733
+ if (responses.reasoningContext !== undefined && responses.reasoningContext !== 'current_turn' && responses.reasoningContext !== 'all_turns') {
734
+ throw new Error(`Invalid agent.responses.reasoningContext ${JSON.stringify(responses.reasoningContext)}.`);
735
+ }
736
+ if (responses.serviceTier !== undefined && (typeof responses.serviceTier !== 'string' || !responses.serviceTier.trim())) {
737
+ throw new Error('Recipe agent.responses.serviceTier must be a non-empty string.');
738
+ }
739
+ if (responses.compactThreshold !== undefined &&
740
+ (typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
741
+ throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
742
+ }
743
+ }
744
+
681
745
  if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
682
746
  throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
683
747
  }
@@ -687,6 +751,17 @@ export function validateRecipe(raw: unknown): Recipe {
687
751
  if (agent.cacheTtl !== undefined && agent.cacheTtl !== '5m' && agent.cacheTtl !== '1h') {
688
752
  throw new Error(`Recipe agent.cacheTtl must be '5m' or '1h', got ${JSON.stringify(agent.cacheTtl)}.`);
689
753
  }
754
+ agent.cacheTtl ??= '1h';
755
+
756
+ if (
757
+ agent.sameRoundThinkTextPolicy !== undefined &&
758
+ agent.sameRoundThinkTextPolicy !== 'public' &&
759
+ agent.sameRoundThinkTextPolicy !== 'private'
760
+ ) {
761
+ throw new Error(
762
+ `Recipe agent.sameRoundThinkTextPolicy must be 'public' or 'private', got ${JSON.stringify(agent.sameRoundThinkTextPolicy)}.`,
763
+ );
764
+ }
690
765
 
691
766
  // Validate agent.thinking if present. Catches typos and constraint
692
767
  // violations (notably max_tokens > budget_tokens) at recipe-load time
@@ -725,6 +800,58 @@ export function validateRecipe(raw: unknown): Recipe {
725
800
  `Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", or "frontdesk".`,
726
801
  );
727
802
  }
803
+ if (
804
+ strategy.compressionRefusalCurveFallbacks !== undefined
805
+ && (
806
+ typeof strategy.compressionRefusalCurveFallbacks !== 'number'
807
+ || !Number.isSafeInteger(strategy.compressionRefusalCurveFallbacks)
808
+ || strategy.compressionRefusalCurveFallbacks < 0
809
+ )
810
+ ) {
811
+ throw new Error('Recipe agent.strategy.compressionRefusalCurveFallbacks must be a non-negative safe integer.');
812
+ }
813
+ if (
814
+ strategy.compressionContextBudgetTokens !== undefined
815
+ && (
816
+ typeof strategy.compressionContextBudgetTokens !== 'number'
817
+ || !Number.isSafeInteger(strategy.compressionContextBudgetTokens)
818
+ || strategy.compressionContextBudgetTokens <= 0
819
+ )
820
+ ) {
821
+ throw new Error('Recipe agent.strategy.compressionContextBudgetTokens must be a positive safe integer.');
822
+ }
823
+ }
824
+
825
+ const refusalHandling = agent.refusalHandling as Record<string, unknown> | undefined;
826
+ const primarySummaryFallback = refusalHandling?.primarySummaryFallback;
827
+ if (primarySummaryFallback !== undefined) {
828
+ if (!primarySummaryFallback || typeof primarySummaryFallback !== 'object' || Array.isArray(primarySummaryFallback)) {
829
+ throw new Error('Recipe agent.refusalHandling.primarySummaryFallback must be an object.');
830
+ }
831
+ const fallback = primarySummaryFallback as Record<string, unknown>;
832
+ if (fallback.enabled !== undefined && typeof fallback.enabled !== 'boolean') {
833
+ throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.enabled must be a boolean.');
834
+ }
835
+ if (
836
+ fallback.maxNewSummaries !== undefined &&
837
+ (
838
+ typeof fallback.maxNewSummaries !== 'number' ||
839
+ !Number.isSafeInteger(fallback.maxNewSummaries) ||
840
+ fallback.maxNewSummaries < 0
841
+ )
842
+ ) {
843
+ throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.maxNewSummaries must be a non-negative safe integer.');
844
+ }
845
+ if (
846
+ fallback.requestBudgetTokens !== undefined &&
847
+ (
848
+ typeof fallback.requestBudgetTokens !== 'number' ||
849
+ !Number.isSafeInteger(fallback.requestBudgetTokens) ||
850
+ fallback.requestBudgetTokens <= 0
851
+ )
852
+ ) {
853
+ throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.requestBudgetTokens must be a positive safe integer.');
854
+ }
728
855
  }
729
856
 
730
857
  // Validate mcpServers entries if present
@@ -202,10 +202,13 @@ const EVENT_HANDLERS: Record<string, EventHandler> = {
202
202
  node.lastEventAt = ts;
203
203
  },
204
204
 
205
- // inference:exhausted = budget exhaustion / stream-side cancel / reboot-
205
+ // inference:exhausted = retries exhausted / genuine stream cancel / reboot-
206
206
  // induced. Postmortem 2026-05-28 P1 #3: not strictly a fault — flip both
207
207
  // fields to the dedicated 'cancelled' terminal state so the renderer can
208
- // distinguish "stopped on purpose" from "errored out".
208
+ // distinguish "stopped on purpose" from "errored out". (AF PR #55 / ≥ 0.6.5
209
+ // stopped emitting this for endTurn and context-budget restarts — those are
210
+ // no longer terminal at all — but user cancels and reboots still land here,
211
+ // and older AF still sends the endTurn-trailing one.)
209
212
  'inference:exhausted': (r, e, ts) => {
210
213
  if (!e.agentName) return;
211
214
  const node = r._ensureNode(e.agentName);
@@ -236,9 +239,20 @@ const EVENT_HANDLERS: Record<string, EventHandler> = {
236
239
  r._ensureNode(e.agentName).lastEventAt = ts;
237
240
  },
238
241
 
242
+ // endTurn tool result: the agent deliberately ended its turn — a successful
243
+ // terminal, same family as inference:completed. This event must be terminal
244
+ // in its own right: agent-framework PR #55 (≥ 0.6.5) settles endTurn from
245
+ // the state machine and no longer follows it with the spurious
246
+ // inference:exhausted that used to flip these nodes to 'cancelled'. On
247
+ // older AF the trailing exhausted still arrives and (harmlessly)
248
+ // re-terminates the node as cancelled one event later.
239
249
  'inference:turn_ended': (r, e, ts) => {
240
250
  if (!e.agentName) return;
241
- r._ensureNode(e.agentName).lastEventAt = ts;
251
+ const node = r._ensureNode(e.agentName);
252
+ node.phase = 'done';
253
+ node.status = 'completed';
254
+ node.completedAt = ts;
255
+ node.lastEventAt = ts;
242
256
  },
243
257
 
244
258
  'tool:started': (r, e, ts) => {
@@ -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,
@@ -115,15 +123,15 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
115
123
  if (ts) parts.push(ts);
116
124
 
117
125
  if (meta.messageId !== undefined && meta.messageId !== null && meta.messageId !== '') {
118
- const mid = String(meta.messageId);
119
- const short = mid.length > 12 ? mid.slice(0, 12) : mid;
120
- parts.push(`msg ${short}`);
126
+ // Render the FULL id — truncating (an old token-saving trim) corrupts
127
+ // Discord snowflakes, so every reply_message/fetch_around/add_reaction
128
+ // call the agent copies from its own context 404s with Unknown message.
129
+ parts.push(`msg ${String(meta.messageId)}`);
121
130
  }
122
131
 
123
132
  const threadId = meta.threadId !== undefined && meta.threadId !== null ? String(meta.threadId) : '';
124
133
  if (threadId && threadId !== topic) {
125
- const short = threadId.length > 12 ? threadId.slice(0, 12) : threadId;
126
- parts.push(`thread ${short}`);
134
+ parts.push(`thread ${threadId}`);
127
135
  }
128
136
 
129
137
  if (parts.length === 0) return null;
@@ -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