clawmatrix 0.1.22 → 0.2.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.
@@ -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, unlink } from "node:fs/promises";
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
- /** Automerge document schema: map of relative paths to file contents. */
13
- interface KnowledgeDoc {
14
- files: Record<string, string>;
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
- /** Path to persist the Automerge document (binary). */
21
- storePath: string;
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
- private doc: Automerge.Doc<KnowledgeDoc>;
48
- private syncStates = new Map<string, Automerge.SyncState>();
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 currently being written by exportToFssuppressed from fs watcher. */
52
- private writingPaths = new Set<string>();
131
+ /** Paths recently written by exportFileToFssuppress 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
+
53
141
  private opts: KnowledgeSyncOptions;
54
- private ig: Ignore = ignore();
55
142
 
56
143
  constructor(opts: KnowledgeSyncOptions) {
57
144
  this.opts = opts;
58
- this.doc = Automerge.init<KnowledgeDoc>();
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;
59
151
  }
60
152
 
153
+ // ── Public API ─────────────────────────────────────────────────
154
+
61
155
  async start() {
62
156
  debug(TAG, `starting knowledge sync: workspace=${this.opts.workspacePath}`);
63
157
 
64
- // Ensure workspace directory exists
65
158
  await mkdir(this.opts.workspacePath, { recursive: true });
159
+ await mkdir(this.docsDir, { recursive: true });
66
160
 
67
- // Load .gitignore rules
68
161
  await this.loadGitignore();
69
162
 
70
- // Load persisted doc or initialize from existing files
71
- const loaded = await this.loadDoc();
72
- if (loaded) {
73
- this.doc = loaded;
74
- const fileCount = Object.keys(this.doc.files ?? {}).length;
75
- debug(TAG, `loaded persisted doc: ${fileCount} files`);
76
- // Export doc state to filesystem (in case doc has newer data)
77
- await this.exportToFs();
78
- } else {
79
- // First run: import existing files into doc
80
- debug(TAG, "no persisted doc found, importing from filesystem");
81
- await this.importFromFs();
82
- const fileCount = Object.keys(this.doc.files ?? {}).length;
83
- debug(TAG, `imported ${fileCount} files from workspace`);
84
- await this.saveDoc();
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
+ }
85
202
  }
86
203
 
87
- // Initialize git repo if not already
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
88
214
  await this.gitInit();
89
215
 
90
216
  // Start watching for file changes
91
217
  this.watcher = watch(this.opts.workspacePath, { recursive: true }, (_event, filename) => {
92
218
  if (!filename) return;
93
- // Ignore files currently being written by export
94
- if (this.writingPaths.has(filename)) return;
95
- // Ignore hidden files
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.saveDoc();
114
- debug(TAG, "knowledge sync stopped, doc saved");
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 msgSize = frame.payload.data.length;
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
- const [newDoc, newSyncState, _patch] = Automerge.receiveSyncMessage(
127
- this.doc,
128
- syncState,
129
- message,
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
- // Export changes to filesystem and git commit
142
- await this.exportToFs();
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
- // Continue sync protocol (may need to send response)
147
- this.sendSyncMessage(peerId);
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
- this.syncStates.set(peerId, Automerge.initSyncState());
154
- this.sendSyncMessage(peerId);
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
- this.syncStates.delete(peerId);
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 methods ─────────────────────────────────────────────
305
+ // ── Private: frame handlers ─────────────────────────────────────
164
306
 
165
- private async loadGitignore() {
166
- this.ig = ignore();
167
- // Always ignore .git and automerge store
168
- this.ig.add([".git", ".clawmatrix"]);
169
- try {
170
- const content = await readFile(path.join(this.opts.workspacePath, ".gitignore"), "utf-8");
171
- this.ig.add(content);
172
- debug(TAG, "loaded .gitignore rules");
173
- } catch {
174
- // No .gitignore that's fine
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 isIgnored(relPath: string): boolean {
179
- return this.ig.ignores(relPath);
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
+ }
180
431
  }
181
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
+ }
462
+ }
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,31 +474,75 @@ export class KnowledgeSync {
190
474
  }
191
475
 
192
476
  private async handleLocalChanges() {
193
- // Reload .gitignore in case it changed
194
- await this.loadGitignore();
477
+ // Only process files in pendingChanges (incremental)
478
+ const changesToProcess = new Set(this.pendingChanges);
479
+ this.pendingChanges.clear();
480
+
481
+ if (changesToProcess.size === 0) return;
482
+
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();
492
+ }
195
493
 
196
- const currentFiles = await this.readWorkspaceFiles();
197
- const docFiles = this.doc.files ?? {};
494
+ // Clean up stale export markers
495
+ this.cleanupStaleExportMarkers();
198
496
 
199
- // Collect changed files for logging
200
497
  const added: string[] = [];
201
498
  const modified: string[] = [];
202
499
  const deleted: string[] = [];
500
+ // Cache file contents from detection phase to avoid re-reading
501
+ const contentCache = new Map<string, string>();
203
502
 
204
- for (const [relPath, content] of Object.entries(currentFiles)) {
205
- if (!(relPath in docFiles)) {
206
- added.push(relPath);
207
- } else if (docFiles[relPath] !== content) {
208
- modified.push(relPath);
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
209
518
  }
210
- }
211
519
 
212
- for (const relPath of Object.keys(docFiles)) {
213
- // Only treat as deleted if the file is genuinely gone, not just skipped by size limit
214
- if (!(relPath in currentFiles)) {
215
- const absPath = path.join(this.opts.workspacePath, relPath);
216
- const exists = await fsStat(absPath).then(() => true, () => false);
217
- if (!exists) deleted.push(relPath);
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
+ }
218
546
  }
219
547
  }
220
548
 
@@ -222,68 +550,275 @@ export class KnowledgeSync {
222
550
 
223
551
  debug(
224
552
  TAG,
225
- `local changes detected: +${added.length} ~${modified.length} -${deleted.length}` +
553
+ `local changes: +${added.length} ~${modified.length} -${deleted.length}` +
226
554
  (added.length > 0 ? ` added=[${added.join(",")}]` : "") +
227
555
  (modified.length > 0 ? ` modified=[${modified.join(",")}]` : "") +
228
556
  (deleted.length > 0 ? ` deleted=[${deleted.join(",")}]` : ""),
229
557
  );
230
558
 
231
- // Update automerge doconly write dirty files to minimize change history
232
- this.doc = Automerge.change(this.doc, (d) => {
233
- if (!d.files) {
234
- (d as KnowledgeDoc).files = {};
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
+ }
235
604
  }
236
- for (const p of added) d.files[p] = currentFiles[p];
237
- for (const p of modified) d.files[p] = currentFiles[p];
238
- for (const p of deleted) delete d.files[p];
239
605
  });
240
606
 
241
- await this.saveDoc();
607
+ await this.saveAutomergeDoc(this.registryPath, this.registry);
242
608
  await this.gitCommit("local changes");
243
609
 
244
- // Send sync messages to all peers
245
- const peerCount = this.opts.peerManager.router.getAllPeers().length;
246
- debug(TAG, `broadcasting changes to ${peerCount} peer(s)`);
247
- this.broadcastSync();
610
+ // Broadcast registry + changed files
611
+ this.broadcastSync(REGISTRY_DOC_ID);
248
612
  }
249
613
 
250
- private broadcastSync() {
251
- const peers = this.opts.peerManager.router.getAllPeers();
252
- for (const peer of peers) {
253
- if (!this.syncStates.has(peer.nodeId)) {
254
- this.syncStates.set(peer.nodeId, Automerge.initSyncState());
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)}`);
255
645
  }
256
- this.sendSyncMessage(peer.nodeId);
257
646
  }
258
647
  }
259
648
 
260
- private sendSyncMessage(peerId: string) {
261
- const syncState = this.syncStates.get(peerId) ?? Automerge.initSyncState();
262
- const [newSyncState, message] = Automerge.generateSyncMessage(this.doc, syncState);
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 */ }
263
656
 
