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 +4 -1
- package/dist/index.js +108 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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(
|
|
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) {
|
|
@@ -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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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.
|
|
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}
|