@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
@@ -0,0 +1,284 @@
1
+ /**
2
+ * ConversationRouter — routes incoming channel messages to per-channel
3
+ * conversation agents ("forks") instead of the primary conversation.
4
+ *
5
+ * The router is a lookup table plus policy — deliberately no LLM in the
6
+ * routing path. The framework consults it from handleMcplChannelIncoming:
7
+ *
8
+ * - A channel with an active binding routes to its bound agent. Whether the
9
+ * message *triggers* inference (vs. just landing in the fork's context) is
10
+ * a separate per-channel-kind rule: in a busy channel everything is case
11
+ * input but only mentions demand a reply.
12
+ * - An unbound channel binds (→ the framework spawns a fork from the
13
+ * template/trunk agent) when the bind rule matches: by default any message
14
+ * in a DM, an explicit bot mention in a channel (`metadata.mentioned`,
15
+ * set server-side by the platform adapters).
16
+ * - Everything else is left unrouted and dropped by the framework — the
17
+ * trunk is a dormant template, not a listener.
18
+ *
19
+ * Bindings expire after an idle TTL. Expiry runs a final closure turn on the
20
+ * fork (see framework wiring) and unbinds; the next qualifying message on
21
+ * that channel spawns a *fresh* fork (generation + 1) from the current
22
+ * trunk, so handbook/template updates propagate between engagements.
23
+ *
24
+ * The binding table is in-memory — bindings are re-established by the next
25
+ * qualifying message after a restart. The generation counters, by contrast,
26
+ * are persisted by the framework (exportGenerations/hydrateGenerations):
27
+ * a restarted host must NOT reuse generation 1's agent name, or it would
28
+ * reopen the previous engagement's Chronicle namespace and re-seed the
29
+ * template context on top of the old history.
30
+ */
31
+
32
+ import type { ContextStrategy } from '@animalabs/context-manager';
33
+ import type { ChannelDescriptor } from './types.js';
34
+
35
+ // ============================================================================
36
+ // Config & types
37
+ // ============================================================================
38
+
39
+ /** When an unbound channel acquires a fork. */
40
+ export type BindRule = 'always' | 'mention' | 'never';
41
+
42
+ /** When a message on a bound channel triggers inference (it always lands in
43
+ * the fork's context). */
44
+ export type TriggerRule = 'always' | 'mention';
45
+
46
+ /** How a channel is classified for bind/trigger rules. Group DMs are their
47
+ * own kind: the bot was added deliberately (so they bind like DMs) but they
48
+ * host multi-human chatter (so they trigger like channels by default —
49
+ * inferring on every message of a four-human group DM is a firehose). */
50
+ export type ChannelKind = 'dm' | 'groupDm' | 'channel';
51
+
52
+ export interface ConversationRouterConfig {
53
+ /** Agent whose context seeds new forks (the "trunk"/warm checkpoint). */
54
+ templateAgent: string;
55
+
56
+ /** Bind rules per channel kind.
57
+ * Defaults: dm 'always', groupDm 'always', channel 'mention'. */
58
+ bind?: { dm?: BindRule; groupDm?: BindRule; channel?: BindRule };
59
+
60
+ /** Trigger rules per channel kind.
61
+ * Defaults: dm 'always', groupDm 'mention', channel 'mention'. */
62
+ trigger?: { dm?: TriggerRule; groupDm?: TriggerRule; channel?: TriggerRule };
63
+
64
+ /** Idle time before a binding expires and the fork is closed. Default 12h. */
65
+ idleTtlMs?: number;
66
+
67
+ /** Final system-initiated user message sent to a fork on expiry. */
68
+ closurePrompt?: string;
69
+
70
+ /** Prefix for generated fork agent names. Default 'conversation'. */
71
+ agentPrefix?: string;
72
+
73
+ /**
74
+ * Fresh compression strategy per fork. Strategy instances are stateful and
75
+ * must not be shared between ContextManagers, so the template agent's own
76
+ * strategy is never reused — without a factory, forks get passthrough.
77
+ */
78
+ strategyFactory?: () => ContextStrategy;
79
+ }
80
+
81
+ export interface ConversationBinding {
82
+ channelId: string;
83
+ agentName: string;
84
+ /** Engagement counter per channel — fresh forks get a new generation. */
85
+ generation: number;
86
+ boundAt: number;
87
+ lastActivity: number;
88
+ }
89
+
90
+ /** The facts route() needs about one incoming message, pre-extracted by the
91
+ * framework so the router stays free of event-shape knowledge. */
92
+ export interface IncomingMessageFacts {
93
+ channelId: string;
94
+ /** Personal mention of the bot (metadata.mentioned from the adapter). */
95
+ mentioned: boolean;
96
+ /** Channel classification (see classifyChannel). */
97
+ kind: ChannelKind;
98
+ /** Clock injection for tests; defaults to Date.now(). */
99
+ now?: number;
100
+ }
101
+
102
+ export type RoutingDecision =
103
+ /** Channel already has a fork — deliver there. */
104
+ | { kind: 'existing'; agentName: string; trigger: boolean }
105
+ /** Bind rule matched — framework should spawn this agent, then bind(). */
106
+ | { kind: 'spawn'; agentName: string; generation: number; trigger: boolean }
107
+ /** No binding and bind rule didn't match — drop. */
108
+ | { kind: 'unbound' };
109
+
110
+ const DEFAULT_IDLE_TTL_MS = 12 * 60 * 60 * 1000;
111
+
112
+ export const DEFAULT_CLOSURE_PROMPT =
113
+ '[system] This engagement is closing due to inactivity. Finalize your ' +
114
+ 'work: post any promised results to the channel and record outstanding ' +
115
+ 'threads as unresolved. Do not ask follow-up questions.';
116
+
117
+ // ============================================================================
118
+ // ConversationRouter
119
+ // ============================================================================
120
+
121
+ export class ConversationRouter {
122
+ private config: ConversationRouterConfig;
123
+
124
+ /** Active bindings, keyed by channelId. */
125
+ private bindings = new Map<string, ConversationBinding>();
126
+
127
+ /** Engagement counter per channel — survives unbind, so a re-bound channel
128
+ * gets a distinct fork agent name. */
129
+ private generations = new Map<string, number>();
130
+
131
+ constructor(config: ConversationRouterConfig) {
132
+ this.config = config;
133
+ }
134
+
135
+ get templateAgent(): string {
136
+ return this.config.templateAgent;
137
+ }
138
+
139
+ get closurePrompt(): string {
140
+ return this.config.closurePrompt ?? DEFAULT_CLOSURE_PROMPT;
141
+ }
142
+
143
+ get idleTtlMs(): number {
144
+ return this.config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
145
+ }
146
+
147
+ get strategyFactory(): (() => ContextStrategy) | undefined {
148
+ return this.config.strategyFactory;
149
+ }
150
+
151
+ /**
152
+ * Decide where an incoming message goes. Pure read except for refreshing
153
+ * lastActivity on an existing binding — a 'spawn' decision does NOT create
154
+ * the binding; the framework calls bind() after the fork agent actually
155
+ * exists, so a failed spawn doesn't leave a dangling route.
156
+ */
157
+ route(facts: IncomingMessageFacts): RoutingDecision {
158
+ const now = facts.now ?? Date.now();
159
+ const existing = this.bindings.get(facts.channelId);
160
+
161
+ if (existing) {
162
+ existing.lastActivity = now;
163
+ return {
164
+ kind: 'existing',
165
+ agentName: existing.agentName,
166
+ trigger: this.shouldTrigger(facts),
167
+ };
168
+ }
169
+
170
+ const bindRule = this.config.bind?.[facts.kind]
171
+ ?? (facts.kind === 'channel' ? 'mention' : 'always');
172
+
173
+ const binds =
174
+ bindRule === 'always' || (bindRule === 'mention' && facts.mentioned);
175
+ if (!binds) {
176
+ return { kind: 'unbound' };
177
+ }
178
+
179
+ const generation = (this.generations.get(facts.channelId) ?? 0) + 1;
180
+ return {
181
+ kind: 'spawn',
182
+ agentName: this.forkAgentName(facts.channelId, generation),
183
+ generation,
184
+ trigger: this.shouldTrigger(facts),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Record a binding after the framework successfully spawned the fork.
190
+ * Returns the new binding.
191
+ */
192
+ bind(channelId: string, agentName: string, generation: number, now = Date.now()): ConversationBinding {
193
+ this.generations.set(channelId, generation);
194
+ const binding: ConversationBinding = {
195
+ channelId,
196
+ agentName,
197
+ generation,
198
+ boundAt: now,
199
+ lastActivity: now,
200
+ };
201
+ this.bindings.set(channelId, binding);
202
+ return binding;
203
+ }
204
+
205
+ unbind(channelId: string): void {
206
+ this.bindings.delete(channelId);
207
+ }
208
+
209
+ getBinding(channelId: string): ConversationBinding | undefined {
210
+ return this.bindings.get(channelId);
211
+ }
212
+
213
+ /** Reverse lookup: the channel an agent is bound to, if any. */
214
+ channelForAgent(agentName: string): string | undefined {
215
+ for (const binding of this.bindings.values()) {
216
+ if (binding.agentName === agentName) return binding.channelId;
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ getBindings(): ConversationBinding[] {
222
+ return Array.from(this.bindings.values());
223
+ }
224
+
225
+ /** Bindings whose idle TTL has elapsed. Does not unbind — the framework
226
+ * runs the closure turn first, then calls unbind(). */
227
+ expired(now = Date.now()): ConversationBinding[] {
228
+ const ttl = this.idleTtlMs;
229
+ return this.getBindings().filter((b) => now - b.lastActivity >= ttl);
230
+ }
231
+
232
+ // ==========================================================================
233
+ // Helpers
234
+ // ==========================================================================
235
+
236
+ /**
237
+ * Classify a channel from its descriptor and/or message metadata. Slack
238
+ * marks DMs/group DMs with is_im/is_mpim on the channel descriptor and
239
+ * channel_type on each message; other platforms can adopt either
240
+ * convention.
241
+ */
242
+ static classifyChannel(
243
+ descriptor?: ChannelDescriptor,
244
+ messageMetadata?: Record<string, unknown>,
245
+ ): ChannelKind {
246
+ const meta = descriptor?.metadata as Record<string, unknown> | undefined;
247
+ if (meta?.is_im === true) return 'dm';
248
+ if (meta?.is_mpim === true) return 'groupDm';
249
+ const channelType = messageMetadata?.channel_type;
250
+ if (channelType === 'im') return 'dm';
251
+ if (channelType === 'mpim') return 'groupDm';
252
+ return 'channel';
253
+ }
254
+
255
+ /** Export the per-channel engagement counters for persistence — they must
256
+ * survive restarts, or a re-engaged channel reuses generation 1's name and
257
+ * reopens (and re-seeds) the previous engagement's Chronicle namespace. */
258
+ exportGenerations(): Record<string, number> {
259
+ return Object.fromEntries(this.generations);
260
+ }
261
+
262
+ /** Restore persisted engagement counters (see exportGenerations). */
263
+ hydrateGenerations(generations: Record<string, number>): void {
264
+ for (const [channelId, generation] of Object.entries(generations)) {
265
+ if (typeof generation === 'number' && generation > (this.generations.get(channelId) ?? 0)) {
266
+ this.generations.set(channelId, generation);
267
+ }
268
+ }
269
+ }
270
+
271
+ private shouldTrigger(facts: IncomingMessageFacts): boolean {
272
+ const rule = this.config.trigger?.[facts.kind]
273
+ ?? (facts.kind === 'dm' ? 'always' : 'mention');
274
+ return rule === 'always' || facts.mentioned;
275
+ }
276
+
277
+ /** `conversation-slack-C123-g2` — agent names double as Chronicle
278
+ * namespace components, so the channel id is sanitized. */
279
+ private forkAgentName(channelId: string, generation: number): string {
280
+ const prefix = this.config.agentPrefix ?? 'conversation';
281
+ const safe = channelId.replace(/[^A-Za-z0-9_-]+/g, '-');
282
+ return `${prefix}-${safe}-g${generation}`;
283
+ }
284
+ }
@@ -145,7 +145,20 @@ export class FeatureSetManager {
145
145
  capabilities: McplCapabilities,
146
146
  config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] }
147
147
  ): FeatureSetsUpdateParams {
148
- const declared = capabilities.featureSets ?? {};
148
+ // Cross-package compat: agent-framework's McplCapabilities types
149
+ // featureSets as Record<string, FeatureSetDeclaration>, but mcpl-core-ts
150
+ // (used by discord-mcpl and other servers) types it as an array of
151
+ // {name, ...FeatureSetDeclaration}. Both representations arrive over
152
+ // the wire. Normalize to Record here so downstream code (resolvePatterns,
153
+ // validateInbound, etc.) can rely on the keyed shape.
154
+ const featureSetsRaw = capabilities.featureSets ?? {};
155
+ const declared: Record<string, FeatureSetDeclaration> = Array.isArray(featureSetsRaw)
156
+ ? Object.fromEntries(
157
+ (featureSetsRaw as ReadonlyArray<FeatureSetDeclaration & { name: string }>).map(
158
+ (d) => [d.name, d],
159
+ ),
160
+ )
161
+ : featureSetsRaw;
149
162
 
150
163
  const state: ServerState = {
151
164
  declared: { ...declared },
package/src/mcpl/index.ts CHANGED
@@ -130,5 +130,17 @@ export { InferenceRouter } from './inference-router.js';
130
130
  // Channel registry (channel lifecycle, incoming messages, synthesized tools)
131
131
  export { ChannelRegistry } from './channel-registry.js';
132
132
 
133
+ // Per-channel conversation routing (fork-per-channel agents)
134
+ export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './conversation-router.js';
135
+ export type {
136
+ ConversationRouterConfig,
137
+ ConversationBinding,
138
+ RoutingDecision,
139
+ IncomingMessageFacts,
140
+ BindRule,
141
+ TriggerRule,
142
+ ChannelKind,
143
+ } from './conversation-router.js';
144
+
133
145
  // State management (Section 8, checkpoint trees + JSON Patch)
134
146
  export { CheckpointManager, applyJsonPatch } from './checkpoint-manager.js';
@@ -197,6 +197,7 @@ export class PushHandler {
197
197
  eventId: params.eventId,
198
198
  eventType: 'mcpl:push-event',
199
199
  ...(params.origin ?? {}),
200
+ ...(params.tags ? { tags: params.tags } : {}),
200
201
  };
201
202
  triggerInference = this.shouldTriggerInference(textContent, metadata);
202
203
  }