@animalabs/agent-framework 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) 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 +153 -5
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1033 -56
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +27 -0
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +140 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/types.d.ts +17 -0
  14. package/dist/src/gate/types.d.ts.map +1 -1
  15. package/dist/src/index.d.ts +2 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +2 -0
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +50 -0
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +180 -0
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/conversation-router.d.ts +161 -0
  24. package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
  25. package/dist/src/mcpl/conversation-router.js +179 -0
  26. package/dist/src/mcpl/conversation-router.js.map +1 -0
  27. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  28. package/dist/src/mcpl/feature-set-manager.js +10 -1
  29. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  30. package/dist/src/mcpl/index.d.ts +2 -0
  31. package/dist/src/mcpl/index.d.ts.map +1 -1
  32. package/dist/src/mcpl/index.js +2 -0
  33. package/dist/src/mcpl/index.js.map +1 -1
  34. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  35. package/dist/src/mcpl/server-connection.js +4 -0
  36. package/dist/src/mcpl/server-connection.js.map +1 -1
  37. package/dist/src/module-registry.d.ts +3 -1
  38. package/dist/src/module-registry.d.ts.map +1 -1
  39. package/dist/src/module-registry.js +9 -2
  40. package/dist/src/module-registry.js.map +1 -1
  41. package/dist/src/modules/discord/index.d.ts +10 -0
  42. package/dist/src/modules/discord/index.d.ts.map +1 -1
  43. package/dist/src/modules/discord/index.js +83 -0
  44. package/dist/src/modules/discord/index.js.map +1 -1
  45. package/dist/src/tool-result-history.d.ts +17 -0
  46. package/dist/src/tool-result-history.d.ts.map +1 -0
  47. package/dist/src/tool-result-history.js +60 -0
  48. package/dist/src/tool-result-history.js.map +1 -0
  49. package/dist/src/types/agent.d.ts +11 -0
  50. package/dist/src/types/agent.d.ts.map +1 -1
  51. package/dist/src/types/framework.d.ts +8 -0
  52. package/dist/src/types/framework.d.ts.map +1 -1
  53. package/dist/src/types/module.d.ts +15 -0
  54. package/dist/src/types/module.d.ts.map +1 -1
  55. package/dist/src/types/trace.d.ts +28 -0
  56. package/dist/src/types/trace.d.ts.map +1 -1
  57. package/dist/test/channel-registry-routing.test.d.ts +2 -0
  58. package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
  59. package/dist/test/channel-registry-routing.test.js +75 -0
  60. package/dist/test/channel-registry-routing.test.js.map +1 -0
  61. package/dist/test/conversation-router.test.d.ts +2 -0
  62. package/dist/test/conversation-router.test.d.ts.map +1 -0
  63. package/dist/test/conversation-router.test.js +160 -0
  64. package/dist/test/conversation-router.test.js.map +1 -0
  65. package/dist/test/conversation-routing.test.d.ts +7 -0
  66. package/dist/test/conversation-routing.test.d.ts.map +1 -0
  67. package/dist/test/conversation-routing.test.js +203 -0
  68. package/dist/test/conversation-routing.test.js.map +1 -0
  69. package/dist/test/framework.test.js +73 -1
  70. package/dist/test/framework.test.js.map +1 -1
  71. package/dist/test/helpers/mock-membrane.d.ts +41 -0
  72. package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
  73. package/dist/test/helpers/mock-membrane.js +161 -0
  74. package/dist/test/helpers/mock-membrane.js.map +1 -0
  75. package/dist/test/inference-failure-observability.test.d.ts +2 -0
  76. package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
  77. package/dist/test/inference-failure-observability.test.js +75 -0
  78. package/dist/test/inference-failure-observability.test.js.map +1 -0
  79. package/dist/test/tool-result-history.test.d.ts +7 -0
  80. package/dist/test/tool-result-history.test.d.ts.map +1 -0
  81. package/dist/test/tool-result-history.test.js +77 -0
  82. package/dist/test/tool-result-history.test.js.map +1 -0
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +2 -2
  85. package/src/agent.ts +53 -18
  86. package/src/framework.ts +1188 -59
  87. package/src/gate/event-gate.ts +144 -1
  88. package/src/gate/types.ts +17 -0
  89. package/src/index.ts +12 -0
  90. package/src/mcpl/channel-registry.ts +221 -0
  91. package/src/mcpl/conversation-router.ts +284 -0
  92. package/src/mcpl/feature-set-manager.ts +14 -1
  93. package/src/mcpl/index.ts +12 -0
  94. package/src/mcpl/server-connection.ts +4 -0
  95. package/src/module-registry.ts +14 -2
  96. package/src/modules/discord/index.ts +93 -0
  97. package/src/tool-result-history.ts +57 -0
  98. package/src/types/agent.ts +11 -0
  99. package/src/types/framework.ts +9 -0
  100. package/src/types/module.ts +16 -0
  101. package/src/types/trace.ts +36 -0