264
- this.syncStates.set(peerId, newSyncState);
657
+ if (content === null) return;
265
658
 
266
- if (!message) {
267
- debug(TAG, `no sync message to send to ${peerId} (already in sync)`);
268
- return;
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
+ }
269
706
  }
707
+ }
270
708
 
271
- debug(TAG, `sending sync message to ${peerId} (${message.byteLength} bytes)`);
709
+ /** Import workspace files matching whitelist into file docs. */
710
+ private async importFromFs() {
711
+ const files = await this.readWhitelistedFiles();
272
712
 
273
- const frame: KnowledgeSyncFrame = {
274
- type: "knowledge_sync",
275
- from: this.opts.nodeId,
276
- to: peerId,
277
- timestamp: Date.now(),
278
- payload: {
279
- data: Buffer.from(message).toString("base64"),
280
- },
281
- };
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 ?? "";
282
718
 
283
- this.opts.peerManager.router.sendTo(peerId, frame);
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
+ }
754
+ }
755
+
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
+ }
284
762
  }
285
763
 
286
- private async readWorkspaceFiles(): Promise<Record<string, string>> {
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;
771
+
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
+ }
793
+ }
794
+
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>> {
287
822
  const files: Record<string, string> = {};
288
823
  await this.walkDir(this.opts.workspacePath, "", files);
289
824
  return files;
@@ -291,65 +826,138 @@ export class KnowledgeSync {
291
826
 
292
827
  private async walkDir(base: string, rel: string, result: Record<string, string>) {
293
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
+
294
834
  for (const entry of entries) {
295
835
  const relPath = rel ? `${rel}/${entry.name}` : entry.name;
296
- // Skip hidden files/dirs
297
- if (entry.name.startsWith(".")) continue;
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
+ }
298
843
  if (entry.isDirectory()) {
299
- // For directories, check with trailing slash so negation rules like !memory/ work
300
844
  if (this.isIgnored(relPath + "/")) continue;
301
- await this.walkDir(base, relPath, result);
845
+ dirEntries.push(relPath);
302
846
  } else if (entry.isFile()) {
303
847
  if (this.isIgnored(relPath)) continue;
304
- const filePath = path.join(base, relPath);
305
- const st = await fsStat(filePath);
306
- if (st.size > this.opts.maxFileSize) continue;
307
- const content = await readFile(filePath, "utf-8");
308
- result[relPath] = content;
848
+ if (!this.matchesWhitelist(relPath)) continue;
849
+ fileEntries.push({ relPath, filePath: path.join(base, relPath) });
309
850
  }
310
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)));
311
864
  }
