@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,545 @@
1
+ /**
2
+ * CheckpointManager — MCPL state management with branching checkpoint trees.
3
+ *
4
+ * Manages per-(serverId, featureSet) checkpoint trees for stateful MCPL tools.
5
+ * Two modes:
6
+ * - hostState: true → host stores state data, applies JSON Patch deltas
7
+ * - hostState: false → host tracks opaque checkpoint IDs only
8
+ *
9
+ * Checkpoint tree metadata is persisted to Chronicle via a `mcpl/checkpoints`
10
+ * state slot. Host-managed state data is persisted per feature set.
11
+ *
12
+ * Spec reference: Section 8 (State Management).
13
+ */
14
+
15
+ import type { JsStore } from '@animalabs/chronicle';
16
+
17
+ import type { StateCheckpoint, JsonPatchOperation } from './types.js';
18
+
19
+ // ============================================================================
20
+ // Constants
21
+ // ============================================================================
22
+
23
+ /** Chronicle state ID for checkpoint tree metadata. */
24
+ const CHECKPOINTS_STATE_ID = 'mcpl/checkpoints';
25
+
26
+ /** Prefix for per-feature-set host-managed state. */
27
+ const STATE_PREFIX = 'mcpl/state';
28
+
29
+ // ============================================================================
30
+ // Internal types
31
+ // ============================================================================
32
+
33
+ interface CheckpointNode {
34
+ checkpoint: string;
35
+ parent: string | null;
36
+ children: string[];
37
+ /** Full state data snapshot (host-managed, when server sends `data`). */
38
+ data?: unknown;
39
+ /** JSON Patch delta from parent (host-managed, when server sends `patch`). */
40
+ patch?: JsonPatchOperation[];
41
+ }
42
+
43
+ interface FeatureSetState {
44
+ hostState: boolean;
45
+ rollback: boolean;
46
+ currentCheckpoint: string | null;
47
+ /** Reconstructed state at currentCheckpoint (host-managed only). */
48
+ currentState: unknown | undefined;
49
+ nodes: Map<string, CheckpointNode>;
50
+ }
51
+
52
+ /** Serialized form stored in Chronicle. */
53
+ interface SerializedTrees {
54
+ trees: Record<string, {
55
+ hostState: boolean;
56
+ rollback: boolean;
57
+ current: string | null;
58
+ nodes: Record<string, { parent: string | null; children: string[]; data?: unknown; patch?: JsonPatchOperation[] }>;
59
+ }>;
60
+ }
61
+
62
+ // ============================================================================
63
+ // JSON Patch (RFC 6902) — minimal implementation
64
+ // ============================================================================
65
+
66
+ /**
67
+ * Parse a JSON Pointer (RFC 6901) into path segments.
68
+ * E.g. "/foo/0/bar" → ["foo", "0", "bar"]
69
+ */
70
+ function parsePointer(pointer: string): string[] {
71
+ if (pointer === '') return [];
72
+ if (!pointer.startsWith('/')) {
73
+ throw new Error(`Invalid JSON Pointer: ${pointer}`);
74
+ }
75
+ return pointer.slice(1).split('/').map(
76
+ (s) => s.replace(/~1/g, '/').replace(/~0/g, '~'),
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Navigate to a parent container and return [parent, lastKey].
82
+ * Throws if the path doesn't exist.
83
+ */
84
+ function navigateTo(
85
+ doc: unknown,
86
+ segments: string[],
87
+ ): [parent: Record<string, unknown> | unknown[], key: string] {
88
+ let current: unknown = doc;
89
+ for (let i = 0; i < segments.length - 1; i++) {
90
+ const seg = segments[i]!;
91
+ if (Array.isArray(current)) {
92
+ current = current[Number(seg)];
93
+ } else if (current !== null && typeof current === 'object') {
94
+ current = (current as Record<string, unknown>)[seg];
95
+ } else {
96
+ throw new Error(`Cannot navigate path segment "${seg}": not an object/array`);
97
+ }
98
+ }
99
+ return [current as Record<string, unknown> | unknown[], segments[segments.length - 1]!];
100
+ }
101
+
102
+ /**
103
+ * Apply a JSON Patch (RFC 6902) to a document.
104
+ * Supports: add, remove, replace, test.
105
+ * Operates on a deep-cloned document to avoid mutation.
106
+ */
107
+ export function applyJsonPatch(doc: unknown, ops: JsonPatchOperation[]): unknown {
108
+ let result: unknown = structuredClone(doc);
109
+
110
+ for (const op of ops) {
111
+ const segments = parsePointer(op.path);
112
+
113
+ switch (op.op) {
114
+ case 'add': {
115
+ if (segments.length === 0) {
116
+ result = op.value;
117
+ break;
118
+ }
119
+ const [parent, key] = navigateTo(result, segments);
120
+ if (Array.isArray(parent)) {
121
+ const idx = key === '-' ? parent.length : Number(key);
122
+ parent.splice(idx, 0, op.value);
123
+ } else {
124
+ (parent as Record<string, unknown>)[key] = op.value;
125
+ }
126
+ break;
127
+ }
128
+
129
+ case 'remove': {
130
+ const [parent, key] = navigateTo(result, segments);
131
+ if (Array.isArray(parent)) {
132
+ parent.splice(Number(key), 1);
133
+ } else {
134
+ delete (parent as Record<string, unknown>)[key];
135
+ }
136
+ break;
137
+ }
138
+
139
+ case 'replace': {
140
+ if (segments.length === 0) {
141
+ result = op.value;
142
+ break;
143
+ }
144
+ const [parent, key] = navigateTo(result, segments);
145
+ if (Array.isArray(parent)) {
146
+ parent[Number(key)] = op.value;
147
+ } else {
148
+ (parent as Record<string, unknown>)[key] = op.value;
149
+ }
150
+ break;
151
+ }
152
+
153
+ case 'test': {
154
+ const [parent, key] = navigateTo(result, segments);
155
+ const actual = Array.isArray(parent)
156
+ ? parent[Number(key)]
157
+ : (parent as Record<string, unknown>)[key];
158
+ if (JSON.stringify(actual) !== JSON.stringify(op.value)) {
159
+ throw new Error(
160
+ `JSON Patch test failed at "${op.path}": expected ${JSON.stringify(op.value)}, got ${JSON.stringify(actual)}`,
161
+ );
162
+ }
163
+ break;
164
+ }
165
+
166
+ case 'move':
167
+ case 'copy':
168
+ // Deferred — not needed for MVP
169
+ throw new Error(`JSON Patch op "${op.op}" not yet supported`);
170
+ }
171
+ }
172
+
173
+ return result;
174
+ }
175
+
176
+ // ============================================================================
177
+ // CheckpointManager
178
+ // ============================================================================
179
+
180
+ export class CheckpointManager {
181
+ private store: JsStore;
182
+ private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
183
+ private trees = new Map<string, FeatureSetState>();
184
+
185
+ constructor(
186
+ store: JsStore,
187
+ emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
188
+ ) {
189
+ this.store = store;
190
+ this.emitTraceFn = emitTraceFn;
191
+
192
+ // Register the checkpoint metadata state slot
193
+ try {
194
+ store.registerState({ id: CHECKPOINTS_STATE_ID, strategy: 'snapshot' });
195
+ } catch {
196
+ // Already registered (e.g. framework restart)
197
+ }
198
+
199
+ // Load existing tree from Chronicle
200
+ this.loadFromStore();
201
+ }
202
+
203
+ // ==========================================================================
204
+ // Registration
205
+ // ==========================================================================
206
+
207
+ /**
208
+ * Register a feature set as stateful.
209
+ * Called during initializeMcpl for feature sets with rollback or hostState.
210
+ */
211
+ registerFeatureSet(
212
+ serverId: string,
213
+ featureSet: string,
214
+ opts: { hostState: boolean; rollback: boolean },
215
+ ): void {
216
+ const key = this.key(serverId, featureSet);
217
+ if (this.trees.has(key)) return; // Already registered (e.g. from loaded state)
218
+
219
+ this.trees.set(key, {
220
+ hostState: opts.hostState,
221
+ rollback: opts.rollback,
222
+ currentCheckpoint: null,
223
+ currentState: undefined,
224
+ nodes: new Map(),
225
+ });
226
+
227
+ // Register per-feature-set state slot for host-managed state
228
+ if (opts.hostState) {
229
+ const stateId = this.stateId(serverId, featureSet);
230
+ try {
231
+ this.store.registerState({ id: stateId, strategy: 'snapshot' });
232
+ } catch {
233
+ // Already registered
234
+ }
235
+ // Load existing state if any
236
+ const existing = this.store.getStateJson(stateId);
237
+ if (existing !== null && existing !== undefined) {
238
+ const tree = this.trees.get(key)!;
239
+ tree.currentState = existing;
240
+ }
241
+ }
242
+
243
+ this.emitTraceFn({
244
+ type: 'mcpl:state_registered',
245
+ serverId,
246
+ featureSet,
247
+ hostState: opts.hostState,
248
+ rollback: opts.rollback,
249
+ });
250
+
251
+ this.persistTree();
252
+ }
253
+
254
+ // ==========================================================================
255
+ // Checkpoint recording
256
+ // ==========================================================================
257
+
258
+ /**
259
+ * Record a checkpoint returned in a tool call response.
260
+ * Updates the tree and persists to Chronicle.
261
+ */
262
+ recordCheckpoint(
263
+ serverId: string,
264
+ featureSet: string,
265
+ checkpoint: StateCheckpoint,
266
+ ): void {
267
+ const key = this.key(serverId, featureSet);
268
+ const tree = this.trees.get(key);
269
+ if (!tree) return;
270
+
271
+ // Add node to tree
272
+ const node: CheckpointNode = {
273
+ checkpoint: checkpoint.checkpoint,
274
+ parent: checkpoint.parent,
275
+ children: [],
276
+ data: checkpoint.data,
277
+ patch: checkpoint.patch,
278
+ };
279
+
280
+ // Link to parent
281
+ if (checkpoint.parent) {
282
+ const parentNode = tree.nodes.get(checkpoint.parent);
283
+ if (parentNode) {
284
+ parentNode.children.push(checkpoint.checkpoint);
285
+ }
286
+ }
287
+
288
+ tree.nodes.set(checkpoint.checkpoint, node);
289
+ tree.currentCheckpoint = checkpoint.checkpoint;
290
+
291
+ // For host-managed state, reconstruct current state
292
+ if (tree.hostState) {
293
+ if (checkpoint.data !== undefined) {
294
+ // Full state provided — use directly
295
+ tree.currentState = structuredClone(checkpoint.data);
296
+ } else if (checkpoint.patch) {
297
+ // Delta provided — apply to current state
298
+ try {
299
+ tree.currentState = applyJsonPatch(
300
+ tree.currentState ?? {},
301
+ checkpoint.patch,
302
+ );
303
+ } catch (err) {
304
+ console.error(
305
+ `[CheckpointManager] JSON Patch failed for ${key}:`,
306
+ err instanceof Error ? err.message : err,
307
+ );
308
+ // Keep existing state on patch failure
309
+ }
310
+ }
311
+
312
+ // Persist reconstructed state
313
+ this.store.setStateJson(this.stateId(serverId, featureSet), tree.currentState);
314
+ }
315
+
316
+ this.emitTraceFn({
317
+ type: 'mcpl:checkpoint_recorded',
318
+ serverId,
319
+ featureSet,
320
+ checkpoint: checkpoint.checkpoint,
321
+ parent: checkpoint.parent,
322
+ hasData: checkpoint.data !== undefined,
323
+ hasPatch: checkpoint.patch !== undefined,
324
+ });
325
+
326
+ this.persistTree();
327
+ }
328
+
329
+ // ==========================================================================
330
+ // State access (for tools/call params)
331
+ // ==========================================================================
332
+
333
+ /** Get current reconstructed state for host-managed feature sets. */
334
+ getCurrentState(serverId: string, featureSet: string): unknown | undefined {
335
+ return this.trees.get(this.key(serverId, featureSet))?.currentState;
336
+ }
337
+
338
+ /** Get current checkpoint ID for server-managed feature sets. */
339
+ getCurrentCheckpoint(serverId: string, featureSet: string): string | null {
340
+ return this.trees.get(this.key(serverId, featureSet))?.currentCheckpoint ?? null;
341
+ }
342
+
343
+ // ==========================================================================
344
+ // Queries
345
+ // ==========================================================================
346
+
347
+ /** Check if a (serverId, featureSet) pair is registered as stateful. */
348
+ isStateful(serverId: string, featureSet: string): boolean {
349
+ return this.trees.has(this.key(serverId, featureSet));
350
+ }
351
+
352
+ /** Check if a (serverId, featureSet) pair uses host-managed state. */
353
+ isHostManaged(serverId: string, featureSet: string): boolean {
354
+ return this.trees.get(this.key(serverId, featureSet))?.hostState ?? false;
355
+ }
356
+
357
+ /**
358
+ * Get the first stateful feature set for a given server.
359
+ * Returns null if no stateful feature sets are registered.
360
+ */
361
+ getStatefulFeatureSet(serverId: string): string | null {
362
+ const prefix = `${serverId}:`;
363
+ for (const [key] of this.trees) {
364
+ if (key.startsWith(prefix)) {
365
+ return key.slice(prefix.length);
366
+ }
367
+ }
368
+ return null;
369
+ }
370
+
371
+ // ==========================================================================
372
+ // Rollback
373
+ // ==========================================================================
374
+
375
+ /**
376
+ * Roll back to a previous checkpoint in the tree.
377
+ * Updates the current pointer and, for host-managed state, reconstructs
378
+ * the state at that checkpoint.
379
+ *
380
+ * Returns the reconstructed state (host-managed) or undefined (server-managed).
381
+ * Throws if the checkpoint is not found.
382
+ */
383
+ rollbackTo(
384
+ serverId: string,
385
+ featureSet: string,
386
+ checkpoint: string,
387
+ ): unknown | undefined {
388
+ const key = this.key(serverId, featureSet);
389
+ const tree = this.trees.get(key);
390
+ if (!tree) throw new Error(`No stateful feature set: ${key}`);
391
+
392
+ const node = tree.nodes.get(checkpoint);
393
+ if (!node) throw new Error(`Checkpoint not found: ${checkpoint}`);
394
+
395
+ tree.currentCheckpoint = checkpoint;
396
+
397
+ if (tree.hostState) {
398
+ // Reconstruct state at this checkpoint by walking from root
399
+ tree.currentState = this.reconstructState(tree, checkpoint);
400
+ this.store.setStateJson(this.stateId(serverId, featureSet), tree.currentState);
401
+ }
402
+
403
+ this.emitTraceFn({
404
+ type: 'mcpl:state_rollback',
405
+ serverId,
406
+ featureSet,
407
+ checkpoint,
408
+ });
409
+
410
+ this.persistTree();
411
+ return tree.currentState;
412
+ }
413
+
414
+ // ==========================================================================
415
+ // Cleanup
416
+ // ==========================================================================
417
+
418
+ /** Remove all state for a server (called on disconnect). */
419
+ removeServer(serverId: string): void {
420
+ const prefix = `${serverId}:`;
421
+ const toRemove: string[] = [];
422
+
423
+ for (const [key] of this.trees) {
424
+ if (key.startsWith(prefix)) {
425
+ toRemove.push(key);
426
+ }
427
+ }
428
+
429
+ for (const key of toRemove) {
430
+ this.trees.delete(key);
431
+ }
432
+
433
+ if (toRemove.length > 0) {
434
+ this.persistTree();
435
+ }
436
+ }
437
+
438
+ // ==========================================================================
439
+ // Private: State reconstruction
440
+ // ==========================================================================
441
+
442
+ /**
443
+ * Reconstruct host-managed state at a given checkpoint by walking
444
+ * from the nearest ancestor with full data down to the target.
445
+ */
446
+ private reconstructState(tree: FeatureSetState, checkpoint: string): unknown {
447
+ // Build path from root to checkpoint
448
+ const path: CheckpointNode[] = [];
449
+ let current: string | null = checkpoint;
450
+
451
+ while (current !== null) {
452
+ const node = tree.nodes.get(current);
453
+ if (!node) break;
454
+ path.unshift(node);
455
+ current = node.parent;
456
+ }
457
+
458
+ // Walk forward, applying data/patches
459
+ let state: unknown = {};
460
+
461
+ for (const node of path) {
462
+ if (node.data !== undefined) {
463
+ // Full state snapshot — use it, discard prior state
464
+ state = structuredClone(node.data);
465
+ } else if (node.patch) {
466
+ // Delta — apply to current state
467
+ try {
468
+ state = applyJsonPatch(state, node.patch);
469
+ } catch {
470
+ // On patch failure, keep last known good state
471
+ }
472
+ }
473
+ }
474
+
475
+ return state;
476
+ }
477
+
478
+ // ==========================================================================
479
+ // Private: Persistence
480
+ // ==========================================================================
481
+
482
+ /** Persist checkpoint tree metadata to Chronicle. */
483
+ private persistTree(): void {
484
+ const serialized: SerializedTrees = { trees: {} };
485
+
486
+ for (const [key, tree] of this.trees) {
487
+ const nodes: SerializedTrees['trees'][string]['nodes'] = {};
488
+ for (const [id, node] of tree.nodes) {
489
+ nodes[id] = {
490
+ parent: node.parent,
491
+ children: node.children,
492
+ data: node.data,
493
+ patch: node.patch,
494
+ };
495
+ }
496
+ serialized.trees[key] = {
497
+ hostState: tree.hostState,
498
+ rollback: tree.rollback,
499
+ current: tree.currentCheckpoint,
500
+ nodes,
501
+ };
502
+ }
503
+
504
+ this.store.setStateJson(CHECKPOINTS_STATE_ID, serialized);
505
+ }
506
+
507
+ /** Load checkpoint tree from Chronicle on startup. */
508
+ private loadFromStore(): void {
509
+ const data = this.store.getStateJson(CHECKPOINTS_STATE_ID) as SerializedTrees | null;
510
+ if (!data?.trees) return;
511
+
512
+ for (const [key, entry] of Object.entries(data.trees)) {
513
+ const nodes = new Map<string, CheckpointNode>();
514
+ for (const [id, nodeData] of Object.entries(entry.nodes)) {
515
+ nodes.set(id, {
516
+ checkpoint: id,
517
+ parent: nodeData.parent,
518
+ children: [...nodeData.children],
519
+ data: nodeData.data,
520
+ patch: nodeData.patch,
521
+ });
522
+ }
523
+
524
+ this.trees.set(key, {
525
+ hostState: entry.hostState,
526
+ rollback: entry.rollback,
527
+ currentCheckpoint: entry.current,
528
+ currentState: undefined, // Will be loaded per-feature-set during registerFeatureSet
529
+ nodes,
530
+ });
531
+ }
532
+ }
533
+
534
+ // ==========================================================================
535
+ // Private: Helpers
536
+ // ==========================================================================
537
+
538
+ private key(serverId: string, featureSet: string): string {
539
+ return `${serverId}:${featureSet}`;
540
+ }
541
+
542
+ private stateId(serverId: string, featureSet: string): string {
543
+ return `${STATE_PREFIX}/${serverId}/${featureSet}`;
544
+ }
545
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * MCPL error codes and helpers.
3
+ *
4
+ * Error codes from Spec Appendix A. Used when rejecting inbound messages
5
+ * from MCPL servers that violate permission or state constraints.
6
+ */
7
+
8
+ import type { JsonRpcError } from './types.js';
9
+
10
+ // ============================================================================
11
+ // Error Codes (Spec Appendix A)
12
+ // ============================================================================
13
+
14
+ /** Message used a disabled feature set. */
15
+ export const FEATURE_SET_NOT_ENABLED = -32001;
16
+
17
+ /** Message used an undeclared feature set. */
18
+ export const UNKNOWN_FEATURE_SET = -32003;
19
+
20
+ /** Rollback targeted a pruned or unknown checkpoint. */
21
+ export const CHECKPOINT_NOT_FOUND = -32005;
22
+
23
+ /** Lacking scope to publish or observe channel. */
24
+ export const CHANNEL_NOT_PERMITTED = -32017;
25
+
26
+ /** Channel ID doesn't exist or not registered. */
27
+ export const UNKNOWN_CHANNEL = -32023;
28
+
29
+ /** Server could not open/connect the requested channel. */
30
+ export const CHANNEL_OPEN_FAILED = -32024;
31
+
32
+ // ============================================================================
33
+ // Error Factories
34
+ // ============================================================================
35
+
36
+ /**
37
+ * Create a "feature set not enabled" error.
38
+ * Returned when a server sends a message tagged with a feature set
39
+ * that the host has disabled.
40
+ */
41
+ export function featureSetNotEnabled(
42
+ featureSet: string,
43
+ canEnable: boolean = true
44
+ ): JsonRpcError {
45
+ return {
46
+ code: FEATURE_SET_NOT_ENABLED,
47
+ message: 'Feature set not enabled',
48
+ data: { featureSet, canEnable },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Create an "unknown feature set" error.
54
+ * Returned when a server sends a message tagged with a feature set
55
+ * that was never declared in capabilities.
56
+ */
57
+ export function unknownFeatureSet(featureSet: string): JsonRpcError {
58
+ return {
59
+ code: UNKNOWN_FEATURE_SET,
60
+ message: 'Unknown feature set',
61
+ data: { featureSet },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Create a "checkpoint not found" error.
67
+ * Returned when a rollback targets a pruned or unknown checkpoint.
68
+ */
69
+ export function checkpointNotFound(checkpoint: string): JsonRpcError {
70
+ return {
71
+ code: CHECKPOINT_NOT_FOUND,
72
+ message: 'Checkpoint not found',
73
+ data: { checkpoint },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Create a "channel not permitted" error.
79
+ * Returned when a server lacks scope to publish or observe a channel.
80
+ */
81
+ export function channelNotPermitted(channelId: string): JsonRpcError {
82
+ return {
83
+ code: CHANNEL_NOT_PERMITTED,
84
+ message: 'Channel not permitted',
85
+ data: { channelId },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Create an "unknown channel" error.
91
+ * Returned when a channel ID doesn't exist or isn't registered.
92
+ */
93
+ export function unknownChannel(channelId: string): JsonRpcError {
94
+ return {
95
+ code: UNKNOWN_CHANNEL,
96
+ message: 'Unknown channel',
97
+ data: { channelId },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Create a "channel open failed" error.
103
+ * Returned when a server could not open/connect the requested channel.
104
+ */
105
+ export function channelOpenFailed(
106
+ channelId: string,
107
+ reason?: string
108
+ ): JsonRpcError {
109
+ return {
110
+ code: CHANNEL_OPEN_FAILED,
111
+ message: 'Channel open failed',
112
+ data: { channelId, reason },
113
+ };
114
+ }