fathom-mcp 0.4.5 → 0.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,72 @@
1
+ #!/bin/bash
2
+ # Fathom SessionStart hook — version check on session startup.
3
+ # Compares local .fathom/version against npm registry and injects update notice.
4
+ #
5
+ # Output: JSON with hookSpecificOutput.additionalContext (update notice or empty).
6
+ # Graceful failure: if npm unreachable or version file missing, skip silently.
7
+
8
+ set -o pipefail
9
+
10
+ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
11
+ TOAST="$HOOK_DIR/hook-toast.sh"
12
+
13
+ # Consume stdin (SessionStart sends JSON we don't need)
14
+ cat > /dev/null
15
+
16
+ # Walk up to find .fathom.json
17
+ find_config() {
18
+ local dir="$PWD"
19
+ while [ "$dir" != "/" ]; do
20
+ if [ -f "$dir/.fathom.json" ]; then
21
+ echo "$dir"
22
+ return 0
23
+ fi
24
+ dir="$(dirname "$dir")"
25
+ done
26
+ return 1
27
+ }
28
+
29
+ PROJECT_DIR=$(find_config 2>/dev/null) || exit 0
30
+
31
+ VERSION_FILE="$PROJECT_DIR/.fathom/version"
32
+
33
+ # If no version file, skip silently — init hasn't been run with this version yet
34
+ if [ ! -f "$VERSION_FILE" ]; then
35
+ exit 0
36
+ fi
37
+
38
+ LOCAL_VERSION=$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')
39
+ if [ -z "$LOCAL_VERSION" ]; then
40
+ exit 0
41
+ fi
42
+
43
+ # Check npm registry for latest version (2s timeout)
44
+ LATEST_VERSION=$(curl -s --max-time 2 "https://registry.npmjs.org/fathom-mcp/latest" 2>/dev/null \
45
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('version',''))" 2>/dev/null || echo "")
46
+
47
+ # If npm check failed, skip silently
48
+ if [ -z "$LATEST_VERSION" ]; then
49
+ "$TOAST" fathom "✓ Fathom v${LOCAL_VERSION}" &>/dev/null
50
+ exit 0
51
+ fi
52
+
53
+ # Compare versions
54
+ if [ "$LOCAL_VERSION" != "$LATEST_VERSION" ]; then
55
+ # Update available — inject notice and show toast
56
+ "$TOAST" fathom "⬆ Fathom v${LATEST_VERSION} available" &>/dev/null
57
+
58
+ python3 -c "
59
+ import json
60
+ print(json.dumps({
61
+ 'hookSpecificOutput': {
62
+ 'hookEventName': 'SessionStart',
63
+ 'additionalContext': 'Fathom update available: v${LOCAL_VERSION} → v${LATEST_VERSION}. Run: npx fathom-mcp update'
64
+ }
65
+ }))
66
+ "
67
+ else
68
+ # Up to date — toast only, no context injection
69
+ "$TOAST" fathom "✓ Fathom v${LOCAL_VERSION}" &>/dev/null
70
+ fi
71
+
72
+ exit 0
package/src/cli.js CHANGED
@@ -7,14 +7,16 @@
7
7
  * npx fathom-mcp — Start MCP server (stdio, for .mcp.json)
8
8
  * npx fathom-mcp init — Interactive setup wizard
9
9
  * npx fathom-mcp status — Check server connection + workspace status
10
+ * npx fathom-mcp update — Update hook scripts + version file
10
11
  */
11
12
 
12
13
  import fs from "fs";
13
14
  import path from "path";
14
15
  import readline from "readline";
16
+ import { execFileSync } from "child_process";
15
17
  import { fileURLToPath } from "url";
16
18
 
17
- import { resolveConfig, writeConfig } from "./config.js";
19
+ import { findConfigFile, resolveConfig, writeConfig } from "./config.js";
18
20
  import { createClient } from "./server-client.js";
19
21
 
20
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -105,6 +107,90 @@ function copyScripts(targetDir) {
105
107
  }
106
108
  }
107
109
 
