seekstone 0.17.2 → 0.18.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
@@ -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 **~14 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
 
@@ -158,10 +158,11 @@ 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. |
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. |
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
+ | `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. |
165
166
 
166
167
  ---
167
168
 
@@ -208,7 +209,7 @@ Any MCP-over-stdio client: Claude Desktop, Claude Code, Cursor, VS Code, Windsur
208
209
  Yes — tested on macOS, Linux, and Windows in CI on every commit.
209
210
 
210
211
  **Is it safe?**
211
- 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). 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.
212
+ 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.
212
213
 
213
214
  ---
214
215
 
package/dist/index.js CHANGED
@@ -71,9 +71,9 @@ var init_model_manifest = __esm({
71
71
  });
72
72
 
73
73
  // src/semantic/config.ts
74
- import { join as join8 } from "path";
74
+ import { join as join9 } from "path";
75
75
  function defaultCacheDir(env, homedir2) {
76
- return env.SEEKSTONE_CACHE_DIR || join8(homedir2, ".cache", "seekstone");
76
+ return env.SEEKSTONE_CACHE_DIR || join9(homedir2, ".cache", "seekstone");
77
77
  }
78
78
  function resolveModel(raw) {
79
79
  const id = raw?.trim();
@@ -96,7 +96,7 @@ function resolveSemanticConfig(env, homedir2) {
96
96
  if (env.SEEKSTONE_SEMANTIC !== "1") return void 0;
97
97
  const modelId = resolveModelId(env.SEEKSTONE_SEMANTIC_MODEL);
98
98
  const cacheDir = defaultCacheDir(env, homedir2);
99
- const modelDir = env.SEEKSTONE_MODEL_PATH || join8(cacheDir, "models", modelId);
99
+ const modelDir = env.SEEKSTONE_MODEL_PATH || join9(cacheDir, "models", modelId);
100
100
  return { modelId, modelDir, cacheDir };
101
101
  }
102
102
  var DEFAULT_MODEL_ID;
@@ -114,10 +114,10 @@ __export(fetch_model_exports, {
114
114
  runFetchModel: () => runFetchModel,
115
115
  selectManifest: () => selectManifest
116
116
  });
117
- import { createHash as createHash3 } from "crypto";
118
- import { existsSync } from "fs";
119
- import { mkdir as mkdir9, readFile as readFile19, writeFile as writeFile4 } from "fs/promises";
120
- import { join as join12 } from "path";
117
+ import { createHash as createHash4 } from "crypto";
118
+ import { existsSync as existsSync2 } from "fs";
119
+ import { mkdir as mkdir10, readFile as readFile20, writeFile as writeFile5 } from "fs/promises";
120
+ import { join as join13 } from "path";
121
121
  function selectManifest(argv, env) {
122
122
  let requested;
123
123
  for (let i = 0; i < argv.length; i++) {
@@ -146,15 +146,15 @@ async function runFetchModel(deps) {
146
146
  return { output, exitCode: 1 };
147
147
  }
148
148
  const fetchFn = deps.fetchFn ?? fetch;
149
- const destDir = deps.env.SEEKSTONE_MODEL_PATH || join12(defaultCacheDir(deps.env, deps.homedir), "models", manifest.id);
149
+ const destDir = deps.env.SEEKSTONE_MODEL_PATH || join13(defaultCacheDir(deps.env, deps.homedir), "models", manifest.id);
150
150
  output.push(`fetch-model: ${manifest.id} \u2192 ${destDir}`);
151
151
  try {
152
- await mkdir9(destDir, { recursive: true });
152
+ await mkdir10(destDir, { recursive: true });
153
153
  let fetched = 0;
154
154
  let skipped = 0;
155
155
  for (const file of manifest.files) {
156
- const dest = join12(destDir, file.name);
157
- if (existsSync(dest) && sha256(await readFile19(dest)) === file.sha256) {
156
+ const dest = join13(destDir, file.name);
157
+ if (existsSync2(dest) && sha2562(await readFile20(dest)) === file.sha256) {
158
158
  skipped++;
159
159
  continue;
160
160
  }
@@ -162,11 +162,11 @@ async function runFetchModel(deps) {
162
162
  const res = await fetchFn(file.url);
163
163
  if (!res.ok) throw new Error(`fetch ${file.url} failed: ${res.status}`);
164
164
  const buf = Buffer.from(await res.arrayBuffer());
165
- const got = sha256(buf);
165
+ const got = sha2562(buf);
166
166
  if (got !== file.sha256) {
167
167
  throw new Error(`checksum mismatch for ${file.name}: expected ${file.sha256}, got ${got}`);
168
168
  }
169
- await writeFile4(dest, buf);
169
+ await writeFile5(dest, buf);
170
170
  fetched++;
171
171
  }
172
172
  output.push(`\u2713 ${manifest.id}: ${fetched} fetched, ${skipped} already present (verified).`);
@@ -179,8 +179,8 @@ async function runFetchModel(deps) {
179
179
  return { output, exitCode: 1 };
180
180
  }
181
181
  }
182
- function sha256(buf) {
183
- return createHash3("sha256").update(buf).digest("hex");
182
+ function sha2562(buf) {
183
+ return createHash4("sha256").update(buf).digest("hex");
184
184
  }
185
185
  var init_fetch_model = __esm({
186
186
  "src/semantic/fetch-model.ts"() {
@@ -271,7 +271,7 @@ function createLogger(opts = {}) {
271
271
  );
272
272
  }
273
273
  }
274
- function writeFile5(record) {
274
+ function writeFile6(record) {
275
275
  if (!fileOk || filePath === void 0) return;
276
276
  const line = `${JSON.stringify(record)}
