@imunitic/synapse 0.2.3 → 0.2.5

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/Index.md.template CHANGED
@@ -3,7 +3,7 @@ title: "Index"
3
3
  ---
4
4
 
5
5
  # Index
6
- Map of the note folders and what each is for. This is the Obsidian second-brain vault, queried via the Local REST API plugin (see the Synapse repo's `CLAUDE.md`).
6
+ Map of the note folders and what each is for. This is the Obsidian second-brain vault, reached through the `synapse` CLI (see the Synapse repo's `CLAUDE.md`).
7
7
 
8
8
  This index is agent-maintained: whenever an agent creates a new top-level folder (folder depth is capped at two levels, i.e. `folder/subfolder`), it must add a one-line section for it here in the same edit. Treat this file as out of date if a folder exists on disk with no matching section below -- fix the drift rather than working around it.
9
9
 
@@ -17,7 +17,6 @@
17
17
  const fs = require("fs");
18
18
  const os = require("os");
19
19
  const path = require("path");
20
- const { execFileSync } = require("child_process");
21
20
  const { hookPath, platformPackageName, HOOK_NAME } = require("../lib/resolve-binaries.cjs");
22
21
 
23
22
  const PKG_ROOT = path.join(__dirname, "..");
@@ -56,28 +55,40 @@ function resolveHookBin() {
56
55
  }
57
56
 
58
57
  function configure(harness) {
58
+ bootstrapSynapseConf();
59
59
  if (harness === "claude") return configureClaude();
60
60
  if (harness === "codex") return configureCodex();
61
61
  if (harness === "opencode") return configureOpencode();
62
62
  }
63
63
 
64
64
  // ---------------------------------------------------------------------------
65
- // Shared: vault/Obsidian plugin data, skill/command copies, JSON merge helpers
65
+ // Shared: synapse.conf bootstrap, skill/command copies, JSON merge helpers
66
66
  // ---------------------------------------------------------------------------
67
67
 
68
- function readObsidianPluginData() {
69
- const { resolveVaultDir } = require("../lib/obsidian-mcp-refresh.cjs");
70
- const vault = resolveVaultDir();
71
- if (!vault || !fs.existsSync(vault)) {
72
- fail("no SYNAPSE_VAULT_DIR resolvable (see synapse.conf) -- can't configure the obsidian MCP connection");
73
- }
74
- const pluginDataPath = path.join(vault, ".obsidian", "plugins", "obsidian-local-rest-api", "data.json");
75
- if (!fs.existsSync(pluginDataPath)) {
76
- fail(`${pluginDataPath} not found -- install/enable the Local REST API plugin in Obsidian first`);
77
- }
78
- const data = JSON.parse(fs.readFileSync(pluginDataPath, "utf8"));
79
- if (!data.apiKey) fail(`${pluginDataPath} has no apiKey`);
80
- return data;
68
+ // Mirrors `core.conf.resolveConfPath`/`resolveWritePath`'s own three-tier
69
+ // order for `synapse.conf` specifically (see `docs/synapse/synapse-config.md`
70
+ // "Where a conf file actually lives"), so a fresh machine gets a real file to
71
+ // edit instead of a silent gap the next `SessionStart` hook has to explain.
72
+ // A no-op once any tier already has a file -- re-running `configure` never
73
+ // overwrites an existing choice, deliberate or inherited.
74
+ function bootstrapSynapseConf() {
75
+ const home = os.homedir();
76
+ const xdg = process.env.XDG_CONFIG_HOME;
77
+ const candidates = [];
78
+ if (xdg) candidates.push(path.join(xdg, "synapse", "synapse.conf"));
79
+ candidates.push(path.join(home, ".config", "synapse", "synapse.conf"));
80
+ candidates.push(path.join(home, ".claude", "synapse.conf"));
81
+ if (candidates.some((p) => fs.existsSync(p))) return;
82
+
83
+ let dest;
84
+ if (xdg) dest = path.join(xdg, "synapse", "synapse.conf");
85
+ else if (fs.existsSync(path.join(home, ".config")) && fs.statSync(path.join(home, ".config")).isDirectory())
86
+ dest = path.join(home, ".config", "synapse", "synapse.conf");
87
+ else dest = path.join(home, ".claude", "synapse.conf");
88
+
89
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
90
+ fs.copyFileSync(path.join(PKG_ROOT, "synapse.conf.template"), dest);
91
+ console.log(`wrote ${dest} -- edit SYNAPSE_VAULT_DIR in it before your next session`);
81
92
  }
82
93
 
83
94
  // The manifest recording exactly which names this tool wrote into a given
@@ -110,14 +121,13 @@ function pruneStale(destRoot, currentNames) {
110
121
  fs.writeFileSync(manifestPath, JSON.stringify(currentNames, null, 2) + "\n");
111
122
  }
112
123
 
113
- // Copies every shared SKILL.md into `destRoot/{name}/SKILL.md`, verbatim
114
- // unless `transform` is given. Each skill gets its own uniquely-named
115
- // subdirectory, so overwriting ours on every run never touches anything a
116
- // harness's own skill discovery also finds there under a different name.
117
- // Returns the names written -- pruning stale ones is the caller's job,
118
- // since a destRoot shared with another source (Codex's own skills, on top
119
- // of these) needs the combined set before it can prune safely.
120
- function copySkills(destRoot, transform) {
124
+ // Copies every shared SKILL.md into `destRoot/{name}/SKILL.md`. Each skill
125
+ // gets its own uniquely-named subdirectory, so overwriting ours on every run
126
+ // never touches anything a harness's own skill discovery also finds there
127
+ // under a different name. Returns the names written -- pruning stale ones is
128
+ // the caller's job, since a destRoot shared with another source (Codex's own
129
+ // skills, on top of these) needs the combined set before it can prune safely.
130
+ function copySkills(destRoot) {
121
131
  const src = path.join(PKG_ROOT, "skills");
122
132
  const names = fs
123
133
  .readdirSync(src, { withFileTypes: true })
@@ -127,32 +137,21 @@ function copySkills(destRoot, transform) {
127
137
  for (const name of names) {
128
138
  const destDir = path.join(destRoot, name);
129
139
  fs.mkdirSync(destDir, { recursive: true });
130
- writeMaybeTransformed(path.join(src, name, "SKILL.md"), path.join(destDir, "SKILL.md"), transform);
140
+ fs.copyFileSync(path.join(src, name, "SKILL.md"), path.join(destDir, "SKILL.md"));
131
141
  }
132
142
  return names;
133
143
  }
134
144
 
135
- function copyCommands(destRoot, transform) {
145
+ function copyCommands(destRoot) {
136
146
  const src = path.join(PKG_ROOT, "commands");
137
147
  const names = fs.readdirSync(src).filter((f) => f.endsWith(".md"));
138
148
  fs.mkdirSync(destRoot, { recursive: true });
139
149
  for (const name of names) {
140
- writeMaybeTransformed(path.join(src, name), path.join(destRoot, name), transform);
150
+ fs.copyFileSync(path.join(src, name), path.join(destRoot, name));
141
151
  }
142
152
  return names;
143
153
  }
144
154
 
145
- function writeMaybeTransformed(srcPath, destPath, transform) {
146
- const text = fs.readFileSync(srcPath, "utf8");
147
- fs.writeFileSync(destPath, transform ? transform(text) : text);
148
- }
149
-
150
- // `\w+` catches a real tool name (mcp__obsidian__vault_read); `\*` catches
151
- // prose referring to the whole family (mcp__obsidian__*).
152
- function opencodeToolNameTransform(text) {
153
- return text.replace(/mcp__obsidian__(\w+|\*)/g, "obsidian_$1");
154
- }
155
-
156
155
  function backupIfExists(filePath) {
157
156
  if (!fs.existsSync(filePath)) return;
158
157
  fs.copyFileSync(filePath, `${filePath}.bak`);
@@ -201,12 +200,11 @@ function mergeHooksInto(existingHooks, rendered, hookBin) {
201
200
  // install: `~/.claude/skills/{name}/SKILL.md` and `~/.claude/commands/
202
201
  // {name}.md` are both read with no plugin involved (a probe skill dropped
203
202
  // directly into the global skills dir showed up in a fresh `claude -p`
204
- // session's own skill listing). Commands need no transform either: they were
205
- // authored for Claude Code in the first place, so the copy is the identity
206
- // function. `~/.claude/settings.json`'s own `hooks`/`env` keys are the same
207
- // mechanism the retired setup.sh (pre-plugin Synapse) already used, and the
208
- // same one NanoNets/Graft uses for its own Claude Code integration -- this
209
- // harness is not doing anything Claude Code doesn't already support natively.
203
+ // session's own skill listing). `~/.claude/settings.json`'s own `hooks`/`env`
204
+ // keys are the same mechanism the retired setup.sh (pre-plugin Synapse)
205
+ // already used, and the same one NanoNets/Graft uses for its own Claude Code
206
+ // integration -- this harness is not doing anything Claude Code doesn't
207
+ // already support natively.
210
208
  function configureClaude() {
211
209
  const hookBin = resolveHookBin();
212
210
 
@@ -231,26 +229,6 @@ function configureClaude() {
231
229
  const rendered = renderHooksTemplate(path.join(PKG_ROOT, "harness", "claude", "hooks.json"), hookBin);
232
230
  settings.hooks = mergeHooksInto(settings.hooks, rendered, hookBin);
233
231
 
234
- // Keeps the obsidian MCP registration current automatically, every
235
- // session -- the same job plugins/synapse/hooks/obsidian-mcp-refresh.cjs
236
- // did as a plugin-era SessionStart hook. Merged the same way as the
237
- // templated hooks above (dedup key = the exact resolved command), just
238
- // not sourced from harness/claude/hooks.json since its path needs no
239
- // per-platform hookBin substitution -- it's always PKG_ROOT-relative.
240
- const refreshCmd = `node ${path.join(PKG_ROOT, "lib", "obsidian-mcp-refresh.cjs")}`;
241
- const refreshHooks = { hooks: { SessionStart: [{ hooks: [{ type: "command", command: refreshCmd }] }] } };
242
- settings.hooks = mergeHooksInto(settings.hooks, refreshHooks, refreshCmd);
243
-
244
- const pluginData = readObsidianPluginData();
245
- if (!pluginData.crypto || typeof pluginData.crypto.cert !== "string") {
246
- fail("Local REST API's data.json has no crypto.cert -- can't set up the HTTPS trust Claude Code needs");
247
- }
248
- const certPath = path.join(os.homedir(), ".claude", "obsidian-local-rest-api-ca.pem");
249
- fs.mkdirSync(path.dirname(certPath), { recursive: true });
250
- fs.writeFileSync(certPath, pluginData.crypto.cert);
251
- settings.env = settings.env || {};
252
- settings.env.NODE_EXTRA_CA_CERTS = certPath;
253
-
254
232
  // The npm-install counterpart to $CLAUDE_PLUGIN_ROOT -- conf.zig's own
255
233
  // resolveConfPath and session_start.zig's synapse-claude.md/Index.md.template
256
234
  // lookups both check this env var now that there's no plugin marketplace
@@ -258,42 +236,15 @@ function configureClaude() {
258
236
  // relative shape CLAUDE_PLUGIN_ROOT used to point at plugins/synapse/ --
259
237
  // synapse-claude.md, Index.md.template, and every *.conf.template sit
260
238
  // directly under it.
239
+ settings.env = settings.env || {};
261
240
  settings.env.SYNAPSE_CONTENT_ROOT = PKG_ROOT;
262
241
 
263
242
  backupIfExists(settingsPath);
264
243
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
265
244
  console.log(`configured ${settingsPath}`);
266
- console.log(`wrote ${certPath}`);
267
-
268
- // Claude Code already has a working HTTPS cert-trust story (unlike
269
- // Codex/OpenCode), so this registers against the real endpoint directly --
270
- // no plain-HTTP fallback needed here. Registering means removing first --
271
- // `claude mcp add` will not overwrite an existing name.
272
- try {
273
- execFileSync("claude", ["mcp", "remove", "obsidian", "-s", "user"], { stdio: "ignore" });
274
- } catch {
275
- // Fine if it wasn't registered yet.
276
- }
277
- execFileSync(
278
- "claude",
279
- [
280
- "mcp",
281
- "add",
282
- "--transport",
283
- "http",
284
- "obsidian",
285
- `https://127.0.0.1:${pluginData.port}/mcp/`,
286
- "--header",
287
- `Authorization: Bearer ${pluginData.apiKey}`,
288
- "-s",
289
- "user",
290
- ],
291
- { stdio: "ignore" }
292
- );
293
- console.log("registered the obsidian MCP server");
294
245
  console.log("");
295
- console.log("No manual steps -- restart Claude Code so the new hooks/settings/MCP");
296
- console.log("registration take effect for the running session.");
246
+ console.log("No manual steps -- restart Claude Code so the new hooks/settings take effect");
247
+ console.log("for the running session.");
297
248
  }
298
249
 
299
250
  // ---------------------------------------------------------------------------
@@ -333,54 +284,9 @@ function configureCodex() {
333
284
  pruneStale(skillsDestRoot, [...sharedNames, ...codexNames]);
334
285
  console.log(`installed ${sharedNames.length + codexNames.length} skills to ${skillsDestRoot}`);
335
286
 
336
- const pluginData = readObsidianPluginData();
337
- if (!pluginData.enableInsecureServer || !pluginData.insecurePort) {
338
- fail(
339
- "Local REST API's plain-HTTP server is off -- Codex can't trust its self-signed HTTPS cert " +
340
- "(no per-server cert field in config.toml). Enable it: Obsidian Settings -> " +
341
- "Local REST API -> Enable HTTP server, then re-run this command."
342
- );
343
- }
344
- const tokenEnvVar = "SYNAPSE_OBSIDIAN_API_KEY";
345
- mergeCodexConfigToml(path.join(os.homedir(), ".codex", "config.toml"), pluginData.insecurePort, tokenEnvVar);
346
- console.log(`configured ${path.join(os.homedir(), ".codex", "config.toml")}`);
347
-
348
- console.log("");
349
- console.log("One manual step -- Codex reads the vault API key from an env var by name,");
350
- console.log("not from config.toml. Add this to your shell profile, then restart your terminal:");
351
- console.log("");
352
- console.log(` export ${tokenEnvVar}="${pluginData.apiKey}"`);
353
287
  console.log("");
354
- console.log("The next real `codex` session will prompt to trust the hooks this just wrote --");
355
- console.log("approve it interactively.");
356
- }
357
-
358
- const CODEX_TOML_BEGIN = "# >>> synapse: obsidian MCP (managed by synapse-setup configure codex) >>>";
359
- const CODEX_TOML_END = "# <<< synapse <<<";
360
-
361
- function mergeCodexConfigToml(configTomlPath, insecurePort, tokenEnvVar) {
362
- const block = [
363
- CODEX_TOML_BEGIN,
364
- "[mcp_servers.obsidian]",
365
- `url = "http://127.0.0.1:${insecurePort}/mcp/"`,
366
- `bearer_token_env_var = "${tokenEnvVar}"`,
367
- CODEX_TOML_END,
368
- ].join("\n");
369
-
370
- let text = "";
371
- if (fs.existsSync(configTomlPath)) text = fs.readFileSync(configTomlPath, "utf8");
372
- const beginIdx = text.indexOf(CODEX_TOML_BEGIN);
373
- const endIdx = text.indexOf(CODEX_TOML_END);
374
- let next;
375
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
376
- next = text.slice(0, beginIdx) + block + text.slice(endIdx + CODEX_TOML_END.length);
377
- } else {
378
- const sep = text.length === 0 || text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n";
379
- next = text + sep + block + "\n";
380
- }
381
- fs.mkdirSync(path.dirname(configTomlPath), { recursive: true });
382
- backupIfExists(configTomlPath);
383
- fs.writeFileSync(configTomlPath, next);
288
+ console.log("No manual steps -- the next real `codex` session will prompt to trust the");
289
+ console.log("hooks this just wrote, approve it interactively.");
384
290
  }
385
291
 
386
292
  // ---------------------------------------------------------------------------
@@ -404,56 +310,18 @@ function configureOpencode() {
404
310
  console.log(`installed ${pluginDest}`);
405
311
 
406
312
  const skillsDest = path.join(os.homedir(), ".config", "opencode", "skill");
407
- const skillNames = copySkills(skillsDest, opencodeToolNameTransform);
313
+ const skillNames = copySkills(skillsDest);
408
314
  pruneStale(skillsDest, skillNames);
409
315
  const commandsDest = path.join(os.homedir(), ".config", "opencode", "command");
410
- const commandNames = copyCommands(commandsDest, opencodeToolNameTransform);
316
+ const commandNames = copyCommands(commandsDest);
411
317
  pruneStale(commandsDest, commandNames);
412
318
  console.log(`installed ${skillNames.length} skills, ${commandNames.length} commands to ~/.config/opencode`);
413
319
 
414
- const pluginData = readObsidianPluginData();
415
- if (!pluginData.enableInsecureServer || !pluginData.insecurePort) {
416
- fail(
417
- "Local REST API's plain-HTTP server is off -- OpenCode's mcp.obsidian schema has no per-server " +
418
- "cert/TLS field either. Enable it: Obsidian Settings -> Local REST API -> Enable HTTP server, " +
419
- "then re-run this command."
420
- );
421
- }
422
- const configPath = path.join(os.homedir(), ".config", "opencode", "opencode.jsonc");
423
- mergeOpencodeMcp(configPath, pluginData.insecurePort, pluginData.apiKey);
424
- console.log(`configured ${configPath}`);
425
-
426
320
  console.log("");
427
321
  console.log("No manual steps -- the plugin resolves its synapse-hook binary from the shared");
428
322
  console.log(`install location (${hookBin}) automatically.`);
429
323
  }
430
324
 
431
- function mergeOpencodeMcp(configPath, insecurePort, apiKey) {
432
- let config = {};
433
- if (fs.existsSync(configPath)) {
434
- const raw = fs.readFileSync(configPath, "utf8");
435
- try {
436
- config = JSON.parse(raw);
437
- } catch (err) {
438
- fail(
439
- `${configPath} isn't plain JSON (${err.message}) -- can't safely merge into a commented .jsonc ` +
440
- `file without risking losing the comments. Add this entry by hand instead: ` +
441
- `mcp.obsidian: {"type":"remote","url":"http://127.0.0.1:${insecurePort}/mcp/",` +
442
- `"headers":{"Authorization":"Bearer <the vault's apiKey>"}}`
443
- );
444
- }
445
- }
446
- config.mcp = config.mcp || {};
447
- config.mcp.obsidian = {
448
- type: "remote",
449
- url: `http://127.0.0.1:${insecurePort}/mcp/`,
450
- headers: { Authorization: `Bearer ${apiKey}` },
451
- };
452
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
453
- backupIfExists(configPath);
454
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
455
- }
456
-
457
325
  function fail(message) {
458
326
  console.error(`synapse-setup: ${message}`);
459
327
  process.exit(1);
@@ -238,7 +238,7 @@ emit into tool calls than read into a window. Never hand-author those.
238
238
  **Do not choose which files to read by judgment, and do not decide how the writing itself
239
239
  happens by habit.** Both are decided by the `synapse-node-authoring` skill — **load it
240
240
  before writing the first node.** It resolves `SYNAPSE_AUTHOR_POOL` (env var, then
241
- `~/.claude/synapse.conf`, default 0) and either walks you through authoring every node
241
+ `synapse.conf`, default 0) and either walks you through authoring every node
242
242
  yourself in one continuous pass (`rank --sources` per node, `## Links` candidates from
243
243
  step 6's `links.tsv`, reading order only — `sources` stays exhaustive either way), or fans
244
244
  out to a configurable pool of concurrent subagents, each handed a self-contained
@@ -28,17 +28,18 @@ of scope — foundational files, not taxonomy notes.
28
28
 
29
29
  ## Prerequisites
30
30
 
31
- Requires the `obsidian` MCP server (`mcp__obsidian__*` tools). If unreachable, say so and stop —
32
- there is no local-file fallback.
33
-
34
- No compiled code anywhere in this command. `adapters/obsidian/store.zig`'s `ObsidianStore` does now
35
- implement `read`/`list`/`search`, but for `synapse frontmatter get`/`set`'s narrow one-field-at-a-time
36
- use — a full-vault tidy sweep, reading every note's whole body to judge category, tags and broken
37
- links, is a different shape of work entirely, and stays on these same MCP tools rather than the
38
- compiled binary, the same way `/synapse-status` and `/synapse-rebuild-diff`'s vault-side checks
39
- already work. The one exception is Step 3's broken-link history check, a plain `git log` call (via
40
- Bash, not a compiled tool) against the vault's own local repo when one exists — best-effort, never
41
- a hard requirement.
31
+ Requires the `synapse` CLI on `PATH`, resolving a vault with a working `LinkGraph`
32
+ (`synapse vault-backlinks`/`vault-links`/`vault-unresolved`/`vault-orphans`/`vault-deadends` see
33
+ `sb — Obsidian CLI as ObsidianStore's transport`). If `SYNAPSE_VAULT_STORE` resolves to a backend
34
+ with no `LinkGraph` yet, these commands exit 1 saying so; stop and report that rather than falling
35
+ back to anything else.
36
+
37
+ This command reaches the vault store only through the `synapse` CLI's `vault-*` subcommands, the
38
+ same door every other skill uses — no MCP tool, no direct `ObsidianStore` call. `vault-links`/
39
+ `vault-backlinks` each answer for one note at a time, so Step 1's inventory sweep runs one pair per
40
+ note in scope: `2N` process spawns for an `N`-note vault. Acceptable for an on-demand, rare command.
41
+ Step 3's broken-link history check is a plain `git log` call (via Bash, not a compiled tool)
42
+ against the vault's own local repo when one exists — best-effort, never a hard requirement.
42
43
 
43
44
  ## Step 1: Inventory sweep
44
45
 
@@ -55,12 +56,23 @@ sweep rather than re-reading anything.
55
56
  ]}
56
57
  ```
57
58
 
58
- `mcp__obsidian__search_query` with that filter, then `mcp__obsidian__vault_read` each match. Keep
59
- the full result (`path`, `frontmatter`, `tags`, `links`, `backlinks`, `unresolvedLinks`, `stat`,
60
- `content`) per note Steps 2–4 below only ever reason over the structured fields, never `content`
61
- itself; the judgment layer in Step 5 is the first point anything actually reads note *bodies*, and
62
- only for the subset flagged by then. `content` is fetched for free in this same call regardless, so
63
- there's no separate "light" read to bother with.
59
+ `synapse vault-search --fields frontmatter,tags,content` with that filter on stdin gives `path`,
60
+ `frontmatter`, `tags`, `content` per matching note in one pass (`stat`'s `ctime`/`mtime` aren't a
61
+ `vault-search` fieldsee the note below). Also run `synapse vault-orphans`, `synapse
62
+ vault-deadends`, and `synapse vault-unresolved` once each, vault-wide three cheap bulk calls that
63
+ Step 3 cross-references by path rather than recomputing per note. For each note in scope, also run
64
+ `synapse vault-backlinks <path>` and `synapse vault-links <path>` and keep the results alongside it
65
+ — this is the `2N` cost named in Prerequisites, needed only for Step 4's weak-folder-fit signal
66
+ (which folder a note's actual link targets fall in, not just whether it has any). Keep the full
67
+ assembled result (`path`, `frontmatter`, `tags`, `content`, `links`, `backlinks`) per note — Steps
68
+ 2–4 below only ever reason over the structured fields, never `content` itself; the judgment layer in
69
+ Step 5 is the first point anything actually reads note *bodies*, and only for the subset flagged by
70
+ then.
71
+
72
+ `stat.ctime`/`stat.mtime` (Step 2's `created` default, Step 4's stale-category check) have no
73
+ `vault-search` equivalent — read a note's mtime directly off disk (`SYNAPSE_VAULT_DIR`-resolved
74
+ path) with a plain `stat` via Bash when either step needs it, same file the vault store itself
75
+ already addresses.
64
76
 
65
77
  ## Step 2: Frontmatter defaults (fixed directly)
66
78
 
@@ -73,25 +85,25 @@ guessing:
73
85
  - Missing `created` → `stat.ctime`, formatted `YYYY-MM-DD HH:MM` to match every other note's
74
86
  convention.
75
87
 
76
- Apply via read-modify-write on the whole file (`vault_read` → edit the one frontmatter line in the
77
- returned content → `vault_write` the whole file back) — never `vault_patch` with
78
- `targetType: frontmatter`, which re-serializes the entire YAML block and silently reformats
79
- unrelated fields, the same hazard `synapse-vault`/`synapse-task` already document.
88
+ Apply via read-modify-write on the whole file (`synapse vault-read` → edit the one frontmatter line
89
+ in the returned content → `synapse vault-write` the whole file back) — never `vault-patch` with
90
+ `--frontmatter`, which re-serializes the entire YAML block and silently reformats unrelated fields,
91
+ the same hazard `synapse-vault`/`synapse-task` already document.
80
92
 
81
93
  ## Step 3: Note-health findings (reported, not fixed)
82
94
 
83
- From the same inventory, no additional vault reads (the broken-link history check below reads the
84
- vault's local git log, not more notes). Each of these three has no safe mechanical repair —
85
- fixing any of them means guessing at intent — so they become findings for the Step 6 proposal
86
- instead of a silent edit:
87
-
88
- - **Broken links** — `unresolvedLinks` non-empty. Before writing the finding, check the vault's own
89
- local git history to say *why* it's broken instead of leaving that to guesswork — the vault is
90
- usually a git repo (`db-sync` auto-commits every agent-driven edit into it, opt-in per vault, same
91
- precondition as that hook). Resolve the vault's filesystem path the same way `synapse.conf`
92
- already does (`SYNAPSE_VAULT_DIR`), skip this sub-step entirely if `{vault}/.git` doesn't exist,
93
- and never let a missing/unreachable git repo block the rest of the finding — worst case it's
94
- reported with no history context, same as today.
95
+ From the same inventory plus Step 1's three bulk link-graph calls, no additional vault reads (the
96
+ broken-link history check below reads the vault's local git log, not more notes). Each of these
97
+ three has no safe mechanical repair — fixing any of them means guessing at intent — so they become
98
+ findings for the Step 6 proposal instead of a silent edit:
99
+
100
+ - **Broken links** — every `vault-unresolved` row whose `source` is in scope. Before writing the
101
+ finding, check the vault's own local git history to say *why* it's broken instead of leaving that
102
+ to guesswork — the vault is usually a git repo (`db-sync` auto-commits every agent-driven edit into
103
+ it, opt-in per vault, same precondition as that hook). Resolve the vault's filesystem path the same
104
+ way `synapse.conf` already does (`SYNAPSE_VAULT_DIR`), skip this sub-step entirely if
105
+ `{vault}/.git` doesn't exist, and never let a missing/unreachable git repo block the rest of the
106
+ finding — worst case it's reported with no history context, same as today.
95
107
  - `git -C {vault} log --all --diff-filter=A --name-only --pretty=format: -- "**/{target}.md"` — a
96
108
  hit means a note by that exact title was created at some point (even if later renamed or
97
109
  deleted): report it as *"used to be a note — find what it's called now, or was deleted"*.
@@ -101,7 +113,9 @@ instead of a silent edit:
101
113
  as plain text or an external reference, not a vault link"* rather than implying anything was
102
114
  lost.
103
115
  - Neither check resolves anything more specific → report the target plainly, same as before.
104
- - **Orphaned notes** — `links` empty *and* `backlinks` empty.
116
+ - **Orphaned notes** — in scope, in both `vault-orphans`' output (no backlinks) and `vault-deadends`'
117
+ (no outgoing links) — the intersection, not either alone: a note with backlinks but no outgoing
118
+ links is a dead end worth noting differently, not an orphan.
105
119
  - **Duplicate/near-duplicate titles** — group notes by title normalized (lowercased, trimmed,
106
120
  internal whitespace collapsed); any group with 2+ members is a finding. This is a mechanical
107
121
  string-normalization match, not fuzzy similarity — genuinely fuzzy "these might be the same
@@ -182,6 +196,6 @@ Print a short summary directly in the response, not left only in tool-call outpu
182
196
  Broken links, orphaned notes, and duplicate titles are proposal findings, never auto-repaired.
183
197
  - Invoked on demand only — no `SessionStart` wiring, no autonomous scheduling. Run it directly, or
184
198
  under a `/loop` the user sets up themselves.
185
- - No compiled code — every step above is a plain `mcp__obsidian__*` call, or (Step 3's broken-link
199
+ - Every step above goes through the `synapse` CLI's `vault-*` subcommands, or (Step 3's broken-link
186
200
  history check only) a plain `git log` via Bash against the vault's own local repo; this command
187
- itself never calls into `ObsidianStore`.
201
+ never calls an `mcp__obsidian__*` tool or `ObsidianStore` directly.
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "PostToolUse": [
24
24
  {
25
- "matcher": "Write|Edit|Bash|mcp__obsidian__vault_(write|patch|append|delete|move)",
25
+ "matcher": "Write|Edit|Bash",
26
26
  "hooks": [
27
27
  {
28
28
  "type": "command",
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "PostToolUse": [
24
24
  {
25
- "matcher": "apply_patch|Bash|mcp__obsidian__vault_(write|patch|append|delete|move)",
25
+ "matcher": "apply_patch|Bash",
26
26
  "hooks": [
27
27
  {
28
28
  "type": "command",
@@ -238,7 +238,7 @@ emit into tool calls than read into a window. Never hand-author those.
238
238
  **Do not choose which files to read by judgment, and do not decide how the writing itself
239
239
  happens by habit.** Both are decided by the `synapse-node-authoring` skill — **load it
240
240
  before writing the first node.** It resolves `SYNAPSE_AUTHOR_POOL` (env var, then
241
- `~/.claude/synapse.conf`, default 0) and either walks you through authoring every node
241
+ `synapse.conf`, default 0) and either walks you through authoring every node
242
242
  yourself in one continuous pass (`rank --sources` per node, `## Links` candidates from
243
243
  step 6's `links.tsv`, reading order only — `sources` stays exhaustive either way), or fans
244
244
  out to a configurable pool of concurrent subagents, each handed a self-contained
@@ -29,17 +29,17 @@ there is no argument to parse, every run produces the same one-pass sweep.
29
29
 
30
30
  ## Prerequisites
31
31
 
32
- Requires the `obsidian` MCP server (`mcp__obsidian__*` tools). If unreachable, say so and stop
33
- there is no local-file fallback.
34
-
35
- No compiled code anywhere in this skill. `adapters/obsidian/store.zig`'s `ObsidianStore` does now
36
- implement `read`/`list`/`search`, but for `synapse frontmatter get`/`set`'s narrow one-field-at-a-time
37
- use a full-vault tidy sweep, reading every note's whole body to judge category, tags and broken
38
- links, is a different shape of work entirely, and stays on these same MCP tools rather than the
39
- compiled binary, the same way the synapse-status and synapse-rebuild-diff skills' vault-side checks
40
- already work. The one exception is Step 3's broken-link history check, a plain `git log` call (via
41
- the shell, not a compiled tool) against the vault's own local repo when one exists best-effort,
42
- never a hard requirement.
32
+ Requires the `synapse` CLI on `PATH`, resolving a vault with a working link graph
33
+ (`synapse vault-backlinks`/`vault-links`/`vault-unresolved`/`vault-orphans`/`vault-deadends`). If
34
+ `SYNAPSE_VAULT_STORE` resolves to a backend with no link graph yet, these commands exit 1 saying so;
35
+ stop and report that rather than falling back to anything else.
36
+
37
+ This skill reaches the vault store only through the `synapse` CLI's `vault-*` subcommands, the same
38
+ door every other skill uses no MCP tool, no direct `ObsidianStore` call. `vault-links`/
39
+ `vault-backlinks` each answer for one note at a time, so Step 1's inventory sweep runs one pair per
40
+ note in scope: `2N` process spawns for an `N`-note vault. Acceptable for an on-demand, rare command.
41
+ Step 3's broken-link history check is a plain `git log` call (via the shell, not a compiled tool)
42
+ against the vault's own local repo when one exists — best-effort, never a hard requirement.
43
43
 
44
44
  ## Step 1: Inventory sweep
45
45
 
@@ -56,12 +56,23 @@ sweep rather than re-reading anything.
56
56
  ]}
57
57
  ```
58
58
 
59
- `mcp__obsidian__search_query` with that filter, then `mcp__obsidian__vault_read` each match. Keep
60
- the full result (`path`, `frontmatter`, `tags`, `links`, `backlinks`, `unresolvedLinks`, `stat`,
61
- `content`) per note Steps 2–4 below only ever reason over the structured fields, never `content`
62
- itself; the judgment layer in Step 5 is the first point anything actually reads note *bodies*, and
63
- only for the subset flagged by then. `content` is fetched for free in this same call regardless, so
64
- there's no separate "light" read to bother with.
59
+ `synapse vault-search --fields frontmatter,tags,content` with that filter on stdin gives `path`,
60
+ `frontmatter`, `tags`, `content` per matching note in one pass (`stat`'s `ctime`/`mtime` aren't a
61
+ `vault-search` fieldsee the note below). Also run `synapse vault-orphans`, `synapse
62
+ vault-deadends`, and `synapse vault-unresolved` once each, vault-wide three cheap bulk calls that
63
+ Step 3 cross-references by path rather than recomputing per note. For each note in scope, also run
64
+ `synapse vault-backlinks <path>` and `synapse vault-links <path>` and keep the results alongside it
65
+ — this is the `2N` cost named in Prerequisites, needed only for Step 4's weak-folder-fit signal
66
+ (which folder a note's actual link targets fall in, not just whether it has any). Keep the full
67
+ assembled result (`path`, `frontmatter`, `tags`, `content`, `links`, `backlinks`) per note — Steps
68
+ 2–4 below only ever reason over the structured fields, never `content` itself; the judgment layer in
69
+ Step 5 is the first point anything actually reads note *bodies*, and only for the subset flagged by
70
+ then.
71
+
72
+ `stat.ctime`/`stat.mtime` (Step 2's `created` default, Step 4's stale-category check) have no
73
+ `vault-search` equivalent — read a note's mtime directly off disk (`SYNAPSE_VAULT_DIR`-resolved
74
+ path) with a plain `stat` via the shell when either step needs it, same file the vault store itself
75
+ already addresses.
65
76
 
66
77
  ## Step 2: Frontmatter defaults (fixed directly)
67
78
 
@@ -74,25 +85,25 @@ derivable without guessing:
74
85
  - Missing `created` → `stat.ctime`, formatted `YYYY-MM-DD HH:MM` to match every other note's
75
86
  convention.
76
87
 
77
- Apply via read-modify-write on the whole file (`vault_read` → edit the one frontmatter line in the
78
- returned content → `vault_write` the whole file back) — never `vault_patch` with
79
- `targetType: frontmatter`, which re-serializes the entire YAML block and silently reformats
80
- unrelated fields, the same hazard the vault and task-status skills already document.
88
+ Apply via read-modify-write on the whole file (`synapse vault-read` → edit the one frontmatter line
89
+ in the returned content → `synapse vault-write` the whole file back) — never `vault-patch` with
90
+ `--frontmatter`, which re-serializes the entire YAML block and silently reformats unrelated fields,
91
+ the same hazard the vault and task-status skills already document.
81
92
 
82
93
  ## Step 3: Note-health findings (reported, not fixed)
83
94
 
84
- From the same inventory, no additional vault reads (the broken-link history check below reads the
85
- vault's local git log, not more notes). Each of these three has no safe mechanical repair —
86
- fixing any of them means guessing at intent — so they become findings for the Step 6 proposal
87
- instead of a silent edit:
88
-
89
- - **Broken links** — `unresolvedLinks` non-empty. Before writing the finding, check the vault's own
90
- local git history to say *why* it's broken instead of leaving that to guesswork — the vault is
91
- usually a git repo (a db-sync hook auto-commits every agent-driven edit into it, opt-in per vault,
92
- same precondition as that hook). Resolve the vault's filesystem path the same way its own conf
93
- file already does (`SYNAPSE_VAULT_DIR`), skip this sub-step entirely if `{vault}/.git` doesn't
94
- exist, and never let a missing/unreachable git repo block the rest of the finding — worst case
95
- it's reported with no history context, same as today.
95
+ From the same inventory plus Step 1's three bulk link-graph calls, no additional vault reads (the
96
+ broken-link history check below reads the vault's local git log, not more notes). Each of these
97
+ three has no safe mechanical repair — fixing any of them means guessing at intent — so they become
98
+ findings for the Step 6 proposal instead of a silent edit:
99
+
100
+ - **Broken links** — every `vault-unresolved` row whose `source` is in scope. Before writing the
101
+ finding, check the vault's own local git history to say *why* it's broken instead of leaving that
102
+ to guesswork — the vault is usually a git repo (a db-sync hook auto-commits every agent-driven edit
103
+ into it, opt-in per vault, same precondition as that hook). Resolve the vault's filesystem path the
104
+ same way its own conf file already does (`SYNAPSE_VAULT_DIR`), skip this sub-step entirely if
105
+ `{vault}/.git` doesn't exist, and never let a missing/unreachable git repo block the rest of the
106
+ finding — worst case it's reported with no history context, same as today.
96
107
  - `git -C {vault} log --all --diff-filter=A --name-only --pretty=format: -- "**/{target}.md"` — a
97
108
  hit means a note by that exact title was created at some point (even if later renamed or
98
109
  deleted): report it as *"used to be a note — find what it's called now, or was deleted"*.
@@ -102,7 +113,9 @@ instead of a silent edit:
102
113
  as plain text or an external reference, not a vault link"* rather than implying anything was
103
114
  lost.
104
115
  - Neither check resolves anything more specific → report the target plainly, same as before.
105
- - **Orphaned notes** — `links` empty *and* `backlinks` empty.
116
+ - **Orphaned notes** — in scope, in both `vault-orphans`' output (no backlinks) and `vault-deadends`'
117
+ (no outgoing links) — the intersection, not either alone: a note with backlinks but no outgoing
118
+ links is a dead end worth noting differently, not an orphan.
106
119
  - **Duplicate/near-duplicate titles** — group notes by title normalized (lowercased, trimmed,
107
120
  internal whitespace collapsed); any group with 2+ members is a finding. This is a mechanical
108
121
  string-normalization match, not fuzzy similarity — genuinely fuzzy "these might be the same
@@ -182,6 +195,6 @@ Print a short summary directly in the response, not left only in tool-call outpu
182
195
  Broken links, orphaned notes, and duplicate titles are proposal findings, never auto-repaired.
183
196
  - Invoked on demand only — no session-start wiring, no autonomous scheduling. Run it only when
184
197
  asked, or on a recurring cadence the user sets up themselves.
185
- - No compiled code — every step above is a plain `mcp__obsidian__*` call, or (Step 3's broken-link
198
+ - Every step above goes through the `synapse` CLI's `vault-*` subcommands, or (Step 3's broken-link
186
199
  history check only) a plain `git log` via the shell against the vault's own local repo; this skill
187
- itself never calls into `ObsidianStore`.
200
+ never calls an `mcp__obsidian__*` tool or `ObsidianStore` directly.
@@ -21,19 +21,15 @@
21
21
  // Claude Code's `Write`/`Edit` sharing `tool_input.file_path`. `db_sync.zig`
22
22
  // itself is a blind `git add -A && commit` against the vault's own repo, so
23
23
  // this plugin only needs to decide *whether* to fire it, not translate a
24
- // payload -- fired on the same write/edit tools, the two vault-mutating MCP
25
- // tools (`obsidian_vault_write`/`obsidian_vault_patch`, the same OpenCode
26
- // tool-naming transform `mcp__obsidian__vault_write`/`vault_patch` go
27
- // through elsewhere in this plugin's own skill/command prose), and `bash`
28
- // (`args.command`, confirmed against `packages/core/src/tool/bash.ts` in
29
- // the real `sst/opencode` source) when the command actually names `synapse
30
- // vault-write`/`vault-patch` -- the CLI door skills use instead of calling
31
- // the MCP tools directly (see `sb Vault store backend selection`). Unlike
32
- // Claude Code's/Codex's `hooks.json`, which can only match a tool *name* and
33
- // so must widen their own matcher to `Bash` and let `db_sync.zig` filter the
34
- // command text itself, this plugin has the real args in hand before ever
35
- // spawning the hook, so it filters right here instead -- no spawn at all for
36
- // an unrelated `bash` call.
24
+ // payload -- fired on the same write/edit tools, and on `bash` (`args.command`,
25
+ // confirmed against `packages/core/src/tool/bash.ts` in the real
26
+ // `sst/opencode` source) when the command actually names `synapse
27
+ // vault-write`/`vault-patch` -- the CLI door skills use to reach the vault
28
+ // (see `sb Vault store backend selection`). Unlike Claude Code's/Codex's
29
+ // `hooks.json`, which can only match a tool *name* and so must widen their
30
+ // own matcher to `Bash` and let `db_sync.zig` filter the command text itself,
31
+ // this plugin has the real args in hand before ever spawning the hook, so it
32
+ // filters right here instead -- no spawn at all for an unrelated `bash` call.
37
33
  //
38
34
  // `stop-nudge`'s Claude Code trigger (`Stop`, once per turn) maps to
39
35
  // `session.idle` -- live-verified as firing exactly once, after every tool
@@ -111,7 +107,6 @@ async function alreadyInjected(client, sessionID) {
111
107
  }
112
108
 
113
109
  const EDIT_TOOLS = new Set(["write", "edit"])
114
- const VAULT_WRITE_TOOLS = new Set(["obsidian_vault_write", "obsidian_vault_patch"])
115
110
 
116
111
  // A `bash` call whose command names a `synapse vault-write`/`vault-patch`
117
112
  // invocation -- the substring check a real shell quoting/path prefix can't
@@ -162,8 +157,6 @@ export const Synapse = async ({ directory, client }) => {
162
157
  tool_input: { file_path: filePath },
163
158
  })
164
159
  runHook("db-sync", {});
165
- } else if (VAULT_WRITE_TOOLS.has(input.tool)) {
166
- runHook("db-sync", {});
167
160
  } else if (input.tool === "bash" && isVaultWriteCommand(input.args?.command)) {
168
161
  runHook("db-sync", {});
169
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imunitic/synapse",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Memory for Claude Code, Codex CLI, and OpenCode: a durable Obsidian vault plus a per-repo code graph.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,9 +22,9 @@
22
22
  "*.conf.template"
23
23
  ],
24
24
  "optionalDependencies": {
25
- "@imunitic/synapse-darwin-arm64": "0.2.3",
26
- "@imunitic/synapse-linux-x64": "0.2.3",
27
- "@imunitic/synapse-linux-arm64": "0.2.3"
25
+ "@imunitic/synapse-darwin-arm64": "0.2.5",
26
+ "@imunitic/synapse-linux-x64": "0.2.5",
27
+ "@imunitic/synapse-linux-arm64": "0.2.5"
28
28
  },
29
29
  "license": "SEE LICENSE IN LICENSE"
30
30
  }
@@ -26,16 +26,28 @@ ever loaded.
26
26
  ```sh
27
27
  pool="${SYNAPSE_AUTHOR_POOL:-}"
28
28
  if [ -z "$pool" ]; then
29
- pool="$(grep -m1 '^SYNAPSE_AUTHOR_POOL=' ~/.claude/synapse.conf 2>/dev/null | cut -d= -f2- | tr -d '"')"
29
+ for conf in \
30
+ ${XDG_CONFIG_HOME:+"$XDG_CONFIG_HOME/synapse/synapse.conf"} \
31
+ "$HOME/.config/synapse/synapse.conf" \
32
+ "$HOME/.claude/synapse.conf"
33
+ do
34
+ if [ -f "$conf" ]; then
35
+ pool="$(grep -m1 '^SYNAPSE_AUTHOR_POOL=' "$conf" 2>/dev/null | cut -d= -f2- | tr -d '"')"
36
+ break
37
+ fi
38
+ done
30
39
  fi
31
40
  pool="${pool:-0}"
32
41
  ```
33
42
 
34
- Environment variable wins over the conf file, same precedence every other Synapse setting
35
- uses. Absent, empty, or malformed all fall through to `0`. **This is read by you, the
36
- orchestrating agent, directly not by any Zig binary.** Nothing compiled dispatches
37
- subagents, so there is nothing for a `synapse` flag to feed. The conf file is the shared home
38
- for the setting; the reader is markdown, not code.
43
+ Environment variable wins over the conf file. The conf lookup is the same three-tier order
44
+ every `synapse-*.conf` file resolves through (see `docs/synapse/synapse-config.md`, "Where a
45
+ conf file actually lives"): the first tier with a file at all wins, whether or not that file
46
+ sets `SYNAPSE_AUTHOR_POOL` a `synapse.conf` at `~/.claude/` is not consulted when one already
47
+ exists at the XDG tier, even if the XDG one is silent on this key. Absent, empty, or malformed
48
+ all fall through to `0`. **This is read by you, the orchestrating agent, directly — not by any
49
+ Zig binary.** Nothing compiled dispatches subagents, so there is nothing for a `synapse` flag to
50
+ feed. The conf file is the shared home for the setting; the reader is markdown, not code.
39
51
 
40
52
  `pool = 0` is not "a pool of zero workers" — go to §2. `pool >= 1` is a real worker pool — go
41
53
  to §3.
@@ -31,7 +31,10 @@ symbol names on CamelCase and snake_case, drops stopwords, and aggregates per di
31
31
  path rather than the code subset — the interesting files here are usually the ones no grammar
32
32
  can read.
33
33
  - `namespaces.tsv` — `group ⇥ namespace ⇥ agree ⇥ total`, where a rule is configured (question 3,
34
- below, for whichever extensions `~/.claude/synapse-namespace-rules.conf` knows about).
34
+ below, for whichever extensions `synapse-namespace-rules.conf` knows about -- resolved through
35
+ the standard tiered lookup every `synapse-*.conf` file uses, see
36
+ `docs/synapse/synapse-config.md`'s "Where a conf file actually lives"; read/write whichever tier
37
+ actually resolves, never assume `~/.claude/`).
35
38
  - `parseable.tsv` and `distinctive.tsv` feed `synapse gate` and this section's own distinctiveness
36
39
  question respectively — see immediately below.
37
40
 
@@ -75,9 +78,10 @@ is about the gap between what the code calls itself and what the *directory* cal
75
78
  family) are what a node's prose should be about.
