@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
@@ -174,6 +174,43 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
174
174
  required: [],
175
175
  },
176
176
  },
177
+ {
178
+ name: 'think',
179
+ description:
180
+ 'Reason privately. The content stays in your own context and is NOT sent to any ' +
181
+ 'channel or surface. This does NOT end or silence your turn: if you also write ' +
182
+ 'plain text this turn, that text is still posted as your reply. Use think() purely ' +
183
+ 'to work things out before (or instead of) speaking. To deliberately NOT reply this ' +
184
+ 'turn, call skip_reply instead.',
185
+ inputSchema: {
186
+ type: 'object' as const,
187
+ properties: {
188
+ content: {
189
+ type: 'string',
190
+ description: 'Your private thought / reasoning (optional; not sent anywhere).',
191
+ },
192
+ },
193
+ required: [],
194
+ },
195
+ },
196
+ {
197
+ name: 'skip_reply',
198
+ description:
199
+ 'End your turn WITHOUT sending anything to any channel or surface. Use when you have ' +
200
+ 'read the messages but deliberately choose not to reply right now — ambient chatter, ' +
201
+ 'nothing to add, or you are waiting. Any plain text you wrote this turn stays private ' +
202
+ 'and is NOT posted. To reply instead, just write plain text (no tool call).',
203
+ inputSchema: {
204
+ type: 'object' as const,
205
+ properties: {
206
+ reason: {
207
+ type: 'string',
208
+ description: 'Optional private note on why you are not replying (not sent anywhere).',
209
+ },
210
+ },
211
+ required: [],
212
+ },
213
+ },
177
214
  ];
178
215
 
179
216
  // ============================================================================
@@ -183,6 +220,45 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
183
220
  interface ChannelRegistryOptions {
184
221
  /** Callback to determine whether an incoming message should trigger inference. */
185
222
  shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
223
+ /**
224
+ * Called when a text-only turn's speech could NOT be delivered to its
225
+ * conversational locus — no locus, an unregistered channel, a missing
226
+ * server, or the server reporting `delivered: false`. The host wires this
227
+ * to drop a `[discord-send-failed]` marker into chronicle so the failure is
228
+ * visible to the agent (and operator) rather than silently lost. Must not
229
+ * itself trigger inference (avoid wake loops).
230
+ */
231
+ onRouteFailure?: (info: {
232
+ conversationId: string;
233
+ channelId: string | null;
234
+ reason: string;
235
+ textLen: number;
236
+ }) => void;
237
+ /**
238
+ * Resolve a conversation fork's HOME channel from its agent name. Conversation
239
+ * forks are spawned bound to a single channel (the framework tracks this in
240
+ * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their
241
+ * plain-text speech must route THERE. The process-global `defaultPublishChannel`
242
+ * tracks only the most-recent inbound across ALL channels, so with one fork per
243
+ * channel running concurrently it misroutes a fork's reply to whichever channel
244
+ * last spoke (item 3). Returns undefined for the trunk/primary agent, which has
245
+ * no home and correctly falls back to the global locus (heartbeats, etc.).
246
+ */
247
+ homeChannelResolver?: (agentName: string) => string | undefined;
248
+ /**
249
+ * Resolve the channel that triggered an agent's CURRENT inference turn, by
250
+ * agent name. This is the fix for single-TRUNK agents (the only mode
251
+ * connectome-host runs — it never exposes conversation forks). A trunk agent
252
+ * has no fork home, so without this its plain-text speech falls back to the
253
+ * process-global `defaultPublishChannel`, which tracks the most-recent inbound
254
+ * across ALL channels and misroutes a reply to whichever channel last spoke
255
+ * under concurrency (item-3 redux). The framework tracks the triggering
256
+ * channel of the live turn per-agent and exposes it here; resolves to
257
+ * undefined for a heartbeat / no-trigger turn, which correctly keeps the
258
+ * global fallback. Consulted AFTER `homeChannelResolver` (a fork's home always
259
+ * wins), BEFORE `defaultPublishChannel`.
260
+ */
261
+ activeChannelResolver?: (agentName: string) => string | undefined;
186
262
  }