277
277
  `;
@@ -293,7 +293,7 @@ function createLogger(opts = {}) {
293
293
  const f = fields ?? {};
294
294
  stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
295
295
  `);
296
- writeFile5({ t: ts, level: lvl, msg, ...f });
296
+ writeFile6({ t: ts, level: lvl, msg, ...f });
297
297
  }
298
298
  return {
299
299
  level,
@@ -3794,6 +3794,82 @@ function installProcessGuards(log2) {
3794
3794
  };
3795
3795
  }
3796
3796
 
3797
+ // src/semantic/bundled-model.ts
3798
+ init_model_manifest();
3799
+ import { createHash as createHash2 } from "crypto";
3800
+ import { existsSync } from "fs";
3801
+ import { mkdir as mkdir8, readdir as readdir3, readFile as readFile16, rename as rename5, writeFile as writeFile3 } from "fs/promises";
3802
+ import { join as join8 } from "path";
3803
+ async function materializeBundledModel(cfg, bundledDir, deps = {}) {
3804
+ if (!bundledDir) return;
3805
+ if (!existsSync(bundledDir)) {
3806
+ deps.log?.warn("bundled model dir missing \u2014 skipping materialization", { dir: bundledDir });
3807
+ return;
3808
+ }
3809
+ let bundledId;
3810
+ try {
3811
+ const raw = await readFile16(join8(bundledDir, "manifest.json"), "utf8");
3812
+ bundledId = String(JSON.parse(raw).id);
3813
+ } catch (err) {
3814
+ throw new Error(
3815
+ `bundled model: cannot read ${join8(bundledDir, "manifest.json")} \u2014 the extension bundle is corrupt; reinstall it (${err instanceof Error ? err.message : String(err)})`
3816
+ );
3817
+ }
3818
+ if (bundledId !== cfg.modelId) {
3819
+ deps.log?.info("bundled model does not match the requested model \u2014 skipping", {
3820
+ bundled: bundledId,
3821
+ requested: cfg.modelId
3822
+ });
3823
+ return;
3824
+ }
3825
+ const manifest = (deps.manifestFor ?? findModel)(cfg.modelId);
3826
+ if (!manifest) return;
3827
+ await mkdir8(cfg.modelDir, { recursive: true });
3828
+ let written = 0;
3829
+ for (const file of manifest.files) {
3830
+ const dest = join8(cfg.modelDir, file.name);
3831
+ if (existsSync(dest) && sha256(await readFile16(dest)) === file.sha256) continue;
3832
+ const buf = await concatParts(bundledDir, file.name);
3833
+ const got = sha256(buf);
3834
+ if (got !== file.sha256) {
3835
+ throw new Error(
3836
+ `bundled model: checksum mismatch for ${file.name} (expected ${file.sha256}, got ${got}) \u2014 the extension bundle is corrupt; reinstall it`
3837
+ );
3838
+ }
3839
+ const tmp = `${dest}.tmp-${process.pid}`;
3840
+ await writeFile3(tmp, buf);
3841
+ await rename5(tmp, dest);
3842
+ written++;
3843
+ }
3844
+ if (written > 0) {
3845
+ deps.log?.info("bundled model materialized", {
3846
+ model: manifest.id,
3847
+ dir: cfg.modelDir,
3848
+ files: written
3849
+ });
3850
+ }
3851
+ }
3852
+ async function concatParts(bundledDir, name) {
3853
+ const prefix = `${name}.`;
3854
+ const partNum = (f) => Number(f.slice(prefix.length, -".part".length));
3855
+ const parts = (await readdir3(bundledDir)).filter((f) => {
3856
+ if (!f.startsWith(prefix) || !f.endsWith(".part")) return false;
3857
+ const middle = f.slice(prefix.length, -".part".length);
3858
+ return /^\d+$/.test(middle);
3859
+ }).sort((a, b) => partNum(a) - partNum(b));
3860
+ if (parts.length === 0) {
3861
+ throw new Error(
3862
+ `bundled model: no shards for ${name} in ${bundledDir} \u2014 the extension bundle is corrupt; reinstall it`
3863
+ );
3864
+ }
3865
+ const buffers = [];
3866
+ for (const part of parts) buffers.push(await readFile16(join8(bundledDir, part)));
3867
+ return Buffer.concat(buffers);
3868
+ }
3869
+ function sha256(buf) {
3870
+ return createHash2("sha256").update(buf).digest("hex");
3871
+ }
3872
+
3797
3873
  // src/index.ts