76
79
 
77
80
  **Namespace rules are self-populating too, the same way grammar discovery below is — write one
78
- back when question 3 turns up a real declaration convention.** `~/.claude/synapse-namespace-rules.conf`
79
- starts empty and nothing seeds it, so `namespaces.tsv` stays empty forever unless something
80
- writes a rule into it. If you just hand-derived a namespace root for an ecosystem this repo uses
81
+ back when question 3 turns up a real declaration convention.** `synapse-namespace-rules.conf`
82
+ (same tiered lookup as above) starts empty and nothing seeds it, so `namespaces.tsv` stays empty
83
+ forever unless something writes a rule into it. If you just hand-derived a namespace root for an
84
+ ecosystem this repo uses
81
85
  and `synapse-namespace-rules.conf` has no entry for its extension yet, the derivation you just
82
86
  did *is* the rule — write it back (create the file as `{}` first if it doesn't exist) so the next
83
87
  repo in this ecosystem gets `namespaces.tsv` for free instead of a repeat of this same
@@ -219,7 +223,8 @@ contribute to a node's prose. Do not build a tally out of those warnings.
219
223
  handling covers a real, cross-grammar class, a per-grammar word substitution would not.
220
224
 
221
225
  The kind-synonym rule list both bullets above lean on is
222
- `~/.claude/synapse-kind-synonyms.conf` (`SYNAPSE_KIND_SYNONYMS_CONF` overrides the path), the
226
+ `synapse-kind-synonyms.conf` (`SYNAPSE_KIND_SYNONYMS_CONF` overrides the path; otherwise the
227
+ same tiered lookup as above), the
223
228
  same shape and precedence as `synapse-grammars.conf`/`synapse-namespace-rules.conf` — ordered
224
229
  rules, first match wins, absent means no mapping rather than a guessed one. Keyed by a
225
230
  `locals.scm` capture's kind suffix (or the empty string for an unsuffixed capture) or by a
@@ -325,7 +330,7 @@ contribute to a node's prose. Do not build a tally out of those warnings.
325
330
  query file that preempts the whole cascade, not a tier to reach automatically. Name it as an
326
331
  option when reporting the outcome (step 5) rather than silently accepting a weak result or a
327
332
  bare `unsupported`.
328
- 4. Write the result back to `~/.claude/synapse-grammars.conf` (create it as `{}` first if it
333
+ 4. Write the result back to `synapse-grammars.conf` (same tiered lookup as above; create it as `{}` first if it
329
334
  doesn't exist) — a positive entry (`{"repo": "...", "scope": "..."}`) for whichever tier
330
335
  verified, `{"unsupported": true}` only when both came up empty or unusable. Record which
331
336
  tier with `"queries"`: omit it (or write `"tags"`) for a real `queries/tags.scm` found in the
package/synapse-claude.md CHANGED
@@ -95,22 +95,26 @@ a real yes/no answer, not a formality to wave past.
95
95
  ## Reading and writing the vault
96
96
 
97
97
  The vault is reached through the `synapse` CLI — `synapse vault-read`/`vault-write`/`vault-list`/
98
- `vault-search`/`vault-search-text`/`vault-doc-map`/`vault-patch` — for reads *and* for writes, never
99
- by resolving a vault path or calling an `mcp__obsidian__*` tool directly. Which concrete store the
100
- CLI talks to (today, an Obsidian vault via its Local REST API; potentially a plain-disk vault later)
101
- is resolved once, inside the compiled binary, from `SYNAPSE_VAULT_STORE`/`SYNAPSE_VAULT_DIR` never
102
- something a skill or an agent turn needs to know or branch on. Today that means Obsidian running
103
- headless at login (via a startup plugin) with the Local REST API plugin installed, and the CLI
104
- reaching it over that REST API; a future disk-backed vault would need no change to any skill or to
105
- this document.
98
+ `vault-search`/`vault-search-text`/`vault-doc-map`/`vault-patch`/`vault-backlinks`/`vault-links`/
99
+ `vault-unresolved`/`vault-orphans`/`vault-deadends` for reads *and* for writes, never by resolving
100
+ a vault path or calling an `mcp__obsidian__*` tool directly. Which concrete store the CLI talks to
101
+ (today, an Obsidian vault reached through its own official CLI; also a plain-disk vault,
102
+ `SYNAPSE_VAULT_STORE=disk`) is resolved once, inside the compiled binary, from
103
+ `SYNAPSE_VAULT_STORE`/`SYNAPSE_VAULT_DIR` never something a skill or an agent turn needs to know or
104
+ branch on. Today that means Obsidian running headless at login, with no plugin, no cert, no MCP
105
+ server needed: `read`/`write`/`list` are plain disk I/O against the vault folder, and
106
+ `search`/the link graph go through Obsidian's own CLI over its local socket — off by default, so a
107
+ `vault-search-text`/`vault-backlinks`/etc. failure is worth mentioning **Settings → General →
108
+ Advanced → Command line interface** for, alongside stopping on it (see the precondition-failure
109
+ rule below).
106
110
 
107
111
  **Every write to a note goes through `synapse vault-write` or `vault-patch`. Never the `Write`/`Edit`
108
112
  tools on the on-disk path, and never a raw `mcp__obsidian__*` tool call either** — not for a one-line
109
113
  change, and least of all when `Write`/`Edit` are already in hand from editing code earlier in the
110
114
  same turn, because that proximity is precisely what causes this to be violated. The vault being an
111
115
  ordinary directory means the wrong path *works*: Obsidian's file watcher converges, the auto-commit
112
- hook matches `Write|Edit`/`Bash` running `vault-write`/`vault-patch` as well as the MCP tools, and
113
- nothing visibly breaks — which is why the habit never self-corrects on its own. The reason is not a
116
+ hook matches `Write|Edit`/`Bash` running `vault-write`/`vault-patch`, and nothing visibly breaks
117
+ which is why the habit never self-corrects on its own. The reason is not a
114
118
  failure mode to dodge; it is that an invariant upheld only when convenient is worth nothing. Nothing
115
119
  else in the system can rely on it, and every note then has to be re-checked by hand instead of
116
120
  trusted. Synapse's own tooling holds this line — `synapse write-node` goes through the same `Store`
@@ -118,13 +122,8 @@ abstraction the CLI does, rather than writing files directly — so agent writes
118
122
  differ. If the CLI itself ever fails (not installed, no vault configured), that's a real precondition
119
123
  failure to report and stop on, never a reason to fall back to a raw file edit.
120
124
 
121
- The `obsidian` MCP server (wrapping the same Local REST API) still exists and is still the right
122
- tool for a command that hasn't been migrated onto the CLI's `vault-*` subcommands yet (`/synapse-vault-tidy`, as of
123
- this writing — it needs whole-vault link/backlink data the CLI doesn't expose). If a project's
124
- `.claude.json` `mcpServers.obsidian` entry ever diverges from the user-scoped one (e.g. points at the
125
- wrong vault path via a stdio `obsidian-mcp` package instead of the REST API), that's a bug in that
126
- project's config, not a Synapse Vault routing choice — fix it by removing the project-level override
127
- so the correct user-scoped REST API server applies.
125
+ Every shipped command and skill, `/synapse-vault-tidy` included, reaches the vault only through
126
+ the `synapse` CLI's `vault-*` subcommands none of them calls an `mcp__obsidian__*` tool.
128
127
 
129
128
  - You may create and edit notes in this vault **without asking for
130
129
  permission first**, as long as each note is placed in the folder
@@ -1,4 +1,5 @@
1
- # Copy this to ~/.claude/synapse.conf and edit the path for this machine.
1
+ # synapse-setup configure copies this file in automatically when it finds no
2
+ # synapse.conf anywhere. Edit the path below for this machine.
2
3
  SYNAPSE_VAULT_DIR="$HOME/Obsidian/YourVault"
3
4
 
4
5
  # Where Synapse clones/builds tree-sitter grammars (shared across every
@@ -1,303 +0,0 @@
1
- #!/usr/bin/env node
2
- // SessionStart hook: keeps the `obsidian` MCP server registration current,
3
- // automatically, every session -- the hook-driven replacement for manually
4
- // running setup-obsidian-mcp.sh. Independent of, and never wired through,
5
- // fetch-and-run.cjs/synapse-hook: different domain (wiring up the connection
6
- // to the vault, not Synapse's own code-graph/vault logic), kept separate so
7
- // a bug here can never risk the core Synapse injection into every turn's
8
- // context.
9
- //
10
- // Node, not a shell script: the only interpreter every hook subprocess is
11
- // guaranteed to have on PATH is the one Claude Code itself ships with. `jq`
12
- // and `curl` are gone entirely (native JSON.parse and the built-in `https`
13
- // module replace them); `claude` itself is still an external dependency,
14
- // since MCP registration is its own command, not something this hook can do
15
- // any other way.
16
- //
17
- // Every missing precondition is silence, same convention every other Synapse
18
- // hook already follows -- this never blocks a turn, and produces no stdout
19
- // (a SessionStart hook's stdout becomes injected context; this is a pure
20
- // side-effecting maintenance task, nothing worth telling the model). Genuine
21
- // problems go to stderr only, same as the original script already did.
22
- //
23
- // One real, pre-existing limitation, not new here: MCP servers connect at
24
- // Claude Code startup, before any hook fires, so this can't fix the *current*
25
- // session's connection -- first-time registration or a cert/key rotation
26
- // still needs one restart to take effect.
27
-
28
- "use strict";
29
-
30
- const fs = require("fs");
31
- const os = require("os");
32
- const path = require("path");
33
- const https = require("https");
34
- const { spawnSync } = require("child_process");
35
-
36
- function commandExists(cmd) {
37
- const res = spawnSync(cmd, ["--version"], { stdio: "ignore" });
38
- return !(res.error && res.error.code === "ENOENT");
39
- }
40
-
41
- // `$VAR`/`${VAR}` expansion looks HOME up through this rather than reading
42
- // `process.env.HOME` directly, so the one thing standing between this
43
- // resolving correctly and not on a platform with no `HOME` env var (Windows)
44
- // is this one substitution -- everything downstream (conf-file tiering,
45
- // `~`/`$HOME` expansion) is otherwise a direct port of core/conf.zig's
46
- // algorithm and stays untouched.
47
- function getVar(name) {
48
- if (name === "HOME") return os.homedir();
49
- return process.env[name];
50
- }
51
-
52
- // Mirrors core/conf.zig's `get`: one key's raw value from a conf file's
53
- // text, unexpanded. Comments, blank lines, an `export` prefix, and a
54
- // matched-quote or trailing-comment unquote, same as a shell would read it.
55
- // The last assignment wins, same as sourcing would.
56
- function confGet(text, key) {
57
- let found = null;
58
- for (let raw of text.split("\n")) {
59
- let line = raw.trim();
60
- if (line.length === 0 || line[0] === "#") continue;
61
- if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();
62
- if (!line.startsWith(key)) continue;
63
- const rest = line.slice(key.length);
64
- if (rest.length === 0 || rest[0] !== "=") continue;
65
- found = confUnquote(rest.slice(1).trim());
66
- }
67
- return found;
68
- }
69
-
70
- function confUnquote(raw) {
71
- if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) {
72
- return raw.slice(1, -1);
73
- }
74
- const m = raw.match(/[#\s]/);
75
- return m ? raw.slice(0, m.index) : raw;
76
- }
77
-
78
- const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
79
-
80
- // Mirrors core/conf.zig's `expand`: a leading `~`, `$VAR` and `${VAR}`
81
- // replaced against `getVar`; everything else (including `${VAR:-default}`,
82
- // `~user`, and a Windows path's backslashes) left exactly as-is.
83
- function confExpand(raw) {
84
- let out = "";
85
- let i = 0;
86
- if (raw.length !== 0 && raw[0] === "~" && (raw.length === 1 || raw[1] === "/")) {
87
- out += getVar("HOME") || "";
88
- i = 1;
89
- }
90
- while (i < raw.length) {
91
- if (raw[i] === "\\" && raw[i + 1] === "$") {
92
- out += "$";
93
- i += 2;
94
- continue;
95
- }
96
- if (raw[i] !== "$") {
97
- out += raw[i];
98
- i += 1;
99
- continue;
100
- }
101
- const after = raw.slice(i + 1);
102
- if (after[0] === "{") {
103
- const close = after.indexOf("}");
104
- if (close === -1) {
105
- out += raw[i];
106
- i += 1;
107
- continue;
108
- }
109
- const name = after.slice(1, close);
110
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
111
- out += raw[i];
112
- i += 1;
113
- continue;
114
- }
115
- out += getVar(name) || "";
116
- i += 1 + close + 1;
117
- continue;
118
- }
119
- const m = after.match(NAME_RE);
120
- if (!m) {
121
- out += raw[i];
122
- i += 1;
123
- continue;
124
- }
125
- out += getVar(m[0]) || "";
126
- i += 1 + m[0].length;
127
- }
128
- return out;
129
- }
130
-
131
- // Mirrors core/conf.zig's `resolveExisting` (tiers 1-2 only -- this hook,
132
- // like the shell script it replaces, never falls back to the plugin's own
133
- // bundled template: that template's SYNAPSE_VAULT_DIR is a placeholder
134
- // path, not a real vault, and silently trying it would be worse than doing
135
- // nothing).
136
- function resolveConfPath(name) {
137
- const xdg = process.env.XDG_CONFIG_HOME;
138
- const home = getVar("HOME");
139
- const candidates = [];
140
- if (xdg) candidates.push(path.join(xdg, "synapse", name));
141
- if (home) candidates.push(path.join(home, ".config", "synapse", name));
142
- if (home) candidates.push(path.join(home, ".claude", name));
143
- for (const p of candidates) {
144
- if (fs.existsSync(p)) return p;
145
- }
146
- return null;
147
- }
148
-
149
- // Mirrors core/conf.zig's `vaultDir`: the env override first, then each
150
- // conf file name in turn (synapse.conf, then the pre-rename second-brain.conf
151
- // as a fallback), each resolved through the same tier order.
152
- function resolveVaultDir() {
153
- const override = process.env.SYNAPSE_VAULT_DIR;
154
- if (override) return override;
155
-
156
- for (const name of ["synapse.conf", "second-brain.conf"]) {
157
- const confPath = resolveConfPath(name);
158
- if (!confPath) continue;
159
- let text;
160
- try {
161
- text = fs.readFileSync(confPath, "utf8");
162
- } catch {
163
- continue;
164
- }
165
- const raw = confGet(text, "SYNAPSE_VAULT_DIR");
166
- if (raw === null) continue;
167
- const expanded = confExpand(raw);
168
- if (expanded) return expanded;
169
- }
170
- return null;
171
- }
172
-
173
- // Same request curl -s --cacert ... --max-time 5 made: any HTTP response
174
- // (whatever the status) counts as reachable, matching curl's own default of
175
- // treating a completed request, not a 2xx, as success. Only a TLS/connection
176
- // failure or the timeout counts as unreachable.
177
- function endpointReachable(port, certPath) {
178
- return new Promise((resolve) => {
179
- let ca;
180
- try {
181
- ca = fs.readFileSync(certPath);
182
- } catch {
183
- resolve(false);
184
- return;
185
- }
186
- const req = https.request(
187
- { hostname: "127.0.0.1", port, path: "/", method: "GET", ca, timeout: 5000 },
188
- (res) => {
189
- res.resume();
190
- resolve(true);
191
- }
192
- );
193
- req.on("timeout", () => {
194
- req.destroy();
195
- resolve(false);
196
- });
197
- req.on("error", () => resolve(false));
198
- req.end();
199
- });
200
- }
201
-
202
- function updateSettingsCaCert(settingsPath, certPath) {
203
- let settings = {};
204
- try {
205
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
206
- } catch {
207
- // Missing file -> {} (matches the original always seeding one); invalid
208
- // JSON in an existing file -> also {}, but written to a temp file and
209
- // swapped in atomically below, same as jq's own "never touch the
210
- // original until the replacement is known-good" behavior.
211
- }
212
- settings.env = settings.env || {};
213
- settings.env.NODE_EXTRA_CA_CERTS = certPath;
214
-
215
- const tmp = path.join(os.tmpdir(), `obsidian-mcp-refresh.${process.pid}.${Date.now()}`);
216
- fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
217
- fs.renameSync(tmp, settingsPath);
218
- }
219
-
220
- // Registering means removing first -- `claude mcp add` will not overwrite an
221
- // existing name, and there is no atomic replace.
222
- function isRegistered() {
223
- const res = spawnSync("claude", ["mcp", "get", "obsidian"], { stdio: "ignore" });
224
- return !res.error && res.status === 0;
225
- }
226
-
227
- function main() {
228
- if (!commandExists("claude")) return;
229
-
230
- const vault = resolveVaultDir();
231
- if (!vault || !fs.existsSync(vault)) return;
232
-
233
- const pluginData = path.join(vault, ".obsidian", "plugins", "obsidian-local-rest-api", "data.json");
234
- if (!fs.existsSync(pluginData)) return;
235
-
236
- const home = os.homedir();
237
- const certPath = path.join(home, ".claude", "obsidian-local-rest-api-ca.pem");
238
-
239
- let data;
240
- try {
241
- data = JSON.parse(fs.readFileSync(pluginData, "utf8"));
242
- } catch {
243
- return;
244
- }
245
-
246
- const apiKey = data.apiKey;
247
- const port = data.port;
248
- if (!apiKey || !port) return;
249
- if (!data.crypto || typeof data.crypto.cert !== "string") return;
250
-
251
- fs.mkdirSync(path.dirname(certPath), { recursive: true });
252
- fs.writeFileSync(certPath, data.crypto.cert);
253
-
254
- endpointReachable(port, certPath).then((endpointOk) => {
255
- // Written unconditionally, before the registered/reachable check below --
256
- // the cert path itself doesn't depend on whether the endpoint answers
257
- // right now, so a session that never gets past the "leave it alone"
258
- // branch still ends up with NODE_EXTRA_CA_CERTS pointing at the current
259
- // cert.
260
- const settingsPath = path.join(home, ".claude", "settings.json");
261
- try {
262
- updateSettingsCaCert(settingsPath, certPath);
263
- } catch {
264
- // Best-effort, matching the original: a failed settings.json update
265
- // does not block anything below.
266
- }
267
-
268
- const registered = isRegistered();
269
-
270
- // An existing registration that already works is, by definition, one
271
- // that worked at some point; replacing it with one pointing at a port
272
- // that does not currently answer trades something working for nothing,
273
- // so leave it alone in that case rather than risk it.
274
- if (!endpointOk && registered) {
275
- process.stderr.write(
276
- "synapse: obsidian MCP endpoint unreachable, left the existing registration in place\n"
277
- );
278
- return;
279
- }
280
-
281
- spawnSync("claude", ["mcp", "remove", "obsidian", "-s", "user"], { stdio: "ignore" });
282
- spawnSync(
283
- "claude",
284
- [
285
- "mcp",
286
- "add",
287
- "--transport",
288
- "http",
289
- "obsidian",
290
- `https://127.0.0.1:${port}/mcp/`,
291
- "--header",
292
- `Authorization: Bearer ${apiKey}`,
293
- "-s",
294
- "user",
295
- ],
296
- { stdio: "ignore" }
297
- );
298
- });
299
- }
300
-
301
- if (require.main === module) main();
302
-
303
- module.exports = { confGet, confUnquote, confExpand, resolveConfPath, resolveVaultDir };