@animalabs/connectome-host 0.3.5 → 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.
package/src/index.ts CHANGED
@@ -15,10 +15,16 @@
15
15
  * DATA_DIR - Data directory for sessions (default: ./data)
16
16
  */
17
17
 
18
- import { Membrane, NativeFormatter } from '@animalabs/membrane';
18
+ import {
19
+ Membrane,
20
+ NativeFormatter,
21
+ OpenAIResponsesAPIAdapter,
22
+ OpenAIResponsesFormatter,
23
+ } from '@animalabs/membrane';
19
24
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
25
+ import { CallLedger } from './call-ledger.js';
20
26
  import { SettingsModule } from './modules/settings-module.js';
21
- import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, type Module, type MountConfig } from '@animalabs/agent-framework';
27
+ import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
22
28
  import { resolve, join, basename } from 'node:path';
23
29
  import { appendFile, mkdir, stat, rename } from 'node:fs/promises';
24
30
  import { readFileSync, existsSync } from 'node:fs';
@@ -61,15 +67,11 @@ const config = {
61
67
  // OAuth/Bearer token (e.g. a Claude subscription token). When set, it takes
62
68
  // precedence over the API key so requests never carry both auth schemes.
63
69
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
70
+ openaiApiKey: process.env.OPENAI_API_KEY,
64
71
  model: process.env.MODEL,
65
72
  dataDir: process.env.DATA_DIR || './data',
66
73
  };
67
74
 
68
- if (!config.apiKey && !config.authToken) {
69
- console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
70
- process.exit(1);
71
- }
72
-
73
75
  // ---------------------------------------------------------------------------
74
76
  // AppContext — mutable container for session switching
75
77
  // ---------------------------------------------------------------------------