3798
3874
  init_config();
3799
3875
 
@@ -3945,8 +4021,8 @@ function candidateSetIdf(queryIds, docs) {
3945
4021
  }
3946
4022
 
3947
4023
  // ../core/src/embed/model2vec.ts
3948
- import { readFile as readFile16 } from "fs/promises";
3949
- import { basename as basename4, join as join9 } from "path";
4024
+ import { readFile as readFile17 } from "fs/promises";
4025
+ import { basename as basename4, join as join10 } from "path";
3950
4026
 
3951
4027
  // ../core/src/embed/safetensors.ts
3952
4028
  function readSafetensors(buf) {
@@ -4127,9 +4203,9 @@ function isCjk(cp) {
4127
4203
  var TENSOR_NAME = "embeddings";
4128
4204
  async function loadModel2Vec(modelDir) {
4129
4205
  const [configRaw, tokenizerRaw, weights] = await Promise.all([
4130
- readFile16(join9(modelDir, "config.json"), "utf8"),
4131
- readFile16(join9(modelDir, "tokenizer.json"), "utf8"),
4132
- readFile16(join9(modelDir, "model.safetensors"))
4206
+ readFile17(join10(modelDir, "config.json"), "utf8"),
4207
+ readFile17(join10(modelDir, "tokenizer.json"), "utf8"),
4208
+ readFile17(join10(modelDir, "model.safetensors"))
4133
4209
  ]);
4134
4210
  const config = JSON.parse(configRaw);
4135
4211
  const tokenizer = loadWordPieceTokenizer(JSON.parse(tokenizerRaw));
@@ -4287,26 +4363,27 @@ function isTokenEmbedder(e) {
4287
4363
  }
4288
4364
 
4289
4365
  // src/semantic/cache.ts
4290
- import { createHash as createHash2 } from "crypto";
4291
- import { mkdir as mkdir8, readFile as readFile17, rename as rename5, writeFile as writeFile3 } from "fs/promises";
4292
- import { join as join10 } from "path";
4293
- var CACHE_VERSION = 2;
4366
+ import { createHash as createHash3 } from "crypto";
4367
+ import { mkdir as mkdir9, readFile as readFile18, rename as rename6, writeFile as writeFile4 } from "fs/promises";
4368
+ import { join as join11 } from "path";
4369
+ var CACHE_VERSION = 3;
4294
4370
  function cachePathsFor(cacheDir, vaultRoot2, modelId) {
4295
- const vaultKey = createHash2("sha256").update(vaultRoot2).digest("hex").slice(0, 16);
4296
- const dir = join10(cacheDir, "embeddings", vaultKey);
4297
- return { dir, bin: join10(dir, `${modelId}.bin`), manifest: join10(dir, `${modelId}.json`) };
4371
+ const vaultKey = createHash3("sha256").update(vaultRoot2).digest("hex").slice(0, 16);
4372
+ const dir = join11(cacheDir, "embeddings", vaultKey);
4373
+ return { dir, bin: join11(dir, `${modelId}.bin`), manifest: join11(dir, `${modelId}.json`) };
4298
4374
  }
4299
4375
  async function loadCache(paths, modelId, dim) {
4300
4376
  try {
4301
- const manifest = JSON.parse(await readFile17(paths.manifest, "utf8"));
4377
+ const manifest = JSON.parse(await readFile18(paths.manifest, "utf8"));
4302
4378
  if (manifest.version !== CACHE_VERSION || manifest.modelId !== modelId || manifest.dim !== dim) {
4303
4379
  return void 0;
4304
4380
  }
4305
- const buf = await readFile17(paths.bin);
4381
+ const buf = await readFile18(paths.bin);
4306
4382
  const totalChunks = manifest.entries.reduce((a, e) => a + e.chunks, 0);
4307
4383
  const vectorBytes = totalChunks * dim * 4;
4308
4384
  const spanBytes = totalChunks * 2 * 4;
4309
4385
  if (buf.byteLength !== vectorBytes + spanBytes) return void 0;
4386
+ if (binHashOf(buf) !== manifest.binHash) return void 0;
4310
4387
  const aligned = new Uint8Array(buf.byteLength);
4311
4388
  aligned.set(buf);
4312
4389
  const floats = new Float32Array(aligned.buffer, 0, totalChunks * dim);
@@ -4329,17 +4406,22 @@ async function loadCache(paths, modelId, dim) {
4329
4406
  }
4330
4407
  }
4331
4408
  async function saveCache(paths, modelId, dim, notes2, hashes) {
4332
- const entries = [];
4333
- const kept = [];
4409
+ const rows = [];
4334
4410
  let totalChunks = 0;
4335
4411
  for (const [path2, note] of notes2) {
4336
4412
  const contentHash2 = hashes.get(path2);
4337
4413
  if (!contentHash2) continue;
4338
4414
  const chunks = note.packed.length / dim;
4339
- entries.push({ path: path2, contentHash: contentHash2, chunks });
4340
- kept.push(note);
4415
+ rows.push({ path: path2, contentHash: contentHash2, chunks, note });
4341
4416
  totalChunks += chunks;
4342
4417
  }
4418
+ rows.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
4419
+ const entries = rows.map(({ path: path2, contentHash: contentHash2, chunks }) => ({
4420
+ path: path2,
4421
+ contentHash: contentHash2,
4422
+ chunks
4423
+ }));
4424
+ const kept = rows.map((r) => r.note);
4343
4425
  const bin = new Uint8Array(totalChunks * dim * 4 + totalChunks * 2 * 4);
4344
4426
  const floats = new Float32Array(bin.buffer, 0, totalChunks * dim);
4345
4427
  const spans = new Uint32Array(bin.buffer, totalChunks * dim * 4, totalChunks * 2);
@@ -4351,15 +4433,29 @@ async function saveCache(paths, modelId, dim, notes2, hashes) {
4351
4433
  floatOffset += note.packed.length;
4352
4434
  spanOffset += note.spans.length;
4353
4435
  }
4354
- const manifest = { version: CACHE_VERSION, modelId, dim, entries };
4355
- await mkdir8(paths.dir, { recursive: true });
4436
+ const manifest = {
4437
+ version: CACHE_VERSION,
4438
+ modelId,
4439
+ dim,
4440
+ binHash: binHashOf(bin),
4441
+ entries
4442
+ };
4443
+ await mkdir9(paths.dir, { recursive: true });
4356
4444
  await atomicWriteBytes(paths.bin, bin);
4357
4445
  await atomicWriteBytes(paths.manifest, new TextEncoder().encode(JSON.stringify(manifest)));
4358
4446
  }
4447
+ function binHashOf(bin) {
4448
+ return createHash3("sha256").update(bin).digest("hex");
4449
+ }
4450
+ var tempSeq = 0;
4451
+ function tempPathFor(absPath) {
4452
+ tempSeq += 1;
4453
+ return `${absPath}.${process.pid}.${tempSeq}.seekstone-cache-tmp`;
4454
+ }
4359
4455
  async function atomicWriteBytes(absPath, bytes) {
4360
- const tmpPath = `${absPath}.seekstone-cache-tmp`;
4361
- await writeFile3(tmpPath, bytes);
4362
- await rename5(tmpPath, absPath);
4456
+ const tmpPath = tempPathFor(absPath);
4457
+ await writeFile4(tmpPath, bytes);
4458
+ await rename6(tmpPath, absPath);
4363
4459
  }
4364
4460
 
4365
4461
  // src/semantic/state.ts
@@ -5283,8 +5379,8 @@ function visibleTools(policy2) {
5283
5379
 
5284
5380
  // src/watcher.ts
5285
5381
  import { realpathSync } from "fs";
5286
- import { readFile as readFile18 } from "fs/promises";
5287
- import { join as join11, relative as relative2, sep as sep3 } from "path";
5382
+ import { readFile as readFile19 } from "fs/promises";
5383
+ import { join as join12, relative as relative2, sep as sep3 } from "path";
5288
5384
  import chokidar from "chokidar";
5289
5385
  var DEFAULT_POLL_INTERVAL_MS = 1e4;
5290
5386
  function resolvePollInterval(optValue, envValue, log2) {
@@ -5315,7 +5411,7 @@ function startWatcher(ctx2, log2, opts) {
5315
5411
  const toRel = (abs) => relative2(vaultRoot2, abs).split(sep3).join("/");
5316
5412
  async function upsert(relPath, op) {
5317
5413
  try {
5318
- const raw = await readFile18(join11(vaultRoot2, relPath), "utf8");
5414
+ const raw = await readFile19(join12(vaultRoot2, relPath), "utf8");
5319
5415
  removeNoteBacklinks(ctx2, relPath);
5320
5416
  upsertDoc(ctx2, buildDoc(relPath, raw));
5321
5417
  addNoteBacklinks(ctx2, relPath, raw);
@@ -5369,7 +5465,7 @@ function startWatcher(ctx2, log2, opts) {
5369
5465
  }
5370
5466
 
5371
5467
  // src/index.ts
5372
- var VERSION = true ? "0.17.2" : "0.0.0-dev";
5468
+ var VERSION = true ? "0.18.0" : "0.0.0-dev";
5373
5469
  var intent = parseCliIntent(process.argv.slice(2));
5374
5470
  if (intent === "version") {
5375
5471
  process.stdout.write(`${VERSION}
@@ -5454,6 +5550,7 @@ if (auditCfg) {
5454
5550
  var semanticCfg = resolveSemanticConfig(process.env, homedir());
5455
5551
  if (semanticCfg) {
5456
5552
  try {
5553
+ await materializeBundledModel(semanticCfg, process.env.SEEKSTONE_BUNDLED_MODEL_DIR, { log });
5457
5554
  ctx.semantic = await Semantic.start(ctx, semanticCfg, { log });
5458
5555
  log.info("semantic search enabled", {
5459
5556
  model: ctx.semantic.embedder.id,