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 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
- 21 tools: 11 read, 10 write.
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("Restrict results to notes containing this tag."),
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("Restrict results to notes containing this tag.")
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("Filter by tag. Include or omit the leading #."),
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?.replace(/^#/, "");
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("Restrict to notes with this tag (# prefix optional)."),
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?.replace(/^#/, "");
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.split(" ").filter(Boolean).includes(tag)) continue;
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 z18 } from "zod";
2627
- var RenameHeadingInput = z18.object({
2628
- path: z18.string().describe("Vault-relative path of the note containing the heading."),
2629
- oldHeading: z18.string().min(1).describe(
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: z18.string().min(1).refine((s) => !/[\n[\]|#]/.test(s), {
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: z18.string().optional().describe(
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 z19 } from "zod";
2759
- var ReplaceInNoteInput = z19.object({
2760
- path: z19.string().describe("Vault-relative path to the note."),
2761
- find: z19.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
2762
- replace: z19.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
2763
- regex: z19.boolean().default(false).describe("Treat find as a regular expression."),
2764
- caseSensitive: z19.boolean().default(false).describe("Case-sensitive matching."),
2765
- wholeWord: z19.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
2766
- limit: z19.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
2767
- dryRun: z19.boolean().default(false).describe("If true, report matches without writing anything."),
2768
- prevHash: z19.string().optional().describe(
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 z20 } from "zod";
2854
- var UndoWriteInput = z20.object({
2855
- seq: z20.number().int().min(1).optional().describe(
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: z20.boolean().default(false).describe(
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: { type: "string", description: "Restrict to notes with this 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: { type: "string", description: "Restrict to notes with this tag (# optional)." },
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: { type: "string", description: "Restrict to notes with this 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: { type: "string", description: "Filter by tag (# prefix optional)." },
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.20.2" : "0.0.0-dev";
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}