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 +43 -14
- package/dist/index.js +66 -98
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# seekstone
|
|
2
2
|
|
|
3
|
-
**The Obsidian MCP server
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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` |
|
|
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` |
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
730
|
+
await mkdir2(join(ctx2.vaultRoot, ".trash"), { recursive: true });
|
|
730
731
|
trashedTo = await freeTrashPath(ctx2.vaultRoot, basename2(input.path));
|
|
731
|
-
await rename2(absPath,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
980
|
-
const absTo =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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}${
|
|
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 +
|
|
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
|
|
1311
|
-
newContent = `${before}${input.content}${
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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((
|
|
3074
|
+
const ready = new Promise((resolve3) => {
|
|
3108
3075
|
watcher2.once("ready", () => {
|
|
3109
|
-
|
|
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.
|
|
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
|
|
3150
|
-
if (!
|
|
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 });
|