clawmatrix 0.1.16 → 0.1.18

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.
@@ -0,0 +1,426 @@
1
+ import * as Automerge from "@automerge/automerge";
2
+ import { watch, type FSWatcher } from "node:fs";
3
+ import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import ignore, { type Ignore } from "ignore";
6
+
7
+ import { spawnProcess } from "./compat.ts";
8
+ import { debug } from "./debug.ts";
9
+ import type { PeerManager } from "./peer-manager.ts";
10
+ import type { KnowledgeSyncFrame } from "./types.ts";
11
+
12
+ /** Automerge document schema: map of relative paths to file contents. */
13
+ interface KnowledgeDoc {
14
+ files: Record<string, string>;
15
+ }
16
+
17
+ export interface KnowledgeSyncOptions {
18
+ /** Workspace directory to watch and sync. */
19
+ workspacePath: string;
20
+ /** Path to persist the Automerge document (binary). */
21
+ storePath: string;
22
+ /** Local node ID. */
23
+ nodeId: string;
24
+ /** Debounce interval in ms for fs changes. */
25
+ debounce: number;
26
+ /** PeerManager for sending frames. */
27
+ peerManager: PeerManager;
28
+ }
29
+
30
+ const TAG = "knowledge";
31
+
32
+ export class KnowledgeSync {
33
+ private doc: Automerge.Doc<KnowledgeDoc>;
34
+ private syncStates = new Map<string, Automerge.SyncState>();
35
+ private watcher: FSWatcher | null = null;
36
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
37
+ private suppressWatch = false;
38
+ private opts: KnowledgeSyncOptions;
39
+ private ig: Ignore = ignore();
40
+
41
+ constructor(opts: KnowledgeSyncOptions) {
42
+ this.opts = opts;
43
+ this.doc = Automerge.init<KnowledgeDoc>();
44
+ }
45
+
46
+ async start() {
47
+ debug(TAG, `starting knowledge sync: workspace=${this.opts.workspacePath}`);
48
+
49
+ // Ensure workspace directory exists
50
+ await mkdir(this.opts.workspacePath, { recursive: true });
51
+
52
+ // Load .gitignore rules
53
+ await this.loadGitignore();
54
+
55
+ // Load persisted doc or initialize from existing files
56
+ const loaded = await this.loadDoc();
57
+ if (loaded) {
58
+ this.doc = loaded;
59
+ const fileCount = Object.keys(this.doc.files ?? {}).length;
60
+ debug(TAG, `loaded persisted doc: ${fileCount} files`);
61
+ // Export doc state to filesystem (in case doc has newer data)
62
+ await this.exportToFs();
63
+ } else {
64
+ // First run: import existing files into doc
65
+ debug(TAG, "no persisted doc found, importing from filesystem");
66
+ await this.importFromFs();
67
+ const fileCount = Object.keys(this.doc.files ?? {}).length;
68
+ debug(TAG, `imported ${fileCount} files from workspace`);
69
+ await this.saveDoc();
70
+ }
71
+
72
+ // Initialize git repo if not already
73
+ await this.gitInit();
74
+
75
+ // Start watching for file changes
76
+ this.watcher = watch(this.opts.workspacePath, { recursive: true }, (_event, filename) => {
77
+ if (this.suppressWatch || !filename) return;
78
+ // Ignore hidden files
79
+ if (filename.startsWith(".")) return;
80
+ // Ignore gitignored files
81
+ if (this.ig.ignores(filename)) return;
82
+ this.scheduleSync();
83
+ });
84
+
85
+ debug(TAG, "file watcher started");
86
+ }
87
+
88
+ async stop() {
89
+ debug(TAG, "stopping knowledge sync");
90
+ if (this.debounceTimer) {
91
+ clearTimeout(this.debounceTimer);
92
+ this.debounceTimer = null;
93
+ }
94
+ this.watcher?.close();
95
+ this.watcher = null;
96
+ await this.saveDoc();
97
+ debug(TAG, "knowledge sync stopped, doc saved");
98
+ }
99
+
100
+ /** Handle incoming knowledge_sync frame from a peer. */
101
+ async handleSyncMessage(frame: KnowledgeSyncFrame) {
102
+ const peerId = frame.from;
103
+ const msgSize = frame.payload.data.length;
104
+ debug(TAG, `received sync message from ${peerId} (${msgSize} bytes)`);
105
+
106
+ const syncState = this.syncStates.get(peerId) ?? Automerge.initSyncState();
107
+ const message = new Uint8Array(Buffer.from(frame.payload.data, "base64"));
108
+
109
+ const [newDoc, newSyncState, _patch] = Automerge.receiveSyncMessage(
110
+ this.doc,
111
+ syncState,
112
+ message,
113
+ );
114
+
115
+ const oldFileCount = Object.keys(this.doc.files ?? {}).length;
116
+ const newFileCount = Object.keys(newDoc.files ?? {}).length;
117
+ this.doc = newDoc;
118
+ this.syncStates.set(peerId, newSyncState);
119
+
120
+ if (oldFileCount !== newFileCount) {
121
+ debug(TAG, `doc updated: ${oldFileCount} → ${newFileCount} files`);
122
+ }
123
+
124
+ // Export changes to filesystem and git commit
125
+ await this.exportToFs();
126
+ await this.saveDoc();
127
+ await this.gitCommit(`sync from ${peerId}`);
128
+
129
+ // Continue sync protocol (may need to send response)
130
+ this.sendSyncMessage(peerId);
131
+ }
132
+
133
+ /** Called when a new peer connects — initiate sync. */
134
+ initPeerSync(peerId: string) {
135
+ debug(TAG, `initiating sync with peer ${peerId}`);
136
+ this.syncStates.set(peerId, Automerge.initSyncState());
137
+ this.sendSyncMessage(peerId);
138
+ }
139
+
140
+ /** Called when a peer disconnects — clean up sync state. */
141
+ removePeerSync(peerId: string) {
142
+ debug(TAG, `removing sync state for peer ${peerId}`);
143
+ this.syncStates.delete(peerId);
144
+ }
145
+
146
+ // ── Private methods ─────────────────────────────────────────────
147
+
148
+ private async loadGitignore() {
149
+ this.ig = ignore();
150
+ // Always ignore .git and automerge store
151
+ this.ig.add([".git", ".clawmatrix"]);
152
+ try {
153
+ const content = await readFile(path.join(this.opts.workspacePath, ".gitignore"), "utf-8");
154
+ this.ig.add(content);
155
+ debug(TAG, "loaded .gitignore rules");
156
+ } catch {
157
+ // No .gitignore — that's fine
158
+ }
159
+ }
160
+
161
+ private isIgnored(relPath: string): boolean {
162
+ return this.ig.ignores(relPath);
163
+ }
164
+
165
+ private scheduleSync() {
166
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
167
+ this.debounceTimer = setTimeout(() => {
168
+ this.debounceTimer = null;
169
+ this.handleLocalChanges().catch((err) => {
170
+ debug(TAG, `local change handling error: ${err}`);
171
+ });
172
+ }, this.opts.debounce);
173
+ }
174
+
175
+ private async handleLocalChanges() {
176
+ // Reload .gitignore in case it changed
177
+ await this.loadGitignore();
178
+
179
+ const currentFiles = await this.readWorkspaceFiles();
180
+ const docFiles = this.doc.files ?? {};
181
+
182
+ // Collect changed files for logging
183
+ const added: string[] = [];
184
+ const modified: string[] = [];
185
+ const deleted: string[] = [];
186
+
187
+ for (const [relPath, content] of Object.entries(currentFiles)) {
188
+ if (!(relPath in docFiles)) {
189
+ added.push(relPath);
190
+ } else if (docFiles[relPath] !== content) {
191
+ modified.push(relPath);
192
+ }
193
+ }
194
+
195
+ for (const relPath of Object.keys(docFiles)) {
196
+ if (!(relPath in currentFiles)) {
197
+ deleted.push(relPath);
198
+ }
199
+ }
200
+
201
+ if (added.length === 0 && modified.length === 0 && deleted.length === 0) return;
202
+
203
+ debug(
204
+ TAG,
205
+ `local changes detected: +${added.length} ~${modified.length} -${deleted.length}` +
206
+ (added.length > 0 ? ` added=[${added.join(",")}]` : "") +
207
+ (modified.length > 0 ? ` modified=[${modified.join(",")}]` : "") +
208
+ (deleted.length > 0 ? ` deleted=[${deleted.join(",")}]` : ""),
209
+ );
210
+
211
+ // Update automerge doc
212
+ this.doc = Automerge.change(this.doc, (d) => {
213
+ if (!d.files) {
214
+ (d as KnowledgeDoc).files = {};
215
+ }
216
+ for (const [relPath, content] of Object.entries(currentFiles)) {
217
+ d.files[relPath] = content;
218
+ }
219
+ for (const relPath of Object.keys(d.files)) {
220
+ if (!(relPath in currentFiles)) {
221
+ delete d.files[relPath];
222
+ }
223
+ }
224
+ });
225
+
226
+ await this.saveDoc();
227
+ await this.gitCommit("local changes");
228
+
229
+ // Send sync messages to all peers
230
+ const peerCount = this.opts.peerManager.router.getAllPeers().length;
231
+ debug(TAG, `broadcasting changes to ${peerCount} peer(s)`);
232
+ this.broadcastSync();
233
+ }
234
+
235
+ private broadcastSync() {
236
+ const peers = this.opts.peerManager.router.getAllPeers();
237
+ for (const peer of peers) {
238
+ if (!this.syncStates.has(peer.nodeId)) {
239
+ this.syncStates.set(peer.nodeId, Automerge.initSyncState());
240
+ }
241
+ this.sendSyncMessage(peer.nodeId);
242
+ }
243
+ }
244
+
245
+ private sendSyncMessage(peerId: string) {
246
+ const syncState = this.syncStates.get(peerId) ?? Automerge.initSyncState();
247
+ const [newSyncState, message] = Automerge.generateSyncMessage(this.doc, syncState);
248
+
249
+ this.syncStates.set(peerId, newSyncState);
250
+
251
+ if (!message) {
252
+ debug(TAG, `no sync message to send to ${peerId} (already in sync)`);
253
+ return;
254
+ }
255
+
256
+ debug(TAG, `sending sync message to ${peerId} (${message.byteLength} bytes)`);
257
+
258
+ const frame: KnowledgeSyncFrame = {
259
+ type: "knowledge_sync",
260
+ from: this.opts.nodeId,
261
+ to: peerId,
262
+ timestamp: Date.now(),
263
+ payload: {
264
+ data: Buffer.from(message).toString("base64"),
265
+ },
266
+ };
267
+
268
+ this.opts.peerManager.router.sendTo(peerId, frame);
269
+ }
270
+
271
+ private async readWorkspaceFiles(): Promise<Record<string, string>> {
272
+ const files: Record<string, string> = {};
273
+ await this.walkDir(this.opts.workspacePath, "", files);
274
+ return files;
275
+ }
276
+
277
+ private async walkDir(base: string, rel: string, result: Record<string, string>) {
278
+ const entries = await readdir(path.join(base, rel), { withFileTypes: true });
279
+ for (const entry of entries) {
280
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
281
+ // Skip hidden files/dirs
282
+ if (entry.name.startsWith(".")) continue;
283
+ // Skip gitignored paths
284
+ if (this.isIgnored(relPath)) continue;
285
+ if (entry.isDirectory()) {
286
+ // For directories, check with trailing slash
287
+ if (this.isIgnored(relPath + "/")) continue;
288
+ await this.walkDir(base, relPath, result);
289
+ } else if (entry.isFile()) {
290
+ const content = await readFile(path.join(base, relPath), "utf-8");
291
+ result[relPath] = content;
292
+ }
293
+ }
294
+ }
295
+
296
+ private async exportToFs() {
297
+ this.suppressWatch = true;
298
+ try {
299
+ const docFiles = this.doc.files ?? {};
300
+ const currentFiles = await this.readWorkspaceFiles();
301
+
302
+ let written = 0;
303
+ let removed = 0;
304
+
305
+ for (const [relPath, content] of Object.entries(docFiles)) {
306
+ // Don't export files that would be gitignored
307
+ if (this.isIgnored(relPath)) continue;
308
+ if (currentFiles[relPath] !== content) {
309
+ const absPath = path.join(this.opts.workspacePath, relPath);
310
+ await mkdir(path.dirname(absPath), { recursive: true });
311
+ await writeFile(absPath, content, "utf-8");
312
+ written++;
313
+ }
314
+ }
315
+
316
+ for (const relPath of Object.keys(currentFiles)) {
317
+ if (!(relPath in docFiles)) {
318
+ const absPath = path.join(this.opts.workspacePath, relPath);
319
+ await unlink(absPath).catch(() => {});
320
+ removed++;
321
+ }
322
+ }
323
+
324
+ if (written > 0 || removed > 0) {
325
+ debug(TAG, `exported to filesystem: ${written} written, ${removed} removed`);
326
+ }
327
+ } finally {
328
+ setTimeout(() => {
329
+ this.suppressWatch = false;
330
+ }, 500);
331
+ }
332
+ }
333
+
334
+ // ── Git operations ──────────────────────────────────────────────
335
+
336
+ private async gitInit() {
337
+ try {
338
+ // Check if already a git repo
339
+ const proc = spawnProcess(["git", "rev-parse", "--git-dir"], {
340
+ cwd: this.opts.workspacePath,
341
+ stdout: "pipe",
342
+ stderr: "pipe",
343
+ });
344
+ const code = await proc.exited;
345
+ if (code !== 0) {
346
+ // Not a git repo — initialize
347
+ const init = spawnProcess(["git", "init"], {
348
+ cwd: this.opts.workspacePath,
349
+ stdout: "pipe",
350
+ stderr: "pipe",
351
+ });
352
+ await init.exited;
353
+ debug(TAG, `initialized git repo at ${this.opts.workspacePath}`);
354
+ } else {
355
+ debug(TAG, "git repo already initialized");
356
+ }
357
+ } catch {
358
+ debug(TAG, "git not available, skipping git integration");
359
+ }
360
+ }
361
+
362
+ private async gitCommit(message: string) {
363
+ try {
364
+ // git add -A (respects .gitignore)
365
+ const add = spawnProcess(["git", "add", "-A"], {
366
+ cwd: this.opts.workspacePath,
367
+ stdout: "pipe",
368
+ stderr: "pipe",
369
+ });
370
+ await add.exited;
371
+
372
+ // Check if there are staged changes
373
+ const diff = spawnProcess(["git", "diff", "--cached", "--quiet"], {
374
+ cwd: this.opts.workspacePath,
375
+ stdout: "pipe",
376
+ stderr: "pipe",
377
+ });
378
+ const diffCode = await diff.exited;
379
+ if (diffCode === 0) return; // Nothing to commit
380
+
381
+ // Commit
382
+ const commit = spawnProcess(
383
+ ["git", "commit", "-m", `[clawmatrix] ${message}`],
384
+ {
385
+ cwd: this.opts.workspacePath,
386
+ stdout: "pipe",
387
+ stderr: "pipe",
388
+ },
389
+ );
390
+ await commit.exited;
391
+ debug(TAG, `git commit: ${message}`);
392
+ } catch (err) {
393
+ debug(TAG, `git commit failed: ${err}`);
394
+ }
395
+ }
396
+
397
+ // ── Persistence ─────────────────────────────────────────────────
398
+
399
+ private async loadDoc(): Promise<Automerge.Doc<KnowledgeDoc> | null> {
400
+ try {
401
+ const data = await readFile(this.opts.storePath);
402
+ debug(TAG, `loaded automerge doc from ${this.opts.storePath} (${data.byteLength} bytes)`);
403
+ return Automerge.load<KnowledgeDoc>(new Uint8Array(data));
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ private async saveDoc() {
410
+ const data = Automerge.save(this.doc);
411
+ await mkdir(path.dirname(this.opts.storePath), { recursive: true });
412
+ await writeFile(this.opts.storePath, Buffer.from(data));
413
+ }
414
+
415
+ private async importFromFs() {
416
+ const files = await this.readWorkspaceFiles();
417
+ if (Object.keys(files).length === 0) return;
418
+
419
+ this.doc = Automerge.change(this.doc, (d) => {
420
+ (d as KnowledgeDoc).files = {};
421
+ for (const [relPath, content] of Object.entries(files)) {
422
+ d.files[relPath] = content;
423
+ }
424
+ });
425
+ }
426
+ }
@@ -13,32 +13,32 @@ export function createClusterEventsTool(): AnyAgentTool {
13
13
  action: {
14
14
  type: "string",
15
15
  enum: ["query", "consume"],
16
- description: '"query" to list events, "consume" to mark events as processed',
16
+ description: '"query" to list events (type/source/unconsumed/since/limit filters apply), "consume" to mark as processed (requires ids)',
17
17
  },
18
18
  type: {
19
19
  type: "string",
20
- description: 'Filter by event type (e.g. "message_received", "call_missed"). Only for action=query.',
20
+ description: 'Filter by event type (e.g. "message_received")',
21
21
  },
22
22
  source: {
23
23
  type: "string",
24
- description: 'Filter by source (e.g. "shortcuts", "iphone"). Only for action=query.',
24
+ description: 'Filter by source (e.g. "shortcuts")',
25
25
  },
26
26
  unconsumed: {
27
27
  type: "boolean",
28
- description: "Only return unconsumed events. Default true. Only for action=query.",
28
+ description: "Only unconsumed events (default true)",
29
29
  },
30
30
  since: {
31
31
  type: "number",
32
- description: "Only return events after this unix timestamp (ms). Only for action=query.",
32
+ description: "Events after this unix timestamp (ms)",
33
33
  },
34
34
  limit: {
35
35
  type: "number",
36
- description: "Max events to return. Default 20. Only for action=query.",
36
+ description: "Max events to return (default 20)",
37
37
  },
38
38
  ids: {
39
39
  type: "array",
40
40
  items: { type: "string" },
41
- description: "Event IDs to mark as consumed. Required for action=consume.",
41
+ description: "Event IDs to consume",
42
42
  },
43
43
  },
44
44
  required: ["action"],
@@ -105,7 +105,7 @@ export function createClusterEventsTool(): AnyAgentTool {
105
105
  }));
