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.
- package/BOOTSTRAP.md +123 -121
- package/README.md +76 -71
- package/openclaw.plugin.json +1 -67
- package/package.json +5 -3
- package/src/cluster-service.ts +58 -0
- package/src/config.ts +14 -2
- package/src/index.ts +51 -62
- package/src/knowledge-sync.ts +426 -0
- package/src/tools/cluster-events.ts +8 -8
- package/src/tools/cluster-exec.ts +1 -1
- package/src/tools/cluster-handoff-reply.ts +2 -6
- package/src/tools/cluster-handoff.ts +2 -10
- package/src/tools/cluster-peers.ts +4 -11
- package/src/tools/cluster-read.ts +1 -1
- package/src/tools/cluster-tool.ts +1 -1
- package/src/tools/cluster-write.ts +1 -1
- package/src/types.ts +10 -1
|
@@ -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
|
|
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"
|
|
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"
|
|
24
|
+
description: 'Filter by source (e.g. "shortcuts")',
|
|
25
25
|
},
|
|
26
26
|
unconsumed: {
|
|
27
27
|
type: "boolean",
|
|
28
|
-
description: "Only
|
|
28
|
+
description: "Only unconsumed events (default true)",
|
|
29
29
|
},
|
|
30
30
|
since: {
|
|
31
31
|
type: "number",
|
|
32
|
-
description: "
|
|
32
|
+
description: "Events after this unix timestamp (ms)",
|
|
33
33
|
},
|
|
34
34
|
limit: {
|
|
35
35
|
type: "number",
|
|
36
|
-
description: "Max events to return
|
|
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
|
|
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
|
|
108
|
+
content: [{ type: "text" as const, text: JSON.stringify(summary) }],
|
|
109
109
|
details: { events: summary },
|
|
110
110
|
};
|
|
111
111
|
} catch (err) {
|
|
@@ -34,7 +34,7 @@ export function createClusterHandoffReplyTool(): AnyAgentTool {
|
|
|
34
34
|
content: [
|
|
35
35
|
{
|
|
36
36
|
type: "text" as const,
|
|
37
|
-
text: `
|
|
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: `
|
|
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
|
-
|
|
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
|
|
46
|
+
text: JSON.stringify(peers),
|
|
54
47
|
},
|
|
55
48
|
],
|
|
56
49
|
details: peers,
|
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;
|