klypix-mcp 1.96.0 → 1.97.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 +17 -7
- package/bin/klypix-install.mjs +59 -0
- package/bin/klypix-semantic-warm.mjs +30 -6
- package/bin/klypix-worker.mjs +1 -1
- package/package.json +2 -2
- package/src/klypix-core.mjs +37 -10
- package/src/semantic-install.mjs +84 -0
- package/src/semantic-memory.mjs +166 -33
package/README.md
CHANGED
|
@@ -235,13 +235,23 @@ not a filing convention.
|
|
|
235
235
|
session's brief. That is brief priority, not a retrieval-ranking boost.
|
|
236
236
|
- **You can ask what the project believed then.** `brain_ask` with `as_of: 2026-03-01` reweights
|
|
237
237
|
ranking by card lifecycle dates, so corrections made later do not leak backwards.
|
|
238
|
-
- **Retrieval is local.**
|
|
239
|
-
`
|
|
240
|
-
|
|
241
|
-
is available for experiments with `KLYPIX_RERANK=1`, but is off by default because
|
|
242
|
-
precision and added latency on the frozen human-paraphrase evaluation.
|
|
243
|
-
|
|
244
|
-
|
|
238
|
+
- **Retrieval is local.** With the on-device model ("search by meaning"), `brain_ask` and
|
|
239
|
+
`search_all_brains` use BGE semantic ranking with lexical help for exact identifiers, paths, and
|
|
240
|
+
versions — still entirely on your machine. Without it, retrieval is keyword-only. The previous
|
|
241
|
+
cross-encoder is available for experiments with `KLYPIX_RERANK=1`, but is off by default because
|
|
242
|
+
it reduced precision and added latency on the frozen human-paraphrase evaluation.
|
|
243
|
+
- **Search by meaning is installed for you (1.97).** `npx klypix-mcp install` run by a person in a
|
|
244
|
+
terminal installs it: about 150 MB to download once, about 110 MB on disk after the files Node
|
|
245
|
+
never loads are removed. Skip it with `--no-semantic` (or `KLYPIX_SEMANTIC=0`). Installs with no
|
|
246
|
+
terminal — CI, scripts, an AI agent running the install — get it only with `--semantic`. The
|
|
247
|
+
Claude plugin never downloads it. Measured on the frozen 102-question set: the right card in the
|
|
248
|
+
top 5 for 54 questions with it, 19 without.
|
|
249
|
+
- **Preparing a brain never makes a question wait.** Each card is turned into a meaning vector once
|
|
250
|
+
(about 45 ms a card on a laptop CPU, one card at a time, ~305 MB peak), newest cards first, by a
|
|
251
|
+
low-priority background job that saves as it goes. A question on a brain that is not ready yet
|
|
252
|
+
answers right away from what is ready plus keywords, and says how far along the preparation is
|
|
253
|
+
("Getting this brain ready for search by meaning: 900 of 3,670 cards so far, about 5 min left").
|
|
254
|
+
After that, only new or edited cards are prepared.
|
|
245
255
|
|
|
246
256
|
### Bounded semantic-memory runtime
|
|
247
257
|
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// npx klypix-mcp install --codex-hooks # optional prompt/file awareness; Codex asks for trust
|
|
13
13
|
// npx klypix-mcp install --allow-untagged # acknowledge deploying an UNTAGGED source checkout
|
|
14
14
|
// # (dev deploy; also KLYPIX_MCP_ALLOW_UNTAGGED=1)
|
|
15
|
+
// npx klypix-mcp install --semantic # also install search by meaning (~150 MB download, once)
|
|
16
|
+
// npx klypix-mcp install --no-semantic # never (also KLYPIX_SEMANTIC=0)
|
|
17
|
+
// A person running install in a terminal gets search by meaning by default;
|
|
18
|
+
// scripted, CI and agent-run installs (no terminal) only with --semantic.
|
|
15
19
|
//
|
|
16
20
|
// Never-throws-silently: it's an explicit CLI, so it reports what it did and exits
|
|
17
21
|
// non-zero on a real failure. Never wires a broken settings.json (refuse + restore).
|
|
@@ -35,6 +39,7 @@ import { brainInstallDecision, deploySourceDecision } from '../src/install-versi
|
|
|
35
39
|
import { acquireInstallLockSync, releaseInstallLockSync } from '../src/install-lock.mjs';
|
|
36
40
|
import { collectRepoState } from '../src/repo-state.mjs';
|
|
37
41
|
import { runSetup, renderBrief } from '../src/setup.mjs';
|
|
42
|
+
import { SEMANTIC_RUNTIME_MANIFEST, dirBytes, pruneSemanticRuntime, semanticInstallDecision } from '../src/semantic-install.mjs';
|
|
38
43
|
|
|
39
44
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
40
45
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
@@ -60,6 +65,10 @@ const SETUP_JSON = process.argv.includes('--json');
|
|
|
60
65
|
// this is source authority (knowingly deploy an untagged working tree).
|
|
61
66
|
const ALLOW_UNTAGGED = process.argv.includes('--allow-untagged')
|
|
62
67
|
|| process.env.KLYPIX_MCP_ALLOW_UNTAGGED === '1';
|
|
68
|
+
// Search by meaning (1.97.0). Measured 2026-10-11 on the real 102-question
|
|
69
|
+
// set: the right card in the top 5 for 54 questions with it, 19 without.
|
|
70
|
+
const SEMANTIC_FLAG = process.argv.includes('--semantic');
|
|
71
|
+
const NO_SEMANTIC = process.argv.includes('--no-semantic') || process.env.KLYPIX_SEMANTIC === '0';
|
|
63
72
|
|
|
64
73
|
const HOME = os.homedir();
|
|
65
74
|
const CLAUDE_DIR = path.join(HOME, '.claude');
|
|
@@ -143,6 +152,52 @@ function wireCodex() {
|
|
|
143
152
|
};
|
|
144
153
|
}
|
|
145
154
|
|
|
155
|
+
// ── Search by meaning: the optional on-device runtime (src/semantic-install.mjs) ──
|
|
156
|
+
function installSemanticRuntimeIfWanted() {
|
|
157
|
+
const semanticDir = path.join(BRAIN_DIR, 'semantic');
|
|
158
|
+
const transformers = path.join(semanticDir, 'node_modules', '@huggingface', 'transformers');
|
|
159
|
+
const decision = semanticInstallDecision({
|
|
160
|
+
optOut: NO_SEMANTIC,
|
|
161
|
+
flag: SEMANTIC_FLAG,
|
|
162
|
+
runtimeOnly: RUNTIME_ONLY,
|
|
163
|
+
plugin: String(process.env.KLYPIX_PLUGIN ?? '').trim() === '1',
|
|
164
|
+
present: exists(transformers),
|
|
165
|
+
ci: Boolean(process.env.CI),
|
|
166
|
+
tty: Boolean(process.stdout.isTTY),
|
|
167
|
+
});
|
|
168
|
+
if (decision !== 'install') return { decision };
|
|
169
|
+
console.log('• Installing search by meaning (about 150 MB to download, once; skip with --no-semantic)…');
|
|
170
|
+
const started = Date.now();
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(semanticDir, { recursive: true });
|
|
173
|
+
fs.writeFileSync(path.join(semanticDir, 'package.json'), `${JSON.stringify(SEMANTIC_RUNTIME_MANIFEST, null, 2)}\n`);
|
|
174
|
+
const npmArgs = ['install', '--prefix', semanticDir, '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'];
|
|
175
|
+
if (process.platform === 'win32') execFileSync('cmd.exe', ['/d', '/s', '/c', 'npm.cmd', ...npmArgs], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 600_000, windowsHide: true });
|
|
176
|
+
else execFileSync('npm', npmArgs, { stdio: ['ignore', 'ignore', 'pipe'], timeout: 600_000 });
|
|
177
|
+
if (!exists(transformers)) throw new Error('npm finished but the runtime is missing');
|
|
178
|
+
let freed = 0;
|
|
179
|
+
try { freed = pruneSemanticRuntime(semanticDir); } catch { /* pruning is an optimisation */ }
|
|
180
|
+
return { decision, ok: true, seconds: Math.round((Date.now() - started) / 1000), sizeMb: Math.round(dirBytes(path.join(semanticDir, 'node_modules')) / 1048576), freedMb: Math.round(freed / 1048576) };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const why = String(error?.stderr || error?.message || error).split('\n').map(s => s.trim()).filter(Boolean).slice(-1)[0] || 'npm failed';
|
|
183
|
+
return { decision, ok: false, error: why.slice(0, 200) };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function reportSemantic(step) {
|
|
188
|
+
const turnOn = 'npx klypix-mcp install --semantic';
|
|
189
|
+
if (!step) return;
|
|
190
|
+
if (step.decision === 'install' && step.ok) {
|
|
191
|
+
console.log(`✓ search by meaning installed in ${step.seconds}s (${step.sizeMb} MB on disk). Your project brains are being prepared in the background, newest cards first: questions work right away and get better as it finishes.`);
|
|
192
|
+
} else if (step.decision === 'install') {
|
|
193
|
+
console.error(`⚠ search by meaning could not be installed (${step.error}). Keyword search still works. Try again with: ${turnOn}`);
|
|
194
|
+
} else if (step.decision === 'present') {
|
|
195
|
+
console.log('✓ search by meaning: on');
|
|
196
|
+
} else if (step.decision === 'skip-noninteractive' || step.decision === 'skip-ci') {
|
|
197
|
+
console.log(`• search by meaning is off. Turn it on with: ${turnOn} (about 150 MB, once; agents then find the right card about 3x as often)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
146
201
|
function reportCodex(result) {
|
|
147
202
|
if (result.ok) {
|
|
148
203
|
const mcp = result.mcp.action === 'skipped-no-project-brain'
|
|
@@ -594,6 +649,9 @@ try {
|
|
|
594
649
|
const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse', 'PreToolUse'].filter(e => !wiredFor(e));
|
|
595
650
|
|
|
596
651
|
releaseInstallLockSync(installLock);
|
|
652
|
+
// Search by meaning: installed AFTER the brain itself is committed, so a
|
|
653
|
+
// slow or failed download never touches a working keyword-only install.
|
|
654
|
+
const semanticStep = installSemanticRuntimeIfWanted();
|
|
597
655
|
// Users who already enabled the optional local semantic runtime should not
|
|
598
656
|
// pay a multi-minute first question after a model/cache contract upgrade.
|
|
599
657
|
// Migrate registered brains once in a detached process after the atomic
|
|
@@ -617,6 +675,7 @@ try {
|
|
|
617
675
|
}
|
|
618
676
|
if (!RUNTIME_ONLY) reportCodex(codex);
|
|
619
677
|
console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
|
|
678
|
+
reportSemantic(semanticStep);
|
|
620
679
|
if (RUNTIME_ONLY) console.log('✓ runtime-only update: host settings and project files were preserved');
|
|
621
680
|
else if (!notWired.length) console.log('✓ wired 5 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) · PreToolUse (--guard) → settings.json');
|
|
622
681
|
else console.error(`⚠ readiness: ${notWired.length} hook(s) did NOT take (${notWired.join(', ')}) — the brain will read but not capture/sync. Re-run \`npx klypix-mcp install --force\` or check ${SETTINGS}.`);
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// One detached, best-effort
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// One detached, best-effort preparation of brains for search by meaning: after
|
|
3
|
+
// an install or a compatible core update (every registered brain), or started
|
|
4
|
+
// by a question on a brain that is not ready yet (--brain <file> --only: that
|
|
5
|
+
// brain alone). It runs at below-normal priority, reports progress to the
|
|
6
|
+
// status file while it works (brainPreparationStatus reads it), refreshes its
|
|
7
|
+
// lock so a long run is never taken for a dead one, and never changes brain
|
|
8
|
+
// content.
|
|
5
9
|
import fs from 'fs';
|
|
6
10
|
import os from 'os';
|
|
7
11
|
import path from 'path';
|
|
@@ -9,6 +13,7 @@ import { parseKlypix } from '../src/klypix-format.mjs';
|
|
|
9
13
|
import { readRegisteredProjectBrains } from '../src/mcp-auto-update.mjs';
|
|
10
14
|
import {
|
|
11
15
|
EMBEDDING_CACHE_KEY,
|
|
16
|
+
cachedVectorsForBrain,
|
|
12
17
|
disposeSemanticModels,
|
|
13
18
|
getEmbedderForUse,
|
|
14
19
|
vectorsForBrain,
|
|
@@ -22,6 +27,7 @@ const brainDir = path.resolve(arg('brain-dir', path.join(os.homedir(), '.claude'
|
|
|
22
27
|
const lockFile = path.join(brainDir, '.semantic-update-warm.lock');
|
|
23
28
|
const statusFile = path.join(brainDir, '.semantic-update-warm.json');
|
|
24
29
|
const explicitBrain = arg('brain');
|
|
30
|
+
const onlyExplicit = process.argv.includes('--only');
|
|
25
31
|
const startedAt = Date.now();
|
|
26
32
|
let lock = null;
|
|
27
33
|
|
|
@@ -31,6 +37,9 @@ const atomicJson = (file, value) => {
|
|
|
31
37
|
fs.renameSync(tmp, file);
|
|
32
38
|
};
|
|
33
39
|
|
|
40
|
+
// A background job: never compete with the person's own work for the CPU.
|
|
41
|
+
try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL); } catch { /* best-effort */ }
|
|
42
|
+
|
|
34
43
|
try {
|
|
35
44
|
fs.mkdirSync(brainDir, { recursive: true });
|
|
36
45
|
try {
|
|
@@ -45,13 +54,17 @@ try {
|
|
|
45
54
|
}
|
|
46
55
|
fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt, modelKey: EMBEDDING_CACHE_KEY }));
|
|
47
56
|
|
|
48
|
-
const registered = readRegisteredProjectBrains(brainDir).map(item => item.brainPath);
|
|
57
|
+
const registered = onlyExplicit && explicitBrain ? [] : readRegisteredProjectBrains(brainDir).map(item => item.brainPath);
|
|
49
58
|
const candidates = [...new Set([explicitBrain ? path.resolve(explicitBrain) : null, ...registered].filter(Boolean))]
|
|
50
59
|
.filter(file => {
|
|
51
60
|
try { return fs.statSync(file).isFile(); } catch { return false; }
|
|
52
61
|
});
|
|
53
62
|
if (!candidates.length) {
|
|
54
|
-
|
|
63
|
+
// Nothing to prepare yet, but fetch the model now (~33 MB, once) so the
|
|
64
|
+
// first question on a new brain does not pay for the download.
|
|
65
|
+
let model = 'unavailable';
|
|
66
|
+
try { model = (await getEmbedderForUse(() => {}, 5 * 60_000)) ? 'downloaded' : 'unavailable'; } catch { /* the first question fetches it */ }
|
|
67
|
+
atomicJson(statusFile, { protocol: 1, result: 'no-registered-brains', model, modelKey: EMBEDDING_CACHE_KEY, completedAt: new Date().toISOString() });
|
|
55
68
|
} else {
|
|
56
69
|
const pipe = await getEmbedderForUse(() => {}, 5 * 60_000);
|
|
57
70
|
if (!pipe) {
|
|
@@ -61,7 +74,18 @@ try {
|
|
|
61
74
|
for (const file of candidates) {
|
|
62
75
|
try {
|
|
63
76
|
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
64
|
-
const
|
|
77
|
+
const brainStarted = new Date().toISOString();
|
|
78
|
+
let lastReport = 0;
|
|
79
|
+
const report = ({ done, total }, force = false) => {
|
|
80
|
+
if (!force && Date.now() - lastReport < 3_000) return;
|
|
81
|
+
lastReport = Date.now();
|
|
82
|
+
try { atomicJson(statusFile, { protocol: 1, result: 'preparing', modelKey: EMBEDDING_CACHE_KEY, brain: file, done, total, startedAt: brainStarted, updatedAt: new Date().toISOString() }); } catch { /* advisory */ }
|
|
83
|
+
try { const now = new Date(); fs.utimesSync(lockFile, now, now); } catch { /* advisory */ }
|
|
84
|
+
};
|
|
85
|
+
let alreadyReady = 0;
|
|
86
|
+
try { alreadyReady = cachedVectorsForBrain(file, struct.cards).size; } catch { /* starts from zero */ }
|
|
87
|
+
report({ done: alreadyReady, total: struct.cards.filter(c => c.type !== 'container' && String(c.text || '').trim()).length }, true);
|
|
88
|
+
const vectors = await vectorsForBrain(pipe, file, struct.cards, { maxEmbed: Infinity, onProgress: report });
|
|
65
89
|
warmed++;
|
|
66
90
|
cards += vectors.size;
|
|
67
91
|
} catch { failed++; }
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -508,7 +508,7 @@ server.registerTool('search_all_brains', {
|
|
|
508
508
|
|
|
509
509
|
server.registerTool('brain_ask', {
|
|
510
510
|
title: 'Ask the project brain a question (whole-brain, correction-aware answer)',
|
|
511
|
-
description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card
|
|
511
|
+
description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card by meaning with the on-device model (installed by `npx klypix-mcp install` run in a terminal, or with `--semantic`), with keywords for exact names; without the model it is keyword-only, and while a brain is still being prepared the answer says how far along it is. INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Cards are often worded differently from the question, so end the question with one more line, `Also: <8–14 words a card answering it would likely use: synonyms, feature, file or tool names>`; they widen the search while the question still leads (measured: right card in the top 5 for 60 of 102 test questions instead of 55; 24 instead of 19 when no local model is installed). Defaults to the project brain ("brain").',
|
|
512
512
|
inputSchema: {
|
|
513
513
|
question: z.string().describe('The natural-language question to answer from the brain.'),
|
|
514
514
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.97.0",
|
|
4
4
|
"mcpName": "io.github.dahshanlabs/klypix-mcp",
|
|
5
5
|
"description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
|
|
6
6
|
"type": "module",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
87
87
|
"test:bench": "node test/bench.mjs",
|
|
88
88
|
"pretest": "node test/publish-workflow.mjs",
|
|
89
|
-
"test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
|
|
89
|
+
"test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
|
|
90
90
|
"test:memory": "node test/memory-runtime.mjs",
|
|
91
91
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
92
92
|
"runtime": "node bin/klypix-runtime.mjs",
|
package/src/klypix-core.mjs
CHANGED
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
dot, embedTexts, getEmbedder, getEmbedderForUse, withRerankerForUse,
|
|
57
57
|
rerankHits, semanticFallbackNotice, semanticMemorySnapshot,
|
|
58
58
|
semanticRuntimeInstalled, shouldPrewarmSemantic, vectorsForBrain,
|
|
59
|
-
cachedVectorsForBrain,
|
|
59
|
+
cachedVectorsForBrain, startBrainPreparation, preparationNotice,
|
|
60
60
|
} from './semantic-memory.mjs';
|
|
61
61
|
|
|
62
62
|
// The MCP and A2A faces prewarm through this protocol-neutral module. Bounded
|
|
@@ -503,7 +503,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
503
503
|
let struct;
|
|
504
504
|
try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
|
|
505
505
|
let vecs = null;
|
|
506
|
-
if (qv) { try {
|
|
506
|
+
if (qv) { try { const r = await questionVectors(pipe, b.path, struct.cards); vecs = r.usable ? r.vecs : null; } catch { /* lexical for this brain */ } }
|
|
507
507
|
for (const c of struct.cards) {
|
|
508
508
|
if (c.type === 'container') continue;
|
|
509
509
|
const t = String(c.text || '').toLowerCase();
|
|
@@ -765,6 +765,21 @@ export async function opBrainTaskContext({
|
|
|
765
765
|
};
|
|
766
766
|
}
|
|
767
767
|
|
|
768
|
+
// Card meanings for a question, never waiting on a preparation (1.97.0): at
|
|
769
|
+
// most a few seconds of embedding inline; whatever is still missing is handed
|
|
770
|
+
// to the background preparation (startBrainPreparation) and the answer says
|
|
771
|
+
// so (preparationNotice). Meaning ranking is used once at least
|
|
772
|
+
// MEANING_READY_SHARE of the cards are ready — below that the keyword path
|
|
773
|
+
// covers every card, which a mostly-unprepared meaning ranking would not.
|
|
774
|
+
const MEANING_READY_SHARE = 0.5;
|
|
775
|
+
async function questionVectors(pipe, file, cards) {
|
|
776
|
+
const stats = {};
|
|
777
|
+
const vecs = await vectorsForBrain(pipe, file, cards, { stats });
|
|
778
|
+
if (stats.pending > 0) startBrainPreparation(file);
|
|
779
|
+
const usable = Boolean(vecs && vecs.size) && (!stats.wanted || stats.ready / stats.wanted >= MEANING_READY_SHARE);
|
|
780
|
+
return { vecs, stats, usable };
|
|
781
|
+
}
|
|
782
|
+
|
|
768
783
|
export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log = () => {} }) {
|
|
769
784
|
// A last line `Also: <words>` is the caller's guess at the card's wording; it
|
|
770
785
|
// joins the meaning query only (splitAskWords in klypix-format.mjs).
|
|
@@ -781,13 +796,14 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
781
796
|
// Semantic blend (best-effort, time-bounded): embed the question + the brain's
|
|
782
797
|
// cards on-device, hand rankForQuestion a Map<cardId, cosine>. A missing/warming
|
|
783
798
|
// model degrades cleanly to pure lexical.
|
|
784
|
-
let semantic = null, mode = 'lexical', cardVecs = null;
|
|
799
|
+
let semantic = null, mode = 'lexical', cardVecs = null, prep = null;
|
|
785
800
|
try {
|
|
786
801
|
const pipe = await getEmbedderForUse(log, 20_000);
|
|
787
802
|
if (pipe) {
|
|
788
803
|
const [qv] = await embedTexts(pipe, [words ? `${q}\n${words}` : q], { kind: 'query' });
|
|
789
|
-
const vecs = await
|
|
790
|
-
|
|
804
|
+
const { vecs, stats, usable } = await questionVectors(pipe, t.file, struct.cards);
|
|
805
|
+
prep = stats;
|
|
806
|
+
if (qv && usable) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); cardVecs = vecs; mode = `semantic+lexical (on-device)${words ? ' + your Also words' : ''}`; }
|
|
791
807
|
}
|
|
792
808
|
} catch { semantic = null; cardVecs = null; }
|
|
793
809
|
// Parity with search_all_brains (8445e9c): never say "warming — retry" to a
|
|
@@ -801,6 +817,7 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
801
817
|
if (!semantic) {
|
|
802
818
|
fallbackNotice = semanticFallbackNotice(false);
|
|
803
819
|
if (fallbackNotice) mode = fallbackNotice.startsWith('LEXICAL ONLY') ? 'lexical-only (no semantic runtime)' : 'lexical (semantic warming)';
|
|
820
|
+
if (prep?.pending > 0) mode = 'lexical (preparing search by meaning)';
|
|
804
821
|
if (words) mode += ' + your Also words';
|
|
805
822
|
}
|
|
806
823
|
const kk = Math.max(1, Math.min(20, k || 10));
|
|
@@ -849,7 +866,11 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
849
866
|
mode += ' + status-mode';
|
|
850
867
|
} catch { statusMd = ''; }
|
|
851
868
|
}
|
|
852
|
-
|
|
869
|
+
// While the brain is still being prepared, that is the one thing to say: the
|
|
870
|
+
// runtime IS installed, so the "warming — retry" fallback line would only
|
|
871
|
+
// repeat it less precisely.
|
|
872
|
+
const prepNotice = preparationNotice(t.file, prep, { usedMeaning: Boolean(semantic) });
|
|
873
|
+
const noticeMd = prepNotice ? `> ${prepNotice}\n\n` : (fallbackNotice ? `> ${fallbackNotice}\n\n` : '');
|
|
853
874
|
const evidenceCache = new Map();
|
|
854
875
|
const evidenceForCard = (card) => formatCardEvidence(inspectCardEvidence(card, { projectRoot: path.dirname(t.file), cache: evidenceCache }));
|
|
855
876
|
return { blocks: [text(stamp + noticeMd + statusMd + questionContextToMarkdown(q, result, { mode, as_of: timeTravel ? as_of : null, evidenceForCard }))] };
|
|
@@ -874,8 +895,8 @@ export async function opBrainChallenge({ vault, canvas, claim, k = 8, via, log =
|
|
|
874
895
|
const pipe = await getEmbedderForUse(log, 20_000);
|
|
875
896
|
if (pipe) {
|
|
876
897
|
const [qv] = await embedTexts(pipe, [q], { kind: 'query' });
|
|
877
|
-
const vecs = await
|
|
878
|
-
if (qv &&
|
|
898
|
+
const { vecs, usable } = await questionVectors(pipe, t.file, struct.cards);
|
|
899
|
+
if (qv && usable) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
|
|
879
900
|
}
|
|
880
901
|
} catch { semantic = null; }
|
|
881
902
|
const result = challengeBrain(struct, q, { semantic, k: Math.max(1, Math.min(20, k || 8)) });
|
|
@@ -1163,7 +1184,11 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all', ref
|
|
|
1163
1184
|
if (mode === 'plans' && cold && installed) {
|
|
1164
1185
|
try {
|
|
1165
1186
|
const pipe = await getEmbedderForUse(log, 20_000);
|
|
1166
|
-
if (pipe)
|
|
1187
|
+
if (pipe) {
|
|
1188
|
+
const stats = {};
|
|
1189
|
+
vecs = await vectorsForBrain(pipe, file, struct.cards, { stats });
|
|
1190
|
+
if (stats.pending > 0) startBrainPreparation(file);
|
|
1191
|
+
}
|
|
1167
1192
|
} catch (e) { embedErr = e; }
|
|
1168
1193
|
}
|
|
1169
1194
|
let pairSim = null, how;
|
|
@@ -1699,7 +1724,9 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
|
|
|
1699
1724
|
try { pipe = await getEmbedderForUse(log, 20_000); } catch { /* saturated/unavailable → structural */ }
|
|
1700
1725
|
if (pipe) {
|
|
1701
1726
|
try {
|
|
1702
|
-
const
|
|
1727
|
+
const stats = {};
|
|
1728
|
+
const vecs = await vectorsForBrain(pipe, file, struct.cards, { stats });
|
|
1729
|
+
if (stats.pending > 0) startBrainPreparation(file);
|
|
1703
1730
|
const items = live.filter(c => vecs.get(c.id));
|
|
1704
1731
|
const sources = normalizedScope === 'orphans' ? items.filter(c => orphanIds.has(c.id)) : items;
|
|
1705
1732
|
for (const a of sources) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Search by meaning: what the installer installs, who gets it, and what it
|
|
2
|
+
// trims afterwards. Pure helpers, kept out of bin/klypix-install.mjs because
|
|
3
|
+
// that file runs the install the moment it is imported.
|
|
4
|
+
//
|
|
5
|
+
// Measured 2026-10-11 on a clean npm cache: ~150 MB download, ~20 s on a fast
|
|
6
|
+
// line. Pruning what Node never loads (onnxruntime-web, other platforms' and
|
|
7
|
+
// architectures' native binaries) leaves ~110 MB on disk instead of ~390 MB,
|
|
8
|
+
// with the same answers and memory (load + 50 questions checked on the pruned
|
|
9
|
+
// tree). The gain, on the real 102-question set: the right card in the top 5
|
|
10
|
+
// for 54 questions with it, 19 without.
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
// Same pins as the desktop app's electron/semanticInstaller.ts: an owned npm
|
|
15
|
+
// tree, so a vulnerable sharp can never be resolved independently.
|
|
16
|
+
export const SEMANTIC_RUNTIME_MANIFEST = Object.freeze({
|
|
17
|
+
name: 'klypix-semantic-runtime',
|
|
18
|
+
private: true,
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
dependencies: { '@huggingface/transformers': '4.2.0', sharp: '0.35.5' },
|
|
21
|
+
overrides: {
|
|
22
|
+
'@huggingface/transformers': { sharp: '$sharp' },
|
|
23
|
+
'onnxruntime-node': { 'adm-zip': '0.6.1' },
|
|
24
|
+
'onnxruntime-web': { protobufjs: '7.6.5' },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Who gets it. A person at a terminal gets it unless they opt out; anything
|
|
29
|
+
// without a terminal (CI, scripts, an agent running the install, the test
|
|
30
|
+
// suite) only when it asks with --semantic. Plugin mode never: the Claude
|
|
31
|
+
// plugin runs only its pinned package and downloads no models.
|
|
32
|
+
export function semanticInstallDecision({ optOut, flag, runtimeOnly, plugin, present, ci, tty }) {
|
|
33
|
+
if (plugin) return 'skip-plugin';
|
|
34
|
+
if (optOut) return 'skip-optout';
|
|
35
|
+
if (present) return 'present';
|
|
36
|
+
if (flag) return 'install';
|
|
37
|
+
if (runtimeOnly) return 'skip-runtime-only';
|
|
38
|
+
if (ci) return 'skip-ci';
|
|
39
|
+
if (!tty) return 'skip-noninteractive';
|
|
40
|
+
return 'install';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function dirBytes(dir) {
|
|
44
|
+
let total = 0;
|
|
45
|
+
let entries = [];
|
|
46
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const p = path.join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) total += dirBytes(p);
|
|
50
|
+
else { try { total += fs.statSync(p).size; } catch { /* gone */ } }
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Remove what Node never loads. Touches only <semanticDir>/node_modules, and
|
|
56
|
+
// never removes the running platform's binary for the running architecture
|
|
57
|
+
// (a tree without it is left alone rather than pruned to nothing).
|
|
58
|
+
export function pruneSemanticRuntime(semanticDir, platform = process.platform, arch = process.arch) {
|
|
59
|
+
const nm = path.join(semanticDir, 'node_modules');
|
|
60
|
+
let freed = 0;
|
|
61
|
+
const drop = (p) => {
|
|
62
|
+
try { fs.statSync(p); } catch { return; }
|
|
63
|
+
freed += dirBytes(p);
|
|
64
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
65
|
+
};
|
|
66
|
+
drop(path.join(nm, 'onnxruntime-web'));
|
|
67
|
+
const binRoot = path.join(nm, 'onnxruntime-node', 'bin');
|
|
68
|
+
let napis = [];
|
|
69
|
+
try { napis = fs.readdirSync(binRoot); } catch { /* none */ }
|
|
70
|
+
for (const napi of napis) {
|
|
71
|
+
let platforms = [];
|
|
72
|
+
try { platforms = fs.readdirSync(path.join(binRoot, napi)); } catch { continue; }
|
|
73
|
+
if (!platforms.includes(platform)) continue;
|
|
74
|
+
for (const plat of platforms) {
|
|
75
|
+
const platDir = path.join(binRoot, napi, plat);
|
|
76
|
+
if (plat !== platform) { drop(platDir); continue; }
|
|
77
|
+
let arches = [];
|
|
78
|
+
try { arches = fs.readdirSync(platDir); } catch { continue; }
|
|
79
|
+
if (!arches.includes(arch)) continue;
|
|
80
|
+
for (const a of arches) if (a !== arch) drop(path.join(platDir, a));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return freed;
|
|
84
|
+
}
|
package/src/semantic-memory.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import path from 'path';
|
|
|
10
10
|
import crypto from 'crypto';
|
|
11
11
|
import { enrichmentTextFor, readEnrichment } from './enrichment.mjs';
|
|
12
12
|
import { createRequire } from 'module';
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
13
15
|
import * as autoUpdateLib from './mcp-auto-update.mjs';
|
|
14
16
|
|
|
15
17
|
// Plugin mode (KLYPIX_PLUGIN=1) never loads the on-device semantic runtime:
|
|
@@ -42,7 +44,13 @@ const intEnv = (name, fallback, min, max) => {
|
|
|
42
44
|
const n = Number(process.env[name]);
|
|
43
45
|
return Number.isFinite(n) ? Math.max(min, Math.min(max, Math.trunc(n))) : fallback;
|
|
44
46
|
};
|
|
45
|
-
|
|
47
|
+
// One card per inference call. A batch pads every card to its longest member
|
|
48
|
+
// (cards run up to 1,500 characters), so batching wastes work on a brain's
|
|
49
|
+
// mixed lengths. Measured 2026-10-11, real bge-small q8, 400 real cards on a
|
|
50
|
+
// laptop CPU: batch 16 = 96 ms a card, 1,176 MB peak; 8 = 79 ms, 720 MB;
|
|
51
|
+
// 4 = 63–70 ms, ~505 MB; 1 = 41–49 ms, ~305 MB. The fastest is also the lightest.
|
|
52
|
+
export const EMBED_BATCH_DEFAULT = 1;
|
|
53
|
+
const EMBED_BATCH_SIZE = intEnv('KLYPIX_EMBED_BATCH_SIZE', EMBED_BATCH_DEFAULT, 1, 64);
|
|
46
54
|
const RERANK_BATCH_SIZE = intEnv('KLYPIX_RERANK_BATCH_SIZE', 4, 1, 32);
|
|
47
55
|
const MAX_INFERENCE_QUEUE = intEnv('KLYPIX_SEMANTIC_MAX_QUEUE', 16, 1, 256);
|
|
48
56
|
const idleRaw = process.env.KLYPIX_SEMANTIC_IDLE_MS;
|
|
@@ -351,13 +359,12 @@ export function semanticFallbackNotice(hadSemantic) {
|
|
|
351
359
|
if (semanticRuntimeInstalled()) {
|
|
352
360
|
return 'lexical only — the on-device semantic model is still warming or the queue is saturated; retry shortly for semantic ranking';
|
|
353
361
|
}
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
+ '
|
|
360
|
-
+ `Semantic ranking activates once the runtime resolves, or is placed at ${path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers')}.`;
|
|
362
|
+
// Since 1.97.0 there IS an install command (bin/klypix-install.mjs --semantic,
|
|
363
|
+
// which a person at a terminal gets by default), so the notice names it: an
|
|
364
|
+
// agent reading this can offer it to the person instead of guessing.
|
|
365
|
+
return 'LEXICAL ONLY — search by meaning is not installed, so these are keyword matches. '
|
|
366
|
+
+ 'Measured on a real 3,829-card brain: the right card in the top 5 for 19 of 102 questions with keywords, 54 with search by meaning. Retrying will not help. '
|
|
367
|
+
+ 'Turn it on with: npx klypix-mcp install --semantic (about 150 MB to download, once).';
|
|
361
368
|
}
|
|
362
369
|
|
|
363
370
|
export function getEmbedder(log = () => {}) {
|
|
@@ -500,11 +507,11 @@ function cacheCandidates(brainPath) {
|
|
|
500
507
|
|
|
501
508
|
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
502
509
|
|
|
503
|
-
async function withCrossProcessCacheLock(brainPath, fn) {
|
|
510
|
+
async function withCrossProcessCacheLock(brainPath, fn, timeoutMs = CACHE_LOCK_TIMEOUT_MS) {
|
|
504
511
|
const file = cacheCandidates(brainPath)[0].file;
|
|
505
512
|
const lockFile = `${file}.lock`;
|
|
506
513
|
fs.mkdirSync(EMB_DIR, { recursive: true });
|
|
507
|
-
const deadline = Date.now() +
|
|
514
|
+
const deadline = Date.now() + timeoutMs;
|
|
508
515
|
let handle = null;
|
|
509
516
|
let waited = false;
|
|
510
517
|
while (!handle) {
|
|
@@ -527,7 +534,10 @@ async function withCrossProcessCacheLock(brainPath, fn) {
|
|
|
527
534
|
await wait(75);
|
|
528
535
|
}
|
|
529
536
|
}
|
|
530
|
-
|
|
537
|
+
// A long holder (the background preparation) refreshes the lock so it is
|
|
538
|
+
// never mistaken for an abandoned one at CACHE_LOCK_STALE_MS.
|
|
539
|
+
const touch = () => { try { const now = new Date(); fs.utimesSync(lockFile, now, now); } catch { /* best-effort */ } };
|
|
540
|
+
try { return await fn(touch); }
|
|
531
541
|
finally {
|
|
532
542
|
try { fs.closeSync(handle); } catch { /* */ }
|
|
533
543
|
try { fs.unlinkSync(lockFile); } catch { /* stale cleanup handles leftovers */ }
|
|
@@ -658,7 +668,23 @@ function readCache(brainPath, accept) {
|
|
|
658
668
|
};
|
|
659
669
|
}
|
|
660
670
|
|
|
661
|
-
|
|
671
|
+
// PREPARING A BRAIN NEVER HOLDS A QUESTION (1.97.0). Embedding every card of a
|
|
672
|
+
// fresh brain takes ~110 ms a card on a laptop CPU (measured 2026-10-11: 100
|
|
673
|
+
// cards 11 s, 3,670 cards 6.8 min), and the old writer did it inside the first
|
|
674
|
+
// question and saved nothing until the end — the agent's call waited minutes
|
|
675
|
+
// and an interrupted run lost everything. Now an interactive caller embeds at
|
|
676
|
+
// most INTERACTIVE_EMBED_BUDGET missing cards (a few seconds) and gets back
|
|
677
|
+
// what is ready; the rest is prepared by the detached, low-priority warm
|
|
678
|
+
// process (bin/klypix-semantic-warm.mjs, maxEmbed: Infinity), which saves as
|
|
679
|
+
// it goes so progress survives an interruption and readers see it.
|
|
680
|
+
export const INTERACTIVE_EMBED_BUDGET = 32;
|
|
681
|
+
const INTERACTIVE_LOCK_WAIT_MS = 3_000;
|
|
682
|
+
const PERSIST_EVERY_MS = 15_000;
|
|
683
|
+
const EMBED_CHUNK = 64;
|
|
684
|
+
|
|
685
|
+
async function vectorsForBrainUnlocked(pipe, brainPath, cards, opts = {}) {
|
|
686
|
+
const maxEmbed = Number.isFinite(opts.maxEmbed) ? Math.max(0, opts.maxEmbed) : (opts.maxEmbed === Infinity ? Infinity : INTERACTIVE_EMBED_BUDGET);
|
|
687
|
+
const stats = opts.stats && typeof opts.stats === 'object' ? opts.stats : {};
|
|
662
688
|
const want = cards.filter((card) => card.type !== 'container' && (card.text || '').trim());
|
|
663
689
|
// Question enrichment (1.77): the prompt/intent that produced a card is
|
|
664
690
|
// appended to its EMBED input — the asker's own vocabulary bridging the
|
|
@@ -692,26 +718,51 @@ ${extra}` : base;
|
|
|
692
718
|
if (entry.t !== textHashes.get(card.id)) { entry.t = textHashes.get(card.id); repaired++; }
|
|
693
719
|
}
|
|
694
720
|
if (repaired) dirty = true;
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
721
|
+
// Newest first: the cards people ask about most are ready soonest.
|
|
722
|
+
const missing = want
|
|
723
|
+
.filter((card) => cache.cards[card.id]?.h !== desiredHashes.get(card.id))
|
|
724
|
+
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
725
|
+
const toEmbed = maxEmbed === Infinity ? missing : missing.slice(0, maxEmbed);
|
|
726
|
+
stats.wanted = want.length;
|
|
727
|
+
stats.embedded = 0;
|
|
728
|
+
stats.pending = missing.length - toEmbed.length;
|
|
729
|
+
if (toEmbed.length) {
|
|
730
|
+
let lastPersist = Date.now();
|
|
731
|
+
for (let offset = 0; offset < toEmbed.length; offset += EMBED_CHUNK) {
|
|
732
|
+
const chunk = toEmbed.slice(offset, offset + EMBED_CHUNK);
|
|
733
|
+
let vectors;
|
|
734
|
+
try { vectors = await embedTexts(pipe, chunk.map((card) => embedInputFor(card))); }
|
|
735
|
+
catch (error) {
|
|
736
|
+
// The repair above needs no model, so it lands even when the embed does
|
|
737
|
+
// not, and so do the chunks already embedded. Everything the merge
|
|
738
|
+
// refused goes back beside them: those vectors are still on disk and a
|
|
739
|
+
// reader may still accept them through `t`. The persisted file is a
|
|
740
|
+
// superset snapshot: it can contain refused entries and entries for
|
|
741
|
+
// deleted cards. Readers reject them through the acceptance rule, and
|
|
742
|
+
// the next write that is dirty for another reason trims them.
|
|
743
|
+
if (repaired || stats.embedded) await persistCache(file, { ...cache, cards: { ...loaded.held, ...cache.cards } });
|
|
744
|
+
stats.pending = missing.length - stats.embedded;
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
chunk.forEach((card, index) => {
|
|
748
|
+
cache.cards[card.id] = { h: desiredHashes.get(card.id), t: textHashes.get(card.id), v: vectors[index] };
|
|
749
|
+
});
|
|
750
|
+
stats.embedded += chunk.length;
|
|
751
|
+
dirty = true;
|
|
752
|
+
if (typeof opts.touch === 'function') opts.touch();
|
|
753
|
+
const more = offset + EMBED_CHUNK < toEmbed.length;
|
|
754
|
+
// Save as it goes, so a long preparation survives an interruption and
|
|
755
|
+
// readers (cachedVectorsForBrain) see the progress.
|
|
756
|
+
if (more && Date.now() - lastPersist >= PERSIST_EVERY_MS) {
|
|
757
|
+
await persistCache(file, { ...cache, cards: { ...loaded.held, ...cache.cards } });
|
|
758
|
+
lastPersist = Date.now();
|
|
759
|
+
}
|
|
760
|
+
if (typeof opts.onProgress === 'function') {
|
|
761
|
+
try { opts.onProgress({ done: want.length - (missing.length - stats.embedded), total: want.length }); } catch { /* progress is advisory */ }
|
|
762
|
+
}
|
|
709
763
|
}
|
|
710
|
-
missing.forEach((card, index) => {
|
|
711
|
-
cache.cards[card.id] = { h: desiredHashes.get(card.id), t: textHashes.get(card.id), v: vectors[index] };
|
|
712
|
-
});
|
|
713
|
-
dirty = true;
|
|
714
764
|
}
|
|
765
|
+
stats.ready = want.length - stats.pending;
|
|
715
766
|
const live = new Set(want.map((card) => card.id));
|
|
716
767
|
for (const id of Object.keys(cache.cards)) {
|
|
717
768
|
if (!live.has(id)) { delete cache.cards[id]; dirty = true; }
|
|
@@ -725,6 +776,72 @@ ${extra}` : base;
|
|
|
725
776
|
return map;
|
|
726
777
|
}
|
|
727
778
|
|
|
779
|
+
// ── Background preparation: start it, read how far it got ────────────────────
|
|
780
|
+
// startBrainPreparation spawns the detached, low-priority warm process for ONE
|
|
781
|
+
// brain (--only: a question never makes this machine prepare every registered
|
|
782
|
+
// project). At most once a minute per brain from this process; the warm
|
|
783
|
+
// process's own lock makes a second start a no-op. The warm writes its
|
|
784
|
+
// progress to WARM_STATUS_FILE while it runs; brainPreparationStatus reads it
|
|
785
|
+
// so an answer can say how far along the preparation is.
|
|
786
|
+
const WARM_STATUS_FILE = path.join(PB_DIR, '.semantic-update-warm.json');
|
|
787
|
+
const WARM_STALE_MS = 2 * 60_000;
|
|
788
|
+
const lastPreparationStart = new Map();
|
|
789
|
+
|
|
790
|
+
export function startBrainPreparation(brainPath, { spawnFn = spawn, now = Date.now() } = {}) {
|
|
791
|
+
if (semanticDisabledByPlugin()) return false;
|
|
792
|
+
const key = canonicalBrainKey(brainPath);
|
|
793
|
+
if (now - (lastPreparationStart.get(key) || 0) < 60_000) return false;
|
|
794
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
795
|
+
const script = [path.join(here, 'klypix-semantic-warm.mjs'), path.join(here, '..', 'bin', 'klypix-semantic-warm.mjs')]
|
|
796
|
+
.find((file) => fs.existsSync(file));
|
|
797
|
+
if (!script) return false;
|
|
798
|
+
lastPreparationStart.set(key, now);
|
|
799
|
+
try {
|
|
800
|
+
const child = spawnFn(process.execPath, [script, '--brain-dir', PB_DIR, '--brain', path.resolve(brainPath), '--only'], {
|
|
801
|
+
detached: true,
|
|
802
|
+
stdio: 'ignore',
|
|
803
|
+
windowsHide: true,
|
|
804
|
+
env: { ...process.env },
|
|
805
|
+
});
|
|
806
|
+
child.unref?.();
|
|
807
|
+
return true;
|
|
808
|
+
} catch { return false; }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function brainPreparationStatus(brainPath, { now = Date.now(), statusFile = WARM_STATUS_FILE } = {}) {
|
|
812
|
+
try {
|
|
813
|
+
const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
|
|
814
|
+
if (status?.result !== 'preparing' || typeof status.brain !== 'string') return null;
|
|
815
|
+
if (canonicalBrainKey(status.brain) !== canonicalBrainKey(brainPath)) return null;
|
|
816
|
+
const updated = Date.parse(status.updatedAt || '');
|
|
817
|
+
if (!Number.isFinite(updated) || now - updated > WARM_STALE_MS) return null; // the run died
|
|
818
|
+
const done = Number(status.done) || 0, total = Number(status.total) || 0;
|
|
819
|
+
const started = Date.parse(status.startedAt || '');
|
|
820
|
+
const ratePerMs = done > 0 && Number.isFinite(started) && updated > started ? done / (updated - started) : null;
|
|
821
|
+
return { done, total, remainingMs: ratePerMs && total > done ? Math.round((total - done) / ratePerMs) : null };
|
|
822
|
+
} catch { return null; }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// One sentence an answer carries while a brain is being prepared. `stats`
|
|
826
|
+
// comes from vectorsForBrain; the live progress, when the warm reports it,
|
|
827
|
+
// is fresher.
|
|
828
|
+
export function preparationNotice(brainPath, stats, { usedMeaning = false, now = Date.now() } = {}) {
|
|
829
|
+
if (!stats || !(stats.pending > 0)) return null;
|
|
830
|
+
const live = brainPreparationStatus(brainPath, { now });
|
|
831
|
+
const total = live?.total || stats.wanted || 0;
|
|
832
|
+
const done = live ? live.done : (stats.ready || 0);
|
|
833
|
+
const minutes = live?.remainingMs != null
|
|
834
|
+
? Math.max(1, Math.round(live.remainingMs / 60_000))
|
|
835
|
+
// ~45 ms a card measured one at a time; ~0.1 s leaves room for a busy
|
|
836
|
+
// machine and the low-priority run until the live rate is known.
|
|
837
|
+
: Math.max(1, Math.round(((total - done) * 0.1) / 60));
|
|
838
|
+
const n = (x) => Number(x).toLocaleString('en-US');
|
|
839
|
+
return `Getting this brain ready for search by meaning: ${n(done)} of ${n(total)} cards so far, about ${minutes} min left. `
|
|
840
|
+
+ (usedMeaning
|
|
841
|
+
? 'This answer used the cards that are ready plus keywords; it gets better as the rest finish.'
|
|
842
|
+
: 'This answer used keyword search; ask again when it is done for better matches.');
|
|
843
|
+
}
|
|
844
|
+
|
|
728
845
|
// READ-ONLY card vectors from the warm cache: never loads the model, never
|
|
729
846
|
// embeds a missing card, never writes. For fast paths (brain_sync task context)
|
|
730
847
|
// that may USE card↔card similarity when it is already paid for — plan ↔ 🏁
|
|
@@ -771,16 +888,32 @@ export function cachedVectorsForBrain(brainPath, cards) {
|
|
|
771
888
|
return map;
|
|
772
889
|
}
|
|
773
890
|
|
|
774
|
-
|
|
775
|
-
|
|
891
|
+
// opts.maxEmbed: how many missing cards this call may embed — the interactive
|
|
892
|
+
// default is INTERACTIVE_EMBED_BUDGET; the background warm passes Infinity.
|
|
893
|
+
// opts.stats receives { wanted, ready, embedded, pending }; opts.onProgress
|
|
894
|
+
// gets { done, total } after each saved chunk.
|
|
895
|
+
export async function vectorsForBrain(pipe, brainPath, cards, opts = {}) {
|
|
896
|
+
if (!BOUNDED) return vectorsForBrainUnlocked(pipe, brainPath, cards, opts);
|
|
776
897
|
const key = canonicalBrainKey(brainPath);
|
|
777
898
|
const previous = vectorBuildTails.get(key) || Promise.resolve();
|
|
899
|
+
// A question never waits behind a preparation: it gets INTERACTIVE_LOCK_WAIT_MS,
|
|
900
|
+
// then reads what the preparation has saved so far (below).
|
|
901
|
+
const interactive = opts.maxEmbed !== Infinity;
|
|
778
902
|
const run = previous.catch(() => {}).then(() => withCrossProcessCacheLock(
|
|
779
903
|
brainPath,
|
|
780
|
-
() => vectorsForBrainUnlocked(pipe, brainPath, cards),
|
|
904
|
+
(touch) => vectorsForBrainUnlocked(pipe, brainPath, cards, { ...opts, touch }),
|
|
905
|
+
interactive ? INTERACTIVE_LOCK_WAIT_MS : CACHE_LOCK_TIMEOUT_MS,
|
|
781
906
|
));
|
|
782
907
|
vectorBuildTails.set(key, run);
|
|
783
908
|
try { return await run; }
|
|
909
|
+
catch (error) {
|
|
910
|
+
if (!interactive || error?.code !== 'KLYPIX_SEMANTIC_CACHE_BUSY') throw error;
|
|
911
|
+
// The background preparation holds the lock: answer from what it has saved.
|
|
912
|
+
const map = cachedVectorsForBrain(brainPath, cards);
|
|
913
|
+
const wanted = cards.filter((card) => card && card.type !== 'container' && typeof card.text === 'string' && card.text.trim()).length;
|
|
914
|
+
if (opts.stats && typeof opts.stats === 'object') Object.assign(opts.stats, { wanted, ready: map.size, embedded: 0, pending: Math.max(0, wanted - map.size), busy: true });
|
|
915
|
+
return map;
|
|
916
|
+
}
|
|
784
917
|
finally { if (vectorBuildTails.get(key) === run) vectorBuildTails.delete(key); }
|
|
785
918
|
}
|
|
786
919
|
|