@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
@@ -7,22 +7,22 @@
7
7
  * have to remember (and keep in sync) all three:
8
8
  *
9
9
  * debounced (wake on every message, batched):
10
- * 1. subscribe to the channel's ambient traffic (`<toolPrefix>--subscribe_channel`)
10
+ * 1. open the channel (`channel_open`)
11
11
  * 2. upsert a per-channel gate policy that DEBOUNCES the ambient tag
12
12
  * (framework.addGatePolicy → gate.json, hot-reloaded) so a burst wakes the
13
13
  * agent once after it settles
14
14
  * 3. pin subscription-gc to "off" for the channel, else a chatty channel
15
- * auto-unsubscribes itself at the idle-char limit and silently leaves
15
+ * auto-closes itself at the idle-char limit and silently leaves
16
16
  * debounced mode
17
17
  *
18
18
  * mentions (revert to mentions/DMs only):
19
19
  * 1. remove the per-channel debounce gate policy
20
- * 2. unsubscribe from ambient traffic
20
+ * 2. close the channel
21
21
  * 3. restore subscription-gc to its default limit
22
22
  *
23
23
  * The SAME `channelId` string is used for all three subsystems — it's the id the
24
24
  * gate sees on an incoming event (`GateEventInfo.channelId`) and the id
25
- * subscription-gc counts/unsubscribes, so they line up by construction (this is
25
+ * subscription-gc counts/closes, so they line up by construction (this is
26
26
  * exactly how SubscriptionGcModule already operates).
27
27
  *
28
28
  * Steps are best-effort with per-step reporting rather than a hard transaction:
@@ -45,7 +45,7 @@ import type { AgentFramework } from '@animalabs/agent-framework';
45
45
  export interface ChannelModeConfig {
46
46
  /** MCPL server id that owns subscriptions (default 'discord'). */
47
47
  serverId?: string;
48
- /** Tool-name prefix for that server (default `mcpl--<serverId>`). */
48
+ /** @deprecated Lifecycle tools are host-generic; retained for config compatibility. */
49
49
  toolPrefix?: string;
50
50
  /** subscription-gc module name, for pinning the idle limit (default 'subscription-gc'). */
51
51
  gcModuleName?: string;
@@ -69,13 +69,11 @@ export class ChannelModeModule implements Module {
69
69
  private callSeq = 0;
70
70
 
71
71
  private readonly serverId: string;
72
- private readonly toolPrefix: string;
73
72
  private readonly gcModuleName: string;
74
73
  private readonly defaultDebounceMs: number;
75
74
 
76
75
  constructor(config: ChannelModeConfig = {}) {
77
76
  this.serverId = config.serverId ?? 'discord';
78
- this.toolPrefix = config.toolPrefix ?? `mcpl--${this.serverId}`;
79
77
  this.gcModuleName = config.gcModuleName ?? 'subscription-gc';
80
78
  this.defaultDebounceMs =
81
79
  typeof config.defaultDebounceMs === 'number' && config.defaultDebounceMs > 0
@@ -103,12 +101,12 @@ export class ChannelModeModule implements Module {
103
101
  name: 'set_channel_mode',
104
102
  description:
105
103
  'Set how a channel gets your attention, in one step. ' +
106
- '`mode: "debounced"` = wake on EVERY message but batched: it subscribes ' +
104
+ '`mode: "debounced"` = wake on EVERY message but batched: it opens ' +
107
105
  'to the channel, adds a gate rule that debounces its ambient traffic ' +
108
106
  '(one wake after the burst settles), and pins the channel so it is never ' +
109
- 'auto-unsubscribed for being chatty. `mode: "mentions"` reverts to ' +
110
- 'mentions/DMs only: it removes that gate rule, unsubscribes, and restores ' +
111
- 'the default auto-unsubscribe limit. `debounceMs` (100–300000, default ' +
107
+ 'auto-closed for being chatty. `mode: "mentions"` reverts to ' +
108
+ 'direct-address-only: it removes that gate rule, closes, and restores ' +
109
+ 'the default auto-close limit. `debounceMs` (100–300000, default ' +
112
110
  `${this.defaultDebounceMs}) sets the quiet window for debounced mode.`,
113
111
  inputSchema: {
114
112
  type: 'object',
@@ -182,7 +180,7 @@ export class ChannelModeModule implements Module {
182
180
  const steps: StepResult[] = [];
183
181
 
184
182
  // 1. Subscribe to ambient traffic.
185
- steps.push(await this.callToolStep('subscribe', `${this.toolPrefix}--subscribe_channel`, { channelId }));
183
+ steps.push(await this.callToolStep('open', 'channel_open', { channelId, serverId: this.serverId }));
186
184
 
187
185
  // 2. Upsert the per-channel ambient debounce policy (prepend so it beats a
188
186
  // broad ambient defer/debounce — first match wins). Validation lives in
@@ -201,7 +199,7 @@ export class ChannelModeModule implements Module {
201
199
  steps.push({ step: 'gate-policy', ok: false, detail: errMsg(err) });
202
200
  }
203
201
 
204
- // 3. Pin subscription-gc off so a chatty channel doesn't auto-unsubscribe
202
+ // 3. Pin subscription-gc off so a chatty channel doesn't auto-close
205
203
  // itself back out of debounced mode.
206
204
  steps.push(
207
205
  await this.callToolStep('gc-off', `${this.gcModuleName}--set_channel_idle_limit`, {
@@ -224,10 +222,10 @@ export class ChannelModeModule implements Module {
224
222
  steps.push({ step: 'gate-policy', ok: false, detail: errMsg(err) });
225
223
  }
226
224
 
227
- // 2. Unsubscribe from ambient traffic.
228
- steps.push(await this.callToolStep('unsubscribe', `${this.toolPrefix}--unsubscribe_channel`, { channelId }));
225
+ // 2. Close ambient traffic through the generic host lifecycle.
226
+ steps.push(await this.callToolStep('close', 'channel_close', { channelId, serverId: this.serverId }));
229
227
 
230
- // 3. Restore the default auto-unsubscribe limit.
228
+ // 3. Restore the default auto-close limit.
231
229
  steps.push(
232
230
  await this.callToolStep('gc-default', `${this.gcModuleName}--set_channel_idle_limit`, {
233
231
  channelId,
@@ -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). */
@@ -109,16 +114,6 @@ export class McplAdminModule implements Module {
109
114
  token: { type: 'string', description: 'Bearer token for WebSocket auth.' },
110
115
  toolPrefix: { type: 'string', description: 'Tool namespace prefix. Default: mcpl--<id>.' },
111
116
  reconnect: { type: 'boolean', description: 'Auto-reconnect on transport failure (default false). Note: does NOT respawn a crashed child — use mcpl_restart for that.' },
112
- channelSubscription: {
113
- type: 'string',
114
- enum: ['auto', 'manual'],
115
- description: "Channel auto-open policy: 'auto' (default) opens every channel the server registers; 'manual' opens none. For an allow-list, use channelAllowlist instead.",
116
- },
117
- channelAllowlist: {
118
- type: 'array',
119
- items: { type: 'string' },
120
- description: 'Allow-list of channel ids to auto-open (overrides channelSubscription).',
121
- },
122
117
  enabledFeatureSets: { type: 'array', items: { type: 'string' } },
123
118
  disabledFeatureSets: { type: 'array', items: { type: 'string' } },
124
119
  enabledTools: { type: 'array', items: { type: 'string' }, description: 'Tool allow-list (bare names, * wildcard).' },
@@ -258,11 +253,6 @@ export class McplAdminModule implements Module {
258
253
  if (typeof input.token === 'string') entry.token = input.token;
259
254
  if (typeof input.toolPrefix === 'string') entry.toolPrefix = input.toolPrefix;
260
255
  if (typeof input.reconnect === 'boolean') entry.reconnect = input.reconnect;
261
- if (Array.isArray(input.channelAllowlist)) {
262
- entry.channelSubscription = input.channelAllowlist.map(String);
263
- } else if (input.channelSubscription === 'auto' || input.channelSubscription === 'manual') {
264
- entry.channelSubscription = input.channelSubscription;
265
- }
266
256
  if (Array.isArray(input.enabledFeatureSets)) entry.enabledFeatureSets = input.enabledFeatureSets.map(String);
267
257
  if (Array.isArray(input.disabledFeatureSets)) entry.disabledFeatureSets = input.disabledFeatureSets.map(String);
268
258
  if (Array.isArray(input.enabledTools)) entry.enabledTools = input.enabledTools.map(String);
@@ -275,6 +265,7 @@ export class McplAdminModule implements Module {
275
265
  saveAgentOverlay(this.overlayPath, overlay);
276
266
 
277
267
  const config = resolveOverlayEntry(id, entry, this.overlayPath) as unknown as McplServerConfig;
268
+ config.env = { ...(config.env ?? {}), AGENT_TIMEZONE: this.timeZone };
278
269
 
279
270
  const alreadyLoaded = framework.listMcplServers().some(s => s.id === id);
280
271
  try {
@@ -3,10 +3,11 @@
3
3
  * itself. State persists to chronicle via ModuleContext (`setState`/`getState`)
4
4
  * so changes survive restarts.
5
5
  *
6
- * First domain: **reasoning** (Anthropic extended thinking). Tools:
7
- * - reasoning_status → show current state
8
- * - reasoning_enable {budgetTokens?} → turn on with optional budget
9
- * - reasoning_disable → turn off
6
+ * First domain: **reasoning** (Anthropic extended thinking), surfaced to the
7
+ * agent as `agent_settings` fields (reasoning_enabled /
8
+ * reasoning_budget_tokens) via the framework's settings-extension hook
9
+ * NOT as standalone tools (the former reasoning_status/enable/disable trio
10
+ * was tool bloat for one boolean + number).
10
11
  *
11
12
  * The host's adapter wrapper (LoggingAnthropicAdapter) reads `getReasoning()`
12
13
  * on each call and injects `thinking: {type:'enabled', budget_tokens: N}` into
@@ -69,75 +70,98 @@ export class SettingsModule implements Module {
69
70
  return { ...this.state.reasoning };
70
71
  }
71
72
 
73
+ /** No standalone tools — reasoning controls live inside the framework's
74
+ * `agent_settings` tool via getAgentSettingsExtension() below. The three
75
+ * former reasoning_* tools were pure tool bloat for one boolean + number. */
72
76
  getTools(): ToolDefinition[] {
73
- return [
74
- {
75
- name: 'reasoning_status',
76
- description:
77
- 'Show whether extended thinking (reasoning) is currently enabled and the token budget.',
78
- inputSchema: { type: 'object', properties: {} },
79
- },
80
- {
81
- name: 'reasoning_enable',
82
- description:
83
- 'Enable extended thinking (reasoning) on your subsequent inference calls. ' +
84
- 'Optional budgetTokens in tokens (default: current value, initially 8192; min 1024).',
85
- inputSchema: {
86
- type: 'object',
87
- properties: {
88
- budgetTokens: {
89
- type: 'number',
90
- description: 'Token budget for thinking blocks (min 1024).',
91
- },
92
- },
93
- },
94
- },
95
- {
96
- name: 'reasoning_disable',
97
- description:
98
- 'Disable extended thinking (reasoning). Subsequent inference calls will not request thinking blocks.',
99
- inputSchema: { type: 'object', properties: {} },
100
- },
101
- ];
77
+ return [];
102
78
  }
103
79
 
104
80
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
105
- const input = (call.input ?? {}) as Record<string, unknown>;
106
- switch (call.name) {
107
- case 'reasoning_status':
108
- return ok(this.reasoningStatusText());
109
- case 'reasoning_enable': {
110
- const budget =
111
- typeof input.budgetTokens === 'number'
112
- ? Math.max(1024, Math.round(input.budgetTokens))
113
- : this.state.reasoning.budgetTokens;
114
- this.state.reasoning = { enabled: true, budgetTokens: budget };
81
+ return {
82
+ success: false,
83
+ error:
84
+ `Unknown tool: ${call.name}. Reasoning controls moved into agent_settings ` +
85
+ `(fields reasoning_enabled / reasoning_budget_tokens).`,
86
+ isError: true,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Declare reasoning as an agent_settings extension: the framework merges
92
+ * these fields into the agent_settings tool and routes get/update/reset for
93
+ * them back here. Framework versions predating the hook simply never call
94
+ * this — in that case reasoning is temporarily not agent-tunable (the
95
+ * adapter still honors persisted state).
96
+ */
97
+ getAgentSettingsExtension(): {
98
+ properties: Record<string, unknown>;
99
+ keys: string[];
100
+ get(agentName: string): Record<string, unknown>;
101
+ update(agentName: string, patch: Record<string, unknown>): Record<string, unknown>;
102
+ reset(agentName: string, keys?: string[]): Record<string, unknown>;
103
+ } {
104
+ return {
105
+ properties: {
106
+ reasoning_enabled: {
107
+ type: 'boolean',
108
+ description:
109
+ 'Extended thinking (reasoning) on subsequent inference calls.',
110
+ },
111
+ reasoning_budget_tokens: {
112
+ type: 'number',
113
+ description: 'Token budget for thinking blocks (min 1024).',
114
+ },
115
+ },
116
+ keys: ['reasoning_enabled', 'reasoning_budget_tokens'],
117
+ get: () => this.reasoningSettingsView(),
118
+ update: (_agentName, patch) => {
119
+ const next = { ...this.state.reasoning };
120
+ if (patch.reasoning_enabled !== undefined) {
121
+ if (typeof patch.reasoning_enabled !== 'boolean') {
122
+ throw new Error('reasoning_enabled must be a boolean');
123
+ }
124
+ next.enabled = patch.reasoning_enabled;
125
+ }
126
+ if (patch.reasoning_budget_tokens !== undefined) {
127
+ const budget = Number(patch.reasoning_budget_tokens);
128
+ if (!Number.isFinite(budget)) {
129
+ throw new Error('reasoning_budget_tokens must be a number');
130
+ }
131
+ next.budgetTokens = Math.max(1024, Math.round(budget));
132
+ }
133
+ this.state.reasoning = next;
115
134
  this.ctx?.setState(this.state);
116
- return ok('Reasoning enabled. ' + this.reasoningStatusText());
117
- }
118
- case 'reasoning_disable':
119
- this.state.reasoning = { ...this.state.reasoning, enabled: false };
135
+ return this.reasoningSettingsView();
136
+ },
137
+ reset: (_agentName, keys) => {
138
+ const all = !keys || keys.length === 0;
139
+ if (all || keys?.includes('reasoning_enabled')) {
140
+ this.state.reasoning.enabled = DEFAULTS.reasoning.enabled;
141
+ }
142
+ if (all || keys?.includes('reasoning_budget_tokens')) {
143
+ this.state.reasoning.budgetTokens = DEFAULTS.reasoning.budgetTokens;
144
+ }
120
145
  this.ctx?.setState(this.state);
121
- return ok('Reasoning disabled. ' + this.reasoningStatusText());
122
- default:
123
- return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
124
- }
146
+ return this.reasoningSettingsView();
147
+ },
148
+ };
149
+ }
150
+
151
+ /** The extension's wire view of reasoning state (flat agent_settings keys). */
152
+ private reasoningSettingsView(): Record<string, unknown> {
153
+ return {
154
+ reasoning_enabled: this.state.reasoning.enabled,
155
+ reasoning_budget_tokens: this.state.reasoning.budgetTokens,
156
+ };
125
157
  }
126
158
 
127
159
  async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
128
160
  return {};
129
161
  }
130
162
 
131
- private reasoningStatusText(): string {
132
- const r = this.state.reasoning;
133
- return `reasoning=${r.enabled ? 'ENABLED' : 'disabled'}, budgetTokens=${r.budgetTokens}`;
134
- }
135
163
  }
136
164
 
137
165
  function clone<T>(x: T): T {
138
166
  return JSON.parse(JSON.stringify(x)) as T;
139
167
  }
140
-
141
- function ok(message: string): ToolResult {
142
- return { success: true, data: { message }, isError: false };
143
- }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SubscriptionGcModule — auto-unsubscribe noisy ambient Discord channels.
2
+ * SubscriptionGcModule — auto-close noisy ordinary channel traffic.
3
3
  *
4
4
  * Problem: a subscribed channel delivers ambient (non-mention, non-DM) messages
5
5
  * into the agent's context whether or not the agent ever acts on them. A busy
@@ -12,7 +12,7 @@
12
12
  * the agent saw every subscribed channel in that context (compressed if
13
13
  * large; nothing is dropped), so the slate is clean.
14
14
  * - The instant a channel's since-last-activation count crosses its limit, it
15
- * is auto-unsubscribed via the surface's `unsubscribe_channel` tool. This
15
+ * is auto-closed via the host's generic `channel_close` tool. This
16
16
  * bounds any one channel's context pollution to ~limit characters.
17
17
  *
18
18
  * Everything is durable across restarts: counters + per-channel overrides
@@ -36,15 +36,15 @@ import type {
36
36
  } from '@animalabs/agent-framework';
37
37
 
38
38
  export interface SubscriptionGcConfig {
39
- /** Default ambient-character budget per channel before auto-unsubscribe. */
39
+ /** Default ambient-character budget per channel before auto-close. */
40
40
  defaultLimitChars?: number;
41
41
  /** MCPL server id that owns subscriptions (default 'discord'). */
42
42
  serverId?: string;
43
- /** Tool-name prefix for that server (default `mcpl--<serverId>`). */
43
+ /** @deprecated Lifecycle tools are host-generic; retained for config compatibility. */
44
44
  toolPrefix?: string;
45
45
  }
46
46
 
47
- /** `'off'` pins a channel (never auto-unsubscribe); a number overrides the
47
+ /** `'off'` pins a channel (never auto-close); a number overrides the
48
48
  * default limit; absence falls back to the default. */
49
49
  type LimitOverride = number | 'off';
50
50
 
@@ -66,7 +66,6 @@ export class SubscriptionGcModule implements Module {
66
66
 
67
67
  private readonly defaultLimitChars: number;
68
68
  private readonly serverId: string;
69
- private readonly toolPrefix: string;
70
69
 
71
70
  private state: GcState = { overrides: {}, counters: {} };
72
71
 
@@ -76,7 +75,6 @@ export class SubscriptionGcModule implements Module {
76
75
  ? Math.round(config.defaultLimitChars)
77
76
  : 20000;
78
77
  this.serverId = config.serverId ?? 'discord';
79
- this.toolPrefix = config.toolPrefix ?? `mcpl--${this.serverId}`;
80
78
  }
81
79
 
82
80
  async start(ctx: ModuleContext): Promise<void> {
@@ -121,9 +119,9 @@ export class SubscriptionGcModule implements Module {
121
119
  description:
122
120
  'Set how many characters of ambient (non-mention, non-DM) traffic a ' +
123
121
  'subscribed channel may emit between your activations before it is ' +
124
- 'auto-unsubscribed. `limit` is a number of characters, "default" to ' +
122
+ 'auto-closed. `limit` is a number of characters, "default" to ' +
125
123
  `use the global default (${this.defaultLimitChars}), or "off" to ` +
126
- 'never auto-unsubscribe this channel.',
124
+ 'never auto-close this channel.',
127
125
  inputSchema: {
128
126
  type: 'object',
129
127
  properties: {
@@ -139,7 +137,7 @@ export class SubscriptionGcModule implements Module {
139
137
  {
140
138
  name: 'list_channel_idle_limits',
141
139
  description:
142
- 'Show the auto-unsubscribe configuration: the global default limit, ' +
140
+ 'Show the auto-close configuration: the global default limit, ' +
143
141
  'per-channel overrides, and the current since-last-activation ambient ' +
144
142
  'character counts per channel.',
145
143
  inputSchema: { type: 'object', properties: {} },
@@ -169,7 +167,7 @@ export class SubscriptionGcModule implements Module {
169
167
  desc = `default (${this.defaultLimitChars})`;
170
168
  } else if (limit === 'off') {
171
169
  this.state.overrides[channelId] = 'off';
172
- desc = 'off (never auto-unsubscribe)';
170
+ desc = 'off (never auto-close)';
173
171
  } else if (Number.isFinite(numeric) && numeric > 0) {
174
172
  this.state.overrides[channelId] = Math.round(numeric);
175
173
  desc = `${Math.round(numeric)} characters`;
@@ -207,14 +205,14 @@ export class SubscriptionGcModule implements Module {
207
205
  const next = (this.state.counters[channelId] ?? 0) + chars;
208
206
 
209
207
  if (limit !== Infinity && next > limit) {
210
- // Cross the threshold → unsubscribe and clear the counter.
208
+ // Cross the threshold → close and clear the counter.
211
209
  delete this.state.counters[channelId];
212
210
  this.persistNow();
213
211
  const result = await this.ctx
214
212
  ?.callTool({
215
213
  id: `gc-unsub-${this.callSeq++}`,
216
- name: `${this.toolPrefix}--unsubscribe_channel`,
217
- input: { channelId },
214
+ name: 'channel_close',
215
+ input: { channelId, serverId: this.serverId },
218
216
  })
219
217
  .catch((err: unknown) => ({
220
218
  success: false,
@@ -231,18 +229,17 @@ export class SubscriptionGcModule implements Module {
231
229
  {
232
230
  type: 'text',
233
231
  text:
234
- `[subscription-gc] Auto-unsubscribed from channel ${channelId}: it emitted ` +
232
+ `[subscription-gc] Auto-closed channel ${channelId}: it emitted ` +
235
233
  `over ${limit} characters of ambient traffic since your last activation without ` +
236
- `engagement. Mentions and DMs there still reach you. Resubscribe with ` +
237
- `${this.toolPrefix}--subscribe_channel, raise/disable its limit with ` +
238
- `set_channel_idle_limit, or check what you're missing with ${this.toolPrefix}--channel_missed.`,
234
+ `engagement. Direct addresses there still reach you. Reopen with channel_open, ` +
235
+ `or raise/disable its limit with set_channel_idle_limit.`,
239
236
  },
240
237
  ],
241
238
  },
242
239
  ],
243
240
  };
244
241
  }
245
- // Unsubscribe failed — keep the channel counted so we retry on the next
242
+ // Close failed — keep the channel counted so we retry on the next
246
243
  // ambient message rather than silently giving up.
247
244
  this.state.counters[channelId] = next;
248
245
  this.persistNow();
@@ -270,7 +267,7 @@ export class SubscriptionGcModule implements Module {
270
267
  if (event.serverId !== this.serverId) return null;
271
268
  const origin = (event.origin ?? {}) as Record<string, unknown>;
272
269
  if (origin.isMention || origin.isDM) return null;
273
- const channelId = origin.channelId;
270
+ const channelId = origin.mcplChannelId ?? origin.channelId;
274
271
  if (typeof channelId !== 'string' || channelId.length === 0) return null;
275
272
  return { channelId, chars: textLen(event.content) };
276
273
  }
@@ -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
  }