ruvnet-brain 4.4.1 → 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 +612 -113
- 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 +17 -2
- 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 +4 -2
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +27 -3
- 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-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/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,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
|
},
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* recommendation-e2e.mjs — the package recommender AS SHIPPED, end to end (ADR-093 rev 2).
|
|
4
|
+
*
|
|
5
|
+
* 1. Spawns the real search worker (kb/forge-mcp-all.mjs) in a throwaway brain home with the flag on,
|
|
6
|
+
* and runs its real `brain/warmup`, which starts the recommend endpoint.
|
|
7
|
+
* 2. For every eval prompt, runs the real hook producer (plugin/scripts/advocacy-route.mjs) as a
|
|
8
|
+
* cold process — flag OFF then flag ON, interleaved — and records wall time and the exact hint
|
|
9
|
+
* the host model would receive.
|
|
10
|
+
* 3. Writes a judge packet (qid, prompt, hint — no labels) and a separate key, so a model standing in
|
|
11
|
+
* for the host can decide what it would say without seeing the answers.
|
|
12
|
+
*
|
|
13
|
+
* Nothing touches the real ~/.claude, ~/.codex or ~/.cache/ruvnet-brain: HOME and RUVNET_BRAIN_HOME
|
|
14
|
+
* are a temp dir. The embedder needs a model cache and @xenova/transformers:
|
|
15
|
+
* --models <dir> (KB_MODEL_CACHE) --xenova <dir> (XENOVA_PATH)
|
|
16
|
+
* Refuses to measure when the 1-minute load average exceeds --max-load (default 60).
|
|
17
|
+
*
|
|
18
|
+
* node scripts/recommendation-e2e.mjs --models <d> --xenova <d> --out <dir> [--set f.json]... [--max-load 60] [--budget ms]
|
|
19
|
+
*
|
|
20
|
+
* --budget raises the hook's semantic budget for a QUALITY run (what the lane says when it answers);
|
|
21
|
+
* the default-budget run is the LATENCY run (how often, and how fast, it answers under its real limit).
|
|
22
|
+
*/
|
|
23
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import readline from 'node:readline';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
31
|
+
const arg = (n, d) => { const i = process.argv.indexOf(n); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
|
|
32
|
+
const sets = process.argv.flatMap((a, i) => (a === '--set' ? [process.argv[i + 1]] : []));
|
|
33
|
+
const outDir = arg('--out', null);
|
|
34
|
+
const maxLoad = Number(arg('--max-load', '60'));
|
|
35
|
+
if (!outDir) { console.error('--out <dir> is required'); process.exit(2); }
|
|
36
|
+
if (os.loadavg()[0] > maxLoad) { console.error(`load average ${os.loadavg()[0].toFixed(1)} > ${maxLoad}; refusing to measure`); process.exit(3); }
|
|
37
|
+
|
|
38
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'reco-e2e-'));
|
|
39
|
+
const kb = path.join(home, 'kb');
|
|
40
|
+
fs.mkdirSync(kb);
|
|
41
|
+
const base = {
|
|
42
|
+
PATH: process.env.PATH, HOME: home, USERPROFILE: home, RUVNET_BRAIN_HOME: home, RUVNET_HOME_OVERRIDE: home,
|
|
43
|
+
KB_DIR: kb, KB_MODEL_CACHE: arg('--models', ''), XENOVA_PATH: arg('--xenova', ''),
|
|
44
|
+
};
|
|
45
|
+
const items = (sets.length ? sets : ['evals/recommendation-eval.v1.json'])
|
|
46
|
+
.flatMap((f) => JSON.parse(fs.readFileSync(path.resolve(ROOT, f), 'utf8')).items.map((i) => ({ ...i, set: path.basename(f) })));
|
|
47
|
+
|
|
48
|
+
const worker = spawn(process.execPath, [path.join(ROOT, 'kb', 'forge-mcp-all.mjs')],
|
|
49
|
+
{ env: { ...base, RUVNET_PACKAGE_RECOMMENDER: '1' }, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
50
|
+
const rl = readline.createInterface({ input: worker.stdout });
|
|
51
|
+
const waiters = new Map();
|
|
52
|
+
rl.on('line', (l) => { try { const m = JSON.parse(l); waiters.get(m.id)?.(m); } catch { /* not ours */ } });
|
|
53
|
+
const call = (id, method) => new Promise((r) => { waiters.set(id, r); worker.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params: {} })}\n`); });
|
|
54
|
+
|
|
55
|
+
function produce(prompt, flag, n) {
|
|
56
|
+
const env = {
|
|
57
|
+
...base, RUVNET_EMIT_CANDIDATES: '1',
|
|
58
|
+
RUVNET_ADVOCACY_ROUTE_STATE: path.join(home, `state-${flag}-${n}.json`),
|
|
59
|
+
RUVNET_ADVOCACY_OUTCOMES: path.join(home, `outcomes-${flag}-${n}.jsonl`),
|
|
60
|
+
RUVNET_ADVOCACY_ROUTE_ROOTS: path.join(home, 'none'),
|
|
61
|
+
...(flag === 'on' ? { RUVNET_PACKAGE_RECOMMENDER: '1' } : {}),
|
|
62
|
+
...(arg('--budget', null) ? { RUVNET_PACKAGE_RECOMMENDER_BUDGET_MS: arg('--budget', null) } : {}),
|
|
63
|
+
// --floor 0 records every hint so a floor can be derived afterwards on the tuning set only.
|
|
64
|
+
...(arg('--floor', null) !== null ? { RUVNET_PACKAGE_RECOMMENDER_MIN_SIMILARITY: arg('--floor', null) } : {}),
|
|
65
|
+
};
|
|
66
|
+
const t = process.hrtime.bigint();
|
|
67
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, 'plugin', 'scripts', 'advocacy-route.mjs')],
|
|
68
|
+
{ input: JSON.stringify({ session_id: `e2e-${flag}-${n}`, prompt }), env, encoding: 'utf8', timeout: 10000 });
|
|
69
|
+
const ms = Number(process.hrtime.bigint() - t) / 1e6;
|
|
70
|
+
let cand = null;
|
|
71
|
+
try { cand = r.stdout.trim() ? JSON.parse(r.stdout.trim()) : null; } catch { cand = null; }
|
|
72
|
+
return { ms, status: r.status, cand };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const q = (a, p) => { const s = [...a].sort((x, y) => x - y); return +s[Math.min(s.length - 1, Math.ceil(p * s.length) - 1)].toFixed(1); };
|
|
76
|
+
try {
|
|
77
|
+
await call(1, 'initialize');
|
|
78
|
+
const warm = await call(2, 'brain/warmup');
|
|
79
|
+
if (!warm.result?.ready) throw new Error(`warmup failed: ${JSON.stringify(warm.error || warm.result)}`);
|
|
80
|
+
for (let i = 0; i < 50 && !fs.existsSync(path.join(home, 'run')); i++) await new Promise((r) => setTimeout(r, 100));
|
|
81
|
+
produce('warm the page cache once before measuring', 'on', 'warm');
|
|
82
|
+
const packet = []; const key = []; const off = []; const on = []; const added = [];
|
|
83
|
+
items.forEach((it, i) => {
|
|
84
|
+
const a = produce(it.prompt, 'off', i);
|
|
85
|
+
const b = produce(it.prompt, 'on', i);
|
|
86
|
+
off.push(a.ms); on.push(b.ms); added.push(b.ms - a.ms);
|
|
87
|
+
const qid = `Q${String(i + 1).padStart(3, '0')}`;
|
|
88
|
+
key.push({ qid, set: it.set, id: it.id, category: it.category, accept: it.accept || [], acceptStores: it.acceptStores || [],
|
|
89
|
+
lane: b.cand ? (b.cand.candidates ? 'semantic' : String(b.cand.findingId).startsWith('recommend:pkg:') ? 'lexical' : 'catalogue') : null,
|
|
90
|
+
offered: b.cand ? (b.cand.candidates || [b.cand.package || b.cand.capability]) : [], status: b.status,
|
|
91
|
+
topSimilarity: Array.isArray(b.cand?.similarities) ? b.cand.similarities[0] : null });
|
|
92
|
+
if (b.cand) packet.push({ qid, prompt: it.prompt, hint: b.cand.copy });
|
|
93
|
+
});
|
|
94
|
+
const timing = {
|
|
95
|
+
samples: items.length, loadAvg1m: +os.loadavg()[0].toFixed(1), host: `${os.platform()} ${os.arch()} ${os.cpus().length} vCPU node ${process.version}`,
|
|
96
|
+
flagOffMs: { p50: q(off, 0.5), p90: q(off, 0.9) }, flagOnMs: { p50: q(on, 0.5), p90: q(on, 0.9), max: q(on, 1) },
|
|
97
|
+
addedMs: { p50: q(added, 0.5), p90: q(added, 0.9), max: q(added, 1) },
|
|
98
|
+
};
|
|
99
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
100
|
+
fs.writeFileSync(path.join(outDir, 'judge-packet.json'), JSON.stringify(packet.sort(() => 0), null, 1));
|
|
101
|
+
fs.writeFileSync(path.join(outDir, 'judge-key.json'), JSON.stringify(key, null, 1));
|
|
102
|
+
fs.writeFileSync(path.join(outDir, 'timing.json'), JSON.stringify(timing, null, 1));
|
|
103
|
+
console.log(JSON.stringify(timing));
|
|
104
|
+
console.log(`hints emitted for ${packet.length}/${items.length} prompts; lanes: ${JSON.stringify(key.reduce((m, k) => ({ ...m, [k.lane]: (m[k.lane] || 0) + 1 }), {}))}`);
|
|
105
|
+
} finally {
|
|
106
|
+
worker.kill('SIGTERM');
|
|
107
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
108
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
process.exit(0);
|