ruvnet-brain 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/install.mjs +679 -121
- package/console/app.js +178 -81
- package/console/index.html +1 -1
- package/console/install-architecture.html +1 -0
- package/console/scope.css +4 -1
- package/console/style.css +13 -0
- package/console/tips.html +4 -4
- package/kb/brain-profile.mjs +1 -0
- package/kb/corpus-release-identity.mjs +1 -1
- package/kb/forge-update.mjs +41 -17
- package/kb/model-requirements.mjs +4 -1
- package/kb/update-storage-transaction.mjs +79 -0
- package/kb/zip-extract.mjs +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +5 -4
- package/plugin/commands/configure.md +5 -4
- package/plugin/commands/rnb-brief.md +41 -0
- package/plugin/commands/rnb.md +80 -0
- package/plugin/commands/rnbc.md +80 -0
- package/plugin/commands/rvbc.md +5 -4
- package/plugin/commands/rvcb.md +5 -4
- package/plugin/commands/whats-new.md +4 -4
- package/plugin/mcp/server.mjs +10 -2
- package/plugin/scripts/advocacy-route.mjs +59 -23
- package/plugin/scripts/anticipate.sh +4 -0
- package/plugin/scripts/brain-confirmation.mjs +258 -0
- package/plugin/scripts/brain-footprint.mjs +494 -0
- package/plugin/scripts/brain-location.mjs +47 -0
- package/plugin/scripts/capability-registry.mjs +11 -1
- package/plugin/scripts/continuity-brief.mjs +324 -0
- package/plugin/scripts/continuity-events.mjs +327 -0
- package/plugin/scripts/continuity-journal.mjs +500 -0
- package/plugin/scripts/decision-gate.mjs +56 -3
- package/plugin/scripts/footprint-io.mjs +186 -0
- package/plugin/scripts/ground-before-write.sh +8 -1
- package/plugin/scripts/ground-ruvnet.sh +103 -12
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-substance.mjs +1 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
- package/plugin/scripts/hook-input.mjs +78 -4
- package/plugin/scripts/kb-copy-proof.mjs +148 -0
- package/plugin/scripts/lesson-bridge.mjs +6 -2
- package/plugin/scripts/nightly-controller.mjs +8 -1
- package/plugin/scripts/node-sqlite.mjs +41 -0
- package/plugin/scripts/package-cards.json +797 -0
- package/plugin/scripts/package-cards.rvf +0 -0
- package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
- package/plugin/scripts/package-cards.rvf.meta.json +1 -0
- package/plugin/scripts/package-recommender-client.mjs +138 -0
- package/plugin/scripts/package-recommender-flag.mjs +30 -0
- package/plugin/scripts/package-recommender.mjs +391 -0
- package/plugin/scripts/project-progression-outbox.mjs +26 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -2
- package/plugin/scripts/project-progression-store.mjs +153 -6
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +225 -41
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +34 -4
- package/plugin/scripts/session-start-health.mjs +7 -1
- package/plugin/scripts/session-start-update-plane.mjs +35 -0
- package/plugin/scripts/turn-outcome-capture.mjs +12 -1
- package/plugin/scripts/unprompted-runtime.mjs +2 -2
- package/plugin/skills/brain-console/SKILL.md +3 -3
- package/plugin/skills/rnbc/SKILL.md +24 -0
- package/plugin/skills/rvbc/SKILL.md +2 -2
- package/scripts/approved-runtime.mjs +2 -2
- package/scripts/ci/warm-brain-models.mjs +28 -0
- package/scripts/codex-hook-trust.mjs +94 -0
- package/scripts/console-instances.mjs +70 -12
- package/scripts/console-runtime-identity.mjs +5 -0
- package/scripts/corpus-canary.mjs +46 -6
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +1 -1
- package/scripts/full-suite-gate.mjs +9 -2
- package/scripts/hook-qualify-hosts.mjs +15 -3
- package/scripts/host-install-matrix.mjs +63 -2
- package/scripts/human-approval-phrases.mjs +46 -0
- package/scripts/installed-brain-health.mjs +53 -0
- package/scripts/move-brain.mjs +310 -0
- package/scripts/onboarding-console.mjs +93 -10
- package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
- package/scripts/oracle/abstain-trace.mjs +139 -0
- package/scripts/oracle/doc2query-generate.mjs +162 -0
- package/scripts/oracle/doc2query-reach.mjs +110 -0
- package/scripts/oracle/judge-train.mjs +158 -0
- package/scripts/oracle/need-set-split.mjs +48 -0
- package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
- package/scripts/package-cards.mjs +374 -0
- package/scripts/publication-receipt.mjs +37 -9
- package/scripts/recommendation-e2e.mjs +110 -0
- package/scripts/recommendation-eval.mjs +105 -0
- package/scripts/recommendation-floor.mjs +56 -0
- package/scripts/recommendation-judge-score.mjs +74 -0
- package/scripts/recommendation-latency.mjs +95 -0
- package/scripts/recommendation-real-host-score.mjs +76 -0
- package/scripts/recommendation-real-host.mjs +137 -0
- package/scripts/release-channel-kind.mjs +1 -1
- package/scripts/release-environment-policy.mjs +33 -0
- package/scripts/single-source-check.mjs +15 -10
- package/scripts/sync-commands.mjs +5 -2
- package/scripts/wired-check.mjs +17 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/oracle/sona-query-adapter-eval.mjs — ADR-099 arm B, an EXPERIMENT: can a SONA MicroLoRA
|
|
4
|
+
* query adapter (@ruvector/sona, rank 1-2) move a novice question's embedding so the gold file ranks
|
|
5
|
+
* higher in the store's own dense index?
|
|
6
|
+
*
|
|
7
|
+
* Train (need-set TRAIN split only): one SONA trajectory per need, beginning at the question's
|
|
8
|
+
* embedding, with one step whose activation is the gold passage's embedding (reward 1) and one step
|
|
9
|
+
* per top dense non-gold passage (reward 0), ended with quality 1. SONA's REINFORCE gradient
|
|
10
|
+
* ((reward - baseline) x activation, ruvector/crates/sona/src/types.rs) then pushes the adapter toward
|
|
11
|
+
* the gold direction. forceLearn() applies it.
|
|
12
|
+
* Evaluate (HELD-OUT, and TRAIN for fit): the gold file's rank among the store's top --depth dense
|
|
13
|
+
* hits for the raw question vs applyMicroLora(question); and the same on the recall-gate fixture, which
|
|
14
|
+
* the adapter must not hurt. An untrained adapter is measured too, because it is not the identity.
|
|
15
|
+
*
|
|
16
|
+
* node scripts/oracle/sona-query-adapter-eval.mjs --kb <kbDir with forge-ask.mjs + node_modules>
|
|
17
|
+
* --sona <dir containing node_modules/@ruvector/sona> --set <need-set.json> --split <split.json>
|
|
18
|
+
* [--recall data/retrieval-query-evidence.json] [--depth 64] [--negatives 4] [--out <file>]
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
|
+
import { wilson } from '../eval-brain.mjs';
|
|
25
|
+
|
|
26
|
+
/** Rank (1-based) of the first hit whose path is `gold`, or null within the list. */
|
|
27
|
+
export function rankOf(paths, gold) {
|
|
28
|
+
const i = paths.indexOf(gold);
|
|
29
|
+
return i < 0 ? null : i + 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Paired comparison of two rank lists (null = not within depth). */
|
|
33
|
+
export function compareRanks(base, adapted, cut = 5) {
|
|
34
|
+
const within = (r) => r != null && r <= cut;
|
|
35
|
+
const n = base.length;
|
|
36
|
+
const r = (k) => { const w = wilson(k, n); return { k, n, lo: +w.lo.toFixed(4), hi: +w.hi.toFixed(4) }; };
|
|
37
|
+
let gained = 0;
|
|
38
|
+
let lost = 0;
|
|
39
|
+
for (let i = 0; i < n; i++) {
|
|
40
|
+
if (!within(base[i]) && within(adapted[i])) gained++;
|
|
41
|
+
if (within(base[i]) && !within(adapted[i])) lost++;
|
|
42
|
+
}
|
|
43
|
+
return { base: r(base.filter(within).length), adapted: r(adapted.filter(within).length), gained, lost,
|
|
44
|
+
inDepthBase: base.filter((x) => x != null).length, inDepthAdapted: adapted.filter((x) => x != null).length };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const args = process.argv.slice(2);
|
|
49
|
+
const arg = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
|
|
50
|
+
const kb = path.resolve(arg('--kb'));
|
|
51
|
+
const depth = Number(arg('--depth', 64));
|
|
52
|
+
const negatives = Number(arg('--negatives', 4));
|
|
53
|
+
const require = createRequire(path.join(path.resolve(arg('--sona')), 'index.js'));
|
|
54
|
+
const { SonaEngine } = require('@ruvector/sona');
|
|
55
|
+
const { __embedInternals } = await import(pathToFileURL(path.join(kb, 'forge-ask.mjs')).href);
|
|
56
|
+
const { loadRvf } = await import(pathToFileURL(path.join(kb, 'resolve-deps.mjs')).href);
|
|
57
|
+
const { RvfDatabase } = loadRvf().mod;
|
|
58
|
+
const embed = __embedInternals.embed;
|
|
59
|
+
const set = JSON.parse(fs.readFileSync(arg('--set'), 'utf8')).questions;
|
|
60
|
+
const split = JSON.parse(fs.readFileSync(arg('--split'), 'utf8'));
|
|
61
|
+
const stores = new Map();
|
|
62
|
+
const storeOf = async (name) => {
|
|
63
|
+
if (stores.has(name)) return stores.get(name);
|
|
64
|
+
const big = fs.existsSync(path.join(kb, `${name}.big.rvf`));
|
|
65
|
+
const base = path.join(kb, big ? `${name}.big.rvf` : `${name}.rvf`);
|
|
66
|
+
const cfg = JSON.parse(fs.readFileSync(`${base}.embed.json`, 'utf8'));
|
|
67
|
+
const idmap = JSON.parse(fs.readFileSync(`${base}.idmap.json`, 'utf8')).idToLabel;
|
|
68
|
+
const labelToId = new Map(Object.entries(idmap).map(([id, label]) => [Number(label), id]));
|
|
69
|
+
const passages = path.join(kb, big && fs.existsSync(path.join(kb, `${name}.big.passages.jsonl`)) ? `${name}.big.passages.jsonl` : `${name}.passages.jsonl`);
|
|
70
|
+
const pathOf = new Map();
|
|
71
|
+
const textOf = new Map();
|
|
72
|
+
for (const l of fs.readFileSync(passages, 'utf8').split('\n')) {
|
|
73
|
+
if (!l) continue;
|
|
74
|
+
const r = JSON.parse(l);
|
|
75
|
+
pathOf.set(String(r.id), r.path);
|
|
76
|
+
if (!textOf.has(r.path)) textOf.set(r.path, String(r.text || ''));
|
|
77
|
+
}
|
|
78
|
+
const db = await RvfDatabase.openReadonly(base);
|
|
79
|
+
const s = { name, cfg, db, labelToId, pathOf, textOf, engine: new SonaEngine(cfg.dimensions) };
|
|
80
|
+
stores.set(name, s);
|
|
81
|
+
return s;
|
|
82
|
+
};
|
|
83
|
+
const search = async (s, vec) => {
|
|
84
|
+
const hits = await s.db.query(Array.from(vec), depth);
|
|
85
|
+
const paths = [];
|
|
86
|
+
for (const h of hits) {
|
|
87
|
+
const p = s.pathOf.get(String(s.labelToId.get(Number(h.id)) ?? h.id));
|
|
88
|
+
if (p && !paths.includes(p)) paths.push(p);
|
|
89
|
+
}
|
|
90
|
+
return paths;
|
|
91
|
+
};
|
|
92
|
+
const passageEmbed = (s, text) => embed(text, { ...s.cfg, queryPrefix: '' });
|
|
93
|
+
const qs = set.map((q) => ({ ...q, store: q.repo.toLowerCase(), isTrain: split.train.includes(q.id) }));
|
|
94
|
+
// TRAIN
|
|
95
|
+
let trained = 0;
|
|
96
|
+
for (const q of qs.filter((x) => x.isTrain)) {
|
|
97
|
+
const s = await storeOf(q.store);
|
|
98
|
+
const qv = Array.from(await embed(q.need, s.cfg));
|
|
99
|
+
const gold = s.textOf.get(q.path);
|
|
100
|
+
if (!gold) continue;
|
|
101
|
+
const t = s.engine.beginTrajectory(qv);
|
|
102
|
+
const zeros = new Array(64).fill(0);
|
|
103
|
+
s.engine.addTrajectoryStep(t, Array.from(await passageEmbed(s, q.span || gold)), zeros, 1);
|
|
104
|
+
for (const p of (await search(s, qv)).filter((p) => p !== q.path).slice(0, negatives)) {
|
|
105
|
+
s.engine.addTrajectoryStep(t, Array.from(await passageEmbed(s, s.textOf.get(p) || '')), zeros, 0);
|
|
106
|
+
}
|
|
107
|
+
s.engine.endTrajectory(t, 1);
|
|
108
|
+
trained++;
|
|
109
|
+
process.stderr.write(`\r[sona-eval] trained ${trained}`);
|
|
110
|
+
}
|
|
111
|
+
const learn = {};
|
|
112
|
+
for (const s of stores.values()) learn[s.name] = String(s.engine.forceLearn());
|
|
113
|
+
// EVALUATE: raw vs adapted, on held-out and train needs, and on the recall fixture
|
|
114
|
+
const evalSet = async (items) => {
|
|
115
|
+
const base = [];
|
|
116
|
+
const adapted = [];
|
|
117
|
+
for (const it of items) {
|
|
118
|
+
const s = await storeOf(it.store);
|
|
119
|
+
const qv = Array.from(await embed(it.query, s.cfg));
|
|
120
|
+
base.push(rankOf(await search(s, qv), it.gold));
|
|
121
|
+
adapted.push(rankOf(await search(s, s.engine.applyMicroLora(qv)), it.gold));
|
|
122
|
+
}
|
|
123
|
+
return compareRanks(base, adapted);
|
|
124
|
+
};
|
|
125
|
+
const needItems = (isTrain) => qs.filter((q) => q.isTrain === isTrain).map((q) => ({ store: q.store, query: q.need, gold: q.path }));
|
|
126
|
+
const report = { kind: 'ruvnet-brain-sona-query-adapter-eval', depth, negatives, trained, learn,
|
|
127
|
+
heldout: await evalSet(needItems(false)), train: await evalSet(needItems(true)) };
|
|
128
|
+
if (arg('--recall')) {
|
|
129
|
+
const fx = JSON.parse(fs.readFileSync(arg('--recall'), 'utf8')).queries;
|
|
130
|
+
const items = Object.entries(fx).filter(([store]) => stores.has(store))
|
|
131
|
+
.map(([store, v]) => ({ store, query: v.query, gold: v.expected.path }));
|
|
132
|
+
report.recallFixtureSameStores = await evalSet(items);
|
|
133
|
+
}
|
|
134
|
+
if (arg('--out')) fs.writeFileSync(arg('--out'), `${JSON.stringify(report, null, 1)}\n`);
|
|
135
|
+
console.log(JSON.stringify(report, null, 1));
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* package-cards.mjs — PACKAGE-LEVEL capability cards, derived mechanically from the knowledge base's
|
|
4
|
+
* own package manifests (ADR-0093, status Proposed).
|
|
5
|
+
*
|
|
6
|
+
* WHY. kb/capability-cards.md has one card per REPOSITORY. A repository like ruvector publishes ~70
|
|
7
|
+
* npm packages and ~100 crates, so a repo card can say "vector database" and never say "typed
|
|
8
|
+
* decisions over embeddings" (@ruvector/typesafe) or "BM25 + ANN + reciprocal rank fusion"
|
|
9
|
+
* (ruvector-hybrid). The owner had to name @ruvector/typesafe himself on 2026-09-30; the Brain held
|
|
10
|
+
* its package.json the whole time. These cards make that knowledge reachable by DESCRIBED need.
|
|
11
|
+
*
|
|
12
|
+
* THE GROUNDING LINE (same line scripts/card-from-source.mjs draws). Every word on a card is copied
|
|
13
|
+
* from a manifest passage the corpus already ingested: the `npm package:` or `Rust crate / manifest:`
|
|
14
|
+
* document of a PUBLIC store. Nothing is inferred from a package name. Each card carries the source
|
|
15
|
+
* path (`<store>/<path>`) and the sha256 of the passage text, so a recommendation can be cited and a
|
|
16
|
+
* stale card detected.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IS LEFT OUT, AND WHY:
|
|
19
|
+
* - private stores (kb/PRIVATE-STORES.json) and any store with no public `## <store>` card — the
|
|
20
|
+
* snapshot is checked into a public repository and ships to strangers.
|
|
21
|
+
* - examples/tests/fixtures/templates/benchmarks/vendored skill copies — a recommendation must name
|
|
22
|
+
* something a user installs, not a demo inside someone's repo.
|
|
23
|
+
* - per-platform native binaries (`-darwin-arm64`, `-linux-x64-gnu`, …) — the parent package
|
|
24
|
+
* installs the right one itself.
|
|
25
|
+
* - manifests with no description — nothing grounded to match on, so honestly absent.
|
|
26
|
+
*
|
|
27
|
+
* FAMILIES. `@ruvector/gnn`, `@ruvector/gnn-wasm`, `ruvector-gnn-node` and the `ruvector-gnn` crate are
|
|
28
|
+
* one capability shipped four ways. Ranking them separately would make every GNN prompt a four-way
|
|
29
|
+
* tie the margin rule must refuse, so they collapse to ONE card (the npm package preferred) with the
|
|
30
|
+
* siblings listed as `variants`.
|
|
31
|
+
*
|
|
32
|
+
* node scripts/package-cards.mjs # report (no write)
|
|
33
|
+
* node scripts/package-cards.mjs --write # write plugin/scripts/package-cards.json
|
|
34
|
+
* node scripts/package-cards.mjs --kb <dir> --out <f> # explicit KB and output (nightly bundle step)
|
|
35
|
+
* node scripts/package-cards.mjs --check # exit 1 if the committed snapshot drifted
|
|
36
|
+
* node scripts/package-cards.mjs --write --embed # also (re)build package-cards.rvf beside it
|
|
37
|
+
*/
|
|
38
|
+
import crypto from 'node:crypto';
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import readline from 'node:readline';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
45
|
+
export const SCHEMA = 'ruvnet-brain.package-cards/1';
|
|
46
|
+
export const DEFAULT_OUT = path.join(ROOT, 'plugin', 'scripts', 'package-cards.json');
|
|
47
|
+
|
|
48
|
+
const MANIFEST_HEAD = /^(npm package|Rust crate \/ manifest): (\S+)/;
|
|
49
|
+
const MIN_DESCRIPTION = 25;
|
|
50
|
+
|
|
51
|
+
/** Parse one manifest passage. Returns null for anything that is not a usable manifest. */
|
|
52
|
+
export function parseManifestPassage(text, passagePath) {
|
|
53
|
+
const s = String(text || '');
|
|
54
|
+
const head = s.match(MANIFEST_HEAD);
|
|
55
|
+
if (!head) return null;
|
|
56
|
+
const kind = head[1] === 'npm package' ? 'npm' : 'crate';
|
|
57
|
+
const name = head[2].trim();
|
|
58
|
+
// An npm manifest with no "name" is rendered with its PATH as the name — not a package.
|
|
59
|
+
if (!name || name.includes('/package.json') || name.endsWith('.toml')) return null;
|
|
60
|
+
const line = (label) => (s.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'))?.[1] || '').trim();
|
|
61
|
+
const description = line('Description');
|
|
62
|
+
const version = line('Version') || null;
|
|
63
|
+
const keywords = line('Keywords').split(',').map((k) => k.trim().toLowerCase()).filter(Boolean);
|
|
64
|
+
const p = String(passagePath || line('Path') || '');
|
|
65
|
+
return { kind, name, version, description, keywords, path: p };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const EXCLUDED_SEGMENTS = new Set([
|
|
69
|
+
'example', 'examples', 'test', 'tests', '__tests__', 'fixture', 'fixtures', 'template', 'templates',
|
|
70
|
+
'demo', 'demos', 'bench', 'benches', 'benchmark', 'benchmarks', 'node_modules', 'platforms', 'npm-platforms',
|
|
71
|
+
'.agents', '.claude', '.github', 'docs', 'archive', 'legacy', 'deprecated', 'scratch', 'tmp', 'vendor',
|
|
72
|
+
'snapshots', 'e2e', 'sample', 'samples', 'playground', 'tutorials', 'tutorial',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** A path a user would install from, not a demo, test, or vendored copy. */
|
|
76
|
+
export function isShippablePath(p) {
|
|
77
|
+
const segs = String(p || '').toLowerCase().split('/');
|
|
78
|
+
return !segs.some((seg) => EXCLUDED_SEGMENTS.has(seg));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PLATFORM = /-(darwin|linux|win32|windows|android|freebsd|macos)(-|$)|-(x64|x86_64|arm64|aarch64|ia32|armv7)(-(gnu|musl|msvc|gnueabihf))?$/;
|
|
82
|
+
/** Per-platform native binary packages — the parent package selects one on install. */
|
|
83
|
+
export function isPlatformBinary(name) {
|
|
84
|
+
return PLATFORM.test(String(name || '').toLowerCase());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const NAME_NOISE = /(?:-(?:test|tests|example|examples|demo|bench|fixture|integration-test|e2e|playground))$/;
|
|
88
|
+
|
|
89
|
+
/** One capability shipped several ways shares a family key. */
|
|
90
|
+
export function familyKey(name) {
|
|
91
|
+
let base = String(name || '').toLowerCase();
|
|
92
|
+
base = base.replace(/^@[^/]+\//, '');
|
|
93
|
+
base = base.replace(/^ruvector-/, '');
|
|
94
|
+
let prev;
|
|
95
|
+
do {
|
|
96
|
+
prev = base;
|
|
97
|
+
base = base.replace(/-(wasm|node|ffi|napi|native|core|js|types|bindings|sys|cli)$/, '');
|
|
98
|
+
} while (base !== prev && base.includes('-'));
|
|
99
|
+
return base;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── PRODUCTS (ADR-093 rev 3): packages that are ONE product, derived from repo + manifest facts ──────
|
|
103
|
+
// A recommendation that names `aidefence-core` (midstream's Rust crate) and a label that says
|
|
104
|
+
// `@claude-flow/aidefence` (ruflo's npm package) are the same advice. The relation is derived, never
|
|
105
|
+
// typed: three rules over facts the manifests already carry.
|
|
106
|
+
// R1 FLAGSHIP — a package whose unscoped name, or whose family key, equals its repo's name, or whose
|
|
107
|
+
// manifest sits at the repo root, IS that repo's product (`ruflo` and root `claude-flow` → ruflo).
|
|
108
|
+
// R2 SCOPE CLI/CORE — `@<scope>/cli|core|main|sdk` is the product of the unscoped package `<scope>`
|
|
109
|
+
// when one exists (`@claude-flow/cli` → `claude-flow` → ruflo's product).
|
|
110
|
+
// R3 SHARED KEY — packages in different repos are one product when their family key is a FLAGSHIP
|
|
111
|
+
// product's key, or is itself a product the corpus names (a `## <name>` heading in the public
|
|
112
|
+
// capability-cards.md): `aidefence-core` + `@claude-flow/aidefence` → aidefence. A key that is only
|
|
113
|
+
// a common word (`deployment`, `ledger`, `witness`) stays local to its repo.
|
|
114
|
+
// The CANONICAL install names the product in a hint: npm over crate, the repo-named package, then no
|
|
115
|
+
// -core/-wasm-style suffix, then the shortest id.
|
|
116
|
+
const GENERIC_KEYS = new Set(`
|
|
117
|
+
core cli server client types utils common shared config sdk api index search memory graph cache
|
|
118
|
+
node wasm runtime backend frontend agent agents swarm flow store queue worker workers proxy bridge
|
|
119
|
+
monitor dashboard plugin plugins cluster router solver main app web ui test tests demo example
|
|
120
|
+
`.trim().split(/\s+/));
|
|
121
|
+
|
|
122
|
+
const isRootManifest = (c) => /^(package\.json|Cargo\.toml)$/i.test(c.source.split('/').slice(1).join('/'))
|
|
123
|
+
|| c.source.split('/').slice(1).join('/').toLowerCase() === `${c.store}/package.json`;
|
|
124
|
+
const unscoped = (id) => String(id).replace(/^@[^/]+\//, '').toLowerCase();
|
|
125
|
+
|
|
126
|
+
export function deriveProducts(cards, { productNames = new Set() } = {}) {
|
|
127
|
+
const byId = new Map(cards.map((c) => [c.id.toLowerCase(), c]));
|
|
128
|
+
const product = new Map();
|
|
129
|
+
const flagship = (c) => unscoped(c.id) === c.store || familyKey(c.id) === c.store || isRootManifest(c);
|
|
130
|
+
for (const c of cards) if (flagship(c)) product.set(c.id, `repo:${c.store}`);
|
|
131
|
+
for (const c of cards) {
|
|
132
|
+
if (product.has(c.id)) continue;
|
|
133
|
+
const m = c.id.toLowerCase().match(/^@([^/]+)\/(cli|core|main|sdk)$/);
|
|
134
|
+
const owner = m && byId.get(m[1]);
|
|
135
|
+
if (owner) product.set(c.id, product.get(owner.id) || `pkg:${owner.id.toLowerCase()}`);
|
|
136
|
+
}
|
|
137
|
+
// R3: a distinctive key shared across repos; joins a flagship that carries the same key.
|
|
138
|
+
const keyProduct = new Map();
|
|
139
|
+
for (const c of cards) {
|
|
140
|
+
const k = familyKey(c.id);
|
|
141
|
+
if (product.has(c.id) && k.length >= 5 && !GENERIC_KEYS.has(k)) keyProduct.set(k, product.get(c.id));
|
|
142
|
+
}
|
|
143
|
+
for (const c of cards) {
|
|
144
|
+
if (product.has(c.id)) continue;
|
|
145
|
+
const k = familyKey(c.id);
|
|
146
|
+
const distinctive = k.length >= 5 && !GENERIC_KEYS.has(k);
|
|
147
|
+
product.set(c.id, keyProduct.get(distinctive ? k : '') || (distinctive && productNames.has(k) ? `key:${k}` : `key:${c.store}:${k}`));
|
|
148
|
+
}
|
|
149
|
+
const members = new Map();
|
|
150
|
+
for (const c of cards) {
|
|
151
|
+
const p = product.get(c.id);
|
|
152
|
+
if (!members.has(p)) members.set(p, []);
|
|
153
|
+
members.get(p).push(c);
|
|
154
|
+
}
|
|
155
|
+
const rank = (c) => [c.kind === 'npm' ? 0 : 1, unscoped(c.id) === c.store ? 0 : 1,
|
|
156
|
+
/-(core|wasm|node|ffi|napi|native|sys|types|bindings|cli)$/.test(c.id) ? 1 : 0, c.id.length];
|
|
157
|
+
const cmp = (a, b) => { const x = rank(a); const y = rank(b); for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] - y[i]; return a.id < b.id ? -1 : 1; };
|
|
158
|
+
const canonical = new Map([...members].map(([p, list]) => [p, [...list].sort(cmp)[0].id]));
|
|
159
|
+
return cards.map((c) => ({ ...c, product: product.get(c.id), canonical: canonical.get(product.get(c.id)) }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Lower is better: the variant a recommendation should name when a family has several. */
|
|
163
|
+
function preference(card) {
|
|
164
|
+
const n = card.name.toLowerCase();
|
|
165
|
+
let rank = card.kind === 'npm' ? 0 : 10;
|
|
166
|
+
if (/-(wasm|node|ffi|napi|native|core|types|bindings|sys|cli)$/.test(n)) rank += 3;
|
|
167
|
+
if (!n.startsWith('@')) rank += 1;
|
|
168
|
+
return rank * 1000 + n.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Public store allowlist: a `## <store>` heading in the public capability-cards.md, minus private. */
|
|
172
|
+
export function publicStoresFrom(cardsMd, privateStores = []) {
|
|
173
|
+
const priv = new Set(privateStores.map((s) => String(s).toLowerCase()));
|
|
174
|
+
const out = new Set();
|
|
175
|
+
for (const m of String(cardsMd || '').matchAll(/^##\s+(.+)$/gm)) {
|
|
176
|
+
const name = m[1].trim().toLowerCase();
|
|
177
|
+
if (!priv.has(name)) out.add(name);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build the card set from an iterable of { store, text, path } manifest passages. Pure — the CLI
|
|
186
|
+
* below does the file IO, the tests drive this directly.
|
|
187
|
+
*/
|
|
188
|
+
export function buildCards(passages, { publicStores, owners = {} } = {}) {
|
|
189
|
+
const byName = new Map();
|
|
190
|
+
for (const row of passages) {
|
|
191
|
+
const store = String(row.store || '').toLowerCase();
|
|
192
|
+
if (publicStores && !publicStores.has(store)) continue;
|
|
193
|
+
const m = parseManifestPassage(row.text, row.path);
|
|
194
|
+
if (!m) continue;
|
|
195
|
+
if (m.description.length < MIN_DESCRIPTION) continue;
|
|
196
|
+
if (!isShippablePath(m.path)) continue;
|
|
197
|
+
if (isPlatformBinary(m.name) || NAME_NOISE.test(m.name.toLowerCase())) continue;
|
|
198
|
+
const card = { ...m, store, source: `${store}/${m.path}`, sourceSha256: sha256(row.text) };
|
|
199
|
+
// Vendored copies: the scope owner (kb/package-owners.json) publishes it; elsewhere it is a copy.
|
|
200
|
+
const scope = m.name.startsWith('@') ? `${m.name.split('/')[0].toLowerCase()}/*` : null;
|
|
201
|
+
const owner = owners[m.name.toLowerCase()] || (scope && owners[scope]) || null;
|
|
202
|
+
const prev = byName.get(m.name);
|
|
203
|
+
const better = !prev
|
|
204
|
+
|| (owner && prev.store !== owner && store === owner)
|
|
205
|
+
|| (!(owner && prev.store === owner) && m.path.length < prev.path.length);
|
|
206
|
+
if (better) byName.set(m.name, card);
|
|
207
|
+
}
|
|
208
|
+
// Collapse each family to its preferred variant, keeping siblings as variants.
|
|
209
|
+
const families = new Map();
|
|
210
|
+
for (const card of byName.values()) {
|
|
211
|
+
const key = `${card.store}:${familyKey(card.name)}`;
|
|
212
|
+
const list = families.get(key) || [];
|
|
213
|
+
list.push(card);
|
|
214
|
+
families.set(key, list);
|
|
215
|
+
}
|
|
216
|
+
const cards = [];
|
|
217
|
+
for (const [key, list] of families) {
|
|
218
|
+
list.sort((a, b) => preference(a) - preference(b) || a.name.localeCompare(b.name));
|
|
219
|
+
const [lead, ...rest] = list;
|
|
220
|
+
const keywords = [...new Set(list.flatMap((c) => c.keywords))].sort();
|
|
221
|
+
cards.push({
|
|
222
|
+
id: lead.name,
|
|
223
|
+
family: key.split(':')[1],
|
|
224
|
+
kind: lead.kind,
|
|
225
|
+
store: lead.store,
|
|
226
|
+
version: lead.version,
|
|
227
|
+
description: lead.description,
|
|
228
|
+
keywords,
|
|
229
|
+
source: lead.source,
|
|
230
|
+
sourceSha256: lead.sourceSha256,
|
|
231
|
+
variants: rest.map((c) => c.name).sort(),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
cards.sort((a, b) => a.id.localeCompare(b.id));
|
|
235
|
+
return cards;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Stream every manifest passage from <kb>/<store>.passages.jsonl, cheap-prefiltered by substring. */
|
|
239
|
+
export async function* manifestPassages(kbDir, stores) {
|
|
240
|
+
for (const store of stores) {
|
|
241
|
+
const file = path.join(kbDir, `${store}.passages.jsonl`);
|
|
242
|
+
if (!fs.existsSync(file)) continue;
|
|
243
|
+
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
244
|
+
for await (const line of rl) {
|
|
245
|
+
const manifest = line.includes('"text":"npm package: ') || line.includes('"text":"Rust crate / manifest: ');
|
|
246
|
+
// README passages ride along (first chunk per path only): a package directory's own README is
|
|
247
|
+
// the manifest's richer, still-grounded description of WHAT IT IS FOR.
|
|
248
|
+
if (!manifest && !/"path":"[^"]*README\.md"/i.test(line)) continue;
|
|
249
|
+
let row;
|
|
250
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
251
|
+
if (typeof row?.text === 'string') yield { store, text: row.text, path: row.path, readme: !manifest };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readJson(file, fallback) {
|
|
257
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The corpus identity this snapshot was derived from, so a reader can tell how old it is. */
|
|
261
|
+
function corpusIdentity(kbDir) {
|
|
262
|
+
const src = readJson(path.join(kbDir, 'SOURCE.json'), {});
|
|
263
|
+
return { builtUtc: src.builtUtc || null, releaseTag: src.releaseTag || null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The package directory's own README, first prose only (no code, badges, tables), capped. */
|
|
267
|
+
export function readmeExcerpt(text, limit = 700) {
|
|
268
|
+
const out = [];
|
|
269
|
+
let fenced = false;
|
|
270
|
+
for (const raw of String(text || '').split('\n')) {
|
|
271
|
+
const t = raw.trim();
|
|
272
|
+
if (t.startsWith('```')) { fenced = !fenced; continue; }
|
|
273
|
+
if (fenced || !t || /^(#|<|\||!\[|\[!\[|---|>)/.test(t)) continue;
|
|
274
|
+
out.push(t.replace(/\*\*|`/g, ''));
|
|
275
|
+
if (out.join(' ').length > limit) break;
|
|
276
|
+
}
|
|
277
|
+
return out.join(' ').replace(/\s+/g, ' ').slice(0, limit).trim();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function attachReadme(card, readmes) {
|
|
281
|
+
const dir = path.posix.dirname(card.source);
|
|
282
|
+
const text = readmes.get(`${dir}/README.md`) || readmes.get(`${dir}/readme.md`);
|
|
283
|
+
const excerpt = text ? readmeExcerpt(text) : '';
|
|
284
|
+
return excerpt.length >= 80 ? { ...card, readme: excerpt, readmeSource: `${dir}/README.md` } : card;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Store → ingest tier from data/registry.tiers.json (T0 = flagship … T3 = archive), lowercased. */
|
|
288
|
+
export function storeTiers(registry) {
|
|
289
|
+
const out = {};
|
|
290
|
+
for (const [tier, v] of Object.entries(registry?.tiers || {})) {
|
|
291
|
+
for (const r of v?.repos || []) out[String(r.repo || r.name || '').toLowerCase()] = tier;
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function generate({ kbDir, cardsMd, privateStores, owners, tiers = {} }) {
|
|
297
|
+
const publicStores = publicStoresFrom(cardsMd, privateStores);
|
|
298
|
+
const stores = [...publicStores].filter((s) => fs.existsSync(path.join(kbDir, `${s}.passages.jsonl`)));
|
|
299
|
+
const rows = [];
|
|
300
|
+
for await (const row of manifestPassages(kbDir, stores)) rows.push(row);
|
|
301
|
+
const manifests = rows.filter((r) => !r.readme);
|
|
302
|
+
const readmes = new Map();
|
|
303
|
+
for (const r of rows) if (r.readme && !readmes.has(`${r.store}/${r.path}`)) readmes.set(`${r.store}/${r.path}`, r.text);
|
|
304
|
+
const cards = buildCards(manifests, { publicStores, owners }).map((card) => attachReadme(card, readmes));
|
|
305
|
+
// Pre-compute the hook's scoring tokens with the hook's OWN tokenizer (imported, not copied), so a
|
|
306
|
+
// cold UserPromptSubmit process does not re-tokenize every card on every prompt.
|
|
307
|
+
const { cardTokenSets, TOKENIZER_VERSION } = await import('../plugin/scripts/package-recommender.mjs');
|
|
308
|
+
return {
|
|
309
|
+
schema: SCHEMA,
|
|
310
|
+
tokenizer: TOKENIZER_VERSION,
|
|
311
|
+
derivedFrom: { ...corpusIdentity(kbDir), manifestPassages: manifests.length, readmePassages: readmes.size, stores: stores.length },
|
|
312
|
+
grounding: 'Every field is copied from a manifest passage of a public store; see source + sourceSha256. t/s are derived scoring tokens.',
|
|
313
|
+
cards: deriveProducts(cards, { productNames: publicStores }).map((card) => ({ ...card, tier: tiers[card.store] || null, ...cardTokenSets(card) })),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Days between the snapshot's source corpus build and `now`; null when the snapshot carries none. */
|
|
318
|
+
export function snapshotAgeDays(doc, now = Date.now()) {
|
|
319
|
+
const t = Date.parse(doc?.derivedFrom?.builtUtc || '');
|
|
320
|
+
return Number.isFinite(t) ? (now - t) / 86_400_000 : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const isMain = (() => {
|
|
324
|
+
try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
325
|
+
catch { return false; }
|
|
326
|
+
})();
|
|
327
|
+
|
|
328
|
+
if (isMain) {
|
|
329
|
+
const arg = (n, d) => { const i = process.argv.indexOf(n); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
|
|
330
|
+
// STALENESS CHECK, no corpus needed (ADR-093 rev 2: the cards ship as a snapshot because sealing them
|
|
331
|
+
// into the nightly corpus would change the sealed inputs' contract). Run at release:
|
|
332
|
+
// node scripts/package-cards.mjs --max-age-days 14 → exit 1 when the snapshot's corpus is older.
|
|
333
|
+
if (process.argv.includes('--max-age-days')) {
|
|
334
|
+
const snap = readJson(arg('--out', DEFAULT_OUT), null);
|
|
335
|
+
const age = snapshotAgeDays(snap);
|
|
336
|
+
const max = Number(arg('--max-age-days', '14'));
|
|
337
|
+
console.log(`[package-cards] snapshot corpus ${snap?.derivedFrom?.builtUtc || 'unknown'} — ${age === null ? 'age unknown' : `${age.toFixed(1)} days old`} (limit ${max})`);
|
|
338
|
+
process.exit(age !== null && age <= max ? 0 : 1);
|
|
339
|
+
}
|
|
340
|
+
const { storeRoot } = await import('../kb/store-root.mjs');
|
|
341
|
+
const kbDir = arg('--kb', storeRoot());
|
|
342
|
+
const out = arg('--out', DEFAULT_OUT);
|
|
343
|
+
const cardsMd = fs.readFileSync(arg('--cards', path.join(ROOT, 'kb', 'capability-cards.md')), 'utf8');
|
|
344
|
+
const privateStores = readJson(path.join(ROOT, 'kb', 'PRIVATE-STORES.json'), {}).privateStores || [];
|
|
345
|
+
const owners = Object.fromEntries(Object.entries(readJson(path.join(ROOT, 'kb', 'package-owners.json'), {}))
|
|
346
|
+
.filter(([k, v]) => !k.startsWith('/') && typeof v === 'string').map(([k, v]) => [k.toLowerCase(), v]));
|
|
347
|
+
const tiers = storeTiers(readJson(path.join(ROOT, 'data', 'registry.tiers.json'), {}));
|
|
348
|
+
const doc = await generate({ kbDir, cardsMd, privateStores, owners, tiers });
|
|
349
|
+
// One card per line: compact enough to keep the hook's cold parse small, line-diffable in review.
|
|
350
|
+
const { cards, ...header } = doc;
|
|
351
|
+
const text = `${JSON.stringify(header).slice(0, -1)},"cards":[\n${cards.map((c) => JSON.stringify(c)).join(',\n')}\n]}\n`;
|
|
352
|
+
console.log(`[package-cards] ${doc.cards.length} cards (${doc.cards.filter((c) => c.readme).length} with README) from ${doc.derivedFrom.manifestPassages} manifest passages in ${doc.derivedFrom.stores} public stores (corpus ${doc.derivedFrom.builtUtc || 'unknown'})`);
|
|
353
|
+
if (process.argv.includes('--check')) {
|
|
354
|
+
const prev = readJson(out, null);
|
|
355
|
+
const ids = (d) => (d?.cards || []).map((c) => `${c.id}@${c.sourceSha256}`).join('\n');
|
|
356
|
+
if (ids(prev) !== ids(doc)) { console.error(`[package-cards] ${out} is stale against ${kbDir}`); process.exit(1); }
|
|
357
|
+
console.log('[package-cards] snapshot current');
|
|
358
|
+
} else if (process.argv.includes('--write')) {
|
|
359
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
360
|
+
const tmp = `${out}.tmp.${process.pid}`;
|
|
361
|
+
fs.writeFileSync(tmp, text);
|
|
362
|
+
fs.renameSync(tmp, out);
|
|
363
|
+
console.log(`[package-cards] wrote ${out} (${text.length} bytes)`);
|
|
364
|
+
}
|
|
365
|
+
if (process.argv.includes('--embed')) {
|
|
366
|
+
// The semantic lane's RVF beside the card file (ADR-093 rev 2). Needs the bge-base embedder:
|
|
367
|
+
// KB_MODEL_CACHE + XENOVA_PATH (or a resolvable @xenova/transformers). Ids index the card array,
|
|
368
|
+
// and package-cards.rvf.meta.json binds the vectors to these exact card bytes.
|
|
369
|
+
const { buildCardIndex } = await import('../kb/package-cards-index.mjs');
|
|
370
|
+
const t0 = Date.now();
|
|
371
|
+
const r = await buildCardIndex(path.dirname(out));
|
|
372
|
+
console.log(`[package-cards] embedded ${r.vectors} vectors into ${r.rvf} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -16,7 +16,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
16
16
|
// post-publication proofs below (payload assertions, MCP wiring, SOURCE.json, rpcSearch)
|
|
17
17
|
// stay here — they are this side's job, not duplication.
|
|
18
18
|
import { HOST_MODES, RECEIPT_MODE_NAMES, MODE_FROM_RECEIPT_NAME, classifyDoctor, VARIANTS,
|
|
19
|
-
createInstalledMcpSession, HOST_WARMUP_TIMEOUT_MS, RELEASE_SEARCH_DEADLINE_MS, SELF_STORE_PROOF_QUERY,
|
|
19
|
+
canarySearchDeadlineMs, createInstalledMcpSession, createRestartingMcpSession, HOST_WARMUP_TIMEOUT_MS, RELEASE_SEARCH_DEADLINE_MS, SELF_STORE_PROOF_QUERY,
|
|
20
20
|
SELF_STORE_PROOF_K } from './host-install-matrix.mjs';
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
22
|
import { evaluateCandidateReceipt, evaluatePublicationReceipt } from './release-proof.mjs';
|
|
@@ -48,6 +48,34 @@ export async function runMeasuredHostSearches(hosts, search, { warmup, after } =
|
|
|
48
48
|
return results;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// One doctor at a time, for the same reason as the searches above. Each doctor is a fresh reader
|
|
52
|
+
// that loads both models and cross-encodes its own pool; three at once on the 3-vCPU / 7 GB macOS
|
|
53
|
+
// runner, beside the resident dual worker, is what timed out 4.4.1's probes in phase "rerank" at
|
|
54
|
+
// 45s (run 36877770786: all three started within 0.25s) while the same probe alone is far inside
|
|
55
|
+
// its deadline. A customer runs one doctor, so the concurrency was the harness's, not the product's.
|
|
56
|
+
// Every host still runs, so one slow lane cannot hide another's result; the failure names them all.
|
|
57
|
+
// And this settles only after the LAST doctor exits: under Promise.all the first exit 1 rejected the
|
|
58
|
+
// lane, whose finally deleted the install temp while the third doctor was still reading it — the
|
|
59
|
+
// "bundle predates the citation verifier" and six plugin-manifest ENOENT lines in that same run.
|
|
60
|
+
export async function runHostDoctors(hosts, doctor) {
|
|
61
|
+
const failures = [];
|
|
62
|
+
for (const host of hosts) {
|
|
63
|
+
try { await doctor(host); } catch (error) { failures.push({ mode: host.mode, error }); }
|
|
64
|
+
}
|
|
65
|
+
if (failures.length) {
|
|
66
|
+
throw new Error(`installed doctor failed for ${failures.map(({ mode }) => mode).join(', ')}: ${failures[0].error.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One warm installed worker per host, reopened (and re-warmed on the warm-up bound) only after a
|
|
71
|
+
// failed search, so one slow canary case cannot turn every later case into a false timeout.
|
|
72
|
+
function openHostSession(serverPath, env) {
|
|
73
|
+
return createRestartingMcpSession({
|
|
74
|
+
open: () => createInstalledMcpSession({ serverPath, env, timeout: WARMUP_TIMEOUT_MS }),
|
|
75
|
+
warm: (fresh) => fresh.search({ query: SELF_STORE_PROOF_QUERY, k: SELF_STORE_PROOF_K, timeoutMs: WARMUP_TIMEOUT_MS }),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
51
79
|
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
52
80
|
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
81
|
const receiptDigest = (candidate) => String(candidate?.artifact?.sha256 || '').replace(/^sha256:/, '');
|
|
@@ -570,9 +598,7 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
570
598
|
}
|
|
571
599
|
const publicMode = MODE_FROM_RECEIPT_NAME[mode];
|
|
572
600
|
if (!mcpSessions.has(publicMode)) {
|
|
573
|
-
mcpSessions.set(publicMode,
|
|
574
|
-
serverPath: findMcpServer(context.home), env: context.env, timeout: WARMUP_TIMEOUT_MS,
|
|
575
|
-
}));
|
|
601
|
+
mcpSessions.set(publicMode, openHostSession(findMcpServer(context.home), context.env));
|
|
576
602
|
}
|
|
577
603
|
// The generous bound is for one-time readiness/model warmup only; the following
|
|
578
604
|
// measured query still has the unchanged strict 30-second deadline.
|
|
@@ -588,11 +614,11 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
588
614
|
},
|
|
589
615
|
});
|
|
590
616
|
for (const [mode, result] of measuredSearches) searched.set(mode, result);
|
|
591
|
-
await
|
|
617
|
+
await runHostDoctors(hostResults, async ({ context, installer }) => {
|
|
592
618
|
await commandAsync(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
593
619
|
env: context.env, cwd: packageRoot, timeout: 300_000, stdio: 'inherit',
|
|
594
620
|
});
|
|
595
|
-
})
|
|
621
|
+
});
|
|
596
622
|
|
|
597
623
|
for (const { mode, verified, context } of hostResults) {
|
|
598
624
|
const search = searched.get(mode);
|
|
@@ -644,7 +670,7 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
644
670
|
}
|
|
645
671
|
let session = mcpSessions.get(mode);
|
|
646
672
|
if (!session) {
|
|
647
|
-
session =
|
|
673
|
+
session = openHostSession(findMcpServer(context.home), context.env);
|
|
648
674
|
mcpSessions.set(mode, session);
|
|
649
675
|
const warmed = await session.search({
|
|
650
676
|
query: SELF_STORE_PROOF_QUERY, k: SELF_STORE_PROOF_K, timeoutMs: WARMUP_TIMEOUT_MS,
|
|
@@ -653,7 +679,9 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
653
679
|
throw new Error(`installed Brain warmup failed for ${mode}: ${warmed.error?.message || 'no MCP result'}`);
|
|
654
680
|
}
|
|
655
681
|
}
|
|
656
|
-
|
|
682
|
+
// Canary cases take the per-OS first-pass bound (host-install-matrix.mjs, measured + derived).
|
|
683
|
+
const canaryDeadlineMs = canarySearchDeadlineMs();
|
|
684
|
+
const result = await session.search({ query, k, timeoutMs: canaryDeadlineMs });
|
|
657
685
|
if (result.error || !result.mcpResult || (Object.hasOwn(result, 'status') && result.status !== 0)) {
|
|
658
686
|
// Same guard as warmupInstalled() above and host-install-matrix.mjs's equivalent canary
|
|
659
687
|
// search: without it, a real timeout on this full-corpus search reaches
|
|
@@ -661,7 +689,7 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
661
689
|
// incompatible MCP response lacks structured retrieval results" — masking a timeout as a
|
|
662
690
|
// data-shape error. This is the release blocker traced from v4.3.28's public verification
|
|
663
691
|
// failure ("retrieval canary acceptance failed for claude", run 35559726522).
|
|
664
|
-
throw new Error(`installed Brain search failed for ${mode} (query="${query}"): ${result.error?.message || `no MCP result within ${
|
|
692
|
+
throw new Error(`installed Brain search failed for ${mode} (query="${query}"): ${result.error?.message || `no MCP result within ${canaryDeadlineMs}ms`}`);
|
|
665
693
|
}
|
|
666
694
|
return parseRetrievalResult(result.mcpResult, { query, k });
|
|
667
695
|
},
|