seekstone 0.20.2 → 0.20.3

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
 
@@ -209,6 +209,9 @@ Any MCP-over-stdio client: Claude Desktop, Claude Code, Cursor, VS Code, Windsur
209
209
  **Does it work on Windows?**
210
210
  Yes — tested on macOS, Linux, and Windows in CI on every commit.
211
211
 
212
+ **Does search handle typos?**
213
+ 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.
214
+
212
215
  **Is it safe?**
213
216
  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
217
 
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) {
@@ -4908,7 +4982,10 @@ var ALL_TOOLS = [
4908
4982
  },
4909
4983
  limit: { type: "number", description: "Max results (1\u201350, default 10)." },
4910
4984
  folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
4911
- tag: { type: "string", description: "Restrict to notes with this tag." },
4985
+ tag: {
4986
+ type: "string",
4987
+ description: 'Restrict to notes with this tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
4988
+ },
4912
4989
  excerptLength: {
4913
4990
  type: "number",
4914
4991
  description: "Max characters of match context per hit (20\u20132000, default 120)."
@@ -4945,7 +5022,10 @@ var ALL_TOOLS = [
4945
5022
  }
4946
5023
  },
4947
5024
  folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
4948
- tag: { type: "string", description: "Restrict to notes with this tag (# optional)." },
5025
+ tag: {
5026
+ type: "string",
5027
+ description: 'Restrict to notes with this tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
5028
+ },
4949
5029
  modifiedAfter: {
4950
5030
  type: "string",
4951
5031
  description: "Only notes modified at or after this ISO 8601 date/time."
@@ -4994,7 +5074,10 @@ var ALL_TOOLS = [
4994
5074
  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
5075
  },
4996
5076
  folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
4997
- tag: { type: "string", description: "Restrict to notes with this tag." }
5077
+ tag: {
5078
+ type: "string",
5079
+ description: 'Restrict to notes with this tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
5080
+ }
4998
5081
  },
4999
5082
  required: ["query"]
5000
5083
  }
@@ -5046,7 +5129,10 @@ var ALL_TOOLS = [
5046
5129
  type: "object",
5047
5130
  properties: {
5048
5131
  folder: { type: "string", description: "Vault-relative folder prefix." },
5049
- tag: { type: "string", description: "Filter by tag (# prefix optional)." },
5132
+ tag: {
5133
+ type: "string",
5134
+ description: 'Filter by tag. # optional, case-insensitive; nested child tags match ("project" matches project/alpha).'
5135
+ },
5050
5136
  limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
5051
5137
  },
5052
5138
  required: []
@@ -5542,7 +5628,7 @@ function startWatcher(ctx2, log2, opts) {
5542
5628
  }
5543
5629
 
5544
5630
  // src/index.ts
5545
- var VERSION = true ? "0.20.2" : "0.0.0-dev";
5631
+ var VERSION = true ? "0.20.3" : "0.0.0-dev";
5546
5632
  var intent = parseCliIntent(process.argv.slice(2));
5547
5633
  if (intent === "version") {
5548
5634
  process.stdout.write(`${VERSION}