@@ -138,14 +140,16 @@ async function createFramework(
138
140
  recipe: Recipe,
139
141
  agentName: string,
140
142
  settingsModule: SettingsModule,
143
+ callLedger: CallLedger | null,
141
144
  ): Promise<AgentFramework> {
142
145
  const model = config.model || recipe.agent.model || 'claude-opus-4-6';
143
146
  const modules = recipe.modules ?? {};
147
+ const timeZone = resolveTimeZone(recipe.agent.timezone);
144
148
 
145
149
  // -- Build module list --
146
150
  // SettingsModule is constructed in main() (before the adapter, so the
147
151
  // adapter can read its state for cross-cutting concerns like reasoning).
148
- const moduleInstances: Module[] = [new TuiModule(), new TimeModule(), settingsModule];
152
+ const moduleInstances: Module[] = [new TuiModule(), new TimeModule(timeZone), settingsModule];
149
153
 
150
154
  // Subagents
151
155
  let subagentModule: SubagentModule | null = null;
@@ -169,10 +173,11 @@ async function createFramework(
169
173
 
170
174
  // Fleet (cross-process child orchestration). Opt-in via recipe.
171
175
  if (modules.fleet === true) {
172
- moduleInstances.push(new FleetModule());
176
+ moduleInstances.push(new FleetModule({ timeZone }));
173
177
  } else if (modules.fleet && typeof modules.fleet === 'object') {
174
178
  const fleetCfg = modules.fleet;
175
179
  const fleetModuleConfig: FleetModuleConfig = {};
180
+ fleetModuleConfig.timeZone = timeZone;
176
181
  if (fleetCfg.children) {
177
182
  fleetModuleConfig.autoStart = fleetCfg.children.map((c) => {
178
183
  const entry: NonNullable<FleetModuleConfig['autoStart']>[number] = {
@@ -310,7 +315,7 @@ async function createFramework(
310
315
  // ability to spawn arbitrary commands via mcpl_deploy; see recipe.ts).
311
316
  let mcplAdminModule: McplAdminModule | null = null;
312
317
  if (modules.mcplAdmin === true) {
313
- mcplAdminModule = new McplAdminModule();
318
+ mcplAdminModule = new McplAdminModule({ timeZone });
314
319
  moduleInstances.push(mcplAdminModule);
315
320
  }
316
321
 
@@ -330,6 +335,7 @@ async function createFramework(
330
335
  basicAuth: webuiConfig.basicAuth,
331
336
  allowedOrigins: webuiConfig.allowedOrigins,
332
337
  observersPath,
338
+ ...(callLedger ? { callLedger } : {}),
333
339
  });
334
340
  moduleInstances.push(webUiModule);
335
341
  moduleInstances.push(new ObserversModule({ path: observersPath }));
@@ -384,7 +390,12 @@ async function createFramework(
384
390
  // Apply the agent overlay (mcpl-servers.agent.json): servers the agent
385
391
  // deployed for itself load unconditionally (no recipe opt-in), and
386
392
  // tombstones suppress recipe/file servers the agent unloaded.
387
- const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH);
393
+ const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => ({
394
+ ...server,
395
+ // Stdio MCPL children inherit a single agent-facing wall clock. Protocol
396
+ // timestamps remain UTC; only their rendered text uses this setting.
397
+ env: { ...(server.env ?? {}), AGENT_TIMEZONE: timeZone },
398
+ }));
388
399
 
389
400
  // No server augmentation needed — gate is wired via FrameworkConfig.gate
390
401
 
@@ -405,6 +416,7 @@ async function createFramework(
405
416
  compressionModel: strategyConfig?.compressionModel ?? model,
406
417
  autoTickOnNewMessage: true,
407
418
  maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
419
+ ...(strategyType === 'frontdesk' ? { timeZone } : {}),
408
420
  };
409
421
  // Forward optional tuning fields when set. The key list is typed
410
422
  // against `RecipeStrategy`, so an unknown field name is a compile
@@ -465,6 +477,20 @@ async function createFramework(
465
477
  maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
466
478
  contextBudgetTokens: recipe.agent.contextBudgetTokens,
467
479
  ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
480
+ ...(recipe.agent.provider === 'openai-responses' && {
481
+ providerParams: {
482
+ reasoning: {
483
+ effort: recipe.agent.responses?.reasoningEffort ?? 'high',
484
+ context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
485
+ },
486
+ ...(recipe.agent.responses?.compactThreshold ? {
487
+ context_management: [{
488
+ type: 'compaction',
489
+ compact_threshold: recipe.agent.responses.compactThreshold,
490
+ }],
491
+ } : {}),
492
+ },
493
+ }),
468
494
  strategy,
469
495
  ...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
470
496
  ...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
@@ -473,6 +499,7 @@ async function createFramework(
473
499
  modules: moduleInstances,
474
500
  mcplServers: finalServers,
475
501
  gate: gateOptions,
502
+ timeZone,
476
503
  });
477
504
 
478
505
  // Wire post-creation hooks
@@ -752,6 +779,16 @@ function countLines(path: string): number {
752
779
 
753
780
  async function main() {
754
781
  const recipe = await resolveRecipe();
782
+ const provider = recipe.agent.provider ?? 'anthropic';
783
+
784
+ if (provider === 'openai-responses' && !config.openaiApiKey) {
785
+ console.error('Missing OPENAI_API_KEY for recipe provider "openai-responses".');
786
+ process.exit(1);
787
+ }
788
+ if (provider === 'anthropic' && !config.apiKey && !config.authToken) {
789
+ console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
790
+ process.exit(1);
791
+ }
755
792
 
756
793
  // SettingsModule constructed early so the adapter can read its state for
757
794
  // cross-cutting concerns (currently: reasoning). It's wired into the
@@ -766,22 +803,34 @@ async function main() {
766
803
  config.dataDir,
767
804
  `llm-calls.${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`,
768
805
  );
806
+ const callLedger = provider === 'anthropic'
807
+ ? new CallLedger({
808
+ dataDir: config.dataDir,
809
+ defaultTtl: recipe.agent.cacheTtl ?? '5m',
810
+ })
811
+ : null;
769
812
  // OAuth (subscription) auth wins over API-key auth when both are present.
770
813
  // Subscription tokens (sk-ant-oat…) additionally require the oauth beta
771
814
  // header on every request.
772
- const adapter = new LoggingAnthropicAdapter(
773
- {
774
- ...(config.authToken
775
- ? {
776
- authToken: config.authToken,
777
- defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
778
- }
779
- : { apiKey: config.apiKey! }),
780
- baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
781
- },
782
- llmLogPath,
783
- () => settingsModule.getReasoning(),
784
- );
815
+ const adapter = provider === 'openai-responses'
816
+ ? new OpenAIResponsesAPIAdapter({
817
+ apiKey: config.openaiApiKey!,
818
+ baseURL: process.env.OPENAI_BASE_URL || undefined,
819
+ })
820
+ : new LoggingAnthropicAdapter(
821
+ {
822
+ ...(config.authToken
823
+ ? {
824
+ authToken: config.authToken,
825
+ defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
826
+ }
827
+ : { apiKey: config.apiKey! }),
828
+ baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
829
+ },
830
+ llmLogPath,
831
+ () => settingsModule.getReasoning(),
832
+ (record) => callLedger!.record(record),
833
+ );
785
834
 
786
835
  // Session management — resolved before Membrane construction so the
787
836
  // active session's import-source sidecar can contribute to agent-name
@@ -814,7 +863,9 @@ async function main() {
814
863
  const agentName = resolved.name;
815
864
 
816
865
  const membrane = new Membrane(adapter, {
817
- formatter: new NativeFormatter(),
866
+ formatter: provider === 'openai-responses'
867
+ ? new OpenAIResponsesFormatter()
868
+ : new NativeFormatter(),
818
869
  // Anchor the assistant role for internal callers that don't set
819
870
  // request.assistantParticipant themselves (autobio compression,
820
871
  // executeMerge). Mismatch here flips stored assistant turns to
@@ -823,7 +874,7 @@ async function main() {
823
874
  });
824
875
 
825
876
  const storePath = sessionManager.getStorePath(activeSession.id);
826
- const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule);
877
+ const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule, callLedger);
827
878
 
828
879
  // Build app context
829
880
  const app: AppContext = {
@@ -844,7 +895,7 @@ async function main() {
844
895
  // re-resolution would matter only if recipe.agent.name is absent
845
896
  // AND the user switches between imports that used different
846
897
  // --agent values; not the canonical flow.
847
- this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule);
898
+ this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule, callLedger);
848
899
  this.framework.start();
849
900
  this.userMessageCount = 0;
850
901
  resetBranchState(this.branchState);
@@ -27,11 +27,13 @@ import type {
27
27
  StreamCallbacks,
28
28
  } from '@animalabs/membrane';
29
29
  import { appendFileSync } from 'node:fs';
30
+ import { summarizeCacheControls, type ProviderCallRecord } from './call-ledger.js';
30
31
 
31
32
  /** Live read of the current reasoning setting. The host wires this to
32
33
  * `SettingsModule.getReasoning()` so toggles via the `settings--reasoning_*`
33
34
  * tools take effect on the next call without restart. */
34
35
  export type ReasoningGetter = () => { enabled: boolean; budgetTokens: number };
36
+ export type ProviderCallObserver = (record: ProviderCallRecord) => void;
35
37
 
36
38
  /** Exact first-system-block identity Anthropic requires on subscription
37
39
  * (sk-ant-oat…) OAuth traffic. Verified 2026-07-09: any other first block —
@@ -44,6 +46,7 @@ const OAUTH_SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for
44
46
  export class LoggingAnthropicAdapter extends AnthropicAdapter {
45
47
  private readonly logPath: string;
46
48
  private readonly getReasoning?: ReasoningGetter;
49
+ private readonly onCall?: ProviderCallObserver;
47
50
  /** True when authenticated with an OAuth/Bearer token instead of an API
48
51
  * key; requests then need the identity block prepended (see above). */
49
52
  private readonly oauthMode: boolean;
@@ -52,10 +55,12 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
52
55
  config: ConstructorParameters<typeof AnthropicAdapter>[0],
53
56
  logPath: string,
54
57
  getReasoning?: ReasoningGetter,
58
+ onCall?: ProviderCallObserver,
55
59
  ) {
56
60
  super(config);
57
61
  this.logPath = logPath;
58
62
  this.getReasoning = getReasoning;
63
+ this.onCall = onCall;
59
64
  this.oauthMode = Boolean(config?.authToken);
60
65
  }
61
66
 
@@ -115,15 +120,61 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
115
120
  }
116
121
  }
117
122
 
118
- private requestSummary(request: ProviderRequest): Record<string, unknown> {
123
+ private requestSummary(request: ProviderRequest, rawRequest?: unknown): Record<string, unknown> {
124
+ const cache = rawRequest ? summarizeCacheControls(rawRequest) : undefined;
119
125
  return {
120
126
  model: request.model,
121
127
  maxTokens: request.maxTokens,
122
128
  messages: request.messages.length,
123
129
  tools: request.tools?.length ?? 0,
130
+ ...(cache ? { cacheBreakpoints: cache.count, cacheTtls: cache.ttls } : {}),
124
131
  };
125
132
  }
126
133
 
134
+ private observeCall(
135
+ kind: 'complete' | 'stream',
136
+ timestamp: string,
137
+ durationMs: number,
138
+ request: ProviderRequest,
139
+ rawRequest: unknown,
140
+ response?: ProviderResponse,
141
+ error?: unknown,
142
+ ): void {
143
+ if (!this.onCall) return;
144
+ const cache = summarizeCacheControls(rawRequest);
145
+ const raw = (response as { raw?: Record<string, unknown> } | undefined)?.raw;
146
+ const rawUsage = raw?.usage as Record<string, unknown> | undefined;
147
+ const cacheCreation = rawUsage?.cache_creation as Record<string, unknown> | undefined;
148
+ const finite = (value: unknown): number | undefined =>
149
+ typeof value === 'number' && Number.isFinite(value) ? value : undefined;
150
+ const text = (value: unknown): string | undefined =>
151
+ typeof value === 'string' && value.length > 0 ? value : undefined;
152
+ try {
153
+ this.onCall({
154
+ timestamp,
155
+ kind,
156
+ durationMs,
157
+ model: request.model,
158
+ messages: request.messages.length,
159
+ inputTokens: response?.usage.inputTokens ?? 0,
160
+ outputTokens: response?.usage.outputTokens ?? 0,
161
+ cacheReadTokens: response?.usage.cacheReadTokens ?? 0,
162
+ cacheWriteTokens: response?.usage.cacheCreationTokens ?? 0,
163
+ cacheWrite5mTokens: finite(cacheCreation?.ephemeral_5m_input_tokens),
164
+ cacheWrite1hTokens: finite(cacheCreation?.ephemeral_1h_input_tokens),
165
+ cacheWriteBucketsAuthoritative: cacheCreation !== undefined,
166
+ inferenceGeo: text(rawUsage?.inference_geo) ?? text(raw?.inference_geo),
167
+ serviceTier: text(rawUsage?.service_tier) ?? text(raw?.service_tier),
168
+ cacheBreakpoints: cache.count,
169
+ cacheTtls: cache.ttls,
170
+ stopReason: response?.stopReason,
171
+ error: error instanceof Error ? error.message : error ? String(error) : undefined,
172
+ });
173
+ } catch {
174
+ // A dashboard observer is never allowed to affect provider traffic.
175
+ }
176
+ }
177
+
127
178
  private refusalRawRequest(response: ProviderResponse, rawRequest: unknown): unknown {
128
179
  const raw = (response as { raw?: { stop_reason?: string } }).raw;
129
180
  return raw?.stop_reason === 'refusal' ? rawRequest : undefined;
@@ -155,22 +206,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
155
206
  const wrapped = this.captureRawRequest(options, sink);
156
207
  try {
157
208
  const response = await super.complete(effective, wrapped);
209
+ const timestamp = new Date().toISOString();
210
+ const durationMs = Date.now() - t0;
158
211
  this.log({
159
212
  type: 'call', kind: 'complete',
160
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
161
- requestSummary: this.requestSummary(request),
213
+ timestamp, durationMs,
214
+ requestSummary: this.requestSummary(request, sink.rawRequest),
162
215
  rawRequest: this.refusalRawRequest(response, sink.rawRequest),
163
216
  rawResponse: (response as { raw?: unknown }).raw ?? null,
164
217
  });
218
+ this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, response);
165
219
  return response;
166
220
  } catch (err) {
221
+ const timestamp = new Date().toISOString();
222
+ const durationMs = Date.now() - t0;
167
223
  this.log({
168
224
  type: 'error', kind: 'complete',
169
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
170
- requestSummary: this.requestSummary(request),
225
+ timestamp, durationMs,
226
+ requestSummary: this.requestSummary(request, sink.rawRequest),
171
227
  rawRequest: sink.rawRequest,
172
228
  error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
173
229
  });
230
+ this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, undefined, err);
174
231
  throw err;
175
232
  }
176
233
  }
@@ -186,22 +243,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
186
243
  const wrapped = this.captureRawRequest(options, sink);
187
244
  try {
188
245
  const response = await super.stream(effective, callbacks, wrapped);
246
+ const timestamp = new Date().toISOString();
247
+ const durationMs = Date.now() - t0;
189
248
  this.log({
190
249
  type: 'call', kind: 'stream',
191
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
192
- requestSummary: this.requestSummary(request),
250
+ timestamp, durationMs,
251
+ requestSummary: this.requestSummary(request, sink.rawRequest),
193
252
  rawRequest: this.refusalRawRequest(response, sink.rawRequest),
194
253
  rawResponse: (response as { raw?: unknown }).raw ?? null,
195
254
  });
255
+ this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, response);
196
256
  return response;
197
257
  } catch (err) {
258
+ const timestamp = new Date().toISOString();
259
+ const durationMs = Date.now() - t0;
198
260
  this.log({
199
261
  type: 'error', kind: 'stream',
200
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
201
- requestSummary: this.requestSummary(request),
262
+ timestamp, durationMs,
263
+ requestSummary: this.requestSummary(request, sink.rawRequest),
202
264
  rawRequest: sink.rawRequest,
203
265
  error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
204
266
  });
267
+ this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, undefined, err);
205
268
  throw err;
206
269
  }
207
270
  }
@@ -29,6 +29,7 @@ import type {
29
29
  ToolCall,
30
30
  ToolResult,
31
31
  } from '@animalabs/agent-framework';
32
+ import { formatZonedDateTime, resolveTimeZone } from '@animalabs/agent-framework';
32
33
  import { spawn as spawnProcess, type ChildProcess } from 'node:child_process';
33
34
  import { connect as netConnect, type Socket } from 'node:net';
34
35
  import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync, realpathSync } from 'node:fs';
