@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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync logic between filesystem and Chronicle tree state.
|
|
3
|
+
*
|
|
4
|
+
* Two directions:
|
|
5
|
+
* - syncFromFs: filesystem → Chronicle (user changes)
|
|
6
|
+
* - materializeToFs: Chronicle → filesystem (agent changes)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { readFile, writeFile, mkdir, readdir, stat, access } from 'node:fs/promises';
|
|
11
|
+
import { join, dirname, relative, resolve } from 'node:path';
|
|
12
|
+
import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
|
|
13
|
+
import type { MountState } from './types.js';
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a relative path within a mount and verify it doesn't escape.
|
|
19
|
+
* Returns the absolute path, or null if the path is outside the mount.
|
|
20
|
+
*/
|
|
21
|
+
function safePath(mountPath: string, relativePath: string): string | null {
|
|
22
|
+
const resolved = resolve(mountPath, relativePath);
|
|
23
|
+
const root = mountPath.endsWith('/') ? mountPath : mountPath + '/';
|
|
24
|
+
if (resolved !== mountPath && !resolved.startsWith(root)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hash file content to a full SHA-256 hex string.
|
|
32
|
+
* Must match Chronicle's storeBlob() hash format (64-char hex).
|
|
33
|
+
*/
|
|
34
|
+
export function hashContent(content: string | Buffer): string {
|
|
35
|
+
const hash = createHash('sha256');
|
|
36
|
+
hash.update(content);
|
|
37
|
+
return hash.digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if content appears to be binary.
|
|
42
|
+
*/
|
|
43
|
+
function isBinary(buffer: Buffer): boolean {
|
|
44
|
+
// Check for null bytes in first 8KB
|
|
45
|
+
const check = buffer.subarray(0, 8192);
|
|
46
|
+
for (let i = 0; i < check.length; i++) {
|
|
47
|
+
if (check[i] === 0) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ConflictInfo {
|
|
53
|
+
/** Relative path of the conflicted file */
|
|
54
|
+
path: string;
|
|
55
|
+
/** Blob hash of the agent's version (retrievable via store.getBlob()) */
|
|
56
|
+
agentBlobHash: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SyncResult {
|
|
60
|
+
/** Paths that were synced */
|
|
61
|
+
synced: string[];
|
|
62
|
+
/** Paths that conflicted (both agent and user modified) — filesystem wins */
|
|
63
|
+
conflicts: ConflictInfo[];
|
|
64
|
+
/** Paths that were skipped (binary, too large, etc.) */
|
|
65
|
+
skipped: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sync filesystem changes into Chronicle tree state.
|
|
70
|
+
*
|
|
71
|
+
* @param store Chronicle store
|
|
72
|
+
* @param mount Mount state
|
|
73
|
+
* @param paths Specific paths to sync (relative to mount). If empty, walks the directory.
|
|
74
|
+
* @returns Sync result with synced/conflicted/skipped paths
|
|
75
|
+
*/
|
|
76
|
+
export async function syncFromFs(
|
|
77
|
+
store: JsStore,
|
|
78
|
+
mount: MountState,
|
|
79
|
+
paths?: string[],
|
|
80
|
+
): Promise<SyncResult> {
|
|
81
|
+
const result: SyncResult = { synced: [], conflicts: [], skipped: [] };
|
|
82
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
83
|
+
|
|
84
|
+
const filesToSync = paths ?? await walkDirectory(mount.config.path, mount.config.ignore ?? []);
|
|
85
|
+
|
|
86
|
+
for (const relativePath of filesToSync) {
|
|
87
|
+
const absolutePath = safePath(mount.config.path, relativePath);
|
|
88
|
+
if (!absolutePath) {
|
|
89
|
+
result.skipped.push(relativePath);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await access(absolutePath);
|
|
95
|
+
} catch {
|
|
96
|
+
// File was deleted on disk — remove from tree
|
|
97
|
+
const existing = store.treeGet(mount.treeStateId, relativePath);
|
|
98
|
+
if (existing) {
|
|
99
|
+
store.treeRemove(mount.treeStateId, relativePath);
|
|
100
|
+
result.synced.push(relativePath);
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const fileStat = await stat(absolutePath);
|
|
107
|
+
if (!fileStat.isFile()) continue;
|
|
108
|
+
if (fileStat.size > maxSize) {
|
|
109
|
+
result.skipped.push(relativePath);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const buffer = await readFile(absolutePath);
|
|
114
|
+
if (isBinary(buffer)) {
|
|
115
|
+
result.skipped.push(relativePath);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const content = buffer.toString('utf-8');
|
|
120
|
+
const hash = hashContent(content);
|
|
121
|
+
|
|
122
|
+
// Check current tree state
|
|
123
|
+
const existing = store.treeGet(mount.treeStateId, relativePath);
|
|
124
|
+
|
|
125
|
+
if (existing && existing.blobHash === hash) {
|
|
126
|
+
// No change
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Store blob and update tree
|
|
131
|
+
const blobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
|
|
132
|
+
const entry: JsTreeEntry = {
|
|
133
|
+
blobHash,
|
|
134
|
+
size: buffer.length,
|
|
135
|
+
mode: 0o644,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Conflict detection: if the file existed in the tree AND the agent has
|
|
139
|
+
// modified it since last materialization, this is a genuine conflict
|
|
140
|
+
// (both agent and user changed the same file).
|
|
141
|
+
if (existing) {
|
|
142
|
+
const baselineHash = mount.materializedHashes.get(relativePath);
|
|
143
|
+
if (baselineHash && existing.blobHash !== baselineHash) {
|
|
144
|
+
// Agent changed the tree entry since we last materialized — conflict.
|
|
145
|
+
// Filesystem still wins, but agent's version is recoverable via agentBlobHash.
|
|
146
|
+
result.conflicts.push({
|
|
147
|
+
path: relativePath,
|
|
148
|
+
agentBlobHash: existing.blobHash,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
store.treeSet(mount.treeStateId, relativePath, entry);
|
|
154
|
+
result.synced.push(relativePath);
|
|
155
|
+
} catch {
|
|
156
|
+
result.skipped.push(relativePath);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Materialize Chronicle tree state to filesystem.
|
|
165
|
+
*
|
|
166
|
+
* @param store Chronicle store
|
|
167
|
+
* @param mount Mount state
|
|
168
|
+
* @param paths Specific paths to materialize. If undefined, materializes all changed since last.
|
|
169
|
+
* @returns List of paths that were written
|
|
170
|
+
*/
|
|
171
|
+
export async function materializeToFs(
|
|
172
|
+
store: JsStore,
|
|
173
|
+
mount: MountState,
|
|
174
|
+
paths?: string[],
|
|
175
|
+
): Promise<string[]> {
|
|
176
|
+
if (mount.config.mode === 'read-only') {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const written: string[] = [];
|
|
181
|
+
|
|
182
|
+
// Get changed files since last materialization
|
|
183
|
+
const currentSeq = store.currentSequence();
|
|
184
|
+
let filesToMaterialize: Array<{ path: string; blobHash: string }>;
|
|
185
|
+
|
|
186
|
+
if (paths) {
|
|
187
|
+
// Materialize specific paths
|
|
188
|
+
filesToMaterialize = [];
|
|
189
|
+
for (const p of paths) {
|
|
190
|
+
const entry = store.treeGet(mount.treeStateId, p);
|
|
191
|
+
if (entry) {
|
|
192
|
+
filesToMaterialize.push({ path: p, blobHash: entry.blobHash });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else if (mount.lastMaterializedSeq > 0) {
|
|
196
|
+
// Diff since last materialization
|
|
197
|
+
const changes = store.treeDiff(
|
|
198
|
+
mount.treeStateId,
|
|
199
|
+
mount.lastMaterializedSeq,
|
|
200
|
+
currentSeq,
|
|
201
|
+
);
|
|
202
|
+
filesToMaterialize = changes
|
|
203
|
+
.filter(c => c.changeType === 'added' || c.changeType === 'modified')
|
|
204
|
+
.map(c => ({
|
|
205
|
+
path: c.path,
|
|
206
|
+
blobHash: c.newEntry!.blobHash,
|
|
207
|
+
}));
|
|
208
|
+
|
|
209
|
+
// Handle removals
|
|
210
|
+
for (const change of changes) {
|
|
211
|
+
if (change.changeType === 'removed') {
|
|
212
|
+
// Don't delete from filesystem — just skip.
|
|
213
|
+
// Agent removing from tree doesn't mean delete user's file.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// First materialization — materialize everything
|
|
218
|
+
const entries = store.treeList(mount.treeStateId);
|
|
219
|
+
filesToMaterialize = entries.map(e => ({
|
|
220
|
+
path: e.path,
|
|
221
|
+
blobHash: e.blobHash,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const { path: relativePath, blobHash } of filesToMaterialize) {
|
|
226
|
+
const absolutePath = safePath(mount.config.path, relativePath);
|
|
227
|
+
if (!absolutePath) continue; // Path traversal — skip silently
|
|
228
|
+
|
|
229
|
+
const blob = store.getBlob(blobHash);
|
|
230
|
+
if (!blob) continue;
|
|
231
|
+
|
|
232
|
+
// Create parent directories
|
|
233
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
234
|
+
|
|
235
|
+
// Write file
|
|
236
|
+
await writeFile(absolutePath, blob);
|
|
237
|
+
written.push(relativePath);
|
|
238
|
+
|
|
239
|
+
// Record blob hash at materialization time for conflict detection
|
|
240
|
+
mount.materializedHashes.set(relativePath, blobHash);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
mount.lastMaterializedSeq = currentSeq;
|
|
244
|
+
|
|
245
|
+
return written;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Walk a directory recursively, respecting ignore patterns.
|
|
250
|
+
*/
|
|
251
|
+
async function walkDirectory(
|
|
252
|
+
basePath: string,
|
|
253
|
+
ignorePatterns: string[],
|
|
254
|
+
): Promise<string[]> {
|
|
255
|
+
const results: string[] = [];
|
|
256
|
+
const maxFiles = 5000; // Safety limit
|
|
257
|
+
|
|
258
|
+
async function walk(dir: string) {
|
|
259
|
+
if (results.length >= maxFiles) return;
|
|
260
|
+
|
|
261
|
+
let entries;
|
|
262
|
+
try {
|
|
263
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
264
|
+
} catch {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
if (results.length >= maxFiles) break;
|
|
270
|
+
|
|
271
|
+
const fullPath = join(dir, entry.name);
|
|
272
|
+
const relativePath = relative(basePath, fullPath);
|
|
273
|
+
|
|
274
|
+
// Check ignore patterns (simple glob matching)
|
|
275
|
+
if (shouldIgnore(relativePath, entry.name, ignorePatterns)) continue;
|
|
276
|
+
|
|
277
|
+
if (entry.isFile()) {
|
|
278
|
+
results.push(relativePath);
|
|
279
|
+
} else if (entry.isDirectory()) {
|
|
280
|
+
await walk(fullPath);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await walk(basePath);
|
|
286
|
+
return results;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Simple ignore pattern matching.
|
|
291
|
+
*/
|
|
292
|
+
function shouldIgnore(
|
|
293
|
+
relativePath: string,
|
|
294
|
+
name: string,
|
|
295
|
+
patterns: string[],
|
|
296
|
+
): boolean {
|
|
297
|
+
for (const pattern of patterns) {
|
|
298
|
+
// Exact name match (e.g., ".git", "node_modules")
|
|
299
|
+
if (pattern === name) return true;
|
|
300
|
+
|
|
301
|
+
// Simple ** glob: "node_modules/**" matches anything under node_modules
|
|
302
|
+
if (pattern.endsWith('/**')) {
|
|
303
|
+
const prefix = pattern.slice(0, -3);
|
|
304
|
+
if (relativePath.startsWith(prefix + '/') || relativePath === prefix) return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Extension glob: "*.pyc" matches any .pyc file
|
|
308
|
+
if (pattern.startsWith('*.')) {
|
|
309
|
+
const ext = pattern.slice(1);
|
|
310
|
+
if (name.endsWith(ext)) return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the Workspace module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Mount Configuration
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configuration for a single filesystem mount.
|
|
11
|
+
*/
|
|
12
|
+
export interface MountConfig {
|
|
13
|
+
/** Mount name — becomes the path prefix (e.g., "project", "config") */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Absolute filesystem path to mount */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Access mode */
|
|
18
|
+
mode: 'read-write' | 'read-only';
|
|
19
|
+
/**
|
|
20
|
+
* Watch mode for filesystem changes:
|
|
21
|
+
* - 'always': chokidar watches continuously, syncs on debounce
|
|
22
|
+
* - 'on-agent-action': sync from filesystem after each agent tool call
|
|
23
|
+
* - 'never': fully virtual, no automatic filesystem reads
|
|
24
|
+
*/
|
|
25
|
+
watch?: 'always' | 'on-agent-action' | 'never';
|
|
26
|
+
/** Debounce window in ms for watch: 'always' mode (default: 300) */
|
|
27
|
+
watchDebounceMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Simple ignore patterns (NOT full gitignore syntax). Supported forms:
|
|
30
|
+
* - Exact name match: ".git", "node_modules"
|
|
31
|
+
* - Directory glob: "node_modules/**" (anything under node_modules)
|
|
32
|
+
* - Extension glob: "*.pyc" (any file ending in .pyc)
|
|
33
|
+
*/
|
|
34
|
+
ignore?: string[];
|
|
35
|
+
/** Whether to follow symlinks (default: false) */
|
|
36
|
+
followSymlinks?: boolean;
|
|
37
|
+
/** Maximum file size in bytes (default: 5MB) */
|
|
38
|
+
maxFileSize?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Configuration for the WorkspaceModule.
|
|
43
|
+
*/
|
|
44
|
+
export interface WorkspaceConfig {
|
|
45
|
+
/** Mount configurations */
|
|
46
|
+
mounts: MountConfig[];
|
|
47
|
+
/** Only materialize the active branch to filesystem (default: true) */
|
|
48
|
+
materializeOnlyActiveBranch?: boolean;
|
|
49
|
+
/** Delta snapshot frequency for tree states (default: 50) */
|
|
50
|
+
deltaSnapshotEvery?: number;
|
|
51
|
+
/** Full snapshot frequency for tree states (default: 10) */
|
|
52
|
+
fullSnapshotEvery?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Internal State
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-mount runtime state (not persisted — rebuilt on start).
|
|
61
|
+
*/
|
|
62
|
+
export interface MountState {
|
|
63
|
+
/** The mount config */
|
|
64
|
+
config: MountConfig;
|
|
65
|
+
/** Tree state ID in Chronicle */
|
|
66
|
+
treeStateId: string;
|
|
67
|
+
/** Sequence number of last materialization */
|
|
68
|
+
lastMaterializedSeq: number;
|
|
69
|
+
/** Paths currently suppressed from watcher (recently materialized) */
|
|
70
|
+
suppressedPaths: Set<string>;
|
|
71
|
+
/** Whether initial lazy sync has been completed */
|
|
72
|
+
initialSyncDone: boolean;
|
|
73
|
+
/** Branch ID that was active when this mount last materialized */
|
|
74
|
+
lastMaterializedBranchId: string | null;
|
|
75
|
+
/** Per-file hash at time of last materialization — baseline for conflict detection */
|
|
76
|
+
materializedHashes: Map<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Persisted module state (via Chronicle snapshot).
|
|
81
|
+
*/
|
|
82
|
+
export interface WorkspaceModuleState {
|
|
83
|
+
/** Per-mount metadata */
|
|
84
|
+
mounts: Record<string, {
|
|
85
|
+
lastMaterializedSeq: number;
|
|
86
|
+
lastMaterializedBranchId?: string;
|
|
87
|
+
}>;
|
|
88
|
+
/** Branch ID considered "active" for materialization */
|
|
89
|
+
activeBranchId?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Tool Inputs
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
export interface ReadInput {
|
|
97
|
+
/** File path (mount-prefixed, e.g., "project/src/main.ts") */
|
|
98
|
+
path: string;
|
|
99
|
+
/** Starting line (1-indexed, optional) */
|
|
100
|
+
offset?: number;
|
|
101
|
+
/** Number of lines to read (optional) */
|
|
102
|
+
limit?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface WriteInput {
|
|
106
|
+
/** File path (mount-prefixed) */
|
|
107
|
+
path: string;
|
|
108
|
+
/** Content to write */
|
|
109
|
+
content: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface EditInput {
|
|
113
|
+
/** File path (mount-prefixed) */
|
|
114
|
+
path: string;
|
|
115
|
+
/** String to find */
|
|
116
|
+
oldString: string;
|
|
117
|
+
/** String to replace with */
|
|
118
|
+
newString: string;
|
|
119
|
+
/** Replace all occurrences (default: false) */
|
|
120
|
+
replaceAll?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface DeleteInput {
|
|
124
|
+
/** File path (mount-prefixed) */
|
|
125
|
+
path: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface LsInput {
|
|
129
|
+
/** Directory path (mount-prefixed, optional — defaults to workspace root) */
|
|
130
|
+
path?: string;
|
|
131
|
+
/** List recursively (default: false) */
|
|
132
|
+
recursive?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface GlobInput {
|
|
136
|
+
/** Glob pattern */
|
|
137
|
+
pattern: string;
|
|
138
|
+
/** Directory to search in (mount-prefixed, optional) */
|
|
139
|
+
path?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface GrepInput {
|
|
143
|
+
/** Regex pattern */
|
|
144
|
+
pattern: string;
|
|
145
|
+
/** File or directory to search in (mount-prefixed, optional) */
|
|
146
|
+
path?: string;
|
|
147
|
+
/** Glob pattern to filter files */
|
|
148
|
+
glob?: string;
|
|
149
|
+
/** Context lines before match */
|
|
150
|
+
contextBefore?: number;
|
|
151
|
+
/** Context lines after match */
|
|
152
|
+
contextAfter?: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface StatusInput {
|
|
156
|
+
/** Specific mount to check (optional — defaults to all) */
|
|
157
|
+
mount?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface MaterializeInput {
|
|
161
|
+
/** Specific file or directory path to materialize (optional — defaults to all) */
|
|
162
|
+
path?: string;
|
|
163
|
+
/** Specific mount (optional — defaults to all read-write mounts) */
|
|
164
|
+
mount?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SyncInput {
|
|
168
|
+
/** Specific file or directory path to sync (optional — defaults to all) */
|
|
169
|
+
path?: string;
|
|
170
|
+
/** Specific mount (optional — defaults to all) */
|
|
171
|
+
mount?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// Events
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
export interface WorkspaceChangedEvent {
|
|
179
|
+
type: 'workspace:changed';
|
|
180
|
+
paths: string[];
|
|
181
|
+
mount: string;
|
|
182
|
+
conflicts?: string[];
|
|
183
|
+
[key: string]: unknown;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface WorkspaceMountedEvent {
|
|
187
|
+
type: 'workspace:mounted';
|
|
188
|
+
mount: string;
|
|
189
|
+
path: string;
|
|
190
|
+
[key: string]: unknown;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface WorkspaceUnmountedEvent {
|
|
194
|
+
type: 'workspace:unmounted';
|
|
195
|
+
mount: string;
|
|
196
|
+
[key: string]: unknown;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type WorkspaceEvent =
|
|
200
|
+
| WorkspaceChangedEvent
|
|
201
|
+
| WorkspaceMountedEvent
|
|
202
|
+
| WorkspaceUnmountedEvent;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem watcher with debounce, ignore patterns, and suppression.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { watch, type FSWatcher } from 'chokidar';
|
|
6
|
+
import { type MountConfig } from './types.js';
|
|
7
|
+
|
|
8
|
+
export interface WatcherEvents {
|
|
9
|
+
onChange(paths: string[]): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Manages filesystem watching for a single mount.
|
|
14
|
+
* Handles debouncing, ignore patterns, and write suppression.
|
|
15
|
+
*/
|
|
16
|
+
export class MountWatcher {
|
|
17
|
+
private watcher: FSWatcher | null = null;
|
|
18
|
+
private pendingChanges = new Set<string>();
|
|
19
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
private suppressedPaths = new Set<string>();
|
|
21
|
+
private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
22
|
+
private readonly debounceMs: number;
|
|
23
|
+
private readonly config: MountConfig;
|
|
24
|
+
private readonly onChangeCallback: (paths: string[]) => void;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
config: MountConfig,
|
|
28
|
+
onChange: (paths: string[]) => void,
|
|
29
|
+
) {
|
|
30
|
+
this.config = config;
|
|
31
|
+
this.debounceMs = config.watchDebounceMs ?? 300;
|
|
32
|
+
this.onChangeCallback = onChange;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start watching the filesystem.
|
|
37
|
+
*/
|
|
38
|
+
start(): void {
|
|
39
|
+
if (this.watcher) return;
|
|
40
|
+
|
|
41
|
+
const ignored = this.config.ignore ?? [];
|
|
42
|
+
|
|
43
|
+
this.watcher = watch(this.config.path, {
|
|
44
|
+
ignored: ignored.length > 0 ? ignored : undefined,
|
|
45
|
+
persistent: true,
|
|
46
|
+
ignoreInitial: true,
|
|
47
|
+
followSymlinks: this.config.followSymlinks ?? false,
|
|
48
|
+
awaitWriteFinish: {
|
|
49
|
+
stabilityThreshold: 100,
|
|
50
|
+
pollInterval: 50,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const handleEvent = (filePath: string) => {
|
|
55
|
+
// Convert absolute path to mount-relative
|
|
56
|
+
const relative = this.toRelative(filePath);
|
|
57
|
+
if (!relative) return;
|
|
58
|
+
|
|
59
|
+
// Skip if this path is suppressed (we just wrote it)
|
|
60
|
+
if (this.suppressedPaths.has(relative)) return;
|
|
61
|
+
|
|
62
|
+
this.pendingChanges.add(relative);
|
|
63
|
+
this.scheduleFire();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
this.watcher.on('add', handleEvent);
|
|
67
|
+
this.watcher.on('change', handleEvent);
|
|
68
|
+
this.watcher.on('unlink', handleEvent);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop watching.
|
|
73
|
+
*/
|
|
74
|
+
async stop(): Promise<void> {
|
|
75
|
+
if (this.debounceTimer) {
|
|
76
|
+
clearTimeout(this.debounceTimer);
|
|
77
|
+
this.debounceTimer = null;
|
|
78
|
+
}
|
|
79
|
+
for (const timer of this.suppressionTimers.values()) {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
this.suppressionTimers.clear();
|
|
83
|
+
this.suppressedPaths.clear();
|
|
84
|
+
this.pendingChanges.clear();
|
|
85
|
+
|
|
86
|
+
if (this.watcher) {
|
|
87
|
+
await this.watcher.close();
|
|
88
|
+
this.watcher = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Suppress watcher events for a path temporarily.
|
|
94
|
+
* Used after materializing to avoid echo events.
|
|
95
|
+
* After the cooldown, the path is re-checked via the recheckCallback.
|
|
96
|
+
*/
|
|
97
|
+
suppress(relativePath: string, cooldownMs = 500): void {
|
|
98
|
+
this.suppressedPaths.add(relativePath);
|
|
99
|
+
|
|
100
|
+
// Clear existing timer for this path
|
|
101
|
+
const existing = this.suppressionTimers.get(relativePath);
|
|
102
|
+
if (existing) clearTimeout(existing);
|
|
103
|
+
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
this.suppressedPaths.delete(relativePath);
|
|
106
|
+
this.suppressionTimers.delete(relativePath);
|
|
107
|
+
}, cooldownMs);
|
|
108
|
+
|
|
109
|
+
this.suppressionTimers.set(relativePath, timer);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if a path is currently suppressed.
|
|
114
|
+
*/
|
|
115
|
+
isSuppressed(relativePath: string): boolean {
|
|
116
|
+
return this.suppressedPaths.has(relativePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private scheduleFire(): void {
|
|
120
|
+
if (this.debounceTimer) {
|
|
121
|
+
clearTimeout(this.debounceTimer);
|
|
122
|
+
}
|
|
123
|
+
this.debounceTimer = setTimeout(() => {
|
|
124
|
+
this.debounceTimer = null;
|
|
125
|
+
if (this.pendingChanges.size > 0) {
|
|
126
|
+
const paths = [...this.pendingChanges];
|
|
127
|
+
this.pendingChanges.clear();
|
|
128
|
+
this.onChangeCallback(paths);
|
|
129
|
+
}
|
|
130
|
+
}, this.debounceMs);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private toRelative(absolutePath: string): string | null {
|
|
134
|
+
const base = this.config.path.endsWith('/')
|
|
135
|
+
? this.config.path
|
|
136
|
+
: this.config.path + '/';
|
|
137
|
+
if (absolutePath.startsWith(base)) {
|
|
138
|
+
return absolutePath.slice(base.length);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|