clawmatrix 0.1.23 → 0.2.1
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/README.md +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2183 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +288 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +171 -92
- package/src/identity.ts +95 -0
- package/src/index.ts +433 -58
- package/src/knowledge-sync.ts +776 -207
- package/src/model-proxy.ts +144 -39
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +261 -32
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +477 -3
- package/src/web.ts +2 -2
package/src/knowledge-sync.ts
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
import * as Automerge from "@automerge/automerge";
|
|
2
2
|
import { watch, type FSWatcher } from "node:fs";
|
|
3
|
-
import { readdir, readFile, stat as fsStat, writeFile, mkdir,
|
|
3
|
+
import { readdir, readFile, stat as fsStat, writeFile, mkdir, rename } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import ignore, { type Ignore } from "ignore";
|
|
6
|
+
import picomatch from "picomatch";
|
|
6
7
|
|
|
7
8
|
import { spawnProcess } from "./compat.ts";
|
|
8
9
|
import { debug } from "./debug.ts";
|
|
9
10
|
import type { PeerManager } from "./peer-manager.ts";
|
|
10
11
|
import type { KnowledgeSyncFrame } from "./types.ts";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// ── Document schemas ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Registry CRDT: tracks which files exist in the sync set. */
|
|
16
|
+
interface RegistryDoc {
|
|
17
|
+
files: Record<string, {
|
|
18
|
+
deleted: boolean;
|
|
19
|
+
version: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Per-file CRDT: just the content. */
|
|
25
|
+
interface FileDoc {
|
|
26
|
+
content: string;
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
// ── Options ───────────────────────────────────────────────────────
|
|
30
|
+
|
|
17
31
|
export interface KnowledgeSyncOptions {
|
|
18
32
|
/** Workspace directory to watch and sync. */
|
|
19
33
|
workspacePath: string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
34
|
+
/** Directory to persist state (.clawmatrix/). */
|
|
35
|
+
stateDir: string;
|
|
22
36
|
/** Local node ID. */
|
|
23
37
|
nodeId: string;
|
|
38
|
+
/** Glob whitelist patterns. Empty = sync nothing. */
|
|
39
|
+
paths: string[];
|
|
24
40
|
/** Debounce interval in ms for fs changes. */
|
|
25
41
|
debounce: number;
|
|
26
42
|
/** Max file size in bytes. Files larger than this are skipped. */
|
|
@@ -30,6 +46,46 @@ export interface KnowledgeSyncOptions {
|
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
const TAG = "knowledge";
|
|
49
|
+
const REGISTRY_DOC_ID = "";
|
|
50
|
+
const SYNC_CONFIG_FILE = ".clawmatrix.sync";
|
|
51
|
+
/** TTL for writtenByExport entries (ms). Stale entries are cleaned up to prevent leaks. */
|
|
52
|
+
const EXPORT_MARKER_TTL = 30_000;
|
|
53
|
+
/** Max concurrent file I/O operations during walkDir / export. */
|
|
54
|
+
const MAX_IO_CONCURRENCY = 32;
|
|
55
|
+
/** Delay before batched git commit (ms). */
|
|
56
|
+
const GIT_COMMIT_DELAY = 1000;
|
|
57
|
+
|
|
58
|
+
/** Parse .clawmatrix.sync content into glob patterns (one per line, # comments, blank lines ignored). */
|
|
59
|
+
function parseSyncConfig(content: string): string[] {
|
|
60
|
+
return content
|
|
61
|
+
.split("\n")
|
|
62
|
+
.map((line) => line.trim())
|
|
63
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Convert relPath to safe filename using percent-encoding for path separators and existing percent signs. */
|
|
67
|
+
function docFileName(relPath: string): string {
|
|
68
|
+
return relPath.replaceAll("%", "%25").replaceAll("/", "%2F") + ".automerge";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Legacy doc filename format (v0.1.x): replaced / with -- */
|
|
72
|
+
function legacyDocFileName(relPath: string): string {
|
|
73
|
+
return relPath.replaceAll("/", "--") + ".automerge";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run async tasks with bounded concurrency. */
|
|
77
|
+
async function pMap<T, R>(items: T[], fn: (item: T) => Promise<R>, concurrency: number): Promise<R[]> {
|
|
78
|
+
const results: R[] = new Array(items.length);
|
|
79
|
+
let i = 0;
|
|
80
|
+
async function worker() {
|
|
81
|
+
while (i < items.length) {
|
|
82
|
+
const idx = i++;
|
|
83
|
+
results[idx] = await fn(items[idx]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
33
89
|
|
|
34
90
|
async function streamToString(stream: ReadableStream | null): Promise<string> {
|
|
35
91
|
if (!stream) return "";
|
|
@@ -43,59 +99,139 @@ async function streamToString(stream: ReadableStream | null): Promise<string> {
|
|
|
43
99
|
return Buffer.concat(chunks).toString("utf-8");
|
|
44
100
|
}
|
|
45
101
|
|
|
102
|
+
/** Update FileDoc content using Automerge.updateText for character-level CRDT merging. */
|
|
103
|
+
function changeFileContent(doc: Automerge.Doc<FileDoc>, content: string): Automerge.Doc<FileDoc> {
|
|
104
|
+
const currentContent = doc.content;
|
|
105
|
+
// If content field doesn't exist yet (new doc), initialize it first
|
|
106
|
+
if (currentContent === undefined) {
|
|
107
|
+
return Automerge.change(doc, (d) => {
|
|
108
|
+
(d as FileDoc).content = content;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// Use updateText for minimal diff — enables proper concurrent merge
|
|
112
|
+
if (currentContent === content) return doc;
|
|
113
|
+
return Automerge.change(doc, (d) => {
|
|
114
|
+
Automerge.updateText(d, ["content"], content);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
46
118
|
export class KnowledgeSync {
|
|
47
|
-
|
|
48
|
-
private
|
|
119
|
+
// ── State ──────────────────────────────────────────────────────
|
|
120
|
+
private registry: Automerge.Doc<RegistryDoc>;
|
|
121
|
+
private fileDocs = new Map<string, Automerge.Doc<FileDoc>>();
|
|
122
|
+
private syncStates = new Map<string, Automerge.SyncState>(); // "peerId:docId"
|
|
123
|
+
private pendingChanges = new Set<string>();
|
|
124
|
+
private activePaths: string[] = []; // resolved from .clawmatrix.sync or config fallback
|
|
125
|
+
private matcher: (path: string) => boolean;
|
|
126
|
+
private ig: Ignore = ignore();
|
|
127
|
+
|
|
128
|
+
// ── FS / Watcher ───────────────────────────────────────────────
|
|
49
129
|
private watcher: FSWatcher | null = null;
|
|
50
130
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
-
/** Paths recently written by
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
private
|
|
131
|
+
/** Paths recently written by exportFileToFs — suppress watcher re-trigger. Stores {content, timestamp}. */
|
|
132
|
+
private writtenByExport = new Map<string, { content: string; ts: number }>();
|
|
133
|
+
/** Deferred git commit timer — batches multiple remote syncs into one commit. */
|
|
134
|
+
private gitCommitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
135
|
+
private pendingGitSources = new Set<string>();
|
|
136
|
+
|
|
137
|
+
// ── Paths ──────────────────────────────────────────────────────
|
|
138
|
+
private registryPath: string;
|
|
139
|
+
private docsDir: string;
|
|
140
|
+
|
|
55
141
|
private opts: KnowledgeSyncOptions;
|
|
56
|
-
private ig: Ignore = ignore();
|
|
57
142
|
|
|
58
143
|
constructor(opts: KnowledgeSyncOptions) {
|
|
59
144
|
this.opts = opts;
|
|
60
|
-
this.
|
|
145
|
+
this.registry = Automerge.init<RegistryDoc>();
|
|
146
|
+
this.registryPath = path.join(opts.stateDir, "registry.automerge");
|
|
147
|
+
this.docsDir = path.join(opts.stateDir, "docs");
|
|
148
|
+
// Initialize matcher from config paths as default (loadSyncConfig may override)
|
|
149
|
+
this.activePaths = opts.paths;
|
|
150
|
+
this.matcher = opts.paths.length > 0 ? picomatch(opts.paths) : () => false;
|
|
61
151
|
}
|
|
62
152
|
|
|
153
|
+
// ── Public API ─────────────────────────────────────────────────
|
|
154
|
+
|
|
63
155
|
async start() {
|
|
64
156
|
debug(TAG, `starting knowledge sync: workspace=${this.opts.workspacePath}`);
|
|
65
157
|
|
|
66
|
-
// Ensure workspace directory exists
|
|
67
158
|
await mkdir(this.opts.workspacePath, { recursive: true });
|
|
159
|
+
await mkdir(this.docsDir, { recursive: true });
|
|
68
160
|
|
|
69
|
-
// Load .gitignore rules
|
|
70
161
|
await this.loadGitignore();
|
|
71
162
|
|
|
72
|
-
// Load
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
debug(TAG, `
|
|
86
|
-
|
|
163
|
+
// Load .clawmatrix.sync (bootstrap config file) or fall back to config.paths
|
|
164
|
+
await this.loadSyncConfig();
|
|
165
|
+
|
|
166
|
+
// Migrate from legacy single-doc format if needed
|
|
167
|
+
await this.migrateFromLegacy();
|
|
168
|
+
|
|
169
|
+
// Load registry
|
|
170
|
+
const loadedRegistry = await this.loadAutomergeDoc<RegistryDoc>(this.registryPath);
|
|
171
|
+
if (loadedRegistry) {
|
|
172
|
+
this.registry = loadedRegistry;
|
|
173
|
+
const entries = Object.keys(this.registry.files ?? {}).filter(
|
|
174
|
+
(k) => !this.registry.files[k].deleted,
|
|
175
|
+
);
|
|
176
|
+
debug(TAG, `loaded registry: ${entries.length} active files`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Load all per-file docs referenced in registry (with fallback to legacy filename format)
|
|
180
|
+
const registryFiles = this.registry.files ?? {};
|
|
181
|
+
for (const [relPath, meta] of Object.entries(registryFiles)) {
|
|
182
|
+
if (meta.deleted) continue;
|
|
183
|
+
const newName = docFileName(relPath);
|
|
184
|
+
const docPath = path.join(this.docsDir, newName);
|
|
185
|
+
let doc = await this.loadAutomergeDoc<FileDoc>(docPath);
|
|
186
|
+
// Fallback: try legacy "--" filename format and migrate if found
|
|
187
|
+
if (!doc) {
|
|
188
|
+
const legacyName = legacyDocFileName(relPath);
|
|
189
|
+
if (legacyName !== newName) {
|
|
190
|
+
const legacyPath = path.join(this.docsDir, legacyName);
|
|
191
|
+
doc = await this.loadAutomergeDoc<FileDoc>(legacyPath);
|
|
192
|
+
if (doc) {
|
|
193
|
+
await this.saveAutomergeDoc(docPath, doc);
|
|
194
|
+
await rename(legacyPath, legacyPath + ".migrated").catch(() => {});
|
|
195
|
+
debug(TAG, `migrated doc filename: ${legacyName} → ${newName}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (doc) {
|
|
200
|
+
this.fileDocs.set(relPath, doc);
|
|
201
|
+
}
|
|
87
202
|
}
|
|
88
203
|
|
|
89
|
-
//
|
|
204
|
+
// Bootstrap: ensure .clawmatrix.sync itself is a synced CRDT doc
|
|
205
|
+
await this.ensureSyncConfigDoc();
|
|
206
|
+
|
|
207
|
+
// Import current workspace files that match whitelist
|
|
208
|
+
await this.importFromFs();
|
|
209
|
+
|
|
210
|
+
// Export any doc state to filesystem
|
|
211
|
+
await this.exportAllToFs();
|
|
212
|
+
|
|
213
|
+
// Initialize git repo
|
|
90
214
|
await this.gitInit();
|
|
91
215
|
|
|
92
216
|
// Start watching for file changes
|
|
93
217
|
this.watcher = watch(this.opts.workspacePath, { recursive: true }, (_event, filename) => {
|
|
94
218
|
if (!filename) return;
|
|
95
|
-
//
|
|
219
|
+
// .clawmatrix.sync is special — always watch it
|
|
220
|
+
if (filename === SYNC_CONFIG_FILE) {
|
|
221
|
+
this.pendingChanges.add(filename);
|
|
222
|
+
this.scheduleSync();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// .gitignore change triggers reload on next handleLocalChanges
|
|
226
|
+
if (filename === ".gitignore") {
|
|
227
|
+
this.pendingChanges.add(filename);
|
|
228
|
+
this.scheduleSync();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
96
231
|
if (filename.startsWith(".")) return;
|
|
97
|
-
// Ignore gitignored files
|
|
98
232
|
if (this.ig.ignores(filename)) return;
|
|
233
|
+
if (!this.matchesWhitelist(filename)) return;
|
|
234
|
+
this.pendingChanges.add(filename);
|
|
99
235
|
this.scheduleSync();
|
|
100
236
|
});
|
|
101
237
|
|
|
@@ -110,75 +246,223 @@ export class KnowledgeSync {
|
|
|
110
246
|
}
|
|
111
247
|
this.watcher?.close();
|
|
112
248
|
this.watcher = null;
|
|
113
|
-
await this.
|
|
114
|
-
|
|
249
|
+
await this.flushPendingGitCommit();
|
|
250
|
+
await this.saveAll();
|
|
251
|
+
debug(TAG, "knowledge sync stopped");
|
|
115
252
|
}
|
|
116
253
|
|
|
117
254
|
/** Handle incoming knowledge_sync frame from a peer. */
|
|
118
255
|
async handleSyncMessage(frame: KnowledgeSyncFrame) {
|
|
119
256
|
const peerId = frame.from;
|
|
120
|
-
const
|
|
121
|
-
debug(TAG, `received sync message from ${peerId} (${msgSize} bytes)`);
|
|
122
|
-
|
|
123
|
-
const syncState = this.syncStates.get(peerId) ?? Automerge.initSyncState();
|
|
124
|
-
const message = new Uint8Array(Buffer.from(frame.payload.data, "base64"));
|
|
257
|
+
const { docId, data } = frame.payload;
|
|
125
258
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const oldFileCount = Object.keys(this.doc.files ?? {}).length;
|
|
133
|
-
const newFileCount = Object.keys(newDoc.files ?? {}).length;
|
|
134
|
-
this.doc = newDoc;
|
|
135
|
-
this.syncStates.set(peerId, newSyncState);
|
|
136
|
-
|
|
137
|
-
if (oldFileCount !== newFileCount) {
|
|
138
|
-
debug(TAG, `doc updated: ${oldFileCount} → ${newFileCount} files`);
|
|
259
|
+
// Backward compat: ignore frames without docId (from old nodes)
|
|
260
|
+
if (docId === undefined || docId === null) {
|
|
261
|
+
debug(TAG, `ignoring legacy sync frame from ${peerId} (no docId)`);
|
|
262
|
+
return;
|
|
139
263
|
}
|
|
140
264
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await this.saveDoc();
|
|
144
|
-
await this.gitCommit(`sync from ${peerId}`);
|
|
265
|
+
const message = new Uint8Array(Buffer.from(data, "base64"));
|
|
266
|
+
const syncKey = `${peerId}:${docId}`;
|
|
145
267
|
|
|
146
|
-
|
|
147
|
-
|
|
268
|
+
try {
|
|
269
|
+
if (docId === REGISTRY_DOC_ID) {
|
|
270
|
+
await this.handleRegistrySync(peerId, syncKey, message);
|
|
271
|
+
} else {
|
|
272
|
+
await this.handleFileSync(peerId, docId, syncKey, message);
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
debug(TAG, `error handling sync message from ${peerId} docId=${docId || "registry"}: ${err}`);
|
|
276
|
+
}
|
|
148
277
|
}
|
|
149
278
|
|
|
150
279
|
/** Called when a new peer connects — initiate sync. */
|
|
151
280
|
initPeerSync(peerId: string) {
|
|
281
|
+
if (peerId === this.opts.nodeId) return; // skip self
|
|
152
282
|
debug(TAG, `initiating sync with peer ${peerId}`);
|
|
153
|
-
|
|
154
|
-
|
|
283
|
+
// Start with registry sync — file syncs follow after registry exchange
|
|
284
|
+
const syncKey = `${peerId}:${REGISTRY_DOC_ID}`;
|
|
285
|
+
this.syncStates.set(syncKey, Automerge.initSyncState());
|
|
286
|
+
this.sendSyncMessage(peerId, REGISTRY_DOC_ID);
|
|
287
|
+
|
|
288
|
+
// Also initiate sync for all known file docs
|
|
289
|
+
for (const relPath of this.fileDocs.keys()) {
|
|
290
|
+
this.syncDocWithPeer(peerId, relPath);
|
|
291
|
+
}
|
|
155
292
|
}
|
|
156
293
|
|
|
157
294
|
/** Called when a peer disconnects — clean up sync state. */
|
|
158
295
|
removePeerSync(peerId: string) {
|
|
159
296
|
debug(TAG, `removing sync state for peer ${peerId}`);
|
|
160
|
-
|
|
297
|
+
const prefix = `${peerId}:`;
|
|
298
|
+
for (const key of this.syncStates.keys()) {
|
|
299
|
+
if (key.startsWith(prefix)) {
|
|
300
|
+
this.syncStates.delete(key);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
161
303
|
}
|
|
162
304
|
|
|
163
|
-
// ── Private
|
|
305
|
+
// ── Private: frame handlers ─────────────────────────────────────
|
|
164
306
|
|
|
165
|
-
private async
|
|
166
|
-
this.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
307
|
+
private async handleRegistrySync(peerId: string, syncKey: string, message: Uint8Array) {
|
|
308
|
+
const syncState = this.syncStates.get(syncKey) ?? Automerge.initSyncState();
|
|
309
|
+
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
|
|
310
|
+
this.registry,
|
|
311
|
+
syncState,
|
|
312
|
+
message,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const oldFiles = Object.keys(this.registry.files ?? {});
|
|
316
|
+
this.registry = newDoc;
|
|
317
|
+
this.syncStates.set(syncKey, newSyncState);
|
|
318
|
+
const newFiles = Object.keys(newDoc.files ?? {});
|
|
319
|
+
|
|
320
|
+
if (oldFiles.length !== newFiles.length) {
|
|
321
|
+
debug(TAG, `registry updated from ${peerId}: ${oldFiles.length} → ${newFiles.length} entries`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await this.saveAutomergeDoc(this.registryPath, this.registry);
|
|
325
|
+
|
|
326
|
+
// Discover new files from registry and initiate their sync
|
|
327
|
+
for (const [relPath, meta] of Object.entries(newDoc.files ?? {})) {
|
|
328
|
+
if (meta.deleted) {
|
|
329
|
+
// Clean up sync states for deleted files
|
|
330
|
+
this.cleanupDeletedFileSyncStates(relPath);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (!this.fileDocs.has(relPath)) {
|
|
334
|
+
this.fileDocs.set(relPath, Automerge.init<FileDoc>());
|
|
335
|
+
debug(TAG, `discovered new file from registry: ${relPath}`);
|
|
336
|
+
}
|
|
337
|
+
this.syncDocWithPeer(peerId, relPath);
|
|
175
338
|
}
|
|
339
|
+
|
|
340
|
+
this.sendSyncMessage(peerId, REGISTRY_DOC_ID);
|
|
176
341
|
}
|
|
177
342
|
|
|
178
|
-
private
|
|
179
|
-
|
|
343
|
+
private async handleFileSync(peerId: string, docId: string, syncKey: string, message: Uint8Array) {
|
|
344
|
+
let doc = this.fileDocs.get(docId) ?? Automerge.init<FileDoc>();
|
|
345
|
+
const syncState = this.syncStates.get(syncKey) ?? Automerge.initSyncState();
|
|
346
|
+
|
|
347
|
+
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
|
|
348
|
+
doc,
|
|
349
|
+
syncState,
|
|
350
|
+
message,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
this.fileDocs.set(docId, newDoc);
|
|
354
|
+
this.syncStates.set(syncKey, newSyncState);
|
|
355
|
+
|
|
356
|
+
await this.saveFileDoc(docId);
|
|
357
|
+
await this.exportFileToFs(docId);
|
|
358
|
+
|
|
359
|
+
// Reload sync config only when content is non-empty (partial syncs may yield empty content)
|
|
360
|
+
if (docId === SYNC_CONFIG_FILE && newDoc.content) {
|
|
361
|
+
await this.loadSyncConfig();
|
|
362
|
+
debug(TAG, `sync config updated from ${peerId}, reloaded patterns: ${JSON.stringify(this.activePaths)}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Defer git commit — schedule instead of immediate
|
|
366
|
+
this.schedulePendingGitCommit(peerId);
|
|
367
|
+
|
|
368
|
+
this.sendSyncMessage(peerId, docId);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Remove sync states for a deleted file across all peers. */
|
|
372
|
+
private cleanupDeletedFileSyncStates(docId: string) {
|
|
373
|
+
const suffix = `:${docId}`;
|
|
374
|
+
for (const key of this.syncStates.keys()) {
|
|
375
|
+
if (key.endsWith(suffix)) {
|
|
376
|
+
this.syncStates.delete(key);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Whitelist matching ─────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
matchesWhitelist(relPath: string): boolean {
|
|
384
|
+
if (relPath === SYNC_CONFIG_FILE) return true; // always synced
|
|
385
|
+
if (this.activePaths.length === 0) return false;
|
|
386
|
+
return this.matcher(relPath);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Private: sync helpers ──────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
private syncDocWithPeer(peerId: string, docId: string) {
|
|
392
|
+
const syncKey = `${peerId}:${docId}`;
|
|
393
|
+
if (!this.syncStates.has(syncKey)) {
|
|
394
|
+
this.syncStates.set(syncKey, Automerge.initSyncState());
|
|
395
|
+
}
|
|
396
|
+
this.sendSyncMessage(peerId, docId);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private sendSyncMessage(peerId: string, docId: string) {
|
|
400
|
+
const syncKey = `${peerId}:${docId}`;
|
|
401
|
+
const syncState = this.syncStates.get(syncKey) ?? Automerge.initSyncState();
|
|
402
|
+
const doc = docId === REGISTRY_DOC_ID ? this.registry : this.fileDocs.get(docId);
|
|
403
|
+
if (!doc) return;
|
|
404
|
+
|
|
405
|
+
const [newSyncState, message] = Automerge.generateSyncMessage(doc, syncState);
|
|
406
|
+
this.syncStates.set(syncKey, newSyncState);
|
|
407
|
+
|
|
408
|
+
if (!message) return;
|
|
409
|
+
|
|
410
|
+
debug(TAG, `sending sync for ${docId || "registry"} to ${peerId} (${message.byteLength} bytes)`);
|
|
411
|
+
|
|
412
|
+
const frame: KnowledgeSyncFrame = {
|
|
413
|
+
type: "knowledge_sync",
|
|
414
|
+
from: this.opts.nodeId,
|
|
415
|
+
to: peerId,
|
|
416
|
+
timestamp: Date.now(),
|
|
417
|
+
payload: {
|
|
418
|
+
docId,
|
|
419
|
+
data: Buffer.from(message).toString("base64"),
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
this.opts.peerManager.router.sendTo(peerId, frame);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private broadcastSync(docId: string) {
|
|
427
|
+
const peers = this.opts.peerManager.router.getAllPeers();
|
|
428
|
+
for (const peer of peers) {
|
|
429
|
+
this.syncDocWithPeer(peer.nodeId, docId);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Private: deferred git commit ────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/** Batch multiple remote syncs into a single git commit after a short delay. */
|
|
436
|
+
private schedulePendingGitCommit(source: string) {
|
|
437
|
+
this.pendingGitSources.add(source);
|
|
438
|
+
if (this.gitCommitTimer) return; // already scheduled
|
|
439
|
+
this.gitCommitTimer = setTimeout(() => {
|
|
440
|
+
this.gitCommitTimer = null;
|
|
441
|
+
const sources = [...this.pendingGitSources];
|
|
442
|
+
this.pendingGitSources.clear();
|
|
443
|
+
if (sources.length > 0) {
|
|
444
|
+
const msg = `sync from ${sources.join(", ")}`;
|
|
445
|
+
this.gitCommit(msg).catch((err) => {
|
|
446
|
+
debug(TAG, `deferred git commit error: ${err}`);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}, GIT_COMMIT_DELAY);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async flushPendingGitCommit() {
|
|
453
|
+
if (this.gitCommitTimer) {
|
|
454
|
+
clearTimeout(this.gitCommitTimer);
|
|
455
|
+
this.gitCommitTimer = null;
|
|
456
|
+
}
|
|
457
|
+
if (this.pendingGitSources.size > 0) {
|
|
458
|
+
const sources = [...this.pendingGitSources];
|
|
459
|
+
this.pendingGitSources.clear();
|
|
460
|
+
await this.gitCommit(`sync from ${sources.join(", ")}`);
|
|
461
|
+
}
|
|
180
462
|
}
|
|
181
463
|
|
|
464
|
+
// ── Private: local change handling ─────────────────────────────
|
|
465
|
+
|
|
182
466
|
private scheduleSync() {
|
|
183
467
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
184
468
|
this.debounceTimer = setTimeout(() => {
|
|
@@ -190,41 +474,75 @@ export class KnowledgeSync {
|
|
|
190
474
|
}
|
|
191
475
|
|
|
192
476
|
private async handleLocalChanges() {
|
|
193
|
-
//
|
|
194
|
-
|
|
477
|
+
// Only process files in pendingChanges (incremental)
|
|
478
|
+
const changesToProcess = new Set(this.pendingChanges);
|
|
479
|
+
this.pendingChanges.clear();
|
|
195
480
|
|
|
196
|
-
|
|
197
|
-
const docFiles = this.doc.files ?? {};
|
|
481
|
+
if (changesToProcess.size === 0) return;
|
|
198
482
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
483
|
+
// Reload .gitignore only when it actually changed
|
|
484
|
+
if (changesToProcess.has(".gitignore")) {
|
|
485
|
+
await this.loadGitignore();
|
|
486
|
+
changesToProcess.delete(".gitignore");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// If .clawmatrix.sync changed, reload patterns before processing other files
|
|
490
|
+
if (changesToProcess.has(SYNC_CONFIG_FILE)) {
|
|
491
|
+
await this.loadSyncConfig();
|
|
205
492
|
}
|
|
206
493
|
|
|
207
|
-
//
|
|
494
|
+
// Clean up stale export markers
|
|
495
|
+
this.cleanupStaleExportMarkers();
|
|
496
|
+
|
|
208
497
|
const added: string[] = [];
|
|
209
498
|
const modified: string[] = [];
|
|
210
499
|
const deleted: string[] = [];
|
|
500
|
+
// Cache file contents from detection phase to avoid re-reading
|
|
501
|
+
const contentCache = new Map<string, string>();
|
|
211
502
|
|
|
212
|
-
for (const
|
|
213
|
-
|
|
214
|
-
if (this.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
503
|
+
for (const relPath of changesToProcess) {
|
|
504
|
+
if (!this.matchesWhitelist(relPath)) continue;
|
|
505
|
+
if (relPath !== SYNC_CONFIG_FILE && this.isIgnored(relPath)) continue;
|
|
506
|
+
|
|
507
|
+
// Clear export marker if content matches
|
|
508
|
+
const exportMarker = this.writtenByExport.get(relPath);
|
|
509
|
+
|
|
510
|
+
const absPath = path.join(this.opts.workspacePath, relPath);
|
|
511
|
+
let currentContent: string | null = null;
|
|
512
|
+
try {
|
|
513
|
+
const st = await fsStat(absPath);
|
|
514
|
+
if (st.size > this.opts.maxFileSize) continue;
|
|
515
|
+
currentContent = await readFile(absPath, "utf-8");
|
|
516
|
+
} catch {
|
|
517
|
+
// File doesn't exist — deletion
|
|
219
518
|
}
|
|
220
|
-
}
|
|
221
519
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
520
|
+
if (exportMarker !== undefined) {
|
|
521
|
+
if (currentContent === exportMarker.content) {
|
|
522
|
+
this.writtenByExport.delete(relPath);
|
|
523
|
+
continue; // Our own write, skip
|
|
524
|
+
}
|
|
525
|
+
this.writtenByExport.delete(relPath);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const existingDoc = this.fileDocs.get(relPath);
|
|
529
|
+
const registryEntry = this.registry.files?.[relPath];
|
|
530
|
+
|
|
531
|
+
if (currentContent === null) {
|
|
532
|
+
// File deleted
|
|
533
|
+
if (existingDoc || (registryEntry && !registryEntry.deleted)) {
|
|
534
|
+
deleted.push(relPath);
|
|
535
|
+
}
|
|
536
|
+
} else if (!existingDoc || !registryEntry || registryEntry.deleted) {
|
|
537
|
+
added.push(relPath);
|
|
538
|
+
contentCache.set(relPath, currentContent);
|
|
539
|
+
} else {
|
|
540
|
+
// Check if content changed
|
|
541
|
+
const docContent = existingDoc.content ?? "";
|
|
542
|
+
if (docContent !== currentContent) {
|
|
543
|
+
modified.push(relPath);
|
|
544
|
+
contentCache.set(relPath, currentContent);
|
|
545
|
+
}
|
|
228
546
|
}
|
|
229
547
|
}
|
|
230
548
|
|
|
@@ -232,68 +550,275 @@ export class KnowledgeSync {
|
|
|
232
550
|
|
|
233
551
|
debug(
|
|
234
552
|
TAG,
|
|
235
|
-
`local changes
|
|
553
|
+
`local changes: +${added.length} ~${modified.length} -${deleted.length}` +
|
|
236
554
|
(added.length > 0 ? ` added=[${added.join(",")}]` : "") +
|
|
237
555
|
(modified.length > 0 ? ` modified=[${modified.join(",")}]` : "") +
|
|
238
556
|
(deleted.length > 0 ? ` deleted=[${deleted.join(",")}]` : ""),
|
|
239
557
|
);
|
|
240
558
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
559
|
+
// Process additions and modifications — reuse cached content
|
|
560
|
+
for (const relPath of [...added, ...modified]) {
|
|
561
|
+
const content = contentCache.get(relPath)!;
|
|
562
|
+
|
|
563
|
+
let doc = this.fileDocs.get(relPath) ?? Automerge.init<FileDoc>();
|
|
564
|
+
doc = changeFileContent(doc, content);
|
|
565
|
+
this.fileDocs.set(relPath, doc);
|
|
566
|
+
|
|
567
|
+
await this.saveFileDoc(relPath);
|
|
568
|
+
this.broadcastSync(relPath);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Process deletions (tombstone in registry)
|
|
572
|
+
for (const relPath of deleted) {
|
|
573
|
+
this.fileDocs.delete(relPath);
|
|
574
|
+
this.cleanupDeletedFileSyncStates(relPath);
|
|
575
|
+
// Remove persisted file doc
|
|
576
|
+
try {
|
|
577
|
+
const docPath = path.join(this.docsDir, docFileName(relPath));
|
|
578
|
+
await rename(docPath, docPath + ".deleted");
|
|
579
|
+
} catch { /* ignore */ }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Update registry
|
|
583
|
+
this.registry = Automerge.change(this.registry, (d) => {
|
|
584
|
+
if (!d.files) (d as RegistryDoc).files = {};
|
|
585
|
+
for (const relPath of added) {
|
|
586
|
+
d.files[relPath] = {
|
|
587
|
+
deleted: false,
|
|
588
|
+
version: 1,
|
|
589
|
+
updatedAt: Date.now(),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
for (const relPath of modified) {
|
|
593
|
+
const existing = d.files[relPath];
|
|
594
|
+
if (existing) {
|
|
595
|
+
existing.version = (existing.version ?? 0) + 1;
|
|
596
|
+
existing.updatedAt = Date.now();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for (const relPath of deleted) {
|
|
600
|
+
if (d.files[relPath]) {
|
|
601
|
+
d.files[relPath].deleted = true;
|
|
602
|
+
d.files[relPath].updatedAt = Date.now();
|
|
603
|
+
}
|
|
245
604
|
}
|
|
246
|
-
for (const p of added) d.files[p] = currentFiles[p];
|
|
247
|
-
for (const p of modified) d.files[p] = currentFiles[p];
|
|
248
|
-
for (const p of deleted) delete d.files[p];
|
|
249
605
|
});
|
|
250
606
|
|
|
251
|
-
await this.
|
|
607
|
+
await this.saveAutomergeDoc(this.registryPath, this.registry);
|
|
252
608
|
await this.gitCommit("local changes");
|
|
253
609
|
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
debug(TAG, `broadcasting changes to ${peerCount} peer(s)`);
|
|
257
|
-
this.broadcastSync();
|
|
610
|
+
// Broadcast registry + changed files
|
|
611
|
+
this.broadcastSync(REGISTRY_DOC_ID);
|
|
258
612
|
}
|
|
259
613
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
614
|
+
// ── Private: sync config (.clawmatrix.sync) ─────────────────────
|
|
615
|
+
|
|
616
|
+
/** Load .clawmatrix.sync from filesystem or CRDT doc, fall back to config.paths. */
|
|
617
|
+
private async loadSyncConfig() {
|
|
618
|
+
const syncConfigPath = path.join(this.opts.workspacePath, SYNC_CONFIG_FILE);
|
|
619
|
+
let content: string | null = null;
|
|
620
|
+
|
|
621
|
+
// Try filesystem first
|
|
622
|
+
try {
|
|
623
|
+
content = await readFile(syncConfigPath, "utf-8");
|
|
624
|
+
} catch { /* doesn't exist */ }
|
|
625
|
+
|
|
626
|
+
// If not on filesystem, try the CRDT doc (e.g. just synced from remote, not yet exported)
|
|
627
|
+
if (content === null) {
|
|
628
|
+
const doc = this.fileDocs.get(SYNC_CONFIG_FILE);
|
|
629
|
+
if (doc?.content) {
|
|
630
|
+
content = doc.content;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (content !== null) {
|
|
635
|
+
const patterns = parseSyncConfig(content);
|
|
636
|
+
this.activePaths = patterns;
|
|
637
|
+
this.matcher = patterns.length > 0 ? picomatch(patterns) : () => false;
|
|
638
|
+
debug(TAG, `loaded sync config: ${patterns.length} patterns from ${SYNC_CONFIG_FILE}`);
|
|
639
|
+
} else {
|
|
640
|
+
// Fall back to config.paths
|
|
641
|
+
this.activePaths = this.opts.paths;
|
|
642
|
+
this.matcher = this.opts.paths.length > 0 ? picomatch(this.opts.paths) : () => false;
|
|
643
|
+
if (this.opts.paths.length > 0) {
|
|
644
|
+
debug(TAG, `no ${SYNC_CONFIG_FILE}, using config fallback: ${JSON.stringify(this.opts.paths)}`);
|
|
265
645
|
}
|
|
266
|
-
this.sendSyncMessage(peer.nodeId);
|
|
267
646
|
}
|
|
268
647
|
}
|
|
269
648
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
649
|
+
/** Ensure .clawmatrix.sync exists as a CRDT doc if it exists on filesystem. */
|
|
650
|
+
private async ensureSyncConfigDoc() {
|
|
651
|
+
const syncConfigPath = path.join(this.opts.workspacePath, SYNC_CONFIG_FILE);
|
|
652
|
+
let content: string | null = null;
|
|
653
|
+
try {
|
|
654
|
+
content = await readFile(syncConfigPath, "utf-8");
|
|
655
|
+
} catch { /* doesn't exist */ }
|
|
273
656
|
|
|
274
|
-
|
|
657
|
+
if (content === null) return;
|
|
275
658
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
659
|
+
const existingDoc = this.fileDocs.get(SYNC_CONFIG_FILE);
|
|
660
|
+
if (existingDoc && existingDoc.content === content) return;
|
|
661
|
+
|
|
662
|
+
// Create or update the CRDT doc
|
|
663
|
+
let doc = existingDoc ?? Automerge.init<FileDoc>();
|
|
664
|
+
doc = changeFileContent(doc, content);
|
|
665
|
+
this.fileDocs.set(SYNC_CONFIG_FILE, doc);
|
|
666
|
+
|
|
667
|
+
// Add to registry
|
|
668
|
+
this.registry = Automerge.change(this.registry, (d) => {
|
|
669
|
+
if (!d.files) (d as RegistryDoc).files = {};
|
|
670
|
+
const existing = d.files[SYNC_CONFIG_FILE];
|
|
671
|
+
if (!existing || existing.deleted) {
|
|
672
|
+
d.files[SYNC_CONFIG_FILE] = { deleted: false, version: 1, updatedAt: Date.now() };
|
|
673
|
+
} else {
|
|
674
|
+
existing.version = (existing.version ?? 0) + 1;
|
|
675
|
+
existing.updatedAt = Date.now();
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
await this.saveFileDoc(SYNC_CONFIG_FILE);
|
|
680
|
+
await this.saveAutomergeDoc(this.registryPath, this.registry);
|
|
681
|
+
debug(TAG, `ensured ${SYNC_CONFIG_FILE} as CRDT doc`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── Private: filesystem I/O ────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
private async loadGitignore() {
|
|
687
|
+
this.ig = ignore();
|
|
688
|
+
this.ig.add([".git", ".clawmatrix"]);
|
|
689
|
+
try {
|
|
690
|
+
const content = await readFile(path.join(this.opts.workspacePath, ".gitignore"), "utf-8");
|
|
691
|
+
this.ig.add(content);
|
|
692
|
+
} catch { /* no .gitignore */ }
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private isIgnored(relPath: string): boolean {
|
|
696
|
+
return this.ig.ignores(relPath);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Clean up stale export markers that were never consumed by the watcher. */
|
|
700
|
+
private cleanupStaleExportMarkers() {
|
|
701
|
+
const now = Date.now();
|
|
702
|
+
for (const [relPath, marker] of this.writtenByExport) {
|
|
703
|
+
if (now - marker.ts > EXPORT_MARKER_TTL) {
|
|
704
|
+
this.writtenByExport.delete(relPath);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Import workspace files matching whitelist into file docs. */
|
|
710
|
+
private async importFromFs() {
|
|
711
|
+
const files = await this.readWhitelistedFiles();
|
|
712
|
+
|
|
713
|
+
let imported = 0;
|
|
714
|
+
const changedPaths: string[] = [];
|
|
715
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
716
|
+
const existingDoc = this.fileDocs.get(relPath);
|
|
717
|
+
const existingContent = existingDoc?.content ?? "";
|
|
718
|
+
|
|
719
|
+
if (!existingDoc) {
|
|
720
|
+
// New file — create doc
|
|
721
|
+
let doc = Automerge.init<FileDoc>();
|
|
722
|
+
doc = changeFileContent(doc, content);
|
|
723
|
+
this.fileDocs.set(relPath, doc);
|
|
724
|
+
|
|
725
|
+
// Add to registry
|
|
726
|
+
this.registry = Automerge.change(this.registry, (d) => {
|
|
727
|
+
if (!d.files) (d as RegistryDoc).files = {};
|
|
728
|
+
if (!d.files[relPath] || d.files[relPath].deleted) {
|
|
729
|
+
d.files[relPath] = {
|
|
730
|
+
deleted: false,
|
|
731
|
+
version: 1,
|
|
732
|
+
updatedAt: Date.now(),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
changedPaths.push(relPath);
|
|
737
|
+
imported++;
|
|
738
|
+
} else if (existingContent !== content) {
|
|
739
|
+
// Content changed — update doc
|
|
740
|
+
const newDoc = changeFileContent(existingDoc, content);
|
|
741
|
+
this.fileDocs.set(relPath, newDoc);
|
|
742
|
+
|
|
743
|
+
this.registry = Automerge.change(this.registry, (d) => {
|
|
744
|
+
if (!d.files) (d as RegistryDoc).files = {};
|
|
745
|
+
const entry = d.files[relPath];
|
|
746
|
+
if (entry) {
|
|
747
|
+
entry.version = (entry.version ?? 0) + 1;
|
|
748
|
+
entry.updatedAt = Date.now();
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
changedPaths.push(relPath);
|
|
752
|
+
imported++;
|
|
753
|
+
}
|
|
279
754
|
}
|
|
280
755
|
|
|
281
|
-
|
|
756
|
+
if (imported > 0) {
|
|
757
|
+
debug(TAG, `imported ${imported} files from workspace`);
|
|
758
|
+
// Only save changed docs + registry (not all docs)
|
|
759
|
+
await this.saveAutomergeDoc(this.registryPath, this.registry);
|
|
760
|
+
await Promise.all(changedPaths.map((relPath) => this.saveFileDoc(relPath)));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
282
763
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
},
|
|
291
|
-
};
|
|
764
|
+
/** Export all file docs to filesystem. */
|
|
765
|
+
private async exportAllToFs() {
|
|
766
|
+
const writeOps: Array<{ relPath: string; content: string; absPath: string }> = [];
|
|
767
|
+
for (const [relPath, doc] of this.fileDocs) {
|
|
768
|
+
const meta = this.registry.files?.[relPath];
|
|
769
|
+
if (meta?.deleted) continue;
|
|
770
|
+
if (this.isIgnored(relPath)) continue;
|
|
292
771
|
|
|
293
|
-
|
|
772
|
+
const content = doc.content ?? "";
|
|
773
|
+
const absPath = path.join(this.opts.workspacePath, relPath);
|
|
774
|
+
|
|
775
|
+
let currentContent: string | null = null;
|
|
776
|
+
try {
|
|
777
|
+
currentContent = await readFile(absPath, "utf-8");
|
|
778
|
+
} catch { /* file doesn't exist */ }
|
|
779
|
+
|
|
780
|
+
if (currentContent !== content) {
|
|
781
|
+
writeOps.push({ relPath, content, absPath });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (writeOps.length > 0) {
|
|
786
|
+
await pMap(writeOps, async ({ relPath, content, absPath }) => {
|
|
787
|
+
this.writtenByExport.set(relPath, { content, ts: Date.now() });
|
|
788
|
+
await mkdir(path.dirname(absPath), { recursive: true });
|
|
789
|
+
await writeFile(absPath, content, "utf-8");
|
|
790
|
+
}, MAX_IO_CONCURRENCY);
|
|
791
|
+
debug(TAG, `exported ${writeOps.length} files to filesystem`);
|
|
792
|
+
}
|
|
294
793
|
}
|
|
295
794
|
|
|
296
|
-
|
|
795
|
+
/** Export a single file doc to filesystem. */
|
|
796
|
+
private async exportFileToFs(relPath: string) {
|
|
797
|
+
const doc = this.fileDocs.get(relPath);
|
|
798
|
+
if (!doc) return;
|
|
799
|
+
|
|
800
|
+
const meta = this.registry.files?.[relPath];
|
|
801
|
+
if (meta?.deleted) return;
|
|
802
|
+
if (this.isIgnored(relPath)) return;
|
|
803
|
+
|
|
804
|
+
const content = doc.content ?? "";
|
|
805
|
+
const absPath = path.join(this.opts.workspacePath, relPath);
|
|
806
|
+
|
|
807
|
+
let currentContent: string | null = null;
|
|
808
|
+
try {
|
|
809
|
+
currentContent = await readFile(absPath, "utf-8");
|
|
810
|
+
} catch { /* file doesn't exist */ }
|
|
811
|
+
|
|
812
|
+
if (currentContent !== content) {
|
|
813
|
+
this.writtenByExport.set(relPath, { content, ts: Date.now() });
|
|
814
|
+
await mkdir(path.dirname(absPath), { recursive: true });
|
|
815
|
+
await writeFile(absPath, content, "utf-8");
|
|
816
|
+
debug(TAG, `exported file: ${relPath}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Read all workspace files matching whitelist. */
|
|
821
|
+
private async readWhitelistedFiles(): Promise<Record<string, string>> {
|
|
297
822
|
const files: Record<string, string> = {};
|
|
298
823
|
await this.walkDir(this.opts.workspacePath, "", files);
|
|
299
824
|
return files;
|
|
@@ -301,58 +826,138 @@ export class KnowledgeSync {
|
|
|
301
826
|
|
|
302
827
|
private async walkDir(base: string, rel: string, result: Record<string, string>) {
|
|
303
828
|
const entries = await readdir(path.join(base, rel), { withFileTypes: true });
|
|
829
|
+
|
|
830
|
+
// Separate files and directories for parallel processing
|
|
831
|
+
const fileEntries: Array<{ relPath: string; filePath: string }> = [];
|
|
832
|
+
const dirEntries: string[] = [];
|
|
833
|
+
|
|
304
834
|
for (const entry of entries) {
|
|
305
835
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
306
|
-
|
|
307
|
-
|
|
836
|
+
if (entry.name.startsWith(".")) {
|
|
837
|
+
// Allow .clawmatrix.sync at workspace root
|
|
838
|
+
if (relPath === SYNC_CONFIG_FILE && entry.isFile()) {
|
|
839
|
+
fileEntries.push({ relPath, filePath: path.join(base, relPath) });
|
|
840
|
+
}
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
308
843
|
if (entry.isDirectory()) {
|
|
309
|
-
// For directories, check with trailing slash so negation rules like !memory/ work
|
|
310
844
|
if (this.isIgnored(relPath + "/")) continue;
|
|
311
|
-
|
|
845
|
+
dirEntries.push(relPath);
|
|
312
846
|
} else if (entry.isFile()) {
|
|
313
847
|
if (this.isIgnored(relPath)) continue;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (st.size > this.opts.maxFileSize) continue;
|
|
317
|
-
const content = await readFile(filePath, "utf-8");
|
|
318
|
-
result[relPath] = content;
|
|
848
|
+
if (!this.matchesWhitelist(relPath)) continue;
|
|
849
|
+
fileEntries.push({ relPath, filePath: path.join(base, relPath) });
|
|
319
850
|
}
|
|
320
851
|
}
|
|
852
|
+
|
|
853
|
+
// Read files with bounded concurrency
|
|
854
|
+
await pMap(fileEntries, async ({ relPath, filePath }) => {
|
|
855
|
+
try {
|
|
856
|
+
const st = await fsStat(filePath);
|
|
857
|
+
if (st.size > this.opts.maxFileSize) return;
|
|
858
|
+
result[relPath] = await readFile(filePath, "utf-8");
|
|
859
|
+
} catch { /* file disappeared between readdir and read */ }
|
|
860
|
+
}, MAX_IO_CONCURRENCY);
|
|
861
|
+
|
|
862
|
+
// Recurse into directories (each walkDir internally bounds its own I/O)
|
|
863
|
+
await Promise.all(dirEntries.map((dirRel) => this.walkDir(base, dirRel, result)));
|
|
321
864
|
}
|
|
322
865
|
|
|
323
|
-
|
|
324
|
-
const docFiles = this.doc.files ?? {};
|
|
325
|
-
const currentFiles = await this.readWorkspaceFiles();
|
|
866
|
+
// ── Private: persistence ───────────────────────────────────────
|
|
326
867
|
|
|
327
|
-
|
|
868
|
+
private async loadAutomergeDoc<T>(filePath: string): Promise<Automerge.Doc<T> | null> {
|
|
869
|
+
try {
|
|
870
|
+
const data = await readFile(filePath);
|
|
871
|
+
return Automerge.load<T>(new Uint8Array(data));
|
|
872
|
+
} catch {
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
328
876
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
877
|
+
private async saveAutomergeDoc<T>(filePath: string, doc: Automerge.Doc<T>) {
|
|
878
|
+
const data = Automerge.save(doc);
|
|
879
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
880
|
+
await writeFile(filePath, Buffer.from(data));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private async saveFileDoc(relPath: string) {
|
|
884
|
+
const doc = this.fileDocs.get(relPath);
|
|
885
|
+
if (!doc) return;
|
|
886
|
+
const docPath = path.join(this.docsDir, docFileName(relPath));
|
|
887
|
+
await this.saveAutomergeDoc(docPath, doc);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private async saveAll() {
|
|
891
|
+
await this.saveAutomergeDoc(this.registryPath, this.registry);
|
|
892
|
+
await Promise.all(
|
|
893
|
+
[...this.fileDocs].map(([relPath]) => this.saveFileDoc(relPath)),
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ── Private: migration from legacy format ──────────────────────
|
|
898
|
+
|
|
899
|
+
private async migrateFromLegacy() {
|
|
900
|
+
const legacyPath = path.join(this.opts.stateDir, "knowledge.automerge");
|
|
901
|
+
const registryExists = await fsStat(this.registryPath).then(() => true, () => false);
|
|
902
|
+
|
|
903
|
+
if (registryExists) return; // Already migrated or fresh install
|
|
904
|
+
|
|
905
|
+
let legacyDoc: Automerge.Doc<{ files: Record<string, string> }> | null = null;
|
|
906
|
+
try {
|
|
907
|
+
const data = await readFile(legacyPath);
|
|
908
|
+
legacyDoc = Automerge.load<{ files: Record<string, string> }>(new Uint8Array(data));
|
|
909
|
+
} catch {
|
|
910
|
+
return; // No legacy file
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const legacyFiles = legacyDoc.files ?? {};
|
|
914
|
+
const fileCount = Object.keys(legacyFiles).length;
|
|
915
|
+
if (fileCount === 0) return;
|
|
916
|
+
|
|
917
|
+
debug(TAG, `migrating ${fileCount} files from legacy single-doc format`);
|
|
918
|
+
|
|
919
|
+
// Create per-file docs + registry entries
|
|
920
|
+
for (const [relPath, content] of Object.entries(legacyFiles)) {
|
|
921
|
+
// Only migrate files matching current whitelist (or all if no whitelist)
|
|
922
|
+
if (this.activePaths.length > 0 && !this.matchesWhitelist(relPath)) {
|
|
923
|
+
debug(TAG, `migration: skipping ${relPath} (not in whitelist)`);
|
|
924
|
+
continue;
|
|
338
925
|
}
|
|
926
|
+
|
|
927
|
+
let doc = Automerge.init<FileDoc>();
|
|
928
|
+
doc = Automerge.change(doc, (d) => {
|
|
929
|
+
(d as FileDoc).content = content;
|
|
930
|
+
});
|
|
931
|
+
this.fileDocs.set(relPath, doc);
|
|
932
|
+
|
|
933
|
+
this.registry = Automerge.change(this.registry, (d) => {
|
|
934
|
+
if (!d.files) (d as RegistryDoc).files = {};
|
|
935
|
+
d.files[relPath] = {
|
|
936
|
+
deleted: false,
|
|
937
|
+
version: 1,
|
|
938
|
+
updatedAt: Date.now(),
|
|
939
|
+
};
|
|
940
|
+
});
|
|
339
941
|
}
|
|
340
942
|
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
// lost if we deleted it here. Deletions propagate through the doc via
|
|
344
|
-
// handleLocalChanges() → Automerge change → broadcastSync() instead.
|
|
943
|
+
// Save all new docs
|
|
944
|
+
await this.saveAll();
|
|
345
945
|
|
|
346
|
-
|
|
347
|
-
|
|
946
|
+
// Rename legacy file
|
|
947
|
+
try {
|
|
948
|
+
await rename(legacyPath, legacyPath + ".migrated");
|
|
949
|
+
debug(TAG, "legacy file renamed to knowledge.automerge.migrated");
|
|
950
|
+
} catch {
|
|
951
|
+
debug(TAG, "warning: could not rename legacy file");
|
|
348
952
|
}
|
|
953
|
+
|
|
954
|
+
debug(TAG, `migration complete: ${this.fileDocs.size} files migrated`);
|
|
349
955
|
}
|
|
350
956
|
|
|
351
|
-
// ── Git operations
|
|
957
|
+
// ── Git operations ─────────────────────────────────────────────
|
|
352
958
|
|
|
353
959
|
private async gitInit() {
|
|
354
960
|
try {
|
|
355
|
-
// Check if already a git repo
|
|
356
961
|
const proc = spawnProcess(["git", "rev-parse", "--git-dir"], {
|
|
357
962
|
cwd: this.opts.workspacePath,
|
|
358
963
|
stdout: "pipe",
|
|
@@ -360,7 +965,6 @@ export class KnowledgeSync {
|
|
|
360
965
|
});
|
|
361
966
|
const code = await proc.exited;
|
|
362
967
|
if (code !== 0) {
|
|
363
|
-
// Not a git repo — initialize
|
|
364
968
|
const init = spawnProcess(["git", "init"], {
|
|
365
969
|
cwd: this.opts.workspacePath,
|
|
366
970
|
stdout: "pipe",
|
|
@@ -368,11 +972,9 @@ export class KnowledgeSync {
|
|
|
368
972
|
});
|
|
369
973
|
await init.exited;
|
|
370
974
|
debug(TAG, `initialized git repo at ${this.opts.workspacePath}`);
|
|
371
|
-
} else {
|
|
372
|
-
debug(TAG, "git repo already initialized");
|
|
373
975
|
}
|
|
374
976
|
|
|
375
|
-
// Ensure
|
|
977
|
+
// Ensure git user config
|
|
376
978
|
const nameCheck = spawnProcess(["git", "config", "user.name"], {
|
|
377
979
|
cwd: this.opts.workspacePath,
|
|
378
980
|
stdout: "pipe",
|
|
@@ -406,7 +1008,6 @@ export class KnowledgeSync {
|
|
|
406
1008
|
|
|
407
1009
|
private async gitCommit(message: string) {
|
|
408
1010
|
try {
|
|
409
|
-
// git add -A (respects .gitignore)
|
|
410
1011
|
const add = spawnProcess(["git", "add", "-A"], {
|
|
411
1012
|
cwd: this.opts.workspacePath,
|
|
412
1013
|
stdout: "pipe",
|
|
@@ -414,16 +1015,14 @@ export class KnowledgeSync {
|
|
|
414
1015
|
});
|
|
415
1016
|
await add.exited;
|
|
416
1017
|
|
|
417
|
-
// Check if there are staged changes
|
|
418
1018
|
const diff = spawnProcess(["git", "diff", "--cached", "--quiet"], {
|
|
419
1019
|
cwd: this.opts.workspacePath,
|
|
420
1020
|
stdout: "pipe",
|
|
421
1021
|
stderr: "pipe",
|
|
422
1022
|
});
|
|
423
1023
|
const diffCode = await diff.exited;
|
|
424
|
-
if (diffCode === 0) return;
|
|
1024
|
+
if (diffCode === 0) return;
|
|
425
1025
|
|
|
426
|
-
// Commit
|
|
427
1026
|
const commit = spawnProcess(
|
|
428
1027
|
["git", "commit", "-m", `[clawmatrix] ${message}`],
|
|
429
1028
|
{
|
|
@@ -443,34 +1042,4 @@ export class KnowledgeSync {
|
|
|
443
1042
|
debug(TAG, `git commit failed: ${err}`);
|
|
444
1043
|
}
|
|
445
1044
|
}
|
|
446
|
-
|
|
447
|
-
// ── Persistence ─────────────────────────────────────────────────
|
|
448
|
-
|
|
449
|
-
private async loadDoc(): Promise<Automerge.Doc<KnowledgeDoc> | null> {
|
|
450
|
-
try {
|
|
451
|
-
const data = await readFile(this.opts.storePath);
|
|
452
|
-
debug(TAG, `loaded automerge doc from ${this.opts.storePath} (${data.byteLength} bytes)`);
|
|
453
|
-
return Automerge.load<KnowledgeDoc>(new Uint8Array(data));
|
|
454
|
-
} catch {
|
|
455
|
-
return null;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
private async saveDoc() {
|
|
460
|
-
const data = Automerge.save(this.doc);
|
|
461
|
-
await mkdir(path.dirname(this.opts.storePath), { recursive: true });
|
|
462
|
-
await writeFile(this.opts.storePath, Buffer.from(data));
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
private async importFromFs() {
|
|
466
|
-
const files = await this.readWorkspaceFiles();
|
|
467
|
-
if (Object.keys(files).length === 0) return;
|
|
468
|
-
|
|
469
|
-
this.doc = Automerge.change(this.doc, (d) => {
|
|
470
|
-
(d as KnowledgeDoc).files = {};
|
|
471
|
-
for (const [relPath, content] of Object.entries(files)) {
|
|
472
|
-
d.files[relPath] = content;
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
1045
|
}
|