312
865
 
313
- private async exportToFs() {
314
- const docFiles = this.doc.files ?? {};
315
- const currentFiles = await this.readWorkspaceFiles();
866
+ // ── Private: persistence ───────────────────────────────────────
316
867
 
317
- let written = 0;
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
+ }
318
876
 
319
- for (const [relPath, content] of Object.entries(docFiles)) {
320
- // Don't export files that would be gitignored
321
- if (this.isIgnored(relPath)) continue;
322
- if (currentFiles[relPath] !== content) {
323
- this.writingPaths.add(relPath);
324
- const absPath = path.join(this.opts.workspacePath, relPath);
325
- await mkdir(path.dirname(absPath), { recursive: true });
326
- await writeFile(absPath, content, "utf-8");
327
- setTimeout(() => this.writingPaths.delete(relPath), 500);
328
- written++;
329
- }
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
330
911
  }
331
912
 
332
- let removed = 0;
333
- for (const relPath of Object.keys(currentFiles)) {
334
- if (!(relPath in docFiles)) {
335
- this.writingPaths.add(relPath);
336
- const absPath = path.join(this.opts.workspacePath, relPath);
337
- await unlink(absPath).catch(() => {});
338
- setTimeout(() => this.writingPaths.delete(relPath), 500);
339
- removed++;
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;
340
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
+ });
341
941
  }
342
942
 
343
- if (written > 0 || removed > 0) {
344
- debug(TAG, `exported to filesystem: ${written} written, ${removed} removed`);
943
+ // Save all new docs
944
+ await this.saveAll();
945
+
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");
345
952
  }
