@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/src/agent.d.ts +130 -0
- package/dist/src/agent.d.ts.map +1 -0
- package/dist/src/agent.js +406 -0
- package/dist/src/agent.js.map +1 -0
- package/dist/src/api/mcp-server.d.ts +37 -0
- package/dist/src/api/mcp-server.d.ts.map +1 -0
- package/dist/src/api/mcp-server.js +598 -0
- package/dist/src/api/mcp-server.js.map +1 -0
- package/dist/src/api/server.d.ts +83 -0
- package/dist/src/api/server.d.ts.map +1 -0
- package/dist/src/api/server.js +785 -0
- package/dist/src/api/server.js.map +1 -0
- package/dist/src/api/types.d.ts +395 -0
- package/dist/src/api/types.d.ts.map +1 -0
- package/dist/src/api/types.js +5 -0
- package/dist/src/api/types.js.map +1 -0
- package/dist/src/framework.d.ts +309 -0
- package/dist/src/framework.d.ts.map +1 -0
- package/dist/src/framework.js +2309 -0
- package/dist/src/framework.js.map +1 -0
- package/dist/src/gate/event-gate.d.ts +75 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -0
- package/dist/src/gate/event-gate.js +503 -0
- package/dist/src/gate/event-gate.js.map +1 -0
- package/dist/src/gate/index.d.ts +3 -0
- package/dist/src/gate/index.d.ts.map +1 -0
- package/dist/src/gate/index.js +2 -0
- package/dist/src/gate/index.js.map +1 -0
- package/dist/src/gate/types.d.ts +82 -0
- package/dist/src/gate/types.d.ts.map +1 -0
- package/dist/src/gate/types.js +9 -0
- package/dist/src/gate/types.js.map +1 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +18 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +141 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
- package/dist/src/mcpl/channel-registry.js +647 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.js +430 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
- package/dist/src/mcpl/errors.d.ts +52 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -0
- package/dist/src/mcpl/errors.js +93 -0
- package/dist/src/mcpl/errors.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
- package/dist/src/mcpl/feature-set-manager.js +250 -0
- package/dist/src/mcpl/feature-set-manager.js.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.js +217 -0
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
- package/dist/src/mcpl/index.d.ts +14 -0
- package/dist/src/mcpl/index.d.ts.map +1 -0
- package/dist/src/mcpl/index.js +21 -0
- package/dist/src/mcpl/index.js.map +1 -0
- package/dist/src/mcpl/inference-router.d.ts +67 -0
- package/dist/src/mcpl/inference-router.d.ts.map +1 -0
- package/dist/src/mcpl/inference-router.js +249 -0
- package/dist/src/mcpl/inference-router.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +60 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -0
- package/dist/src/mcpl/push-handler.js +162 -0
- package/dist/src/mcpl/push-handler.js.map +1 -0
- package/dist/src/mcpl/scope-manager.d.ts +76 -0
- package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
- package/dist/src/mcpl/scope-manager.js +213 -0
- package/dist/src/mcpl/scope-manager.js.map +1 -0
- package/dist/src/mcpl/server-connection.d.ts +174 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -0
- package/dist/src/mcpl/server-connection.js +559 -0
- package/dist/src/mcpl/server-connection.js.map +1 -0
- package/dist/src/mcpl/server-registry.d.ts +63 -0
- package/dist/src/mcpl/server-registry.d.ts.map +1 -0
- package/dist/src/mcpl/server-registry.js +119 -0
- package/dist/src/mcpl/server-registry.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +755 -0
- package/dist/src/mcpl/types.d.ts.map +1 -0
- package/dist/src/mcpl/types.js +48 -0
- package/dist/src/mcpl/types.js.map +1 -0
- package/dist/src/module-registry.d.ts +93 -0
- package/dist/src/module-registry.d.ts.map +1 -0
- package/dist/src/module-registry.js +386 -0
- package/dist/src/module-registry.js.map +1 -0
- package/dist/src/modules/api/index.d.ts +22 -0
- package/dist/src/modules/api/index.d.ts.map +1 -0
- package/dist/src/modules/api/index.js +63 -0
- package/dist/src/modules/api/index.js.map +1 -0
- package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
- package/dist/src/modules/discord/discord-js-client.js +273 -0
- package/dist/src/modules/discord/discord-js-client.js.map +1 -0
- package/dist/src/modules/discord/index.d.ts +82 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -0
- package/dist/src/modules/discord/index.js +959 -0
- package/dist/src/modules/discord/index.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +327 -0
- package/dist/src/modules/discord/types.d.ts.map +1 -0
- package/dist/src/modules/discord/types.js +5 -0
- package/dist/src/modules/discord/types.js.map +1 -0
- package/dist/src/modules/index.d.ts +11 -0
- package/dist/src/modules/index.d.ts.map +1 -0
- package/dist/src/modules/index.js +7 -0
- package/dist/src/modules/index.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +59 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -0
- package/dist/src/modules/workspace/index.js +831 -0
- package/dist/src/modules/workspace/index.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +48 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -0
- package/dist/src/modules/workspace/sync.js +253 -0
- package/dist/src/modules/workspace/sync.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +166 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -0
- package/dist/src/modules/workspace/types.js +5 -0
- package/dist/src/modules/workspace/types.js.map +1 -0
- package/dist/src/modules/workspace/watcher.d.ts +43 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
- package/dist/src/modules/workspace/watcher.js +120 -0
- package/dist/src/modules/workspace/watcher.js.map +1 -0
- package/dist/src/queue.d.ts +50 -0
- package/dist/src/queue.d.ts.map +1 -0
- package/dist/src/queue.js +95 -0
- package/dist/src/queue.js.map +1 -0
- package/dist/src/safe-slice.d.ts +8 -0
- package/dist/src/safe-slice.d.ts.map +1 -0
- package/dist/src/safe-slice.js +18 -0
- package/dist/src/safe-slice.js.map +1 -0
- package/dist/src/types/agent.d.ts +118 -0
- package/dist/src/types/agent.d.ts.map +1 -0
- package/dist/src/types/agent.js +2 -0
- package/dist/src/types/agent.js.map +1 -0
- package/dist/src/types/events.d.ts +220 -0
- package/dist/src/types/events.d.ts.map +1 -0
- package/dist/src/types/events.js +2 -0
- package/dist/src/types/events.js.map +1 -0
- package/dist/src/types/framework.d.ts +184 -0
- package/dist/src/types/framework.d.ts.map +1 -0
- package/dist/src/types/framework.js +2 -0
- package/dist/src/types/framework.js.map +1 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +7 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/inference-log.d.ts +64 -0
- package/dist/src/types/inference-log.d.ts.map +1 -0
- package/dist/src/types/inference-log.js +5 -0
- package/dist/src/types/inference-log.js.map +1 -0
- package/dist/src/types/module.d.ts +302 -0
- package/dist/src/types/module.d.ts.map +1 -0
- package/dist/src/types/module.js +2 -0
- package/dist/src/types/module.js.map +1 -0
- package/dist/src/types/trace.d.ts +173 -0
- package/dist/src/types/trace.d.ts.map +1 -0
- package/dist/src/types/trace.js +2 -0
- package/dist/src/types/trace.js.map +1 -0
- package/dist/test/event-gate.test.d.ts +2 -0
- package/dist/test/event-gate.test.d.ts.map +1 -0
- package/dist/test/event-gate.test.js +535 -0
- package/dist/test/event-gate.test.js.map +1 -0
- package/dist/test/framework.test.d.ts +5 -0
- package/dist/test/framework.test.d.ts.map +1 -0
- package/dist/test/framework.test.js +738 -0
- package/dist/test/framework.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +58 -0
- package/src/agent.ts +512 -0
- package/src/api/mcp-server.ts +687 -0
- package/src/api/server.ts +1023 -0
- package/src/api/types.ts +542 -0
- package/src/framework.ts +2784 -0
- package/src/gate/event-gate.ts +623 -0
- package/src/gate/index.ts +12 -0
- package/src/gate/types.ts +112 -0
- package/src/index.ts +63 -0
- package/src/mcpl/channel-registry.ts +824 -0
- package/src/mcpl/checkpoint-manager.ts +545 -0
- package/src/mcpl/errors.ts +114 -0
- package/src/mcpl/feature-set-manager.ts +333 -0
- package/src/mcpl/hook-orchestrator.ts +281 -0
- package/src/mcpl/index.ts +134 -0
- package/src/mcpl/inference-router.ts +335 -0
- package/src/mcpl/push-handler.ts +232 -0
- package/src/mcpl/scope-manager.ts +262 -0
- package/src/mcpl/server-connection.ts +694 -0
- package/src/mcpl/server-registry.ts +144 -0
- package/src/mcpl/types.ts +991 -0
- package/src/module-registry.ts +547 -0
- package/src/modules/api/index.ts +94 -0
- package/src/modules/discord/discord-js-client.ts +353 -0
- package/src/modules/discord/index.ts +1146 -0
- package/src/modules/discord/types.ts +465 -0
- package/src/modules/index.ts +43 -0
- package/src/modules/workspace/index.ts +969 -0
- package/src/modules/workspace/sync.ts +314 -0
- package/src/modules/workspace/types.ts +202 -0
- package/src/modules/workspace/watcher.ts +142 -0
- package/src/queue.ts +106 -0
- package/src/safe-slice.ts +18 -0
- package/src/types/agent.ts +119 -0
- package/src/types/events.ts +252 -0
- package/src/types/framework.ts +211 -0
- package/src/types/index.ts +6 -0
- package/src/types/inference-log.ts +64 -0
- package/src/types/module.ts +367 -0
- 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
|