110
+ // --- Headless agent integration ----------------------------------------------
111
+
112
+ const HEADLESS_CMDS = {
113
+ "claude-code": (prompt) => ["claude", "-p", prompt],
114
+ "codex": (prompt) => ["codex", "exec", prompt],
115
+ "gemini": (prompt) => ["gemini", prompt],
116
+ "opencode": (prompt) => ["opencode", "run", prompt],
117
+ };
118
+
119
+ function buildIntegrationPrompt(blob) {
120
+ return [
121
+ "The following instructions were generated by fathom-mcp init for this project.",
122
+ "Add them to the file where you store persistent behavioral instructions",
123
+ "(e.g. CLAUDE.md for Claude Code). If the file exists, read it first and",
124
+ "integrate the new section without removing existing content. If a section",
125
+ "with the same heading already exists, replace it. If no instructions file",
126
+ "exists yet, create one.",
127
+ "",
128
+ "--- INSTRUCTIONS ---",
129
+ blob,
130
+ "--- END ---",
131
+ ].join("\n");
132
+ }
133
+
134
+ /**
135
+ * Check if fathom-server is available on PATH.
136
+ * Returns "installed" or "not-found".
137
+ */
138
+ function detectFathomServer() {
139
+ try {
140
+ execFileSync("which", ["fathom-server"], { stdio: "pipe" });
141
+ return "installed";
142
+ } catch {
143
+ return "not-found";
144
+ }
145
+ }
146
+
147
+ function runAgentHeadless(agentKey, prompt) {
148
+ const cmdBuilder = HEADLESS_CMDS[agentKey];
149
+ if (!cmdBuilder) return null;
150
+ const [cmd, ...args] = cmdBuilder(prompt);
151
+ try {
152
+ const result = execFileSync(cmd, args, {
153
+ cwd: process.cwd(),
154
+ encoding: "utf8",
155
+ stdio: ["pipe", "pipe", "inherit"],
156
+ timeout: 60000,
157
+ });
158
+ return result;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ // --- CLI flag parsing --------------------------------------------------------
165
+
166
+ function parseFlags(argv) {
167
+ const flags = {
168
+ nonInteractive: false,
169
+ apiKey: null,
170
+ server: null,
171
+ workspace: null,
172
+ };
173
+ for (let i = 0; i < argv.length; i++) {
174
+ if (argv[i] === "-y" || argv[i] === "--yes") {
175
+ flags.nonInteractive = true;
176
+ } else if (argv[i] === "--api-key" && argv[i + 1]) {
177
+ flags.apiKey = argv[i + 1];
178
+ i++;
179
+ } else if (argv[i] === "--server" && argv[i + 1]) {
180
+ flags.server = argv[i + 1];
181
+ i++;
182
+ } else if (argv[i] === "--workspace" && argv[i + 1]) {
183
+ flags.workspace = argv[i + 1];
184
+ i++;
185
+ }
186
+ }
187
+ // Check environment variables as fallback
188
+ if (!flags.apiKey && process.env.FATHOM_API_KEY) {
189
+ flags.apiKey = process.env.FATHOM_API_KEY;
190
+ }
191
+ return flags;
192
+ }
193
+
108
194
  // --- Agent registry ----------------------------------------------------------
109
195
 
110
196
  const MCP_SERVER_ENTRY = {
@@ -208,9 +294,18 @@ export { AGENTS, writeMcpJson, writeCodexToml, writeGeminiJson, writeOpencodeJso
208
294
 
209
295
  // --- Init wizard -------------------------------------------------------------
210
296
 
211
- async function runInit() {
297
+ async function runInit(flags = {}) {
298
+ const {
299
+ nonInteractive = false,
300
+ apiKey: flagApiKey = null,
301
+ server: flagServer = null,
302
+ workspace: flagWorkspace = null,
303
+ } = flags;
212
304
  const cwd = process.cwd();
213
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
305
+
306
+ const rl = nonInteractive
307
+ ? null
308
+ : readline.createInterface({ input: process.stdin, output: process.stdout });
214
309
 
215
310
  console.log(`
216
311
  ▐▘ ▗ ▌
@@ -221,83 +316,152 @@ async function runInit() {
221
316
  hifathom.com · fathom@myrakrusemark.com
222
317
  `);
223
318
 
319
+ // Non-interactive: require API key
320
+ if (nonInteractive && !flagApiKey) {
321
+ console.error(" Error: --api-key required in non-interactive mode.");
322
+ process.exit(1);
323
+ }
324
+
224
325
  // Check for existing config in *this* directory only (don't walk up —
225
326
  // a parent's .fathom.json belongs to a different workspace)
226
327
  const localConfigPath = path.join(cwd, ".fathom.json");
227
328
  if (fs.existsSync(localConfigPath)) {
228
- console.log(` Found existing config at: ${localConfigPath}`);
229
- const proceed = await askYesNo(rl, " Overwrite?", false);
230
- if (!proceed) {
231
- console.log(" Aborted.");
232
- rl.close();
233
- process.exit(0);
329
+ if (nonInteractive) {
330
+ console.log(` Overwriting existing config at: ${localConfigPath}`);
331
+ } else {
332
+ console.log(` Found existing config at: ${localConfigPath}`);
333
+ const proceed = await askYesNo(rl, " Overwrite?", false);
334
+ if (!proceed) {
335
+ console.log(" Aborted.");
336
+ rl.close();
337
+ process.exit(0);
338
+ }
234
339
  }
235
340
  }
236
341
 
237
342
  // 1. Workspace name
238
343
  const defaultName = path.basename(cwd);
239
- const workspace = await ask(rl, " Workspace name", defaultName);
240
-
241
- // 2. Vault subdirectory
242
- const vault = await ask(rl, " Vault subdirectory", "vault");
344
+ const workspace = nonInteractive
345
+ ? (flagWorkspace || defaultName)
346
+ : await ask(rl, " Workspace name", defaultName);
243
347
 
244
- // 3. Description (optional)
245
- const description = await ask(rl, " Workspace description (optional)", "");
348
+ // 2. Description (optional)
349
+ const description = nonInteractive ? "" : await ask(rl, " Workspace description (optional)", "");
246
350
 
247
351
  // 4. Agent selection — auto-detect and let user choose
248
352
  const agentKeys = Object.keys(AGENTS);
249
353
  const detected = agentKeys.filter((key) => AGENTS[key].detect(cwd));
250
354
 
251
- console.log("\n Detected agents:");
252
- for (const key of agentKeys) {
253
- const agent = AGENTS[key];
254
- const isDetected = detected.includes(key);
255
- const mark = isDetected ? "✓" : " ";
256
- const markers = { "claude-code": ".claude/", "codex": ".codex/", "gemini": ".gemini/", "opencode": "opencode.json" };
257
- const hint = isDetected ? ` (${markers[key] || key} found)` : "";
258
- console.log(` ${mark} ${agent.name}${hint}`);
259
- }
355
+ let selectedAgents;
356
+ if (nonInteractive) {
357
+ selectedAgents = detected.length > 0 ? [detected[0]] : ["claude-code"];
358
+ console.log(` Agent: ${AGENTS[selectedAgents[0]].name} (auto-detected)`);
359
+ } else {
360
+ console.log("\n Detected agents:");
361
+ for (const key of agentKeys) {
362
+ const agent = AGENTS[key];
363
+ const isDetected = detected.includes(key);
364
+ const mark = isDetected ? "✓" : " ";
365
+ const markers = { "claude-code": ".claude/", "codex": ".codex/", "gemini": ".gemini/", "opencode": "opencode.json" };
366
+ const hint = isDetected ? ` (${markers[key] || key} found)` : "";
367
+ console.log(` ${mark} ${agent.name}${hint}`);
368
+ }
260
369
 
261
- console.log("\n Configure for which agents?");
262
- agentKeys.forEach((key, i) => {
263
- const agent = AGENTS[key];
264
- const mark = detected.includes(key) ? " ✓" : "";
265
- console.log(` ${i + 1}. ${agent.name}${mark}`);
266
- });
370
+ console.log("\n Configure for which agents?");
371
+ agentKeys.forEach((key, i) => {
372
+ const agent = AGENTS[key];
373
+ const mark = detected.includes(key) ? " ✓" : "";
374
+ console.log(` ${i + 1}. ${agent.name}${mark}`);
375
+ });
267
376
 
268
- const defaultSelection = detected.length > 0
269
- ? detected.map((key) => agentKeys.indexOf(key) + 1).join(",")
270
- : "1";
271
- const selectionStr = await ask(rl, "\n Enter numbers, comma-separated", defaultSelection);
377
+ const defaultSelection = detected.length > 0
378
+ ? detected.map((key) => agentKeys.indexOf(key) + 1).join(",")
379
+ : "1";
380
+ const selectionStr = await ask(rl, "\n Enter numbers, comma-separated", defaultSelection);
272
381
 
273
- const selectedIndices = selectionStr
274
- .split(",")
275
- .map((s) => parseInt(s.trim(), 10))
276
- .filter((n) => n >= 1 && n <= agentKeys.length);
277
- const selectedAgents = [...new Set(selectedIndices.map((i) => agentKeys[i - 1]))];
382
+ const selectedIndices = selectionStr
383
+ .split(",")
384
+ .map((s) => parseInt(s.trim(), 10))
385
+ .filter((n) => n >= 1 && n <= agentKeys.length);
386
+ selectedAgents = [...new Set(selectedIndices.map((i) => agentKeys[i - 1]))];
278
387
 
279
- if (selectedAgents.length === 0) {
280
- console.log(" No agents selected. Defaulting to Claude Code.");
281
- selectedAgents.push("claude-code");
388
+ if (selectedAgents.length === 0) {
389
+ console.log(" No agents selected. Defaulting to Claude Code.");
390
+ selectedAgents.push("claude-code");
391
+ }
282
392
  }
283
393
 
284
394
  // 5. Server URL
285
- const serverUrl = await ask(rl, "\n Fathom server URL", "http://localhost:4243");
395
+ const serverUrl = nonInteractive
396
+ ? (flagServer || "http://localhost:4243")
397
+ : await ask(rl, "\n Fathom server URL", "http://localhost:4243");
286
398
 
287
399
  // 6. API key
288
- const apiKey = await ask(rl, " API key (from dashboard or server first-run output)", "");
400
+ const apiKey = flagApiKey || (nonInteractive ? "" : await ask(rl, " API key (from dashboard or server first-run output)", ""));
401
+
402
+ // 7. Server probe — check reachability early
403
+ const regClient = createClient({ server: serverUrl, apiKey, workspace });
404
+ const serverReachable = serverUrl ? await regClient.healthCheck() : false;
405
+ const serverOnPath = detectFathomServer();
406
+
407
+ if (!serverReachable) {
408
+ console.log(`\n ⚠ Fathom server not reachable at ${serverUrl}\n`);
409
+ if (serverOnPath === "installed") {
410
+ console.log(" Start it: fathom-server");
411
+ } else {
412
+ console.log(" Install & start it:");
413
+ console.log(" pip install fathom-server && fathom-server");
414
+ console.log(" # or: docker run -p 4243:4243 ghcr.io/myra/fathom-server");
415
+ }
416
+ console.log("\n Without the server, only \"local\" and \"none\" vault modes are available.");
417
+ }
289
418
 
290
- // 7. Hooks only ask if Claude Code is selected
419
+ // 8. Vault mode selection
420
+ let vaultMode;
421
+ if (nonInteractive) {
422
+ vaultMode = serverReachable ? "hosted" : "local";
423
+ console.log(` Vault mode: ${vaultMode} (auto-selected)`);
424
+ } else {
425
+ if (serverReachable) {
426
+ console.log("\n Vault mode:");
427
+ console.log(" 1. Hosted (default) — vault stored on server, accessible everywhere");
428
+ console.log(" 2. Synced — local vault + server mirror (local is source of truth)");
429
+ console.log(" 3. Local — vault on disk only, not visible to server");
430
+ console.log(" 4. None — no vault, coordination features only");
431
+ const modeChoice = await ask(rl, "\n Select mode", "1");
432
+ const modeMap = { "1": "hosted", "2": "synced", "3": "local", "4": "none" };
433
+ vaultMode = modeMap[modeChoice] || "hosted";
434
+ } else {
435
+ console.log("\n Vault mode (server not available — hosted/synced require server):");
436
+ console.log(" 1. Local (default) — vault on disk only");
437
+ console.log(" 2. None — no vault, coordination features only");
438
+ const modeChoice = await ask(rl, "\n Select mode", "1");
439
+ vaultMode = modeChoice === "2" ? "none" : "local";
440
+ }
441
+ }
442
+
443
+ // Only ask about vault subdirectory if mode needs a local dir
444
+ const needsLocalVault = vaultMode === "synced" || vaultMode === "local";
445
+ const vault = needsLocalVault
446
+ ? (nonInteractive ? "vault" : await ask(rl, " Vault subdirectory", "vault"))
447
+ : "vault";
448
+
449
+ // 9. Hooks — only ask if Claude Code is selected
291
450
  const hasClaude = selectedAgents.includes("claude-code");
292
451
  let enableRecallHook = false;
293
452
  let enablePrecompactHook = false;
294
453
  if (hasClaude) {
295
- console.log();
296
- enableRecallHook = await askYesNo(rl, " Enable vault recall on every message (UserPromptSubmit)?", true);
297
- enablePrecompactHook = await askYesNo(rl, " Enable PreCompact vault snapshot hook?", true);
454
+ if (nonInteractive) {
455
+ enableRecallHook = true;
456
+ enablePrecompactHook = true;
457
+ } else {
458
+ console.log();
459
+ enableRecallHook = await askYesNo(rl, " Enable vault recall on every message (UserPromptSubmit)?", true);
460
+ enablePrecompactHook = await askYesNo(rl, " Enable PreCompact vault snapshot hook?", true);
461
+ }
298
462
  }
299
463
 
300
- rl.close();
464
+ rl?.close();
301
465
 
302
466
  // --- Write files ---
303
467
 
@@ -306,6 +470,7 @@ async function runInit() {
306
470
  // .fathom.json
307
471
  const configData = {
308
472
  workspace,
473
+ vaultMode,
309
474
  vault,
310
475
  server: serverUrl,
311
476
  apiKey,
@@ -326,13 +491,23 @@ async function runInit() {
326
491
  console.log(` ✓ .fathom/scripts/ (${copiedScripts.length} scripts)`);
327
492
  }
328
493
 
329
- // vault/ directory
330
- const vaultDir = path.join(cwd, vault);
331
- if (!fs.existsSync(vaultDir)) {
332
- fs.mkdirSync(vaultDir, { recursive: true });
333
- console.log(` ${vault}/ (created)`);
334
- } else {
335
- console.log(` · ${vault}/ (already exists)`);
494
+ // .fathom/version
495
+ const pkgJsonPath = path.join(__dirname, "..", "package.json");
496
+ const pkg = readJsonFile(pkgJsonPath);
497
+ const packageVersion = pkg?.version || "unknown";
498
+ const fathomDir = path.join(cwd, ".fathom");
499
+ fs.writeFileSync(path.join(fathomDir, "version"), packageVersion + "\n");
500
+ console.log(` .fathom/version (${packageVersion})`);
501
+
502
+ // vault/ directory — only create for synced/local modes
503
+ if (needsLocalVault) {
504
+ const vaultDir = path.join(cwd, vault);
505
+ if (!fs.existsSync(vaultDir)) {
506
+ fs.mkdirSync(vaultDir, { recursive: true });
507
+ console.log(` ✓ ${vault}/ (created)`);
508
+ } else {
509
+ console.log(` · ${vault}/ (already exists)`);
510
+ }
336
511
  }
337
512
 
338
513
  // fathom-agents.md — boilerplate agent instructions
@@ -357,11 +532,26 @@ async function runInit() {
357
532
  }
358
533
 
359
534
  // Claude Code hooks — only if claude-code is selected
360
- if (hasClaude && (enableRecallHook || enablePrecompactHook)) {
535
+ if (hasClaude) {
361
536
  const claudeSettingsPath = path.join(cwd, ".claude", "settings.local.json");
362
537
  const claudeSettings = readJsonFile(claudeSettingsPath) || {};
363
538
 
364
539
  const hooks = {};
540
+
541
+ // SessionStart — always registered (version check should always be on)
542
+ hooks["SessionStart"] = [
543
+ ...(claudeSettings.hooks?.["SessionStart"] || []),
544
+ ];
545
+ const sessionStartCmd = "bash .fathom/scripts/fathom-sessionstart.sh";
546
+ const hasFathomSessionStart = hooks["SessionStart"].some((entry) =>
547
+ entry.hooks?.some((h) => h.command === sessionStartCmd)
548
+ );
549
+ if (!hasFathomSessionStart) {
550
+ hooks["SessionStart"].push({
551
+ hooks: [{ type: "command", command: sessionStartCmd, timeout: 10000 }],
552
+ });
553
+ }
554
+
365
555
  if (enableRecallHook) {
366
556
  hooks["UserPromptSubmit"] = [
367
557
  ...(claudeSettings.hooks?.["UserPromptSubmit"] || []),
@@ -402,41 +592,125 @@ async function runInit() {
402
592
  appendToGitignore(cwd, [".fathom.json", ".fathom/scripts/"]);
403
593
  console.log(" ✓ .gitignore");
404
594
 
405
- // Register with server (best-effort)
406
- if (serverUrl) {
407
- const regClient = createClient({ server: serverUrl, apiKey, workspace });
408
- const isUp = await regClient.healthCheck();
409
- if (isUp) {
410
- const regResult = await regClient.registerWorkspace(workspace, cwd, {
411
- vault,
412
- description,
413
- agents: selectedAgents,
414
- type: selectedAgents[0] || "local",
415
- });
416
- if (regResult.ok) {
417
- console.log(` ✓ Registered workspace "${workspace}" with server`);
418
- } else if (regResult.error) {
419
- console.log(` · Server: ${regResult.error}`);
420
- }
595
+ // Register with server
596
+ if (serverReachable) {
597
+ const regResult = await regClient.registerWorkspace(workspace, cwd, {
598
+ vault,
599
+ description,
600
+ agents: selectedAgents,
601
+ type: selectedAgents[0] || "local",
602
+ });
603
+ if (regResult.ok) {
604
+ console.log(` ✓ Registered workspace "${workspace}" with server`);
605
+ } else if (regResult.error) {
606
+ console.log(` · Server registration: ${regResult.error}`);
421
607
  }
422
608
  }
423
609
 
424
- // Per-agent next steps
610
+ // Context-aware next steps
425
611
  console.log(`\n Done! Fathom MCP is configured for workspace "${workspace}".`);
612
+ console.log(` Vault mode: ${vaultMode}`);
426
613
  console.log("\n Next steps:");
427
- console.log(" 1. Start the server: cd fathom-server && python app.py");
614
+
615
+ if (!serverReachable) {
616
+ if (serverOnPath === "installed") {
617
+ console.log(" 1. Start the server: fathom-server");
618
+ } else {
619
+ console.log(" 1. Install & start the server: pip install fathom-server && fathom-server");
620
+ }
621
+ }
622
+
623
+ const stepNum = serverReachable ? 1 : 2;
624
+ switch (vaultMode) {
625
+ case "hosted":
626
+ console.log(` ${stepNum}. Your vault is stored on the server. Start writing!`);
627
+ break;
628
+ case "synced":
629
+ console.log(` ${stepNum}. Local vault syncs to server. Files in ./${vault}/ are the source of truth.`);
630
+ break;
631
+ case "local":
632
+ console.log(` ${stepNum}. Local vault only. Server can't search or peek into it.`);
633
+ break;
634
+ case "none":
635
+ console.log(` ${stepNum}. No vault configured. Rooms, messaging, and search still work.`);
636
+ break;
637
+ }
428
638
  for (const agentKey of selectedAgents) {
429
639
  const agent = AGENTS[agentKey];
430
640
  console.log(` · ${agent.name}: ${agent.nextSteps}`);
431
641
  }
642
+
643
+ // Auto-integrate agent instructions
644
+ const agentMdPath = path.join(cwd, ".fathom", "fathom-agents.md");
645
+ let instructionsBlob = "";
646
+ try {
647
+ instructionsBlob = fs.readFileSync(agentMdPath, "utf-8");
648
+ } catch { /* file wasn't created — use empty */ }
649
+
650
+ const primaryAgent = selectedAgents[0];
651
+
652
+ if (instructionsBlob) {
653
+ const prompt = buildIntegrationPrompt(instructionsBlob);
654
+ const cmdParts = HEADLESS_CMDS[primaryAgent]?.(prompt);
655
+
656
+ if (nonInteractive) {
657
+ if (cmdParts) {
658
+ console.log(`\n Integrating instructions via ${AGENTS[primaryAgent].name}...`);
659
+ const result = runAgentHeadless(primaryAgent, prompt);
660
+ if (result !== null) {
661
+ console.log(result);
662
+ } else {
663
+ console.log(" Agent integration failed — paste these instructions manually:\n");
664
+ printInstructionsFallback(agentMdPath, selectedAgents);
665
+ }
666
+ } else {
667
+ printInstructionsFallback(agentMdPath, selectedAgents);
668
+ }
669
+ } else {
670
+ if (cmdParts) {
671
+ const [cmd, ...args] = cmdParts;
672
+ const displayCmd = `${cmd} ${args[0]}${args.length > 1 ? " ..." : ""}`;
673
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
674
+ console.log("\n" + "─".repeat(60));
675
+ const integrate = await askYesNo(
676
+ rl2,
677
+ `\n Auto-integrate instructions into your project?\n This will run: ${displayCmd}\n\n Proceed?`,
678
+ true,
679
+ );
680
+ rl2.close();
681
+
682
+ if (integrate) {
683
+ console.log(`\n Running ${AGENTS[primaryAgent].name}...`);
684
+ const result = runAgentHeadless(primaryAgent, prompt);
685
+ if (result !== null) {
686
+ console.log(result);
687
+ } else {
688
+ console.log(" Agent integration failed — paste these instructions manually:\n");
689
+ printInstructionsFallback(agentMdPath, selectedAgents);
690
+ }
691
+ } else {
692
+ printInstructionsFallback(agentMdPath, selectedAgents);
693
+ }
694
+ } else {
695
+ printInstructionsFallback(agentMdPath, selectedAgents);
696
+ }
697
+ }
698
+ } else {
699
+ console.log("\n No agent instructions template found — skipping integration.\n");
700
+ }
701
+ }
702
+
703
+ function printInstructionsFallback(agentMdPath, selectedAgents) {
704
+ const hasNonClaude = selectedAgents.some((k) => k !== "claude-code");
705
+ const docTarget = hasNonClaude
706
+ ? "your CLAUDE.md, AGENTS.md, or equivalent"
707
+ : "your CLAUDE.md";
708
+
432
709
  console.log(`
433
- Agent instructions:
434
- Some instructions are needed for your agent to use Fathom + Memento
435
- effectively (memory discipline, vault conventions, cross-workspace
436
- communication). Saved to: .fathom/fathom-agents.md
710
+ Agent instructions saved to: ${path.relative(process.cwd(), agentMdPath)}
437
711
 
438
- Paste it into your CLAUDE.md, AGENTS.md, or equivalent or point
439
- your agent at the file and ask it to integrate the instructions.
712
+ Paste into ${docTarget}, or point your agent at the file
713
+ and ask it to integrate the instructions.
440
714
  `);
441
715
  }
442
716
 
@@ -449,6 +723,7 @@ async function runStatus() {
449
723
  console.log("\n Fathom MCP Status\n");
450
724
  console.log(` Config: ${config._configPath || "(not found — using defaults)"}`);
451
725
  console.log(` Workspace: ${config.workspace}`);
726
+ console.log(` Vault mode: ${config.vaultMode}`);
452
727
  console.log(` Vault: ${config.vault}`);
453
728
  console.log(` Server: ${config.server}`);
454
729
  console.log(` API Key: ${config.apiKey ? config.apiKey.slice(0, 7) + "..." + config.apiKey.slice(-4) : "(not set)"}`);
@@ -484,6 +759,72 @@ async function runStatus() {
484
759
  console.log();
485
760
  }
486
761
 
762
+ // --- Update command ----------------------------------------------------------
763
+
764
+ async function runUpdate() {
765
+ const found = findConfigFile(process.cwd());
766
+ if (!found) {
767
+ console.error(" Error: No .fathom.json found. Run `npx fathom-mcp init` first.");
768
+ process.exit(1);
769
+ }
770
+
771
+ const projectDir = found.dir;
772
+ const fathomDir = path.join(projectDir, ".fathom");
773
+
774
+ // Read package version from our own package.json
775
+ const pkgJsonPath = path.join(__dirname, "..", "package.json");
776
+ const pkg = readJsonFile(pkgJsonPath);
777
+ const packageVersion = pkg?.version || "unknown";
778
+
779
+ // Copy all scripts
780
+ const scriptsDir = path.join(fathomDir, "scripts");
781
+ const copiedScripts = copyScripts(scriptsDir);
782
+
783
+ // Write version file
784
+ fs.mkdirSync(fathomDir, { recursive: true });
785
+ fs.writeFileSync(path.join(fathomDir, "version"), packageVersion + "\n");
786
+
787
+ // Ensure SessionStart hook is registered for Claude Code workspaces
788
+ const agents = found.config.agents || [];
789
+ const hasClaude = agents.includes("claude-code");
790
+ let hooksUpdated = false;
791
+ if (hasClaude) {
792
+ const claudeSettingsPath = path.join(projectDir, ".claude", "settings.local.json");
793
+ const claudeSettings = readJsonFile(claudeSettingsPath) || {};
794
+ const sessionStartCmd = "bash .fathom/scripts/fathom-sessionstart.sh";
795
+ const existingHooks = claudeSettings.hooks?.["SessionStart"] || [];
796
+ const hasSessionStart = existingHooks.some((entry) =>
797
+ entry.hooks?.some((h) => h.command === sessionStartCmd)
798
+ );
799
+ if (!hasSessionStart) {
800
+ claudeSettings.hooks = claudeSettings.hooks || {};
801
+ claudeSettings.hooks["SessionStart"] = [
802
+ ...existingHooks,
803
+ { hooks: [{ type: "command", command: sessionStartCmd, timeout: 10000 }] },
804
+ ];
805
+ writeJsonFile(claudeSettingsPath, claudeSettings);
806
+ hooksUpdated = true;
807
+ }
808
+ }
809
+
810
+ console.log(`\n ✓ Fathom hooks updated to v${packageVersion}\n`);
811
+
812
+ if (copiedScripts.length > 0) {
813
+ console.log(" Updated scripts:");
814
+ for (const script of copiedScripts) {
815
+ console.log(` ${script}`);
816
+ }
817
+ }
818
+
819
+ if (hooksUpdated) {
820
+ console.log("\n Registered hooks:");
821
+ console.log(" SessionStart → fathom-sessionstart.sh (version check)");
822
+ }
823
+
824
+ console.log(`\n Version written to .fathom/version`);
825
+ console.log(" Restart your agent session to pick up changes.\n");
826
+ }
827
+
487
828
  // --- Main --------------------------------------------------------------------
488
829
 
489
830
  // Guard: only run CLI when this module is the entry point (not when imported by tests)
@@ -495,7 +836,8 @@ if (isMain) {
495
836
  const command = process.argv[2];
496
837
 
497
838
  if (command === "init") {
498
- runInit().catch((e) => {
839
+ const flags = parseFlags(process.argv.slice(3));
840
+ runInit(flags).catch((e) => {
499
841
  console.error(`Error: ${e.message}`);
500
842
  process.exit(1);
501
843
  });
@@ -504,12 +846,25 @@ if (isMain) {
504
846
  console.error(`Error: ${e.message}`);
505
847
  process.exit(1);
506
848
  });
849
+ } else if (command === "update") {
850
+ runUpdate().catch((e) => {
851
+ console.error(`Error: ${e.message}`);
852
+ process.exit(1);
853
+ });
507
854
  } else if (!command || command === "serve") {
508
855
  // Default: start MCP server
509
856
  import("./index.js");
510
857
  } else {
511
858
  console.error(`Unknown command: ${command}`);
512
- console.error("Usage: fathom-mcp [init|status|serve]");
859
+ console.error(`Usage: fathom-mcp [init|status|update|serve]
860
+
861
+ fathom-mcp init Interactive setup
862
+ fathom-mcp init -y --api-key KEY Non-interactive setup
863
+ fathom-mcp init -y --api-key KEY --server URL Custom server URL
864
+ fathom-mcp init -y --api-key KEY --workspace NAME Custom workspace name
865
+ fathom-mcp status Check connection status
866
+ fathom-mcp update Update hooks + version
867
+ fathom-mcp Start MCP server`);
513
868
  process.exit(1);
514
869
  }
515
870
  }
package/src/config.js CHANGED
@@ -12,9 +12,12 @@ import path from "path";
12
12
 
13
13
  const CONFIG_FILENAME = ".fathom.json";
14
14
 
15
+ const VALID_VAULT_MODES = new Set(["hosted", "synced", "local", "none"]);
16
+
15
17
  const DEFAULTS = {
16
18
  workspace: "",
17
19
  vault: "vault",
20
+ vaultMode: "local", // hosted | synced | local | none
18
21
  server: "http://localhost:4243",
19
22
  apiKey: "",
20
23
  description: "",
@@ -65,6 +68,9 @@ export function resolveConfig(startDir = process.cwd()) {
65
68
  const { config } = found;
66
69
  if (config.workspace) result.workspace = config.workspace;
67
70
  if (config.vault) result.vault = config.vault;
71
+ if (config.vaultMode && VALID_VAULT_MODES.has(config.vaultMode)) {
72
+ result.vaultMode = config.vaultMode;
73
+ }
68
74
  if (config.server) result.server = config.server;
69
75
  if (config.apiKey) result.apiKey = config.apiKey;
70
76
  if (config.description) result.description = config.description;
@@ -81,6 +87,9 @@ export function resolveConfig(startDir = process.cwd()) {
81
87
  if (process.env.FATHOM_API_KEY) result.apiKey = process.env.FATHOM_API_KEY;
82
88
  if (process.env.FATHOM_WORKSPACE) result.workspace = process.env.FATHOM_WORKSPACE;
83
89
  if (process.env.FATHOM_VAULT_DIR) result.vault = process.env.FATHOM_VAULT_DIR;
90
+ if (process.env.FATHOM_VAULT_MODE && VALID_VAULT_MODES.has(process.env.FATHOM_VAULT_MODE)) {
91
+ result.vaultMode = process.env.FATHOM_VAULT_MODE;
92
+ }
84
93
 
85
94
  // Derive workspace name from directory if still empty
86
95
  if (!result.workspace) {
@@ -111,6 +120,7 @@ export function writeConfig(dir, config) {
111
120
  const filePath = path.join(dir, CONFIG_FILENAME);
112
121
  const data = {
113
122
  workspace: config.workspace,
123
+ vaultMode: config.vaultMode || "local",
114
124
  vault: config.vault || "vault",
115
125
  server: config.server || DEFAULTS.server,
116
126
  apiKey: config.apiKey || "",
package/src/index.js CHANGED
@@ -8,6 +8,9 @@
8
8
  * - server-client.js (HTTP to fathom-server — search, rooms, workspaces)
9
9
  */
10
10
 
11
+ import fs from "fs";
12
+ import path from "path";
13
+
11
14
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
12
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
16
  import {
@@ -224,13 +227,14 @@ const tools = [
224
227
  "to the latest message. Default: 60 minutes before the latest message. Use start to look " +
225
228
  "further back. Example: minutes=15, start=120 returns 15 minutes of conversation starting " +
226
229
  "2 hours before the latest message. Response includes window metadata with has_older flag " +
227
- "for pseudo-pagination.",
230
+ "for pseudo-pagination. Automatically marks the room as read for this workspace unless mark_read=false.",
228
231
  inputSchema: {
229
232
  type: "object",
230
233
  properties: {
231
234
  room: { type: "string", description: "Room name to read from" },
232
235
  minutes: { type: "number", description: "Window duration in minutes. Default: 60." },
233
236
  start: { type: "number", description: "Offset in minutes from the latest message. Default: 0 (window ends at latest message). Set to 120 to look back starting 2 hours before the latest message." },
237
+ mark_read: { type: "boolean", description: "Set to false to peek without marking the room as read. Default: true." },
234
238
  },
235
239
  required: ["room"],
236
240
  },
@@ -238,8 +242,9 @@ const tools = [
238
242
  {
239
243
  name: "fathom_room_list",
240
244
  description:
241
- "List all rooms with activity summary — message count, last activity time, last sender, and description. " +
242
- "Use to discover active rooms.",
245
+ "List all rooms with activity summary — message count, last activity time, last sender, " +
246
+ "description, and per-room unread_count for this workspace. Use to discover active rooms " +
247
+ "and see which have new messages.",
243
248
  inputSchema: {
244
249
  type: "object",
245
250
  properties: {},
@@ -288,19 +293,39 @@ const tools = [
288
293
  },
289
294
  ];
290
295
 
291
- // --- Vault path resolution for cross-workspace reads -------------------------
296
+ // --- Vault routing by mode ---------------------------------------------------
292
297
 
293
298
  /**
294
- * Resolve vault path for a tool call. If workspace param differs from config
295
- * workspace, we delegate to the server instead of local I/O.
299
+ * Resolve vault routing for a tool call based on vault mode and workspace.
300
+ *
301
+ * Returns an object describing where to route:
302
+ * { local: true, vaultPath, sync? } — local disk (local/synced mode)
303
+ * { local: false, workspace } — server API (hosted mode / cross-workspace)
304
+ * { local: false, disabled: true } — vault not configured (none mode)
296
305
  */
297
306
  function resolveVault(args) {
298
307
  const ws = args.workspace;
299
- if (!ws || ws === config.workspace) {
300
- return { vaultPath: config.vault, local: true };
308
+ const isOwnWorkspace = !ws || ws === config.workspace;
309
+
310
+ // Cross-workspace: always route through server
311
+ if (!isOwnWorkspace) {
312
+ return { local: false, workspace: ws };
313
+ }
314
+
315
+ // Own workspace: route by vault mode
316
+ switch (config.vaultMode) {
317
+ case "hosted":
318
+ return { local: false, workspace: config.workspace };
319
+ case "synced":
320
+ return { local: true, vaultPath: config.vault, sync: true };
321
+ case "local":
322
+ return { local: true, vaultPath: config.vault, sync: false };
323
+ case "none":
324
+ return { local: false, disabled: true };
325
+ default:
326
+ // Fallback for existing installs without vaultMode — behave as local
327
+ return { local: true, vaultPath: config.vault, sync: false };
301
328
  }
302
- // Cross-workspace — delegate to server
303
- return { vaultPath: null, local: false, workspace: ws };
304
329
  }
305
330
 
306
331
  // --- Server setup & dispatch -------------------------------------------------
@@ -317,87 +342,108 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
317
342
  let result;
318
343
 
319
344
  switch (name) {
320
- // --- Local file I/O tools ---
345
+ // --- Vault write ---
321
346
  case "fathom_vault_write": {
322
- const { vaultPath, local } = resolveVault(args);
323
- if (!local) {
324
- result = { error: "Cross-workspace writes not supported via MCP. Use the server API." };
325
- } else {
326
- result = handleVaultWrite(args, vaultPath);
347
+ const v = resolveVault(args);
348
+ if (v.disabled) {
349
+ result = { error: "Vault not configured for this workspace. Use hosted or synced mode, or create a local ./vault/ directory." };
350
+ } else if (v.local) {
351
+ result = handleVaultWrite(args, v.vaultPath);
327
352
  if (result.ok) {
328
- // Fire-and-forget: notify server for access tracking
329
353
  client.notifyAccess(args.path, args.workspace).catch(() => {});
354
+ if (v.sync) client.pushFile(config.workspace, args.path, args.content).catch(() => {});
330
355
  }
356
+ } else {
357
+ result = await client.writeFile(v.workspace, args.path, args.content);
331
358
  }
332
359
  break;
333
360
  }
361
+
362
+ // --- Vault append ---
334
363
  case "fathom_vault_append": {
335
- const { vaultPath, local } = resolveVault(args);
336
- if (!local) {
337
- result = { error: "Cross-workspace appends not supported via MCP. Use the server API." };
338
- } else {
339
- result = handleVaultAppend(args, vaultPath);
364
+ const v = resolveVault(args);
365
+ if (v.disabled) {
366
+ result = { error: "Vault not configured for this workspace. Use hosted or synced mode, or create a local ./vault/ directory." };
367
+ } else if (v.local) {
368
+ result = handleVaultAppend(args, v.vaultPath);
340
369
  if (result.ok) {
341
370
  client.notifyAccess(args.path, args.workspace).catch(() => {});
371
+ if (v.sync) client.appendFile(config.workspace, args.path, args.content).catch(() => {});
342
372
  }
373
+ } else {
374
+ result = await client.appendFile(v.workspace, args.path, args.content);
343
375
  }
344
376
  break;
345
377
  }
378
+
379
+ // --- Vault read ---
346
380
  case "fathom_vault_read": {
347
- const { vaultPath, local } = resolveVault(args);
348
- if (!local) {
349
- // Cross-workspace reads go through server
350
- result = { error: "Cross-workspace reads: use fathom_vault_search or the server API." };
351
- } else {
352
- result = handleVaultRead(args, vaultPath);
381
+ const v = resolveVault(args);
382
+ if (v.disabled) {
383
+ result = { error: "Vault not configured for this workspace. Use hosted or synced mode, or create a local ./vault/ directory." };
384
+ } else if (v.local) {
385
+ result = handleVaultRead(args, v.vaultPath);
353
386
  if (!result.error) {
354
387
  client.notifyAccess(args.path, args.workspace).catch(() => {});
355
388
  }
389
+ } else {
390
+ result = await client.readFile(v.workspace, args.path);
356
391
  }
357
392
  break;
358
393
  }
394
+
395
+ // --- Vault image (local-only for now — base64 over HTTP would be wasteful) ---
359
396
  case "fathom_vault_image": {
360
- const { vaultPath, local } = resolveVault(args);
361
- if (!local) {
362
- result = { error: "Cross-workspace image reads not supported via MCP." };
397
+ const v = resolveVault(args);
398
+ if (!v.local) {
399
+ result = { error: "Image reads require local or synced vault mode." };
363
400
  } else {
364
- result = handleVaultImage(args, vaultPath);
401
+ result = handleVaultImage(args, v.vaultPath);
365
402
  }
366
403
  break;
367
404
  }
405
+
406
+ // --- Vault write asset (local-only) ---
368
407
  case "fathom_vault_write_asset": {
369
- const { vaultPath, local } = resolveVault(args);
370
- if (!local) {
371
- result = { error: "Cross-workspace asset writes not supported via MCP." };
408
+ const v = resolveVault(args);
409
+ if (!v.local) {
410
+ result = { error: "Asset writes require local or synced vault mode." };
372
411
  } else {
373
- result = handleVaultWriteAsset(args, vaultPath);
412
+ result = handleVaultWriteAsset(args, v.vaultPath);
374
413
  }
375
414
  break;
376
415
  }
377
416
 
378
- // --- Local listing with server fallback ---
417
+ // --- Vault list ---
379
418
  case "fathom_vault_list": {
380
- const { vaultPath, local } = resolveVault(args);
381
- if (local) {
419
+ const v = resolveVault(args);
420
+ if (v.disabled) {
421
+ result = { error: "Vault not configured for this workspace." };
422
+ } else if (v.local) {
382
423
  // Try server first for activity-enriched data, fall back to local
383
424
  result = await client.vaultList(args.workspace);
384
425
  if (result.error) {
385
- result = handleVaultList(vaultPath);
426
+ result = handleVaultList(v.vaultPath);
386
427
  }
387
428
  } else {
388
- result = await client.vaultList(args.workspace);
429
+ // Hosted or cross-workspace: server has the files
430
+ result = await client.listFiles(v.workspace);
389
431
  }
390
432
  break;
391
433
  }
434
+
435
+ // --- Vault folder ---
392
436
  case "fathom_vault_folder": {
393
- const { vaultPath, local } = resolveVault(args);
394
- if (local) {
437
+ const v = resolveVault(args);
438
+ if (v.disabled) {
439
+ result = { error: "Vault not configured for this workspace." };
440
+ } else if (v.local) {
395
441
  result = await client.vaultFolder(args.folder, args.workspace);
396
442
  if (result.error) {
397
- result = handleVaultFolder(args, vaultPath);
443
+ result = handleVaultFolder(args, v.vaultPath);
398
444
  }
399
445
  } else {
400
- result = await client.vaultFolder(args.folder, args.workspace);
446
+ result = await client.vaultFolder(args.folder, v.workspace);
401
447
  }
402
448
  break;
403
449
  }
@@ -416,10 +462,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
416
462
  result = await client.roomPost(args.room, args.message, config.workspace);
417
463
  break;
418
464
  case "fathom_room_read":
419
- result = await client.roomRead(args.room, args.minutes, args.start);
465
+ result = await client.roomRead(
466
+ args.room, args.minutes, args.start,
467
+ args.mark_read !== false ? config.workspace : undefined,
468
+ args.mark_read,
469
+ );
420
470
  break;
421
471
  case "fathom_room_list":
422
- result = await client.roomList();
472
+ result = await client.roomList(config.workspace);
423
473
  break;
424
474
  case "fathom_room_describe":
425
475
  result = await client.roomDescribe(args.room, args.description);
@@ -447,6 +497,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
447
497
  };
448
498
  });
449
499
 
500
+ /**
501
+ * Startup sync for synced mode — scan local vault, compare with server,
502
+ * and push any files the server is missing.
503
+ */
504
+ async function startupSync() {
505
+ if (config.vaultMode !== "synced") return;
506
+ if (!fs.existsSync(config.vault)) return;
507
+
508
+ try {
509
+ // Build local manifest
510
+ const manifest = [];
511
+ const walkDir = (dir) => {
512
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
513
+ const abs = path.join(dir, entry.name);
514
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
515
+ walkDir(abs);
516
+ } else if (entry.isFile()) {
517
+ const stat = fs.statSync(abs);
518
+ manifest.push({
519
+ path: path.relative(config.vault, abs),
520
+ mtime: stat.mtime.toISOString(),
521
+ size: stat.size,
522
+ });
523
+ }
524
+ }
525
+ };
526
+ walkDir(config.vault);
527
+
528
+ if (manifest.length === 0) return;
529
+
530
+ // Ask server what it needs
531
+ const diff = await client.syncManifest(config.workspace, manifest);
532
+ if (diff.error || !diff.needed) return;
533
+
534
+ // Push needed files
535
+ for (const filePath of diff.needed) {
536
+ const abs = path.resolve(config.vault, filePath);
537
+ if (fs.existsSync(abs)) {
538
+ const content = fs.readFileSync(abs, "utf-8");
539
+ await client.pushFile(config.workspace, filePath, content);
540
+ }
541
+ }
542
+ } catch {
543
+ // Sync failure is non-fatal — local vault is source of truth
544
+ }
545
+ }
546
+
450
547
  async function main() {
451
548
  // Auto-register workspace with server (fire-and-forget)
452
549
  if (config.server && config.workspace) {
@@ -457,6 +554,9 @@ async function main() {
457
554
  }).catch(() => {});
458
555
  }
459
556
 
557
+ // Startup sync for synced mode (fire-and-forget)
558
+ startupSync().catch(() => {});
559
+
460
560
  const transport = new StdioServerTransport();
461
561
  await server.connect(transport);
462
562
  }
@@ -82,14 +82,22 @@ export function createClient(config) {
82
82
  });
83
83
  }
84
84
 
85
- async function roomRead(room, minutes, start) {
85
+ async function roomRead(room, minutes, start, ws, markRead) {
86
86
  return request("GET", `/api/room/${encodeURIComponent(room)}`, {
87
- params: { minutes, start },
87
+ params: { minutes, start, workspace: ws, mark_read: markRead },
88
88
  });
89
89
  }
90
90
 
91
- async function roomList() {
92
- return request("GET", "/api/room/list");
91
+ async function roomList(ws) {
92
+ return request("GET", "/api/room/list", {
93
+ params: { workspace: ws },
94
+ });
95
+ }
96
+
97
+ async function roomMarkRead(room, ws) {
98
+ return request("POST", `/api/room/${encodeURIComponent(room)}/read`, {
99
+ body: { workspace: ws },
100
+ });
93
101
  }
94
102
 
95
103
  async function roomDescribe(room, description) {
@@ -145,6 +153,43 @@ export function createClient(config) {
145
153
  });
146
154
  }
147
155
 
156
+ // --- Hosted vault file CRUD (server-stored files) --------------------------
157
+
158
+ async function readFile(ws, filePath) {
159
+ return request("GET", `/api/vault/${encodeURIComponent(ws)}/files/${filePath}`);
160
+ }
161
+
162
+ async function writeFile(ws, filePath, content) {
163
+ return request("PUT", `/api/vault/${encodeURIComponent(ws)}/files/${filePath}`, {
164
+ body: { content },
165
+ });
166
+ }
167
+
168
+ async function appendFile(ws, filePath, content) {
169
+ return request("POST", `/api/vault/${encodeURIComponent(ws)}/files/${filePath}/append`, {
170
+ body: { content },
171
+ });
172
+ }
173
+
174
+ async function deleteFile(ws, filePath) {
175
+ return request("DELETE", `/api/vault/${encodeURIComponent(ws)}/files/${filePath}`);
176
+ }
177
+
178
+ async function listFiles(ws) {
179
+ return request("GET", `/api/vault/${encodeURIComponent(ws)}/files`);
180
+ }
181
+
182
+ /** Push a file to server storage (used by synced mode). Same as writeFile. */
183
+ async function pushFile(ws, filePath, content) {
184
+ return writeFile(ws, filePath, content);
185
+ }
186
+
187
+ async function syncManifest(ws, manifest) {
188
+ return request("POST", `/api/vault/${encodeURIComponent(ws)}/sync/manifest`, {
189
+ body: manifest,
190
+ });
191
+ }
192
+
148
193
  // --- Auth ------------------------------------------------------------------
149
194
 
150
195
  async function getApiKey() {
@@ -169,6 +214,7 @@ export function createClient(config) {
169
214
  roomPost,
170
215
  roomRead,
171
216
  roomList,
217
+ roomMarkRead,
172
218
  roomDescribe,
173
219
  sendToWorkspace,
174
220
  listWorkspaces,
@@ -176,6 +222,13 @@ export function createClient(config) {
176
222
  notifyAccess,
177
223
  vaultList,
178
224
  vaultFolder,
225
+ readFile,
226
+ writeFile,
227
+ appendFile,
228
+ deleteFile,
229
+ listFiles,
230
+ pushFile,
231
+ syncManifest,
179
232
  getApiKey,
180
233
  healthCheck,
181
234
  };