claude-code-swarm 0.3.16 → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "Launch Claude Code with swarmkit capabilities, including team orchestration, MAP observability, and session tracking.",
5
5
  "owner": {
6
6
  "name": "alexngai"
@@ -162,12 +162,25 @@ if (!def.enabled()) {
162
162
  runNoop();
163
163
  } else {
164
164
  const { cmd, args } = def.launch();
165
- if (which(cmd)) {
166
- // Replace this process with the real server
167
- const { execFileSync: _ , ...rest } = await import('node:child_process');
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.16",
4
+ "version": "0.3.18",
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.16",
3
+ "version": "0.3.18",
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.8",
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.0",
57
+ "minimem": "^0.1.1",
58
58
  "opentasks": "^0.0.8",
59
59
  "skill-tree": "^0.1.5",
60
60
  "vitest": "^4.0.18",
@@ -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 });
@@ -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,30 @@ 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
+ const fakeClient = {
527
+ write: () => {},
528
+ writable: true,
529
+ };
530
+ commandHandler({
531
+ action: "bridge-memory-sync",
532
+ agentId: SESSION_ID || "minimem",
533
+ timestamp: new Date().toISOString(),
534
+ memoryDir: minimemDir,
535
+ }, fakeClient);
536
+ });
537
+
538
+ // Clean up watcher on exit
539
+ if (memWatcher) {
540
+ process.on("exit", () => memWatcher.close());
541
+ process.on("SIGTERM", () => memWatcher.close());
542
+ }
543
+ }
544
+
520
545
  // Start inactivity timer
521
546
  resetInactivityTimer();
522
547
 
@@ -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
- minimemStatus = "enabled";
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
- skilltreeStatus = "enabled";
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 {
@@ -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
+ }
@@ -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
+ const params = {
400
+ resource_id: _memoryResourceId || "",
401
+ agent_id: command.agentId || "minimem",
402
+ commit_hash: `memory-${Date.now()}`,
403
+ timestamp: command.timestamp || new Date().toISOString(),
404
+ // Include the memory directory path for resource resolution on first call
405
+ path: command.memoryDir || "",
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) {