106
106
 
107
107
  return {
108
- content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }],
108
+ content: [{ type: "text" as const, text: JSON.stringify(summary) }],
109
109
  details: { events: summary },
110
110
  };
111
111
  } catch (err) {
@@ -53,7 +53,7 @@ export function createClusterExecTool(): AnyAgentTool {
53
53
  content: [
54
54
  {
55
55
  type: "text" as const,
56
- text: JSON.stringify(result, null, 2),
56
+ text: JSON.stringify(result),
57
57
  },
58
58
  ],
59
59
  details: result,
@@ -34,7 +34,7 @@ export function createClusterHandoffReplyTool(): AnyAgentTool {
34
34
  content: [
35
35
  {
36
36
  type: "text" as const,
37
- text: `Remote agent needs more information.\n\nHandoff ID: ${result.handoffId}\nQuestion: ${result.result}\n\nUse cluster_handoff_reply again to respond.`,
37
+ text: `Input required (handoff_id:${result.handoffId}): ${result.result}`,
38
38
  },
39
39
  ],
40
40
  details: result,
@@ -52,11 +52,7 @@ export function createClusterHandoffReplyTool(): AnyAgentTool {
52
52
  content: [
53
53
  {
54
54
  type: "text" as const,
55
- text: JSON.stringify(
56
- { nodeId: result.nodeId, agent: result.agent, result: result.result },
57
- null,
58
- 2,
59
- ),
55
+ text: JSON.stringify({ nodeId: result.nodeId, agent: result.agent, result: result.result }),
60
56
  },
61
57
  ],
62
58
  details: result,
@@ -42,7 +42,7 @@ export function createClusterHandoffTool(): AnyAgentTool {
42
42
  content: [
43
43
  {
44
44
  type: "text" as const,
45
- text: `Remote agent needs more information before continuing.\n\nHandoff ID: ${result.handoffId}\nQuestion: ${result.result}\n\nUse cluster_handoff_reply tool to respond.`,
45
+ text: `Input required (handoff_id:${result.handoffId}): ${result.result}`,
46
46
  },
47
47
  ],
48
48
  details: result,
@@ -65,15 +65,7 @@ export function createClusterHandoffTool(): AnyAgentTool {
65
65
  content: [
66
66
  {
67
67
  type: "text" as const,
68
- text: JSON.stringify(
69
- {
70
- nodeId: result.nodeId,
71
- agent: result.agent,
72
- result: result.result,
73
- },
74
- null,
75
- 2,
76
- ),
68
+ text: JSON.stringify({ nodeId: result.nodeId, agent: result.agent, result: result.result }),
77
69
  },
78
70
  ],
79
71
  details: result,
@@ -21,36 +21,29 @@ export function createClusterPeersTool(): AnyAgentTool {
21
21
  description: a.description,
22
22
  tags: a.tags,
23
23
  })),
24
- models: entry.models.map((m) => ({
25
- id: m.id,
26
- provider: m.provider,
27
- })),
24
+ models: entry.models.map((m) => m.id),
28
25
  tags: entry.tags,
29
26
  tools: entry.toolProxy?.enabled ? (entry.toolProxy.allow ?? []) : [],
30
27
  status: entry.connection?.isOpen ? "connected" : "unreachable",
31
28
  latencyMs: entry.latencyMs,
32
29
  }));
33
30
 
34
- // Include satellite nodes
31
+ // Include satellite nodes (minimal fields — no agents/models)
35
32
  const satellites = runtime.webHandler?.getSatelliteContexts() ?? runtime.peerManager.satelliteContexts;
36
33
  for (const sat of satellites) {
37
34
  if (Date.now() - sat.ts >= 600_000) continue;
38
35
  peers.push({
39
36
  nodeId: sat.nodeId,
40
- agents: [],
41
- models: [],
42
- tags: [],
43
37
  tools: sat.tools ?? [],
44
38
  status: "satellite",
45
- latencyMs: undefined,
46
- });
39
+ } as (typeof peers)[number]);
47
40
  }