@@ -330,6 +330,20 @@ export class EventGate {
330
330
  private defaultSkipped = 0;
331
331
  private defaultByEventType = new Map<string, { triggered: number; skipped: number }>();
332
332
 
333
+ // Sleep state (set via the `sleep` tool). Passive: evaluate() suppresses
334
+ // non-privileged wakes while now() < sleepUntil. No timer — after expiry the
335
+ // next event evaluates normally.
336
+ private sleepUntil = 0;
337
+ private sleepNote: string | undefined;
338
+ private sleepSuppressed = 0;
339
+
340
+ // Privileged users who may wake the agent through sleep. Hot-reloaded from
341
+ // privilegedUsersPath on change.
342
+ private privilegedUsersPath: string | undefined;
343
+ private privilegedUserIds = new Set<string>();
344
+ private privilegedMtime = 0;
345
+ private lastPrivilegedCheck = 0;
346
+
333
347
  // Dependency-injected callbacks
334
348
  private emitTrace: (event: TraceEventLike) => void;
335
349
  private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
@@ -342,6 +356,7 @@ export class EventGate {
342
356
  constructor(opts: {
343
357
  configPath: string;
344
358
  initialConfig?: GateConfig;
359
+ privilegedUsersPath?: string;
345
360
  emitTrace: (event: TraceEventLike) => void;
346
361
  addMessage: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
347
362
  requestInference: (agentName: string, reason: string, source: string) => void;
@@ -350,11 +365,13 @@ export class EventGate {
350
365
  now?: () => number;
351
366
  }) {
352
367
  this.configPath = opts.configPath;
368
+ this.privilegedUsersPath = opts.privilegedUsersPath;
353
369
  this.emitTrace = opts.emitTrace;
354
370
  this.addMessageFn = opts.addMessage;
355
371
  this.requestInferenceFn = opts.requestInference;
356
372
  this.getAgentNamesFn = opts.getAgentNames;
357
373
  this.now = opts.now ?? (() => Date.now());
374
+ this.loadPrivileged();
358
375
 
359
376
  // Seed or reconcile the on-disk config.
360
377
  //
@@ -597,6 +614,35 @@ export class EventGate {
597
614
  evaluate(info: GateEventInfo): GateDecision {
598
615
  this.reloadIfChanged();
599
616
 
617
+ // Sleep suppression: while asleep, drop EXTERNAL wakes. Two things still
618
+ // rouse the agent — its own heartbeat (an internal clock, not an outside
619
+ // interruption: each tick lets the agent reconsider and optionally `wake`)
620
+ // and a privileged user's ping. The event always enters context regardless
621
+ // (the gate only governs inference triggering).
622
+ if (this.sleepUntil > this.now()) {
623
+ this.reloadPrivilegedIfChanged();
624
+ const isHeartbeat =
625
+ info.metadata?.source === 'heartbeat' || info.serverId === 'heartbeat';
626
+ const authorId = this.extractAuthorId(info.metadata);
627
+ const privileged = authorId !== null && this.privilegedUserIds.has(authorId);
628
+ if (!isHeartbeat && !privileged) {
629
+ this.sleepSuppressed++;
630
+ this.totalEvaluations++;
631
+ this.emitTrace({
632
+ type: 'gate:decision',
633
+ eventType: info.eventType,
634
+ serverId: info.serverId || undefined,
635
+ channelId: info.channelId || undefined,
636
+ matchedPolicy: 'asleep',
637
+ trigger: false,
638
+ behavior: 'skip',
639
+ timestamp: this.now(),
640
+ });
641
+ return { trigger: false, policyName: 'asleep', behavior: 'skip' };
642
+ }
643
+ // Privileged author — fall through to normal policy evaluation.
644
+ }
645
+
600
646
  const policy = this.matchPolicy(info);
601
647
  const decision = this.computeDecision(policy, info);
602
648
 
@@ -927,9 +973,93 @@ export class EventGate {
927
973
  // Tool: gate:status
928
974
  // =========================================================================
929
975
 
976
+ // =========================================================================
977
+ // Sleep
978
+ // =========================================================================
979
+
980
+ /** Begin (or extend) a sleep window of `seconds`. Returns the wake time. */
981
+ setSleep(seconds: number, note?: string): { until: number } {
982
+ const ms = Math.max(0, Math.floor(seconds * 1000));
983
+ this.sleepUntil = this.now() + ms;
984
+ this.sleepNote = note;
985
+ this.sleepSuppressed = 0;
986
+ this.reloadPrivilegedIfChanged();
987
+ this.emitTrace({
988
+ type: 'gate:decision',
989
+ eventType: 'sleep:set',
990
+ matchedPolicy: 'asleep',
991
+ trigger: false,
992
+ behavior: 'skip',
993
+ timestamp: this.now(),
994
+ });
995
+ return { until: this.sleepUntil };
996
+ }
997
+
998
+ /** End sleep immediately. Returns true if the agent was asleep. */
999
+ clearSleep(): boolean {
1000
+ const wasAsleep = this.sleepUntil > this.now();
1001
+ this.sleepUntil = 0;
1002
+ this.sleepNote = undefined;
1003
+ return wasAsleep;
1004
+ }
1005
+
1006
+ /** Current sleep state, or null if awake. */
1007
+ getSleepState(): { until: number; remainingMs: number; note?: string; suppressed: number } | null {
1008
+ const remainingMs = this.sleepUntil - this.now();
1009
+ if (remainingMs <= 0) return null;
1010
+ return { until: this.sleepUntil, remainingMs, note: this.sleepNote, suppressed: this.sleepSuppressed };
1011
+ }
1012
+
1013
+ /** Author id from an event's metadata, across the discord ingest shapes. */
1014
+ private extractAuthorId(metadata?: Record<string, unknown>): string | null {
1015
+ if (!metadata) return null;
1016
+ const author = metadata.author as { id?: unknown } | undefined;
1017
+ if (author && author.id != null) return String(author.id);
1018
+ if (metadata.authorId != null) return String(metadata.authorId);
1019
+ return null;
1020
+ }
1021
+
1022
+ /** Load the privileged-users file (bare array or { userIds: [...] }). */
1023
+ private loadPrivileged(): void {
1024
+ if (!this.privilegedUsersPath) return;
1025
+ try {
1026
+ if (!existsSync(this.privilegedUsersPath)) {
1027
+ this.privilegedUserIds = new Set();
1028
+ this.privilegedMtime = 0;
1029
+ return;
1030
+ }
1031
+ this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs;
1032
+ const raw = JSON.parse(readFileSync(this.privilegedUsersPath, 'utf8'));
1033
+ const ids = Array.isArray(raw) ? raw : Array.isArray(raw?.userIds) ? raw.userIds : [];
1034
+ this.privilegedUserIds = new Set(ids.map((x: unknown) => String(x)));
1035
+ } catch (err) {
1036
+ this.configErrors.push(
1037
+ `Failed to load privileged-users file: ${err instanceof Error ? err.message : String(err)}`,
1038
+ );
1039
+ }
1040
+ }
1041
+
1042
+ /** Throttled mtime-based reload of the privileged-users file. */
1043
+ private reloadPrivilegedIfChanged(): void {
1044
+ if (!this.privilegedUsersPath) return;
1045
+ const now = this.now();
1046
+ if (now - this.lastPrivilegedCheck < RELOAD_THROTTLE_MS) return;
1047
+ this.lastPrivilegedCheck = now;
1048
+ try {
1049
+ if (!existsSync(this.privilegedUsersPath)) {
1050
+ if (this.privilegedUserIds.size > 0) this.privilegedUserIds = new Set();
1051
+ return;
1052
+ }
1053
+ const mtime = statSync(this.privilegedUsersPath).mtimeMs;
1054
+ if (mtime !== this.privilegedMtime) this.loadPrivileged();
1055
+ } catch {
1056
+ /* leave the last-known set in place on a transient stat/read error */
1057
+ }
1058
+ }
1059
+
930
1060
  getToolDefinition(): ToolDefinition {
931
1061
  return {
932
- name: 'gate:status',
1062
+ name: 'gate_status',
933
1063
  description: 'Show the current EventGate configuration, per-policy match counts, debounce state, and any config errors.',
934
1064
  inputSchema: {
935
1065
  type: 'object',
@@ -976,7 +1106,20 @@ export class EventGate {
976
1106
  const byEventType: Record<string, { triggered: number; skipped: number }> = {};
977
1107
  for (const [k, v] of this.defaultByEventType) byEventType[k] = { ...v };
978
1108
 
1109
+ const sleepState = this.getSleepState();
1110
+
979
1111
  return {
1112
+ ...(sleepState
1113
+ ? {
1114
+ sleep: {
1115
+ until: sleepState.until,
1116
+ remainingMs: sleepState.remainingMs,
1117
+ note: sleepState.note,
1118
+ suppressed: sleepState.suppressed,
1119
+ privilegedCount: this.privilegedUserIds.size,
1120
+ },
1121
+ }
1122
+ : {}),
980
1123
  configPath: this.configPath,
981
1124
  configSource: this.configSource,
982
1125
  lastReloadTimestamp: this.lastReloadTimestamp,
package/src/gate/types.ts CHANGED
@@ -129,6 +129,13 @@ export interface GateOptions {
129
129
  configPath?: string;
130
130
  /** Initial config, seeded to gate.json if the file doesn't exist. */
131
131
  config?: GateConfig;
132
+ /**
133
+ * Path to a JSON file listing user ids that may wake the agent even while
134
+ * it is asleep (`sleep` tool). The file is either a bare array of id
135
+ * strings or `{ "userIds": [...] }`. Hot-reloaded on change. When unset or
136
+ * missing, no one bypasses sleep.
137
+ */
138
+ privilegedUsersPath?: string;
132
139
  }
133
140
 
134
141
  // ---------------------------------------------------------------------------
@@ -202,4 +209,14 @@ export interface GateStatus {
202
209
  skipped: number;
203
210
  byEventType: Record<string, { triggered: number; skipped: number }>;
204
211
  };
212
+ /** Present when the agent is currently asleep (sleep tool). */
213
+ sleep?: {
214
+ until: number;
215
+ remainingMs: number;
216
+ note?: string;
217
+ /** How many wake events have been suppressed during the current sleep. */
218
+ suppressed: number;
219
+ /** Count of privileged user ids that may wake through sleep. */
220
+ privilegedCount: number;
221
+ };
205
222
  }
package/src/index.ts CHANGED
@@ -63,6 +63,18 @@ export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior
63
63
  // MCPL channel registry (exposed for modules that need channel-level operations)
64
64
  export { ChannelRegistry } from './mcpl/index.js';
65
65
 
66
+ // Per-channel conversation routing
67
+ export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js';
68
+ export type {
69
+ ConversationRouterConfig,
70
+ ConversationBinding,
71
+ RoutingDecision,
72
+ IncomingMessageFacts,
73
+ BindRule,
74
+ TriggerRule,
75
+ ChannelKind,
76
+ } from './mcpl/index.js';
77
+
66
78
  // Re-export commonly used types from dependencies
67
79
  export type { ContextManager, ContextStrategy, TokenBudget } from '@animalabs/context-manager';
68
80
  export { PassthroughStrategy, AutobiographicalStrategy, KnowledgeStrategy } from '@animalabs/context-manager';
@@ -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,20 @@ 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;
186
237
  }
187
238
 
188
239
  // ============================================================================
@@ -201,6 +252,12 @@ export class ChannelRegistry {
201
252
  op?: 'start' | 'stop',
202
253
  ) => void;
203
254
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
255
+ private onRouteFailure?: (info: {
256
+ conversationId: string;
257
+ channelId: string | null;
258
+ reason: string;
259
+ textLen: number;
260
+ }) => void;
204
261
 
205
262
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
206
263
  private channels = new Map<string, ChannelEntry>();
@@ -246,6 +303,7 @@ export class ChannelRegistry {
246
303
  this.emitTraceFn = emitTraceFn;
247
304
  this.sendTypingFn = options?.sendTypingFn;
248
305
  this.shouldTriggerInference = options?.shouldTriggerInference;
306
+ this.onRouteFailure = options?.onRouteFailure;
249
307
  }
250
308
 
251
309
  /**
@@ -372,6 +430,43 @@ export class ChannelRegistry {
372
430
  this.defaultPublishMessageId = message.messageId;
373
431
  this.defaultPublishThreadId = message.threadId;
374
432
 
433
+ // Lazy-register the channel if we've never seen it. A channel can deliver
434
+ // an incoming message before its channels/register (boot enumeration) or
435
+ // channels/changed (post-boot create / View-permission grant) round-trip
436
+ // lands — or the registration event can be missed entirely (e.g. the bot
437
+ // gains visibility in a way that fires neither `channelCreate` nor a
438
+ // View-permission transition). Without a registry entry, routeSpeech()
439
+ // can't resolve this channel as an outbound locus and the agent's reply
440
+ // is silently dropped, even though this very message proves the channel
441
+ // is reachable. The inbound message carries enough to make it publishable,
442
+ // so register it here; a later authoritative channels/register or
443
+ // channels/changed will overwrite this descriptor with the richer one.
444
+ const incomingKey = `${serverId}:${message.channelId}`;
445
+ if (!this.channels.has(incomingKey)) {
446
+ const channelLabel =
447
+ typeof message.metadata?.channelName === 'string' && message.metadata.channelName
448
+ ? (message.metadata.channelName as string)
449
+ : message.channelId;
450
+ this.channels.set(incomingKey, {
451
+ serverId,
452
+ descriptor: {
453
+ id: message.channelId,
454
+ type: serverId,
455
+ label: channelLabel,
456
+ direction: 'bidirectional',
457
+ address: message.threadId ? { threadId: message.threadId } : undefined,
458
+ metadata: { lazyRegistered: true },
459
+ },
460
+ open: true,
461
+ });
462
+ this.emitTraceFn({
463
+ type: 'mcpl:channel-lazy-registered',
464
+ serverId,
465
+ channelId: message.channelId,
466
+ label: channelLabel,
467
+ });
468
+ }
469
+
375
470
  // Determine whether to trigger inference
376
471
  let triggerInference = true;
377
472
  if (this.shouldTriggerInference) {
@@ -536,6 +631,14 @@ export class ChannelRegistry {
536
631
  return this.defaultPublishChannel;
537
632
  }
538
633
 
634
+ /**
635
+ * Get the descriptor for a channel by its channelId (first match across
636
+ * servers). Used by the conversation router for DM classification.
637
+ */
638
+ getDescriptor(channelId: string): ChannelDescriptor | undefined {
639
+ return this.findChannelEntry(channelId)?.descriptor;
640
+ }
641
+
539
642
  /**
540
643
  * Get all open channels.
541
644
  */
@@ -577,6 +680,12 @@ export class ChannelRegistry {
577
680
  case 'channel_publish':
578
681
  return this.handleToolPublish(input as { channelId: string; content: string });
579
682
 
683
+ case 'think':
684
+ return this.handleToolThink(input as { content?: string });
685
+
686
+ case 'skip_reply':
687
+ return this.handleToolSkipReply(input as { reason?: string });
688
+
580
689
  default:
581
690
  return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
582
691
  }
@@ -730,6 +839,11 @@ export class ChannelRegistry {
730
839
  // Private: Channel Lookup
731
840
  // ==========================================================================
732
841
 
842
+ /** Server id owning a registered channel (by descriptor id), or null. */
843
+ getChannelServerId(channelId: string): string | null {
844
+ return this.findChannelEntry(channelId)?.serverId ?? null;
845
+ }
846
+
733
847
  /**
734
848
  * Find a channel entry by channelId (searches across all servers).
735
849
  * Returns the first match.
@@ -874,6 +988,113 @@ export class ChannelRegistry {
874
988
  }
875
989
  }
876
990
 
991
+ /**
992
+ * Handle the synthesized `think` tool — a private reasoning scratchpad. It
993
+ * sends nothing, and (unlike before) does NOT silence the turn: trailing
994
+ * plain text is still routed as the reply. The thought stays in the agent's
995
+ * own context/chronicle. To deliberately not reply, the agent uses skip_reply.
996
+ */
997
+ private handleToolThink(input: { content?: string }): ToolResult {
998
+ return {
999
+ success: true,
1000
+ data: {
1001
+ noted: true,
1002
+ note:
1003
+ 'Thought recorded (private — not sent anywhere). This did NOT silence your turn: ' +
1004
+ 'write plain text to reply, or call skip_reply to end the turn without replying.',
1005
+ content: typeof input?.content === 'string' ? input.content : undefined,
1006
+ },
1007
+ };
1008
+ }
1009
+
1010
+ /**
1011
+ * Handle the synthesized `skip_reply` tool — the deliberate "stay silent"
1012
+ * signal. A no-op as far as any surface is concerned (sends nothing); its
1013
+ * effect is that the framework's output routing treats this as a silencing
1014
+ * tool, so any trailing prose this turn is NOT posted. Replaces the old
1015
+ * overloaded use of `think` for staying silent.
1016
+ */
1017
+ private handleToolSkipReply(input: { reason?: string }): ToolResult {
1018
+ return {
1019
+ success: true,
1020
+ data: {
1021
+ skipped: true,
1022
+ note: 'Ended the turn without replying — nothing was sent to any channel.',
1023
+ reason: typeof input?.reason === 'string' ? input.reason : undefined,
1024
+ },
1025
+ };
1026
+ }
1027
+
1028
+ /**
1029
+ * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN).
1030
+ * Publish the agent's plain-text speech to the current conversational locus
1031
+ * (the most recent incoming channel, tracked cross-surface here in the host).
1032
+ * Called by the framework on a text-only turn — replaces the per-surface
1033
+ * sticky auto-post that used to live in discord-mcpl. Returns null when there
1034
+ * is no locus / the channel or its server can't be resolved (in which case
1035
+ * the speech simply stays in chronicle + module surfaces).
1036
+ */
1037
+ async routeSpeech(
1038
+ conversationId: string,
1039
+ text: string,
1040
+ ): Promise<{ delivered: boolean; channelId: string } | null> {
1041
+ // Surface a routing failure: emit a trace AND notify the host (which drops
1042
+ // a `[discord-send-failed]` marker into chronicle) so the agent learns her
1043
+ // reply never reached the human, instead of it vanishing silently.
1044
+ const fail = (channelId: string | null, reason: string): null => {
1045
+ console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
1046
+ this.emitTraceFn({
1047
+ type: 'mcpl:speech-route-failed',
1048
+ channelId: channelId ?? '',
1049
+ reason,
1050
+ textLen: text.length,
1051
+ });
1052
+ this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length });
1053
+ return null;
1054
+ };
1055
+
1056
+ const channelId = this.defaultPublishChannel;
1057
+ if (!channelId) {
1058
+ return fail(null, 'no locus (defaultPublishChannel null)');
1059
+ }
1060
+
1061
+ const entry = this.findChannelEntry(channelId);
1062
+ if (!entry) {
1063
+ return fail(channelId, `no registered channel for locus "${channelId}"`);
1064
+ }
1065
+
1066
+ const server = this.serverRegistry.getServer(entry.serverId);
1067
+ if (!server) {
1068
+ return fail(channelId, `server "${entry.serverId}" not found`);
1069
+ }
1070
+
1071
+ const publishParams: ChannelsPublishParams = {
1072
+ conversationId,
1073
+ channelId,
1074
+ content: [{ type: 'text', text }],
1075
+ };
1076
+ const result = await server.sendChannelsPublish(publishParams);
1077
+ const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
1078
+
1079
+ // The server accepted the publish RPC but reported the message was not
1080
+ // actually delivered (e.g. missing Send Messages permission). Previously
1081
+ // this returned `{ delivered: true }`, masking the failure. Surface it.
1082
+ if (delivered === false) {
1083
+ return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`);
1084
+ }
1085
+
1086
+ console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
1087
+ this.emitTraceFn({
1088
+ type: 'mcpl:speech-routed',
1089
+ serverId: entry.serverId,
1090
+ channelId,
1091
+ delivered,
1092
+ textLen: text.length,
1093
+ });
1094
+
1095
+ return { delivered, channelId };
1096
+ }
1097
+
877
1098
  private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
878
1099
  // Resolve content: accept both `content` and `text` (backward compat)
879
1100
  const messageText = input.content ?? input.text;