@animalabs/agent-framework 0.1.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 (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/src/agent.d.ts +130 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +406 -0
  6. package/dist/src/agent.js.map +1 -0
  7. package/dist/src/api/mcp-server.d.ts +37 -0
  8. package/dist/src/api/mcp-server.d.ts.map +1 -0
  9. package/dist/src/api/mcp-server.js +598 -0
  10. package/dist/src/api/mcp-server.js.map +1 -0
  11. package/dist/src/api/server.d.ts +83 -0
  12. package/dist/src/api/server.d.ts.map +1 -0
  13. package/dist/src/api/server.js +785 -0
  14. package/dist/src/api/server.js.map +1 -0
  15. package/dist/src/api/types.d.ts +395 -0
  16. package/dist/src/api/types.d.ts.map +1 -0
  17. package/dist/src/api/types.js +5 -0
  18. package/dist/src/api/types.js.map +1 -0
  19. package/dist/src/framework.d.ts +309 -0
  20. package/dist/src/framework.d.ts.map +1 -0
  21. package/dist/src/framework.js +2309 -0
  22. package/dist/src/framework.js.map +1 -0
  23. package/dist/src/gate/event-gate.d.ts +75 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -0
  25. package/dist/src/gate/event-gate.js +503 -0
  26. package/dist/src/gate/event-gate.js.map +1 -0
  27. package/dist/src/gate/index.d.ts +3 -0
  28. package/dist/src/gate/index.d.ts.map +1 -0
  29. package/dist/src/gate/index.js +2 -0
  30. package/dist/src/gate/index.js.map +1 -0
  31. package/dist/src/gate/types.d.ts +82 -0
  32. package/dist/src/gate/types.d.ts.map +1 -0
  33. package/dist/src/gate/types.js +9 -0
  34. package/dist/src/gate/types.js.map +1 -0
  35. package/dist/src/index.d.ts +17 -0
  36. package/dist/src/index.d.ts.map +1 -0
  37. package/dist/src/index.js +18 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +141 -0
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
  41. package/dist/src/mcpl/channel-registry.js +647 -0
  42. package/dist/src/mcpl/channel-registry.js.map +1 -0
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
  45. package/dist/src/mcpl/checkpoint-manager.js +430 -0
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
  47. package/dist/src/mcpl/errors.d.ts +52 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -0
  49. package/dist/src/mcpl/errors.js +93 -0
  50. package/dist/src/mcpl/errors.js.map +1 -0
  51. package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
  53. package/dist/src/mcpl/feature-set-manager.js +250 -0
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -0
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
  57. package/dist/src/mcpl/hook-orchestrator.js +217 -0
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
  59. package/dist/src/mcpl/index.d.ts +14 -0
  60. package/dist/src/mcpl/index.d.ts.map +1 -0
  61. package/dist/src/mcpl/index.js +21 -0
  62. package/dist/src/mcpl/index.js.map +1 -0
  63. package/dist/src/mcpl/inference-router.d.ts +67 -0
  64. package/dist/src/mcpl/inference-router.d.ts.map +1 -0
  65. package/dist/src/mcpl/inference-router.js +249 -0
  66. package/dist/src/mcpl/inference-router.js.map +1 -0
  67. package/dist/src/mcpl/push-handler.d.ts +60 -0
  68. package/dist/src/mcpl/push-handler.d.ts.map +1 -0
  69. package/dist/src/mcpl/push-handler.js +162 -0
  70. package/dist/src/mcpl/push-handler.js.map +1 -0
  71. package/dist/src/mcpl/scope-manager.d.ts +76 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
  73. package/dist/src/mcpl/scope-manager.js +213 -0
  74. package/dist/src/mcpl/scope-manager.js.map +1 -0
  75. package/dist/src/mcpl/server-connection.d.ts +174 -0
  76. package/dist/src/mcpl/server-connection.d.ts.map +1 -0
  77. package/dist/src/mcpl/server-connection.js +559 -0
  78. package/dist/src/mcpl/server-connection.js.map +1 -0
  79. package/dist/src/mcpl/server-registry.d.ts +63 -0
  80. package/dist/src/mcpl/server-registry.d.ts.map +1 -0
  81. package/dist/src/mcpl/server-registry.js +119 -0
  82. package/dist/src/mcpl/server-registry.js.map +1 -0
  83. package/dist/src/mcpl/types.d.ts +755 -0
  84. package/dist/src/mcpl/types.d.ts.map +1 -0
  85. package/dist/src/mcpl/types.js +48 -0
  86. package/dist/src/mcpl/types.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +93 -0
  88. package/dist/src/module-registry.d.ts.map +1 -0
  89. package/dist/src/module-registry.js +386 -0
  90. package/dist/src/module-registry.js.map +1 -0
  91. package/dist/src/modules/api/index.d.ts +22 -0
  92. package/dist/src/modules/api/index.d.ts.map +1 -0
  93. package/dist/src/modules/api/index.js +63 -0
  94. package/dist/src/modules/api/index.js.map +1 -0
  95. package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
  96. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
  97. package/dist/src/modules/discord/discord-js-client.js +273 -0
  98. package/dist/src/modules/discord/discord-js-client.js.map +1 -0
  99. package/dist/src/modules/discord/index.d.ts +82 -0
  100. package/dist/src/modules/discord/index.d.ts.map +1 -0
  101. package/dist/src/modules/discord/index.js +959 -0
  102. package/dist/src/modules/discord/index.js.map +1 -0
  103. package/dist/src/modules/discord/types.d.ts +327 -0
  104. package/dist/src/modules/discord/types.d.ts.map +1 -0
  105. package/dist/src/modules/discord/types.js +5 -0
  106. package/dist/src/modules/discord/types.js.map +1 -0
  107. package/dist/src/modules/index.d.ts +11 -0
  108. package/dist/src/modules/index.d.ts.map +1 -0
  109. package/dist/src/modules/index.js +7 -0
  110. package/dist/src/modules/index.js.map +1 -0
  111. package/dist/src/modules/workspace/index.d.ts +59 -0
  112. package/dist/src/modules/workspace/index.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/index.js +831 -0
  114. package/dist/src/modules/workspace/index.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +48 -0
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -0
  117. package/dist/src/modules/workspace/sync.js +253 -0
  118. package/dist/src/modules/workspace/sync.js.map +1 -0
  119. package/dist/src/modules/workspace/types.d.ts +166 -0
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -0
  121. package/dist/src/modules/workspace/types.js +5 -0
  122. package/dist/src/modules/workspace/types.js.map +1 -0
  123. package/dist/src/modules/workspace/watcher.d.ts +43 -0
  124. package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
  125. package/dist/src/modules/workspace/watcher.js +120 -0
  126. package/dist/src/modules/workspace/watcher.js.map +1 -0
  127. package/dist/src/queue.d.ts +50 -0
  128. package/dist/src/queue.d.ts.map +1 -0
  129. package/dist/src/queue.js +95 -0
  130. package/dist/src/queue.js.map +1 -0
  131. package/dist/src/safe-slice.d.ts +8 -0
  132. package/dist/src/safe-slice.d.ts.map +1 -0
  133. package/dist/src/safe-slice.js +18 -0
  134. package/dist/src/safe-slice.js.map +1 -0
  135. package/dist/src/types/agent.d.ts +118 -0
  136. package/dist/src/types/agent.d.ts.map +1 -0
  137. package/dist/src/types/agent.js +2 -0
  138. package/dist/src/types/agent.js.map +1 -0
  139. package/dist/src/types/events.d.ts +220 -0
  140. package/dist/src/types/events.d.ts.map +1 -0
  141. package/dist/src/types/events.js +2 -0
  142. package/dist/src/types/events.js.map +1 -0
  143. package/dist/src/types/framework.d.ts +184 -0
  144. package/dist/src/types/framework.d.ts.map +1 -0
  145. package/dist/src/types/framework.js +2 -0
  146. package/dist/src/types/framework.js.map +1 -0
  147. package/dist/src/types/index.d.ts +7 -0
  148. package/dist/src/types/index.d.ts.map +1 -0
  149. package/dist/src/types/index.js +7 -0
  150. package/dist/src/types/index.js.map +1 -0
  151. package/dist/src/types/inference-log.d.ts +64 -0
  152. package/dist/src/types/inference-log.d.ts.map +1 -0
  153. package/dist/src/types/inference-log.js +5 -0
  154. package/dist/src/types/inference-log.js.map +1 -0
  155. package/dist/src/types/module.d.ts +302 -0
  156. package/dist/src/types/module.d.ts.map +1 -0
  157. package/dist/src/types/module.js +2 -0
  158. package/dist/src/types/module.js.map +1 -0
  159. package/dist/src/types/trace.d.ts +173 -0
  160. package/dist/src/types/trace.d.ts.map +1 -0
  161. package/dist/src/types/trace.js +2 -0
  162. package/dist/src/types/trace.js.map +1 -0
  163. package/dist/test/event-gate.test.d.ts +2 -0
  164. package/dist/test/event-gate.test.d.ts.map +1 -0
  165. package/dist/test/event-gate.test.js +535 -0
  166. package/dist/test/event-gate.test.js.map +1 -0
  167. package/dist/test/framework.test.d.ts +5 -0
  168. package/dist/test/framework.test.d.ts.map +1 -0
  169. package/dist/test/framework.test.js +738 -0
  170. package/dist/test/framework.test.js.map +1 -0
  171. package/dist/tsconfig.tsbuildinfo +1 -0
  172. package/package.json +58 -0
  173. package/src/agent.ts +512 -0
  174. package/src/api/mcp-server.ts +687 -0
  175. package/src/api/server.ts +1023 -0
  176. package/src/api/types.ts +542 -0
  177. package/src/framework.ts +2784 -0
  178. package/src/gate/event-gate.ts +623 -0
  179. package/src/gate/index.ts +12 -0
  180. package/src/gate/types.ts +112 -0
  181. package/src/index.ts +63 -0
  182. package/src/mcpl/channel-registry.ts +824 -0
  183. package/src/mcpl/checkpoint-manager.ts +545 -0
  184. package/src/mcpl/errors.ts +114 -0
  185. package/src/mcpl/feature-set-manager.ts +333 -0
  186. package/src/mcpl/hook-orchestrator.ts +281 -0
  187. package/src/mcpl/index.ts +134 -0
  188. package/src/mcpl/inference-router.ts +335 -0
  189. package/src/mcpl/push-handler.ts +232 -0
  190. package/src/mcpl/scope-manager.ts +262 -0
  191. package/src/mcpl/server-connection.ts +694 -0
  192. package/src/mcpl/server-registry.ts +144 -0
  193. package/src/mcpl/types.ts +991 -0
  194. package/src/module-registry.ts +547 -0
  195. package/src/modules/api/index.ts +94 -0
  196. package/src/modules/discord/discord-js-client.ts +353 -0
  197. package/src/modules/discord/index.ts +1146 -0
  198. package/src/modules/discord/types.ts +465 -0
  199. package/src/modules/index.ts +43 -0
  200. package/src/modules/workspace/index.ts +969 -0
  201. package/src/modules/workspace/sync.ts +314 -0
  202. package/src/modules/workspace/types.ts +202 -0
  203. package/src/modules/workspace/watcher.ts +142 -0
  204. package/src/queue.ts +106 -0
  205. package/src/safe-slice.ts +18 -0
  206. package/src/types/agent.ts +119 -0
  207. package/src/types/events.ts +252 -0
  208. package/src/types/framework.ts +211 -0
  209. package/src/types/index.ts +6 -0
  210. package/src/types/inference-log.ts +64 -0
  211. package/src/types/module.ts +367 -0
  212. package/src/types/trace.ts +196 -0
@@ -0,0 +1,824 @@
1
+ /**
2
+ * ChannelRegistry — manages MCPL channel lifecycle, incoming messages, and
3
+ * synthesized channel tools.
4
+ *
5
+ * Adapted from battle-tested patterns in Anarchid/agent-framework@mcpl-module-proto.
6
+ *
7
+ * Responsibilities:
8
+ * - Register/unregister channel descriptors from MCPL servers
9
+ * - Auto-open channels on registration
10
+ * - Route incoming messages to the processing queue
11
+ * - Manage typing indicator timers (7s interval for Discord compatibility)
12
+ * - Expose synthesized tools: channel_list, channel_open, channel_close, channel_publish
13
+ * - Build channel context for beforeInference params
14
+ */
15
+
16
+ import type { ContentBlock } from '@animalabs/membrane';
17
+
18
+ import type {
19
+ ChannelDescriptor,
20
+ ChannelContext,
21
+ ChannelsRegisterParams,
22
+ ChannelsRegisterResult,
23
+ ChannelsChangedParams,
24
+ ChannelsIncomingParams,
25
+ ChannelsIncomingResult,
26
+ ChannelIncomingMessageResult,
27
+ ChannelsPublishParams,
28
+ McplContentBlock,
29
+ } from './types.js';
30
+
31
+ import type { McplServerRegistry } from './server-registry.js';
32
+ import type { FeatureSetManager } from './feature-set-manager.js';
33
+ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
34
+
35
+ // ============================================================================
36
+ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
37
+ // ============================================================================
38
+
39
+ const TYPING_INTERVAL_MS = 7_000;
40
+
41
+ // ============================================================================
42
+ // Internal Types
43
+ // ============================================================================
44
+
45
+ /** A registered channel entry, keyed by `{serverId}:{channelId}`. */
46
+ interface ChannelEntry {
47
+ serverId: string;
48
+ descriptor: ChannelDescriptor;
49
+ open: boolean;
50
+ }
51
+
52
+ /** Minimal responder interface for sending JSON-RPC results back. */
53
+ interface Responder {
54
+ respond(result: unknown): void;
55
+ respondError?(code: number, message: string, data?: unknown): void;
56
+ }
57
+
58
+ /**
59
+ * Event pushed to the processing queue when an incoming channel message arrives.
60
+ * Uses the CustomEvent pattern (`${string}:${string}`) from ProcessEvent.
61
+ */
62
+ interface McplChannelIncomingEvent {
63
+ type: 'mcpl:channel-incoming';
64
+ serverId: string;
65
+ channelId: string;
66
+ messageId: string;
67
+ threadId?: string;
68
+ author: { id: string; name: string };
69
+ content: ContentBlock[];
70
+ timestamp: string;
71
+ metadata?: Record<string, unknown>;
72
+ triggerInference?: boolean;
73
+ targetAgents?: string[];
74
+ }
75
+
76
+ // ============================================================================
77
+ // Content Conversion: McplContentBlock → membrane ContentBlock
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Convert a single MCPL wire-format content block to a membrane ContentBlock.
82
+ */
83
+ function convertBlock(block: McplContentBlock): ContentBlock {
84
+ switch (block.type) {
85
+ case 'text':
86
+ return { type: 'text', text: block.text };
87
+
88
+ case 'image':
89
+ if (block.data && block.mimeType) {
90
+ return {
91
+ type: 'image',
92
+ source: { type: 'base64', data: block.data, mediaType: block.mimeType },
93
+ } as ContentBlock;
94
+ }
95
+ if (block.uri) {
96
+ return {
97
+ type: 'image',
98
+ source: { type: 'url', url: block.uri },
99
+ } as ContentBlock;
100
+ }
101
+ return { type: 'text', text: '[Image: no data]' };
102
+
103
+ case 'audio':
104
+ if (block.data && block.mimeType) {
105
+ return {
106
+ type: 'audio',
107
+ source: { type: 'base64', data: block.data, mediaType: block.mimeType },
108
+ } as ContentBlock;
109
+ }
110
+ return { type: 'text', text: '[Audio: no data]' };
111
+
112
+ case 'resource':
113
+ return { type: 'text', text: `[Resource: ${block.uri}]` };
114
+ }
115
+ }
116
+
117
+ // ============================================================================
118
+ // Channel Tool Definitions
119
+ // ============================================================================
120
+
121
+ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
122
+ {
123
+ name: 'channel_list',
124
+ description: 'List all available channels',
125
+ inputSchema: { type: 'object' as const, properties: {} },
126
+ },
127
+ {
128
+ name: 'channel_open',
129
+ description: 'Open a channel to start receiving messages',
130
+ inputSchema: {
131
+ type: 'object' as const,
132
+ properties: {
133
+ channelId: { type: 'string', description: 'ID of the channel to open' },
134
+ },
135
+ required: ['channelId'],
136
+ },
137
+ },
138
+ {
139
+ name: 'channel_close',
140
+ description: 'Close a channel to stop receiving messages',
141
+ inputSchema: {
142
+ type: 'object' as const,
143
+ properties: {
144
+ channelId: { type: 'string', description: 'ID of the channel to close' },
145
+ },
146
+ required: ['channelId'],
147
+ },
148
+ },
149
+ {
150
+ name: 'channel_publish',
151
+ description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.',
152
+ inputSchema: {
153
+ type: 'object' as const,
154
+ properties: {
155
+ channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' },
156
+ content: { type: 'string', description: 'Text content to publish' },
157
+ text: { type: 'string', description: 'Alias for content' },
158
+ },
159
+ required: [],
160
+ },
161
+ },
162
+ ];
163
+
164
+ // ============================================================================
165
+ // Constructor Options
166
+ // ============================================================================
167
+
168
+ interface ChannelRegistryOptions {
169
+ /** Callback to determine whether an incoming message should trigger inference. */
170
+ shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
171
+ }
172
+
173
+ // ============================================================================
174
+ // ChannelRegistry
175
+ // ============================================================================
176
+
177
+ export class ChannelRegistry {
178
+ private serverRegistry: McplServerRegistry;
179
+ private featureSetManager: FeatureSetManager;
180
+ private pushEventFn: (event: ProcessEvent) => void;
181
+ private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
182
+ private sendTypingFn?: (serverId: string, channelId: string) => void;
183
+ private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
184
+
185
+ /** Registered channels, keyed by `{serverId}:{channelId}`. */
186
+ private channels = new Map<string, ChannelEntry>();
187
+
188
+ /** Most recent incoming channel ID — used for speech routing / default publish. */
189
+ private defaultPublishChannel: string | null = null;
190
+
191
+ /** Most recent incoming message metadata, used for buildChannelContext. */
192
+ private defaultPublishMessageId: string | null = null;
193
+ private defaultPublishThreadId: string | undefined = undefined;
194
+
195
+ /** Per-channel typing indicator timers. */
196
+ private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
197
+
198
+ constructor(
199
+ serverRegistry: McplServerRegistry,
200
+ featureSetManager: FeatureSetManager,
201
+ pushEventFn: (event: ProcessEvent) => void,
202
+ emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
203
+ options?: ChannelRegistryOptions & {
204
+ sendTypingFn?: (serverId: string, channelId: string) => void;
205
+ },
206
+ ) {
207
+ this.serverRegistry = serverRegistry;
208
+ this.featureSetManager = featureSetManager;
209
+ this.pushEventFn = pushEventFn;
210
+ this.emitTraceFn = emitTraceFn;
211
+ this.sendTypingFn = options?.sendTypingFn;
212
+ this.shouldTriggerInference = options?.shouldTriggerInference;
213
+ }
214
+
215
+ // ==========================================================================
216
+ // Handler Methods (called from framework.ts wireMcplEvents)
217
+ // ==========================================================================
218
+
219
+ /**
220
+ * Handle `channels/register` from a server.
221
+ *
222
+ * Registers each channel descriptor in the map and auto-opens them.
223
+ */
224
+ async handleRegister(
225
+ serverId: string,
226
+ params: ChannelsRegisterParams,
227
+ responder?: Responder,
228
+ ): Promise<void> {
229
+ const registeredIds: string[] = [];
230
+
231
+ for (const channel of params.channels) {
232
+ const key = `${serverId}:${channel.id}`;
233
+ this.channels.set(key, {
234
+ serverId,
235
+ descriptor: channel,
236
+ open: false,
237
+ });
238
+ registeredIds.push(channel.id);
239
+ }
240
+
241
+ // Respond before auto-opening — the server blocks on this response and
242
+ // can't process channels/open until it arrives.
243
+ const result: ChannelsRegisterResult = { registered: registeredIds };
244
+ responder?.respond(result);
245
+
246
+ // Auto-open all registered channels
247
+ await this.autoOpenChannels(serverId, params.channels);
248
+
249
+ this.emitTraceFn({
250
+ type: 'mcpl:channels-register',
251
+ serverId,
252
+ channelIds: registeredIds,
253
+ count: registeredIds.length,
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Handle `channels/changed` notification from a server.
259
+ *
260
+ * Processes added (register + auto-open), removed (delete + stop typing),
261
+ * and updated (replace descriptor) channels.
262
+ */
263
+ async handleChanged(
264
+ serverId: string,
265
+ params: ChannelsChangedParams,
266
+ ): Promise<void> {
267
+ // Process removed channels
268
+ if (params.removed) {
269
+ for (const channelId of params.removed) {
270
+ const key = `${serverId}:${channelId}`;
271
+ this.channels.delete(key);
272
+ this.stopTyping(channelId);
273
+ }
274
+ }
275
+
276
+ // Process updated channels (replace descriptor, preserve open state)
277
+ if (params.updated) {
278
+ for (const channel of params.updated) {
279
+ const key = `${serverId}:${channel.id}`;
280
+ const existing = this.channels.get(key);
281
+ if (existing) {
282
+ existing.descriptor = channel;
283
+ }
284
+ }
285
+ }
286
+
287
+ // Process added channels (register + auto-open)
288
+ if (params.added) {
289
+ for (const channel of params.added) {
290
+ const key = `${serverId}:${channel.id}`;
291
+ this.channels.set(key, {
292
+ serverId,
293
+ descriptor: channel,
294
+ open: false,
295
+ });
296
+ }
297
+ await this.autoOpenChannels(serverId, params.added);
298
+ }
299
+
300
+ this.emitTraceFn({
301
+ type: 'mcpl:channels-changed',
302
+ serverId,
303
+ added: params.added?.map((c) => c.id) ?? [],
304
+ removed: params.removed ?? [],
305
+ updated: params.updated?.map((c) => c.id) ?? [],
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Handle `channels/incoming` from a server.
311
+ *
312
+ * Converts each message's content, pushes McplChannelIncomingEvent to the
313
+ * queue, and responds with per-message results.
314
+ */
315
+ handleIncoming(
316
+ serverId: string,
317
+ params: ChannelsIncomingParams,
318
+ responder?: Responder,
319
+ ): void {
320
+ const results: ChannelIncomingMessageResult[] = [];
321
+
322
+ for (const message of params.messages) {
323
+ // Convert MCPL content blocks to membrane ContentBlocks
324
+ const convertedContent: ContentBlock[] = message.content.map(convertBlock);
325
+
326
+ // Track default publish channel (most recent incoming)
327
+ this.defaultPublishChannel = message.channelId;
328
+ this.defaultPublishMessageId = message.messageId;
329
+ this.defaultPublishThreadId = message.threadId;
330
+
331
+ // Determine whether to trigger inference
332
+ let triggerInference = true;
333
+ if (this.shouldTriggerInference) {
334
+ const textContent = message.content
335
+ .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
336
+ .map((b) => b.text)
337
+ .join('\n');
338
+ triggerInference = this.shouldTriggerInference(
339
+ textContent,
340
+ {
341
+ ...message.metadata,
342
+ eventType: 'channel:incoming',
343
+ serverId,
344
+ channelId: message.channelId,
345
+ messageId: message.messageId,
346
+ threadId: message.threadId,
347
+ author: message.author,
348
+ },
349
+ );
350
+ }
351
+
352
+ // Build the incoming event
353
+ const event: McplChannelIncomingEvent = {
354
+ type: 'mcpl:channel-incoming',
355
+ serverId,
356
+ channelId: message.channelId,
357
+ messageId: message.messageId,
358
+ threadId: message.threadId,
359
+ author: message.author,
360
+ content: convertedContent,
361
+ timestamp: message.timestamp,
362
+ metadata: message.metadata,
363
+ triggerInference,
364
+ };
365
+
366
+ // Push to the processing queue
367
+ // Cast through unknown because McplChannelIncomingEvent matches the
368
+ // CustomEvent `${string}:${string}` type pattern but lacks an index signature.
369
+ this.pushEventFn(event as unknown as ProcessEvent);
370
+
371
+ // Collect per-message result
372
+ results.push({
373
+ messageId: message.messageId,
374
+ accepted: true,
375
+ });
376
+ }
377
+
378
+ const result: ChannelsIncomingResult = { results };
379
+ responder?.respond(result);
380
+
381
+ this.emitTraceFn({
382
+ type: 'mcpl:channels-incoming',
383
+ serverId,
384
+ messageCount: params.messages.length,
385
+ channelIds: [...new Set(params.messages.map((m) => m.channelId))],
386
+ });
387
+ }
388
+
389
+ // ==========================================================================
390
+ // Typing Indicator Management
391
+ // ==========================================================================
392
+
393
+ /**
394
+ * Start sending typing indicators for a channel.
395
+ *
396
+ * Sends a typing notification immediately and every 7 seconds thereafter.
397
+ * Discord typing indicators last ~10s, so 7s keeps them alive.
398
+ *
399
+ * No-op if already typing on this channel.
400
+ */
401
+ startTyping(channelId: string): void {
402
+ if (this.typingIntervals.has(channelId)) {
403
+ return; // Already typing
404
+ }
405
+
406
+ // Find the channel entry and its server
407
+ const entry = this.findChannelEntry(channelId);
408
+ if (!entry) {
409
+ return;
410
+ }
411
+
412
+ // Send typing immediately
413
+ this.sendTypingNotification(entry.serverId, channelId);
414
+
415
+ // Set up interval
416
+ const interval = setInterval(() => {
417
+ this.sendTypingNotification(entry.serverId, channelId);
418
+ }, TYPING_INTERVAL_MS);
419
+
420
+ this.typingIntervals.set(channelId, interval);
421
+ }
422
+
423
+ /**
424
+ * Stop sending typing indicators.
425
+ *
426
+ * If channelId is specified, stops typing on that channel only.
427
+ * If no channelId, stops all typing indicators.
428
+ */
429
+ stopTyping(channelId?: string): void {
430
+ if (channelId !== undefined) {
431
+ const interval = this.typingIntervals.get(channelId);
432
+ if (interval) {
433
+ clearInterval(interval);
434
+ this.typingIntervals.delete(channelId);
435
+ }
436
+ } else {
437
+ // Clear all typing intervals
438
+ for (const interval of this.typingIntervals.values()) {
439
+ clearInterval(interval);
440
+ }
441
+ this.typingIntervals.clear();
442
+ }
443
+ }
444
+
445
+ // ==========================================================================
446
+ // Accessors
447
+ // ==========================================================================
448
+
449
+ /**
450
+ * Get the default publish channel ID (most recent incoming channel).
451
+ */
452
+ getDefaultPublishChannel(): string | null {
453
+ return this.defaultPublishChannel;
454
+ }
455
+
456
+ /**
457
+ * Get all open channels.
458
+ */
459
+ getOpenChannels(): ChannelEntry[] {
460
+ const result: ChannelEntry[] = [];
461
+ for (const entry of this.channels.values()) {
462
+ if (entry.open) {
463
+ result.push(entry);
464
+ }
465
+ }
466
+ return result;
467
+ }
468
+
469
+ // ==========================================================================
470
+ // Synthesized Channel Tools
471
+ // ==========================================================================
472
+
473
+ /**
474
+ * Get synthesized tool definitions for channel operations.
475
+ */
476
+ getChannelTools(): ToolDefinition[] {
477
+ return CHANNEL_TOOL_DEFINITIONS;
478
+ }
479
+
480
+ /**
481
+ * Handle a call to one of the synthesized channel tools.
482
+ */
483
+ async handleChannelToolCall(toolName: string, input: unknown): Promise<ToolResult> {
484
+ switch (toolName) {
485
+ case 'channel_list':
486
+ return this.handleToolList();
487
+
488
+ case 'channel_open':
489
+ return this.handleToolOpen(input as { channelId: string });
490
+
491
+ case 'channel_close':
492
+ return this.handleToolClose(input as { channelId: string });
493
+
494
+ case 'channel_publish':
495
+ return this.handleToolPublish(input as { channelId: string; content: string });
496
+
497
+ default:
498
+ return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
499
+ }
500
+ }
501
+
502
+ // ==========================================================================
503
+ // Channel Context for beforeInference
504
+ // ==========================================================================
505
+
506
+ /**
507
+ * Build channel context for inclusion in beforeInference params.
508
+ *
509
+ * Returns undefined if no channels are active.
510
+ */
511
+ buildChannelContext(): ChannelContext | undefined {
512
+ const openChannels = this.getOpenChannels();
513
+ if (openChannels.length === 0 && !this.defaultPublishChannel) {
514
+ return undefined;
515
+ }
516
+
517
+ const context: ChannelContext = {};
518
+
519
+ // Incoming: from defaultPublishChannel if set
520
+ if (this.defaultPublishChannel && this.defaultPublishMessageId) {
521
+ context.incoming = {
522
+ channelId: this.defaultPublishChannel,
523
+ messageId: this.defaultPublishMessageId,
524
+ threadId: this.defaultPublishThreadId,
525
+ };
526
+ }
527
+
528
+ // Default outgoing: same as incoming (reply to last channel)
529
+ if (this.defaultPublishChannel) {
530
+ context.defaultOutgoing = {
531
+ channelId: this.defaultPublishChannel,
532
+ };
533
+ }
534
+
535
+ // Candidates: all open channel IDs
536
+ if (openChannels.length > 0) {
537
+ context.candidates = openChannels.map((e) => e.descriptor.id);
538
+ }
539
+
540
+ return context;
541
+ }
542
+
543
+ // ==========================================================================
544
+ // Lifecycle
545
+ // ==========================================================================
546
+
547
+ /**
548
+ * Stop all typing intervals and clear all channel registrations.
549
+ */
550
+ stopAll(): void {
551
+ // Clear all typing intervals
552
+ for (const interval of this.typingIntervals.values()) {
553
+ clearInterval(interval);
554
+ }
555
+ this.typingIntervals.clear();
556
+
557
+ // Clear channels map
558
+ this.channels.clear();
559
+
560
+ // Reset default publish tracking
561
+ this.defaultPublishChannel = null;
562
+ this.defaultPublishMessageId = null;
563
+ this.defaultPublishThreadId = undefined;
564
+ }
565
+
566
+ // ==========================================================================
567
+ // Private: Auto-open channels
568
+ // ==========================================================================
569
+
570
+ private async autoOpenChannels(
571
+ serverId: string,
572
+ channels: ChannelDescriptor[],
573
+ ): Promise<void> {
574
+ const server = this.serverRegistry.getServer(serverId);
575
+ if (!server) return;
576
+
577
+ for (const channel of channels) {
578
+ const key = `${serverId}:${channel.id}`;
579
+ try {
580
+ await server.sendChannelsOpen({
581
+ type: channel.type,
582
+ address: channel.address,
583
+ });
584
+ const entry = this.channels.get(key);
585
+ if (entry) {
586
+ entry.open = true;
587
+ }
588
+ } catch (err) {
589
+ this.emitTraceFn({
590
+ type: 'mcpl:channel-open-failed',
591
+ serverId,
592
+ channelId: channel.id,
593
+ error: (err as Error).message,
594
+ });
595
+ }
596
+ }
597
+ }
598
+
599
+ // ==========================================================================
600
+ // Private: Typing notification
601
+ // ==========================================================================
602
+
603
+ /**
604
+ * Send a typing notification for a channel.
605
+ *
606
+ * Uses the sendTypingFn callback if provided. If not, this is a no-op
607
+ * (typing timer lifecycle is still managed for when the callback is wired).
608
+ */
609
+ private sendTypingNotification(serverId: string, channelId: string): void {
610
+ if (this.sendTypingFn) {
611
+ this.sendTypingFn(serverId, channelId);
612
+ }
613
+ // TODO: When server-connection exposes a public sendNotification or
614
+ // sendTyping method, wire it here directly instead of using a callback.
615
+ }
616
+
617
+ // ==========================================================================
618
+ // Private: Channel Lookup
619
+ // ==========================================================================
620
+
621
+ /**
622
+ * Find a channel entry by channelId (searches across all servers).
623
+ * Returns the first match.
624
+ */
625
+ private findChannelEntry(channelId: string): ChannelEntry | undefined {
626
+ for (const [key, entry] of this.channels) {
627
+ if (entry.descriptor.id === channelId) {
628
+ return entry;
629
+ }
630
+ }
631
+ return undefined;
632
+ }
633
+
634
+ /**
635
+ * Find the composite key for a channel by its channelId.
636
+ */
637
+ private findChannelKey(channelId: string): string | undefined {
638
+ for (const [key, entry] of this.channels) {
639
+ if (entry.descriptor.id === channelId) {
640
+ return key;
641
+ }
642
+ }
643
+ return undefined;
644
+ }
645
+
646
+ // ==========================================================================
647
+ // Private: Tool Handlers
648
+ // ==========================================================================
649
+
650
+ private handleToolList(): ToolResult {
651
+ const allChannels: Array<{
652
+ id: string;
653
+ type: string;
654
+ label: string;
655
+ direction: string;
656
+ open: boolean;
657
+ serverId: string;
658
+ }> = [];
659
+
660
+ for (const entry of this.channels.values()) {
661
+ allChannels.push({
662
+ id: entry.descriptor.id,
663
+ type: entry.descriptor.type,
664
+ label: entry.descriptor.label,
665
+ direction: entry.descriptor.direction,
666
+ open: entry.open,
667
+ serverId: entry.serverId,
668
+ });
669
+ }
670
+
671
+ return {
672
+ success: true,
673
+ data: allChannels,
674
+ };
675
+ }
676
+
677
+ private async handleToolOpen(input: { channelId: string }): Promise<ToolResult> {
678
+ const entry = this.findChannelEntry(input.channelId);
679
+ if (!entry) {
680
+ return {
681
+ success: false,
682
+ error: `Channel not found: ${input.channelId}`,
683
+ isError: true,
684
+ };
685
+ }
686
+
687
+ if (entry.open) {
688
+ return {
689
+ success: true,
690
+ data: { channelId: input.channelId, status: 'already open' },
691
+ };
692
+ }
693
+
694
+ const server = this.serverRegistry.getServer(entry.serverId);
695
+ if (!server) {
696
+ return {
697
+ success: false,
698
+ error: `Server not found: ${entry.serverId}`,
699
+ isError: true,
700
+ };
701
+ }
702
+
703
+ try {
704
+ await server.sendChannelsOpen({
705
+ type: entry.descriptor.type,
706
+ address: entry.descriptor.address,
707
+ });
708
+ entry.open = true;
709
+ return {
710
+ success: true,
711
+ data: { channelId: input.channelId, status: 'opened' },
712
+ };
713
+ } catch (err) {
714
+ return {
715
+ success: false,
716
+ error: `Failed to open channel: ${(err as Error).message}`,
717
+ isError: true,
718
+ };
719
+ }
720
+ }
721
+
722
+ private async handleToolClose(input: { channelId: string }): Promise<ToolResult> {
723
+ const entry = this.findChannelEntry(input.channelId);
724
+ if (!entry) {
725
+ return {
726
+ success: false,
727
+ error: `Channel not found: ${input.channelId}`,
728
+ isError: true,
729
+ };
730
+ }
731
+
732
+ if (!entry.open) {
733
+ return {
734
+ success: true,
735
+ data: { channelId: input.channelId, status: 'already closed' },
736
+ };
737
+ }
738
+
739
+ const server = this.serverRegistry.getServer(entry.serverId);
740
+ if (!server) {
741
+ return {
742
+ success: false,
743
+ error: `Server not found: ${entry.serverId}`,
744
+ isError: true,
745
+ };
746
+ }
747
+
748
+ try {
749
+ await server.sendChannelsClose({ channelId: input.channelId });
750
+ entry.open = false;
751
+ this.stopTyping(input.channelId);
752
+ return {
753
+ success: true,
754
+ data: { channelId: input.channelId, status: 'closed' },
755
+ };
756
+ } catch (err) {
757
+ return {
758
+ success: false,
759
+ error: `Failed to close channel: ${(err as Error).message}`,
760
+ isError: true,
761
+ };
762
+ }
763
+ }
764
+
765
+ private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
766
+ // Resolve content: accept both `content` and `text` (backward compat)
767
+ const messageText = input.content ?? input.text;
768
+ if (!messageText) {
769
+ return {
770
+ success: false,
771
+ error: 'Either content or text parameter is required',
772
+ isError: true,
773
+ };
774
+ }
775
+
776
+ // Resolve channelId: default to most recent incoming channel
777
+ const channelId = input.channelId ?? this.defaultPublishChannel;
778
+ if (!channelId) {
779
+ return {
780
+ success: false,
781
+ error: 'No channelId specified and no default channel available',
782
+ isError: true,
783
+ };
784
+ }
785
+
786
+ const entry = this.findChannelEntry(channelId);
787
+ if (!entry) {
788
+ return {
789
+ success: false,
790
+ error: `Channel not found: ${channelId}`,
791
+ isError: true,
792
+ };
793
+ }
794
+
795
+ const server = this.serverRegistry.getServer(entry.serverId);
796
+ if (!server) {
797
+ return {
798
+ success: false,
799
+ error: `Server not found: ${entry.serverId}`,
800
+ isError: true,
801
+ };
802
+ }
803
+
804
+ try {
805
+ const publishParams: ChannelsPublishParams = {
806
+ conversationId: '', // Framework will fill this when wired
807
+ channelId,
808
+ content: [{ type: 'text', text: messageText }],
809
+ };
810
+
811
+ const result = await server.sendChannelsPublish(publishParams);
812
+ return {
813
+ success: true,
814
+ data: result ?? { delivered: true },
815
+ };
816
+ } catch (err) {
817
+ return {
818
+ success: false,
819
+ error: `Failed to publish to channel: ${(err as Error).message}`,
820
+ isError: true,
821
+ };
822
+ }
823
+ }
824
+ }