187
263
 
188
264
  // ============================================================================
@@ -201,6 +277,14 @@ export class ChannelRegistry {
201
277
  op?: 'start' | 'stop',
202
278
  ) => void;
203
279
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
280
+ private onRouteFailure?: (info: {
281
+ conversationId: string;
282
+ channelId: string | null;
283
+ reason: string;
284
+ textLen: number;
285
+ }) => void;
286
+ private homeChannelResolver?: (agentName: string) => string | undefined;
287
+ private activeChannelResolver?: (agentName: string) => string | undefined;
204
288
 
205
289
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
206
290
  private channels = new Map<string, ChannelEntry>();
@@ -246,6 +330,9 @@ export class ChannelRegistry {
246
330
  this.emitTraceFn = emitTraceFn;
247
331
  this.sendTypingFn = options?.sendTypingFn;
248
332
  this.shouldTriggerInference = options?.shouldTriggerInference;
333
+ this.onRouteFailure = options?.onRouteFailure;
334
+ this.homeChannelResolver = options?.homeChannelResolver;
335
+ this.activeChannelResolver = options?.activeChannelResolver;
249
336
  }
250
337
 
251
338
  /**
@@ -372,6 +459,43 @@ export class ChannelRegistry {
372
459
  this.defaultPublishMessageId = message.messageId;
373
460
  this.defaultPublishThreadId = message.threadId;
374
461
 
462
+ // Lazy-register the channel if we've never seen it. A channel can deliver
463
+ // an incoming message before its channels/register (boot enumeration) or
464
+ // channels/changed (post-boot create / View-permission grant) round-trip
465
+ // lands — or the registration event can be missed entirely (e.g. the bot
466
+ // gains visibility in a way that fires neither `channelCreate` nor a
467
+ // View-permission transition). Without a registry entry, routeSpeech()
468
+ // can't resolve this channel as an outbound locus and the agent's reply
469
+ // is silently dropped, even though this very message proves the channel
470
+ // is reachable. The inbound message carries enough to make it publishable,
471
+ // so register it here; a later authoritative channels/register or
472
+ // channels/changed will overwrite this descriptor with the richer one.
473
+ const incomingKey = `${serverId}:${message.channelId}`;
474
+ if (!this.channels.has(incomingKey)) {
475
+ const channelLabel =
476
+ typeof message.metadata?.channelName === 'string' && message.metadata.channelName
477
+ ? (message.metadata.channelName as string)
478
+ : message.channelId;
479
+ this.channels.set(incomingKey, {
480
+ serverId,
481
+ descriptor: {
482
+ id: message.channelId,
483
+ type: serverId,
484
+ label: channelLabel,
485
+ direction: 'bidirectional',
486
+ address: message.threadId ? { threadId: message.threadId } : undefined,
487
+ metadata: { lazyRegistered: true },
488
+ },
489
+ open: true,
490
+ });
491
+ this.emitTraceFn({
492
+ type: 'mcpl:channel-lazy-registered',
493
+ serverId,
494
+ channelId: message.channelId,
495
+ label: channelLabel,
496
+ });
497
+ }
498
+
375
499
  // Determine whether to trigger inference
376
500
  let triggerInference = true;
377
501
  if (this.shouldTriggerInference) {
@@ -389,6 +513,7 @@ export class ChannelRegistry {
389
513
  messageId: message.messageId,
390
514
  threadId: message.threadId,
391
515
  author: message.author,
516
+ ...(message.tags ? { tags: message.tags } : {}),
392
517
  },
393
518
  );
394
519
  }
@@ -430,6 +555,50 @@ export class ChannelRegistry {
430
555
  });
431
556
  }
432
557
 
558
+ /**
559
+ * Ensure a channel is registered AND open so it can serve as an outbound
560
+ * routing locus, lazy-registering it if unseen and opening it if closed.
561
+ *
562
+ * Mirrors the lazy-registration inside handleIncoming(), but for channels
563
+ * that only ever arrive as push/events rather than channels/incoming — the
564
+ * motivating case is Discord DMs, which discord-mcpl forwards via push/event
565
+ * with the channel closed (`channelIsOpen:false`). Such a channel is never
566
+ * registered, so routeSpeech() can't resolve it and the agent's reply is
567
+ * silently dropped even though the inbound message proves the channel is
568
+ * reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative
569
+ * channels/register or channels/changed overwrites this descriptor with the
570
+ * richer one; re-opening an already-open channel is a no-op.
571
+ */
572
+ ensureChannelRegistered(serverId: string, channelId: string, label?: string): void {
573
+ const existing = this.findChannelEntry(channelId);
574
+ if (existing) {
575
+ if (!existing.open) {
576
+ existing.open = true;
577
+ this.emitTraceFn({ type: 'mcpl:channel-opened', serverId: existing.serverId, channelId });
578
+ }
579
+ return;
580
+ }
581
+
582
+ const key = `${serverId}:${channelId}`;
583
+ this.channels.set(key, {
584
+ serverId,
585
+ descriptor: {
586
+ id: channelId,
587
+ type: serverId,
588
+ label: label ?? channelId,
589
+ direction: 'bidirectional',
590
+ metadata: { lazyRegistered: true },
591
+ },
592
+ open: true,
593
+ });
594
+ this.emitTraceFn({
595
+ type: 'mcpl:channel-lazy-registered',
596
+ serverId,
597
+ channelId,
598
+ label: label ?? channelId,
599
+ });
600
+ }
601
+
433
602
  // ==========================================================================
