seekstone 0.12.0 → 0.12.2

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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # seekstone
2
2
 
3
- **The Obsidian MCP server for Claude — search and edit your vault without burning context.**
3
+ **The Obsidian MCP server that needs no plugin, no running Obsidian app — and doesn't blow your context window.**
4
4
 
5
5
  Seekstone is an Obsidian MCP server that gives Claude (and any [Model Context Protocol](https://modelcontextprotocol.io) client) direct read and write access to your Obsidian vault. No Obsidian app needs to be running, no plugins are required, and nothing leaves your machine.
6
6
 
7
- It reads your vault **directly from disk** instead of routing through the Obsidian Local REST API plugin. The practical difference: a search that returns ~1.75 MB and ~459,000 tokens via the REST plugin returns **~3 KB and ~800 tokens** via Seekstone — a **~575× reduction**. Claude can search and read your entire note library without burning most of its context window on a single tool call.
7
+ It reads your vault **directly from disk** instead of routing through the Obsidian Local REST API plugin, and holds a warm full-text index in-process. The practical difference: searches return in **single-digit milliseconds** with **~2 KB payloads** that stay flat as your vault grows — a broad query that costs tens of megabytes of context through a REST-proxy server (up to **95 MB** at 10k notes) costs ~2 KB through Seekstone, up to a **~47,000× reduction**. Benchmarked against 7 other Obsidian MCP servers across committed 1k/5k/10k-note vaults — [fully reproducible](https://github.com/shaqmughal/seekstone/tree/main/packages/harness), full results at [seekstone.dev/benchmarks](https://seekstone.dev/benchmarks).
8
8
 
9
9
  (Previously also published as `obsidian-mcp-seekstone` — that alias is deprecated; existing installs keep working, but install `seekstone` going forward.)
10
10
 
@@ -16,6 +16,12 @@ It reads your vault **directly from disk** instead of routing through the Obsidi
16
16
 
17
17
  Choose the method that suits you best.
18
18
 
19
+ ### Using an AI agent? Paste this prompt
20
+
21
+ If you use Claude Code, Cursor, or another coding agent, paste this prompt and the agent does the install:
22
+
23
+ > Install the **seekstone** MCP server for this editor. Run `npx -y seekstone init --client code --write` (use `desktop`, `cursor`, or `vscode` for other clients). It auto-detects my Obsidian vault; if it lists several, ask me which one and re-run with `--vault "<path>"`. Relay any errors to me, then tell me to restart this session so the seekstone tools load.
24
+
19
25
  ### Option 1 — One-click (Claude Desktop, no terminal needed)
20
26
 
21
27
  Download `seekstone.mcpb` from [GitHub Releases](https://github.com/shaqmughal/seekstone/releases/latest), double-click it in Claude Desktop, and pick your Obsidian vault folder when prompted. No JSON editing, no terminal, no Node.js setup required.
@@ -28,7 +34,7 @@ Run the setup helper and let Seekstone find your vault automatically:
28
34
  npx -y seekstone init
29
35
  ```
30
36
 
31
- Seekstone reads Obsidian's own vault registry to detect your vault, validates it, and either prints the config to paste or patches Claude Desktop directly:
37
+ Seekstone reads Obsidian's own vault registry to detect your vault, validates it, and either prints the config to paste or patches the client config directly:
32
38
 
33
39
  ```bash
34
40
  # Auto-detect vault, print config to paste
@@ -40,8 +46,10 @@ npx -y seekstone init --write
40
46
  # Specify vault explicitly if you have multiple
41
47
  npx -y seekstone init --vault "/path/to/vault"
42
48
 
43
- # Generate the Claude Code command instead
44
- npx -y seekstone init --client code
49
+ # Auto-configure Claude Code / Cursor / VS Code in one step
50
+ npx -y seekstone init --client code --write
51
+ npx -y seekstone init --client cursor --write
52
+ npx -y seekstone init --client vscode --write
45
53
  ```
46
54
 
47
55
  ### Option 3 — Manual config (Claude Desktop)
@@ -84,17 +92,27 @@ One-click via the [Install in VS Code](https://vscode.dev/redirect?url=vscode:mc
84
92
  code --add-mcp '{"name":"seekstone","command":"npx","args":["-y","seekstone"],"env":{"SEEKSTONE_VAULT":"/absolute/path/to/your/vault"}}'
85
93
  ```
86
94
 
95
+ Or auto-detect and write the workspace config:
96
+
97
+ ```bash
98
+ npx -y seekstone init --client vscode --write
99
+ ```
100
+
87
101
  Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their own MCP config file.
88
102
 
89
103
  ---
90
104
 
91
105
  ## Tools
92
106
 
107
+ 18 tools: 10 read, 8 write.
108
+
93
109
  ### Read
94
110
 
95
111
  | Tool | Description |
96
112
  |---|---|
97
- | `search` | Full-text search. Returns ranked excerpts (default ~120 chars, tunable via `excerptLength`), not full notes. Fuzzy, prefix, and phrase queries. |
113
+ | `search` | Full-text search. Returns ranked excerpts (default ~120 chars, tunable via `excerptLength`), not full notes. Fuzzy and prefix matching. |
114
+ | `query_notes` | Structured metadata query. Filter by frontmatter key/value predicates (`eq`, `ne`, `contains`, `exists`, `missing`, `gt`/`gte`/`lt`/`lte`), tag, folder, modified time, and size; sort and select the fields you need. Returns compact rows, not note content. |
115
+ | `context_pack` | Answer-ready context for a natural-language question in one call, hard-capped at a byte budget (default 2 KB): ranked excerpts, linked neighbor notes with one-line summaries, and follow-up source paths — replaces a search → read → get_backlinks round-trip loop. |
98
116
  | `read_note` | Read the full content of a note by vault-relative path. Supports returning a single section, block, or line range. |
99
117
  | `list_notes` | List notes, optionally filtered by folder prefix or tag. |
100
118
  | `list_tags` | List all tags in the vault sorted by usage count (or alphabetically). |
@@ -107,15 +125,17 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
107
125
 
108
126
  | Tool | Description |
109
127
  |---|---|
110
- | `create_note` | Create a note (optional frontmatter + body); parent dirs created automatically. |
111
- | `delete_note` | Permanently delete a note. |
112
- | `move_note` | Move/rename a note; destination dirs created automatically. |
128
+ | `create_note` | Create a note (optional frontmatter + body); parent dirs created automatically. Never clobbers an existing note unless you pass `overwrite: true`. |
129
+ | `delete_note` | Move a note to the vault's `.trash/` folder (Obsidian-compatible, restorable). Pass `permanent: true` for an unrecoverable delete. |
130
+ | `move_note` | Move/rename a note — wikilinks and markdown links in other notes that point at it are rewritten so nothing breaks; destination dirs created automatically. |
113
131
  | `append_note` | Append to a note body without touching frontmatter. |
114
132
  | `patch_frontmatter` | Set/update/delete frontmatter keys without reordering existing keys or changing quote style. |
115
133
  | `patch_note` | Insert text immediately after a heading without touching frontmatter. |
116
- | `replace_in_note` | Replace the first occurrence of a word or phrase in the note body. |
134
+ | `replace_in_note` | Find and replace text in the note body — literal or regex, whole-word, case sensitivity, optional `limit` (replaces **all** occurrences by default), dry-run preview. |
117
135
  | `append_periodic_note` | Append to today's periodic note, creating it from a template if it doesn't yet exist. |
118
136
 
137
+ The content-editing tools (`append_note`, `patch_note`, `patch_frontmatter`, `replace_in_note`, `append_periodic_note`, and `create_note` with `overwrite: true`) support optional **compare-and-swap**: pass the `contentHash` you got from `read_note` as `prevHash` and the write fails cleanly if the note changed underneath you, instead of silently discarding the concurrent edit.
138
+
119
139
  ---
120
140
 
121
141
  ## Configuration
@@ -126,6 +146,15 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
126
146
  | `SEEKSTONE_LOG_LEVEL` | no | `error` \| `warn` \| `info` (default) \| `debug`. |
127
147
  | `SEEKSTONE_LOG_FILE` | no | Absolute path; when set, JSON-line logs are appended here (size-rotated). |
128
148
  | `SEEKSTONE_WATCH_POLL` | no | Set to `1` to stat-poll for changes instead of native OS events — reliable on network drives, WSL, containers. |
149
+ | `SEEKSTONE_LOG_MAX_SIZE` | no | Log-rotation threshold for `SEEKSTONE_LOG_FILE` (e.g. `10mb`; default 5 MB). |
150
+ | `SEEKSTONE_READ_ONLY` | no | Set to `1` to run read-only: the 8 write tools are unregistered entirely (and rejected if called anyway), so the session provably cannot modify your vault. |
151
+ | `SEEKSTONE_WRITE_PATHS` | no | Comma-separated vault-relative globs (e.g. `journal/**,inbox/*.md`). Writes are permitted only under matching paths; the rest of the vault stays read-only. |
152
+
153
+ ---
154
+
155
+ ## Write safety
156
+
157
+ Giving an AI write access to your notes deserves more than "trust us." Seekstone ships the [Write-Safety Contract](https://github.com/shaqmughal/seekstone/blob/main/docs/WRITE-SAFETY.md): **eight named guarantees, each linked to the code that enforces it and the test that proves it**, verified byte-by-byte in CI on every commit and release — zero network, vault sandbox, byte-identical frontmatter on body edits, atomic writes, no-clobber creates, recoverable deletes, optional compare-and-swap, and write scoping / read-only mode.
129
158
 
130
159
  ---
131
160
 
@@ -135,7 +164,7 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
135
164
  No — Seekstone reads the vault folder from disk directly.
136
165
 
137
166
  **Do I need the Local REST API plugin?**
138
- No — Seekstone bypasses it entirely (that's where the 575× reduction comes from).
167
+ No — Seekstone bypasses it entirely (that's the source of the up-to-~47,000× payload reduction).
139
168
 
140
169
  **How does `seekstone init` find my vault automatically?**
141
170
  It reads Obsidian's own vault registry (`obsidian.json`) — the same file Obsidian uses to track your known vaults. One vault → auto-selected. Multiple → lists them and asks you to pick with `--vault`.
@@ -144,13 +173,13 @@ It reads Obsidian's own vault registry (`obsidian.json`) — the same file Obsid
144
173
  An MCP Bundle — a zip containing the server and its manifest. Claude Desktop installs it with a double-click, no terminal required.
145
174
 
146
175
  **Which AI clients does it support?**
147
- Any MCP-over-stdio client: Claude Desktop, Claude Code, Cursor, Windsurf, Continue, and others.
176
+ Any MCP-over-stdio client: Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Continue, and others.
148
177
 
149
178
  **Does it work on Windows?**
150
179
  Yes — tested on macOS, Linux, and Windows in CI on every commit.
151
180
 
152
181
  **Is it safe?**
153
- No network calls, no telemetry. The vault path is sandboxed — no tool reads or writes outside it.
182
+ No network calls, no telemetry. The vault path is sandboxed — no tool reads or writes outside it. Writes are covered by the tested Write-Safety Contract above, and `SEEKSTONE_READ_ONLY=1` removes the write tools entirely.
154
183
 
155
184
  ---
156
185
 
@@ -163,4 +192,4 @@ No network calls, no telemetry. The vault path is sandboxed — no tool reads or
163
192
 
164
193
  ## License
165
194
 
166
- MIT © Shaq Mughal · [GitHub](https://github.com/shaqmughal/seekstone) · [Issues](https://github.com/shaqmughal/seekstone/issues)
195
+ MIT © Shaq Mughal · [seekstone.dev](https://seekstone.dev) · [GitHub](https://github.com/shaqmughal/seekstone) · [Issues](https://github.com/shaqmughal/seekstone/issues)
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { resolve as resolve2 } from "path";
4
5
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
7
  import {
@@ -75,7 +76,6 @@ Docs: https://github.com/shaqmughal/seekstone`;
75
76
 
76
77
  // src/tools/append_note.ts
77
78
  import { readFile } from "fs/promises";
78
- import { join } from "path";
79
79
 
80
80
  // ../core/src/frontmatter.ts
81
81
  import { parse as parseYaml } from "yaml";
@@ -200,6 +200,16 @@ function assertWritable(policy2, relPath) {
200
200
  }
201
201
  }
202
202
 
203
+ // src/vault-path.ts
204
+ import { resolve, sep } from "path";
205
+ function resolveVaultPath(vaultRoot2, inputPath) {
206
+ const abs = resolve(vaultRoot2, inputPath);
207
+ if (abs !== vaultRoot2 && !abs.startsWith(vaultRoot2 + sep)) {
208
+ throw new Error(`Path outside vault: ${inputPath}`);
209
+ }
210
+ return abs;
211
+ }
212
+
203
213
  // src/tools/append_note.ts
204
214
  var AppendNoteInput = z.object({
205
215
  path: z.string().describe("Vault-relative path to the note."),
@@ -211,10 +221,7 @@ var AppendNoteInput = z.object({
211
221
  )
212
222
  });
213
223
  async function appendNote(ctx2, input) {
214
- const absPath = join(ctx2.vaultRoot, input.path);
215
- if (!absPath.startsWith(ctx2.vaultRoot)) {
216
- throw new Error(`Path outside vault: ${input.path}`);
217
- }
224
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
218
225
  assertWritable(ctx2.policy, input.path);
219
226
  const original = await readFile(absPath, "utf8");
220
227
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
@@ -565,7 +572,7 @@ function contextPack(ctx2, input) {
565
572
 
566
573
  // src/tools/create_note.ts
567
574
  import { access, mkdir, readFile as readFile2 } from "fs/promises";
568
- import { dirname, join as join2 } from "path";
575
+ import { dirname } from "path";
569
576
  import { stringify as stringifyYaml } from "yaml";
570
577
  import { z as z4 } from "zod";
571
578
 
@@ -611,10 +618,7 @@ var CreateNoteInput = z4.object({
611
618
  });
612
619
  async function createNote(ctx2, rawInput) {
613
620
  const input = CreateNoteInput.parse(rawInput);
614
- const absPath = join2(ctx2.vaultRoot, input.path);
615
- if (!absPath.startsWith(ctx2.vaultRoot)) {
616
- throw new Error(`Path outside vault: ${input.path}`);
617
- }
621
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
618
622
  assertWritable(ctx2.policy, input.path);
619
623
  if (!input.overwrite) {
620
624
  try {
@@ -643,7 +647,7 @@ async function createNote(ctx2, rawInput) {
643
647
  let raw = "";
644
648
  if (input.frontmatter && Object.keys(input.frontmatter).length > 0) {
645
649
  raw = `---
646
- ${stringifyYaml(input.frontmatter)}---
650
+ ${stringifyYaml(input.frontmatter, { lineWidth: 0 })}---
647
651
  `;
648
652
  }
649
653
  raw += input.content ?? "";
@@ -659,7 +663,7 @@ ${stringifyYaml(input.frontmatter)}---
659
663
 
660
664
  // src/tools/delete_note.ts
661
665
  import { access as access2, mkdir as mkdir2, rename as rename2, rm } from "fs/promises";
662
- import { basename as basename2, extname as extname2, join as join3 } from "path";
666
+ import { basename as basename2, extname as extname2, join } from "path";
663
667
  import { z as z5 } from "zod";
664
668
 
665
669
  // src/index/backlinks.ts
@@ -707,7 +711,7 @@ async function freeTrashPath(vaultRoot2, noteBase) {
707
711
  let candidate = `.trash/${noteBase}`;
708
712
  for (; ; ) {
709
713
  try {
710
- await access2(join3(vaultRoot2, candidate));
714
+ await access2(join(vaultRoot2, candidate));
711
715
  } catch {
712
716
  return candidate;
713
717
  }
@@ -716,19 +720,16 @@ async function freeTrashPath(vaultRoot2, noteBase) {
716
720
  }
717
721
  async function deleteNote(ctx2, rawInput) {
718
722
  const input = DeleteNoteInput.parse(rawInput);
719
- const absPath = join3(ctx2.vaultRoot, input.path);
720
- if (!absPath.startsWith(ctx2.vaultRoot)) {
721
- throw new Error(`Path outside vault: ${input.path}`);
722
- }
723
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
723
724
  assertWritable(ctx2.policy, input.path);
724
725
  let trashedTo;
725
726
  if (input.permanent) {
726
727
  await rm(absPath);
727
728
  } else {
728
729
  await access2(absPath);
729
- await mkdir2(join3(ctx2.vaultRoot, ".trash"), { recursive: true });
730
+ await mkdir2(join(ctx2.vaultRoot, ".trash"), { recursive: true });
730
731
  trashedTo = await freeTrashPath(ctx2.vaultRoot, basename2(input.path));
731
- await rename2(absPath, join3(ctx2.vaultRoot, trashedTo));
732
+ await rename2(absPath, join(ctx2.vaultRoot, trashedTo));
732
733
  }
733
734
  removeNoteBacklinks(ctx2, input.path);
734
735
  if (ctx2.notes.has(input.path)) {
@@ -741,7 +742,6 @@ async function deleteNote(ctx2, rawInput) {
741
742
  }
742
743
 
743
744
  // src/tools/get_backlinks.ts
744
- import { join as join4 } from "path";
745
745
  import { z as z6 } from "zod";
746
746
  var GetBacklinksInput = z6.object({
747
747
  path: z6.string().describe("Vault-relative path to the target note."),
@@ -750,10 +750,7 @@ var GetBacklinksInput = z6.object({
750
750
  });
751
751
  var EXCERPT_MAX = 200;
752
752
  function getBacklinks(ctx2, input) {
753
- const absPath = join4(ctx2.vaultRoot, input.path);
754
- if (!absPath.startsWith(ctx2.vaultRoot)) {
755
- throw new Error(`Path outside vault: ${input.path}`);
756
- }
753
+ resolveVaultPath(ctx2.vaultRoot, input.path);
757
754
  const includeContext = input.includeContext ?? true;
758
755
  const limit = input.limit ?? 50;
759
756
  const refs = ctx2.backlinks.get(input.path) ?? [];
@@ -778,16 +775,12 @@ function getBacklinks(ctx2, input) {
778
775
  }
779
776
 
780
777
  // src/tools/get_links.ts
781
- import { join as join5 } from "path";
782
778
  import { z as z7 } from "zod";
783
779
  var GetLinksInput = z7.object({
784
780
  path: z7.string().describe("Vault-relative path to the note.")
785
781
  });
786
782
  function getLinks(ctx2, input) {
787
- const absPath = join5(ctx2.vaultRoot, input.path);
788
- if (!absPath.startsWith(ctx2.vaultRoot)) {
789
- throw new Error(`Path outside vault: ${input.path}`);
790
- }
783
+ resolveVaultPath(ctx2.vaultRoot, input.path);
791
784
  const note = ctx2.notes.get(input.path);
792
785
  if (note === void 0) throw new Error(`Note not found: ${input.path}`);
793
786
  const rawLinks = extractLinksWithLines(note.raw);
@@ -872,7 +865,7 @@ function listTags(ctx2, input) {
872
865
 
873
866
  // src/tools/move_note.ts
874
867
  import { access as access3, mkdir as mkdir3, rename as rename3 } from "fs/promises";
875
- import { dirname as dirname2, join as join6 } from "path";
868
+ import { dirname as dirname2, join as join2 } from "path";
876
869
  import { z as z10 } from "zod";
877
870
 
878
871
  // src/tools/rewrite_links.ts
@@ -976,10 +969,8 @@ var MoveNoteInput = z10.object({
976
969
  });
977
970
  async function moveNote(ctx2, rawInput) {
978
971
  const input = MoveNoteInput.parse(rawInput);
979
- const absFrom = join6(ctx2.vaultRoot, input.from);
980
- const absTo = join6(ctx2.vaultRoot, input.to);
981
- if (!absFrom.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.from}`);
982
- if (!absTo.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.to}`);
972
+ const absFrom = resolveVaultPath(ctx2.vaultRoot, input.from);
973
+ const absTo = resolveVaultPath(ctx2.vaultRoot, input.to);
983
974
  assertWritable(ctx2.policy, input.from);
984
975
  assertWritable(ctx2.policy, input.to);
985
976
  try {
@@ -1037,7 +1028,7 @@ async function moveNote(ctx2, rawInput) {
1037
1028
  );
1038
1029
  removeNoteBacklinks(ctx2, path2);
1039
1030
  if (count > 0) {
1040
- await atomicWrite(join6(ctx2.vaultRoot, path2), content);
1031
+ await atomicWrite(join2(ctx2.vaultRoot, path2), content);
1041
1032
  upsertDoc(ctx2, buildDoc(path2, content));
1042
1033
  notesRewritten++;
1043
1034
  linksRewritten += count;
@@ -1054,7 +1045,6 @@ async function moveNote(ctx2, rawInput) {
1054
1045
 
1055
1046
  // src/tools/outline_note.ts
1056
1047
  import { readFile as readFile3 } from "fs/promises";
1057
- import { join as join7 } from "path";
1058
1048
 
1059
1049
  // ../core/src/outline.ts
1060
1050
  var HEADING_RE = /^(#{1,6})\s+(.+)/;
@@ -1111,10 +1101,7 @@ var OutlineNoteInput = z11.object({
1111
1101
  includeSizes: z11.boolean().default(false).describe("Include per-section character length in each heading entry. Default false.")
1112
1102
  });
1113
1103
  async function outlineNote(ctx2, input) {
1114
- const absPath = join7(ctx2.vaultRoot, input.path);
1115
- if (!absPath.startsWith(ctx2.vaultRoot)) {
1116
- throw new Error(`Path outside vault: ${input.path}`);
1117
- }
1104
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1118
1105
  const raw = await readFile3(absPath, "utf8");
1119
1106
  return buildOutline(raw, {
1120
1107
  includeBlocks: input.includeBlocks,
@@ -1124,7 +1111,6 @@ async function outlineNote(ctx2, input) {
1124
1111
 
1125
1112
  // src/tools/patch_frontmatter.ts
1126
1113
  import { readFile as readFile4 } from "fs/promises";
1127
- import { join as join8 } from "path";
1128
1114
  import { parseDocument } from "yaml";
1129
1115
  import { z as z12 } from "zod";
1130
1116
  var PatchFrontmatterInput = z12.object({
@@ -1137,10 +1123,7 @@ var PatchFrontmatterInput = z12.object({
1137
1123
  )
1138
1124
  });
1139
1125
  async function patchFrontmatter(ctx2, input) {
1140
- const absPath = join8(ctx2.vaultRoot, input.path);
1141
- if (!absPath.startsWith(ctx2.vaultRoot)) {
1142
- throw new Error(`Path outside vault: ${input.path}`);
1143
- }
1126
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1144
1127
  assertWritable(ctx2.policy, input.path);
1145
1128
  const original = await readFile4(absPath, "utf8");
1146
1129
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
@@ -1158,7 +1141,7 @@ async function patchFrontmatter(ctx2, input) {
1158
1141
  }
1159
1142
  }
1160
1143
  newContent = `---
1161
- ${doc.toString()}---
1144
+ ${doc.toString({ lineWidth: 0 })}---
1162
1145
  ${original}`;
1163
1146
  } else {
1164
1147
  const opensWithCRLF = original.startsWith("---\r\n");
@@ -1183,7 +1166,7 @@ ${original}`;
1183
1166
  }
1184
1167
  const head = opensWithCRLF ? "---\r\n" : "---\n";
1185
1168
  const tail = opensWithCRLF ? "---\r\n" : "---\n";
1186
- newContent = `${head}${doc.toString()}${tail}${fm.body}`;
1169
+ newContent = `${head}${doc.toString({ lineWidth: 0 })}${tail}${fm.body}`;
1187
1170
  }
1188
1171
  await atomicWrite(absPath, newContent);
1189
1172
  const cached = ctx2.notes.get(input.path);
@@ -1203,7 +1186,6 @@ ${original}`;
1203
1186
 
1204
1187
  // src/tools/patch_note.ts
1205
1188
  import { readFile as readFile5 } from "fs/promises";
1206
- import { join as join9 } from "path";
1207
1189
  import { z as z13 } from "zod";
1208
1190
  var PatchNoteInput = z13.object({
1209
1191
  path: z13.string().describe("Vault-relative path to the note."),
@@ -1232,10 +1214,7 @@ function lineStart(raw, offset) {
1232
1214
  return prev === -1 ? 0 : prev + 1;
1233
1215
  }
1234
1216
  async function patchNote(ctx2, input) {
1235
- const absPath = join9(ctx2.vaultRoot, input.path);
1236
- if (!absPath.startsWith(ctx2.vaultRoot)) {
1237
- throw new Error(`Path outside vault: ${input.path}`);
1238
- }
1217
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1239
1218
  assertWritable(ctx2.policy, input.path);
1240
1219
  const raw = await readFile5(absPath, "utf8");
1241
1220
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
@@ -1250,15 +1229,15 @@ async function patchNote(ctx2, input) {
1250
1229
  const heading = outline.headings.find((h) => h.text.toLowerCase() === targetText.toLowerCase());
1251
1230
  if (!heading) {
1252
1231
  if (input.createIfMissing) {
1253
- const sep3 = raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
1232
+ const sep4 = raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
1254
1233
  const contentNl = input.content.endsWith("\n") ? "" : "\n";
1255
- const newContent2 = `${raw}${sep3}## ${targetText}
1234
+ const newContent2 = `${raw}${sep4}## ${targetText}
1256
1235
 
1257
1236
  ${input.content}${contentNl}`;
1258
1237
  await atomicWrite(absPath, newContent2);
1259
1238
  await verifyFrontmatter(absPath, originalFmRegion);
1260
1239
  updateCache(ctx2, input.path, newContent2, fm.bodyStart);
1261
- const headingOffset = raw.length + sep3.length;
1240
+ const headingOffset = raw.length + sep4.length;
1262
1241
  return {
1263
1242
  path: input.path,
1264
1243
  bytesWritten: Buffer.byteLength(newContent2, "utf8"),
@@ -1307,8 +1286,8 @@ ${input.content}${contentNl}`;
1307
1286
  let newContent;
1308
1287
  switch (input.operation) {
1309
1288
  case "prepend": {
1310
- const sep3 = input.content.endsWith("\n") ? "" : "\n";
1311
- newContent = `${before}${input.content}${sep3}${section}${after}`;
1289
+ const sep4 = input.content.endsWith("\n") ? "" : "\n";
1290
+ newContent = `${before}${input.content}${sep4}${section}${after}`;
1312
1291
  break;
1313
1292
  }
1314
1293
  case "append": {
@@ -1353,7 +1332,7 @@ function updateCache(ctx2, path2, newContent, bodyStart) {
1353
1332
 
1354
1333
  // src/tools/periodic_note.ts
1355
1334
  import { access as access4, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
1356
- import { dirname as dirname3, join as join10 } from "path";
1335
+ import { dirname as dirname3, join as join3 } from "path";
1357
1336
  import { z as z14 } from "zod";
1358
1337
  var PERIOD = z14.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
1359
1338
  var DEFAULTS = {
@@ -1373,7 +1352,7 @@ async function readJson(absPath) {
1373
1352
  async function resolveConfig(vaultRoot2, period) {
1374
1353
  const defaults = DEFAULTS[period];
1375
1354
  if (period === "daily") {
1376
- const cfg2 = await readJson(join10(vaultRoot2, ".obsidian", "daily-notes.json"));
1355
+ const cfg2 = await readJson(join3(vaultRoot2, ".obsidian", "daily-notes.json"));
1377
1356
  return {
1378
1357
  folder: cfg2?.folder ?? defaults.folder,
1379
1358
  format: cfg2?.format ?? defaults.format,
@@ -1381,7 +1360,7 @@ async function resolveConfig(vaultRoot2, period) {
1381
1360
  };
1382
1361
  }
1383
1362
  const cfg = await readJson(
1384
- join10(vaultRoot2, ".obsidian", "plugins", "periodic-notes", "data.json")
1363
+ join3(vaultRoot2, ".obsidian", "plugins", "periodic-notes", "data.json")
1385
1364
  );
1386
1365
  const entry = cfg?.[period];
1387
1366
  return {
@@ -1434,8 +1413,7 @@ async function getPeriodicNote(ctx2, rawInput) {
1434
1413
  const date = input.date ? new Date(input.date) : /* @__PURE__ */ new Date();
1435
1414
  const cfg = await resolveConfig(ctx2.vaultRoot, input.period);
1436
1415
  const path2 = resolveNotePath(cfg, date);
1437
- const abs = join10(ctx2.vaultRoot, path2);
1438
- if (!abs.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${path2}`);
1416
+ const abs = resolveVaultPath(ctx2.vaultRoot, path2);
1439
1417
  let existed = false;
1440
1418
  try {
1441
1419
  await access4(abs);
@@ -1447,15 +1425,13 @@ async function getPeriodicNote(ctx2, rawInput) {
1447
1425
  assertWritable(ctx2.policy, path2);
1448
1426
  let body = "";
1449
1427
  if (cfg.template) {
1450
- const templateAbs = join10(
1451
- ctx2.vaultRoot,
1452
- cfg.template.endsWith(".md") ? cfg.template : `${cfg.template}.md`
1453
- );
1454
- if (templateAbs.startsWith(ctx2.vaultRoot)) {
1455
- try {
1456
- body = await readFile6(templateAbs, "utf8");
1457
- } catch {
1458
- }
1428
+ try {
1429
+ const templateAbs = resolveVaultPath(
1430
+ ctx2.vaultRoot,
1431
+ cfg.template.endsWith(".md") ? cfg.template : `${cfg.template}.md`
1432
+ );
1433
+ body = await readFile6(templateAbs, "utf8");
1434
+ } catch {
1459
1435
  }
1460
1436
  }
1461
1437
  await mkdir4(dirname3(abs), { recursive: true });
@@ -1477,8 +1453,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
1477
1453
  const date = input.date ? new Date(input.date) : /* @__PURE__ */ new Date();
1478
1454
  const cfg = await resolveConfig(ctx2.vaultRoot, input.period);
1479
1455
  const path2 = resolveNotePath(cfg, date);
1480
- const abs = join10(ctx2.vaultRoot, path2);
1481
- if (!abs.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${path2}`);
1456
+ const abs = resolveVaultPath(ctx2.vaultRoot, path2);
1482
1457
  assertWritable(ctx2.policy, path2);
1483
1458
  let original = "";
1484
1459
  try {
@@ -1656,7 +1631,6 @@ function queryNotes(ctx2, input) {
1656
1631
 
1657
1632
  // src/tools/read_note.ts
1658
1633
  import { readFile as readFile7 } from "fs/promises";
1659
- import { join as join11 } from "path";
1660
1634
  import { z as z16 } from "zod";
1661
1635
  var ReadNoteInput = z16.object({
1662
1636
  path: z16.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
@@ -1673,10 +1647,7 @@ var ReadNoteInput = z16.object({
1673
1647
  message: "At most one of section, block, or lines may be specified."
1674
1648
  });
1675
1649
  async function readNote(ctx2, input) {
1676
- const absPath = join11(ctx2.vaultRoot, input.path);
1677
- if (!absPath.startsWith(ctx2.vaultRoot)) {
1678
- throw new Error(`Path outside vault: ${input.path}`);
1679
- }
1650
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1680
1651
  const raw = await readFile7(absPath, "utf8");
1681
1652
  const noteBytes = Buffer.byteLength(raw, "utf8");
1682
1653
  const hash = contentHash(raw);
@@ -1766,7 +1737,6 @@ async function readNote(ctx2, input) {
1766
1737
 
1767
1738
  // src/tools/replace_in_note.ts
1768
1739
  import { readFile as readFile8 } from "fs/promises";
1769
- import { join as join12 } from "path";
1770
1740
  import { z as z17 } from "zod";
1771
1741
  var ReplaceInNoteInput = z17.object({
1772
1742
  path: z17.string().describe("Vault-relative path to the note."),
@@ -1791,10 +1761,7 @@ function buildRegex(find, opts) {
1791
1761
  return new RegExp(pattern, flags);
1792
1762
  }
1793
1763
  async function replaceInNote(ctx2, input) {
1794
- const absPath = join12(ctx2.vaultRoot, input.path);
1795
- if (!absPath.startsWith(ctx2.vaultRoot)) {
1796
- throw new Error(`Path outside vault: ${input.path}`);
1797
- }
1764
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1798
1765
  assertWritable(ctx2.policy, input.path);
1799
1766
  let re;
1800
1767
  try {
@@ -2076,7 +2043,7 @@ import { basename as basename3, extname as extname4 } from "path";
2076
2043
 
2077
2044
  // ../core/src/walk.ts
2078
2045
  import { stat } from "fs/promises";
2079
- import { extname as extname3, join as join13, relative, sep } from "path";
2046
+ import { extname as extname3, join as join4, relative, sep as sep2 } from "path";
2080
2047
  import fg from "fast-glob";
2081
2048
  var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]);
2082
2049
  var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
@@ -2118,7 +2085,7 @@ async function walkVault(vaultRoot2) {
2118
2085
  const stats = typeof m === "string" ? await stat(absPath) : m.stats;
2119
2086
  if (!stats) continue;
2120
2087
  const relPath = relative(vaultRoot2, absPath);
2121
- const topDir = relPath.split(sep)[0] ?? "";
2088
+ const topDir = relPath.split(sep2)[0] ?? "";
2122
2089
  entries.push({
2123
2090
  absPath,
2124
2091
  relPath,
@@ -2207,7 +2174,7 @@ function buildBacklinks(notes2) {
2207
2174
  // src/init.ts
2208
2175
  import { execFileSync } from "child_process";
2209
2176
  import { access as access5, copyFile, mkdir as mkdir5, readdir, readFile as readFile10, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2210
- import path, { dirname as dirname4, join as join14 } from "path";
2177
+ import path, { dirname as dirname4, join as join5 } from "path";
2211
2178
  function parseInitArgs(argv) {
2212
2179
  const opts = { write: false, client: "desktop" };
2213
2180
  for (let i = 0; i < argv.length; i++) {
@@ -2250,7 +2217,7 @@ function cursorConfigPath(platform, env) {
2250
2217
  return j(env.home ?? "", ".cursor", "mcp.json");
2251
2218
  }
2252
2219
  function vscodeMcpConfigPath(cwd) {
2253
- return join14(cwd, ".vscode", "mcp.json");
2220
+ return join5(cwd, ".vscode", "mcp.json");
2254
2221
  }
2255
2222
  function obsidianRegistryPath(platform, env) {
2256
2223
  if (platform === "win32") {
@@ -2312,7 +2279,7 @@ async function validateVault(vaultPath) {
2312
2279
  return { ok: false, error: `Path does not exist: ${vaultPath}` };
2313
2280
  }
2314
2281
  try {
2315
- await access5(join14(vaultPath, ".obsidian"));
2282
+ await access5(join5(vaultPath, ".obsidian"));
2316
2283
  } catch {
2317
2284
  return {
2318
2285
  ok: false,
@@ -2332,7 +2299,7 @@ async function countMarkdown(root) {
2332
2299
  }
2333
2300
  for (const e of entries) {
2334
2301
  if (e.name.startsWith(".")) continue;
2335
- const full = join14(dir, e.name);
2302
+ const full = join5(dir, e.name);
2336
2303
  if (e.isDirectory()) await walk(full);
2337
2304
  else if (e.name.endsWith(".md")) count++;
2338
2305
  }
@@ -3052,7 +3019,7 @@ function visibleTools(policy2) {
3052
3019
  // src/watcher.ts
3053
3020
  import { realpathSync } from "fs";
3054
3021
  import { readFile as readFile11 } from "fs/promises";
3055
- import { join as join15, relative as relative2, sep as sep2 } from "path";
3022
+ import { join as join6, relative as relative2, sep as sep3 } from "path";
3056
3023
  import chokidar from "chokidar";
3057
3024
  function startWatcher(ctx2, log2, opts) {
3058
3025
  const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
@@ -3061,10 +3028,10 @@ function startWatcher(ctx2, log2, opts) {
3061
3028
  vaultRoot2 = realpathSync(ctx2.vaultRoot);
3062
3029
  } catch {
3063
3030
  }
3064
- const toRel = (abs) => relative2(vaultRoot2, abs).split(sep2).join("/");
3031
+ const toRel = (abs) => relative2(vaultRoot2, abs).split(sep3).join("/");
3065
3032
  async function upsert(relPath, op) {
3066
3033
  try {
3067
- const raw = await readFile11(join15(vaultRoot2, relPath), "utf8");
3034
+ const raw = await readFile11(join6(vaultRoot2, relPath), "utf8");
3068
3035
  removeNoteBacklinks(ctx2, relPath);
3069
3036
  upsertDoc(ctx2, buildDoc(relPath, raw));
3070
3037
  addNoteBacklinks(ctx2, relPath, raw);
@@ -3096,7 +3063,7 @@ function startWatcher(ctx2, log2, opts) {
3096
3063
  ignored: (p) => {
3097
3064
  const rel = relative2(vaultRoot2, p);
3098
3065
  if (!rel || rel === ".") return false;
3099
- return rel.split(sep2).filter(Boolean).some((seg) => seg.startsWith(".") && seg !== "..");
3066
+ return rel.split(sep3).filter(Boolean).some((seg) => seg.startsWith(".") && seg !== "..");
3100
3067
  }
3101
3068
  });
3102
3069
  watcher2.on("add", onUpsert("add")).on("change", onUpsert("change")).on("unlink", (abs) => {
@@ -3104,9 +3071,9 @@ function startWatcher(ctx2, log2, opts) {
3104
3071
  opts?.onRawEvent?.("unlink", abs, relPath);
3105
3072
  if (relPath.endsWith(".md")) remove(relPath);
3106
3073
  });
3107
- const ready = new Promise((resolve) => {
3074
+ const ready = new Promise((resolve3) => {
3108
3075
  watcher2.once("ready", () => {
3109
- resolve();
3076
+ resolve3();
3110
3077
  });
3111
3078
  });
3112
3079
  return {
@@ -3116,7 +3083,7 @@ function startWatcher(ctx2, log2, opts) {
3116
3083
  }
3117
3084
 
3118
3085
  // src/index.ts
3119
- var VERSION = true ? "0.12.0" : "0.0.0-dev";
3086
+ var VERSION = true ? "0.12.2" : "0.0.0-dev";
3120
3087
  var intent = parseCliIntent(process.argv.slice(2));
3121
3088
  if (intent === "version") {
3122
3089
  process.stdout.write(`${VERSION}
@@ -3146,11 +3113,12 @@ if (intent === "init") {
3146
3113
  }
3147
3114
  var log = createLogger();
3148
3115
  installProcessGuards(log);
3149
- var vaultRoot = process.env.SEEKSTONE_VAULT;
3150
- if (!vaultRoot) {
3116
+ var rawVaultRoot = process.env.SEEKSTONE_VAULT;
3117
+ if (!rawVaultRoot) {
3151
3118
  log.error("SEEKSTONE_VAULT env var is required");
3152
3119
  process.exit(1);
3153
3120
  }
3121
+ var vaultRoot = resolve2(rawVaultRoot);
3154
3122
  var policy = parseWritePolicy(process.env);
3155
3123
  if (policy.readOnly) log.info("read-only mode", { env: "SEEKSTONE_READ_ONLY" });
3156
3124
  if (policy.writeGlobs) log.info("write paths scoped", { globs: policy.writeGlobs });