seekstone 0.18.0 → 0.20.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 +4 -3
- package/dist/index.js +98 -36
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ If you use Claude Code, Cursor, or another coding agent, paste this prompt and t
|
|
|
24
24
|
|
|
25
25
|
### Option 1 — One-click (Claude Desktop, no terminal needed)
|
|
26
26
|
|
|
27
|
-
Download `seekstone.mcpb`
|
|
27
|
+
Download [`seekstone.mcpb`](https://github.com/shaqmughal/seekstone/releases/latest/download/seekstone.mcpb), double-click it in Claude Desktop, and pick your Obsidian vault folder when prompted. No JSON editing, no terminal, no Node.js setup required. Want semantic search out of the box? Grab [`seekstone-semantic.mcpb`](https://github.com/shaqmughal/seekstone/releases/latest/download/seekstone-semantic.mcpb) instead — the local embedding model ships inside the bundle, and nothing is downloaded at runtime.
|
|
28
28
|
|
|
29
29
|
### Option 2 — Guided setup (recommended for CLI users)
|
|
30
30
|
|
|
@@ -112,7 +112,7 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
|
|
|
112
112
|
|---|---|
|
|
113
113
|
| `search` | Full-text search. Returns ranked excerpts (default ~120 chars, tunable via `excerptLength`), not full notes. Fuzzy and prefix matching; with `SEEKSTONE_SEMANTIC=1`, `mode: "semantic"`/`"hybrid"` searches by meaning via a local embedding model (nothing leaves your machine). |
|
|
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
|
-
| `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. |
|
|
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
117
|
| `list_notes` | List notes, optionally filtered by folder prefix or tag. |
|
|
118
118
|
| `list_tags` | List all tags in the vault sorted by usage count (or alphabetically). |
|
|
@@ -158,11 +158,12 @@ Every write tool (`append_note`, `patch_note`, `patch_frontmatter`, `replace_in_
|
|
|
158
158
|
| `SEEKSTONE_HISTORY_MAX_ENTRIES` | no | Cap on journal entries (default `1000`); the oldest are dropped past it. |
|
|
159
159
|
| `SEEKSTONE_AUDIT_FILE` | no | Absolute path; off unless set. Appends one JSON-line audit record per write-tool call — ok or refused — with the tool, paths, sha-256 before/after, outcome, and op metadata. Never note content. |
|
|
160
160
|
| `SEEKSTONE_AUDIT_MAX_SIZE` | no | Rotate the audit file to `<file>.1` past this size (e.g. `10mb`; default 10 MB). |
|
|
161
|
-
| `SEEKSTONE_SEMANTIC` | no | Set to `1` to enable semantic search (`search`
|
|
161
|
+
| `SEEKSTONE_SEMANTIC` | no | Set to `1` to enable semantic search (`search` and `context_pack` gain `mode: "semantic"` and `"hybrid"`). Download the local model once with `npx -y seekstone fetch-model`; the running server never touches the network. The `seekstone-semantic.mcpb` bundle sets this automatically and ships the model inside. |
|
|
162
162
|
| `SEEKSTONE_SEMANTIC_MODEL` | no | `potion-base-8M` (default, ~30 MB) or `potion-retrieval-32M` (~129 MB, more accurate, ~2× query latency). Fetch it first: `npx -y seekstone fetch-model --model potion-retrieval-32M`. |
|
|
163
163
|
| `SEEKSTONE_MODEL_PATH` | no | Directory holding the Model2Vec embedding model (default: where `fetch-model` puts the selected model). |
|
|
164
164
|
| `SEEKSTONE_CACHE_DIR` | no | Cache root for the model and per-vault embedding caches (default `~/.cache/seekstone`). |
|
|
165
165
|
| `SEEKSTONE_BUNDLED_MODEL_DIR` | no | Set by the `seekstone-semantic.mcpb` bundle's manifest — sharded model files shipped inside the extension, reassembled into the model directory at boot (disk-only, hash-verified). Not usually set by hand. |
|
|
166
|
+
| `SEEKSTONE_INSTRUCTIONS` | no | Path to a file whose contents become the MCP server's `instructions` string (surfaced to clients alongside the tool list). A relative path resolves against the vault root. Read once at boot, trimmed, capped at 16 KB (truncated with a warning past that). Missing, unreadable, or empty after trim means no instructions — never a boot failure. |
|
|
166
167
|
|
|
167
168
|
---
|
|
168
169
|
|
package/dist/index.js
CHANGED
|
@@ -192,7 +192,7 @@ var init_fetch_model = __esm({
|
|
|
192
192
|
|
|
193
193
|
// src/index.ts
|
|
194
194
|
import { homedir } from "os";
|
|
195
|
-
import { resolve as
|
|
195
|
+
import { resolve as resolve3 } from "path";
|
|
196
196
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
197
197
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
198
198
|
import {
|
|
@@ -439,6 +439,8 @@ Optional environment:
|
|
|
439
439
|
SEEKSTONE_SEMANTIC_MODEL potion-base-8M (default) | potion-retrieval-32M (bigger, more accurate)
|
|
440
440
|
SEEKSTONE_MODEL_PATH Override the embedding-model directory
|
|
441
441
|
SEEKSTONE_CACHE_DIR Override the cache root (default ~/.cache/seekstone)
|
|
442
|
+
SEEKSTONE_INSTRUCTIONS Path to a file whose contents become the MCP
|
|
443
|
+
"instructions" (relative paths resolve against the vault)
|
|
442
444
|
|
|
443
445
|
Add to Claude Code:
|
|
444
446
|
claude mcp add seekstone --env SEEKSTONE_VAULT=/path/to/vault -- npx -y seekstone
|
|
@@ -1081,22 +1083,25 @@ function basenameNoExt(path2) {
|
|
|
1081
1083
|
const dot = base.lastIndexOf(".");
|
|
1082
1084
|
return dot > 0 ? base.slice(0, dot) : base;
|
|
1083
1085
|
}
|
|
1084
|
-
function
|
|
1086
|
+
function retrieve(ctx2, input) {
|
|
1085
1087
|
const mode = input.mode ?? "lexical";
|
|
1086
1088
|
if (mode === "lexical") return lexicalSearch(ctx2, input);
|
|
1087
1089
|
const semantic = requireSemantic(ctx2);
|
|
1088
1090
|
if (mode === "semantic") return semanticSearch(ctx2, semantic, input);
|
|
1089
1091
|
const words = queryWords(input.query);
|
|
1090
1092
|
if (words.length > 0 && words.length <= MAX_ROUTE_WORDS) {
|
|
1091
|
-
const
|
|
1093
|
+
const lex = lexicalSearch(ctx2, input);
|
|
1092
1094
|
if (routeToLexical(
|
|
1093
1095
|
input.query,
|
|
1094
|
-
|
|
1096
|
+
lex.hits.map((h) => h.path)
|
|
1095
1097
|
))
|
|
1096
|
-
return
|
|
1098
|
+
return lex;
|
|
1097
1099
|
}
|
|
1098
1100
|
return semanticSearch(ctx2, semantic, input);
|
|
1099
1101
|
}
|
|
1102
|
+
function search(ctx2, input) {
|
|
1103
|
+
return retrieve(ctx2, input).hits;
|
|
1104
|
+
}
|
|
1100
1105
|
function requireSemantic(ctx2) {
|
|
1101
1106
|
if (!ctx2.semantic) {
|
|
1102
1107
|
throw new Error(
|
|
@@ -1143,7 +1148,7 @@ function lexicalSearch(ctx2, input) {
|
|
|
1143
1148
|
hits.push(hit);
|
|
1144
1149
|
if (hits.length >= input.limit) break;
|
|
1145
1150
|
}
|
|
1146
|
-
return hits;
|
|
1151
|
+
return { hits, totalCandidates: results.length };
|
|
1147
1152
|
}
|
|
1148
1153
|
function semanticSearch(ctx2, semantic, input) {
|
|
1149
1154
|
const queryVec = semantic.embedQuery(input.query);
|
|
@@ -1166,7 +1171,7 @@ function semanticSearch(ctx2, semantic, input) {
|
|
|
1166
1171
|
hits.push(hit);
|
|
1167
1172
|
if (hits.length >= input.limit) break;
|
|
1168
1173
|
}
|
|
1169
|
-
return hits;
|
|
1174
|
+
return { hits, totalCandidates: candidates.length };
|
|
1170
1175
|
}
|
|
1171
1176
|
function filterNote(ctx2, input, path2) {
|
|
1172
1177
|
if (input.folder && !path2.startsWith(input.folder)) return void 0;
|
|
@@ -1189,7 +1194,12 @@ function decorate(hit, title, tags) {
|
|
|
1189
1194
|
// src/tools/context_pack.ts
|
|
1190
1195
|
var ContextPackInput = z3.object({
|
|
1191
1196
|
query: z3.string().min(1).describe("Natural-language question or topic to assemble context for."),
|
|
1192
|
-
budgetBytes: z3.number().int().min(256).max(16384).default(2048).describe("Hard cap on the response JSON size in bytes. The pack never exceeds it.")
|
|
1197
|
+
budgetBytes: z3.number().int().min(256).max(16384).default(2048).describe("Hard cap on the response JSON size in bytes. The pack never exceeds it."),
|
|
1198
|
+
mode: z3.enum(["lexical", "semantic", "hybrid"]).optional().describe(
|
|
1199
|
+
"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
|
+
),
|
|
1201
|
+
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.")
|
|
1193
1203
|
});
|
|
1194
1204
|
var MAX_CANDIDATES = 10;
|
|
1195
1205
|
var NEIGHBOR_HITS = 3;
|
|
@@ -1220,18 +1230,22 @@ function clamp(n, lo, hi) {
|
|
|
1220
1230
|
return Math.min(hi, Math.max(lo, n));
|
|
1221
1231
|
}
|
|
1222
1232
|
function contextPack(ctx2, input) {
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1233
|
+
const excerptLen = clamp(Math.floor(input.budgetBytes / 10), 80, 400);
|
|
1234
|
+
const { hits: results, totalCandidates } = retrieve(ctx2, {
|
|
1235
|
+
query: input.query,
|
|
1236
|
+
mode: input.mode,
|
|
1237
|
+
folder: input.folder,
|
|
1238
|
+
tag: input.tag,
|
|
1239
|
+
limit: MAX_CANDIDATES,
|
|
1240
|
+
excerptLength: excerptLen
|
|
1227
1241
|
});
|
|
1228
|
-
const totalMatches =
|
|
1229
|
-
if (
|
|
1230
|
-
return { excerpts: [], neighborhood: [], sources: [], totalMatches
|
|
1242
|
+
const totalMatches = totalCandidates;
|
|
1243
|
+
if (results.length === 0) {
|
|
1244
|
+
return { excerpts: [], neighborhood: [], sources: [], totalMatches, confidence: "none" };
|
|
1231
1245
|
}
|
|
1232
1246
|
const terms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
|
|
1233
1247
|
const scoreByPath = /* @__PURE__ */ new Map();
|
|
1234
|
-
for (const r of results) scoreByPath.set(r.
|
|
1248
|
+
for (const r of results) scoreByPath.set(r.path, r.score);
|
|
1235
1249
|
const budget = input.budgetBytes;
|
|
1236
1250
|
let used = Buffer.byteLength(
|
|
1237
1251
|
JSON.stringify({
|
|
@@ -1244,24 +1258,20 @@ function contextPack(ctx2, input) {
|
|
|
1244
1258
|
}),
|
|
1245
1259
|
"utf8"
|
|
1246
1260
|
);
|
|
1247
|
-
const excerptLen = clamp(Math.floor(budget / 10), 80, 400);
|
|
1248
1261
|
const reserve = Math.min(Math.floor(budget / 4), 600);
|
|
1249
1262
|
const candidates = results.slice(0, MAX_CANDIDATES);
|
|
1250
1263
|
const excerpts = [];
|
|
1251
1264
|
const overflowHits = [];
|
|
1252
1265
|
let droppedForBudget = false;
|
|
1253
|
-
const buildEntry = (
|
|
1254
|
-
const note = ctx2.notes.get(
|
|
1255
|
-
const body = note?.body ?? "";
|
|
1256
|
-
const noteTags = note?.tags ? note.tags.split(" ").filter(Boolean) : [];
|
|
1266
|
+
const buildEntry = (h, len) => {
|
|
1267
|
+
const note = ctx2.notes.get(h.path);
|
|
1257
1268
|
const entry = {
|
|
1258
|
-
path:
|
|
1259
|
-
score:
|
|
1260
|
-
excerpt: extractExcerpt(
|
|
1269
|
+
path: h.path,
|
|
1270
|
+
score: h.score,
|
|
1271
|
+
excerpt: h.excerpt.length > len ? extractExcerpt(h.excerpt, terms, len) : h.excerpt
|
|
1261
1272
|
};
|
|
1262
|
-
|
|
1263
|
-
if (
|
|
1264
|
-
if (noteTags.length > 0) entry.tags = noteTags;
|
|
1273
|
+
if (h.title) entry.title = h.title;
|
|
1274
|
+
if (h.tags && h.tags.length > 0) entry.tags = h.tags;
|
|
1265
1275
|
const fm = pickFm(note?.fm ?? null);
|
|
1266
1276
|
if (fm) entry.fm = fm;
|
|
1267
1277
|
return entry;
|
|
@@ -1270,7 +1280,7 @@ function contextPack(ctx2, input) {
|
|
|
1270
1280
|
let phaseOpen = true;
|
|
1271
1281
|
for (const r of candidates) {
|
|
1272
1282
|
if (!phaseOpen) {
|
|
1273
|
-
overflowHits.push({ path: r.
|
|
1283
|
+
overflowHits.push({ path: r.path });
|
|
1274
1284
|
continue;
|
|
1275
1285
|
}
|
|
1276
1286
|
let placed = false;
|
|
@@ -1295,7 +1305,7 @@ function contextPack(ctx2, input) {
|
|
|
1295
1305
|
if (!placed) {
|
|
1296
1306
|
phaseOpen = false;
|
|
1297
1307
|
droppedForBudget = true;
|
|
1298
|
-
overflowHits.push({ path: r.
|
|
1308
|
+
overflowHits.push({ path: r.path });
|
|
1299
1309
|
}
|
|
1300
1310
|
}
|
|
1301
1311
|
const includedPaths = new Set(excerpts.map((e) => e.path));
|
|
@@ -3782,6 +3792,47 @@ async function runInit(opts, deps) {
|
|
|
3782
3792
|
return { ok: true, exitCode: 0, output: out, wrotePath: configPath, backupPath };
|
|
3783
3793
|
}
|
|
3784
3794
|
|
|
3795
|
+
// src/instructions.ts
|
|
3796
|
+
import { readFileSync } from "fs";
|
|
3797
|
+
import { isAbsolute, resolve as resolve2 } from "path";
|
|
3798
|
+
var MAX_BYTES = 16 * 1024;
|
|
3799
|
+
function truncateUtf8(text, maxBytes) {
|
|
3800
|
+
let bytes = 0;
|
|
3801
|
+
let out = "";
|
|
3802
|
+
for (const ch of text) {
|
|
3803
|
+
const chBytes = Buffer.byteLength(ch, "utf8");
|
|
3804
|
+
if (bytes + chBytes > maxBytes) break;
|
|
3805
|
+
out += ch;
|
|
3806
|
+
bytes += chBytes;
|
|
3807
|
+
}
|
|
3808
|
+
return out;
|
|
3809
|
+
}
|
|
3810
|
+
function resolveInstructions(env, vaultRoot2, log2) {
|
|
3811
|
+
const raw = (env.SEEKSTONE_INSTRUCTIONS ?? "").trim();
|
|
3812
|
+
if (raw === "") return void 0;
|
|
3813
|
+
const path2 = isAbsolute(raw) ? raw : resolve2(vaultRoot2, raw);
|
|
3814
|
+
let content;
|
|
3815
|
+
try {
|
|
3816
|
+
content = readFileSync(path2, "utf8");
|
|
3817
|
+
} catch (err) {
|
|
3818
|
+
log2.warn("cannot read SEEKSTONE_INSTRUCTIONS file; starting without instructions", {
|
|
3819
|
+
path: path2,
|
|
3820
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3821
|
+
});
|
|
3822
|
+
return void 0;
|
|
3823
|
+
}
|
|
3824
|
+
let trimmed = content.trim();
|
|
3825
|
+
if (trimmed === "") return void 0;
|
|
3826
|
+
const bytes = Buffer.byteLength(trimmed, "utf8");
|
|
3827
|
+
if (bytes > MAX_BYTES) {
|
|
3828
|
+
log2.warn("SEEKSTONE_INSTRUCTIONS file exceeds 16 KB; truncated", { path: path2, bytes });
|
|
3829
|
+
trimmed = truncateUtf8(trimmed, MAX_BYTES).trim();
|
|
3830
|
+
if (trimmed === "") return void 0;
|
|
3831
|
+
}
|
|
3832
|
+
log2.info("instructions attached", { bytes: Buffer.byteLength(trimmed, "utf8") });
|
|
3833
|
+
return trimmed;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3785
3836
|
// src/process-guards.ts
|
|
3786
3837
|
function installProcessGuards(log2) {
|
|
3787
3838
|
const onUnhandledRejection = (reason) => {
|
|
@@ -4927,7 +4978,7 @@ var ALL_TOOLS = [
|
|
|
4927
4978
|
{
|
|
4928
4979
|
name: "context_pack",
|
|
4929
4980
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
4930
|
-
description: 'Assemble everything needed to ANSWER a natural-language question in one call, under a strict byte budget (default 2048): ranked excerpts, linked neighbor notes (backlinks/outlinks) with one-line summaries, and follow-up source paths. Use search to locate notes and query_notes for metadata filters; use context_pack when you want answer-ready context without multiple round-trips. Empty excerpts with confidence "none" or "low" means the vault lacks coverage \u2014 do not infer content.',
|
|
4981
|
+
description: 'Assemble everything needed to ANSWER a natural-language question in one call, under a strict byte budget (default 2048): ranked excerpts, linked neighbor notes (backlinks/outlinks) with one-line summaries, and follow-up source paths. Retrieval is lexical by default: for a question phrased in words the note itself may not use, pass mode "hybrid" or "semantic", and scope with folder/tag exactly as in search. Use search to locate notes and query_notes for metadata filters; use context_pack when you want answer-ready context without multiple round-trips. Empty excerpts with confidence "none" or "low" means the vault lacks coverage \u2014 do not infer content; with folder or tag set, "none" can also mean the filter excluded every match, which totalMatches distinguishes.',
|
|
4931
4982
|
inputSchema: {
|
|
4932
4983
|
type: "object",
|
|
4933
4984
|
properties: {
|
|
@@ -4935,7 +4986,14 @@ var ALL_TOOLS = [
|
|
|
4935
4986
|
budgetBytes: {
|
|
4936
4987
|
type: "number",
|
|
4937
4988
|
description: "Hard cap on response JSON bytes (256\u201316384, default 2048)."
|
|
4938
|
-
}
|
|
4989
|
+
},
|
|
4990
|
+
mode: {
|
|
4991
|
+
type: "string",
|
|
4992
|
+
enum: ["lexical", "semantic", "hybrid"],
|
|
4993
|
+
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."
|
|
4994
|
+
},
|
|
4995
|
+
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
4996
|
+
tag: { type: "string", description: "Restrict to notes with this tag." }
|
|
4939
4997
|
},
|
|
4940
4998
|
required: ["query"]
|
|
4941
4999
|
}
|
|
@@ -5453,9 +5511,9 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
5453
5511
|
opts?.onRawEvent?.("unlink", abs, relPath);
|
|
5454
5512
|
if (relPath.endsWith(".md")) remove(relPath);
|
|
5455
5513
|
});
|
|
5456
|
-
const ready = new Promise((
|
|
5514
|
+
const ready = new Promise((resolve4) => {
|
|
5457
5515
|
watcher2.once("ready", () => {
|
|
5458
|
-
|
|
5516
|
+
resolve4();
|
|
5459
5517
|
});
|
|
5460
5518
|
});
|
|
5461
5519
|
return {
|
|
@@ -5465,7 +5523,7 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
5465
5523
|
}
|
|
5466
5524
|
|
|
5467
5525
|
// src/index.ts
|
|
5468
|
-
var VERSION = true ? "0.
|
|
5526
|
+
var VERSION = true ? "0.20.0" : "0.0.0-dev";
|
|
5469
5527
|
var intent = parseCliIntent(process.argv.slice(2));
|
|
5470
5528
|
if (intent === "version") {
|
|
5471
5529
|
process.stdout.write(`${VERSION}
|
|
@@ -5512,7 +5570,7 @@ if (!rawVaultRoot) {
|
|
|
5512
5570
|
log.error("SEEKSTONE_VAULT env var is required");
|
|
5513
5571
|
process.exit(1);
|
|
5514
5572
|
}
|
|
5515
|
-
var vaultRoot =
|
|
5573
|
+
var vaultRoot = resolve3(rawVaultRoot);
|
|
5516
5574
|
var policy = parseWritePolicy(process.env);
|
|
5517
5575
|
if (policy.readOnly) log.info("read-only mode", { env: "SEEKSTONE_READ_ONLY" });
|
|
5518
5576
|
if (policy.writeGlobs) log.info("write paths scoped", { globs: policy.writeGlobs });
|
|
@@ -5564,7 +5622,11 @@ if (semanticCfg) {
|
|
|
5564
5622
|
}
|
|
5565
5623
|
var watcher = startWatcher(ctx, log);
|
|
5566
5624
|
process.on("exit", () => void watcher.stop());
|
|
5567
|
-
var
|
|
5625
|
+
var instructions = resolveInstructions(process.env, vaultRoot, log);
|
|
5626
|
+
var server = new Server(
|
|
5627
|
+
{ name: "seekstone", version: VERSION },
|
|
5628
|
+
{ capabilities: { tools: {} }, instructions }
|
|
5629
|
+
);
|
|
5568
5630
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
5569
5631
|
tools: visibleTools(ctx.policy)
|
|
5570
5632
|
}));
|