@animalabs/agent-framework 0.5.6 → 0.6.0

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 (177) hide show
  1. package/dist/src/agent.d.ts +20 -4
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +45 -14
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +220 -5
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1489 -69
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +71 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +292 -9
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  14. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script-worker.js +69 -0
  16. package/dist/src/gate/gate-script-worker.js.map +1 -0
  17. package/dist/src/gate/gate-script.d.ts +44 -0
  18. package/dist/src/gate/gate-script.d.ts.map +1 -0
  19. package/dist/src/gate/gate-script.js +164 -0
  20. package/dist/src/gate/gate-script.js.map +1 -0
  21. package/dist/src/gate/types.d.ts +35 -3
  22. package/dist/src/gate/types.d.ts.map +1 -1
  23. package/dist/src/index.d.ts +2 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +2 -0
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/mcpl/channel-registry.d.ts +93 -1
  28. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  29. package/dist/src/mcpl/channel-registry.js +260 -6
  30. package/dist/src/mcpl/channel-registry.js.map +1 -1
  31. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  32. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  33. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  34. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  35. package/dist/src/mcpl/conversation-router.d.ts +161 -0
  36. package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
  37. package/dist/src/mcpl/conversation-router.js +179 -0
  38. package/dist/src/mcpl/conversation-router.js.map +1 -0
  39. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  40. package/dist/src/mcpl/feature-set-manager.js +10 -1
  41. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  42. package/dist/src/mcpl/index.d.ts +2 -0
  43. package/dist/src/mcpl/index.d.ts.map +1 -1
  44. package/dist/src/mcpl/index.js +2 -0
  45. package/dist/src/mcpl/index.js.map +1 -1
  46. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  47. package/dist/src/mcpl/push-handler.js +1 -0
  48. package/dist/src/mcpl/push-handler.js.map +1 -1
  49. package/dist/src/mcpl/server-connection.d.ts +35 -30
  50. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  51. package/dist/src/mcpl/server-connection.js +149 -206
  52. package/dist/src/mcpl/server-connection.js.map +1 -1
  53. package/dist/src/mcpl/transport.d.ts +98 -0
  54. package/dist/src/mcpl/transport.d.ts.map +1 -0
  55. package/dist/src/mcpl/transport.js +281 -0
  56. package/dist/src/mcpl/transport.js.map +1 -0
  57. package/dist/src/mcpl/types.d.ts +42 -2
  58. package/dist/src/mcpl/types.d.ts.map +1 -1
  59. package/dist/src/mcpl/types.js.map +1 -1
  60. package/dist/src/module-registry.d.ts +5 -1
  61. package/dist/src/module-registry.d.ts.map +1 -1
  62. package/dist/src/module-registry.js +16 -2
  63. package/dist/src/module-registry.js.map +1 -1
  64. package/dist/src/modules/discord/index.d.ts +10 -0
  65. package/dist/src/modules/discord/index.d.ts.map +1 -1
  66. package/dist/src/modules/discord/index.js +83 -0
  67. package/dist/src/modules/discord/index.js.map +1 -1
  68. package/dist/src/prose-segments.d.ts +18 -0
  69. package/dist/src/prose-segments.d.ts.map +1 -0
  70. package/dist/src/prose-segments.js +39 -0
  71. package/dist/src/prose-segments.js.map +1 -0
  72. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  73. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  74. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  75. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  76. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  77. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  78. package/dist/src/runtime/liveness-watchdog.js +106 -0
  79. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  80. package/dist/src/tool-result-history.d.ts +17 -0
  81. package/dist/src/tool-result-history.d.ts.map +1 -0
  82. package/dist/src/tool-result-history.js +60 -0
  83. package/dist/src/tool-result-history.js.map +1 -0
  84. package/dist/src/types/agent.d.ts +21 -0
  85. package/dist/src/types/agent.d.ts.map +1 -1
  86. package/dist/src/types/framework.d.ts +22 -0
  87. package/dist/src/types/framework.d.ts.map +1 -1
  88. package/dist/src/types/module.d.ts +27 -0
  89. package/dist/src/types/module.d.ts.map +1 -1
  90. package/dist/src/types/trace.d.ts +28 -0
  91. package/dist/src/types/trace.d.ts.map +1 -1
  92. package/dist/test/channel-registry-routing.test.d.ts +2 -0
  93. package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
  94. package/dist/test/channel-registry-routing.test.js +182 -0
  95. package/dist/test/channel-registry-routing.test.js.map +1 -0
  96. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  97. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  98. package/dist/test/checkpoint-attribution.test.js +40 -0
  99. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  100. package/dist/test/conversation-router.test.d.ts +2 -0
  101. package/dist/test/conversation-router.test.d.ts.map +1 -0
  102. package/dist/test/conversation-router.test.js +160 -0
  103. package/dist/test/conversation-router.test.js.map +1 -0
  104. package/dist/test/conversation-routing.test.d.ts +7 -0
  105. package/dist/test/conversation-routing.test.d.ts.map +1 -0
  106. package/dist/test/conversation-routing.test.js +203 -0
  107. package/dist/test/conversation-routing.test.js.map +1 -0
  108. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  109. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  110. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  111. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  112. package/dist/test/event-gate-script.test.d.ts +2 -0
  113. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  114. package/dist/test/event-gate-script.test.js +95 -0
  115. package/dist/test/event-gate-script.test.js.map +1 -0
  116. package/dist/test/event-gate-tags.test.d.ts +2 -0
  117. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  118. package/dist/test/event-gate-tags.test.js +76 -0
  119. package/dist/test/event-gate-tags.test.js.map +1 -0
  120. package/dist/test/framework.test.js +73 -1
  121. package/dist/test/framework.test.js.map +1 -1
  122. package/dist/test/helpers/mock-membrane.d.ts +41 -0
  123. package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
  124. package/dist/test/helpers/mock-membrane.js +161 -0
  125. package/dist/test/helpers/mock-membrane.js.map +1 -0
  126. package/dist/test/inference-failure-observability.test.d.ts +2 -0
  127. package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
  128. package/dist/test/inference-failure-observability.test.js +75 -0
  129. package/dist/test/inference-failure-observability.test.js.map +1 -0
  130. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  131. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  132. package/dist/test/liveness-watchdog.test.js +78 -0
  133. package/dist/test/liveness-watchdog.test.js.map +1 -0
  134. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  135. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  136. package/dist/test/mcpl-ws-transport.test.js +155 -0
  137. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  138. package/dist/test/prose-segments.test.d.ts +2 -0
  139. package/dist/test/prose-segments.test.d.ts.map +1 -0
  140. package/dist/test/prose-segments.test.js +52 -0
  141. package/dist/test/prose-segments.test.js.map +1 -0
  142. package/dist/test/tool-result-history.test.d.ts +7 -0
  143. package/dist/test/tool-result-history.test.d.ts.map +1 -0
  144. package/dist/test/tool-result-history.test.js +77 -0
  145. package/dist/test/tool-result-history.test.js.map +1 -0
  146. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  147. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  148. package/dist/test/trunk-channel-routing.test.js +175 -0
  149. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  150. package/dist/tsconfig.tsbuildinfo +1 -1
  151. package/package.json +3 -3
  152. package/src/agent.ts +53 -18
  153. package/src/framework.ts +1685 -71
  154. package/src/gate/event-gate.ts +297 -9
  155. package/src/gate/gate-script-worker.ts +73 -0
  156. package/src/gate/gate-script.ts +180 -0
  157. package/src/gate/types.ts +36 -3
  158. package/src/index.ts +12 -0
  159. package/src/mcpl/channel-registry.ts +331 -6
  160. package/src/mcpl/checkpoint-manager.ts +16 -0
  161. package/src/mcpl/conversation-router.ts +284 -0
  162. package/src/mcpl/feature-set-manager.ts +14 -1
  163. package/src/mcpl/index.ts +12 -0
  164. package/src/mcpl/push-handler.ts +1 -0
  165. package/src/mcpl/server-connection.ts +157 -224
  166. package/src/mcpl/transport.ts +307 -0
  167. package/src/mcpl/types.ts +49 -2
  168. package/src/module-registry.ts +25 -2
  169. package/src/modules/discord/index.ts +93 -0
  170. package/src/prose-segments.ts +42 -0
  171. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  172. package/src/runtime/liveness-watchdog.ts +122 -0
  173. package/src/tool-result-history.ts +57 -0
  174. package/src/types/agent.ts +21 -0
  175. package/src/types/framework.ts +24 -0
  176. package/src/types/module.ts +29 -0
  177. package/src/types/trace.ts +36 -0
package/src/framework.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { JsStore } from '@animalabs/chronicle';
3
- import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult } from '@animalabs/membrane';
3
+ import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
4
4
  import { MembraneError } from '@animalabs/membrane';
5
5
  import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
