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 +3 -3
- package/dist/index.js +46 -31
- 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,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`
|
|
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
|
|
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
|
|
1091
|
+
const lex = lexicalSearch(ctx2, input);
|
|
1092
1092
|
if (routeToLexical(
|
|
1093
1093
|
input.query,
|
|
1094
|
-
|
|
1094
|
+
lex.hits.map((h) => h.path)
|
|
1095
1095
|
))
|
|
1096
|
-
return
|
|
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
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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 =
|
|
1229
|
-
if (
|
|
1230
|
-
return { excerpts: [], neighborhood: [], sources: [], totalMatches
|
|
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.
|
|
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 = (
|
|
1254
|
-
const note = ctx2.notes.get(
|
|
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:
|
|
1259
|
-
score:
|
|
1260
|
-
excerpt: extractExcerpt(
|
|
1267
|
+
path: h.path,
|
|
1268
|
+
score: h.score,
|
|
1269
|
+
excerpt: h.excerpt.length > len ? extractExcerpt(h.excerpt, terms, len) : h.excerpt
|
|
1261
1270
|
};
|
|
1262
|
-
|
|
1263
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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}
|