953
+
954
+ debug(TAG, `migration complete: ${this.fileDocs.size} files migrated`);
346
955
  }
347
956
 
348
- // ── Git operations ──────────────────────────────────────────────
957
+ // ── Git operations ─────────────────────────────────────────────
349
958
 
350
959
  private async gitInit() {
351
960
  try {
352
- // Check if already a git repo
353
961
  const proc = spawnProcess(["git", "rev-parse", "--git-dir"], {
354
962
  cwd: this.opts.workspacePath,
355
963
  stdout: "pipe",
@@ -357,7 +965,6 @@ export class KnowledgeSync {
357
965
  });
358
966
  const code = await proc.exited;
359
967
  if (code !== 0) {
360
- // Not a git repo — initialize
361
968
  const init = spawnProcess(["git", "init"], {
362
969
  cwd: this.opts.workspacePath,
363
970
  stdout: "pipe",
@@ -365,11 +972,9 @@ export class KnowledgeSync {
365
972
  });
366
973
  await init.exited;
367
974
  debug(TAG, `initialized git repo at ${this.opts.workspacePath}`);
368
- } else {
369
- debug(TAG, "git repo already initialized");
370
975
  }
371
976
 
372
- // Ensure local git user config exists (so commits don't fail on unconfigured machines)
977
+ // Ensure git user config
373
978
  const nameCheck = spawnProcess(["git", "config", "user.name"], {
374
979
  cwd: this.opts.workspacePath,
375
980
  stdout: "pipe",
@@ -403,7 +1008,6 @@ export class KnowledgeSync {
403
1008
 
404
1009
  private async gitCommit(message: string) {
405
1010
  try {
406
- // git add -A (respects .gitignore)
407
1011
  const add = spawnProcess(["git", "add", "-A"], {
408
1012
  cwd: this.opts.workspacePath,
409
1013
  stdout: "pipe",
@@ -411,16 +1015,14 @@ export class KnowledgeSync {
411
1015
  });
412
1016
  await add.exited;
413
1017
 
414
- // Check if there are staged changes
415
1018
  const diff = spawnProcess(["git", "diff", "--cached", "--quiet"], {
416
1019
  cwd: this.opts.workspacePath,
417
1020
  stdout: "pipe",
418
1021
  stderr: "pipe",
419
1022
  });
420
1023
  const diffCode = await diff.exited;
421
- if (diffCode === 0) return; // Nothing to commit
1024
+ if (diffCode === 0) return;
422
1025
 
423
- // Commit
424
1026
  const commit = spawnProcess(
425
1027
  ["git", "commit", "-m", `[clawmatrix] ${message}`],
426
1028
  {
@@ -440,34 +1042,4 @@ export class KnowledgeSync {
440
1042
  debug(TAG, `git commit failed: ${err}`);
441
1043
  }
442
1044
  }
443
-
444
- // ── Persistence ─────────────────────────────────────────────────
445
-
446
- private async loadDoc(): Promise<Automerge.Doc<KnowledgeDoc> | null> {
447
- try {
448
- const data = await readFile(this.opts.storePath);
449
- debug(TAG, `loaded automerge doc from ${this.opts.storePath} (${data.byteLength} bytes)`);
450
- return Automerge.load<KnowledgeDoc>(new Uint8Array(data));
451
- } catch {
452
- return null;
453
- }
454
- }
455
-
456
- private async saveDoc() {
457
- const data = Automerge.save(this.doc);
458
- await mkdir(path.dirname(this.opts.storePath), { recursive: true });
459
- await writeFile(this.opts.storePath, Buffer.from(data));
460
- }
461
-
462
- private async importFromFs() {
463
- const files = await this.readWorkspaceFiles();
464
- if (Object.keys(files).length === 0) return;
465
-
466
- this.doc = Automerge.change(this.doc, (d) => {
467
- (d as KnowledgeDoc).files = {};
468
- for (const [relPath, content] of Object.entries(files)) {
469
- d.files[relPath] = content;
470
- }
471
- });
472
- }
473
1045
  }