@@ -81,6 +82,8 @@ interface FleetEventSubscription {
81
82
  // ---------------------------------------------------------------------------
82
83
 
83
84
  export interface FleetModuleConfig {
85
+ /** IANA zone for wall-clock timestamps returned by fleet tools. */
86
+ timeZone?: string;
84
87
  /** Default subscription sent to children at handshake (default: ['*']). */
85
88
  defaultSubscription?: string[];
86
89
  /** Max events to retain per child for peek (default: 500). */
@@ -255,6 +258,7 @@ export class FleetModule implements Module {
255
258
  sigtermEscalationMs: config.sigtermEscalationMs ?? 5_000,
256
259
  childIndexPath: config.childIndexPath ?? process.argv[1] ?? '',
257
260
  childRuntimePath: config.childRuntimePath ?? process.execPath,
261
+ timeZone: resolveTimeZone(config.timeZone),
258
262
  };
259
263
 
260
264
  this.autoStartChildren = config.autoStart ?? [];
@@ -1014,7 +1018,12 @@ export class FleetModule implements Module {
1014
1018
  {
1015
1019
  detached: true,
1016
1020
  stdio: startupFd !== null ? ['ignore', startupFd, startupFd] : 'ignore',
1017
- env: { ...process.env, ...input.env, DATA_DIR: dataDir },
1021
+ env: {
1022
+ ...process.env,
1023
+ ...input.env,
1024
+ AGENT_TIMEZONE: this.config.timeZone,
1025
+ DATA_DIR: dataDir,
1026
+ },
1018
1027
  },
1019
1028
  );
1020
1029
  proc.unref();
@@ -1095,9 +1104,9 @@ export class FleetModule implements Module {
1095
1104
  name: c.name,
1096
1105
  status: c.status,
1097
1106
  pid: c.pid,
1098
- startedAt: c.startedAt,
1099
- exitedAt: c.exitedAt,
1100
- lastEventAt: c.lastEventAt,
1107
+ startedAt: formatZonedDateTime(c.startedAt, this.config.timeZone),
1108
+ exitedAt: c.exitedAt === null ? null : formatZonedDateTime(c.exitedAt, this.config.timeZone),
1109
+ lastEventAt: c.lastEventAt === null ? null : formatZonedDateTime(c.lastEventAt, this.config.timeZone),
1101
1110
  eventCount: c.events.length,
1102
1111
  }));