48
41
 
49
42
  return {
50
43
  content: [
51
44
  {
52
45
  type: "text" as const,
53
- text: JSON.stringify(peers, null, 2),
46
+ text: JSON.stringify(peers),
54
47
  },
55
48
  ],
56
49
  details: peers,
@@ -30,7 +30,7 @@ export function createClusterReadTool(): AnyAgentTool {
30
30
  content: [
31
31
  {
32
32
  type: "text" as const,
33
- text: JSON.stringify(result, null, 2),
33
+ text: JSON.stringify(result),
34
34
  },
35
35
  ],
36
36
  details: result,
@@ -39,7 +39,7 @@ export function createClusterToolTool(): AnyAgentTool {
39
39
  content: [
40
40
  {
41
41
  type: "text" as const,
42
- text: JSON.stringify(result, null, 2),
42
+ text: JSON.stringify(result),
43
43
  },
44
44
  ],
45
45
  details: result,
@@ -42,7 +42,7 @@ export function createClusterWriteTool(): AnyAgentTool {
42
42
  content: [
43
43
  {
44
44
  type: "text" as const,
45
- text: JSON.stringify(result, null, 2),
45
+ text: JSON.stringify(result),
46
46
  },
47
47
  ],
48
48
  details: result,
package/src/types.ts CHANGED
@@ -319,6 +319,14 @@ export interface IngestedEvent {
319
319
  consumed: boolean; // whether an agent has consumed this event
320
320
  }
321
321
 
322
+ // ── Knowledge sync ────────────────────────────────────────────────
323
+ export interface KnowledgeSyncFrame extends ClusterFrame {
324
+ type: "knowledge_sync";
325
+ payload: {
326
+ data: string; // base64-encoded Automerge sync message
327
+ };
328
+ }
329
+
322
330
  // ── Union of all frame types ───────────────────────────────────────
323
331
  export type AnyClusterFrame =
324
332
  | AuthChallenge
@@ -343,4 +351,5 @@ export type AnyClusterFrame =
343
351
  | HandoffInputRequired
344
352
  | HandoffInput
345
353
  | ToolProxyRequest
346
- | ToolProxyResponse;
354
+ | ToolProxyResponse
355
+ | KnowledgeSyncFrame;