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 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` from [GitHub Releases](https://github.com/shaqmughal/seekstone/releases/latest), double-click it in Claude Desktop, and pick your Obsidian vault folder when prompted. No JSON editing, no terminal, no Node.js setup required.
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` gains `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. |
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 resolve2 } from "path";
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 search(ctx2, input) {
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 lexHits = lexicalSearch(ctx2, input);
1093
+ const lex = lexicalSearch(ctx2, input);
1092
1094
  if (routeToLexical(
1093
1095
  input.query,
1094
- lexHits.map((h) => h.path)
1096
+ lex.hits.map((h) => h.path)
1095
1097
  ))
1096
- return lexHits;
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 results = ctx2.index.search(input.query, {
1224
- boost: { title: 3, tags: 2, body: 1 },
1225
- fuzzy: 0.2,
1226
- prefix: true
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 = results.length;
1229
- if (totalMatches === 0) {
1230
- return { excerpts: [], neighborhood: [], sources: [], totalMatches: 0, confidence: "none" };
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.id, r.score);
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 = (r, len) => {
1254
- const note = ctx2.notes.get(r.id);
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: r.id,
1259
- score: Math.round(r.score * 100) / 100,
1260
- excerpt: extractExcerpt(body, terms, len)
1269
+ path: h.path,
1270
+ score: h.score,
1271
+ excerpt: h.excerpt.length > len ? extractExcerpt(h.excerpt, terms, len) : h.excerpt
1261
1272
  };
1262
- const title = r.title;
1263
- if (title && title !== basenameNoExt(r.id)) entry.title = title;
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.id });
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.id });
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((resolve3) => {
5514
+ const ready = new Promise((resolve4) => {
5457
5515
  watcher2.once("ready", () => {
5458
- resolve3();
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.18.0" : "0.0.0-dev";
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 = resolve2(rawVaultRoot);
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 server = new Server({ name: "seekstone", version: VERSION }, { capabilities: { tools: {} } });
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
  }));