seekstone 0.18.0 → 0.19.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,7 +158,7 @@ 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`). |
package/dist/index.js CHANGED
@@ -1081,22 +1081,25 @@ function basenameNoExt(path2) {
1081
1081
  const dot = base.lastIndexOf(".");
1082
1082
  return dot > 0 ? base.slice(0, dot) : base;
1083
1083
  }
1084
- function search(ctx2, input) {
1084
+ function retrieve(ctx2, input) {
1085
1085
  const mode = input.mode ?? "lexical";
1086
1086
  if (mode === "lexical") return lexicalSearch(ctx2, input);
1087
1087
  const semantic = requireSemantic(ctx2);
1088
1088
  if (mode === "semantic") return semanticSearch(ctx2, semantic, input);
1089
1089
  const words = queryWords(input.query);
1090
1090
  if (words.length > 0 && words.length <= MAX_ROUTE_WORDS) {
1091
- const lexHits = lexicalSearch(ctx2, input);
1091
+ const lex = lexicalSearch(ctx2, input);
1092
1092
  if (routeToLexical(
1093
1093
  input.query,
1094
- lexHits.map((h) => h.path)
1094
+ lex.hits.map((h) => h.path)
1095
1095
  ))
1096
- return lexHits;
1096
+ return lex;
1097
1097
  }
1098
1098
  return semanticSearch(ctx2, semantic, input);
1099
1099
  }
1100
+ function search(ctx2, input) {
1101
+ return retrieve(ctx2, input).hits;
1102
+ }
1100
1103
  function requireSemantic(ctx2) {
1101
1104
  if (!ctx2.semantic) {
1102
1105
  throw new Error(
@@ -1143,7 +1146,7 @@ function lexicalSearch(ctx2, input) {
1143
1146
  hits.push(hit);
1144
1147
  if (hits.length >= input.limit) break;
1145
1148
  }
1146
- return hits;
1149
+ return { hits, totalCandidates: results.length };
1147
1150
  }
1148
1151
  function semanticSearch(ctx2, semantic, input) {
1149
1152
  const queryVec = semantic.embedQuery(input.query);
@@ -1166,7 +1169,7 @@ function semanticSearch(ctx2, semantic, input) {
1166
1169
  hits.push(hit);
1167
1170
  if (hits.length >= input.limit) break;
1168
1171
  }
1169
- return hits;
1172
+ return { hits, totalCandidates: candidates.length };
1170
1173
  }
1171
1174
  function filterNote(ctx2, input, path2) {
1172
1175
  if (input.folder && !path2.startsWith(input.folder)) return void 0;
@@ -1189,7 +1192,12 @@ function decorate(hit, title, tags) {
1189
1192
  // src/tools/context_pack.ts
1190
1193
  var ContextPackInput = z3.object({
1191
1194
  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.")
1195
+ 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."),
1196
+ mode: z3.enum(["lexical", "semantic", "hybrid"]).optional().describe(
1197
+ "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."
1198
+ ),
1199
+ folder: z3.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
1200
+ tag: z3.string().optional().describe("Restrict results to notes containing this tag.")
1193
1201
  });
1194
1202
  var MAX_CANDIDATES = 10;
1195
1203
  var NEIGHBOR_HITS = 3;
@@ -1220,18 +1228,22 @@ function clamp(n, lo, hi) {
1220
1228
  return Math.min(hi, Math.max(lo, n));
1221
1229
  }
1222
1230
  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
1231
+ const excerptLen = clamp(Math.floor(input.budgetBytes / 10), 80, 400);
1232
+ const { hits: results, totalCandidates } = retrieve(ctx2, {
1233
+ query: input.query,
1234
+ mode: input.mode,
1235
+ folder: input.folder,
1236
+ tag: input.tag,
1237
+ limit: MAX_CANDIDATES,
1238
+ excerptLength: excerptLen
1227
1239
  });
1228
- const totalMatches = results.length;
1229
- if (totalMatches === 0) {
1230
- return { excerpts: [], neighborhood: [], sources: [], totalMatches: 0, confidence: "none" };
1240
+ const totalMatches = totalCandidates;
1241
+ if (results.length === 0) {
1242
+ return { excerpts: [], neighborhood: [], sources: [], totalMatches, confidence: "none" };
1231
1243
  }
1232
1244
  const terms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
1233
1245
  const scoreByPath = /* @__PURE__ */ new Map();
1234
- for (const r of results) scoreByPath.set(r.id, r.score);
1246
+ for (const r of results) scoreByPath.set(r.path, r.score);
1235
1247
  const budget = input.budgetBytes;
1236
1248
  let used = Buffer.byteLength(
1237
1249
  JSON.stringify({
@@ -1244,24 +1256,20 @@ function contextPack(ctx2, input) {
1244
1256
  }),
1245
1257
  "utf8"
1246
1258
  );
1247
- const excerptLen = clamp(Math.floor(budget / 10), 80, 400);
1248
1259
  const reserve = Math.min(Math.floor(budget / 4), 600);
1249
1260
  const candidates = results.slice(0, MAX_CANDIDATES);
1250
1261
  const excerpts = [];
1251
1262
  const overflowHits = [];
1252
1263
  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) : [];
1264
+ const buildEntry = (h, len) => {
1265
+ const note = ctx2.notes.get(h.path);
1257
1266
  const entry = {
1258
- path: r.id,
1259
- score: Math.round(r.score * 100) / 100,
1260
- excerpt: extractExcerpt(body, terms, len)
1267
+ path: h.path,
1268
+ score: h.score,
1269
+ excerpt: h.excerpt.length > len ? extractExcerpt(h.excerpt, terms, len) : h.excerpt
1261
1270
  };
1262
- const title = r.title;
1263
- if (title && title !== basenameNoExt(r.id)) entry.title = title;
1264
- if (noteTags.length > 0) entry.tags = noteTags;
1271
+ if (h.title) entry.title = h.title;
1272
+ if (h.tags && h.tags.length > 0) entry.tags = h.tags;
1265
1273
  const fm = pickFm(note?.fm ?? null);
1266
1274
  if (fm) entry.fm = fm;
1267
1275
  return entry;
@@ -1270,7 +1278,7 @@ function contextPack(ctx2, input) {
1270
1278
  let phaseOpen = true;
1271
1279
  for (const r of candidates) {
1272
1280
  if (!phaseOpen) {
1273
- overflowHits.push({ path: r.id });
1281
+ overflowHits.push({ path: r.path });
1274
1282
  continue;
1275
1283
  }
1276
1284
  let placed = false;
@@ -1295,7 +1303,7 @@ function contextPack(ctx2, input) {
1295
1303
  if (!placed) {
1296
1304
  phaseOpen = false;
1297
1305
  droppedForBudget = true;
1298
- overflowHits.push({ path: r.id });
1306
+ overflowHits.push({ path: r.path });
1299
1307
  }
1300
1308
  }
1301
1309
  const includedPaths = new Set(excerpts.map((e) => e.path));
@@ -4927,7 +4935,7 @@ var ALL_TOOLS = [
4927
4935
  {
4928
4936
  name: "context_pack",
4929
4937
  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.',
4938
+ 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
4939
  inputSchema: {
4932
4940
  type: "object",
4933
4941
  properties: {
@@ -4935,7 +4943,14 @@ var ALL_TOOLS = [
4935
4943
  budgetBytes: {
4936
4944
  type: "number",
4937
4945
  description: "Hard cap on response JSON bytes (256\u201316384, default 2048)."
4938
- }
4946
+ },
4947
+ mode: {
4948
+ type: "string",
4949
+ enum: ["lexical", "semantic", "hybrid"],
4950
+ 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."
4951
+ },
4952
+ folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
4953
+ tag: { type: "string", description: "Restrict to notes with this tag." }
4939
4954
  },
4940
4955
  required: ["query"]
4941
4956
  }
@@ -5465,7 +5480,7 @@ function startWatcher(ctx2, log2, opts) {
5465
5480
  }
5466
5481
 
5467
5482
  // src/index.ts
5468
- var VERSION = true ? "0.18.0" : "0.0.0-dev";
5483
+ var VERSION = true ? "0.19.0" : "0.0.0-dev";
5469
5484
  var intent = parseCliIntent(process.argv.slice(2));
5470
5485
  if (intent === "version") {
5471
5486
  process.stdout.write(`${VERSION}