@appsoftwareltd/etherpk-mcp 0.4.0 → 0.4.2
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 +4 -0
- package/dist/main.js +118 -17
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
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.
|
|
28
|
+
version: "0.4.2",
|
|
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
|
|
745
|
-
*
|
|
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,9 +3445,38 @@ async function loadLocalCache(dir, graphId) {
|
|
|
3409
3445
|
return 0;
|
|
3410
3446
|
}
|
|
3411
3447
|
}
|
|
3448
|
+
/** A `.tmp` younger than this may be a snapshot another process is still writing. */
|
|
3449
|
+
var ABANDONED_TMP_AFTER_MS = 10 * 6e4;
|
|
3450
|
+
/**
|
|
3451
|
+
* Remove what an older build or a killed write left in a graph's directory: index and vectors
|
|
3452
|
+
* files stamped with another version (a bump renames them, so they are never opened again and
|
|
3453
|
+
* were 52 MB of litter per graph), and `.tmp` files from an atomic write that never reached its
|
|
3454
|
+
* rename - only once they are old enough that no live `serve` can be writing them. The files
|
|
3455
|
+
* this build reads are left alone.
|
|
3456
|
+
*/
|
|
3457
|
+
async function removeStaleFiles(dir, now = Date.now()) {
|
|
3458
|
+
const keep = new Set([
|
|
3459
|
+
cacheFile(dir),
|
|
3460
|
+
indexFile(dir),
|
|
3461
|
+
vectorsFile(dir)
|
|
3462
|
+
].map((f) => f.split("/").pop()));
|
|
3463
|
+
const removed = [];
|
|
3464
|
+
for (const name of await readdir(dir).catch(() => [])) {
|
|
3465
|
+
const stale = /^(index|vectors)\.v\d+\.sqlite$/.test(name) && !keep.has(name);
|
|
3466
|
+
const temp = /\.\d+\.tmp$/.test(name);
|
|
3467
|
+
if (!stale && !temp) continue;
|
|
3468
|
+
if (temp) {
|
|
3469
|
+
if (now - ((await stat(join(dir, name)).catch(() => null))?.mtimeMs ?? now) < ABANDONED_TMP_AFTER_MS) continue;
|
|
3470
|
+
}
|
|
3471
|
+
await rm(join(dir, name), { force: true });
|
|
3472
|
+
removed.push(name);
|
|
3473
|
+
}
|
|
3474
|
+
return removed;
|
|
3475
|
+
}
|
|
3412
3476
|
/** Hosts opened in this process, so each gets its own paths in sqlite-wasm's filesystem. */
|
|
3413
3477
|
var hostSerial = 0;
|
|
3414
|
-
function nodeIndexHost(dir) {
|
|
3478
|
+
function nodeIndexHost(dir, options = {}) {
|
|
3479
|
+
const tidy = options.tidy ?? true;
|
|
3415
3480
|
let sqlite3;
|
|
3416
3481
|
let live;
|
|
3417
3482
|
const path = indexFile(dir);
|
|
@@ -3444,7 +3509,7 @@ function nodeIndexHost(dir) {
|
|
|
3444
3509
|
}
|
|
3445
3510
|
} catch (error) {
|
|
3446
3511
|
console.error(`etherpk-mcp: ignoring an unreadable vectors file ${vectorsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3447
|
-
await rm(vectorsPath, { force: true });
|
|
3512
|
+
if (tidy) await rm(vectorsPath, { force: true });
|
|
3448
3513
|
s.capi.sqlite3_js_posix_create_file(vectorsVfsName, new Uint8Array(0));
|
|
3449
3514
|
}
|
|
3450
3515
|
}
|
|
@@ -3471,7 +3536,7 @@ function nodeIndexHost(dir) {
|
|
|
3471
3536
|
const db = wrapOo1Db(oo1);
|
|
3472
3537
|
if (!isUsableIndex(db)) {
|
|
3473
3538
|
db.close();
|
|
3474
|
-
await rm(path, { force: true });
|
|
3539
|
+
if (tidy) await rm(path, { force: true });
|
|
3475
3540
|
return null;
|
|
3476
3541
|
}
|
|
3477
3542
|
attachVectors(db);
|
|
@@ -3481,13 +3546,14 @@ function nodeIndexHost(dir) {
|
|
|
3481
3546
|
};
|
|
3482
3547
|
} catch (error) {
|
|
3483
3548
|
console.error(`etherpk-mcp: ignoring an unreadable index file ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3484
|
-
await rm(path, { force: true });
|
|
3549
|
+
if (tidy) await rm(path, { force: true });
|
|
3485
3550
|
return null;
|
|
3486
3551
|
}
|
|
3487
3552
|
}
|
|
3488
3553
|
return {
|
|
3489
3554
|
async open() {
|
|
3490
3555
|
const s = await init();
|
|
3556
|
+
if (tidy) await removeStaleFiles(dir);
|
|
3491
3557
|
await importVectors(s);
|
|
3492
3558
|
live = await openSaved(s) ?? openFresh(s);
|
|
3493
3559
|
return {
|
|
@@ -3534,7 +3600,7 @@ async function listGraphStores(env, model) {
|
|
|
3534
3600
|
for (const graphId of await readdir(hostDir).catch(() => [])) {
|
|
3535
3601
|
const dir = join(hostDir, graphId);
|
|
3536
3602
|
if (!await exists$1(indexFile(dir))) continue;
|
|
3537
|
-
const opened = await nodeIndexHost(dir).open(graphId);
|
|
3603
|
+
const opened = await nodeIndexHost(dir, { tidy: false }).open(graphId);
|
|
3538
3604
|
try {
|
|
3539
3605
|
prepareEmbeddingStore(opened.db);
|
|
3540
3606
|
const status = semanticStatus(opened.db, model);
|
|
@@ -3553,6 +3619,22 @@ async function listGraphStores(env, model) {
|
|
|
3553
3619
|
}
|
|
3554
3620
|
return stores;
|
|
3555
3621
|
}
|
|
3622
|
+
/**
|
|
3623
|
+
* A store's state as one line a person can act on: a word first, then the counts, then how
|
|
3624
|
+
* fresh they are. A building `serve` refreshes the snapshot every 30 seconds, so a snapshot
|
|
3625
|
+
* under a minute and a half old means "building now" and an older one means nothing is - the
|
|
3626
|
+
* distinction the raw count and timestamp left the reader to infer (2026-09-17).
|
|
3627
|
+
*/
|
|
3628
|
+
function describeGraphStore(store, now = Date.now()) {
|
|
3629
|
+
const { embedded, total } = store.status;
|
|
3630
|
+
const ageMs = Math.max(0, now - store.updatedAt.getTime());
|
|
3631
|
+
const age = ageMs < 9e4 ? `${Math.round(ageMs / 1e3)} s ago` : ageMs < 36e5 ? `${Math.round(ageMs / 6e4)} min ago` : `${Math.round(ageMs / 36e5)} h ago`;
|
|
3632
|
+
const count = `${embedded.toLocaleString("en-GB")} of ${total.toLocaleString("en-GB")} passages`;
|
|
3633
|
+
if (total === 0) return `empty - no passages indexed here yet (snapshot ${age})`;
|
|
3634
|
+
if (embedded >= total) return `up to date - ${count} embedded (snapshot ${age})`;
|
|
3635
|
+
const percent = Math.floor(embedded / total * 100);
|
|
3636
|
+
return ageMs < 9e4 ? `building - ${count} (${percent}%) embedded, snapshot ${age}, refreshed every 30 s while it builds` : `paused - ${count} (${percent}%) embedded, last snapshot ${age}; nothing is building it now, it continues when serve next runs`;
|
|
3637
|
+
}
|
|
3556
3638
|
/** Remove every persisted graph under the cache root (logout of the last server). */
|
|
3557
3639
|
async function removeCacheRoot(env) {
|
|
3558
3640
|
await rm(cacheRoot(env), {
|
|
@@ -3785,6 +3867,13 @@ var SemanticUnavailable = class extends Error {
|
|
|
3785
3867
|
}
|
|
3786
3868
|
};
|
|
3787
3869
|
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.";
|
|
3870
|
+
/** The thread count `serve` uses: the option, else the environment, else the conservative default. */
|
|
3871
|
+
function semanticThreads(env, requested) {
|
|
3872
|
+
if (requested !== void 0) return Math.max(1, Math.floor(requested));
|
|
3873
|
+
const fromEnv = Number(env.ETHERPK_MCP_SEMANTIC_THREADS);
|
|
3874
|
+
if (Number.isInteger(fromEnv) && fromEnv >= 1) return fromEnv;
|
|
3875
|
+
return Math.max(1, Math.min(4, Math.floor(availableParallelism() / 4)));
|
|
3876
|
+
}
|
|
3788
3877
|
/**
|
|
3789
3878
|
* Load the runtime from the cache directory and open the model. Resident memory is a few
|
|
3790
3879
|
* hundred megabytes, so `serve` calls this only when semantic mode is set up on the machine.
|
|
@@ -3801,7 +3890,7 @@ async function loadEmbeddingModel(env, options = {}) {
|
|
|
3801
3890
|
throw new SemanticUnavailable(`The embedding runtime in ${status.runtimeDir} failed to load (${error instanceof Error ? error.message : String(error)}); ${SETUP_HINT}`);
|
|
3802
3891
|
}
|
|
3803
3892
|
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 =
|
|
3893
|
+
const threads = semanticThreads(env, options.threads);
|
|
3805
3894
|
const session = await ort.InferenceSession.create(join(status.modelDir, "model.onnx"), {
|
|
3806
3895
|
intraOpNumThreads: threads,
|
|
3807
3896
|
interOpNumThreads: 1,
|
|
@@ -7492,6 +7581,7 @@ function createSemanticIndex(options) {
|
|
|
7492
7581
|
const { index, model } = options;
|
|
7493
7582
|
const floor = options.floor ?? .25;
|
|
7494
7583
|
const settleMs = options.settleMs ?? 1500;
|
|
7584
|
+
const pauseMs = options.pauseMs ?? 100;
|
|
7495
7585
|
let disposed = false;
|
|
7496
7586
|
let running;
|
|
7497
7587
|
let again = false;
|
|
@@ -7512,6 +7602,7 @@ function createSemanticIndex(options) {
|
|
|
7512
7602
|
hash: passage.hash,
|
|
7513
7603
|
...quantise(vectors[n])
|
|
7514
7604
|
}));
|
|
7605
|
+
if (pauseMs > 0) await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
7515
7606
|
}
|
|
7516
7607
|
await index.semantic.put(model.id, model.dims, rows);
|
|
7517
7608
|
if (options.onProgress) options.onProgress(await index.semantic.status(model.id));
|
|
@@ -9773,11 +9864,8 @@ async function semanticCommand(what) {
|
|
|
9773
9864
|
const stores = await listGraphStores(process.env, MODEL.id);
|
|
9774
9865
|
if (stores.length === 0) return;
|
|
9775
9866
|
console.log("");
|
|
9776
|
-
console.log("Cached graphs
|
|
9777
|
-
for (const store of stores) {
|
|
9778
|
-
const done = store.status.embedded >= store.status.total;
|
|
9779
|
-
console.log(` ${store.host} ${store.graphId} ${store.status.embedded} of ${store.status.total} passages embedded${done ? " - up to date" : ""} (${store.updatedAt.toISOString()})`);
|
|
9780
|
-
}
|
|
9867
|
+
console.log("Cached graphs:");
|
|
9868
|
+
for (const store of stores) console.log(` ${store.host} ${store.graphId}\n ${describeGraphStore(store)}`);
|
|
9781
9869
|
return;
|
|
9782
9870
|
}
|
|
9783
9871
|
case "remove":
|
|
@@ -9796,8 +9884,21 @@ function reportSemanticProgress(status) {
|
|
|
9796
9884
|
if (!upToDate && Date.now() - lastProgressLog < 3e4) return;
|
|
9797
9885
|
lastProgressLog = Date.now();
|
|
9798
9886
|
announcedUpToDate = upToDate;
|
|
9799
|
-
console.error(`etherpk-mcp: semantic: ${status.embedded} of ${status.total} passages embedded${upToDate ? " - up to date" : ""}
|
|
9887
|
+
console.error(`etherpk-mcp: semantic: ${status.embedded} of ${status.total} passages embedded${upToDate ? " - up to date" : ""}.${memoryNote()}`);
|
|
9888
|
+
}
|
|
9889
|
+
/**
|
|
9890
|
+
* `ETHERPK_MCP_DEBUG_MEMORY=1` adds the process's memory to every progress line and logs it every
|
|
9891
|
+
* ten seconds: the numbers that separate the JavaScript heap, the buffers outside it and the
|
|
9892
|
+
* native runtime when a serve grows without reason (2026-09-17).
|
|
9893
|
+
*/
|
|
9894
|
+
var debugMemory = process.env.ETHERPK_MCP_DEBUG_MEMORY === "1";
|
|
9895
|
+
function memoryNote() {
|
|
9896
|
+
if (!debugMemory) return "";
|
|
9897
|
+
const m = process.memoryUsage();
|
|
9898
|
+
const mb = (n) => Math.round(n / 1e6);
|
|
9899
|
+
return ` [rss ${mb(m.rss)} MB, heap ${mb(m.heapUsed)} MB, external ${mb(m.external)} MB, arrayBuffers ${mb(m.arrayBuffers)} MB]`;
|
|
9800
9900
|
}
|
|
9901
|
+
if (debugMemory) setInterval(() => console.error(`etherpk-mcp: memory${memoryNote()}`), 1e4).unref();
|
|
9801
9902
|
async function serve(args) {
|
|
9802
9903
|
const wanted = args.graph?.trim();
|
|
9803
9904
|
if (!wanted) fail("serve needs --graph <id or name>.");
|