@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,831 @@
1
+ /**
2
+ * WorkspaceModule — mountable filesystem abstraction backed by Chronicle tree state.
3
+ *
4
+ * Provides a unified workspace with mount-based filesystem access,
5
+ * auto-sync between real filesystem and Chronicle, and manual materialization.
6
+ */
7
+ import { readFile, stat } from 'node:fs/promises';
8
+ import { join, resolve, relative } from 'node:path';
9
+ import { MountWatcher } from './watcher.js';
10
+ import { syncFromFs, materializeToFs, hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
11
+ export class WorkspaceModule {
12
+ name = 'workspace';
13
+ ctx = null;
14
+ store = null;
15
+ config;
16
+ mounts = new Map();
17
+ watchers = new Map();
18
+ constructor(config) {
19
+ // Detect overlapping mount paths: if mount A contains mount B,
20
+ // auto-add an ignore rule on A for B's path to prevent syncing
21
+ // the sub-mount's directory through the super-mount.
22
+ for (const outer of config.mounts) {
23
+ for (const inner of config.mounts) {
24
+ if (outer === inner)
25
+ continue;
26
+ const outerPath = resolve(outer.path);
27
+ const innerPath = resolve(inner.path);
28
+ const rel = relative(outerPath, innerPath);
29
+ if (rel && !rel.startsWith('..') && !rel.startsWith('/')) {
30
+ // inner is nested under outer — add ignore rule
31
+ outer.ignore = outer.ignore ?? [];
32
+ const pattern = rel + '/**';
33
+ if (!outer.ignore.includes(pattern) && !outer.ignore.includes(rel)) {
34
+ outer.ignore.push(rel);
35
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
36
+ `(${rel}/) — auto-ignoring to prevent overlap`);
37
+ }
38
+ }
39
+ }
40
+ }
41
+ this.config = config;
42
+ }
43
+ /**
44
+ * Inject the Chronicle store. Must be called after framework creation.
45
+ */
46
+ initStore(store) {
47
+ this.store = store;
48
+ // Register tree states for each mount
49
+ for (const mount of this.config.mounts) {
50
+ const treeStateId = `workspace/${mount.name}/tree`;
51
+ try {
52
+ store.registerState({
53
+ id: treeStateId,
54
+ strategy: 'tree',
55
+ deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
56
+ fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
57
+ });
58
+ }
59
+ catch {
60
+ // State already registered (restart scenario)
61
+ }
62
+ const mountState = {
63
+ config: mount,
64
+ treeStateId,
65
+ lastMaterializedSeq: 0,
66
+ suppressedPaths: new Set(),
67
+ initialSyncDone: false,
68
+ lastMaterializedBranchId: null,
69
+ materializedHashes: new Map(),
70
+ };
71
+ this.mounts.set(mount.name, mountState);
72
+ }
73
+ }
74
+ async start(ctx) {
75
+ this.ctx = ctx;
76
+ // Restore persisted state if restarting
77
+ if (ctx.isRestart) {
78
+ const saved = ctx.getState();
79
+ if (saved) {
80
+ for (const [name, meta] of Object.entries(saved.mounts)) {
81
+ const mount = this.mounts.get(name);
82
+ if (mount) {
83
+ mount.lastMaterializedSeq = meta.lastMaterializedSeq;
84
+ mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
85
+ }
86
+ }
87
+ }
88
+ }
89
+ // Start watchers for 'always' mode mounts
90
+ for (const [name, mount] of this.mounts) {
91
+ const watchMode = mount.config.watch ?? 'always';
92
+ if (watchMode === 'always') {
93
+ const watcher = new MountWatcher(mount.config, (paths) => {
94
+ this.handleFsChanges(name, paths);
95
+ });
96
+ watcher.start();
97
+ this.watchers.set(name, watcher);
98
+ }
99
+ // Emit mounted event
100
+ this.ctx?.pushEvent({
101
+ type: 'workspace:mounted',
102
+ mount: name,
103
+ path: mount.config.path,
104
+ });
105
+ }
106
+ }
107
+ async stop() {
108
+ // Persist state
109
+ if (this.ctx) {
110
+ const activeBranchId = this.store ? this.store.currentBranch().id : undefined;
111
+ const state = { mounts: {}, activeBranchId };
112
+ for (const [name, mount] of this.mounts) {
113
+ state.mounts[name] = {
114
+ lastMaterializedSeq: mount.lastMaterializedSeq,
115
+ lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined,
116
+ };
117
+ }
118
+ this.ctx.setState(state);
119
+ }
120
+ // Stop watchers
121
+ for (const watcher of this.watchers.values()) {
122
+ await watcher.stop();
123
+ }
124
+ this.watchers.clear();
125
+ this.ctx = null;
126
+ }
127
+ // ==========================================================================
128
+ // Tool Definitions
129
+ // ==========================================================================
130
+ getTools() {
131
+ return [
132
+ {
133
+ name: 'read',
134
+ description: 'Read a file from the workspace. Returns content with line numbers.',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: {
138
+ path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
139
+ offset: { type: 'number', description: 'Starting line number (1-indexed)' },
140
+ limit: { type: 'number', description: 'Maximum number of lines to return' },
141
+ },
142
+ required: ['path'],
143
+ },
144
+ },
145
+ {
146
+ name: 'write',
147
+ description: 'Create or overwrite a file in the workspace.',
148
+ inputSchema: {
149
+ type: 'object',
150
+ properties: {
151
+ path: { type: 'string', description: 'File path (mount-prefixed)' },
152
+ content: { type: 'string', description: 'Content to write' },
153
+ },
154
+ required: ['path', 'content'],
155
+ },
156
+ },
157
+ {
158
+ name: 'edit',
159
+ description: 'Edit a file by replacing a substring. The oldString must be unique unless replaceAll is true.',
160
+ inputSchema: {
161
+ type: 'object',
162
+ properties: {
163
+ path: { type: 'string', description: 'File path (mount-prefixed)' },
164
+ oldString: { type: 'string', description: 'String to find' },
165
+ newString: { type: 'string', description: 'Replacement string' },
166
+ replaceAll: { type: 'boolean', description: 'Replace all occurrences (default: false)' },
167
+ },
168
+ required: ['path', 'oldString', 'newString'],
169
+ },
170
+ },
171
+ {
172
+ name: 'delete',
173
+ description: 'Delete a file from the workspace.',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ path: { type: 'string', description: 'File path (mount-prefixed)' },
178
+ },
179
+ required: ['path'],
180
+ },
181
+ },
182
+ {
183
+ name: 'ls',
184
+ description: 'List directory contents from the workspace tree.',
185
+ inputSchema: {
186
+ type: 'object',
187
+ properties: {
188
+ path: { type: 'string', description: 'Directory path (mount-prefixed, optional)' },
189
+ recursive: { type: 'boolean', description: 'List recursively (default: false)' },
190
+ },
191
+ },
192
+ },
193
+ {
194
+ name: 'glob',
195
+ description: 'Find files matching a glob pattern in the workspace.',
196
+ inputSchema: {
197
+ type: 'object',
198
+ properties: {
199
+ pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.ts")' },
200
+ path: { type: 'string', description: 'Directory to search in (mount-prefixed, optional)' },
201
+ },
202
+ required: ['pattern'],
203
+ },
204
+ },
205
+ {
206
+ name: 'grep',
207
+ description: 'Search file contents with a regex pattern.',
208
+ inputSchema: {
209
+ type: 'object',
210
+ properties: {
211
+ pattern: { type: 'string', description: 'Regular expression pattern' },
212
+ path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
213
+ glob: { type: 'string', description: 'Glob pattern to filter files' },
214
+ contextBefore: { type: 'number', description: 'Context lines before match' },
215
+ contextAfter: { type: 'number', description: 'Context lines after match' },
216
+ },
217
+ required: ['pattern'],
218
+ },
219
+ },
220
+ {
221
+ name: 'status',
222
+ description: 'Show workspace status: mounted directories, pending changes, conflicts.',
223
+ inputSchema: {
224
+ type: 'object',
225
+ properties: {
226
+ mount: { type: 'string', description: 'Specific mount to check (optional)' },
227
+ },
228
+ },
229
+ },
230
+ {
231
+ name: 'materialize',
232
+ description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk.',
233
+ inputSchema: {
234
+ type: 'object',
235
+ properties: {
236
+ path: { type: 'string', description: 'Specific path to materialize (optional — defaults to all changed)' },
237
+ mount: { type: 'string', description: 'Specific mount (optional)' },
238
+ },
239
+ },
240
+ },
241
+ {
242
+ name: 'sync',
243
+ description: 'Pull filesystem state into the workspace. Detects user changes on disk.',
244
+ inputSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ path: { type: 'string', description: 'Specific path to sync (optional — defaults to all)' },
248
+ mount: { type: 'string', description: 'Specific mount (optional)' },
249
+ },
250
+ },
251
+ },
252
+ ];
253
+ }
254
+ // ==========================================================================
255
+ // Tool Dispatch
256
+ // ==========================================================================
257
+ async handleToolCall(call) {
258
+ try {
259
+ const input = call.input;
260
+ switch (call.name) {
261
+ case 'read': return await this.handleRead(input);
262
+ case 'write': return await this.handleWrite(input);
263
+ case 'edit': return await this.handleEdit(input);
264
+ case 'delete': return await this.handleDelete(input);
265
+ case 'ls': return await this.handleLs(input);
266
+ case 'glob': return await this.handleGlob(input);
267
+ case 'grep': return await this.handleGrep(input);
268
+ case 'status': return await this.handleStatus(input);
269
+ case 'materialize': return await this.handleMaterialize(input);
270
+ case 'sync': return await this.handleSync(input);
271
+ default:
272
+ return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
273
+ }
274
+ }
275
+ catch (err) {
276
+ return { success: false, error: String(err), isError: true };
277
+ }
278
+ }
279
+ async onProcess(_event, _state) {
280
+ return {};
281
+ }
282
+ // ==========================================================================
283
+ // Path Resolution
284
+ // ==========================================================================
285
+ /**
286
+ * Parse a mount-prefixed path into (mountName, relativePath).
287
+ */
288
+ parsePath(path) {
289
+ const slashIdx = path.indexOf('/');
290
+ const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
291
+ const relativePath = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
292
+ const mount = this.mounts.get(mountName);
293
+ if (!mount) {
294
+ throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
295
+ }
296
+ // Path traversal guard (CWE-22): ensure resolved path stays within mount
297
+ const resolved = resolve(mount.config.path, relativePath);
298
+ const mountRoot = mount.config.path.endsWith('/') ? mount.config.path : mount.config.path + '/';
299
+ if (resolved !== mount.config.path && !resolved.startsWith(mountRoot)) {
300
+ throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
301
+ }
302
+ return { mount, relativePath };
303
+ }
304
+ getStore() {
305
+ if (!this.store)
306
+ throw new Error('WorkspaceModule: store not initialized. Call initStore() first.');
307
+ return this.store;
308
+ }
309
+ // ==========================================================================
310
+ // Lazy Sync
311
+ // ==========================================================================
312
+ /**
313
+ * Ensure a file is synced from filesystem if not yet in tree (lazy sync).
314
+ */
315
+ async ensureSynced(mount, relativePath) {
316
+ const store = this.getStore();
317
+ const existing = store.treeGet(mount.treeStateId, relativePath);
318
+ if (existing)
319
+ return; // Already in tree
320
+ // Try to read from filesystem
321
+ const absolutePath = join(mount.config.path, relativePath);
322
+ try {
323
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
324
+ const fileStat = await stat(absolutePath);
325
+ if (!fileStat.isFile() || fileStat.size > maxSize)
326
+ return;
327
+ const buffer = await readFile(absolutePath);
328
+ const content = buffer.toString('utf-8');
329
+ const blobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
330
+ store.treeSet(mount.treeStateId, relativePath, {
331
+ blobHash,
332
+ size: buffer.length,
333
+ mode: 0o644,
334
+ });
335
+ }
336
+ catch {
337
+ // File doesn't exist on disk — that's fine
338
+ }
339
+ }
340
+ // ==========================================================================
341
+ // Tool Handlers
342
+ // ==========================================================================
343
+ async handleRead(input) {
344
+ const { mount, relativePath } = this.parsePath(input.path);
345
+ const store = this.getStore();
346
+ await this.ensureSynced(mount, relativePath);
347
+ const entry = store.treeGet(mount.treeStateId, relativePath);
348
+ if (!entry) {
349
+ return { success: false, error: `File not found: ${input.path}`, isError: true };
350
+ }
351
+ const blob = store.getBlob(entry.blobHash);
352
+ if (!blob) {
353
+ return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
354
+ }
355
+ const content = blob.toString('utf-8');
356
+ const lines = content.split('\n');
357
+ // Apply offset/limit
358
+ const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
359
+ const endLine = input.limit ? startLine + input.limit : lines.length;
360
+ const slice = lines.slice(startLine, endLine);
361
+ // Format with line numbers (cat -n style)
362
+ const formatted = slice
363
+ .map((line, i) => `${String(startLine + i + 1).padStart(6)}\t${line}`)
364
+ .join('\n');
365
+ return {
366
+ success: true,
367
+ data: {
368
+ path: input.path,
369
+ totalLines: lines.length,
370
+ fromLine: startLine + 1,
371
+ toLine: Math.min(endLine, lines.length),
372
+ content: formatted,
373
+ },
374
+ };
375
+ }
376
+ async handleWrite(input) {
377
+ const { mount, relativePath } = this.parsePath(input.path);
378
+ if (mount.config.mode === 'read-only') {
379
+ return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
380
+ }
381
+ const store = this.getStore();
382
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
383
+ if (Buffer.byteLength(input.content) > maxSize) {
384
+ return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
385
+ }
386
+ const blobHash = store.storeBlob(Buffer.from(input.content, 'utf-8'), 'text/plain');
387
+ store.treeSet(mount.treeStateId, relativePath, {
388
+ blobHash,
389
+ size: Buffer.byteLength(input.content),
390
+ mode: 0o644,
391
+ });
392
+ return {
393
+ success: true,
394
+ data: {
395
+ path: input.path,
396
+ size: Buffer.byteLength(input.content),
397
+ hash: hashContent(input.content),
398
+ },
399
+ };
400
+ }
401
+ async handleEdit(input) {
402
+ const { mount, relativePath } = this.parsePath(input.path);
403
+ if (mount.config.mode === 'read-only') {
404
+ return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
405
+ }
406
+ const store = this.getStore();
407
+ await this.ensureSynced(mount, relativePath);
408
+ const entry = store.treeGet(mount.treeStateId, relativePath);
409
+ if (!entry) {
410
+ return { success: false, error: `File not found: ${input.path}`, isError: true };
411
+ }
412
+ const blob = store.getBlob(entry.blobHash);
413
+ if (!blob) {
414
+ return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
415
+ }
416
+ let content = blob.toString('utf-8');
417
+ // Validate uniqueness
418
+ if (!input.replaceAll) {
419
+ const count = content.split(input.oldString).length - 1;
420
+ if (count === 0) {
421
+ return { success: false, error: `String not found in ${input.path}`, isError: true };
422
+ }
423
+ if (count > 1) {
424
+ return {
425
+ success: false,
426
+ error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
427
+ isError: true,
428
+ };
429
+ }
430
+ }
431
+ content = input.replaceAll
432
+ ? content.replaceAll(input.oldString, input.newString)
433
+ : content.replace(input.oldString, input.newString);
434
+ const newBlobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
435
+ store.treeSet(mount.treeStateId, relativePath, {
436
+ blobHash: newBlobHash,
437
+ size: Buffer.byteLength(content),
438
+ mode: entry.mode,
439
+ });
440
+ return {
441
+ success: true,
442
+ data: {
443
+ path: input.path,
444
+ size: Buffer.byteLength(content),
445
+ },
446
+ };
447
+ }
448
+ async handleDelete(input) {
449
+ const { mount, relativePath } = this.parsePath(input.path);
450
+ if (mount.config.mode === 'read-only') {
451
+ return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
452
+ }
453
+ const store = this.getStore();
454
+ const entry = store.treeGet(mount.treeStateId, relativePath);
455
+ if (!entry) {
456
+ return { success: false, error: `File not found: ${input.path}`, isError: true };
457
+ }
458
+ store.treeRemove(mount.treeStateId, relativePath);
459
+ return { success: true, data: { path: input.path, deleted: true } };
460
+ }
461
+ async handleLs(input) {
462
+ const store = this.getStore();
463
+ if (!input.path) {
464
+ // List all mounts
465
+ const mounts = [...this.mounts.entries()].map(([name, m]) => ({
466
+ name,
467
+ path: m.config.path,
468
+ mode: m.config.mode,
469
+ }));
470
+ return { success: true, data: { mounts } };
471
+ }
472
+ const { mount, relativePath } = this.parsePath(input.path);
473
+ // Ensure initial sync — always sync on first access regardless of watch mode,
474
+ // so that ls/glob/grep see filesystem contents even for unwatched mounts
475
+ if (!mount.initialSyncDone) {
476
+ await syncFromFs(store, mount);
477
+ mount.initialSyncDone = true;
478
+ }
479
+ const prefix = relativePath ? relativePath + '/' : '';
480
+ const entries = store.treeList(mount.treeStateId, prefix || undefined);
481
+ if (input.recursive) {
482
+ return {
483
+ success: true,
484
+ data: {
485
+ path: input.path,
486
+ entries: entries.map(e => ({
487
+ path: e.path,
488
+ size: e.size,
489
+ })),
490
+ count: entries.length,
491
+ },
492
+ };
493
+ }
494
+ // Non-recursive: deduplicate to show immediate children only
495
+ const seen = new Set();
496
+ const children = [];
497
+ for (const entry of entries) {
498
+ const rest = entry.path.slice(prefix.length);
499
+ const slashIdx = rest.indexOf('/');
500
+ if (slashIdx >= 0) {
501
+ const dirName = rest.slice(0, slashIdx);
502
+ if (!seen.has(dirName)) {
503
+ seen.add(dirName);
504
+ children.push({ name: dirName, type: 'directory' });
505
+ }
506
+ }
507
+ else {
508
+ children.push({ name: rest, type: 'file' });
509
+ }
510
+ }
511
+ return {
512
+ success: true,
513
+ data: {
514
+ path: input.path,
515
+ entries: children,
516
+ count: children.length,
517
+ },
518
+ };
519
+ }
520
+ async handleGlob(input) {
521
+ const store = this.getStore();
522
+ const regex = globToRegex(input.pattern);
523
+ const matches = [];
524
+ // Search across mounts
525
+ const mountsToSearch = input.path
526
+ ? [this.parsePath(input.path)]
527
+ : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
528
+ for (const { mount, relativePath } of mountsToSearch) {
529
+ if (!mount.initialSyncDone) {
530
+ await syncFromFs(store, mount);
531
+ mount.initialSyncDone = true;
532
+ }
533
+ const prefix = relativePath ? relativePath + '/' : undefined;
534
+ const entries = store.treeList(mount.treeStateId, prefix);
535
+ for (const entry of entries) {
536
+ const testPath = relativePath ? entry.path.slice(relativePath.length + 1) : entry.path;
537
+ if (regex.test(testPath)) {
538
+ matches.push(`${mount.config.name}/${entry.path}`);
539
+ }
540
+ }
541
+ }
542
+ return {
543
+ success: true,
544
+ data: {
545
+ pattern: input.pattern,
546
+ matches,
547
+ count: matches.length,
548
+ },
549
+ };
550
+ }
551
+ async handleGrep(input) {
552
+ const store = this.getStore();
553
+ let regex;
554
+ try {
555
+ regex = new RegExp(input.pattern);
556
+ }
557
+ catch (e) {
558
+ return { success: false, error: `Invalid regex: ${input.pattern}`, isError: true };
559
+ }
560
+ const fileGlob = input.glob ? globToRegex(input.glob) : null;
561
+ const contextBefore = input.contextBefore ?? 0;
562
+ const contextAfter = input.contextAfter ?? 0;
563
+ const mountsToSearch = input.path
564
+ ? [this.parsePath(input.path)]
565
+ : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
566
+ const results = [];
567
+ for (const { mount, relativePath } of mountsToSearch) {
568
+ if (!mount.initialSyncDone) {
569
+ await syncFromFs(store, mount);
570
+ mount.initialSyncDone = true;
571
+ }
572
+ const prefix = relativePath ? relativePath + '/' : undefined;
573
+ const entries = store.treeList(mount.treeStateId, prefix);
574
+ for (const entry of entries) {
575
+ if (fileGlob && !fileGlob.test(entry.path))
576
+ continue;
577
+ const blob = store.getBlob(entry.blobHash);
578
+ if (!blob)
579
+ continue;
580
+ const content = blob.toString('utf-8');
581
+ const lines = content.split('\n');
582
+ const fileMatches = [];
583
+ for (let i = 0; i < lines.length; i++) {
584
+ if (regex.test(lines[i])) {
585
+ const match = {
586
+ line: i + 1,
587
+ text: lines[i],
588
+ };
589
+ if (contextBefore > 0 || contextAfter > 0) {
590
+ const start = Math.max(0, i - contextBefore);
591
+ const end = Math.min(lines.length, i + contextAfter + 1);
592
+ match.context = lines.slice(start, end);
593
+ }
594
+ fileMatches.push(match);
595
+ }
596
+ }
597
+ if (fileMatches.length > 0) {
598
+ results.push({
599
+ file: `${mount.config.name}/${entry.path}`,
600
+ matches: fileMatches,
601
+ });
602
+ }
603
+ }
604
+ }
605
+ return {
606
+ success: true,
607
+ data: {
608
+ pattern: input.pattern,
609
+ results,
610
+ totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
611
+ },
612
+ };
613
+ }
614
+ async handleStatus(_input) {
615
+ const store = this.getStore();
616
+ const status = {};
617
+ for (const [name, mount] of this.mounts) {
618
+ const entries = store.treeList(mount.treeStateId);
619
+ const currentSeq = store.currentSequence();
620
+ const changes = mount.lastMaterializedSeq > 0
621
+ ? store.treeDiff(mount.treeStateId, mount.lastMaterializedSeq, currentSeq)
622
+ : [];
623
+ const currentBranch = store.currentBranch();
624
+ status[name] = {
625
+ path: mount.config.path,
626
+ mode: mount.config.mode,
627
+ watch: mount.config.watch ?? 'always',
628
+ fileCount: entries.length,
629
+ lastMaterializedSeq: mount.lastMaterializedSeq,
630
+ currentSeq,
631
+ pendingChanges: changes.length,
632
+ initialSyncDone: mount.initialSyncDone,
633
+ currentBranch: currentBranch.name,
634
+ lastMaterializedBranch: mount.lastMaterializedBranchId,
635
+ canMaterialize: !mount.lastMaterializedBranchId || mount.lastMaterializedBranchId === currentBranch.id,
636
+ };
637
+ }
638
+ return { success: true, data: status };
639
+ }
640
+ async handleMaterialize(input) {
641
+ const store = this.getStore();
642
+ // Guard: only materialize on the active branch
643
+ if (this.config.materializeOnlyActiveBranch !== false) {
644
+ const currentBranch = store.currentBranch();
645
+ for (const mount of this.mounts.values()) {
646
+ if (mount.lastMaterializedBranchId && mount.lastMaterializedBranchId !== currentBranch.id) {
647
+ return {
648
+ success: false,
649
+ error: `Cannot materialize: current branch "${currentBranch.name}" differs from last materialized branch. Switch back or set materializeOnlyActiveBranch: false.`,
650
+ isError: true,
651
+ };
652
+ }
653
+ }
654
+ }
655
+ const allWritten = [];
656
+ let mountsToMaterialize;
657
+ if (input.mount) {
658
+ const m = this.mounts.get(input.mount);
659
+ if (!m) {
660
+ return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
661
+ }
662
+ mountsToMaterialize = [{ name: input.mount, mount: m }];
663
+ }
664
+ else {
665
+ mountsToMaterialize = [...this.mounts.entries()]
666
+ .filter(([, m]) => m.config.mode === 'read-write')
667
+ .map(([name, mount]) => ({ name, mount }));
668
+ }
669
+ for (const { name, mount } of mountsToMaterialize) {
670
+ let paths;
671
+ if (input.path) {
672
+ const { relativePath } = this.parsePath(input.path);
673
+ paths = relativePath ? [relativePath] : undefined;
674
+ }
675
+ // Suppress watcher for paths we're about to write
676
+ const watcher = this.watchers.get(name);
677
+ const written = await materializeToFs(store, mount, paths);
678
+ for (const p of written) {
679
+ watcher?.suppress(p);
680
+ allWritten.push({ mount: name, path: p });
681
+ }
682
+ // Track which branch we materialized on
683
+ if (written.length > 0) {
684
+ mount.lastMaterializedBranchId = store.currentBranch().id;
685
+ }
686
+ }
687
+ return {
688
+ success: true,
689
+ data: {
690
+ materialized: allWritten,
691
+ count: allWritten.length,
692
+ },
693
+ };
694
+ }
695
+ /**
696
+ * Programmatically materialize a mount's files from Chronicle tree to filesystem.
697
+ * Used after branch switches to refresh filesystem state.
698
+ * Resets branch tracking to allow cross-branch materialization.
699
+ */
700
+ async materializeMount(mountName) {
701
+ const store = this.getStore();
702
+ const mount = this.mounts.get(mountName);
703
+ if (!mount || mount.config.mode === 'read-only')
704
+ return [];
705
+ // Reset tracking — we're deliberately materializing on the new branch
706
+ mount.lastMaterializedBranchId = null;
707
+ mount.lastMaterializedSeq = 0;
708
+ const watcher = this.watchers.get(mountName);
709
+ const written = await materializeToFs(store, mount);
710
+ for (const p of written) {
711
+ watcher?.suppress(p);
712
+ }
713
+ if (written.length > 0) {
714
+ mount.lastMaterializedBranchId = store.currentBranch().id;
715
+ }
716
+ return written;
717
+ }
718
+ async handleSync(input) {
719
+ const store = this.getStore();
720
+ const allResults = [];
721
+ let mountsToSync;
722
+ if (input.mount) {
723
+ const m = this.mounts.get(input.mount);
724
+ if (!m) {
725
+ return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
726
+ }
727
+ mountsToSync = [{ name: input.mount, mount: m }];
728
+ }
729
+ else {
730
+ mountsToSync = [...this.mounts.entries()].map(([name, mount]) => ({ name, mount }));
731
+ }
732
+ for (const { name, mount } of mountsToSync) {
733
+ let paths;
734
+ if (input.path) {
735
+ const { relativePath } = this.parsePath(input.path);
736
+ // Empty relativePath means mount root — sync entire mount, not a single path
737
+ paths = relativePath ? [relativePath] : undefined;
738
+ }
739
+ const result = await syncFromFs(store, mount, paths);
740
+ mount.initialSyncDone = true;
741
+ if (result.synced.length > 0 || result.conflicts.length > 0) {
742
+ allResults.push({ mount: name, synced: result.synced, conflicts: result.conflicts });
743
+ // Emit workspace:changed event
744
+ if (this.ctx) {
745
+ const event = {
746
+ type: 'workspace:changed',
747
+ paths: result.synced.map(p => `${name}/${p}`),
748
+ mount: name,
749
+ conflicts: result.conflicts.length > 0
750
+ ? result.conflicts.map(c => `${name}/${c.path}`)
751
+ : undefined,
752
+ };
753
+ this.ctx.pushEvent(event);
754
+ }
755
+ }
756
+ }
757
+ return {
758
+ success: true,
759
+ data: {
760
+ results: allResults,
761
+ totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
762
+ totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
763
+ },
764
+ };
765
+ }
766
+ // ==========================================================================
767
+ // Internal: Filesystem Change Handling
768
+ // ==========================================================================
769
+ /**
770
+ * Handle filesystem changes detected by watcher (watch: 'always' mode).
771
+ */
772
+ async handleFsChanges(mountName, paths) {
773
+ const store = this.store;
774
+ const mount = this.mounts.get(mountName);
775
+ if (!store || !mount)
776
+ return;
777
+ const result = await syncFromFs(store, mount, paths);
778
+ if ((result.synced.length > 0 || result.conflicts.length > 0) && this.ctx) {
779
+ const event = {
780
+ type: 'workspace:changed',
781
+ paths: result.synced.map(p => `${mountName}/${p}`),
782
+ mount: mountName,
783
+ conflicts: result.conflicts.length > 0
784
+ ? result.conflicts.map(c => `${mountName}/${c.path}`)
785
+ : undefined,
786
+ };
787
+ this.ctx.pushEvent(event);
788
+ }
789
+ }
790
+ }
791
+ // ==========================================================================
792
+ // Utilities
793
+ // ==========================================================================
794
+ /**
795
+ * Convert a glob pattern to a RegExp.
796
+ */
797
+ function globToRegex(pattern) {
798
+ // Split pattern into segments, handling {a,b,c} alternation
799
+ let regex = '';
800
+ let i = 0;
801
+ while (i < pattern.length) {
802
+ if (pattern[i] === '{') {
803
+ const closeIdx = pattern.indexOf('}', i);
804
+ if (closeIdx > i) {
805
+ const alternatives = pattern.slice(i + 1, closeIdx).split(',');
806
+ regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
807
+ i = closeIdx + 1;
808
+ continue;
809
+ }
810
+ }
811
+ // Accumulate non-brace characters, convert as a chunk
812
+ let chunk = '';
813
+ while (i < pattern.length && pattern[i] !== '{') {
814
+ chunk += pattern[i];
815
+ i++;
816
+ }
817
+ if (chunk) {
818
+ regex += globPartToRegex(chunk);
819
+ }
820
+ }
821
+ return new RegExp(`^${regex}$`);
822
+ }
823
+ function globPartToRegex(part) {
824
+ return part
825
+ .replace(/[.+^$()|[\]\\]/g, '\\$&')
826
+ .replace(/\*\*/g, '__DOUBLESTAR__')
827
+ .replace(/\*/g, '[^/]*')
828
+ .replace(/__DOUBLESTAR__/g, '.*')
829
+ .replace(/\?/g, '[^/]');
830
+ }
831
+ //# sourceMappingURL=index.js.map