@appsoftwareltd/etherpk-mcp 0.4.0 → 0.4.1

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
@@ -97,6 +97,10 @@ model files into `~/.cache/etherpk/mcp/models/all-MiniLM-L6-v2-int8/`.
97
97
  after that only edits are processed. `semantic status` lists each cached graph with how many
98
98
  passages are done; every semantic result says `embedded`/`total` and `complete`; run by
99
99
  hand, `serve` prints a line every 30 seconds. `semantic remove` deletes runtime and model.
100
+ - **Load on the machine.** The first pass uses a quarter of the cores, at most four, and pauses
101
+ between model calls; `ETHERPK_MCP_SEMANTIC_THREADS=8` in the registration's `env` makes it
102
+ faster and hotter. `ETHERPK_MCP_DEBUG_MEMORY=1` logs the process's memory if you ever need
103
+ to see it.
100
104
  - **One cache directory for both.** Setup and `serve` must see the same cache root - by default
101
105
  `~/.cache/etherpk/mcp` (`C:\Users\<you>\.cache\etherpk\mcp` on Windows). If you set
102
106
  `ETHERPK_MCP_CACHE_DIR` in your shell, put it in the agent registration's `env` too, since
package/dist/main.js CHANGED
@@ -25,7 +25,7 @@ import { parse, stringify } from "yaml";
25
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
26
  var package_default = {
27
27
  name: "@appsoftwareltd/etherpk-mcp",
28
- version: "0.4.0",
28
+ version: "0.4.1",
29
29
  license: "Elastic-2.0",
30
30
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
31
31
  type: "module",
@@ -741,8 +741,11 @@ function normaliseSyncServer(value) {
741
741
  }
742
742
  /**
743
743
  * Tolerant of a hand-edited file: keeps the entries it knows, refuses the rest. A file in the
744
- * single-login shape written before 0.3.0 reads as null, so its commands say "not logged in"
745
- * and one `login` rewrites it; the token in it was never lost, only its shape.
744
+ * single-login shape written before 0.3.0 - `{ syncServer, pat, vaultKey }` at the top level -
745
+ * is read as that one login under its server: an agent registered under 0.2.0 must keep
746
+ * working when npx pulls a newer version, and refusing the file showed the user only
747
+ * "Connection closed" in Claude Code (2026-09-17). The next `login` or `logout` rewrites it in
748
+ * the current shape.
746
749
  */
747
750
  function parseConfig(raw) {
748
751
  let parsed;
@@ -752,6 +755,14 @@ function parseConfig(raw) {
752
755
  return null;
753
756
  }
754
757
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
758
+ const legacy = parsed;
759
+ if (typeof legacy.syncServer === "string" && typeof legacy.pat === "string" && legacy.pat !== "" && !("servers" in legacy)) {
760
+ const syncServer = normaliseSyncServer(legacy.syncServer);
761
+ if (!/^https?:\/\//.test(syncServer)) return null;
762
+ const login = { pat: legacy.pat };
763
+ if (typeof legacy.vaultKey === "string" && legacy.vaultKey !== "") login.vaultKey = legacy.vaultKey;
764
+ return { servers: { [syncServer]: login } };
765
+ }
755
766
  const servers = parsed.servers;
756
767
  if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return null;
757
768
  const out = emptyConfig();
@@ -3338,13 +3349,38 @@ async function openCacheDb() {
3338
3349
  }
3339
3350
  return db;
3340
3351
  }
3352
+ /**
3353
+ * Give every typed array in a row its own exact-size buffer.
3354
+ *
3355
+ * Node's v8 deserialiser hands typed arrays back as zero-copy VIEWS on the buffer it was given
3356
+ * - here the whole snapshot file - and IndexedDB's structured clone copies a view's entire
3357
+ * underlying buffer, not the view. Restoring 2,474 rows that all pointed into one 11 MB file
3358
+ * therefore cloned 11 MB per row: 28 GB resident, the machine in swap, a thermal shutdown
3359
+ * (2026-09-17). Capture copies too, so a snapshot never carries more than the bytes it means
3360
+ * whatever the store handed back. Plain Uint8Arrays, which is what the engine reads.
3361
+ */
3362
+ function withStandaloneBuffers(value) {
3363
+ if (ArrayBuffer.isView(value)) {
3364
+ const view = value;
3365
+ const copy = new Uint8Array(view.byteLength);
3366
+ copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
3367
+ return copy;
3368
+ }
3369
+ if (Array.isArray(value)) return value.map(withStandaloneBuffers);
3370
+ if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
3371
+ const out = {};
3372
+ for (const [key, entry] of Object.entries(value)) out[key] = withStandaloneBuffers(entry);
3373
+ return out;
3374
+ }
3375
+ return value;
3376
+ }
3341
3377
  /** Every row of every cache store that belongs to `graphId`, as one serialisable snapshot. */
3342
3378
  async function captureLocalCache(graphId) {
3343
3379
  const db = await openCacheDb();
3344
3380
  try {
3345
3381
  const tx = db.transaction([...CACHE_STORES], "readonly");
3346
3382
  const rows = {};
3347
- for (const store of CACHE_STORES) rows[store] = (await request(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId);
3383
+ for (const store of CACHE_STORES) rows[store] = (await request(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId).map(withStandaloneBuffers);
3348
3384
  await done(tx);
3349
3385
  return {
3350
3386
  version: CACHE_DB_VERSION,
@@ -3362,7 +3398,7 @@ async function restoreLocalCache(snapshot) {
3362
3398
  const tx = db.transaction([...CACHE_STORES], "readwrite");
3363
3399
  let count = 0;
3364
3400
  for (const store of CACHE_STORES) for (const row of snapshot.rows[store]) {
3365
- tx.objectStore(store).put(row);
3401
+ tx.objectStore(store).put(withStandaloneBuffers(row));
3366
3402
  count++;
3367
3403
  }
3368
3404
  await done(tx);
@@ -3409,6 +3445,28 @@ async function loadLocalCache(dir, graphId) {
3409
3445
  return 0;
3410
3446
  }
3411
3447
  }
3448
+ /**
3449
+ * Remove what an older build or a killed write left in a graph's directory: index and vectors
3450
+ * files stamped with another version (a bump renames them, so they are never opened again and
3451
+ * were 52 MB of litter per graph), and `.tmp` files from an atomic write that never reached its
3452
+ * rename. The files this build reads are left alone.
3453
+ */
3454
+ async function removeStaleFiles(dir) {
3455
+ const keep = new Set([
3456
+ cacheFile(dir),
3457
+ indexFile(dir),
3458
+ vectorsFile(dir)
3459
+ ].map((f) => f.split("/").pop()));
3460
+ const removed = [];
3461
+ for (const name of await readdir(dir).catch(() => [])) {
3462
+ const stale = /^(index|vectors)\.v\d+\.sqlite$/.test(name) && !keep.has(name);
3463
+ const abandoned = /\.\d+\.tmp$/.test(name);
3464
+ if (!stale && !abandoned) continue;
3465
+ await rm(join(dir, name), { force: true });
3466
+ removed.push(name);
3467
+ }
3468
+ return removed;
3469
+ }
3412
3470
  /** Hosts opened in this process, so each gets its own paths in sqlite-wasm's filesystem. */
3413
3471
  var hostSerial = 0;
3414
3472
  function nodeIndexHost(dir) {
@@ -3488,6 +3546,7 @@ function nodeIndexHost(dir) {
3488
3546
  return {
3489
3547
  async open() {
3490
3548
  const s = await init();
3549
+ await removeStaleFiles(dir);
3491
3550
  await importVectors(s);
3492
3551
  live = await openSaved(s) ?? openFresh(s);
3493
3552
  return {
@@ -3785,6 +3844,13 @@ var SemanticUnavailable = class extends Error {
3785
3844
  }
3786
3845
  };
3787
3846
  var SETUP_HINT = "run `npx @appsoftwareltd/etherpk-mcp semantic setup` on this computer once (it installs a ~300 MB runtime and a 23 MB model into the cache directory); the next semantic search will use it, no restart needed.";
3847
+ /** The thread count `serve` uses: the option, else the environment, else the conservative default. */
3848
+ function semanticThreads(env, requested) {
3849
+ if (requested !== void 0) return Math.max(1, Math.floor(requested));
3850
+ const fromEnv = Number(env.ETHERPK_MCP_SEMANTIC_THREADS);
3851
+ if (Number.isInteger(fromEnv) && fromEnv >= 1) return fromEnv;
3852
+ return Math.max(1, Math.min(4, Math.floor(availableParallelism() / 4)));
3853
+ }
3788
3854
  /**
3789
3855
  * Load the runtime from the cache directory and open the model. Resident memory is a few
3790
3856
  * hundred megabytes, so `serve` calls this only when semantic mode is set up on the machine.
@@ -3801,7 +3867,7 @@ async function loadEmbeddingModel(env, options = {}) {
3801
3867
  throw new SemanticUnavailable(`The embedding runtime in ${status.runtimeDir} failed to load (${error instanceof Error ? error.message : String(error)}); ${SETUP_HINT}`);
3802
3868
  }
3803
3869
  const tokenizer = new Tokenizer(JSON.parse(await readFile(join(status.modelDir, "tokenizer.json"), "utf8")), JSON.parse(await readFile(join(status.modelDir, "tokenizer_config.json"), "utf8")));
3804
- const threads = options.threads ?? Math.max(1, Math.min(8, Math.floor(availableParallelism() / 2)));
3870
+ const threads = semanticThreads(env, options.threads);
3805
3871
  const session = await ort.InferenceSession.create(join(status.modelDir, "model.onnx"), {
3806
3872
  intraOpNumThreads: threads,
3807
3873
  interOpNumThreads: 1,
@@ -7492,6 +7558,7 @@ function createSemanticIndex(options) {
7492
7558
  const { index, model } = options;
7493
7559
  const floor = options.floor ?? .25;
7494
7560
  const settleMs = options.settleMs ?? 1500;
7561
+ const pauseMs = options.pauseMs ?? 100;
7495
7562
  let disposed = false;
7496
7563
  let running;
7497
7564
  let again = false;
@@ -7512,6 +7579,7 @@ function createSemanticIndex(options) {
7512
7579
  hash: passage.hash,
7513
7580
  ...quantise(vectors[n])
7514
7581
  }));
7582
+ if (pauseMs > 0) await new Promise((resolve) => setTimeout(resolve, pauseMs));
7515
7583
  }
7516
7584
  await index.semantic.put(model.id, model.dims, rows);
7517
7585
  if (options.onProgress) options.onProgress(await index.semantic.status(model.id));
@@ -9796,8 +9864,21 @@ function reportSemanticProgress(status) {
9796
9864
  if (!upToDate && Date.now() - lastProgressLog < 3e4) return;
9797
9865
  lastProgressLog = Date.now();
9798
9866
  announcedUpToDate = upToDate;
9799
- console.error(`etherpk-mcp: semantic: ${status.embedded} of ${status.total} passages embedded${upToDate ? " - up to date" : ""}.`);
9867
+ console.error(`etherpk-mcp: semantic: ${status.embedded} of ${status.total} passages embedded${upToDate ? " - up to date" : ""}.${memoryNote()}`);
9868
+ }
9869
+ /**
9870
+ * `ETHERPK_MCP_DEBUG_MEMORY=1` adds the process's memory to every progress line and logs it every
9871
+ * ten seconds: the numbers that separate the JavaScript heap, the buffers outside it and the
9872
+ * native runtime when a serve grows without reason (2026-09-17).
9873
+ */
9874
+ var debugMemory = process.env.ETHERPK_MCP_DEBUG_MEMORY === "1";
9875
+ function memoryNote() {
9876
+ if (!debugMemory) return "";
9877
+ const m = process.memoryUsage();
9878
+ const mb = (n) => Math.round(n / 1e6);
9879
+ return ` [rss ${mb(m.rss)} MB, heap ${mb(m.heapUsed)} MB, external ${mb(m.external)} MB, arrayBuffers ${mb(m.arrayBuffers)} MB]`;
9800
9880
  }
9881
+ if (debugMemory) setInterval(() => console.error(`etherpk-mcp: memory${memoryNote()}`), 1e4).unref();
9801
9882
  async function serve(args) {
9802
9883
  const wanted = args.graph?.trim();
9803
9884
  if (!wanted) fail("serve needs --graph <id or name>.");