6
6
  import type {
@@ -50,7 +50,10 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
50
50
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
51
51
  import { InferenceRouter } from './mcpl/inference-router.js';
52
52
  import { ChannelRegistry } from './mcpl/channel-registry.js';
53
+ import { ConversationRouter } from './mcpl/conversation-router.js';
53
54
  import { safeSlice } from './safe-slice.js';
55
+ import { toolResultDataToHistoryString } from './tool-result-history.js';
56
+ import { splitProseSegments } from './prose-segments.js';
54
57
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
55
58
  import { isToolAllowed } from './mcpl/tool-policy.js';
56
59
  import { EventGate } from './gate/event-gate.js';
@@ -74,6 +77,7 @@ import type {
74
77
  import type { ContextInjection } from '@animalabs/context-manager';
75
78
 
76
79
  const FRAMEWORK_STATE_ID = 'framework/state';
80
+ const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
77
81
  const INFERENCE_LOG_ID = 'framework/inference-log';
78
82
  const PROCESS_LOG_ID = 'framework/process-log';
79
83
  const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
@@ -180,6 +184,24 @@ function extractGateFields(event: ProcessEvent): {
180
184
  /**
181
185
  * The main agent framework.
182
186
  */
187
+ /** Params for a `host/command` request from an MCPL surface server. */
188
+ interface HostCommandParams {
189
+ command?: string;
190
+ agentName?: string;
191
+ turns?: number;
192
+ /** Message-granular undo: branch the chronicle so the last N messages
193
+ * (regardless of participant) are no longer on the active branch.
194
+ * Mutually exclusive with `turns`. */
195
+ messages?: number;
196
+ /** For the `hide` command: Discord message id of the (first) message to
197
+ * remove. With `toMessageId`, removes the inclusive range between them. */
198
+ fromMessageId?: string;
199
+ /** For the `hide` command: Discord message id ending an inclusive range. */
200
+ toMessageId?: string;
201
+ requesterId?: string;
202
+ requesterName?: string;
203
+ }
204
+
183
205
  export class AgentFramework {
184
206
  private store: JsStore;
185
207
  private ownsStore: boolean;
@@ -190,6 +212,16 @@ export class AgentFramework {
190
212
  private inferencePolicy: InferencePolicy;
191
213
  private errorPolicy: ErrorPolicy;
192
214
  private pendingRequests: InferenceRequest[] = [];
215
+ /**
216
+ * Per-agent channel that triggered the agent's CURRENT inference turn, if any
217
+ * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
218
+ * route a single-trunk agent's plain-text speech back to the channel it is
219
+ * answering, instead of the process-global most-recent-inbound locus that a
220
+ * concurrent message elsewhere can hijack. Set at turn start (or cleared for a
221
+ * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
222
+ * turn. Never read between turns (a given agent runs one turn at a time).
223
+ */
224
+ private activeTriggerChannels: Map<string, string> = new Map();
193
225
  private running = false;
194
226
  private loopPromise: Promise<void> | null = null;
195
227
  private traceListeners: TraceEventListener[] = [];
@@ -199,6 +231,12 @@ export class AgentFramework {
199
231
  private processLoggingBroadcast: boolean;
200
232
  private activeStreams: Map<string, Promise<void>> = new Map();
201
233
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
234
+ /** Per-agent count of consecutive exhausted inferences (reset on any success).
235
+ * Drives hard-down escalation — see noteInferenceExhausted. */
236
+ private consecutiveInferenceFailures: Map<string, number> = new Map();
237
+ /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
238
+ * escalated loudly to stderr. */
239
+ private readonly inferenceFailureEscalationThreshold = 3;
202
240
  /** Name of the primary (non-ephemeral) agent for routing framework messages. */
203
241
  private primaryAgentName: string | null = null;
204
242
 
@@ -210,6 +248,9 @@ export class AgentFramework {
210
248
  private turnCounters: Map<string, number> = new Map(); // agentName → next turnIndex
211
249
  private redoStacks: Map<string, RedoEntry[]> = new Map(); // agentName → redo entries
212
250
 
251
+ /** Liveness watchdog (fail hard on a wedged main thread). Null unless enabled. */
252
+ private livenessWatchdog: import('./runtime/liveness-watchdog.js').LivenessWatchdog | null = null;
253
+
213
254
  // MCPL subsystems (null when no mcplServers configured)
214
255
  private mcplServerRegistry: McplServerRegistry | null = null;
215
256
  private featureSetManager: FeatureSetManager | null = null;
@@ -219,6 +260,22 @@ export class AgentFramework {
219
260
  private inferenceRouter: InferenceRouter | null = null;
220
261
  private channelRegistry: ChannelRegistry | null = null;
221
262
  private checkpointManager: CheckpointManager | null = null;
263
+ /** Per-channel conversation routing (null unless config.conversations set). */
264
+ private conversationRouter: ConversationRouter | null = null;
265
+ /** Agent configs by name — fork agents are built from the template's config. */
266
+ private agentConfigs: Map<string, AgentConfig> = new Map();
267
+ /**
268
+ * Fork agent → its home channel. Permanent (unlike router bindings, which
269
+ * expire): publish/injection scoping must survive unbinding so the closure
270
+ * turn still lands in the right channel.
271
+ */
272
+ private conversationAgentHomes: Map<string, string> = new Map();
273
+ /** Last idle-TTL sweep timestamp. */
274
+ private lastConversationSweep = 0;
275
+
276
+ /** Forks whose TTL closure turn has been queued — disposed (removed from
277
+ * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
278
+ private closingConversationAgents: Set<string> = new Set();
222
279
  private mcplTools: import('./types/index.js').ToolDefinition[] = [];
223
280
  private mcplToolRefreshInFlight = false;
224
281
  private mcplToolRefreshPending = false;
@@ -265,6 +322,8 @@ export class AgentFramework {
265
322
  getMessage: (id) => this.getMessage(id),
266
323
  queryMessages: (filter) => this.queryMessages(filter),
267
324
  pushEvent: (event) => this.pushEvent(event),
325
+ onTrace: (listener) => this.onTrace(listener),
326
+ callTool: (call) => this.executeToolCall(call),
268
327
  });
269
328
  }
270
329
 
@@ -352,6 +411,35 @@ export class AgentFramework {
352
411
  await framework.addModule(module);
353
412
  }
354
413
 
414
+ // Initialize per-channel conversation routing (if configured)
415
+ if (config.conversations) {
416
+ if (!framework.agents.has(config.conversations.templateAgent)) {
417
+ throw new Error(
418
+ `conversations.templateAgent "${config.conversations.templateAgent}" ` +
419
+ `is not a configured agent`
420
+ );
421
+ }
422
+ framework.conversationRouter = new ConversationRouter(config.conversations);
423
+
424
+ // Generation counters persist across restarts — reusing generation 1's
425
+ // agent name after a restart would reopen (and re-seed) the previous
426
+ // engagement's Chronicle namespace.
427
+ try {
428
+ store.registerState({ id: CONVERSATION_ROUTER_STATE_ID, strategy: 'snapshot' });
429
+ } catch {
430
+ // Already registered
431
+ }
432
+ try {
433
+ const data = store.getStateJson(CONVERSATION_ROUTER_STATE_ID) as
434
+ { generations?: Record<string, number> } | null;
435
+ if (data?.generations) {
436
+ framework.conversationRouter.hydrateGenerations(data.generations);
437
+ }
438
+ } catch {
439
+ // No persisted state yet
440
+ }
441
+ }
442
+
355
443
  // Initialize EventGate if configured (before MCPL so it can be wired as trigger filter)
356
444
  if (config.gate) {
357
445
  const configPath = config.gate.configPath
@@ -361,6 +449,7 @@ export class AgentFramework {
361
449
  framework.eventGate = new EventGate({
362
450
  configPath,
363
451
  initialConfig: config.gate.config,
452
+ privilegedUsersPath: config.gate.privilegedUsersPath,
364
453
  emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
365
454
  addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata),
366
455
  requestInference: (agentName, reason, source) => {
@@ -370,6 +459,19 @@ export class AgentFramework {
370
459
  });
371
460
  }
372
461
 
462
+ // Liveness watchdog: fail hard if the main thread wedges (opt-in).
463
+ if (config.watchdog?.enabled) {
464
+ const { LivenessWatchdog } = await import('./runtime/liveness-watchdog.js');
465
+ framework.livenessWatchdog = new LivenessWatchdog({
466
+ enabled: true,
467
+ thresholdMs: config.watchdog.thresholdMs,
468
+ action: config.watchdog.action,
469
+ reportPath: config.watchdog.reportPath
470
+ ?? (config.storePath ? join(config.storePath, 'watchdog-wedge.jsonl') : undefined),
471
+ });
472
+ framework.livenessWatchdog.start();
473
+ }
474
+
373
475
  // Initialize MCPL subsystems if configured
374
476
  if (config.mcplServers && config.mcplServers.length > 0) {
375
477
  // Validate tool prefixes: no collisions with module names or between servers
@@ -455,6 +557,7 @@ export class AgentFramework {
455
557
 
456
558
  // Dispose EventGate (clear debounce timers)
457
559
  this.eventGate?.dispose();
560
+ this.livenessWatchdog?.stop();
458
561
 
459
562
  // Stop modules and MCPL servers in parallel
460
563
  const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll()];
@@ -486,8 +589,15 @@ export class AgentFramework {
486
589
  /**
487
590
  * Add a trace event listener for observability.
488
591
  */
489
- onTrace(listener: TraceEventListener): void {
592
+ onTrace(listener: TraceEventListener): () => void {
490
593
  this.traceListeners.push(listener);
594
+ // Return an unsubscribe so callers with a bounded lifetime (e.g. modules
595
+ // that get torn down and recreated on session switch) don't leak
596
+ // listeners. Existing callers that ignore the return value are unaffected.
597
+ return () => {
598
+ const idx = this.traceListeners.indexOf(listener);
599
+ if (idx >= 0) this.traceListeners.splice(idx, 1);
600
+ };
491
601
  }
492
602
 
493
603
  /**
@@ -540,6 +650,14 @@ export class AgentFramework {
540
650
  return Array.from(this.agents.values());
541
651
  }
542
652
 
653
+ /**
654
+ * Get the per-channel conversation router (null unless config.conversations
655
+ * was set). Exposed for modules/UIs that surface active engagements.
656
+ */
657
+ getConversationRouter(): ConversationRouter | null {
658
+ return this.conversationRouter;
659
+ }
660
+
543
661
  /**
544
662
  * Abort an in-flight inference for an agent.
545
663
  */
@@ -568,13 +686,93 @@ export class AgentFramework {
568
686
  getAllTools(): import('./types/index.js').ToolDefinition[] {
569
687
  const moduleTools = this.moduleRegistry.getAllTools();
570
688
  const channelTools = this.channelRegistry?.getChannelTools() ?? [];
571
- const gateTools = this.eventGate ? [this.eventGate.getToolDefinition()] : [];
689
+ const gateTools = this.eventGate
690
+ ? [
691
+ this.eventGate.getToolDefinition(),
692
+ ...AgentFramework.SLEEP_TOOLS,
693
+ ...AgentFramework.WAKE_RULE_TOOLS,
694
+ AgentFramework.EVENT_TAGS_TOOL,
695
+ ]
696
+ : [];
572
697
  if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
573
698
  return moduleTools;
574
699
  }
575
700
  return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
576
701
  }
577
702
 
703
+ /**
704
+ * Build the membrane-normalized request that WOULD be emitted if `agentName`
705
+ * were activated right now — WITHOUT running inference, opening a stream, or
706
+ * mutating agent state. Intended for debug/preview tooling.
707
+ *
708
+ * Transparency contract (default): the preview is side-effect-free and
709
+ * leaves no trace on the system. It does only read-only work —
710
+ * `ContextManager.compile` (which never triggers compression itself;
711
+ * compression runs in the background out-of-band), tool filtering, and
712
+ * system-prompt assembly — then delegates to `Agent.buildActivationRequest`.
713
+ * No tokens are spent, nothing is written to Chronicle, and no external
714
+ * MCPL server is contacted.
715
+ *
716
+ * The trade-off is fidelity: the dynamically-gathered ContextInjection[]
717
+ * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
718
+ * by default, because gathering them is not transparent —
719
+ * - module `gatherContext` can run inference (e.g. RetrievalModule makes
720
+ * Haiku calls — real token cost and latency), and
721
+ * - MCPL `beforeInference` hooks are arbitrary RPCs to external servers
722
+ * with side effects, and a preview never sends the paired
723
+ * `afterInference`, which can leave a stateful server half-open.
724
+ *
725
+ * Pass `{ injections: true }` to opt into full-fidelity gathering and accept
726
+ * those side effects (byte-faithful to a real activation's injected context).
727
+ */
728
+ async previewActivation(
729
+ agentName: string,
730
+ opts?: { injections?: boolean }
731
+ ): Promise<NormalizedRequest> {
732
+ const agent = this.agents.get(agentName);
733
+ if (!agent) {
734
+ throw new Error(`Agent not found: ${agentName}`);
735
+ }
736
+
737
+ const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
738
+
739
+ // Default: no dynamic injection gathering → fully transparent (no
740
+ // inference, no Chronicle writes, no external RPC). Opt in explicitly.
741
+ if (!opts?.injections) {
742
+ return agent.buildActivationRequest(tools, undefined);
743
+ }
744
+
745
+ // Full-fidelity path: mirrors startAgentStream's injection gathering.
746
+ // NOT transparent — see the doc comment above.
747
+ let injections: ContextInjection[] | undefined;
748
+
749
+ // Module gatherContext (fail-open, matches startAgentStream)
750
+ try {
751
+ const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
752
+ if (moduleInjections.length > 0) {
753
+ injections = moduleInjections;
754
+ }
755
+ } catch (error) {
756
+ console.error('Module gatherContext error (preview):', error);
757
+ }
758
+
759
+ // MCPL beforeInference hooks (fail-open). Note: the paired afterInference
760
+ // is intentionally never sent here — this is a preview, not a real turn.
761
+ if (this.hookOrchestrator) {
762
+ try {
763
+ const hookParams = this.buildBeforeInferenceParams(agent);
764
+ const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
765
+ if (hookInjections.length > 0) {
766
+ injections = injections ? [...injections, ...hookInjections] : hookInjections;
767
+ }
768
+ } catch (error) {
769
+ console.error('beforeInference hook error (preview):', error);
770
+ }
771
+ }
772
+
773
+ return agent.buildActivationRequest(tools, injections);
774
+ }
775
+
578
776
  /**
579
777
  * Check if process logging is enabled.
580
778
  */
@@ -599,6 +797,45 @@ export class AgentFramework {
599
797
  return this.moduleRegistry.getModule(name);
600
798
  }
601
799
 
800
+ // =========================================================================
801
+ // Wake-rule surface (gate policy mutation) — backs wake_add_rule /
802
+ // wake_remove_rule and lets host modules compose higher-level wake modes
803
+ // (e.g. an "every-message-debounced" channel mode) without reaching into
804
+ // the private EventGate.
805
+ // =========================================================================
806
+
807
+ /**
808
+ * Add or replace (upsert) a gate policy at runtime. Validated and persisted
809
+ * to gate.json; hot-applied in memory. Throws if no gate is configured or the
810
+ * policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
811
+ * defer/debounce rules (first match wins).
812
+ */
813
+ addGatePolicy(
814
+ rawPolicy: unknown,
815
+ options?: { position?: 'append' | 'prepend' },
816
+ ): import('./gate/types.js').GatePolicy {
817
+ if (!this.eventGate) {
818
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
819
+ }
820
+ return this.eventGate.addPolicy(rawPolicy, options);
821
+ }
822
+
823
+ /**
824
+ * Remove a gate policy by name at runtime. Returns false if it didn't exist.
825
+ * Throws if no gate is configured.
826
+ */
827
+ removeGatePolicy(name: string): boolean {
828
+ if (!this.eventGate) {
829
+ throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
830
+ }
831
+ return this.eventGate.removePolicy(name);
832
+ }
833
+
834
+ /** Current gate policy names (freshest on-disk view). Empty when no gate. */
835
+ getGatePolicyNames(): string[] {
836
+ return this.eventGate?.listPolicyNames() ?? [];
837
+ }
838
+
602
839
  /**
603
840
  * Get the Membrane instance.
604
841
  */
@@ -840,6 +1077,461 @@ export class AgentFramework {
840
1077
  * Query inference logs.
841
1078
  * Returns entries with summary info (doesn't resolve blobs).
842
1079
  */
1080
+ /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
1081
+ private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
1082
+ {
1083
+ name: 'sleep',
1084
+ description:
1085
+ 'Go quiet: suppress external pings and wakes for a number of seconds. ' +
1086
+ 'Messages still accumulate in your context — you just won’t be woken to ' +
1087
+ 'respond to them until the window passes. Your heartbeat still beats: you’ll ' +
1088
+ 'briefly rouse on each tick and can keep resting or call `wake` to get up early. ' +
1089
+ 'Privileged users can also still reach you. By default announces in your current channel.',
1090
+ inputSchema: {
1091
+ type: 'object',
1092
+ properties: {
1093
+ seconds: { type: 'number', description: 'How long to stay asleep, in seconds.' },
1094
+ announce: {
1095
+ type: 'boolean',
1096
+ description: 'Announce the sleep in the current sticky channel (default true).',
1097
+ },
1098
+ message: {
1099
+ type: 'string',
1100
+ description: 'Optional custom announcement text (overrides the default).',
1101
+ },
1102
+ },
1103
+ required: ['seconds'],
1104
+ },
1105
+ },
1106
+ {
1107
+ name: 'wake',
1108
+ description: 'End your current sleep early, resuming normal wakes immediately.',
1109
+ inputSchema: { type: 'object' },
1110
+ },
1111
+ ];
1112
+
1113
+ /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
1114
+ * Present when a gate is wired. They write validated policies into the
1115
+ * hot-reloaded gate.json (same validation as load), so a rule takes effect
1116
+ * immediately and survives restart. */
1117
+ private static readonly WAKE_RULE_TOOLS: import('./types/index.js').ToolDefinition[] = [
1118
+ {
1119
+ name: 'wake_add_rule',
1120
+ description:
1121
+ 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
1122
+ 'hand-edit the file. The rule is validated and hot-applied immediately. ' +
1123
+ 'A rule with the same `name` replaces the existing one in place. Use ' +
1124
+ '`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
1125
+ 'rules (first match wins). Two common shapes:\n' +
1126
+ '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
1127
+ '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
1128
+ '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
1129
+ 'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, behavior: { debounce: 60000 } }` ' +
1130
+ '(the channel must be subscribed for ambient events to arrive — see channel mode).',
1131
+ inputSchema: {
1132
+ type: 'object',
1133
+ properties: {
1134
+ name: { type: 'string', description: 'Unique rule name. Reusing a name replaces that rule.' },
1135
+ match: {
1136
+ type: 'object',
1137
+ description: 'Match criteria (all AND together). Omitted fields match anything.',
1138
+ properties: {
1139
+ scope: { type: 'array', items: { type: 'string' }, description: 'Event types, e.g. ["mcpl:channel-incoming","workspace:modified"].' },
1140
+ source: { type: 'string', description: 'Integration/serverId, glob ok (e.g. "discord").' },
1141
+ channel: { type: 'string', description: 'Channel id, glob ok (e.g. "discord:*:12345").' },
1142
+ mount: { type: 'string', description: 'Workspace mount name, glob ok (workspace:* events).' },
1143
+ pathGlob: { type: 'string', description: 'Glob over touched paths (workspace:* events).' },
1144
+ tagsAny: { type: 'array', items: { type: 'string' }, description: 'Match if ANY tag matches (globs ok).' },
1145
+ tagsAll: { type: 'array', items: { type: 'string' }, description: 'Match only if EVERY tag matches.' },
1146
+ tagsNone: { type: 'array', items: { type: 'string' }, description: 'Match only if NONE match.' },
1147
+ metadataTrue: { type: 'array', items: { type: 'string' }, description: 'Match if ANY listed metadata field is truthy.' },
1148
+ filter: {
1149
+ type: 'object',
1150
+ description: 'Content filter.',
1151
+ properties: {
1152
+ type: { type: 'string', enum: ['text', 'regex'] },
1153
+ pattern: { type: 'string' },
1154
+ },
1155
+ required: ['type', 'pattern'],
1156
+ },
1157
+ },
1158
+ },
1159
+ behavior: {
1160
+ type: 'string',
1161
+ enum: ['always', 'defer', 'skip'],
1162
+ description:
1163
+ 'Simple behavior: "always" (wake now) or "defer" (don\'t wake; still enters ' +
1164
+ 'context). For debounce / rate-limit / sampling, use debounceMs / rateLimit / ' +
1165
+ 'passiveSample below instead of this field. Exactly one behavior must be given.',
1166
+ },
1167
+ debounceMs: {
1168
+ type: 'number',
1169
+ description: 'Shorthand for { debounce: ms }: wake once after ms of quiet (100–300000).',
1170
+ },
1171
+ rateLimit: {
1172
+ type: 'object',
1173
+ description: 'Token-bucket wake: at most `tokens` wakes per window; refills one per refillIntervalMs.',
1174
+ properties: {
1175
+ tokens: { type: 'number', description: 'Bucket capacity (> 0).' },
1176
+ refillIntervalMs: { type: 'number', description: 'Ms between token refills (> 0).' },
1177
+ keyBy: { type: 'string', description: 'Metadata field to partition buckets by (e.g. "channelId").' },
1178
+ },
1179
+ required: ['tokens', 'refillIntervalMs'],
1180
+ },
1181
+ passiveSample: {
1182
+ type: 'object',
1183
+ description: 'Wake every Nth matching event.',
1184
+ properties: {
1185
+ every: { type: 'number', description: 'Fire every N matches (positive integer).' },
1186
+ keyBy: { type: 'string', description: 'Metadata field for separate per-key counters.' },
1187
+ },
1188
+ required: ['every'],
1189
+ },
1190
+ position: {
1191
+ type: 'string',
1192
+ enum: ['append', 'prepend'],
1193
+ description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
1194
+ },
1195
+ resets: {
1196
+ type: 'array',
1197
+ items: { type: 'string' },
1198
+ description: 'Names of other rules whose runtime state to clear when this rule fires.',
1199
+ },
1200
+ },
1201
+ required: ['name'],
1202
+ },
1203
+ },
1204
+ {
1205
+ name: 'wake_remove_rule',
1206
+ description:
1207
+ 'Remove a wake rule (gate.json policy) by name at runtime. Any pending ' +
1208
+ 'debounce batch for that rule is delivered first. Returns whether a rule ' +
1209
+ 'was removed (false if no rule had that name).',
1210
+ inputSchema: {
1211
+ type: 'object',
1212
+ properties: {
1213
+ name: { type: 'string', description: 'The rule name to remove.' },
1214
+ },
1215
+ required: ['name'],
1216
+ },
1217
+ },
1218
+ ];
1219
+
1220
+ /** Discoverability for event tags (MCPL RFC-001): the reserved chat:* core,
1221
+ * each connected server's declared tag ontology, and gate.js status. */
1222
+ private static readonly EVENT_TAGS_TOOL: import('./types/index.js').ToolDefinition = {
1223
+ name: 'event_tags',
1224
+ description:
1225
+ 'List the event tags available for gating: the reserved cross-platform ' +
1226
+ 'chat:* core, each connected server\'s declared tag ontology (descriptions, ' +
1227
+ 'implications, suggested treatments), and the status of your programmable ' +
1228
+ 'gate (gate.js). Use these tag names in gate.json policies (tagsAny / ' +
1229
+ 'tagsAll / tagsNone) or in gate.js.',
1230
+ inputSchema: { type: 'object' },
1231
+ };
1232
+
1233
+ /** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
1234
+ * the agent can author rules without reading the spec. */
1235
+ private static readonly CHAT_CORE_TAGS: Record<string, string> = {
1236
+ 'chat:addressed': 'Directed at you (umbrella: dm/mention/reply)',
1237
+ 'chat:mention': 'You were explicitly @-mentioned',
1238
+ 'chat:reply': 'A reply to your own message',
1239
+ 'chat:dm': 'A direct/private message to you',
1240
+ 'chat:ambient': 'Overheard in a followed channel; not addressed',
1241
+ 'chat:broadcast': 'Channel-wide ping (@everyone / channel post)',
1242
+ 'chat:to-self': 'Acts on your own content (reaction/reply to you)',
1243
+ 'chat:from-human': 'Authored by a human',
1244
+ 'chat:from-bot': 'Authored by a bot/automation',
1245
+ 'chat:from-self': 'Your own message, echoed back',
1246
+ 'chat:from-agent': 'Authored by another persona/agent',
1247
+ 'chat:edited': 'An edit of an existing message',
1248
+ 'chat:deleted': 'A deletion',
1249
+ 'chat:reaction': 'An emoji reaction was added',
1250
+ 'chat:reaction-remove': 'A reaction was removed',
1251
+ 'chat:has-image': 'Has an image attachment',
1252
+ 'chat:has-audio': 'Has an audio attachment',
1253
+ 'chat:has-file': 'Has a file attachment',
1254
+ 'chat:has-link': 'Contains a link',
1255
+ 'chat:command': 'A slash/bot command invocation',
1256
+ 'chat:private': 'Private conversation',
1257
+ 'chat:group': 'Group (multi-party) conversation',
1258
+ 'chat:thread': 'Occurred in a thread',
1259
+ };
1260
+
1261
+ /** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
1262
+ private static readonly REFUSAL_REACTIONS: Record<string, string> = {
1263
+ bio: '☣️',
1264
+ chem: '🧪',
1265
+ nuclear: '☢️',
1266
+ cyber: '💻',
1267
+ reasoning_extraction: '🧠',
1268
+ };
1269
+
1270
+ /**
1271
+ * Mark an inference refusal visibly: react on the message that holds the
1272
+ * conversational locus (the most recent incoming channel message) with a
1273
+ * category-specific emoji. Best-effort — failures are logged, never thrown,
1274
+ * and non-Discord loci are silently skipped.
1275
+ */
1276
+ private async reactToRefusal(agentName: string, category: string): Promise<void> {
1277
+ try {
1278
+ const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
1279
+ if (!incoming) return;
1280
+ // incoming.channelId is the MCPL composite id ("discord:<guild>:<channel>");
1281
+ // the reaction tool wants the raw Discord channel (or thread) id — the
1282
+ // last segment.
1283
+ const parts = incoming.channelId.split(':');
1284
+ if (parts[0] !== 'discord') return;
1285
+ const channelId = parts[parts.length - 1];
1286
+ const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
1287
+ // Resolve the MCPL server that owns the locus channel and call
1288
+ // tools/call directly on its connection (bare tool name — no prefix
1289
+ // games), bypassing the agent event queue so no synthetic tool-result
1290
+ // is injected into a turn the agent never took.
1291
+ const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
1292
+ const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
1293
+ if (!server) {
1294
+ console.error(
1295
+ `[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`,
1296
+ );
1297
+ return;
1298
+ }
1299
+ await server.sendToolsCall('add_reaction', {
1300
+ channelId,
1301
+ messageId: incoming.messageId,
1302
+ emoji,
1303
+ });
1304
+ } catch (err) {
1305
+ console.error(
1306
+ '[inference-refusal] reaction failed:',
1307
+ err instanceof Error ? err.message : err,
1308
+ );
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * Handle a `host/command` request from an MCPL surface server (e.g. a
1314
+ * Discord slash command). Currently supports:
1315
+ *
1316
+ * undo — revert the last N inference turns (branch-based, see
1317
+ * `undoLastTurn`). The response includes the last message the agent
1318
+ * would see in its context after the undo, obtained via the transparent
1319
+ * `previewActivation` render (no inference, no Chronicle writes).
1320
+ */
1321
+ private async handleHostCommand(
1322
+ serverId: string,
1323
+ params: HostCommandParams,
1324
+ ): Promise<{
1325
+ ok: boolean;
1326
+ error?: string;
1327
+ undone?: number;
1328
+ requested?: number;
1329
+ messagesRemoved?: number;
1330
+ hidden?: number;
1331
+ /** For `hide`: the Discord (channelId, messageId) of each removed message
1332
+ * that carried one — so the surface can mark them with a reaction. */
1333
+ hiddenRefs?: Array<{ channelId: string; messageId: string }>;
1334
+ lastVisible?: { participant?: string; role?: string; preview?: string } | null;
1335
+ }> {
1336
+ if (params.command !== 'undo' && params.command !== 'hide') {
1337
+ return { ok: false, error: `Unknown host command: ${String(params.command)}` };
1338
+ }
1339
+
1340
+ const agentName = params.agentName ?? [...this.agents.keys()][0];
1341
+ if (!agentName || !this.agents.has(agentName)) {
1342
+ return { ok: false, error: `Unknown agent: ${String(agentName)}` };
1343
+ }
1344
+
1345
+ // hide: redact a single message (or an inclusive range) from the active
1346
+ // branch, addressed by Discord message id. Unlike undo this is a
1347
+ // removal-in-place (chronicle redact), not a branch rewind.
1348
+ if (params.command === 'hide') {
1349
+ const agent = this.agents.get(agentName)!;
1350
+ if (agent.state.status !== 'idle') {
1351
+ return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
1352
+ }
1353
+ if (!params.fromMessageId) {
1354
+ return { ok: false, error: 'hide: fromMessageId is required' };
1355
+ }
1356
+ const cm = agent.getContextManager();
1357
+ const all = cm.getAllMessages();
1358
+ const byDiscordId = (did: string) =>
1359
+ all.findIndex((m) => String((m.metadata as { messageId?: unknown } | undefined)?.messageId) === String(did));
1360
+
1361
+ // Collect Discord refs of every message in [lo, hi] that carries one,
1362
+ // so the surface can mark them. channelId here is whatever the ingest
1363
+ // path stored (raw id or "discord:guild:channel"); the surface
1364
+ // normalizes it.
1365
+ const refsIn = (lo: number, hi: number) => {
1366
+ const refs: Array<{ channelId: string; messageId: string }> = [];
1367
+ for (let i = lo; i <= hi; i++) {
1368
+ const md = all[i].metadata as { messageId?: unknown; channelId?: unknown } | undefined;
1369
+ if (md?.messageId && md?.channelId) {
1370
+ refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
1371
+ }
1372
+ }
1373
+ return refs;
1374
+ };
1375
+
1376
+ const fromIdx = byDiscordId(params.fromMessageId);
1377
+ if (fromIdx < 0) {
1378
+ return {
1379
+ ok: false,
1380
+ error: `Message ${params.fromMessageId} is not an addressable message in context (it may only exist inside backscroll text, or predates this session).`,
1381
+ };
1382
+ }
1383
+
1384
+ try {
1385
+ if (params.toMessageId) {
1386
+ const toIdx = byDiscordId(params.toMessageId);
1387
+ if (toIdx < 0) {
1388
+ return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
1389
+ }
1390
+ const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
1391
+ const refs = refsIn(lo, hi);
1392
+ cm.removeMessages(all[lo].id, all[hi].id);
1393
+ console.error(
1394
+ `[host-command] hide agent=${agentName} range removed=${hi - lo + 1} ` +
1395
+ `(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1396
+ );
1397
+ return {
1398
+ ok: true,
1399
+ hidden: hi - lo + 1,
1400
+ hiddenRefs: refs,
1401
+ lastVisible: await this.lastVisiblePreview(agentName),
1402
+ };
1403
+ }
1404
+ const refs = refsIn(fromIdx, fromIdx);
1405
+ cm.removeMessage(all[fromIdx].id);
1406
+ console.error(
1407
+ `[host-command] hide agent=${agentName} removed=1 (${params.fromMessageId}) ` +
1408
+ `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1409
+ );
1410
+ return {
1411
+ ok: true,
1412
+ hidden: 1,
1413
+ hiddenRefs: refs,
1414
+ lastVisible: await this.lastVisiblePreview(agentName),
1415
+ };
1416
+ } catch (error) {
1417
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1418
+ }
1419
+ }
1420
+
1421
+ // Message-granular mode: branch the chronicle at the message that should
1422
+ // become the new tail (ContextManager.branchAt — origin-sequence-based
1423
+ // time-travel branching; see chronicle's rfc-state-item-origins addendum).
1424
+ // No turn checkpoints involved, so this reaches past turn boundaries.
1425
+ if (typeof params.messages === 'number' && params.messages > 0) {
1426
+ const agent = this.agents.get(agentName)!;
1427
+ if (agent.state.status !== 'idle') {
1428
+ return { ok: false, error: `Cannot undo while agent is ${agent.state.status}` };
1429
+ }
1430
+ const n = Math.max(1, Math.min(50, Math.floor(params.messages)));
1431
+ const cm = agent.getContextManager();
1432
+ const allMessages = cm.getAllMessages();
1433
+ if (n >= allMessages.length) {
1434
+ return {
1435
+ ok: false,
1436
+ error: `Cannot remove ${n} message(s) — history has ${allMessages.length}; at least one must remain.`,
1437
+ };
1438
+ }
1439
+ const target = allMessages[allMessages.length - 1 - n];
1440
+ const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
1441
+ await cm.switchBranch(branchName);
1442
+
1443
+ // Materialize config files from the new branch (fire-and-forget; gate
1444
+ // picks up via mtime) — mirrors undoLastTurn.
1445
+ const wsUndo = this.moduleRegistry.getModule('workspace');
1446
+ if (wsUndo && 'materializeMount' in wsUndo) {
1447
+ (wsUndo as { materializeMount: (m: string) => Promise<void> })
1448
+ .materializeMount('_config')
1449
+ .catch(() => {});
1450
+ }
1451
+
1452
+ console.error(
1453
+ `[host-command] undo-messages agent=${agentName} removed=${n} branch=${branchName}` +
1454
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1455
+ );
1456
+
1457
+ return {
1458
+ ok: true,
1459
+ messagesRemoved: n,
1460
+ lastVisible: await this.lastVisiblePreview(agentName),
1461
+ };
1462
+ }
1463
+
1464
+ const requested = Math.max(1, Math.min(20, Math.floor(params.turns ?? 1)));
1465
+ let undone = 0;
1466
+ try {
1467
+ for (let i = 0; i < requested; i++) {
1468
+ const r = this.undoLastTurn(agentName);
1469
+ if (!r.undone) break;
1470
+ undone++;
1471
+ }
1472
+ } catch (error) {
1473
+ // e.g. "Cannot undo while agent is streaming" — report what happened,
1474
+ // including any turns already undone before the failure.
1475
+ const msg = error instanceof Error ? error.message : String(error);
1476
+ if (undone === 0) return { ok: false, error: msg };
1477
+ console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
1478
+ }
1479
+
1480
+ console.error(
1481
+ `[host-command] undo agent=${agentName} requested=${requested} undone=${undone}` +
1482
+ ` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
1483
+ );
1484
+
1485
+ if (undone === 0) {
1486
+ return { ok: true, undone: 0, requested, lastVisible: null };
1487
+ }
1488
+
1489
+ return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
1490
+ }
1491
+
1492
+ /**
1493
+ * The last message the agent would see in its context right now, via the
1494
+ * transparent `previewActivation` render (no inference, no Chronicle
1495
+ * writes). Returns null if the preview fails or the context is empty.
1496
+ */
1497
+ private async lastVisiblePreview(
1498
+ agentName: string,
1499
+ ): Promise<{ participant?: string; role?: string; preview?: string } | null> {
1500
+ try {
1501
+ const preview = await this.previewActivation(agentName);
1502
+ const messages = (preview as { messages?: unknown[] }).messages ?? [];
1503
+ const last = messages[messages.length - 1] as
1504
+ | { role?: string; participant?: string; content?: unknown }
1505
+ | undefined;
1506
+ if (!last) return null;
1507
+ const blocks = Array.isArray(last.content) ? last.content : [];
1508
+ const text = blocks
1509
+ .map((b) => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
1510
+ .join(' ')
1511
+ .trim();
1512
+ // Surface-injected preambles (<system>…</system>, <backscroll>…
1513
+ // </backscroll>) can dominate the head of a bundled incoming message
1514
+ // and bury the actual conversational line. Strip them for the
1515
+ // preview; fall back to the raw text if nothing else remains.
1516
+ const stripped = text
1517
+ .replace(/<system>[\s\S]*?<\/system>/g, '')
1518
+ .replace(/<backscroll\b[\s\S]*?<\/backscroll>/g, '')
1519
+ .trim();
1520
+ const body = stripped.length > 0 ? stripped : text;
1521
+ return {
1522
+ participant: last.participant,
1523
+ role: last.role,
1524
+ preview: body.length > 400 ? `${body.slice(0, 400)}…` : body,
1525
+ };
1526
+ } catch (error) {
1527
+ console.error(
1528
+ '[host-command] post-undo context preview failed:',
1529
+ error instanceof Error ? error.message : error,
1530
+ );
1531
+ return null;
1532
+ }
1533
+ }
1534
+
843
1535
  queryInferenceLogs(query?: InferenceLogQuery): InferenceLogQueryResult {
844
1536
  const limit = query?.limit ?? 50;
845
1537
  const offset = query?.offset ?? 0;
@@ -1307,6 +1999,7 @@ export class AgentFramework {
1307
1999
 
1308
2000
  const agent = new Agent(config, contextManager, this.membrane);
1309
2001
  this.agents.set(config.name, agent);
2002
+ this.agentConfigs.set(config.name, config);
1310
2003
 
1311
2004
  // First non-ephemeral agent becomes the primary for message routing
1312
2005
  if (!this.primaryAgentName) {
@@ -1334,6 +2027,9 @@ export class AgentFramework {
1334
2027
  await this.handleProcessEvent(event);
1335
2028
  }
1336
2029
 
2030
+ // Close idle conversation forks (no-op unless conversations configured)
2031
+ this.sweepExpiredConversations();
2032
+
1337
2033
  // Check for inference requests
1338
2034
  await this.processInferenceRequests();
1339
2035
 
@@ -1392,25 +2088,18 @@ export class AgentFramework {
1392
2088
  // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
1393
2089
  const maxChars = this.getMaxToolResultChars(agent);
1394
2090
 
1395
- // Store tool results as a user message (tool_result blocks)
1396
- const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => {
1397
- let content: string;
1398
- if (tc.result.isError) {
1399
- content = tc.result.error ?? 'Unknown error';
1400
- } else {
1401
- content = JSON.stringify(tc.result.data);
1402
- if (maxChars && content.length > maxChars) {
1403
- content = safeSlice(content, 0, maxChars)
1404
- + '\n\n[truncated — original was ' + content.length + ' chars]';
1405
- }
1406
- }
1407
- return {
1408
- type: 'tool_result' as const,
1409
- toolUseId: tc.id,
1410
- content,
1411
- isError: tc.result.isError,
1412
- };
1413
- });
2091
+ // Store tool results as a user message (tool_result blocks).
2092
+ // Use the history serializer so MCP image blocks become a short
2093
+ // `[image: type, size]` placeholder instead of megabytes of base64
2094
+ // that would corrupt under truncation.
2095
+ const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => ({
2096
+ type: 'tool_result' as const,
2097
+ toolUseId: tc.id,
2098
+ content: tc.result.isError
2099
+ ? tc.result.error ?? 'Unknown error'
2100
+ : toolResultDataToHistoryString(tc.result.data, maxChars),
2101
+ isError: tc.result.isError,
2102
+ }));
1414
2103
  agent.getContextManager().addMessage('user', toolResultContent);
1415
2104
 
1416
2105
  // Flush any messages that were deferred while waiting for tool results.
@@ -1482,7 +2171,7 @@ export class AgentFramework {
1482
2171
  // These events are protocol-level (spec Sections 9 & 14) and always
1483
2172
  // represent content intended for the model's context window.
1484
2173
  if (event.type === 'mcpl:channel-incoming') {
1485
- this.handleMcplChannelIncoming(event as unknown as {
2174
+ await this.handleMcplChannelIncoming(event as unknown as {
1486
2175
  type: 'mcpl:channel-incoming';
1487
2176
  serverId: string;
1488
2177
  channelId: string;
@@ -1578,9 +2267,11 @@ export class AgentFramework {
1578
2267
  if (!decision.trigger) return;
1579
2268
  }
1580
2269
 
2270
+ // Broadcast requests exclude conversation forks — they are driven by
2271
+ // their own channel's messages, not by framework-wide events.
1581
2272
  const targetAgents =
1582
2273
  response.requestInference === true
1583
- ? Array.from(this.agents.keys())
2274
+ ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
1584
2275
  : response.requestInference;
1585
2276
 
1586
2277
  for (const agentName of targetAgents) {
@@ -1605,7 +2296,7 @@ export class AgentFramework {
1605
2296
  * Convert an incoming MCPL channel message to a context message.
1606
2297
  * This replaces the old MCPLModule.onProcess() message conversion.
1607
2298
  */
1608
- private handleMcplChannelIncoming(event: {
2299
+ private async handleMcplChannelIncoming(event: {
1609
2300
  type: 'mcpl:channel-incoming';
1610
2301
  serverId: string;
1611
2302
  channelId: string;
@@ -1616,7 +2307,7 @@ export class AgentFramework {
1616
2307
  timestamp: string;
1617
2308
  metadata?: Record<string, unknown>;
1618
2309
  triggerInference?: boolean;
1619
- }): void {
2310
+ }): Promise<void> {
1620
2311
  const metadata: Record<string, unknown> = {
1621
2312
  ...event.metadata,
1622
2313
  channelId: event.channelId,
@@ -1627,6 +2318,14 @@ export class AgentFramework {
1627
2318
  };
1628
2319
  if (event.threadId) metadata.threadId = event.threadId;
1629
2320
 
2321
+ // Per-channel conversation routing: messages go to the channel's fork
2322
+ // agent (spawned from the template on first qualifying message), never
2323
+ // to the primary conversation.
2324
+ if (this.conversationRouter) {
2325
+ await this.routeConversationIncoming(event, metadata);
2326
+ return;
2327
+ }
2328
+
1630
2329
  const id = this.addMessage('user', event.content, metadata);
1631
2330
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
1632
2331
 
@@ -1637,7 +2336,268 @@ export class AgentFramework {
1637
2336
  reason: 'mcpl:channel-incoming',
1638
2337
  source: event.serverId,
1639
2338
  timestamp: Date.now(),
2339
+ // Route this turn's auto-published speech back to THIS channel, not
2340
+ // the global most-recent-inbound locus (item-3 redux, trunk agents).
2341
+ channelId: event.channelId,
2342
+ });
2343
+ }
2344
+ }
2345
+ }
2346
+
2347
+ /**
2348
+ * Route an incoming channel message through the ConversationRouter:
2349
+ * deliver to the channel's bound fork agent, spawning it from the template
2350
+ * agent first when the bind policy matches. Unrouted messages are dropped —
2351
+ * the template ("trunk") is a dormant warm checkpoint, not a listener.
2352
+ */
2353
+ private async routeConversationIncoming(
2354
+ event: {
2355
+ serverId: string;
2356
+ channelId: string;
2357
+ messageId: string;
2358
+ author: { id: string; name: string };
2359
+ content: ContentBlock[];
2360
+ metadata?: Record<string, unknown>;
2361
+ triggerInference?: boolean;
2362
+ },
2363
+ messageMetadata: Record<string, unknown>,
2364
+ ): Promise<void> {
2365
+ const router = this.conversationRouter!;
2366
+ const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
2367
+
2368
+ const decision = router.route({
2369
+ channelId: event.channelId,
2370
+ mentioned: event.metadata?.mentioned === true,
2371
+ kind: ConversationRouter.classifyChannel(descriptor, event.metadata),
2372
+ });
2373
+
2374
+ if (decision.kind === 'unbound') {
2375
+ this.emitTrace({
2376
+ type: 'mcpl:conversation-unrouted',
2377
+ channelId: event.channelId,
2378
+ messageId: event.messageId,
2379
+ });
2380
+ return;
2381
+ }
2382
+
2383
+ let agent: Agent | undefined;
2384
+ if (decision.kind === 'spawn') {
2385
+ try {
2386
+ agent = await this.createConversationAgent(decision.agentName, event.channelId);
2387
+ router.bind(event.channelId, decision.agentName, decision.generation);
2388
+ this.persistConversationRouterState();
2389
+ this.emitTrace({
2390
+ type: 'mcpl:conversation-spawned',
2391
+ channelId: event.channelId,
2392
+ agentName: decision.agentName,
2393
+ generation: decision.generation,
2394
+ template: router.templateAgent,
2395
+ });
2396
+ } catch (error) {
2397
+ const err = error instanceof Error ? error : new Error(String(error));
2398
+ console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
2399
+ this.emitTrace({
2400
+ type: 'mcpl:conversation-spawn-failed',
2401
+ channelId: event.channelId,
2402
+ agentName: decision.agentName,
2403
+ error: err.message,
1640
2404
  });
2405
+ return; // next qualifying message proposes the same spawn again
2406
+ }
2407
+ } else {
2408
+ agent = this.agents.get(decision.agentName);
2409
+ if (!agent) {
2410
+ // Bound agent vanished (e.g. external reset) — unbind so the next
2411
+ // qualifying message respawns a fresh fork.
2412
+ router.unbind(event.channelId);
2413
+ this.emitTrace({
2414
+ type: 'mcpl:conversation-binding-orphaned',
2415
+ channelId: event.channelId,
2416
+ agentName: decision.agentName,
2417
+ });
2418
+ return;
2419
+ }
2420
+ }
2421
+
2422
+ // Respect an explicit server-config-level veto (shouldTriggerInference)
2423
+ // on top of the router's own trigger policy.
2424
+ const trigger = decision.trigger && event.triggerInference !== false;
2425
+ messageMetadata.triggered = trigger;
2426
+
2427
+ const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
2428
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
2429
+
2430
+ if (trigger) {
2431
+ this.pendingRequests.push({
2432
+ agentName: agent.name,
2433
+ reason: 'mcpl:channel-incoming',
2434
+ source: event.serverId,
2435
+ timestamp: Date.now(),
2436
+ // A fork's home channel wins in routeSpeech regardless, but carry the
2437
+ // triggering channel too so the trunk/active path stays consistent.
2438
+ channelId: event.channelId,
2439
+ });
2440
+ }
2441
+ }
2442
+
2443
+ /**
2444
+ * Spawn a persistent per-channel conversation agent ("fork") from the
2445
+ * template agent: own ContextManager under `conversations/{name}`, seeded
2446
+ * with a copy of the template's compiled context (the SubagentModule
2447
+ * wholesale-copy pattern), registered in the event loop like any agent —
2448
+ * but never primary.
2449
+ */
2450
+ private async createConversationAgent(name: string, channelId: string): Promise<Agent> {
2451
+ const router = this.conversationRouter!;
2452
+ const templateConfig = this.agentConfigs.get(router.templateAgent);
2453
+ const template = this.agents.get(router.templateAgent);
2454
+ if (!template || !templateConfig) {
2455
+ throw new Error(`conversation template agent "${router.templateAgent}" not found`);
2456
+ }
2457
+
2458
+ const contextManager = await ContextManager.open({
2459
+ store: this.store,
2460
+ namespace: `conversations/${name}`,
2461
+ isolate: true,
2462
+ // Strategy instances are stateful — never share the template's.
2463
+ strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
2464
+ membrane: this.membrane,
2465
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
2466
+ });
2467
+
2468
+ // Seed with the template's compiled context, renaming the template
2469
+ // participant so the fork reads its inheritance as its own history.
2470
+ // Guard: only seed a genuinely fresh namespace. Generation counters are
2471
+ // persisted precisely so names aren't reused, but if this namespace has
2472
+ // history anyway (counter state lost, crash between spawn and persist),
2473
+ // seeding again would stack another template copy on top of it.
2474
+ const { messages: existing } = await contextManager.compile();
2475
+ if (existing.length === 0) {
2476
+ const { messages: compiled } = await template.getContextManager().compile();
2477
+ for (const msg of compiled) {
2478
+ const participant = msg.participant === template.name ? name : msg.participant;
2479
+ contextManager.addMessage(participant, msg.content);
2480
+ }
2481
+ }
2482
+
2483
+ const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
2484
+ const agent = new Agent(config, contextManager, this.membrane);
2485
+ this.agents.set(name, agent);
2486
+ this.agentConfigs.set(name, config);
2487
+ this.conversationAgentHomes.set(name, channelId);
2488
+ return agent;
2489
+ }
2490
+
2491
+ /** Persist the router's generation counters (see hydration in create()). */
2492
+ private persistConversationRouterState(): void {
2493
+ if (!this.conversationRouter) return;
2494
+ try {
2495
+ this.store.setStateJson(CONVERSATION_ROUTER_STATE_ID, {
2496
+ generations: this.conversationRouter.exportGenerations(),
2497
+ });
2498
+ } catch (error) {
2499
+ console.error('Failed to persist conversation router state:', error);
2500
+ }
2501
+ }
2502
+
2503
+ /**
2504
+ * Remove a closed conversation fork from the framework: its closure turn
2505
+ * has finished, so the home mapping has served its purpose and keeping the
2506
+ * agent registered would just grow every agent scan and broadcast filter
2507
+ * forever. The fork's context stays in Chronicle for investigation.
2508
+ */
2509
+ private disposeConversationAgent(agentName: string): void {
2510
+ this.closingConversationAgents.delete(agentName);
2511
+ const channelId = this.conversationAgentHomes.get(agentName);
2512
+ this.agents.delete(agentName);
2513
+ this.agentConfigs.delete(agentName);
2514
+ this.conversationAgentHomes.delete(agentName);
2515
+ this.emitTrace({
2516
+ type: 'mcpl:conversation-disposed',
2517
+ agentName,
2518
+ channelId,
2519
+ });
2520
+ }
2521
+
2522
+ /**
2523
+ * Scope MCPL context injections for a conversation-bound agent: drop
2524
+ * injections that are another open channel's context (injection namespace
2525
+ * = channelId by adapter convention), keep its own channel and anything
2526
+ * that isn't channel context.
2527
+ */
2528
+ private scopeInjectionsForAgent(
2529
+ agentName: string,
2530
+ injections: ContextInjection[],
2531
+ ): ContextInjection[] {
2532
+ const home = this.conversationAgentHomes.get(agentName);
2533
+ if (!home || !this.channelRegistry || injections.length === 0) {
2534
+ return injections;
2535
+ }
2536
+ const openChannelIds = new Set(
2537
+ this.channelRegistry.getOpenChannels().map((e) => e.descriptor.id),
2538
+ );
2539
+ return injections.filter((inj) => {
2540
+ const ns = inj.namespace;
2541
+ if (!ns || !openChannelIds.has(ns)) return true;
2542
+ return ns === home;
2543
+ });
2544
+ }
2545
+
2546
+ /**
2547
+ * Idle-TTL sweep for conversation bindings (runs at most once per minute
2548
+ * from the event loop). Expired forks get a final system-initiated closure
2549
+ * turn — publish scoping still works because the agent's home channel
2550
+ * mapping is permanent — and the channel unbinds immediately, so the next
2551
+ * qualifying message spawns a fresh fork from the current template.
2552
+ */
2553
+ private sweepExpiredConversations(): void {
2554
+ if (!this.conversationRouter) return;
2555
+ const now = Date.now();
2556
+ if (now - this.lastConversationSweep < 60_000) return;
2557
+ this.lastConversationSweep = now;
2558
+
2559
+ for (const binding of this.conversationRouter.expired(now)) {
2560
+ this.conversationRouter.unbind(binding.channelId);
2561
+ this.emitTrace({
2562
+ type: 'mcpl:conversation-closed',
2563
+ channelId: binding.channelId,
2564
+ agentName: binding.agentName,
2565
+ reason: 'idle-ttl',
2566
+ });
2567
+
2568
+ const agent = this.agents.get(binding.agentName);
2569
+ if (!agent) {
2570
+ // Agent vanished (external reset) — nothing to close, just make sure
2571
+ // its bookkeeping doesn't linger.
2572
+ this.agentConfigs.delete(binding.agentName);
2573
+ this.conversationAgentHomes.delete(binding.agentName);
2574
+ continue;
2575
+ }
2576
+ agent.getContextManager().addMessage(
2577
+ 'user',
2578
+ [{ type: 'text', text: this.conversationRouter.closurePrompt }],
2579
+ { channelId: binding.channelId, conversationClosure: true },
2580
+ );
2581
+ this.pendingRequests.push({
2582
+ agentName: binding.agentName,
2583
+ reason: 'conversation:closure',
2584
+ source: 'framework',
2585
+ timestamp: now,
2586
+ });
2587
+ // Disposed when the closure stream ends (driveStream finally), with
2588
+ // the reaper below as fallback.
2589
+ this.closingConversationAgents.add(binding.agentName);
2590
+ }
2591
+
2592
+ // Fallback reaper: a closing fork whose closure inference never ran
2593
+ // (request dropped as stale, inference policy veto) would otherwise stay
2594
+ // registered forever. No active stream + no pending request = done.
2595
+ for (const agentName of [...this.closingConversationAgents]) {
2596
+ if (
2597
+ !this.activeStreams.has(agentName) &&
2598
+ !this.pendingRequests.some((r) => r.agentName === agentName)
2599
+ ) {
2600
+ this.disposeConversationAgent(agentName);
1641
2601
  }
1642
2602
  }
1643
2603
  }
@@ -1657,19 +2617,74 @@ export class AgentFramework {
1657
2617
  const id = this.addMessage('user', event.content, metadata);
1658
2618
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
1659
2619
 
2620
+ // Some push events carry a channel of origin (Discord DMs arrive here, not
2621
+ // via channels/incoming, because discord-mcpl forwards them with the channel
2622
+ // closed). Register + open that channel so routeSpeech can resolve it, and
2623
+ // thread it onto the wake request so the reply routes back to it instead of
2624
+ // the global locus (item-3 redux, DM sub-case).
2625
+ const triggerChannel = this.derivePushEventChannel(event.origin);
2626
+ if (triggerChannel && this.channelRegistry) {
2627
+ this.channelRegistry.ensureChannelRegistered(
2628
+ event.serverId,
2629
+ triggerChannel.channelId,
2630
+ triggerChannel.label,
2631
+ );
2632
+ }
2633
+
1660
2634
  if (event.triggerInference) {
1661
- const targetAgents = event.targetAgents ?? [...this.agents.keys()];
2635
+ // Default broadcast excludes conversation forks (channel-driven).
2636
+ const targetAgents = event.targetAgents
2637
+ ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
1662
2638
  for (const agentName of targetAgents) {
1663
2639
  this.pendingRequests.push({
1664
2640
  agentName,
1665
2641
  reason: 'mcpl:push-event',
1666
2642
  source: event.serverId,
1667
2643
  timestamp: Date.now(),
2644
+ channelId: triggerChannel?.channelId,
1668
2645
  });
1669
2646
  }
1670
2647
  }
1671
2648
  }
1672
2649
 
2650
+ /**
2651
+ * Derive the MCPL composite channel id (the outbound routing locus) for a
2652
+ * push event from its server-defined `origin`, if it carries one.
2653
+ *
2654
+ * Discord DMs are the motivating case: discord-mcpl forwards them via
2655
+ * push/event (the DM channel is closed), so they never pass through
2656
+ * channels/incoming and their channel is never registered — leaving the
2657
+ * agent's reply with nowhere to route (item-3 redux, DM sub-case). Prefers an
2658
+ * explicit `origin.mcplChannelId` (a surface declaring its own composite id —
2659
+ * the surface-agnostic contract); otherwise reconstructs the Discord form
2660
+ * `discord:{guildId|dm}:{channelId}` from origin fields, matching
2661
+ * discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the
2662
+ * fix works even against a discord-mcpl build that predates `mcplChannelId`.
2663
+ * Returns undefined for push events with no channel provenance (heartbeats,
2664
+ * timers), which correctly keep the global fallback.
2665
+ */
2666
+ private derivePushEventChannel(
2667
+ origin: Record<string, unknown> | undefined,
2668
+ ): { channelId: string; label?: string } | undefined {
2669
+ if (!origin) return undefined;
2670
+ const label = typeof origin.channelName === 'string' ? origin.channelName : undefined;
2671
+
2672
+ const explicit = origin.mcplChannelId;
2673
+ if (typeof explicit === 'string' && explicit) {
2674
+ return { channelId: explicit, label };
2675
+ }
2676
+
2677
+ // Discord fallback: reconstruct the composite from origin parts. `guildId`
2678
+ // is null for a DM (→ 'dm'); a real guild id for a non-open guild channel.
2679
+ if (origin.source === 'discord' && typeof origin.channelId === 'string' && origin.channelId) {
2680
+ const guild =
2681
+ typeof origin.guildId === 'string' && origin.guildId ? origin.guildId : 'dm';
2682
+ return { channelId: `discord:${guild}:${origin.channelId}`, label };
2683
+ }
2684
+
2685
+ return undefined;
2686
+ }
2687
+
1673
2688
  private async processInferenceRequests(): Promise<void> {
1674
2689
  if (this.pendingRequests.length === 0) {
1675
2690
  return;
@@ -1730,7 +2745,19 @@ export class AgentFramework {
1730
2745
 
1731
2746
  // Start streaming inference (non-blocking — driveStream runs in background)
1732
2747
  const trigger = requests[0];
1733
- await this.startAgentStream(agent, trigger);
2748
+ // Route this turn's auto-published speech to the channel that triggered
2749
+ // it (item-3 redux). A batched wake may carry several triggering channels
2750
+ // (messages arrived in >1 channel while the agent was busy/idle) — reply
2751
+ // in the MOST-RECENT one: it matches the legacy last-inbound semantics and
2752
+ // is the message the agent is most likely answering. The common case is
2753
+ // 1 message → 1 channel (unambiguous). Non-channel wakes carry no
2754
+ // channelId, leaving the field undefined → global fallback. `reduce`
2755
+ // keeps the last defined channelId across the (FIFO-ordered) batch.
2756
+ const triggerChannel = requests.reduce<string | undefined>(
2757
+ (acc, r) => r.channelId ?? acc,
2758
+ undefined,
2759
+ );
2760
+ await this.startAgentStream(agent, { ...trigger, channelId: triggerChannel });
1734
2761
  }
1735
2762
  }
1736
2763
 
@@ -1741,6 +2768,18 @@ export class AgentFramework {
1741
2768
  this.redoStacks.delete(agent.name); // new work invalidates redo
1742
2769
  }
1743
2770
 
2771
+ // Establish this turn's outbound routing locus (item-3 redux). Set it to the
2772
+ // triggering channel for a channel/DM-triggered turn; clear it otherwise so
2773
+ // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
2774
+ // instead falls back to the global default. A given agent runs one turn at a
2775
+ // time, so a set here is only read during THIS turn's routeSpeech /
2776
+ // buildChannelContext; retries re-run with the same trigger, re-setting it.
2777
+ if (trigger?.channelId) {
2778
+ this.activeTriggerChannels.set(agent.name, trigger.channelId);
2779
+ } else {
2780
+ this.activeTriggerChannels.delete(agent.name);
2781
+ }
2782
+
1744
2783
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
1745
2784
  this.eventGate?.onInferenceStarted(agent.name);
1746
2785
 
@@ -1753,6 +2792,11 @@ export class AgentFramework {
1753
2792
  let injections: ContextInjection[] | undefined;
1754
2793
 
1755
2794
  // Module gatherContext (fail-open, 5s timeout per module)
2795
+ // NOTE: module injections are NOT channel-scoped — only the MCPL hook
2796
+ // injections below pass through scopeInjectionsForAgent (channel
2797
+ // context arrives via beforeInference hooks by adapter convention). A
2798
+ // module that starts emitting per-channel context must scope it per
2799
+ // agent itself, or conversation forks will see cross-channel content.
1756
2800
  try {
1757
2801
  const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
1758
2802
  if (moduleInjections.length > 0) {
@@ -1766,7 +2810,10 @@ export class AgentFramework {
1766
2810
  if (this.hookOrchestrator) {
1767
2811
  try {
1768
2812
  const hookParams = this.buildBeforeInferenceParams(agent, trigger);
1769
- const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
2813
+ const hookInjections = this.scopeInjectionsForAgent(
2814
+ agent.name,
2815
+ await this.hookOrchestrator.beforeInference(hookParams),
2816
+ );
1770
2817
  if (hookInjections.length > 0) {
1771
2818
  injections = injections ? [...injections, ...hookInjections] : hookInjections;
1772
2819
  }
@@ -1819,6 +2866,13 @@ export class AgentFramework {
1819
2866
  const myStreamId = agent.streamId;
1820
2867
  let hadToolCalls = false;
1821
2868
 
2869
+ // Typing indicator: show "<agent> is typing…" in the channel she's
2870
+ // responding to, for the whole duration of this turn. Started here (paired
2871
+ // with the finally below, so it can never leak) and refreshed on a 7s
2872
+ // interval by the ChannelRegistry until stopped on any exit path.
2873
+ const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
2874
+ if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
2875
+
1822
2876
  try {
1823
2877
  for await (const event of stream) {
1824
2878
  switch (event.type) {
@@ -1852,13 +2906,27 @@ export class AgentFramework {
1852
2906
  calls: event.calls.map((c) => ({ id: c.id, name: c.name, input: c.input })),
1853
2907
  });
1854
2908
 
1855
- // Build assistant content blocks from preamble text + tool_use blocks
1856
- const assistantBlocks: ContentBlock[] = [];
1857
- if (event.context.preamble) {
1858
- assistantBlocks.push({ type: 'text', text: event.context.preamble });
1859
- }
1860
- for (const c of event.calls) {
1861
- assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input as Record<string, unknown> });
2909
+ // Build assistant content blocks for this round. Prefer the
2910
+ // membrane's verbatim round content (membrane ≥0.5.64): it
2911
+ // preserves native thinking / redacted_thinking blocks with
2912
+ // their signatures IN ORDER — the API requires signed thinking
2913
+ // to precede its tool_use in the same assistant turn, so
2914
+ // rebuilding from preamble + calls would break thinking+tools.
2915
+ const roundContent = (event.context as { roundContent?: ContentBlock[] }).roundContent;
2916
+ let assistantBlocks: ContentBlock[];
2917
+ if (roundContent && roundContent.length > 0) {
2918
+ assistantBlocks = roundContent.filter(
2919
+ (b) => b.type !== 'tool_result'
2920
+ );
2921
+ } else {
2922
+ // Fallback (older membrane / XML tool mode): preamble + calls
2923
+ assistantBlocks = [];
2924
+ if (event.context.preamble) {
2925
+ assistantBlocks.push({ type: 'text', text: event.context.preamble });
2926
+ }
2927
+ for (const c of event.calls) {
2928
+ assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input as Record<string, unknown> });
2929
+ }
1862
2930
  }
1863
2931
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
1864
2932
 
@@ -1909,7 +2977,9 @@ export class AgentFramework {
1909
2977
  const toolResultContent: ContentBlock[] = readyState.toolResults.map(tc => ({
1910
2978
  type: 'tool_result' as const,
1911
2979
  toolUseId: tc.id,
1912
- content: tc.result.isError ? (tc.result.error ?? 'Unknown error') : JSON.stringify(tc.result.data),
2980
+ content: tc.result.isError
2981
+ ? (tc.result.error ?? 'Unknown error')
2982
+ : toolResultDataToHistoryString(tc.result.data),
1913
2983
  isError: tc.result.isError,
1914
2984
  }));
1915
2985
  agent.getContextManager().addMessage('user', toolResultContent);
@@ -1917,18 +2987,20 @@ export class AgentFramework {
1917
2987
  }
1918
2988
 
1919
2989
  // Add assistant response to context.
1920
- // If we had tool calls, the tool_use blocks were already stored as
1921
- // pendingAssistantBlocks and flushed when tool results arrived.
1922
- // Only store trailing content (text after the last tool round) to
1923
- // avoid double-storing tool_use blocks.
1924
- const hadToolCalls = response.content.some(
1925
- (b: ContentBlock) => b.type === 'tool_use' || b.type === 'tool_result'
2990
+ // If we had tool calls, each round's blocks (thinking + text +
2991
+ // tool_use) were already stored as pendingAssistantBlocks and
2992
+ // flushed when tool results arrived. Only store TRAILING content
2993
+ // — blocks after the last tool block. A type-based filter would
2994
+ // double-store earlier rounds' text/thinking AND detach signed
2995
+ // thinking blocks from their tool_use turn (the API requires
2996
+ // thinking to precede tool_use in the same assistant turn).
2997
+ const lastToolIdx = response.content.reduce(
2998
+ (last: number, b: ContentBlock, i: number) =>
2999
+ b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
3000
+ -1
1926
3001
  );
1927
- if (hadToolCalls) {
1928
- // Filter to only trailing text/thinking blocks (no tool_use — those are already stored)
1929
- const trailingContent = response.content.filter(
1930
- (block: ContentBlock) => block.type !== 'tool_use' && block.type !== 'tool_result'
1931
- );
3002
+ if (lastToolIdx >= 0) {
3003
+ const trailingContent = response.content.slice(lastToolIdx + 1);
1932
3004
  if (trailingContent.length > 0) {
1933
3005
  agent.addAssistantResponse(trailingContent);
1934
3006
  }
@@ -2034,6 +3106,24 @@ export class AgentFramework {
2034
3106
  stopReason: response.stopReason,
2035
3107
  });
2036
3108
 
3109
+ // Surface refusals instead of going silently mute: stderr line
3110
+ // (headless inference failures are otherwise under-logged) + an
3111
+ // emoji reaction on the triggering Discord message, keyed by the
3112
+ // refusal category from stop_details.
3113
+ if (response.stopReason === 'refusal') {
3114
+ // stop_details lives on the raw PROVIDER response
3115
+ // (response.raw is RawAccess = { request, response, headers }).
3116
+ const stopDetails = (response.raw?.response as {
3117
+ stop_details?: { category?: string; explanation?: string };
3118
+ } | undefined)?.stop_details;
3119
+ const category = stopDetails?.category ?? 'unknown';
3120
+ console.error(
3121
+ `[inference-refusal] agent=${agent.name} category=${category}` +
3122
+ (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
3123
+ );
3124
+ void this.reactToRefusal(agent.name, category);
3125
+ }
3126
+
2037
3127
  // Dispatch speech (and thoughts if any)
2038
3128
  if (speechContent.length > 0 || thoughts.length > 0) {
2039
3129
  const speechContext: SpeechContext = {
@@ -2052,6 +3142,94 @@ export class AgentFramework {
2052
3142
  );
2053
3143
  }
2054
3144
 
3145
+ // Host-owned output routing (see LOCUS-ROUTING-DESIGN). On a
3146
+ // text-only turn (no tool calls => speechContent populated),
3147
+ // publish the agent's speech to the conversational locus — the
3148
+ // most recent incoming channel, tracked cross-surface in the
3149
+ // ChannelRegistry. This replaces discord-mcpl's per-surface sticky
3150
+ // auto-post. Tool-call turns produce `thoughts`, not `speech`, so
3151
+ // they are never routed here — which is precisely how the `think`
3152
+ // tool (and any explicit send tool) yields a silent turn.
3153
+ if (speechContent.length > 0 && this.channelRegistry) {
3154
+ const speechText = speechContent
3155
+ .map((b) => (b as ContentBlock & { type: 'text' }).text)
3156
+ .join('\n')
3157
+ .trim();
3158
+ if (speechText) {
3159
+ try {
3160
+ await this.channelRegistry.routeSpeech(agent.name, speechText);
3161
+ } catch (err) {
3162
+ console.error('speech routing failed:', err);
3163
+ }
3164
+ }
3165
+ } else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
3166
+ // Tool-call turn that also produced prose. Per design, route the
3167
+ // prose to the locus as a reply UNLESS the turn used an explicit
3168
+ // send/publish tool (channel_publish, *--send_message,
3169
+ // *--reply_message, *--send_dm) — already delivered, so routing
3170
+ // again would double-post. `think` and other non-sending tools
3171
+ // (shell, workspace, etc.) still let the agent speak in the same
3172
+ // turn. The global speech/thoughts split is left untouched
3173
+ // (module/TUI rendering unaffected) — this only governs what
3174
+ // reaches the channel.
3175
+ // Tools whose presence suppresses the trailing prose, for two
3176
+ // distinct reasons:
3177
+ // - `skip_reply` — the agent EXPLICITLY chose not to reply this
3178
+ // turn (the deliberate "stay silent" signal).
3179
+ // - explicit delivery tools (channel_publish / *send_message /
3180
+ // *reply_message / *send_dm) — already sent the message, so
3181
+ // routing it again would double-post.
3182
+ // `think` is deliberately NOT here: it is silent *reasoning*, but
3183
+ // prose written after a think is the agent's actual reply and must
3184
+ // be delivered. (A think-only turn has no trailing prose and stays
3185
+ // silent via the `!t` check below.)
3186
+ const SILENCING = new Set([
3187
+ 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
3188
+ ]);
3189
+ const bare = (n: string) => (n.includes('--') ? n.split('--').pop()! : n);
3190
+ const toolNames = response.content
3191
+ .filter((b) => b.type === 'tool_use')
3192
+ .map((b) => (b as unknown as { name?: string }).name)
3193
+ .filter((n): n is string => typeof n === 'string');
3194
+ const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
3195
+
3196
+ // Split the turn's content into ordered prose segments, broken at
3197
+ // each tool_use / tool_result boundary. `response.content` holds the
3198
+ // WHOLE turn's blocks in provider order — earlier tool rounds
3199
+ // included (see the trailing-slice note above, which slices exactly
3200
+ // because the full turn is present) — so a left-to-right walk
3201
+ // reconstructs the emission order. Interleaved prose
3202
+ // ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
3203
+ // separate messages IN ORDER instead of being collapsed into one
3204
+ // trailing post (item 4). The silencing rule stays turn-wide: an
3205
+ // explicit send / skip suppresses ALL auto-routed prose this turn,
3206
+ // preserving the double-post guard.
3207
+ const segments = splitProseSegments(response.content);
3208
+
3209
+ if (silenced || segments.length === 0) {
3210
+ console.error(
3211
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
3212
+ `(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
3213
+ );
3214
+ } else {
3215
+ console.error(
3216
+ `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s) in order`,
3217
+ );
3218
+ // Deliver sequentially (await each) so the segments land in order.
3219
+ for (const seg of segments) {
3220
+ try {
3221
+ await this.channelRegistry.routeSpeech(agent.name, seg);
3222
+ } catch (err) {
3223
+ console.error('speech routing failed:', err);
3224
+ }
3225
+ }
3226
+ }
3227
+ }
3228
+ // NOTE: agent.reset() + onInferenceEnded() already ran above, BEFORE
3229
+ // dispatchSpeech (main's zombie-subagent fix, PR #32 — sync listeners
3230
+ // must see status === 'idle'). Locus routing is speech dispatch and
3231
+ // doesn't depend on the status field, so it correctly runs after.
3232
+
2055
3233
  break;
2056
3234
  }
2057
3235
 
@@ -2147,9 +3325,18 @@ export class AgentFramework {
2147
3325
  agent.reset();
2148
3326
  this.eventGate?.onInferenceEnded(agent.name);
2149
3327
  } finally {
3328
+ // Stop the typing indicator on every exit path (complete, error,
3329
+ // exhausted, abort) so it never sticks after the turn ends.
3330
+ this.channelRegistry?.stopTyping();
2150
3331
  this.activeStreams.delete(agent.name);
2151
3332
  this.pendingAssistantBlocks.delete(agent.name);
2152
3333
 
3334
+ // A conversation fork whose TTL closure turn just finished is done for
3335
+ // good — dispose it so the agent map doesn't grow monotonically.
3336
+ if (this.closingConversationAgents.has(agent.name)) {
3337
+ this.disposeConversationAgent(agent.name);
3338
+ }
3339
+
2153
3340
  // Flush any deferred messages (e.g. if stream failed while tools were pending)
2154
3341
  if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
2155
3342
  const deferred = this.deferredMessages.splice(0);
@@ -2216,17 +3403,57 @@ export class AgentFramework {
2216
3403
  }
2217
3404
 
2218
3405
  private toMembraneToolResult(callId: string, afResult: ToolResult, maxChars?: number): MembraneToolResult {
2219
- let content: string;
2220
3406
  if (afResult.isError) {
2221
- content = afResult.error ?? 'Unknown error';
2222
- } else {
2223
- content = JSON.stringify(afResult.data);
2224
- if (maxChars && content.length > maxChars) {
2225
- content = safeSlice(content, 0, maxChars)
2226
- + '\n\n[truncated — original was ' + content.length + ' chars]';
3407
+ return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
3408
+ }
3409
+ // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
3410
+ // blocks natively rather than JSON-stringifying them away. Anything else
3411
+ // (objects, scalars) falls through to JSON. The error path was handled
3412
+ // above, so isError is always false on these return paths.
3413
+ const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
3414
+ if (blocks) {
3415
+ return { toolUseId: callId, content: blocks, isError: false };
3416
+ }
3417
+ let content = JSON.stringify(afResult.data);
3418
+ if (maxChars && content.length > maxChars) {
3419
+ content = safeSlice(content, 0, maxChars)
3420
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
3421
+ }
3422
+ return { toolUseId: callId, content, isError: false };
3423
+ }
3424
+
3425
+ /**
3426
+ * If `data` is an MCP tool-result content array carrying at least one image,
3427
+ * convert to Membrane's native ToolResultContentBlock[]. Returns null when
3428
+ * the array is text-only (let JSON path handle it; saves a code path).
3429
+ * `maxChars`, when provided, caps each accompanying text block so an image
3430
+ * inlined alongside an enormous text payload can't blow the context.
3431
+ */
3432
+ private tryNativeToolResultContent(data: unknown, maxChars?: number): ToolResultContentBlock[] | null {
3433
+ if (!Array.isArray(data)) return null;
3434
+ let hasImage = false;
3435
+ const blocks: ToolResultContentBlock[] = [];
3436
+ for (const raw of data) {
3437
+ if (!raw || typeof raw !== 'object') return null;
3438
+ const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
3439
+ if (b.type === 'text' && typeof b.text === 'string') {
3440
+ let text = b.text;
3441
+ if (maxChars && text.length > maxChars) {
3442
+ text = safeSlice(text, 0, maxChars)
3443
+ + '\n\n[truncated — original was ' + text.length + ' chars]';
3444
+ }
3445
+ blocks.push({ type: 'text', text });
3446
+ } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
3447
+ hasImage = true;
3448
+ blocks.push({
3449
+ type: 'image',
3450
+ source: { type: 'base64', data: b.data, mediaType: b.mimeType },
3451
+ });
3452
+ } else {
3453
+ return null; // unknown shape — bail to JSON path
2227
3454
  }
2228
3455
  }
2229
- return { toolUseId: callId, content, isError: afResult.isError };
3456
+ return hasImage ? blocks : null;
2230
3457
  }
2231
3458
 
2232
3459
  private getMaxToolResultChars(agent: Agent): number | undefined {
@@ -2320,12 +3547,39 @@ export class AgentFramework {
2320
3547
  return;
2321
3548
  }
2322
3549
 
2323
- // Route gate:status tool
2324
- if (enrichedCall.name === 'gate:status' && this.eventGate) {
3550
+ // Route gate_status tool
3551
+ if (enrichedCall.name === 'gate_status' && this.eventGate) {
2325
3552
  this.dispatchGateToolCall(agentName, enrichedCall);
2326
3553
  return;
2327
3554
  }
2328
3555
 
3556
+ // Route sleep / wake tools
3557
+ if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
3558
+ this.dispatchSleepToolCall(agentName, enrichedCall);
3559
+ return;
3560
+ }
3561
+
3562
+ // Route wake-rule tools (runtime gate.json policy add/remove)
3563
+ if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
3564
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
3565
+ return;
3566
+ }
3567
+
3568
+ // Route event_tags (tag/ontology discovery)
3569
+ if (enrichedCall.name === 'event_tags' && this.eventGate) {
3570
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
3571
+ return;
3572
+ }
3573
+
3574
+ // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
3575
+ // stay-silent) tools — handled by the channel registry like the other
3576
+ // synthesized channel tools, but they aren't `channel_`-prefixed so they
3577
+ // need an explicit route here.
3578
+ if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
3579
+ this.dispatchChannelToolCall(agentName, enrichedCall);
3580
+ return;
3581
+ }
3582
+
2329
3583
  const sepIndex = enrichedCall.name.indexOf('--');
2330
3584
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
2331
3585
  const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
@@ -2479,6 +3733,27 @@ export class AgentFramework {
2479
3733
  }
2480
3734
 
2481
3735
  private emitTrace(event: { type: TraceEvent['type']; [key: string]: unknown }): void {
3736
+ // Centralized inference-health observability. Every terminal failure path
3737
+ // funnels through an `inference:exhausted` trace and every successful model
3738
+ // response through `inference:completed`, so intercepting here is the one
3739
+ // place that reliably sees all outcomes regardless of which code path
3740
+ // produced them. In headless/daemon mode no trace client is attached, so
3741
+ // without this the only durable record of a failed inference is a field in
3742
+ // llm-calls.jsonl — invisible to operator, agent, and monitoring.
3743
+ if (event.type === 'inference:exhausted') {
3744
+ this.noteInferenceExhausted(
3745
+ (event.agentName as string) ?? 'unknown',
3746
+ (event.error as string) ?? 'unknown error',
3747
+ );
3748
+ } else if (event.type === 'inference:completed') {
3749
+ // A successful response — even mid-turn between tool calls — proves the
3750
+ // agent isn't hard-down; clear its consecutive-failure streak.
3751
+ const name = event.agentName as string | undefined;
3752
+ if (name && this.consecutiveInferenceFailures.get(name)) {
3753
+ this.consecutiveInferenceFailures.set(name, 0);
3754
+ }
3755
+ }
3756
+
2482
3757
  const traceEvent = {
2483
3758
  ...event,
2484
3759
  timestamp: Date.now(),
@@ -2493,6 +3768,59 @@ export class AgentFramework {
2493
3768
  }
2494
3769
  }
2495
3770
 
3771
+ /**
3772
+ * Handle a fully-exhausted inference (the agent could not produce a response
3773
+ * this turn, after retries). Severity here is high — the agent can't think
3774
+ * at all — yet historically the only durable record was a buried field in
3775
+ * llm-calls.jsonl. This surfaces it three ways:
3776
+ *
3777
+ * 1. stderr — always, with the underlying API reason. The place an
3778
+ * operator greps; previously empty in headless mode.
3779
+ * 2. chronicle marker — a `[inference-failed]` message so the agent itself
3780
+ * learns its turn failed and why (otherwise it's an experiential blank,
3781
+ * indistinguishable from "not addressed"). addMessage does NOT request
3782
+ * inference, so this never causes a retry/wake loop.
3783
+ * 3. escalation — after N consecutive failures the agent is hard-down;
3784
+ * log that loudly (a repeated identical failure is the textbook signal).
3785
+ */
3786
+ private noteInferenceExhausted(agentName: string, reason: string): void {
3787
+ const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
3788
+ this.consecutiveInferenceFailures.set(agentName, streak);
3789
+
3790
+ // (1) Durable stderr line — works in headless/daemon mode with no client.
3791
+ console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
3792
+
3793
+ // (2) Agent-facing chronicle marker (no inference triggered → no loop).
3794
+ const agent = this.agents.get(agentName);
3795
+ if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
3796
+ try {
3797
+ agent.getContextManager().addMessage(
3798
+ 'user',
3799
+ [{
3800
+ type: 'text',
3801
+ text:
3802
+ `[inference-failed] Your previous turn did not complete: the model ` +
3803
+ `call failed and produced no response, so nothing was sent. Reason: ` +
3804
+ `${reason}. If this recurs with the same cause, change approach ` +
3805
+ `rather than retrying identically (e.g. drop an oversized attachment ` +
3806
+ `or an unsupported setting).`,
3807
+ }],
3808
+ { system: true, kind: 'inference-failed', reason, consecutive: streak },
3809
+ );
3810
+ } catch (err) {
3811
+ console.error(`[inference-failed] could not record chronicle marker for ${agentName}:`, err);
3812
+ }
3813
+ }
3814
+
3815
+ // (3) Hard-down escalation on repeated identical failure.
3816
+ if (streak >= this.inferenceFailureEscalationThreshold) {
3817
+ console.error(
3818
+ `[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
3819
+ `inferences — it cannot complete a turn. Last reason: ${reason}`,
3820
+ );
3821
+ }
3822
+ }
3823
+
2496
3824
  // ==========================================================================
2497
3825
  // MCPL subsystem wiring
2498
3826
  // ==========================================================================
@@ -2563,6 +3891,42 @@ export class AgentFramework {
2563
3891
  }
2564
3892
  },
2565
3893
  shouldTriggerInference: triggerFilter,
3894
+ // Route a conversation fork's plain-text speech to its HOME channel, not
3895
+ // the process-global most-recent-inbound locus (item 3). The trunk agent
3896
+ // has no home entry, so this returns undefined and routeSpeech falls back
3897
+ // to defaultPublishChannel. `conversationAgentHomes` is the permanent
3898
+ // spawn-time binding; `channelForAgent` is the router's live binding as a
3899
+ // belt-and-suspenders fallback.
3900
+ homeChannelResolver: (agentName) =>
3901
+ this.conversationAgentHomes.get(agentName)
3902
+ ?? this.conversationRouter?.channelForAgent(agentName),
3903
+ // Route a single TRUNK agent's plain-text speech to the channel that
3904
+ // triggered its CURRENT turn (item-3 redux). connectome-host runs every
3905
+ // agent as a trunk (it never exposes conversation forks), so without this
3906
+ // a reply falls back to the process-global most-recent-inbound locus and
3907
+ // a concurrent message in another channel hijacks it. Empty for
3908
+ // heartbeat / no-trigger turns → correct global fallback.
3909
+ activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
3910
+ // A text-only turn whose speech couldn't be delivered must not vanish
3911
+ // silently: record a `[discord-send-failed]` marker in chronicle so the
3912
+ // agent sees, on her next turn, that her reply never reached the human.
3913
+ // addMessage() alone does not request inference, so this never wakes
3914
+ // her (matching the `discord-send-failed-skip` gate intent: context
3915
+ // yes, wake no).
3916
+ onRouteFailure: ({ channelId, reason, textLen }) => {
3917
+ try {
3918
+ this.addMessage(
3919
+ 'user',
3920
+ [{
3921
+ type: 'text',
3922
+ text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${channelId ?? 'the channel'} (${reason}). It was saved to your archive but the human did not receive it.`,
3923
+ }],
3924
+ { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
3925
+ );
3926
+ } catch (err) {
3927
+ console.error('onRouteFailure: failed to record send-failure marker:', err);
3928
+ }
3929
+ },
2566
3930
  },
2567
3931
  );
2568
3932
 
@@ -2716,6 +4080,21 @@ export class AgentFramework {
2716
4080
  this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
2717
4081
  });
2718
4082
 
4083
+ // Handle host-level admin commands from a surface (e.g. Discord /undo)
4084
+ connection.on('host-command', async (
4085
+ params: HostCommandParams,
4086
+ responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
4087
+ ) => {
4088
+ if (!responder) return;
4089
+ try {
4090
+ const result = await this.handleHostCommand(connection.id, params ?? {});
4091
+ responder.respond(result);
4092
+ } catch (error) {
4093
+ const err = error instanceof Error ? error : new Error(String(error));
4094
+ responder.respondError(-32603, err.message);
4095
+ }
4096
+ });
4097
+
2719
4098
  // Handle dynamic tool list changes (notifications/tools/list_changed)
2720
4099
  connection.on('tools-list-changed', () => {
2721
4100
  this.handleToolsListChanged(connection.id);
@@ -2860,14 +4239,20 @@ export class AgentFramework {
2860
4239
  const startTime = Date.now();
2861
4240
  const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
2862
4241
 
2863
- // Build state params for stateful tools (Step 8)
4242
+ // Build state params for stateful tools (Step 8).
4243
+ // Host-managed state is single-valued per server, so inject the host-managed
4244
+ // set's `state` for any call; otherwise fall back to a server-managed set's
4245
+ // opaque `checkpoint`. Never blindly pick "first stateful" — when a server
4246
+ // mixes host-managed (e.g. a feed) and server-managed (e.g. post/undo) sets,
4247
+ // that misattributes the feed's state.
2864
4248
  let stateParams: { state?: unknown; checkpoint?: string } | undefined;
2865
4249
  if (this.checkpointManager) {
2866
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
2867
- if (fs) {
2868
- if (this.checkpointManager.isHostManaged(serverId, fs)) {
2869
- stateParams = { state: this.checkpointManager.getCurrentState(serverId, fs) };
2870
- } else {
4250
+ const hostFs = this.checkpointManager.getHostManagedFeatureSet(serverId);
4251
+ if (hostFs) {
4252
+ stateParams = { state: this.checkpointManager.getCurrentState(serverId, hostFs) };
4253
+ } else {
4254
+ const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
4255
+ if (fs) {
2871
4256
  const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
2872
4257
  if (cp) stateParams = { checkpoint: cp };
2873
4258
  }
@@ -2879,19 +4264,37 @@ export class AgentFramework {
2879
4264
  const durationMs = Date.now() - startTime;
2880
4265
  this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
2881
4266
 
2882
- // Record checkpoint from stateful tool response (Step 8)
4267
+ // Record checkpoint from stateful tool response (Step 8).
4268
+ // Attribute to the set the server tagged (result.state.featureSet) first;
4269
+ // else the host-managed set (host-managed checkpoints carry data/patch);
4270
+ // else the single stateful set. Avoids recording a feed checkpoint onto a
4271
+ // server-managed set just because it registered first.
2883
4272
  if (result.state && this.checkpointManager) {
2884
- const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
2885
- if (fs) {
4273
+ const tagged = result.state.featureSet;
4274
+ const fs = tagged
4275
+ ?? this.checkpointManager.getHostManagedFeatureSet(serverId)
4276
+ ?? this.checkpointManager.getStatefulFeatureSet(serverId);
4277
+ if (fs && this.checkpointManager.isStateful(serverId, fs)) {
2886
4278
  this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
2887
4279
  }
2888
4280
  }
2889
4281
 
2890
- // Convert MCP tool result to framework ToolResult
4282
+ // Convert MCP tool result to framework ToolResult.
4283
+ // When the result contains non-text blocks (e.g. images from an MCP
4284
+ // tool like zulip-mcp's fetch_attachment), pass the full content array
4285
+ // through so toMembraneToolResult can preserve image blocks natively.
4286
+ // Text-only results still collapse to a joined string for backward
4287
+ // compatibility with callers that expect data to be string-ish.
2891
4288
  const textContent = result.content
2892
4289
  ?.filter((c) => c.type === 'text' && c.text)
2893
4290
  .map((c) => c.text!)
2894
4291
  .join('\n');
4292
+ const hasNonText = result.content?.some((c) => c.type !== 'text');
4293
+ const data = result.isError
4294
+ ? undefined
4295
+ : hasNonText
4296
+ ? result.content
4297
+ : (textContent || undefined);
2895
4298
 
2896
4299
  this.pushEvent({
2897
4300
  type: 'tool-result',
@@ -2900,7 +4303,7 @@ export class AgentFramework {
2900
4303
  moduleName: `mcpl:${serverId}`,
2901
4304
  result: {
2902
4305
  success: !result.isError,
2903
- data: result.isError ? undefined : textContent || undefined,
4306
+ data,
2904
4307
  error: result.isError ? (textContent || 'Tool call failed') : undefined,
2905
4308
  isError: result.isError ?? false,
2906
4309
  },
@@ -2927,6 +4330,43 @@ export class AgentFramework {
2927
4330
  * Dispatch a synthesized channel tool call.
2928
4331
  */
2929
4332
  private dispatchChannelToolCall(agentName: string, call: ToolCall): void {
4333
+ // Conversation forks act only on their home channel. channel_publish and
4334
+ // channel_close default a missing channelId to home and reject foreign
4335
+ // ones; channel_open is rejected outright — opening channels mutates
4336
+ // framework-global state (the open-channel set every agent's injections
4337
+ // are scoped against), which is not a fork's call to make.
4338
+ const home = this.conversationAgentHomes.get(agentName);
4339
+ if (home) {
4340
+ const reject = (error: string): void => {
4341
+ this.emitTrace({
4342
+ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
4343
+ error: `conversation agent ${agentName}: ${error}`,
4344
+ });
4345
+ this.pushEvent({
4346
+ type: 'tool-result',
4347
+ callId: call.id,
4348
+ agentName,
4349
+ moduleName: 'channels',
4350
+ result: { success: false, error, isError: true },
4351
+ });
4352
+ };
4353
+
4354
+ if (call.name === 'channel_open') {
4355
+ reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`);
4356
+ return;
4357
+ }
4358
+ if (call.name === 'channel_publish' || call.name === 'channel_close') {
4359
+ const input = (call.input ?? {}) as { channelId?: string };
4360
+ if (!input.channelId) {
4361
+ call = { ...call, input: { ...input, channelId: home } };
4362
+ } else if (input.channelId !== home) {
4363
+ const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing';
4364
+ reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`);
4365
+ return;
4366
+ }
4367
+ }
4368
+ }
4369
+
2930
4370
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
2931
4371
  const startTime = Date.now();
2932
4372
 
@@ -2955,6 +4395,174 @@ export class AgentFramework {
2955
4395
  });
2956
4396
  }
2957
4397
 
4398
+ /**
4399
+ * Handle the synthesized `wake_add_rule` / `wake_remove_rule` tools: mutate
4400
+ * the hot-reloaded gate.json at runtime. Validation lives in the EventGate
4401
+ * (same path as gate.json load), so an invalid rule is surfaced as a tool
4402
+ * error and nothing is written.
4403
+ */
4404
+ private dispatchWakeRuleToolCall(agentName: string, call: ToolCall): void {
4405
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4406
+ const finish = (result: ToolResult) => {
4407
+ this.emitTrace({
4408
+ type: result.isError ? 'tool:failed' : 'tool:completed',
4409
+ module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
4410
+ ...(result.isError ? { error: result.error } : {}),
4411
+ });
4412
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
4413
+ };
4414
+
4415
+ try {
4416
+ if (call.name === 'wake_remove_rule') {
4417
+ const input = (call.input ?? {}) as { name?: unknown };
4418
+ if (typeof input.name !== 'string' || !input.name) {
4419
+ finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
4420
+ return;
4421
+ }
4422
+ const removed = this.removeGatePolicy(input.name);
4423
+ finish({
4424
+ success: true,
4425
+ data: { removed, name: input.name, policies: this.getGatePolicyNames() },
4426
+ });
4427
+ return;
4428
+ }
4429
+
4430
+ // wake_add_rule — assemble the canonical behavior from the typed fields
4431
+ // (exactly one), then let the gate's own validator do the authoritative
4432
+ // range/shape checks.
4433
+ const input = (call.input ?? {}) as {
4434
+ name?: unknown; match?: unknown; resets?: unknown; position?: unknown;
4435
+ behavior?: unknown; debounceMs?: unknown; rateLimit?: unknown; passiveSample?: unknown;
4436
+ };
4437
+ const behaviorSources = [
4438
+ input.behavior !== undefined ? 'behavior' : null,
4439
+ input.debounceMs !== undefined ? 'debounceMs' : null,
4440
+ input.rateLimit !== undefined ? 'rateLimit' : null,
4441
+ input.passiveSample !== undefined ? 'passiveSample' : null,
4442
+ ].filter((s): s is string => s !== null);
4443
+ if (behaviorSources.length === 0) {
4444
+ finish({
4445
+ success: false,
4446
+ error: 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
4447
+ isError: true,
4448
+ });
4449
+ return;
4450
+ }
4451
+ if (behaviorSources.length > 1) {
4452
+ finish({
4453
+ success: false,
4454
+ error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
4455
+ isError: true,
4456
+ });
4457
+ return;
4458
+ }
4459
+ const behavior: unknown =
4460
+ input.debounceMs !== undefined ? { debounce: input.debounceMs }
4461
+ : input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
4462
+ : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
4463
+ : input.behavior;
4464
+
4465
+ const position = input.position === 'prepend' ? 'prepend' : 'append';
4466
+ const rawPolicy = {
4467
+ name: input.name,
4468
+ match: input.match ?? {},
4469
+ behavior,
4470
+ ...(input.resets !== undefined ? { resets: input.resets } : {}),
4471
+ };
4472
+ const policy = this.addGatePolicy(rawPolicy, { position });
4473
+ finish({
4474
+ success: true,
4475
+ data: { added: policy.name, policies: this.getGatePolicyNames() },
4476
+ });
4477
+ } catch (err) {
4478
+ const msg = err instanceof Error ? err.message : String(err);
4479
+ finish({ success: false, error: msg, isError: true });
4480
+ }
4481
+ }
4482
+
4483
+ /**
4484
+ * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
4485
+ * suppression window, optionally announces in the sticky channel, and ends
4486
+ * the turn (the agent goes idle immediately). `wake` clears sleep.
4487
+ */
4488
+ private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
4489
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4490
+ const gate = this.eventGate!;
4491
+ const input = (call.input ?? {}) as { seconds?: number; announce?: boolean; message?: string };
4492
+
4493
+ const finish = (result: ToolResult) => {
4494
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
4495
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
4496
+ };
4497
+
4498
+ if (call.name === 'wake') {
4499
+ const was = gate.clearSleep();
4500
+ finish({ success: true, data: { woke: was } });
4501
+ return;
4502
+ }
4503
+
4504
+ const seconds = Number(input.seconds);
4505
+ if (!Number.isFinite(seconds) || seconds <= 0) {
4506
+ finish({ success: false, error: 'sleep: `seconds` must be a positive number', isError: true });
4507
+ return;
4508
+ }
4509
+
4510
+ const { until } = gate.setSleep(seconds, input.message);
4511
+ const announce = input.announce !== false; // default true
4512
+ const human =
4513
+ seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
4514
+ : seconds >= 60 ? `${Math.round(seconds / 60)}m`
4515
+ : `${Math.round(seconds)}s`;
4516
+
4517
+ // Announce in the sticky channel (best-effort; never blocks the result).
4518
+ if (announce && this.channelRegistry) {
4519
+ const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
4520
+ this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
4521
+ console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
4522
+ });
4523
+ }
4524
+
4525
+ console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
4526
+ // endTurn: the agent stops here and goes idle for the duration.
4527
+ finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
4528
+ }
4529
+
4530
+ /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
4531
+ * server's declared tag ontology + gate.js status. */
4532
+ private buildEventTagsResult(): Record<string, unknown> {
4533
+ const servers: Record<string, Record<string, unknown>> = {};
4534
+ for (const conn of this.mcplServerRegistry?.getAllServers() ?? []) {
4535
+ const declared = this.featureSetManager?.getDeclaredFeatureSets(conn.id) ?? {};
4536
+ const sets: Record<string, unknown> = {};
4537
+ for (const [name, decl] of Object.entries(declared)) {
4538
+ if (decl.tagOntology) sets[name] = decl.tagOntology;
4539
+ }
4540
+ if (Object.keys(sets).length > 0) servers[conn.id] = sets;
4541
+ }
4542
+ return {
4543
+ core: AgentFramework.CHAT_CORE_TAGS,
4544
+ servers,
4545
+ gateScript: this.eventGate?.getStatus().script ?? null,
4546
+ hint:
4547
+ 'Use these in gate.json policies (match.tagsAny / tagsAll / tagsNone) or ' +
4548
+ 'in gate.js. Unknown/undeclared tags are tolerated (open ontologies).',
4549
+ };
4550
+ }
4551
+
4552
+ private dispatchEventTagsToolCall(agentName: string, call: ToolCall): void {
4553
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
4554
+ let result: import('./types/events.js').ToolResult;
4555
+ try {
4556
+ result = { success: true, data: this.buildEventTagsResult() };
4557
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
4558
+ } catch (error) {
4559
+ const err = error instanceof Error ? error : new Error(String(error));
4560
+ this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
4561
+ result = { success: false, error: err.message, isError: true };
4562
+ }
4563
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
4564
+ }
4565
+
2958
4566
  private dispatchGateToolCall(agentName: string, call: ToolCall): void {
2959
4567
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
2960
4568
  const startTime = Date.now();
@@ -2988,7 +4596,13 @@ export class AgentFramework {
2988
4596
  const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2989
4597
  return {
2990
4598
  inferenceId,
2991
- conversationId: agent.name, // Simplified; proper conversation tracking TODO
4599
+ // Conversation identity = the agent (a trunk agent IS its own conversation;
4600
+ // forks get their own agent). The turn's channel LOCUS — the "proper
4601
+ // conversation tracking" this once flagged as a TODO — is now tracked
4602
+ // per-agent in `activeTriggerChannels` and surfaced to the agent via
4603
+ // buildChannelContext (channels.defaultOutgoing) below, so the agent is
4604
+ // told the same channel its speech will route to (item-3 redux).
4605
+ conversationId: agent.name,
2992
4606
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
2993
4607
  userMessage: null, // Could extract from trigger context
2994
4608
  model: {
@@ -2997,7 +4611,7 @@ export class AgentFramework {
2997
4611
  contextWindow: 200000,
2998
4612
  capabilities: ['tools'],
2999
4613
  },
3000
- channels: this.channelRegistry?.buildChannelContext(),
4614
+ channels: this.channelRegistry?.buildChannelContext(agent.name),
3001
4615
  };
3002
4616
  }
3003
4617
  }