clawmatrix 0.1.23 → 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 recently written by exportToFs with their expected content used to
52
- * suppress watcher-triggered syncs for our own writes. Entries are cleared
53
- * once handleLocalChanges confirms the file content matches. */
54
- private writtenByExport = new Map<string, string>();
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.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;
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 persisted doc or initialize from existing files
73
- const loaded = await this.loadDoc();
74
- if (loaded) {
75
- this.doc = loaded;
76
- const fileCount = Object.keys(this.doc.files ?? {}).length;
77
- debug(TAG, `loaded persisted doc: ${fileCount} files`);
78
- // Export doc state to filesystem (in case doc has newer data)
79
- await this.exportToFs();
80
- } else {
81
- // First run: import existing files into doc
82
- debug(TAG, "no persisted doc found, importing from filesystem");
83
- await this.importFromFs();
84
- const fileCount = Object.keys(this.doc.files ?? {}).length;
85
- debug(TAG, `imported ${fileCount} files from workspace`);
86
- 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
+ }
87
202
  }
88
203
 
89
- // 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
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
- // 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
+ }
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
- // 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();
195
480
 
196
- const currentFiles = await this.readWorkspaceFiles();
197
- const docFiles = this.doc.files ?? {};
481
+ if (changesToProcess.size === 0) return;
198
482
 
199
- // Clear export markers whose content matches the current file (our write landed).
200
- // If the content differs, a real local edit happened after export — treat it as modified.
201
- for (const [relPath, expectedContent] of this.writtenByExport) {
202
- if (currentFiles[relPath] === expectedContent) {
203
- this.writtenByExport.delete(relPath);
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
- // Collect changed files for logging
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 [relPath, content] of Object.entries(currentFiles)) {
213
- // Skip files that were just written by exportToFs and haven't been edited since
214
- if (this.writtenByExport.has(relPath)) continue;
215
- if (!(relPath in docFiles)) {
216
- added.push(relPath);
217
- } else if (docFiles[relPath] !== content) {
218
- 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
219
518
  }
220
- }
221
519
 
222
- for (const relPath of Object.keys(docFiles)) {
223
- // Only treat as deleted if the file is genuinely gone, not just skipped by size limit
224
- if (!(relPath in currentFiles)) {
225
- const absPath = path.join(this.opts.workspacePath, relPath);
226
- const exists = await fsStat(absPath).then(() => true, () => false);
227
- 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
+ }
228
546
  }
229
547
  }
230
548
 
@@ -232,68 +550,275 @@ export class KnowledgeSync {
232
550
 
233
551
  debug(
234
552
  TAG,
235
- `local changes detected: +${added.length} ~${modified.length} -${deleted.length}` +
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
- // Update automerge doconly write dirty files to minimize change history
242
- this.doc = Automerge.change(this.doc, (d) => {
243
- if (!d.files) {
244
- (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
+ }
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.saveDoc();
607
+ await this.saveAutomergeDoc(this.registryPath, this.registry);
252
608
  await this.gitCommit("local changes");
253
609
 
254
- // Send sync messages to all peers
255
- const peerCount = this.opts.peerManager.router.getAllPeers().length;
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
- private broadcastSync() {
261
- const peers = this.opts.peerManager.router.getAllPeers();
262
- for (const peer of peers) {
263
- if (!this.syncStates.has(peer.nodeId)) {
264
- 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)}`);
265
645
  }
266
- this.sendSyncMessage(peer.nodeId);
267
646
  }
268
647
  }
269
648
 
270
- private sendSyncMessage(peerId: string) {
271
- const syncState = this.syncStates.get(peerId) ?? Automerge.initSyncState();
272
- 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 */ }
273
656
 
274
- this.syncStates.set(peerId, newSyncState);
657
+ if (content === null) return;
275
658
 
276
- if (!message) {
277
- debug(TAG, `no sync message to send to ${peerId} (already in sync)`);
278
- 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
+ }
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
- debug(TAG, `sending sync message to ${peerId} (${message.byteLength} bytes)`);
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
- const frame: KnowledgeSyncFrame = {
284
- type: "knowledge_sync",
285
- from: this.opts.nodeId,
286
- to: peerId,
287
- timestamp: Date.now(),
288
- payload: {
289
- data: Buffer.from(message).toString("base64"),
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
- this.opts.peerManager.router.sendTo(peerId, frame);
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
- private async readWorkspaceFiles(): Promise<Record<string, string>> {
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
- // Skip hidden files/dirs
307
- 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
+ }
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
- await this.walkDir(base, relPath, result);
845
+ dirEntries.push(relPath);
312
846
  } else if (entry.isFile()) {
313
847
  if (this.isIgnored(relPath)) continue;
314
- const filePath = path.join(base, relPath);
315
- const st = await fsStat(filePath);
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
- private async exportToFs() {
324
- const docFiles = this.doc.files ?? {};
325
- const currentFiles = await this.readWorkspaceFiles();
866
+ // ── Private: persistence ───────────────────────────────────────
326
867
 
327
- 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
+ }
328
876
 
329
- for (const [relPath, content] of Object.entries(docFiles)) {
330
- // Don't export files that would be gitignored
331
- if (this.isIgnored(relPath)) continue;
332
- if (currentFiles[relPath] !== content) {
333
- this.writtenByExport.set(relPath, content);
334
- const absPath = path.join(this.opts.workspacePath, relPath);
335
- await mkdir(path.dirname(absPath), { recursive: true });
336
- await writeFile(absPath, content, "utf-8");
337
- written++;
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
- // Note: we intentionally do NOT delete local files that are absent from
342
- // the doc. A locally created file that hasn't been synced yet would be
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
- if (written > 0) {
347
- debug(TAG, `exported to filesystem: ${written} written`);
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 local git user config exists (so commits don't fail on unconfigured machines)
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; // Nothing to commit
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
  }