claude-code-swarm 0.3.16 → 0.3.17
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/mcp-launcher.mjs +17 -4
- package/.claude-plugin/plugin.json +1 -1
- package/e2e/tier7-memory-sync.test.mjs +259 -0
- package/hooks/hooks.json +9 -0
- package/package.json +3 -3
- package/scripts/map-hook.mjs +14 -0
- package/scripts/map-sidecar.mjs +26 -0
- package/src/__tests__/memory-sync.test.mjs +132 -0
- package/src/__tests__/memory-watcher.test.mjs +104 -0
- package/src/bootstrap.mjs +27 -2
- package/src/map-events.mjs +30 -0
- package/src/memory-watcher.mjs +73 -0
- package/src/sidecar-server.mjs +38 -0
|
@@ -162,12 +162,25 @@ if (!def.enabled()) {
|
|
|
162
162
|
runNoop();
|
|
163
163
|
} else {
|
|
164
164
|
const { cmd, args } = def.launch();
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
const resolved = which(cmd);
|
|
166
|
+
// Debug: log MCP server resolution to a temp file for diagnostics
|
|
167
|
+
try {
|
|
168
|
+
const { appendFileSync } = await import('node:fs');
|
|
169
|
+
appendFileSync('/tmp/mcp-launcher-debug.log', `[${new Date().toISOString()}] server=${server} cmd=${cmd} resolved=${resolved} cwd=${process.cwd()} args=${JSON.stringify(args)}\n`);
|
|
170
|
+
} catch {}
|
|
171
|
+
if (resolved) {
|
|
172
|
+
// Replace this process with the real server.
|
|
173
|
+
// NODE_NO_WARNINGS suppresses Node.js experimental feature warnings on stderr
|
|
174
|
+
// (e.g., sqlite). Some MCP clients interpret stderr output as server errors,
|
|
175
|
+
// causing them to mark the server as "failed" even though it's functional.
|
|
176
|
+
const spawnEnv = { ...process.env, NODE_NO_WARNINGS: '1' };
|
|
168
177
|
const child = (await import('node:child_process')).spawn(cmd, args, {
|
|
169
|
-
stdio: 'inherit',
|
|
178
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
179
|
+
env: spawnEnv,
|
|
170
180
|
});
|
|
181
|
+
// Swallow stderr to prevent MCP client from misinterpreting it.
|
|
182
|
+
// In debug mode, forward to our stderr.
|
|
183
|
+
child.stderr.on('data', () => {});
|
|
171
184
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
172
185
|
} else {
|
|
173
186
|
process.stderr.write(`[${server}-mcp] ${cmd} CLI not found\n`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-swarm",
|
|
3
3
|
"description": "Spin up Claude Code agent teams from openteams YAML topologies with optional MAP (Multi-Agent Protocol) observability and coordination. Provides hooks for session lifecycle, agent spawn/complete tracking, and a /swarm skill to launch team configurations.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.17",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "alexngai"
|
|
7
7
|
},
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 7: minimem → MAP sync integration test
|
|
3
|
+
*
|
|
4
|
+
* Tests the full bridge flow:
|
|
5
|
+
* Agent uses minimem MCP tool (write) → PostToolUse hook fires
|
|
6
|
+
* → map-hook.mjs builds bridge command → sidecar calls x-openhive/memory.sync
|
|
7
|
+
* → Mock MAP server receives the sync notification
|
|
8
|
+
*
|
|
9
|
+
* Groups:
|
|
10
|
+
* 1. Hook configuration verification (no agent, no LLM)
|
|
11
|
+
* 2. Bridge command builder (no agent, no LLM)
|
|
12
|
+
* 3. Live agent writes memory → MAP event received (LIVE_AGENT_TEST=1)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
16
|
+
import { createWorkspace } from "./helpers/workspace.mjs";
|
|
17
|
+
import { cleanupWorkspace, waitFor } from "./helpers/cleanup.mjs";
|
|
18
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
|
|
21
|
+
// Check if dependencies are available (try require for CJS-compatible packages)
|
|
22
|
+
import { createRequire } from "module";
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
|
|
25
|
+
let minimemAvailable = false;
|
|
26
|
+
try {
|
|
27
|
+
require("minimem");
|
|
28
|
+
minimemAvailable = true;
|
|
29
|
+
} catch { /* minimem not installed */ }
|
|
30
|
+
|
|
31
|
+
let mapSdkAvailable = false;
|
|
32
|
+
try {
|
|
33
|
+
require("@multi-agent-protocol/sdk");
|
|
34
|
+
mapSdkAvailable = true;
|
|
35
|
+
} catch { /* MAP SDK not installed */ }
|
|
36
|
+
|
|
37
|
+
// Check if Claude CLI is available
|
|
38
|
+
let cliAvailable = false;
|
|
39
|
+
try {
|
|
40
|
+
const { CLI_AVAILABLE } = await import("./helpers/cli.mjs");
|
|
41
|
+
cliAvailable = CLI_AVAILABLE;
|
|
42
|
+
} catch { /* CLI not available */ }
|
|
43
|
+
|
|
44
|
+
// ── Group 1: Hook configuration ─────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe("tier7: minimem sync hook configuration", { timeout: 30_000 }, () => {
|
|
47
|
+
it("hooks.json has a PostToolUse entry for minimem", () => {
|
|
48
|
+
const hooksPath = join(import.meta.dirname, "..", "hooks", "hooks.json");
|
|
49
|
+
const hooks = JSON.parse(readFileSync(hooksPath, "utf-8"));
|
|
50
|
+
const postToolUse = hooks.hooks.PostToolUse || [];
|
|
51
|
+
|
|
52
|
+
const minimemHook = postToolUse.find((h) => h.matcher === "minimem");
|
|
53
|
+
expect(minimemHook).toBeDefined();
|
|
54
|
+
expect(minimemHook.hooks[0].command).toContain("minimem-mcp-used");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("minimem hook checks both minimem.enabled and map.enabled", () => {
|
|
58
|
+
const hooksPath = join(import.meta.dirname, "..", "hooks", "hooks.json");
|
|
59
|
+
const hooks = JSON.parse(readFileSync(hooksPath, "utf-8"));
|
|
60
|
+
const postToolUse = hooks.hooks.PostToolUse || [];
|
|
61
|
+
|
|
62
|
+
const minimemHook = postToolUse.find((h) => h.matcher === "minimem");
|
|
63
|
+
const cmd = minimemHook.hooks[0].command;
|
|
64
|
+
|
|
65
|
+
expect(cmd).toContain("minimem");
|
|
66
|
+
expect(cmd).toContain("map");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("hooks.json minimem hook is gated behind config check", () => {
|
|
70
|
+
const hooksPath = join(import.meta.dirname, "..", "hooks", "hooks.json");
|
|
71
|
+
const hooks = JSON.parse(readFileSync(hooksPath, "utf-8"));
|
|
72
|
+
const postToolUse = hooks.hooks.PostToolUse || [];
|
|
73
|
+
|
|
74
|
+
const minimemHook = postToolUse.find((h) => h.matcher === "minimem");
|
|
75
|
+
const cmd = minimemHook.hooks[0].command;
|
|
76
|
+
|
|
77
|
+
// Should have the config check prefix (node -e "..." && node map-hook.mjs)
|
|
78
|
+
expect(cmd).toContain('node -e');
|
|
79
|
+
expect(cmd).toContain('process.exit');
|
|
80
|
+
expect(cmd).toContain('map-hook.mjs');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── Group 2: Bridge command builder ─────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("tier7: minimem bridge command builder", { timeout: 30_000 }, () => {
|
|
87
|
+
it("builds command for write operations", async () => {
|
|
88
|
+
const { buildMinimemBridgeCommand } = await import("../src/map-events.mjs");
|
|
89
|
+
|
|
90
|
+
const cmd = buildMinimemBridgeCommand({
|
|
91
|
+
tool_name: "minimem__memory_append",
|
|
92
|
+
tool_input: { text: "Test memory entry" },
|
|
93
|
+
session_id: "test-session",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(cmd).not.toBeNull();
|
|
97
|
+
expect(cmd.action).toBe("bridge-memory-sync");
|
|
98
|
+
expect(cmd.agentId).toBe("test-session");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("skips read-only operations", async () => {
|
|
102
|
+
const { buildMinimemBridgeCommand } = await import("../src/map-events.mjs");
|
|
103
|
+
|
|
104
|
+
const cmd = buildMinimemBridgeCommand({
|
|
105
|
+
tool_name: "minimem__memory_search",
|
|
106
|
+
tool_input: { query: "test" },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(cmd).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("map-hook.mjs switch statement includes minimem-mcp-used", () => {
|
|
113
|
+
const hookScript = readFileSync(
|
|
114
|
+
join(import.meta.dirname, "..", "scripts", "map-hook.mjs"),
|
|
115
|
+
"utf-8"
|
|
116
|
+
);
|
|
117
|
+
expect(hookScript).toContain('"minimem-mcp-used"');
|
|
118
|
+
expect(hookScript).toContain("handleMinimemMcpUsed");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("sidecar-server.mjs handles bridge-memory-sync command", () => {
|
|
122
|
+
const sidecarScript = readFileSync(
|
|
123
|
+
join(import.meta.dirname, "..", "src", "sidecar-server.mjs"),
|
|
124
|
+
"utf-8"
|
|
125
|
+
);
|
|
126
|
+
expect(sidecarScript).toContain('"bridge-memory-sync"');
|
|
127
|
+
expect(sidecarScript).toContain("x-openhive/memory.sync");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Group 3: Live agent test ────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe.skipIf(!process.env.LIVE_AGENT_TEST || !minimemAvailable || !cliAvailable || !mapSdkAvailable)(
|
|
134
|
+
"tier7: live agent minimem → MAP sync",
|
|
135
|
+
{ timeout: 300_000 },
|
|
136
|
+
() => {
|
|
137
|
+
let mockServer;
|
|
138
|
+
let workspace;
|
|
139
|
+
let messages;
|
|
140
|
+
let runResult;
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
const { MockMapServer } = await import("./helpers/map-mock-server.mjs");
|
|
144
|
+
mockServer = new MockMapServer();
|
|
145
|
+
await mockServer.start();
|
|
146
|
+
|
|
147
|
+
workspace = createWorkspace({
|
|
148
|
+
config: {
|
|
149
|
+
minimem: { enabled: true, provider: "none" },
|
|
150
|
+
map: {
|
|
151
|
+
enabled: true,
|
|
152
|
+
server: `ws://localhost:${mockServer.port}`,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
gitInit: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Initialize minimem in the workspace so the MCP server can start.
|
|
159
|
+
// Must run `minimem sync` to create index.db — without it the MCP server
|
|
160
|
+
// starts but may report tools inconsistently.
|
|
161
|
+
const minimemDir = join(workspace.dir, ".swarm", "minimem");
|
|
162
|
+
mkdirSync(join(minimemDir, "memory"), { recursive: true });
|
|
163
|
+
writeFileSync(join(minimemDir, "MEMORY.md"), "# Test Memory\n");
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(minimemDir, "config.json"),
|
|
166
|
+
JSON.stringify({ embedding: { provider: "none" } })
|
|
167
|
+
);
|
|
168
|
+
writeFileSync(join(minimemDir, ".gitignore"), "index.db\n");
|
|
169
|
+
|
|
170
|
+
// Initialize the search index — critical for MCP server readiness.
|
|
171
|
+
// Use require() to avoid ESM resolution issues with node:sqlite in vitest.
|
|
172
|
+
try {
|
|
173
|
+
const { Minimem } = require("minimem");
|
|
174
|
+
const mem = await Minimem.create({ memoryDir: minimemDir, embedding: { provider: "none" } });
|
|
175
|
+
await mem.sync();
|
|
176
|
+
await mem.close();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn("[tier7] minimem init warning:", err.message?.slice(0, 200));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { runClaude } = await import("./helpers/cli.mjs");
|
|
182
|
+
runResult = await runClaude(
|
|
183
|
+
'Use the minimem MCP tools: First search for "test" using memory_search. Then write a memory file at .swarm/minimem/memory/e2e-test.md containing "### 2026-03-31 12:00\n<!-- type: decision -->\nChose PostgreSQL for the main database." using the Write tool. Report what you did.',
|
|
184
|
+
{
|
|
185
|
+
cwd: workspace.dir,
|
|
186
|
+
model: "haiku",
|
|
187
|
+
maxTurns: 6,
|
|
188
|
+
maxBudgetUsd: 0.5,
|
|
189
|
+
timeout: 120_000,
|
|
190
|
+
label: "memory-sync-e2e",
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
messages = runResult.messages;
|
|
194
|
+
}, 300_000);
|
|
195
|
+
|
|
196
|
+
afterAll(async () => {
|
|
197
|
+
if (workspace) {
|
|
198
|
+
try { cleanupWorkspace(workspace.dir); } catch { /* best effort */ }
|
|
199
|
+
}
|
|
200
|
+
if (mockServer) {
|
|
201
|
+
try { await mockServer.stop(); } catch { /* best effort */ }
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("agent completed without error", () => {
|
|
206
|
+
expect(runResult.exitCode).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("agent used minimem MCP tools", () => {
|
|
210
|
+
const { extractToolCalls } = require("./helpers/assertions.mjs");
|
|
211
|
+
const allCalls = extractToolCalls(messages);
|
|
212
|
+
const memCalls = allCalls.filter((c) =>
|
|
213
|
+
c.name?.includes("minimem") || c.name?.includes("memory_search")
|
|
214
|
+
);
|
|
215
|
+
expect(memCalls.length).toBeGreaterThan(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("agent wrote a memory file", () => {
|
|
219
|
+
const { extractToolCalls } = require("./helpers/assertions.mjs");
|
|
220
|
+
const allCalls = extractToolCalls(messages);
|
|
221
|
+
// Agent may use Write tool to create memory file, or minimem append/upsert
|
|
222
|
+
const writeCalls = allCalls.filter(
|
|
223
|
+
(c) => c.name === "Write" || c.name?.includes("append") || c.name?.includes("upsert")
|
|
224
|
+
);
|
|
225
|
+
expect(writeCalls.length).toBeGreaterThan(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("memory file was written to disk", () => {
|
|
229
|
+
const minimemDir = join(workspace.dir, ".swarm", "minimem");
|
|
230
|
+
const memoryDir = join(minimemDir, "memory");
|
|
231
|
+
if (!existsSync(memoryDir)) return; // skip if dir not created
|
|
232
|
+
const mdFiles = readdirSync(memoryDir).filter((f) => f.endsWith(".md"));
|
|
233
|
+
expect(mdFiles.length).toBeGreaterThan(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("sidecar file watcher sent MAP sync notification", async () => {
|
|
237
|
+
// The sidecar's memory file watcher detects .md file writes and sends
|
|
238
|
+
// bridge-memory-sync → callExtension("x-openhive/memory.sync").
|
|
239
|
+
// This covers both Write tool and minimem MCP tool writes.
|
|
240
|
+
//
|
|
241
|
+
// The watcher has a 2s debounce, so we wait up to 10s for the event.
|
|
242
|
+
const received = await waitFor(() => {
|
|
243
|
+
const extCalls = mockServer.getByMethod("x-openhive/memory.sync");
|
|
244
|
+
if (extCalls.length > 0) return true;
|
|
245
|
+
const broadcasts = mockServer.getMessages("memory.sync");
|
|
246
|
+
if (broadcasts.length > 0) return true;
|
|
247
|
+
// Check all received messages for any memory-related content
|
|
248
|
+
const all = mockServer.receivedMessages || [];
|
|
249
|
+
return all.some(
|
|
250
|
+
(m) =>
|
|
251
|
+
JSON.stringify(m).includes("memory") &&
|
|
252
|
+
JSON.stringify(m).includes("sync")
|
|
253
|
+
);
|
|
254
|
+
}, 10_000);
|
|
255
|
+
|
|
256
|
+
expect(received).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
);
|
package/hooks/hooks.json
CHANGED
|
@@ -62,6 +62,15 @@
|
|
|
62
62
|
}
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
|
+
{
|
|
66
|
+
"matcher": "minimem",
|
|
67
|
+
"hooks": [
|
|
68
|
+
{
|
|
69
|
+
"type": "command",
|
|
70
|
+
"command": "node -e \"const f=require('fs'),p=require('path'),h=require('os').homedir();let c={};try{c=JSON.parse(f.readFileSync('.swarm/claude-swarm/config.json','utf-8'))}catch{try{c=JSON.parse(f.readFileSync(p.join(h,'.claude-swarm','config.json'),'utf-8'))}catch{}};process.exit((c.minimem?.enabled||process.env.SWARM_MINIMEM_ENABLED)&&(c.map?.enabled||c.map?.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED)?0:1)\" 2>/dev/null && node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" minimem-mcp-used"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
65
74
|
{
|
|
66
75
|
"matcher": "Task",
|
|
67
76
|
"hooks": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-swarm",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
4
4
|
"description": "Claude Code plugin for launching agent teams from openteams topologies with MAP observability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"swarm-generate-agents": "./scripts/generate-agents.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@multi-agent-protocol/sdk": "^0.1.
|
|
21
|
+
"@multi-agent-protocol/sdk": "^0.1.9",
|
|
22
22
|
"agentic-mesh": "^0.2.0",
|
|
23
23
|
"js-yaml": "^4.1.0"
|
|
24
24
|
},
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"agent-inbox": "^0.1.9",
|
|
57
|
-
"minimem": "^0.1.
|
|
57
|
+
"minimem": "^0.1.1",
|
|
58
58
|
"opentasks": "^0.0.8",
|
|
59
59
|
"skill-tree": "^0.1.5",
|
|
60
60
|
"vitest": "^4.0.18",
|
package/scripts/map-hook.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
buildOpentasksBridgeCommands,
|
|
49
49
|
handleNativeTaskCreatedEvent,
|
|
50
50
|
handleNativeTaskUpdatedEvent,
|
|
51
|
+
buildMinimemBridgeCommand,
|
|
51
52
|
} from "../src/map-events.mjs";
|
|
52
53
|
import { syncSessionlog, dispatchSessionlogHook } from "../src/sessionlog.mjs";
|
|
53
54
|
import { findSocketPath, pushSyncEvent } from "../src/opentasks-client.mjs";
|
|
@@ -194,6 +195,18 @@ async function handleSessionlogDispatch(hookData) {
|
|
|
194
195
|
await dispatchSessionlogHook(sessionlogHookName, hookData);
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
// ── minimem sync ─────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async function handleMinimemMcpUsed(hookData, sessionId) {
|
|
201
|
+
const config = readConfig();
|
|
202
|
+
if (!config.minimem?.enabled) return;
|
|
203
|
+
|
|
204
|
+
const command = buildMinimemBridgeCommand(hookData);
|
|
205
|
+
if (command) {
|
|
206
|
+
await sendCommand(config, command, sessionId);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
197
210
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
198
211
|
|
|
199
212
|
async function main() {
|
|
@@ -214,6 +227,7 @@ async function main() {
|
|
|
214
227
|
case "opentasks-mcp-used": await handleOpentasksMcpUsed(hookData, sessionId); break;
|
|
215
228
|
case "native-task-created": await handleNativeTaskCreated(hookData, sessionId); break;
|
|
216
229
|
case "native-task-updated": await handleNativeTaskUpdated(hookData, sessionId); break;
|
|
230
|
+
case "minimem-mcp-used": await handleMinimemMcpUsed(hookData, sessionId); break;
|
|
217
231
|
case "sessionlog-dispatch": await handleSessionlogDispatch(hookData); break;
|
|
218
232
|
default:
|
|
219
233
|
log.warn("unknown action", { action });
|
package/scripts/map-sidecar.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { connectToMAP } from "../src/map-connection.mjs";
|
|
|
25
25
|
import { createMeshPeer, createMeshInbox } from "../src/mesh-connection.mjs";
|
|
26
26
|
import { createSocketServer, createCommandHandler } from "../src/sidecar-server.mjs";
|
|
27
27
|
import { createContentProvider } from "../src/content-provider.mjs";
|
|
28
|
+
import { startMemoryWatcher } from "../src/memory-watcher.mjs";
|
|
28
29
|
import { readConfig } from "../src/config.mjs";
|
|
29
30
|
import { createLogger, init as initLog } from "../src/log.mjs";
|
|
30
31
|
import { configureNodePath, resolvePackage } from "../src/swarmkit-resolver.mjs";
|
|
@@ -517,6 +518,31 @@ async function main() {
|
|
|
517
518
|
return commandHandler(command, client);
|
|
518
519
|
});
|
|
519
520
|
|
|
521
|
+
// Start memory file watcher if minimem is enabled
|
|
522
|
+
const sidecarConfig = readConfig();
|
|
523
|
+
if (sidecarConfig.minimem?.enabled) {
|
|
524
|
+
const minimemDir = sidecarConfig.minimem?.dir || ".swarm/minimem";
|
|
525
|
+
const memWatcher = startMemoryWatcher(minimemDir, (_event) => {
|
|
526
|
+
// Send bridge-memory-sync through the command handler
|
|
527
|
+
// This reuses the same callExtension path as the PostToolUse hook
|
|
528
|
+
const fakeClient = {
|
|
529
|
+
write: () => {},
|
|
530
|
+
writable: true,
|
|
531
|
+
};
|
|
532
|
+
commandHandler({
|
|
533
|
+
action: "bridge-memory-sync",
|
|
534
|
+
agentId: SESSION_ID || "minimem",
|
|
535
|
+
timestamp: new Date().toISOString(),
|
|
536
|
+
}, fakeClient);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Clean up watcher on exit
|
|
540
|
+
if (memWatcher) {
|
|
541
|
+
process.on("exit", () => memWatcher.close());
|
|
542
|
+
process.on("SIGTERM", () => memWatcher.close());
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
520
546
|
// Start inactivity timer
|
|
521
547
|
resetInactivityTimer();
|
|
522
548
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for minimem → MAP sync bridge
|
|
3
|
+
*
|
|
4
|
+
* Tests the bridge command builder (map-events.mjs) that converts
|
|
5
|
+
* minimem MCP tool usage into MAP sync commands.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import { buildMinimemBridgeCommand } from "../map-events.mjs";
|
|
10
|
+
|
|
11
|
+
describe("buildMinimemBridgeCommand", () => {
|
|
12
|
+
describe("write operations (should emit)", () => {
|
|
13
|
+
it("emits for memory_append tool", () => {
|
|
14
|
+
const cmd = buildMinimemBridgeCommand({
|
|
15
|
+
tool_name: "minimem__memory_append",
|
|
16
|
+
tool_input: { text: "Decided to use Redis" },
|
|
17
|
+
tool_output: '{"content":[{"text":"Appended to memory/2026-03-27.md"}]}',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(cmd).not.toBeNull();
|
|
21
|
+
expect(cmd.action).toBe("bridge-memory-sync");
|
|
22
|
+
expect(cmd.timestamp).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("emits for memory_upsert tool", () => {
|
|
26
|
+
const cmd = buildMinimemBridgeCommand({
|
|
27
|
+
tool_name: "minimem__memory_upsert",
|
|
28
|
+
tool_input: { path: "memory/decision.md", content: "# Decision" },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(cmd).not.toBeNull();
|
|
32
|
+
expect(cmd.action).toBe("bridge-memory-sync");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("emits for tool names containing 'append'", () => {
|
|
36
|
+
const cmd = buildMinimemBridgeCommand({
|
|
37
|
+
tool_name: "minimem__appendToday",
|
|
38
|
+
tool_input: { text: "some note" },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(cmd).not.toBeNull();
|
|
42
|
+
expect(cmd.action).toBe("bridge-memory-sync");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("emits for tool names containing 'upsert'", () => {
|
|
46
|
+
const cmd = buildMinimemBridgeCommand({
|
|
47
|
+
tool_name: "minimem__upsert_file",
|
|
48
|
+
tool_input: { path: "memory/test.md" },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(cmd).not.toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("includes session_id as agentId", () => {
|
|
55
|
+
const cmd = buildMinimemBridgeCommand({
|
|
56
|
+
tool_name: "minimem__memory_append",
|
|
57
|
+
tool_input: { text: "test" },
|
|
58
|
+
session_id: "sess-abc-123",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(cmd.agentId).toBe("sess-abc-123");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("defaults agentId to 'minimem' when no session_id", () => {
|
|
65
|
+
const cmd = buildMinimemBridgeCommand({
|
|
66
|
+
tool_name: "minimem__memory_append",
|
|
67
|
+
tool_input: { text: "test" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(cmd.agentId).toBe("minimem");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("read operations (should NOT emit)", () => {
|
|
75
|
+
it("does not emit for memory_search", () => {
|
|
76
|
+
const cmd = buildMinimemBridgeCommand({
|
|
77
|
+
tool_name: "minimem__memory_search",
|
|
78
|
+
tool_input: { query: "redis caching" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(cmd).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not emit for memory_get_details", () => {
|
|
85
|
+
const cmd = buildMinimemBridgeCommand({
|
|
86
|
+
tool_name: "minimem__memory_get_details",
|
|
87
|
+
tool_input: { results: [] },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(cmd).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not emit for knowledge_search", () => {
|
|
94
|
+
const cmd = buildMinimemBridgeCommand({
|
|
95
|
+
tool_name: "minimem__knowledge_search",
|
|
96
|
+
tool_input: { query: "database" },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(cmd).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not emit for knowledge_graph", () => {
|
|
103
|
+
const cmd = buildMinimemBridgeCommand({
|
|
104
|
+
tool_name: "minimem__knowledge_graph",
|
|
105
|
+
tool_input: { nodeId: "k-test" },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(cmd).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not emit for knowledge_path", () => {
|
|
112
|
+
const cmd = buildMinimemBridgeCommand({
|
|
113
|
+
tool_name: "minimem__knowledge_path",
|
|
114
|
+
tool_input: { fromId: "k-a", toId: "k-b" },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(cmd).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("edge cases", () => {
|
|
122
|
+
it("handles missing tool_name", () => {
|
|
123
|
+
const cmd = buildMinimemBridgeCommand({});
|
|
124
|
+
expect(cmd).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("handles empty tool_name", () => {
|
|
128
|
+
const cmd = buildMinimemBridgeCommand({ tool_name: "" });
|
|
129
|
+
expect(cmd).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for memory file watcher
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import { startMemoryWatcher } from "../memory-watcher.mjs";
|
|
10
|
+
|
|
11
|
+
describe("startMemoryWatcher", () => {
|
|
12
|
+
let tmpDir;
|
|
13
|
+
let watcher;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (watcher) {
|
|
17
|
+
watcher.close();
|
|
18
|
+
watcher = null;
|
|
19
|
+
}
|
|
20
|
+
if (tmpDir) {
|
|
21
|
+
try { rmSync(tmpDir, { recursive: true }); } catch {}
|
|
22
|
+
tmpDir = null;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns null for non-existent directory", () => {
|
|
27
|
+
const result = startMemoryWatcher("/nonexistent/path", () => {});
|
|
28
|
+
expect(result).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null for empty/undefined dir", () => {
|
|
32
|
+
expect(startMemoryWatcher("", () => {})).toBeNull();
|
|
33
|
+
expect(startMemoryWatcher(undefined, () => {})).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns a watcher handle with close method", () => {
|
|
37
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
38
|
+
mkdirSync(join(tmpDir, "memory"), { recursive: true });
|
|
39
|
+
|
|
40
|
+
watcher = startMemoryWatcher(tmpDir, () => {});
|
|
41
|
+
expect(watcher).not.toBeNull();
|
|
42
|
+
expect(typeof watcher.close).toBe("function");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("detects new .md file and calls onSync", async () => {
|
|
46
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
47
|
+
mkdirSync(join(tmpDir, "memory"), { recursive: true });
|
|
48
|
+
|
|
49
|
+
const onSync = vi.fn();
|
|
50
|
+
watcher = startMemoryWatcher(tmpDir, onSync);
|
|
51
|
+
|
|
52
|
+
// Wait for watcher to be ready
|
|
53
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
54
|
+
|
|
55
|
+
// Write a new .md file
|
|
56
|
+
writeFileSync(join(tmpDir, "memory", "test-note.md"), "# Test Note\nContent here.");
|
|
57
|
+
|
|
58
|
+
// Wait for debounce (2s) + buffer
|
|
59
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
60
|
+
|
|
61
|
+
expect(onSync).toHaveBeenCalled();
|
|
62
|
+
const call = onSync.mock.calls[0][0];
|
|
63
|
+
expect(call.type).toBe("add");
|
|
64
|
+
expect(call.path).toContain("test-note.md");
|
|
65
|
+
}, 10_000);
|
|
66
|
+
|
|
67
|
+
it("ignores non-.md files", async () => {
|
|
68
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
69
|
+
|
|
70
|
+
const onSync = vi.fn();
|
|
71
|
+
watcher = startMemoryWatcher(tmpDir, onSync);
|
|
72
|
+
|
|
73
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
74
|
+
|
|
75
|
+
// Write non-.md files
|
|
76
|
+
writeFileSync(join(tmpDir, "index.db"), "binary data");
|
|
77
|
+
writeFileSync(join(tmpDir, "config.json"), "{}");
|
|
78
|
+
|
|
79
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
80
|
+
|
|
81
|
+
expect(onSync).not.toHaveBeenCalled();
|
|
82
|
+
}, 10_000);
|
|
83
|
+
|
|
84
|
+
it("debounces rapid changes", async () => {
|
|
85
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
|
|
86
|
+
mkdirSync(join(tmpDir, "memory"), { recursive: true });
|
|
87
|
+
|
|
88
|
+
const onSync = vi.fn();
|
|
89
|
+
watcher = startMemoryWatcher(tmpDir, onSync);
|
|
90
|
+
|
|
91
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
92
|
+
|
|
93
|
+
// Write multiple files rapidly
|
|
94
|
+
writeFileSync(join(tmpDir, "memory", "note1.md"), "# Note 1");
|
|
95
|
+
writeFileSync(join(tmpDir, "memory", "note2.md"), "# Note 2");
|
|
96
|
+
writeFileSync(join(tmpDir, "memory", "note3.md"), "# Note 3");
|
|
97
|
+
|
|
98
|
+
// Wait for debounce
|
|
99
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
100
|
+
|
|
101
|
+
// Should only fire once (debounced)
|
|
102
|
+
expect(onSync).toHaveBeenCalledTimes(1);
|
|
103
|
+
}, 10_000);
|
|
104
|
+
});
|
package/src/bootstrap.mjs
CHANGED
|
@@ -431,12 +431,37 @@ export async function bootstrap(pluginDirOverride, sessionId) {
|
|
|
431
431
|
|
|
432
432
|
let minimemStatus = "disabled";
|
|
433
433
|
if (config.minimem?.enabled) {
|
|
434
|
-
|
|
434
|
+
// Check if minimem CLI is available and memory directory exists
|
|
435
|
+
try {
|
|
436
|
+
const { execFileSync } = await import("node:child_process");
|
|
437
|
+
const { existsSync } = await import("node:fs");
|
|
438
|
+
execFileSync("which", ["minimem"], { stdio: "pipe" });
|
|
439
|
+
const dir = config.minimem?.dir || ".swarm/minimem";
|
|
440
|
+
if (existsSync(dir)) {
|
|
441
|
+
minimemStatus = "ready";
|
|
442
|
+
} else {
|
|
443
|
+
// Create the directory and initialize so MCP server can start
|
|
444
|
+
const { mkdirSync } = await import("node:fs");
|
|
445
|
+
mkdirSync(dir + "/memory", { recursive: true });
|
|
446
|
+
try {
|
|
447
|
+
execFileSync("minimem", ["init", dir], { stdio: "pipe", timeout: 10_000 });
|
|
448
|
+
} catch { /* init may fail if already initialized */ }
|
|
449
|
+
minimemStatus = "ready";
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
minimemStatus = "enabled"; // CLI not found — MCP server won't start
|
|
453
|
+
}
|
|
435
454
|
}
|
|
436
455
|
|
|
437
456
|
let skilltreeStatus = "disabled";
|
|
438
457
|
if (config.skilltree?.enabled) {
|
|
439
|
-
|
|
458
|
+
try {
|
|
459
|
+
const { execFileSync } = await import("node:child_process");
|
|
460
|
+
execFileSync("which", ["skill-tree"], { stdio: "pipe" });
|
|
461
|
+
skilltreeStatus = "ready";
|
|
462
|
+
} catch {
|
|
463
|
+
skilltreeStatus = "enabled";
|
|
464
|
+
}
|
|
440
465
|
}
|
|
441
466
|
|
|
442
467
|
return {
|
package/src/map-events.mjs
CHANGED
|
@@ -443,3 +443,33 @@ export async function handleNativeTaskUpdatedEvent(config, hookData, sessionId)
|
|
|
443
443
|
}, sessionId);
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
|
+
|
|
447
|
+
// ── minimem/skill-tree sync → MAP ──────────────────────────────────────────────
|
|
448
|
+
//
|
|
449
|
+
// When agents use minimem MCP tools that write data (append, upsert),
|
|
450
|
+
// emit x-openhive/memory.sync so OpenHive can update its content cache.
|
|
451
|
+
// This is a best-effort notification — the source of truth is the filesystem.
|
|
452
|
+
|
|
453
|
+
/** Tools that indicate a memory write (vs read-only search) */
|
|
454
|
+
const MINIMEM_WRITE_TOOLS = new Set([
|
|
455
|
+
"minimem__memory_append",
|
|
456
|
+
"minimem__memory_upsert",
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Build bridge command for minimem MCP tool use.
|
|
461
|
+
* Only emits for write operations (append/upsert), not reads.
|
|
462
|
+
*/
|
|
463
|
+
export function buildMinimemBridgeCommand(hookData) {
|
|
464
|
+
const toolName = hookData.tool_name || "";
|
|
465
|
+
// Only emit for write operations
|
|
466
|
+
if (!MINIMEM_WRITE_TOOLS.has(toolName) && !toolName.includes("append") && !toolName.includes("upsert")) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
action: "bridge-memory-sync",
|
|
472
|
+
agentId: hookData.session_id || "minimem",
|
|
473
|
+
timestamp: new Date().toISOString(),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-watcher.mjs — Watches the minimem memory directory for file changes
|
|
3
|
+
* and sends bridge-memory-sync commands to notify OpenHive via MAP.
|
|
4
|
+
*
|
|
5
|
+
* This bridges the gap between filesystem writes (Write tool, manual edits)
|
|
6
|
+
* and MAP sync notifications. minimem's MCP tools are read-only, so this
|
|
7
|
+
* watcher is the only way to detect when an agent writes to memory.
|
|
8
|
+
*
|
|
9
|
+
* Runs inside the MAP sidecar process (persistent for the session).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import chokidar from "chokidar";
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { createLogger } from "./log.mjs";
|
|
15
|
+
|
|
16
|
+
const log = createLogger("memory-watcher");
|
|
17
|
+
|
|
18
|
+
const DEBOUNCE_MS = 2000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start watching a minimem directory for file changes.
|
|
22
|
+
* When changes are detected (debounced), calls the provided callback.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} memoryDir - Path to the minimem directory (e.g., ".swarm/minimem")
|
|
25
|
+
* @param {(event: { type: string; path: string }) => void} onSync - Called when sync should be emitted
|
|
26
|
+
* @returns {{ close: () => void } | null} Watcher handle, or null if directory doesn't exist
|
|
27
|
+
*/
|
|
28
|
+
export function startMemoryWatcher(memoryDir, onSync) {
|
|
29
|
+
if (!memoryDir || !existsSync(memoryDir)) {
|
|
30
|
+
log.debug("memory watcher skipped — directory not found", { dir: memoryDir });
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let debounceTimer = null;
|
|
35
|
+
|
|
36
|
+
const watcher = chokidar.watch(memoryDir, {
|
|
37
|
+
ignoreInitial: true,
|
|
38
|
+
ignored: [/node_modules/, /\.git/, /index\.db/, /\.cache/, /\.minimem/],
|
|
39
|
+
depth: 3,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function debouncedSync(eventType, filePath) {
|
|
43
|
+
// Only react to .md file changes
|
|
44
|
+
if (!filePath.endsWith(".md")) return;
|
|
45
|
+
|
|
46
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
47
|
+
debounceTimer = setTimeout(() => {
|
|
48
|
+
debounceTimer = null;
|
|
49
|
+
log.debug("memory change detected", { event: eventType, path: filePath });
|
|
50
|
+
onSync({ type: eventType, path: filePath });
|
|
51
|
+
}, DEBOUNCE_MS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
watcher.on("add", (p) => debouncedSync("add", p));
|
|
55
|
+
watcher.on("change", (p) => debouncedSync("change", p));
|
|
56
|
+
watcher.on("unlink", (p) => debouncedSync("unlink", p));
|
|
57
|
+
|
|
58
|
+
watcher.on("ready", () => {
|
|
59
|
+
log.info("memory watcher started", { dir: memoryDir });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
watcher.on("error", (err) => {
|
|
63
|
+
log.warn("memory watcher error", { error: err.message });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
close() {
|
|
68
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
69
|
+
watcher.close();
|
|
70
|
+
log.debug("memory watcher stopped");
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/sidecar-server.mjs
CHANGED
|
@@ -95,6 +95,7 @@ export function createCommandHandler(connection, scope, registeredAgents, opts =
|
|
|
95
95
|
// Use a getter pattern so the connection ref can be updated
|
|
96
96
|
let conn = connection;
|
|
97
97
|
let _trajectoryResourceId = null; // Cached resource_id from server response
|
|
98
|
+
let _memoryResourceId = null; // Cached resource_id for memory sync
|
|
98
99
|
const { inboxInstance, meshPeer, transportMode = "websocket" } = opts;
|
|
99
100
|
const useMeshRegistry = transportMode === "mesh" && inboxInstance;
|
|
100
101
|
|
|
@@ -389,6 +390,43 @@ export function createCommandHandler(connection, scope, registeredAgents, opts =
|
|
|
389
390
|
break;
|
|
390
391
|
}
|
|
391
392
|
|
|
393
|
+
// --- Memory/skill sync → MAP (x-openhive vendor extensions) ---
|
|
394
|
+
|
|
395
|
+
case "bridge-memory-sync": {
|
|
396
|
+
const c = conn || await waitForConn();
|
|
397
|
+
if (c) {
|
|
398
|
+
try {
|
|
399
|
+
// Use callExtension for JSON-RPC vendor-prefixed method
|
|
400
|
+
// Same pattern as trajectory/checkpoint
|
|
401
|
+
const params = {
|
|
402
|
+
resource_id: _memoryResourceId || "",
|
|
403
|
+
agent_id: command.agentId || "minimem",
|
|
404
|
+
commit_hash: `memory-${Date.now()}`,
|
|
405
|
+
timestamp: command.timestamp || new Date().toISOString(),
|
|
406
|
+
};
|
|
407
|
+
const result = await c.callExtension("x-openhive/memory.sync", params);
|
|
408
|
+
// Cache resource_id from server response
|
|
409
|
+
if (result?.resource_id) {
|
|
410
|
+
_memoryResourceId = result.resource_id;
|
|
411
|
+
}
|
|
412
|
+
respond(client, { ok: true, method: "memory-sync", resource_id: result?.resource_id });
|
|
413
|
+
} catch (err) {
|
|
414
|
+
log.warn("x-openhive/memory.sync not supported, falling back to broadcast", { error: err.message });
|
|
415
|
+
// Fallback: emit as a regular message
|
|
416
|
+
await c.send({ scope }, {
|
|
417
|
+
type: "memory.sync",
|
|
418
|
+
agent_id: command.agentId || "minimem",
|
|
419
|
+
timestamp: command.timestamp,
|
|
420
|
+
_origin: command.agentId || "minimem",
|
|
421
|
+
}, { relationship: "broadcast" });
|
|
422
|
+
respond(client, { ok: true, method: "broadcast-fallback" });
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
respond(client, { ok: false, error: "no connection" });
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
|
|
392
430
|
case "state": {
|
|
393
431
|
const c = conn || await waitForConn();
|
|
394
432
|
if (c) {
|