434
603
  // Typing Indicator Management
435
604
  // ==========================================================================
@@ -536,6 +705,14 @@ export class ChannelRegistry {
536
705
  return this.defaultPublishChannel;
537
706
  }
538
707
 
708
+ /**
709
+ * Get the descriptor for a channel by its channelId (first match across
710
+ * servers). Used by the conversation router for DM classification.
711
+ */
712
+ getDescriptor(channelId: string): ChannelDescriptor | undefined {
713
+ return this.findChannelEntry(channelId)?.descriptor;
714
+ }
715
+
539
716
  /**
540
717
  * Get all open channels.
541
718
  */
@@ -577,6 +754,12 @@ export class ChannelRegistry {
577
754
  case 'channel_publish':
578
755
  return this.handleToolPublish(input as { channelId: string; content: string });
579
756
 
757
+ case 'think':
758
+ return this.handleToolThink(input as { content?: string });
759
+
760
+ case 'skip_reply':
761
+ return this.handleToolSkipReply(input as { reason?: string });
762
+
580
763
  default:
581
764
  return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
582
765
  }
@@ -591,15 +774,28 @@ export class ChannelRegistry {
591
774
  *
592
775
  * Returns undefined if no channels are active.
593
776
  */
594
- buildChannelContext(): ChannelContext | undefined {
777
+ buildChannelContext(agentName?: string): ChannelContext | undefined {
595
778
  const openChannels = this.getOpenChannels();
596
- if (openChannels.length === 0 && !this.defaultPublishChannel) {
779
+
780
+ // Resolve the outbound locus the SAME way routeSpeech does, so the agent is
781
+ // told the channel its speech will actually land in: a conversation fork's
782
+ // home channel, else this turn's triggering channel (single trunk agent —
783
+ // item-3 redux), else the global default (heartbeats). Without this, a fork
784
+ // was advertised the global locus but published somewhere else; and a trunk
785
+ // agent was told the wrong channel under concurrency.
786
+ const home = agentName ? this.homeChannelResolver?.(agentName) : undefined;
787
+ const active = agentName ? this.activeChannelResolver?.(agentName) : undefined;
788
+ const outgoing = home ?? active ?? this.defaultPublishChannel;
789
+
790
+ if (openChannels.length === 0 && !outgoing) {
597
791
  return undefined;
598
792
  }
599
793
 
600
794
  const context: ChannelContext = {};
601
795
 
602
- // Incoming: from defaultPublishChannel if set
796
+ // Incoming: the most-recent inbound message (what the agent is replying to).
797
+ // Left process-global — per-channel inbound tracking (the right messageId for
798
+ // a fork's own channel) is a separate concern from the outbound routing fix.
603
799
  if (this.defaultPublishChannel && this.defaultPublishMessageId) {
604
800
  context.incoming = {
605
801
  channelId: this.defaultPublishChannel,
@@ -608,10 +804,10 @@ export class ChannelRegistry {
608
804
  };
609
805
  }
610
806
 
611
- // Default outgoing: same as incoming (reply to last channel)
612
- if (this.defaultPublishChannel) {
807
+ // Default outgoing: the resolved outbound locus (home channel for forks).
808
+ if (outgoing) {
613
809
  context.defaultOutgoing = {
614
- channelId: this.defaultPublishChannel,
810
+ channelId: outgoing,
615
811
  };
616
812
  }
617
813
 
@@ -730,6 +926,11 @@ export class ChannelRegistry {
730
926
  // Private: Channel Lookup
731
927
  // ==========================================================================
732
928
 
929
+ /** Server id owning a registered channel (by descriptor id), or null. */
930
+ getChannelServerId(channelId: string): string | null {
931
+ return this.findChannelEntry(channelId)?.serverId ?? null;
932
+ }
933
+
733
934
  /**
734
935
  * Find a channel entry by channelId (searches across all servers).
735
936
  * Returns the first match.
@@ -874,6 +1075,130 @@ export class ChannelRegistry {
874
1075
  }
875
1076
  }
876
1077
 
1078
+ /**
1079
+ * Handle the synthesized `think` tool — a private reasoning scratchpad. It
1080
+ * sends nothing, and (unlike before) does NOT silence the turn: trailing
1081
+ * plain text is still routed as the reply. The thought stays in the agent's
1082
+ * own context/chronicle. To deliberately not reply, the agent uses skip_reply.
1083
+ */
1084
+ private handleToolThink(input: { content?: string }): ToolResult {
1085
+ return {
1086
+ success: true,
1087
+ data: {
1088
+ noted: true,
1089
+ note:
1090
+ 'Thought recorded (private — not sent anywhere). This did NOT silence your turn: ' +
1091
+ 'write plain text to reply, or call skip_reply to end the turn without replying.',
1092
+ content: typeof input?.content === 'string' ? input.content : undefined,
1093
+ },
1094
+ };
1095
+ }
1096
+
1097
+ /**
1098
+ * Handle the synthesized `skip_reply` tool — the deliberate "stay silent"
1099
+ * signal. A no-op as far as any surface is concerned (sends nothing); its
1100
+ * effect is that the framework's output routing treats this as a silencing
1101
+ * tool, so any trailing prose this turn is NOT posted. Replaces the old
1102
+ * overloaded use of `think` for staying silent.
1103
+ */
1104
+ private handleToolSkipReply(input: { reason?: string }): ToolResult {
1105
+ return {
1106
+ success: true,
1107
+ data: {
1108
+ skipped: true,
1109
+ note: 'Ended the turn without replying — nothing was sent to any channel.',
1110
+ reason: typeof input?.reason === 'string' ? input.reason : undefined,
1111
+ },
1112
+ };
1113
+ }
1114
+
1115
+ /**
1116
+ * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN).
1117
+ * Publish the agent's plain-text speech to the current conversational locus
1118
+ * (the most recent incoming channel, tracked cross-surface here in the host).
1119
+ * Called by the framework on a text-only turn — replaces the per-surface
1120
+ * sticky auto-post that used to live in discord-mcpl. Returns null when there
1121
+ * is no locus / the channel or its server can't be resolved (in which case
1122
+ * the speech simply stays in chronicle + module surfaces).
1123
+ */
1124
+ async routeSpeech(
1125
+ conversationId: string,
1126
+ text: string,
1127
+ ): Promise<{ delivered: boolean; channelId: string } | null> {
1128
+ // Surface a routing failure: emit a trace AND notify the host (which drops
1129
+ // a `[discord-send-failed]` marker into chronicle) so the agent learns her
1130
+ // reply never reached the human, instead of it vanishing silently.
1131
+ const fail = (channelId: string | null, reason: string): null => {
1132
+ console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
1133
+ this.emitTraceFn({
1134
+ type: 'mcpl:speech-route-failed',
1135
+ channelId: channelId ?? '',
1136
+ reason,
1137
+ textLen: text.length,
1138
+ });
1139
+ this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length });
1140
+ return null;
1141
+ };
1142
+
1143
+ // Resolve the outbound locus, in precedence order:
1144
+ // 1. fork HOME channel — a conversation fork always replies in the channel
1145
+ // it was spawned to serve (item 3, forks).
1146
+ // 2. this turn's TRIGGERING channel — a single trunk agent (connectome-
1147
+ // host's only mode) replies in the channel whose message woke THIS
1148
+ // inference, so a concurrent inbound elsewhere can't hijack the reply
1149
+ // (item-3 redux, trunk agents). Also carries DM channels, which arrive
1150
+ // as push/events and never touch `defaultPublishChannel`.
1151
+ // 3. the process-global `defaultPublishChannel` — last resort for turns
1152
+ // with no triggering channel (heartbeats, timers).
1153
+ // Using the global for a fork or a concurrent trunk turn is the item-3 bug:
1154
+ // it tracks the most-recent inbound across ALL channels, so a reply lands
1155
+ // wherever a message last happened to arrive rather than where it belongs.
1156
+ const home = this.homeChannelResolver?.(conversationId);
1157
+ const channelId =
1158
+ home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel;
1159
+ if (!channelId) {
1160
+ // Reached only when the agent has no home, no active triggering channel,
1161
+ // AND no global inbound was ever seen.
1162
+ return fail(null, 'no locus (no home/active channel, defaultPublishChannel null)');
1163
+ }
1164
+
1165
+ const entry = this.findChannelEntry(channelId);
1166
+ if (!entry) {
1167
+ return fail(channelId, `no registered channel for locus "${channelId}"`);
1168
+ }
1169
+
1170
+ const server = this.serverRegistry.getServer(entry.serverId);
1171
+ if (!server) {
1172
+ return fail(channelId, `server "${entry.serverId}" not found`);
1173
+ }
1174
+
1175
+ const publishParams: ChannelsPublishParams = {
1176
+ conversationId,
1177
+ channelId,
1178
+ content: [{ type: 'text', text }],
1179
+ };
1180
+ const result = await server.sendChannelsPublish(publishParams);
1181
+ const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
1182
+
1183
+ // The server accepted the publish RPC but reported the message was not
1184
+ // actually delivered (e.g. missing Send Messages permission). Previously
1185
+ // this returned `{ delivered: true }`, masking the failure. Surface it.
1186
+ if (delivered === false) {
1187
+ return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`);
1188
+ }
1189
+
1190
+ console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
1191
+ this.emitTraceFn({
1192
+ type: 'mcpl:speech-routed',
1193
+ serverId: entry.serverId,
1194
+ channelId,
1195
+ delivered,
1196
+ textLen: text.length,
1197
+ });
1198
+
1199
+ return { delivered, channelId };
1200
+ }
1201
+
877
1202
  private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
878
1203
  // Resolve content: accept both `content` and `text` (backward compat)
879
1204
  const messageText = input.content ?? input.text;
@@ -368,6 +368,22 @@ export class CheckpointManager {
368
368
  return null;
369
369
  }
370
370
 
371
+ /**
372
+ * Get the server's host-managed (hostState) feature set. Host-managed state is
373
+ * single-valued by protocol (one unkeyed `state` per tool call), so there is at
374
+ * most one such set — return it so its state is threaded correctly even when the
375
+ * server also declares server-managed (rollback) sets. Returns null if none.
376
+ */
377
+ getHostManagedFeatureSet(serverId: string): string | null {
378
+ const prefix = `${serverId}:`;
379
+ for (const [key, tree] of this.trees) {
380
+ if (key.startsWith(prefix) && tree.hostState) {
381
+ return key.slice(prefix.length);
382
+ }
383
+ }
384
+ return null;
385
+ }
386
+
371
387
  // ==========================================================================
372
388
  // Rollback
373
389
  // ==========================================================================