seekstone 0.20.2 → 0.21.0
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 +6 -2
- package/dist/index.js +246 -43
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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, and holds a warm full-text index in-process. The practical difference: keyword searches return in **single-digit milliseconds** (semantic search in **~26 ms**) 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).
|
|
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: keyword searches return in **single-digit milliseconds** (semantic search in **~26 ms**) 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
|
|
|
@@ -104,7 +104,7 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
|
|
|
104
104
|
|
|
105
105
|
## Tools
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
22 tools: 12 read, 10 write.
|
|
108
108
|
|
|
109
109
|
### Read
|
|
110
110
|
|
|
@@ -114,6 +114,7 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
|
|
|
114
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
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. Scopable by `folder`/`tag` and takes the same `mode` as `search`. |
|
|
116
116
|
| `read_note` | Read the full content of a note by vault-relative path. Supports returning a single section, block, or line range. |
|
|
117
|
+
| `read_notes` | Read up to 20 notes (or sections, blocks, line ranges) in one call under a total byte budget (default 16 KB). Over budget, each note is cut to a fair share and marked `truncated`; one bad path returns its own error without failing the batch. |
|
|
117
118
|
| `list_notes` | List notes, optionally filtered by folder prefix or tag. |
|
|
118
119
|
| `list_tags` | List all tags in the vault sorted by usage count (or alphabetically). |
|
|
119
120
|
| `outline_note` | Return a note's heading and block structure without its full content. |
|
|
@@ -209,6 +210,9 @@ Any MCP-over-stdio client: Claude Desktop, Claude Code, Cursor, VS Code, Windsur
|
|
|
209
210
|
**Does it work on Windows?**
|
|
210
211
|
Yes — tested on macOS, Linux, and Windows in CI on every commit.
|
|
211
212
|
|
|
213
|
+
**Does search handle typos?**
|
|
214
|
+
Yes, small ones. Keyword search allows one typo per word of 3–7 letters and two for words of 8 or more, so `benchamrk` still finds "benchmark". One- and two-letter words must match exactly, and swapping two letters counts as two typos. It also matches word prefixes: `kuber` finds "Kubernetes". The `tag` filter on `search`, `context_pack`, `query_notes`, and `list_notes` works like Obsidian's: it ignores case and includes nested tags, so `project` matches `#Project/alpha`. A tag no note has returns an `unknown_tag` error listing the closest existing tags.
|
|
215
|
+
|
|
212
216
|
**Is it safe?**
|
|
213
217
|
No network calls while running, no telemetry (the optional semantic-search model is downloaded once by the explicit `fetch-model` subcommand, SHA-256-verified, before any serving starts — or shipped inside the `seekstone-semantic.mcpb` bundle and reassembled from disk at boot, verified against the same pinned hashes). 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.
|
|
214
218
|
|
package/dist/index.js
CHANGED
|
@@ -1038,6 +1038,73 @@ function resolveLink(target, notes2, maps) {
|
|
|
1038
1038
|
// src/tools/search.ts
|
|
1039
1039
|
import { z as z2 } from "zod";
|
|
1040
1040
|
|
|
1041
|
+
// src/index/tags.ts
|
|
1042
|
+
function normalizeTag(tag) {
|
|
1043
|
+
return tag.replace(/^#/, "").toLowerCase();
|
|
1044
|
+
}
|
|
1045
|
+
function noteHasTag(noteTags, wanted) {
|
|
1046
|
+
for (const t of noteTags.split(" ")) {
|
|
1047
|
+
if (!t) continue;
|
|
1048
|
+
const tag = t.toLowerCase();
|
|
1049
|
+
if (tag === wanted || tag.startsWith(`${wanted}/`)) return true;
|
|
1050
|
+
}
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
function editDistance(a, b) {
|
|
1054
|
+
let row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
1055
|
+
let dist = b.length;
|
|
1056
|
+
for (let i = 0; i < a.length; i++) {
|
|
1057
|
+
let diag = i;
|
|
1058
|
+
let left = i + 1;
|
|
1059
|
+
const next = [left];
|
|
1060
|
+
for (const [j, up] of row.slice(1).entries()) {
|
|
1061
|
+
left = Math.min(up + 1, left + 1, diag + (a[i] === b[j] ? 0 : 1));
|
|
1062
|
+
diag = up;
|
|
1063
|
+
next.push(left);
|
|
1064
|
+
}
|
|
1065
|
+
row = next;
|
|
1066
|
+
dist = left;
|
|
1067
|
+
}
|
|
1068
|
+
return dist;
|
|
1069
|
+
}
|
|
1070
|
+
var MAX_SUGGESTIONS = 3;
|
|
1071
|
+
function suggestTags(notes2, wanted) {
|
|
1072
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1073
|
+
for (const note of notes2) {
|
|
1074
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1075
|
+
for (const t of note.tags.split(" ")) {
|
|
1076
|
+
if (!t) continue;
|
|
1077
|
+
const parts = t.toLowerCase().split("/");
|
|
1078
|
+
for (let i = 1; i <= parts.length; i++) seen.add(parts.slice(0, i).join("/"));
|
|
1079
|
+
}
|
|
1080
|
+
for (const tag of seen) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
1081
|
+
}
|
|
1082
|
+
const budget = Math.max(2, Math.ceil(wanted.length / 3));
|
|
1083
|
+
const scored = [];
|
|
1084
|
+
for (const [tag, count] of counts) {
|
|
1085
|
+
const leaf = tag.slice(tag.lastIndexOf("/") + 1);
|
|
1086
|
+
const dist = Math.min(editDistance(wanted, tag), editDistance(wanted, leaf));
|
|
1087
|
+
if (dist <= budget) scored.push({ tag, dist, count });
|
|
1088
|
+
}
|
|
1089
|
+
scored.sort((a, b) => a.dist - b.dist || b.count - a.count || a.tag.localeCompare(b.tag));
|
|
1090
|
+
return scored.slice(0, MAX_SUGGESTIONS).map((s) => s.tag);
|
|
1091
|
+
}
|
|
1092
|
+
function assertTagExists(notes2, tag) {
|
|
1093
|
+
const wanted = normalizeTag(tag);
|
|
1094
|
+
for (const note of notes2.values()) {
|
|
1095
|
+
if (noteHasTag(note.tags, wanted)) return;
|
|
1096
|
+
}
|
|
1097
|
+
const didYouMean = suggestTags(notes2.values(), wanted);
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
JSON.stringify({
|
|
1100
|
+
error: "unknown_tag",
|
|
1101
|
+
tag,
|
|
1102
|
+
...didYouMean.length > 0 ? { didYouMean } : {},
|
|
1103
|
+
hint: "No note in the vault has this tag. Matching ignores case and includes nested child tags. Retry with a suggested tag, or call list_tags to see the tags that exist."
|
|
1104
|
+
})
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1041
1108
|
// src/semantic/excerpt.ts
|
|
1042
1109
|
function chunkExcerpt(body, span, terms, maxLen) {
|
|
1043
1110
|
const normalized = body.includes("\r") ? body.replace(/\r\n/g, "\n") : body;
|
|
@@ -1072,7 +1139,9 @@ var SearchInput = z2.object({
|
|
|
1072
1139
|
),
|
|
1073
1140
|
limit: z2.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
|
|
1074
1141
|
folder: z2.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
|
|
1075
|
-
tag: z2.string().optional().describe(
|
|
1142
|
+
tag: z2.string().optional().describe(
|
|
1143
|
+
'Restrict results to notes with this tag. # optional, case-insensitive, and nested child tags match (e.g. "project" matches #project/alpha).'
|
|
1144
|
+
),
|
|
1076
1145
|
excerptLength: z2.number().int().min(20).max(2e3).optional().describe(
|
|
1077
1146
|
"Max characters of match context per hit (default 120). Lower trims payload; higher gives more context."
|
|
1078
1147
|
)
|
|
@@ -1084,6 +1153,11 @@ function basenameNoExt(path2) {
|
|
|
1084
1153
|
return dot > 0 ? base.slice(0, dot) : base;
|
|
1085
1154
|
}
|
|
1086
1155
|
function retrieve(ctx2, input) {
|
|
1156
|
+
const result = retrieveUnchecked(ctx2, input);
|
|
1157
|
+
if (result.hits.length === 0 && input.tag) assertTagExists(ctx2.notes, input.tag);
|
|
1158
|
+
return result;
|
|
1159
|
+
}
|
|
1160
|
+
function retrieveUnchecked(ctx2, input) {
|
|
1087
1161
|
const mode = input.mode ?? "lexical";
|
|
1088
1162
|
if (mode === "lexical") return lexicalSearch(ctx2, input);
|
|
1089
1163
|
const semantic = requireSemantic(ctx2);
|
|
@@ -1177,12 +1251,7 @@ function filterNote(ctx2, input, path2) {
|
|
|
1177
1251
|
if (input.folder && !path2.startsWith(input.folder)) return void 0;
|
|
1178
1252
|
const note = ctx2.notes.get(path2);
|
|
1179
1253
|
if (!note) return void 0;
|
|
1180
|
-
if (input.tag)
|
|
1181
|
-
const noteTags = note.tags.split(" ");
|
|
1182
|
-
if (!noteTags.some((t) => t === input.tag || t === input.tag?.replace(/^#/, ""))) {
|
|
1183
|
-
return void 0;
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1254
|
+
if (input.tag && !noteHasTag(note.tags, normalizeTag(input.tag))) return void 0;
|
|
1186
1255
|
return note;
|
|
1187
1256
|
}
|
|
1188
1257
|
function decorate(hit, title, tags) {
|
|
@@ -1199,7 +1268,9 @@ var ContextPackInput = z3.object({
|
|
|
1199
1268
|
"lexical = keyword search (default). semantic = meaning-based search over local embeddings (requires SEEKSTONE_SEMANTIC=1 and a fetched model). hybrid = exact-title lookups go lexical, everything else semantic. A question asked in words the note does not use needs semantic or hybrid."
|
|
1200
1269
|
),
|
|
1201
1270
|
folder: z3.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
|
|
1202
|
-
tag: z3.string().optional().describe(
|
|
1271
|
+
tag: z3.string().optional().describe(
|
|
1272
|
+
'Restrict results to notes with this tag. # optional, case-insensitive, and nested child tags match (e.g. "project" matches #project/alpha).'
|
|
1273
|
+
)
|
|
1203
1274
|
});
|
|
1204
1275
|
var MAX_CANDIDATES = 10;
|
|
1205
1276
|
var NEIGHBOR_HITS = 3;
|
|
@@ -1647,18 +1718,17 @@ var ListNotesInput = z8.object({
|
|
|
1647
1718
|
folder: z8.string().optional().describe(
|
|
1648
1719
|
'Vault-relative folder prefix to list. Omit for all notes. Example: "Daily Notes/2026".'
|
|
1649
1720
|
),
|
|
1650
|
-
tag: z8.string().optional().describe(
|
|
1721
|
+
tag: z8.string().optional().describe(
|
|
1722
|
+
'Filter by tag. # optional, case-insensitive, and nested child tags match (e.g. "project" matches #project/alpha).'
|
|
1723
|
+
),
|
|
1651
1724
|
limit: z8.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
|
|
1652
1725
|
});
|
|
1653
1726
|
function listNotes(ctx2, input) {
|
|
1654
|
-
const tag = input.tag
|
|
1727
|
+
const tag = input.tag === void 0 ? void 0 : normalizeTag(input.tag);
|
|
1655
1728
|
const results = [];
|
|
1656
1729
|
for (const [path2, note] of ctx2.notes) {
|
|
1657
1730
|
if (input.folder && !path2.startsWith(input.folder)) continue;
|
|
1658
|
-
if (tag)
|
|
1659
|
-
const noteTags = note.tags.split(" ").filter(Boolean);
|
|
1660
|
-
if (!noteTags.includes(tag)) continue;
|
|
1661
|
-
}
|
|
1731
|
+
if (tag && !noteHasTag(note.tags, tag)) continue;
|
|
1662
1732
|
results.push({
|
|
1663
1733
|
path: path2,
|
|
1664
1734
|
title: note.title,
|
|
@@ -1667,6 +1737,7 @@ function listNotes(ctx2, input) {
|
|
|
1667
1737
|
});
|
|
1668
1738
|
if (results.length >= input.limit) break;
|
|
1669
1739
|
}
|
|
1740
|
+
if (results.length === 0 && input.tag) assertTagExists(ctx2.notes, input.tag);
|
|
1670
1741
|
return results.sort((a, b) => a.path.localeCompare(b.path));
|
|
1671
1742
|
}
|
|
1672
1743
|
|
|
@@ -2403,7 +2474,9 @@ var ISO_DATE = z16.string().refine((s) => !Number.isNaN(Date.parse(s)), { messag
|
|
|
2403
2474
|
var QueryNotesInput = z16.object({
|
|
2404
2475
|
where: z16.array(Predicate).max(20).default([]).describe("Frontmatter predicates. All must match (AND)."),
|
|
2405
2476
|
folder: z16.string().optional().describe("Restrict to notes under this vault-relative folder prefix."),
|
|
2406
|
-
tag: z16.string().optional().describe(
|
|
2477
|
+
tag: z16.string().optional().describe(
|
|
2478
|
+
'Restrict to notes with this tag. # optional, case-insensitive, and nested child tags match (e.g. "project" matches #project/alpha).'
|
|
2479
|
+
),
|
|
2407
2480
|
modifiedAfter: ISO_DATE.optional().describe(
|
|
2408
2481
|
"Only notes modified at or after this ISO 8601 date/time."
|
|
2409
2482
|
),
|
|
@@ -2468,14 +2541,14 @@ function matches(fm, p) {
|
|
|
2468
2541
|
}
|
|
2469
2542
|
}
|
|
2470
2543
|
function queryNotes(ctx2, input) {
|
|
2471
|
-
const tag = input.tag
|
|
2544
|
+
const tag = input.tag === void 0 ? void 0 : normalizeTag(input.tag);
|
|
2472
2545
|
const after = input.modifiedAfter ? Date.parse(input.modifiedAfter) : void 0;
|
|
2473
2546
|
const before = input.modifiedBefore ? Date.parse(input.modifiedBefore) : void 0;
|
|
2474
2547
|
const fmKeys = input.select.filter((s) => s !== "mtime" && s !== "size" && s !== "tags");
|
|
2475
2548
|
const hits = [];
|
|
2476
2549
|
for (const [path2, note] of ctx2.notes) {
|
|
2477
2550
|
if (input.folder && !path2.startsWith(input.folder)) continue;
|
|
2478
|
-
if (tag && !note.tags
|
|
2551
|
+
if (tag && !noteHasTag(note.tags, tag)) continue;
|
|
2479
2552
|
if (after !== void 0 && note.mtimeMs < after) continue;
|
|
2480
2553
|
if (before !== void 0 && note.mtimeMs >= before) continue;
|
|
2481
2554
|
if (input.minSizeBytes !== void 0 && note.sizeBytes < input.minSizeBytes) continue;
|
|
@@ -2498,6 +2571,7 @@ function queryNotes(ctx2, input) {
|
|
|
2498
2571
|
}
|
|
2499
2572
|
hits.push({ hit, note });
|
|
2500
2573
|
}
|
|
2574
|
+
if (hits.length === 0 && input.tag) assertTagExists(ctx2.notes, input.tag);
|
|
2501
2575
|
const dir = input.order === "desc" ? -1 : 1;
|
|
2502
2576
|
hits.sort((a, b) => {
|
|
2503
2577
|
switch (input.sort) {
|
|
@@ -2620,19 +2694,77 @@ async function readNote(ctx2, input) {
|
|
|
2620
2694
|
};
|
|
2621
2695
|
}
|
|
2622
2696
|
|
|
2697
|
+
// src/tools/read_notes.ts
|
|
2698
|
+
import { z as z18 } from "zod";
|
|
2699
|
+
var DEFAULT_BUDGET_BYTES = 16384;
|
|
2700
|
+
var ReadNotesInput = z18.object({
|
|
2701
|
+
items: z18.array(ReadNoteInput).min(1).max(20).describe(
|
|
2702
|
+
"Notes to read, 1\u201320, each with the same options as read_note (path plus at most one of section, block, or lines)."
|
|
2703
|
+
),
|
|
2704
|
+
budgetBytes: z18.number().int().min(256).max(65536).default(DEFAULT_BUDGET_BYTES).describe(
|
|
2705
|
+
"Cap on the total note text returned across all items (UTF-8 bytes). When exceeded, each item is truncated to a fair share."
|
|
2706
|
+
)
|
|
2707
|
+
});
|
|
2708
|
+
async function readNotes(ctx2, input) {
|
|
2709
|
+
const budget = input.budgetBytes;
|
|
2710
|
+
const results = await Promise.all(
|
|
2711
|
+
input.items.map((item) => readNote(ctx2, item).catch((err) => toItemError(item.path, err)))
|
|
2712
|
+
);
|
|
2713
|
+
const entries = results.filter((r) => !("error" in r)).map((r) => ({ r, size: r.bytesReturned, alloc: 0 }));
|
|
2714
|
+
allocate(entries, budget);
|
|
2715
|
+
const cut = new Map(
|
|
2716
|
+
entries.map((e) => [e.r, truncateItem(e.r, e.alloc)])
|
|
2717
|
+
);
|
|
2718
|
+
const notes2 = results.map((r) => cut.get(r) ?? r);
|
|
2719
|
+
const bytesUsed = [...cut.values()].reduce((sum, r) => sum + r.bytesReturned, 0);
|
|
2720
|
+
return { notes: notes2, budgetBytes: budget, bytesUsed };
|
|
2721
|
+
}
|
|
2722
|
+
function toItemError(path2, err) {
|
|
2723
|
+
const { message, code } = err;
|
|
2724
|
+
if (message.startsWith("{")) return { path: path2, ...JSON.parse(message) };
|
|
2725
|
+
if (message.startsWith("Path outside vault")) return { path: path2, error: "outside_vault" };
|
|
2726
|
+
if (code === "ENOENT") return { path: path2, error: "not_found" };
|
|
2727
|
+
return { path: path2, error: "read_failed", message };
|
|
2728
|
+
}
|
|
2729
|
+
function allocate(entries, budget) {
|
|
2730
|
+
let pending = entries.filter((e) => e.size > 0);
|
|
2731
|
+
let remaining = budget;
|
|
2732
|
+
for (const e of entries) e.alloc = e.size;
|
|
2733
|
+
if (entries.reduce((sum, e) => sum + e.size, 0) <= budget) return;
|
|
2734
|
+
while (pending.length > 0) {
|
|
2735
|
+
const share = Math.floor(remaining / pending.length);
|
|
2736
|
+
const fits = pending.filter((e) => e.size <= share);
|
|
2737
|
+
if (fits.length === 0) {
|
|
2738
|
+
const extra = remaining - share * pending.length;
|
|
2739
|
+
for (const [k, e] of pending.entries()) e.alloc = share + (k < extra ? 1 : 0);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
for (const e of fits) remaining -= e.size;
|
|
2743
|
+
pending = pending.filter((e) => e.size > share);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
function truncateItem(r, maxBytes) {
|
|
2747
|
+
if (r.bytesReturned <= maxBytes) return r;
|
|
2748
|
+
const buf = Buffer.from(r.content, "utf8");
|
|
2749
|
+
let end = maxBytes;
|
|
2750
|
+
while (end > 0 && (buf.readUInt8(end) & 192) === 128) end--;
|
|
2751
|
+
const content = buf.toString("utf8", 0, end);
|
|
2752
|
+
return { ...r, content, bytesReturned: end, truncated: true };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2623
2755
|
// src/tools/rename_heading.ts
|
|
2624
2756
|
import { readFile as readFile11 } from "fs/promises";
|
|
2625
2757
|
import { join as join5 } from "path";
|
|
2626
|
-
import { z as
|
|
2627
|
-
var RenameHeadingInput =
|
|
2628
|
-
path:
|
|
2629
|
-
oldHeading:
|
|
2758
|
+
import { z as z19 } from "zod";
|
|
2759
|
+
var RenameHeadingInput = z19.object({
|
|
2760
|
+
path: z19.string().describe("Vault-relative path of the note containing the heading."),
|
|
2761
|
+
oldHeading: z19.string().min(1).describe(
|
|
2630
2762
|
"Current heading text, without # markers. Matched case-insensitively; if the note has duplicate headings the first one wins, mirroring Obsidian link resolution."
|
|
2631
2763
|
),
|
|
2632
|
-
newHeading:
|
|
2764
|
+
newHeading: z19.string().min(1).refine((s) => !/[\n[\]|#]/.test(s), {
|
|
2633
2765
|
message: 'newHeading must not contain newlines, "[", "]", "|", or "#" \u2014 those break [[note#heading]] link syntax.'
|
|
2634
2766
|
}).describe("New heading text, without # markers."),
|
|
2635
|
-
prevHash:
|
|
2767
|
+
prevHash: z19.string().optional().describe(
|
|
2636
2768
|
"Optional compare-and-swap guard: the contentHash from a prior read of this note. Fails with hash_conflict if the note changed since."
|
|
2637
2769
|
)
|
|
2638
2770
|
});
|
|
@@ -2755,17 +2887,17 @@ async function renameHeading(ctx2, rawInput) {
|
|
|
2755
2887
|
|
|
2756
2888
|
// src/tools/replace_in_note.ts
|
|
2757
2889
|
import { readFile as readFile12 } from "fs/promises";
|
|
2758
|
-
import { z as
|
|
2759
|
-
var ReplaceInNoteInput =
|
|
2760
|
-
path:
|
|
2761
|
-
find:
|
|
2762
|
-
replace:
|
|
2763
|
-
regex:
|
|
2764
|
-
caseSensitive:
|
|
2765
|
-
wholeWord:
|
|
2766
|
-
limit:
|
|
2767
|
-
dryRun:
|
|
2768
|
-
prevHash:
|
|
2890
|
+
import { z as z20 } from "zod";
|
|
2891
|
+
var ReplaceInNoteInput = z20.object({
|
|
2892
|
+
path: z20.string().describe("Vault-relative path to the note."),
|
|
2893
|
+
find: z20.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
|
|
2894
|
+
replace: z20.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
|
|
2895
|
+
regex: z20.boolean().default(false).describe("Treat find as a regular expression."),
|
|
2896
|
+
caseSensitive: z20.boolean().default(false).describe("Case-sensitive matching."),
|
|
2897
|
+
wholeWord: z20.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
|
|
2898
|
+
limit: z20.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
|
|
2899
|
+
dryRun: z20.boolean().default(false).describe("If true, report matches without writing anything."),
|
|
2900
|
+
prevHash: z20.string().optional().describe(
|
|
2769
2901
|
"Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
2770
2902
|
)
|
|
2771
2903
|
});
|
|
@@ -2850,12 +2982,12 @@ async function replaceInNote(ctx2, input) {
|
|
|
2850
2982
|
// src/tools/undo_write.ts
|
|
2851
2983
|
import { mkdir as mkdir6, readFile as readFile13, rm as rm3 } from "fs/promises";
|
|
2852
2984
|
import { dirname as dirname4 } from "path";
|
|
2853
|
-
import { z as
|
|
2854
|
-
var UndoWriteInput =
|
|
2855
|
-
seq:
|
|
2985
|
+
import { z as z21 } from "zod";
|
|
2986
|
+
var UndoWriteInput = z21.object({
|
|
2987
|
+
seq: z21.number().int().min(1).optional().describe(
|
|
2856
2988
|
"Journal entry to revert (from list_writes). Defaults to the most recent undoable write."
|
|
2857
2989
|
),
|
|
2858
|
-
force:
|
|
2990
|
+
force: z21.boolean().default(false).describe(
|
|
2859
2991
|
"Restore even if a file changed after the journaled write. The clobbered state is journaled first, so nothing is lost. Defaults to false \u2014 conflicts are refused."
|
|
2860
2992
|
)
|
|
2861
2993
|
});
|
|
@@ -3012,6 +3144,7 @@ function safeMeta(args) {
|
|
|
3012
3144
|
}
|
|
3013
3145
|
if (typeof a.query === "string") out.queryLen = a.query.length;
|
|
3014
3146
|
if (Array.isArray(a.where)) out.whereCount = a.where.length;
|
|
3147
|
+
if (Array.isArray(a.items)) out.itemsCount = a.items.length;
|
|
3015
3148
|
return out;
|
|
3016
3149
|
}
|
|
3017
3150
|
function resultBytes(r) {
|
|
@@ -3161,6 +3294,11 @@ async function run(ctx2, name, args) {
|
|
|
3161
3294
|
const result = await readNote(ctx2, input);
|
|
3162
3295
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
3163
3296
|
}
|
|
3297
|
+
case "read_notes": {
|
|
3298
|
+
const input = ReadNotesInput.parse(args);
|
|
3299
|
+
const result = await readNotes(ctx2, input);
|
|
3300
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
3301
|
+
}
|
|
3164
3302
|
case "list_notes": {
|
|
3165
3303
|
const input = ListNotesInput.parse(args);
|
|
3166
3304
|
const entries = listNotes(ctx2, input);
|
|
@@ -4908,7 +5046,10 @@ var ALL_TOOLS = [
|
|
|
4908
5046
|
},
|
|
4909
5047
|
limit: { type: "number", description: "Max results (1\u201350, default 10)." },
|
|
4910
5048
|
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
4911
|
-
tag: {
|
|
5049
|
+
tag: {
|
|
5050
|
+
type: "string",
|
|
5051
|
+
description: 'Restrict to notes with this tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
|
|
5052
|
+
},
|
|
4912
5053
|
excerptLength: {
|
|
4913
5054
|
type: "number",
|
|
4914
5055
|
description: "Max characters of match context per hit (20\u20132000, default 120)."
|
|
@@ -4945,7 +5086,10 @@ var ALL_TOOLS = [
|
|
|
4945
5086
|
}
|
|
4946
5087
|
},
|
|
4947
5088
|
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
4948
|
-
tag: {
|
|
5089
|
+
tag: {
|
|
5090
|
+
type: "string",
|
|
5091
|
+
description: 'Restrict to notes with this tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
|
|
5092
|
+
},
|
|
4949
5093
|
modifiedAfter: {
|
|
4950
5094
|
type: "string",
|
|
4951
5095
|
description: "Only notes modified at or after this ISO 8601 date/time."
|
|
@@ -4994,7 +5138,10 @@ var ALL_TOOLS = [
|
|
|
4994
5138
|
description: "lexical = keyword search (default). semantic = meaning-based over local embeddings (needs SEEKSTONE_SEMANTIC=1 and a fetched model). hybrid = exact-title lookups go lexical, everything else semantic."
|
|
4995
5139
|
},
|
|
4996
5140
|
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
4997
|
-
tag: {
|
|
5141
|
+
tag: {
|
|
5142
|
+
type: "string",
|
|
5143
|
+
description: 'Restrict to notes with this tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
|
|
5144
|
+
}
|
|
4998
5145
|
},
|
|
4999
5146
|
required: ["query"]
|
|
5000
5147
|
}
|
|
@@ -5038,6 +5185,59 @@ var ALL_TOOLS = [
|
|
|
5038
5185
|
required: ["path"]
|
|
5039
5186
|
}
|
|
5040
5187
|
},
|
|
5188
|
+
{
|
|
5189
|
+
name: "read_notes",
|
|
5190
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
5191
|
+
description: "Read up to 20 notes (or spans of them) in one call instead of repeated read_note calls, e.g. the top hits from search, query_notes, list_notes, or get_backlinks. Each item takes the same options as read_note and returns the same fields, in request order. budgetBytes (default 16384) caps the total note text: when the notes exceed it, each is cut to a fair share, marked truncated: true, and keeps noteBytes, so read the rest with read_note. A failing item (missing path, unknown section) returns its own error without failing the batch. contentHash is always the whole file's hash, usable as prevHash.",
|
|
5192
|
+
inputSchema: {
|
|
5193
|
+
type: "object",
|
|
5194
|
+
properties: {
|
|
5195
|
+
items: {
|
|
5196
|
+
type: "array",
|
|
5197
|
+
description: "Notes to read, 1\u201320. Same options as read_note.",
|
|
5198
|
+
items: {
|
|
5199
|
+
type: "object",
|
|
5200
|
+
properties: {
|
|
5201
|
+
path: {
|
|
5202
|
+
type: "string",
|
|
5203
|
+
description: 'Vault-relative path, e.g. "Daily Notes/2026-05-29.md".'
|
|
5204
|
+
},
|
|
5205
|
+
section: {
|
|
5206
|
+
type: "string",
|
|
5207
|
+
description: "Return only this heading section (heading text, # prefix optional)."
|
|
5208
|
+
},
|
|
5209
|
+
block: {
|
|
5210
|
+
type: "string",
|
|
5211
|
+
description: "Return only the line anchored by this block id (^ prefix optional)."
|
|
5212
|
+
},
|
|
5213
|
+
lines: {
|
|
5214
|
+
type: "object",
|
|
5215
|
+
description: "Return only this line range (1-indexed, inclusive).",
|
|
5216
|
+
properties: {
|
|
5217
|
+
from: { type: "number", description: "First line (1-indexed)." },
|
|
5218
|
+
to: {
|
|
5219
|
+
type: "number",
|
|
5220
|
+
description: "Last line (1-indexed, inclusive). Defaults to EOF."
|
|
5221
|
+
}
|
|
5222
|
+
},
|
|
5223
|
+
required: ["from"]
|
|
5224
|
+
},
|
|
5225
|
+
includeFrontmatter: {
|
|
5226
|
+
type: "boolean",
|
|
5227
|
+
description: "Prepend frontmatter to section/block span results. Default false for spans."
|
|
5228
|
+
}
|
|
5229
|
+
},
|
|
5230
|
+
required: ["path"]
|
|
5231
|
+
}
|
|
5232
|
+
},
|
|
5233
|
+
budgetBytes: {
|
|
5234
|
+
type: "number",
|
|
5235
|
+
description: "Cap on total note text returned across all items, in UTF-8 bytes (256\u201365536, default 16384)."
|
|
5236
|
+
}
|
|
5237
|
+
},
|
|
5238
|
+
required: ["items"]
|
|
5239
|
+
}
|
|
5240
|
+
},
|
|
5041
5241
|
{
|
|
5042
5242
|
name: "list_notes",
|
|
5043
5243
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
@@ -5046,7 +5246,10 @@ var ALL_TOOLS = [
|
|
|
5046
5246
|
type: "object",
|
|
5047
5247
|
properties: {
|
|
5048
5248
|
folder: { type: "string", description: "Vault-relative folder prefix." },
|
|
5049
|
-
tag: {
|
|
5249
|
+
tag: {
|
|
5250
|
+
type: "string",
|
|
5251
|
+
description: 'Filter by tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
|
|
5252
|
+
},
|
|
5050
5253
|
limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
|
|
5051
5254
|
},
|
|
5052
5255
|
required: []
|
|
@@ -5542,7 +5745,7 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
5542
5745
|
}
|
|
5543
5746
|
|
|
5544
5747
|
// src/index.ts
|
|
5545
|
-
var VERSION = true ? "0.
|
|
5748
|
+
var VERSION = true ? "0.21.0" : "0.0.0-dev";
|
|
5546
5749
|
var intent = parseCliIntent(process.argv.slice(2));
|
|
5547
5750
|
if (intent === "version") {
|
|
5548
5751
|
process.stdout.write(`${VERSION}
|