1103
1112
  return { success: true, data: list };
@@ -1120,9 +1129,10 @@ export class FleetModule implements Module {
1120
1129
  socketPath: c.socketPath,
1121
1130
  pid: c.pid,
1122
1131
  status: c.status,
1123
- startedAt: c.startedAt,
1124
- exitedAt: c.exitedAt,
1125
- lastEventAt: c.lastEventAt,
1132
+ startedAt: formatZonedDateTime(c.startedAt, this.config.timeZone),
1133
+ exitedAt: c.exitedAt === null ? null : formatZonedDateTime(c.exitedAt, this.config.timeZone),
1134
+ lastEventAt: c.lastEventAt === null ? null : formatZonedDateTime(c.lastEventAt, this.config.timeZone),
1135
+ timeZone: this.config.timeZone,
1126
1136
  exitCode: c.exitCode,
1127
1137
  exitReason: c.exitReason,
1128
1138
  eventCount: c.events.length,
@@ -33,6 +33,7 @@ import type {
33
33
  AgentFramework,
34
34
  McplServerConfig,
35
35
  } from '@animalabs/agent-framework';
36
+ import { resolveTimeZone } from '@animalabs/agent-framework';
36
37
  import {
37
38
  DEFAULT_CONFIG_PATH,
38
39
  DEFAULT_AGENT_OVERLAY_PATH,
@@ -44,6 +45,8 @@ import {
44
45
  } from '../mcpl-config.js';
45
46
 
46
47
  export interface McplAdminModuleConfig {
48
+ /** IANA zone propagated to newly deployed stdio servers. */
49
+ timeZone?: string;
47
50
  /** Path to the agent overlay file. Default: `mcpl-servers.agent.json` in cwd. */
48
51
  overlayPath?: string;
49
52
  /** Path to the human-owned server config file (read-only here). */
@@ -64,10 +67,12 @@ export class McplAdminModule implements Module {
64
67
  private framework: AgentFramework | null = null;
65
68
  private overlayPath: string;
66
69
  private configPath: string;
70
+ private timeZone: string;
67
71
 
68
72
  constructor(config?: McplAdminModuleConfig) {
69
73
  this.overlayPath = config?.overlayPath ?? DEFAULT_AGENT_OVERLAY_PATH;
70
74
  this.configPath = config?.configPath ?? DEFAULT_CONFIG_PATH;
75
+ this.timeZone = resolveTimeZone(config?.timeZone);
71
76
  }
72
77
 
73
78
  /** Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework). */
@@ -275,6 +280,7 @@ export class McplAdminModule implements Module {
275
280
  saveAgentOverlay(this.overlayPath, overlay);
276
281
 
277
282
  const config = resolveOverlayEntry(id, entry, this.overlayPath) as unknown as McplServerConfig;
283
+ config.env = { ...(config.env ?? {}), AGENT_TIMEZONE: this.timeZone };
278
284
 
279
285
  const alreadyLoaded = framework.listMcplServers().some(s => s.id === id);
280
286
  try {
@@ -15,21 +15,23 @@ import type {
15
15
  ToolCall,
16
16
  ToolResult,
17
17
  } from '@animalabs/agent-framework';
18
+ import { formatZonedDateTime, resolveTimeZone } from '@animalabs/agent-framework';
18
19
 
19
20
  interface TimeState {
20
21
  sessionStartAnnounced?: boolean;
21
22
  }
22
23
 
23
- function formatNow(date: Date = new Date()): {
24
+ export function formatNow(date: Date = new Date(), configuredTimeZone?: string): {
24
25
  iso: string;
25
26
  local: string;
26
27
  timezone: string;
27
28
  unixMs: number;
28
29
  } {
29
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
30
+ const timezone = resolveTimeZone(configuredTimeZone);
31
+ const local = formatZonedDateTime(date, timezone);
30
32
  return {
31
- iso: date.toISOString(),
32
- local: date.toString(),
33
+ iso: local.slice(0, local.indexOf(' [')),
34
+ local,
33
35
  timezone,
34
36
  unixMs: date.getTime(),
35
37
  };
@@ -39,6 +41,11 @@ export class TimeModule implements Module {
39
41
  readonly name = 'time';
40
42
 
41
43
  private ctx: ModuleContext | null = null;
44
+ private readonly timeZone: string;
45
+
46
+ constructor(timeZone?: string) {
47
+ this.timeZone = resolveTimeZone(timeZone);
48
+ }
42
49
 
43
50
  async start(ctx: ModuleContext): Promise<void> {
44
51
  this.ctx = ctx;
@@ -46,10 +53,9 @@ export class TimeModule implements Module {
46
53
  const state = ctx.getState<TimeState>() ?? {};
47
54
  if (state.sessionStartAnnounced) return;
48
55
 
49
- const now = formatNow();
56
+ const now = formatNow(new Date(), this.timeZone);
50
57
  const text =
51
- `The time at the start of this session is: ${now.iso} ` +
52
- `(local: ${now.local}, timezone: ${now.timezone}).`;
58
+ `The local time at the start of this session is ${now.local}.`;
53
59
 
54
60
  ctx.addMessage('user', [{ type: 'text', text }]);
55
61
  ctx.setState<TimeState>({ ...state, sessionStartAnnounced: true });
@@ -67,7 +73,7 @@ export class TimeModule implements Module {
67
73
  return [
68
74
  {
69
75
  name: 'now',
70
- description: 'Return the current wall-clock time as ISO 8601, local string, timezone, and unix milliseconds.',
76
+ description: 'Return the current wall-clock time in the agent-configured timezone, plus unix milliseconds.',
71
77
  inputSchema: {
72
78
  type: 'object',
73
79
  properties: {},
@@ -78,7 +84,7 @@ export class TimeModule implements Module {
78
84
 
79
85
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
80
86
  if (call.name === 'now') {
81
- return { success: true, data: formatNow() };
87
+ return { success: true, data: formatNow(new Date(), this.timeZone) };
82
88
  }
83
89
  return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
84
90
  }
@@ -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
 
@@ -2486,6 +2508,9 @@ export class WebUiModule implements Module {
2486
2508
  ...(sharedServer!.latestPerAgentCost.length > 0
2487
2509
  ? { perAgentCost: sharedServer!.latestPerAgentCost }
2488
2510
  : {}),
2511
+ ...(sharedServer!.latestCallLedger
2512
+ ? { callLedger: sharedServer!.latestCallLedger }
2513
+ : {}),
2489
2514
  };
2490
2515
  }
2491
2516
 
@@ -283,12 +283,25 @@ export function filterEntryForScopes(
283
283
  /** Project a welcome through an observer's scope mask. Without 'messages'
284
284
  * the entire conversation payload (entries + agent trees, which carry
285
285
  * streaming buffers) is emptied — a health/ops observer like the fleet hub
286
- * gets structure and usage, never content. */
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. */
287
292
  export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>): WelcomeMessage {
288
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
+ }
289
302
  if (!scopes.has('messages')) {
290
303
  return {
291
- ...welcome,
304
+ ...scoped,
292
305
  messages: [],
293
306
  history: { startIndex: welcome.history.totalCount, totalCount: welcome.history.totalCount },
294
307
  localTree: emptyTree,
@@ -296,7 +309,7 @@ export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>
296
309
  };
297
310
  }
298
311
  return {
299
- ...welcome,
312
+ ...scoped,
300
313
  messages: welcome.messages
301
314
  .map((e) => filterEntryForScopes(e, scopes))
302
315
  .filter((e): e is WelcomeMessageEntry => e !== null),