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
|
@@ -48,7 +48,7 @@ import { inspectSessionSnapshots } from './session-snapshot-contract.mjs';
|
|
|
48
48
|
import { learnings } from './learnings.mjs';
|
|
49
49
|
import { gatesSurvey } from './gates.mjs';
|
|
50
50
|
// The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why.
|
|
51
|
-
import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
|
|
51
|
+
import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, revertSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
|
|
52
52
|
// The brain on/off switch (ADR-054). The sentinel is the enforcement artifact; settings.json holds
|
|
53
53
|
// only a mirror. The console is the ONE surface allowed to flip it — protect-brain-state.sh walls
|
|
54
54
|
// the file off from agent edits — so both halves of the write live here, in saveBrainPower().
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
openRouterCredentialStatus,
|
|
69
69
|
saveOpenRouterCredential,
|
|
70
70
|
learnerCwd,
|
|
71
|
+
loadRuntimePreferences,
|
|
71
72
|
} from '../plugin/scripts/runtime-preferences.mjs';
|
|
72
73
|
import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
|
|
73
74
|
// One canonical answer to "which directory is this, and have I counted it already?" — shared with
|
|
@@ -770,6 +771,30 @@ function gatherInventory() {
|
|
|
770
771
|
};
|
|
771
772
|
}
|
|
772
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Keys a project-level <project>/.swarm/ruvnet-brain-settings.json overrides FOR THE RUNTIME THAT
|
|
776
|
+
* READS IT. runtime-preferences merges that file over the user-level choices, and route-cheap, the
|
|
777
|
+
* managed-CLI gate (routing, qeFleet) and learn-capture/learn-flush (learningScope) obey the merge.
|
|
778
|
+
* The console saves user-level values only, so without this a project seeded by "Apply these choices
|
|
779
|
+
* to new projects" silently ignored every later change made here (RNBC QA 2026-10-01). Only keys whose
|
|
780
|
+
* consumer honours the project file are reported — advocacy, autoApply and provider are read at user
|
|
781
|
+
* level by their consumers, so a project value for them changes nothing and is not claimed to.
|
|
782
|
+
*/
|
|
783
|
+
const PROJECT_HONOURED_KEYS = Object.freeze(['routing', 'qeFleet', 'learningScope']);
|
|
784
|
+
function projectOverrides(keys) {
|
|
785
|
+
try {
|
|
786
|
+
const prefs = loadRuntimePreferences({ cwd: process.cwd() });
|
|
787
|
+
if (!prefs.projectInherited) return null;
|
|
788
|
+
const raw = readJSON(prefs.paths.project) || {};
|
|
789
|
+
const values = raw.values && typeof raw.values === 'object' ? raw.values : raw;
|
|
790
|
+
const out = {};
|
|
791
|
+
for (const key of keys) {
|
|
792
|
+
if (PROJECT_HONOURED_KEYS.includes(key) && Object.hasOwn(values, key) && prefs.values[key] === values[key]) out[key] = values[key];
|
|
793
|
+
}
|
|
794
|
+
return Object.keys(out).length ? { path: prefs.paths.project.replace(CONSOLE_ROOT, '~'), values: out } : null;
|
|
795
|
+
} catch { return null; }
|
|
796
|
+
}
|
|
797
|
+
|
|
773
798
|
function gatherConfig() {
|
|
774
799
|
const cfg = readJSON(CONFIG_PATH) || {};
|
|
775
800
|
const credential = openRouterCredentialStatus({ cwd: process.cwd() });
|
|
@@ -797,6 +822,7 @@ function gatherConfig() {
|
|
|
797
822
|
// What the project would pick FOR you, kept separate from what you actually picked. The form can
|
|
798
823
|
// then say "recommended: on" without ever claiming that is the current state.
|
|
799
824
|
defaults: { provider: 'auto', nightly: true, routing: 'auto', qeFleet: false },
|
|
825
|
+
projectOverrides: projectOverrides(CONFIG_SCHEMA.map((field) => field.key)),
|
|
800
826
|
schema: CONFIG_SCHEMA.filter((field) =>
|
|
801
827
|
!Object.hasOwn(CONFIG_CONTROL_SUPPORT, field.key)
|
|
802
828
|
&& (field.key !== 'nightly' || schedule.artifact.supported)),
|
|
@@ -841,6 +867,7 @@ function gatherAdvocacy() {
|
|
|
841
867
|
Object.hasOwn(chosen, field.key) ? state.values[field.key] : null,
|
|
842
868
|
])),
|
|
843
869
|
defaults: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [field.key, field.default])),
|
|
870
|
+
projectOverrides: projectOverrides(LIVE_USER_SETTING_KEYS),
|
|
844
871
|
schema: LIVE_USER_FIELDS,
|
|
845
872
|
unavailable: [],
|
|
846
873
|
};
|
|
@@ -880,8 +907,19 @@ function saveAdvocacy(values) {
|
|
|
880
907
|
const result = saveSettings(supplied);
|
|
881
908
|
if (!result.ok) return { ok: false, rejected: result.errors || [], log: result.log };
|
|
882
909
|
publishSettingsToCache();
|
|
910
|
+
// THE SAVE IS REVERSIBLE FROM WHERE IT WAS MADE. The Settings card promises "every save is
|
|
911
|
+
// reversible", and saveSettings already writes the backup and the existedBefore flag its own
|
|
912
|
+
// revertSettings() consumes — but this form returned no undo token, so the console showed an Undo
|
|
913
|
+
// button for config.json and none here (RNBC QA 2026-10-01). Journalled only after the write.
|
|
914
|
+
const undoToken = journalUndo({
|
|
915
|
+
kind: 'restore-user-settings',
|
|
916
|
+
file: result.file,
|
|
917
|
+
backup: result.backup,
|
|
918
|
+
existedBefore: result.existedBefore,
|
|
919
|
+
});
|
|
883
920
|
return {
|
|
884
921
|
ok: true,
|
|
922
|
+
undoToken,
|
|
885
923
|
backup: result.backup ? result.backup.replace(CONSOLE_ROOT, '~') : null,
|
|
886
924
|
values: Object.fromEntries(LIVE_USER_SETTING_KEYS.map((key) => [key, result.values[key]])),
|
|
887
925
|
log: result.log,
|
|
@@ -1113,6 +1151,14 @@ function publishSettingsToCache() {
|
|
|
1113
1151
|
if (!c || !c.data || !c.data.sections) return;
|
|
1114
1152
|
c.data.sections.config = gatherConfig();
|
|
1115
1153
|
c.data.sections.userSettings = gatherAdvocacy();
|
|
1154
|
+
// The Savings card reads two of the same choices (routing, and whether an OpenRouter key exists).
|
|
1155
|
+
// Patching only Settings left the Savings card saying "No key added" / the old routing state on
|
|
1156
|
+
// the next reload, beside a Settings card saying the opposite (RNBC QA 2026-10-01).
|
|
1157
|
+
if (c.data.sections.savings) {
|
|
1158
|
+
const cfgNow = readJSON(CONFIG_PATH) || {};
|
|
1159
|
+
c.data.sections.savings.routing = cfgNow.routing === 'off' ? 'off' : cfgNow.routing === 'auto' ? 'auto' : null;
|
|
1160
|
+
try { c.data.sections.savings.routerEngine = gatherRouterEngine(); } catch { /* the refresh replaces it */ }
|
|
1161
|
+
}
|
|
1116
1162
|
writeCache(STATE_CACHE, new Date(0).toISOString(), c.data, c.scope ?? null);
|
|
1117
1163
|
} catch { /* the authoritative stores are already correct; refresh will replace an unreadable cache */ }
|
|
1118
1164
|
}
|
|
@@ -1199,7 +1245,13 @@ function gatherLessons() {
|
|
|
1199
1245
|
const trig = TRIGGER_BY_KEY.get(l.trigger);
|
|
1200
1246
|
const meaning = ENFORCEMENT_MEANING[l.enforcement] || { label: l.enforcement, detail: '' };
|
|
1201
1247
|
const userStated = l.origin === ORIGIN.USER_STATED && l.sourceClass === SOURCE_CLASS.CURRENT_USER;
|
|
1202
|
-
|
|
1248
|
+
// Quarantine is about whether history can BECOME policy, so it applies to an imported row that
|
|
1249
|
+
// was never ratified. A row that WAS ratified is delivered by lessonsFor() today whatever its
|
|
1250
|
+
// source class — RNBC QA 2026-10-01 measured 12 such rows filed here as "quarantined, cannot be
|
|
1251
|
+
// switched on" behind a checked, disabled box while the gate enforced every one of them. A rule in
|
|
1252
|
+
// force must be reported in force and must keep a working off switch.
|
|
1253
|
+
const importedClass = l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION;
|
|
1254
|
+
const quarantined = importedClass && l.status !== STATUS.RATIFIED && l.status !== STATUS.ACTIVE;
|
|
1203
1255
|
const origin = l.sourceClass === SOURCE_CLASS.CURRENT_USER
|
|
1204
1256
|
? 'you taught me this'
|
|
1205
1257
|
: l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER
|
|
@@ -1234,7 +1286,7 @@ function gatherLessons() {
|
|
|
1234
1286
|
// Honest ceiling: ratifying a model-inferred lesson can NOT raise it to block
|
|
1235
1287
|
// (lesson-store.mjs:380). Say so before they click, not after.
|
|
1236
1288
|
canReachBlock: userStated,
|
|
1237
|
-
canRatify: !
|
|
1289
|
+
canRatify: !importedClass,
|
|
1238
1290
|
intendedEnforcement: l.intendedEnforcement || null,
|
|
1239
1291
|
};
|
|
1240
1292
|
});
|
|
@@ -1969,8 +2021,12 @@ function gatherRouterEngine() {
|
|
|
1969
2021
|
const cfg = readJSON(CONFIG_PATH) || {};
|
|
1970
2022
|
// User-constraint detection (Brain-side by design — a fact about THIS user, not routing logic):
|
|
1971
2023
|
// an OpenRouter key decides whether metered cross-provider candidates are even reachable.
|
|
1972
|
-
|
|
1973
|
-
|
|
2024
|
+
// The SAME reader the Settings card uses (env → SOPS+age store → legacy plaintext). This read only
|
|
2025
|
+
// env + plaintext, so a key saved through Settings (encrypted, plaintext retired) showed "No key
|
|
2026
|
+
// added" here under a Settings row saying "•••• set" (RNBC QA 2026-10-01).
|
|
2027
|
+
let openrouterKey = false;
|
|
2028
|
+
try { openrouterKey = openRouterCredentialStatus({ cwd: process.cwd() }).configured === true; }
|
|
2029
|
+
catch { openrouterKey = !!process.env.OPENROUTER_API_KEY || !!(cfg.openrouterKey && String(cfg.openrouterKey).length > 8); }
|
|
1974
2030
|
// House (issue #21): three mechanisms used to disagree — Settings wrote config.json's `provider`,
|
|
1975
2031
|
// but the chip strip derived "yours" from whichever pool candidate happened to be
|
|
1976
2032
|
// subscriptionCovered first, sourced from profile.json (a file nothing in the console writes). The
|
|
@@ -2383,7 +2439,7 @@ function gatherStack() {
|
|
|
2383
2439
|
const rows = a.rows.map((r) => ({ name: r.name, installed: r.installed, target: r.target, tag: r.tag, state: r.state, source: r.source ?? 'npm-global', marketplace: r.marketplace ?? null }));
|
|
2384
2440
|
const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(SYSTEM_HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
|
|
2385
2441
|
const by = (st) => rows.filter((r) => r.state === st).length;
|
|
2386
|
-
const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), shadows: shadows.length, stale: a.stale.length };
|
|
2442
|
+
const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), unverified: by('INSTALLED_UNVERIFIED'), shadows: shadows.length, stale: a.stale.length };
|
|
2387
2443
|
const recommendations = buildStackRecommendations({ rows: a.rows, stale: a.stale });
|
|
2388
2444
|
const result = { error: a.error, packages: rows, shadows, summary, recommendations };
|
|
2389
2445
|
// Cache the last good audit so repeat page-loads render instantly ("as of HH:MM — re-checking").
|
|
@@ -2708,7 +2764,12 @@ function saveConfig(values) {
|
|
|
2708
2764
|
credentialChange = saveOpenRouterCredential(requestedSecret, { cwd: process.cwd() });
|
|
2709
2765
|
if (!credentialChange.ok) return { ok: false, rejected, log: credentialChange.log };
|
|
2710
2766
|
}
|
|
2711
|
-
|
|
2767
|
+
// The page sends every field the person has ever chosen, so changing only the model house carries the
|
|
2768
|
+
// already-saved nightly value with it. Re-running the installer for a choice the scheduler already
|
|
2769
|
+
// satisfies rewrote the plist and re-registered the runner on every unrelated save (RNBC review
|
|
2770
|
+
// 2026-10-01). Only a request that differs from the measured scheduler state is a scheduler change; a
|
|
2771
|
+
// degraded or unknown state still goes to the installer, which is how it gets repaired.
|
|
2772
|
+
if (requestedNightly !== undefined && nightlyStatus().state !== (requestedNightly ? 'on' : 'off')) {
|
|
2712
2773
|
nightlyChange = applyNightlyChoice(requestedNightly);
|
|
2713
2774
|
if (!nightlyChange.ok) {
|
|
2714
2775
|
rollbackCredential();
|
|
@@ -2809,6 +2870,10 @@ function undo(undoToken) {
|
|
|
2809
2870
|
if (!fs.existsSync(UNDO_JOURNAL)) return { ok: false, log: 'no undo history' };
|
|
2810
2871
|
const journal = readUndoJournal();
|
|
2811
2872
|
const entry = journal.find((e) => e.token === undoToken);
|
|
2873
|
+
// "Saved again after this point" is decided by POSITION in the append-only journal, never by the `at`
|
|
2874
|
+
// stamp: it has millisecond resolution and two saves do land in the same millisecond (a Linux runner,
|
|
2875
|
+
// 2026-10-01), which made the later save invisible and let a stale undo wipe it.
|
|
2876
|
+
const savedLater = (kind) => journal.slice(journal.indexOf(entry) + 1).some((e) => e.kind === kind && e.token && e.token !== undoToken);
|
|
2812
2877
|
if (!entry) return { ok: false, log: 'that undo token was not found' };
|
|
2813
2878
|
|
|
2814
2879
|
// ONE UNDO, ONCE. The token was never consumed, so the same button replayed forever: clicking it
|
|
@@ -2829,7 +2894,7 @@ function undo(undoToken) {
|
|
|
2829
2894
|
// An undo can only speak for the last write. If something was written after it, the honest answer
|
|
2830
2895
|
// is to refuse and say so — restoring anyway would be destroying newer data while claiming to
|
|
2831
2896
|
// protect older data.
|
|
2832
|
-
const laterSave =
|
|
2897
|
+
const laterSave = savedLater('restore-config');
|
|
2833
2898
|
if (laterSave) {
|
|
2834
2899
|
return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save, or restore a backup by hand.' };
|
|
2835
2900
|
}
|
|
@@ -2869,6 +2934,19 @@ function undo(undoToken) {
|
|
|
2869
2934
|
}
|
|
2870
2935
|
return { ok: false, log: 'no backup available to restore' };
|
|
2871
2936
|
}
|
|
2937
|
+
if (entry.kind === 'restore-user-settings') {
|
|
2938
|
+
// Same "an undo speaks only for the last write" rule as restore-config: a later save through this
|
|
2939
|
+
// form would be wiped out by restoring an older backup.
|
|
2940
|
+
const laterSave = savedLater('restore-user-settings');
|
|
2941
|
+
if (laterSave) {
|
|
2942
|
+
return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save.' };
|
|
2943
|
+
}
|
|
2944
|
+
const r = revertSettings({ file: entry.file, backup: entry.backup || undefined, existedBefore: entry.existedBefore });
|
|
2945
|
+
if (!r.ok) return { ok: false, log: r.log };
|
|
2946
|
+
markUndoConsumed(undoToken);
|
|
2947
|
+
publishSettingsToCache();
|
|
2948
|
+
return { ok: true, log: entry.backup ? 'restored your previous settings' : 'removed the settings file (there was none before this save)' };
|
|
2949
|
+
}
|
|
2872
2950
|
// EVERY branch below marks its token consumed on success, for the reason spelled out on the
|
|
2873
2951
|
// restore-config branch above: these all copy a saved snapshot over a live file, so replaying one
|
|
2874
2952
|
// re-applies an old state over whatever the user has done since. The replay guard at the top of
|
|
@@ -2953,7 +3031,7 @@ function undo(undoToken) {
|
|
|
2953
3031
|
// The undo kinds this function actually implements. Exported so the closure test can check the
|
|
2954
3032
|
// registry against the REAL handler set rather than a hand-copied list that would drift from it.
|
|
2955
3033
|
export const HANDLED_UNDO_KINDS = Object.freeze([
|
|
2956
|
-
'restore-config', 'reinstall-version', 'restore-backup',
|
|
3034
|
+
'restore-config', 'restore-user-settings', 'reinstall-version', 'restore-backup',
|
|
2957
3035
|
'restore-memory-backup', 'restore-store-backups', 'restore-project-distill', 'auto-rebuild', 'none',
|
|
2958
3036
|
]);
|
|
2959
3037
|
|
|
@@ -2962,7 +3040,10 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; cha
|
|
|
2962
3040
|
function serveStatic(req, res) {
|
|
2963
3041
|
const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '') || 'index.html';
|
|
2964
3042
|
const file = path.join(CONSOLE_DIR, rel);
|
|
2965
|
-
|
|
3043
|
+
// Containment by path, not by string prefix: `startsWith(CONSOLE_DIR)` also accepted a sibling such
|
|
3044
|
+
// as `<root>/console-anything/…` reached with an encoded `..` (RNBC review 2026-10-01).
|
|
3045
|
+
const inside = path.relative(CONSOLE_DIR, file);
|
|
3046
|
+
if (!inside || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
|
|
2966
3047
|
let body = fs.readFileSync(file);
|
|
2967
3048
|
const ext = path.extname(file);
|
|
2968
3049
|
if (ext === '.html') body = Buffer.from(String(body).replace('</head>', `<script>window.__CONSOLE_TOKEN__=${JSON.stringify(TOKEN)}</script></head>`));
|
|
@@ -3444,6 +3525,8 @@ export {
|
|
|
3444
3525
|
saveBrainPower,
|
|
3445
3526
|
gatherBrainProfile,
|
|
3446
3527
|
saveBrainProfile,
|
|
3528
|
+
setLesson,
|
|
3529
|
+
gatherLessons,
|
|
3447
3530
|
gatherRouterEngine,
|
|
3448
3531
|
autoEligibleIds,
|
|
3449
3532
|
gatherConfig,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/oracle/abstain-threshold-sweep.mjs — what would moving the abstain threshold do?
|
|
4
|
+
*
|
|
5
|
+
* The product abstains when the top cross-encoder logit is below 0. The threshold is applied AFTER
|
|
6
|
+
* scoring, so replaying a measured run at another threshold t is exact for the top citation: no
|
|
7
|
+
* model is re-run. For each t it reports, with 95% Wilson intervals:
|
|
8
|
+
* (abstained = no citation, or a numeric top logit < t -- the rule both harnesses apply)
|
|
9
|
+
* needs confident hits (gold or pre-registered alternative within 5 AND top logit >= t),
|
|
10
|
+
* confident misses (top logit >= t but neither within 5), their precision, and how
|
|
11
|
+
* many confident answers at least cite the gold repository first
|
|
12
|
+
* offTopic adversarial questions still abstained (no citation, or top logit < t)
|
|
13
|
+
* heldOut routed passes of the named/described/scenario strata (grounded AND routed AND
|
|
14
|
+
* a citation with logit >= t), the gated eval-brain metric
|
|
15
|
+
*
|
|
16
|
+
* node scripts/oracle/abstain-threshold-sweep.mjs --needs <needs.json> --adversarial <adversarial.json>
|
|
17
|
+
* --heldout <heldout.json> [--thresholds 0,-1,-2,-3,-4] [--out <file>]
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { wilson } from '../eval-brain.mjs';
|
|
23
|
+
|
|
24
|
+
const rate = (k, n) => { const w = wilson(k, n); return { k, n, p: +w.p.toFixed(4), lo: +w.lo.toFixed(4), hi: +w.hi.toFixed(4) }; };
|
|
25
|
+
const within5 = (r) => (r.fileRank != null && r.fileRank <= 5) || (r.altFileRank != null && r.altFileRank <= 5);
|
|
26
|
+
|
|
27
|
+
export function sweep({ needs = [], adversarial = [], heldout = [] }, thresholds) {
|
|
28
|
+
return thresholds.map((t) => {
|
|
29
|
+
// The abstain rule both harnesses apply (measure-need-set scoreRow, eval-brain gradeQuestion): no
|
|
30
|
+
// citation, or a NUMERIC top logit below the threshold. A citation without a logit (a card answer)
|
|
31
|
+
// is not an abstention.
|
|
32
|
+
const answered = (cited, ce) => Boolean(cited) && !(typeof ce === 'number' && ce < t);
|
|
33
|
+
const confident = needs.filter((r) => answered(r.cited, r.topCe));
|
|
34
|
+
const hits = confident.filter(within5).length;
|
|
35
|
+
const routedRows = heldout.filter((r) => ['named', 'described', 'scenario'].includes(r.stratum));
|
|
36
|
+
return {
|
|
37
|
+
threshold: t,
|
|
38
|
+
needs: { confidentHit: rate(hits, needs.length), confidentMiss: rate(confident.length - hits, needs.length),
|
|
39
|
+
precision: rate(hits, confident.length),
|
|
40
|
+
// Weaker than a file hit: the top citation is at least from the gold repository.
|
|
41
|
+
confidentRightRepo: rate(confident.filter((r) => r.repoRank === 1).length, confident.length) },
|
|
42
|
+
offTopicAbstain: rate(adversarial.filter((r) => !answered(r.citedPath, r.ce)).length, adversarial.length),
|
|
43
|
+
heldOutRouted: rate(routedRows.filter((r) => r.grounded && r.routed && answered(r.citedPath, r.ce)).length, routedRows.length),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const args = process.argv.slice(2);
|
|
50
|
+
const arg = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
51
|
+
const read = (f) => (f ? JSON.parse(fs.readFileSync(f, 'utf8')).rows : []);
|
|
52
|
+
const thresholds = String(arg('--thresholds') || '0,-0.5,-1,-1.5,-2,-3,-4').split(',').map(Number);
|
|
53
|
+
const out = { kind: 'ruvnet-brain-abstain-threshold-sweep',
|
|
54
|
+
rows: sweep({ needs: read(arg('--needs')), adversarial: read(arg('--adversarial')), heldout: read(arg('--heldout')) }, thresholds) };
|
|
55
|
+
if (arg('--out')) fs.writeFileSync(arg('--out'), `${JSON.stringify(out, null, 1)}\n`);
|
|
56
|
+
const f = (m) => `${m.k}/${m.n} [${(100 * m.lo).toFixed(1)}-${(100 * m.hi).toFixed(1)}]`;
|
|
57
|
+
for (const r of out.rows) {
|
|
58
|
+
console.log(`t=${r.threshold}: needs hit ${f(r.needs.confidentHit)} miss ${f(r.needs.confidentMiss)} precision ${f(r.needs.precision)} right-repo ${f(r.needs.confidentRightRepo)} | off-topic abstain ${f(r.offTopicAbstain)} | held-out routed ${f(r.heldOutRouted)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/oracle/abstain-trace.mjs — WHY does the reranker abstain on a novice need whose gold
|
|
4
|
+
* repository WAS searched?
|
|
5
|
+
*
|
|
6
|
+
* For each need it measures, in the gold store, with the production models:
|
|
7
|
+
* poolRank rank of the gold file among the store's dense pool (searchKb, depth --pool)
|
|
8
|
+
* ceProduction cross-encoder logit on the text production reranks (the assembled document, which
|
|
9
|
+
* the reranker reads only through its first 3000 chars / 512 tokens)
|
|
10
|
+
* ceBestChunk best logit over the gold file's own sidecar chunks, each read on its own
|
|
11
|
+
* ceSpan logit on the verbatim gold span the question was written from (an upper bound:
|
|
12
|
+
* the answer text itself, nothing else)
|
|
13
|
+
* ceTopProd production's top logit for this need (from the measured needs run)
|
|
14
|
+
* and classifies the abstention:
|
|
15
|
+
* not-in-pool dense retrieval never surfaced the gold file
|
|
16
|
+
* window gold scores >= 0 on some chunk but < 0 on the text production reads
|
|
17
|
+
* calibration even the verbatim span scores < 0: the model, not the window, says "irrelevant"
|
|
18
|
+
* chunking span >= 0 but every stored chunk < 0 (the answer is split across chunks)
|
|
19
|
+
* outranked production text >= 0, yet the top citation is another file
|
|
20
|
+
*
|
|
21
|
+
* node scripts/oracle/abstain-trace.mjs --kb <kbDir> --set <need-set.json> --rows <needs.json>
|
|
22
|
+
* [--runtime <dir>] [--keyword 8] [--sample 20] [--pool 64] [--out <file>]
|
|
23
|
+
*
|
|
24
|
+
* --rows is a measure-need-set output (it supplies reposSearched and the production top logit);
|
|
25
|
+
* only needs whose gold repository was searched are traced. Nothing written contains a local path.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import readline from 'node:readline';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
const arg = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
|
|
34
|
+
|
|
35
|
+
export function classify({ poolRank, ceProduction, ceBestChunk, ceSpan }) {
|
|
36
|
+
if (poolRank == null) return 'not-in-pool';
|
|
37
|
+
if (ceProduction >= 0) return 'outranked';
|
|
38
|
+
if (ceBestChunk >= 0) return 'window';
|
|
39
|
+
if (ceSpan < 0) return 'calibration';
|
|
40
|
+
return 'chunking';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Deterministic sample: every step-th need of the eligible list, spread over the whole list. */
|
|
44
|
+
export function sampleEvenly(list, n) {
|
|
45
|
+
if (list.length <= n) return list;
|
|
46
|
+
const step = list.length / n;
|
|
47
|
+
return Array.from({ length: n }, (_, i) => list[Math.floor(i * step)]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function chunksOf(kb, store, file) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const name of [`${store}.passages.jsonl`, `${store}.big.passages.jsonl`]) {
|
|
53
|
+
const p = path.join(kb, name);
|
|
54
|
+
if (!fs.existsSync(p)) continue;
|
|
55
|
+
const rl = readline.createInterface({ input: fs.createReadStream(p), crlfDelay: Infinity });
|
|
56
|
+
for await (const line of rl) {
|
|
57
|
+
if (!line.includes(file)) continue;
|
|
58
|
+
try { const r = JSON.parse(line); if (r.path === file) out.push(String(r.text || '')); } catch { /* skip */ }
|
|
59
|
+
}
|
|
60
|
+
if (out.length) break;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
const kb = arg('--kb');
|
|
67
|
+
const set = JSON.parse(fs.readFileSync(arg('--set'), 'utf8'));
|
|
68
|
+
const rows = JSON.parse(fs.readFileSync(arg('--rows'), 'utf8')).rows;
|
|
69
|
+
const poolDepth = Number(arg('--pool', 64));
|
|
70
|
+
const want = Number(arg('--sample', 20));
|
|
71
|
+
const byId = new Map(set.questions.map((q) => [q.id, q]));
|
|
72
|
+
// The runtime is the KB directory's own copy of the reader (as in production), unless --runtime
|
|
73
|
+
// names another directory holding forge-ask.mjs / forge-rerank.mjs and their node_modules.
|
|
74
|
+
const runtime = path.resolve(arg('--runtime', kb));
|
|
75
|
+
const { searchKb } = await import(pathToFileURL(path.join(runtime, 'forge-ask.mjs')).href);
|
|
76
|
+
const { rerankPairs } = await import(pathToFileURL(path.join(runtime, 'forge-rerank.mjs')).href);
|
|
77
|
+
// --keyword N also counts the keyword lane (keyword-lane.mjs in the runtime) as part of the pool.
|
|
78
|
+
const keywordTopN = Number(arg('--keyword', 0));
|
|
79
|
+
const keywordCandidates = keywordTopN > 0
|
|
80
|
+
? (await import(pathToFileURL(path.join(runtime, 'keyword-lane.mjs')).href)).keywordCandidates : null;
|
|
81
|
+
const ce = async (query, texts) => {
|
|
82
|
+
if (!texts.length) return [];
|
|
83
|
+
const scored = await rerankPairs(query, texts.map((t, i) => ({ fullText: t, i })));
|
|
84
|
+
const out = new Array(texts.length);
|
|
85
|
+
for (const s of scored) out[s.i] = s.ceScore;
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
const eligible = rows.filter((r) => (r.reposSearched || []).some((s) => s.toLowerCase() === r.repo.toLowerCase()));
|
|
89
|
+
const traced = [];
|
|
90
|
+
for (const r of eligible) {
|
|
91
|
+
const q = byId.get(r.id);
|
|
92
|
+
const store = r.repo.toLowerCase();
|
|
93
|
+
const hits = await searchKb({ dir: kb, name: store, query: q.need, k: poolDepth, n: poolDepth });
|
|
94
|
+
const idx = hits.findIndex((h) => h.path === q.path);
|
|
95
|
+
let lane = idx < 0 ? null : 'dense';
|
|
96
|
+
let gold = idx < 0 ? null : hits[idx];
|
|
97
|
+
if (!gold && keywordCandidates) {
|
|
98
|
+
const kw = keywordCandidates(kb, store, q.need, { topN: keywordTopN, exclude: new Set(hits.map((h) => h.path)) });
|
|
99
|
+
const k = kw.findIndex((c) => c.path === q.path);
|
|
100
|
+
if (k >= 0) { gold = kw[k]; lane = 'keyword'; }
|
|
101
|
+
}
|
|
102
|
+
traced.push({ r, q, store, gold, lane, poolRank: idx < 0 ? (gold ? poolDepth + 1 : null) : idx + 1 });
|
|
103
|
+
process.stderr.write(`\r[abstain-trace] pool ${traced.length}/${eligible.length}`);
|
|
104
|
+
}
|
|
105
|
+
const inPool = traced.filter((t) => t.poolRank != null);
|
|
106
|
+
const sample = sampleEvenly(inPool, want);
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const t of sample) {
|
|
109
|
+
const { gold } = t;
|
|
110
|
+
const chunks = await chunksOf(kb, t.store, t.q.path);
|
|
111
|
+
const [ceProduction] = await ce(t.q.need, [gold.fullText || gold.text || '']);
|
|
112
|
+
const chunkScores = await ce(t.q.need, chunks);
|
|
113
|
+
const [ceSpan] = await ce(t.q.need, [t.q.span || '']);
|
|
114
|
+
const row = {
|
|
115
|
+
id: t.q.id, repo: t.r.repo, need: t.q.need, poolRank: t.poolRank, lane: t.lane,
|
|
116
|
+
ceProduction: +ceProduction.toFixed(3),
|
|
117
|
+
ceBestChunk: chunkScores.length ? +Math.max(...chunkScores).toFixed(3) : null,
|
|
118
|
+
chunks: chunks.length, goldDocChars: (gold.fullText || '').length,
|
|
119
|
+
ceSpan: +ceSpan.toFixed(3), ceTopProd: t.r.topCe, prodTop: t.r.topPath, prodAbstained: t.r.abstained,
|
|
120
|
+
};
|
|
121
|
+
row.cause = classify(row);
|
|
122
|
+
out.push(row);
|
|
123
|
+
process.stderr.write(`\r[abstain-trace] ce ${out.length}/${sample.length} `);
|
|
124
|
+
}
|
|
125
|
+
const count = (k) => out.filter((x) => x.cause === k).length;
|
|
126
|
+
const report = {
|
|
127
|
+
kind: 'ruvnet-brain-abstain-trace', poolDepth,
|
|
128
|
+
eligible: eligible.length, goldInPool: inPool.length, goldViaKeyword: traced.filter((t) => t.lane === 'keyword').length, notInPool: traced.length - inPool.length, sampled: out.length,
|
|
129
|
+
causes: Object.fromEntries(['not-in-pool', 'window', 'calibration', 'chunking', 'outranked'].map((k) => [k, count(k)])),
|
|
130
|
+
rows: out,
|
|
131
|
+
poolRanks: traced.map((t) => ({ id: t.q.id, poolRank: t.poolRank })),
|
|
132
|
+
};
|
|
133
|
+
const file = arg('--out');
|
|
134
|
+
if (file) fs.writeFileSync(file, `${JSON.stringify(report, null, 1)}\n`);
|
|
135
|
+
console.log(JSON.stringify({ ...report, rows: undefined, poolRanks: undefined }, 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,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/oracle/doc2query-generate.mjs — ADR-099 arm A, generation: newcomer-style questions per
|
|
4
|
+
* documentation file ("doc2query"), produced ONCE where the corpus is built, never on a customer
|
|
5
|
+
* machine.
|
|
6
|
+
*
|
|
7
|
+
* The generator sees only the file's title, path and opening text, never the need set. Each question
|
|
8
|
+
* must describe a NEED in plain words. Deterministic checks drop any question that:
|
|
9
|
+
* - names the product, the repository or a code identifier, or
|
|
10
|
+
* - shares more than 3 consecutive words with the excerpt
|
|
11
|
+
* (the need-set producer's leak rules). Output is append-only JSONL
|
|
12
|
+
* {store, path, questions[], rejected}, so a run can be resumed and interrupted runs keep their work.
|
|
13
|
+
*
|
|
14
|
+
* node scripts/oracle/doc2query-generate.mjs --kb <kbDir> --stores ruflo,ruvector,ruview --out <file.jsonl>
|
|
15
|
+
* [--kind md] [--per-file 3] [--batch 25] [--model haiku] [--limit N] [--conc 2]
|
|
16
|
+
*
|
|
17
|
+
* Children run with scripts/subscription-hosts.mjs#subscriptionOnlyEnv (no API billing keys) and the
|
|
18
|
+
* same minimised-context claude flags as scripts/oracle/producer-hosts.mjs.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { claudeArgs, spawnHost, isQuotaRefusal } from './producer-hosts.mjs';
|
|
25
|
+
import { subscriptionOnlyEnv } from '../subscription-hosts.mjs';
|
|
26
|
+
|
|
27
|
+
export const EXCERPT_CHARS = 1500;
|
|
28
|
+
export const D2Q_SCHEMA = Object.freeze({
|
|
29
|
+
type: 'object', additionalProperties: false,
|
|
30
|
+
properties: { items: { type: 'array', items: { type: 'object', additionalProperties: false,
|
|
31
|
+
properties: { docId: { type: 'string' }, questions: { type: 'array', items: { type: 'string' } } },
|
|
32
|
+
required: ['docId', 'questions'] } } },
|
|
33
|
+
required: ['items'],
|
|
34
|
+
});
|
|
35
|
+
export const D2Q_SYSTEM = 'You write search questions for documentation. You see only the documents in the message, '
|
|
36
|
+
+ 'you have no tools, and you use no outside knowledge. Return only the structured output.';
|
|
37
|
+
|
|
38
|
+
export function d2qPrompt(docs, perFile) {
|
|
39
|
+
return [
|
|
40
|
+
`For each DOC below write ${perFile} different questions that a newcomer might type into a search box, `
|
|
41
|
+
+ 'where this document is what would help them.',
|
|
42
|
+
'Rules for every question:',
|
|
43
|
+
'- 12-35 words, plain everyday language, describing the person\'s NEED or problem, not the document.',
|
|
44
|
+
'- The person has never heard of this project: no product, project, repository, package, library or tool names, no code, no identifiers, no file names.',
|
|
45
|
+
'- Do not copy more than 3 consecutive words from the document.',
|
|
46
|
+
'- The three questions should cover different things the document helps with.',
|
|
47
|
+
'Return one item per DOC with its docId copied exactly.',
|
|
48
|
+
'',
|
|
49
|
+
...docs.map((d) => `===DOC docId=${d.docId}\ntitle: ${d.title}\n${d.excerpt}\n===END`),
|
|
50
|
+
].join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PRODUCT_NAME = /^(?:ruv\w*|ruflo|rufl\w*|ruview|claudeflow|agentdb|agentic|sona|rvf|ruvllm|cognitum|densepose|metaharness|reasoningbank)$/;
|
|
54
|
+
const words = (s) => String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
55
|
+
/** Longest run of consecutive words a question shares with the excerpt. */
|
|
56
|
+
export function sharedRun(question, excerpt) {
|
|
57
|
+
const q = words(question);
|
|
58
|
+
const grams = new Set();
|
|
59
|
+
const e = words(excerpt);
|
|
60
|
+
let best = 0;
|
|
61
|
+
for (let n = 1; n <= q.length; n++) {
|
|
62
|
+
grams.clear();
|
|
63
|
+
for (let i = 0; i + n <= e.length; i++) grams.add(e.slice(i, i + n).join(' '));
|
|
64
|
+
let found = false;
|
|
65
|
+
for (let i = 0; i + n <= q.length; i++) if (grams.has(q.slice(i, i + n).join(' '))) { found = true; break; }
|
|
66
|
+
if (!found) break;
|
|
67
|
+
best = n;
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Keep a question only if it obeys the leak rules; return the reason when it does not. */
|
|
73
|
+
export function leakReason(question, { excerpt, store, path: p }) {
|
|
74
|
+
const q = String(question || '').trim();
|
|
75
|
+
const n = words(q).length;
|
|
76
|
+
if (n < 8 || n > 45) return 'length';
|
|
77
|
+
if (/[`{}<>=]|::|\w\(|\b[a-z]+[A-Z][A-Za-z]+\b|\b\w+\.(?:md|js|ts|rs|py|json|toml)\b|@[a-z0-9-]+\//.test(q)) return 'identifier';
|
|
78
|
+
// Product names: the store itself and the rUv family's own names (a newcomer has heard none of them).
|
|
79
|
+
if (words(q).some((w) => w === String(store).toLowerCase() || PRODUCT_NAME.test(w))) return 'names-source';
|
|
80
|
+
void p;
|
|
81
|
+
if (sharedRun(q, excerpt) > 3) return 'copies-source';
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function excerptOf(chunks) {
|
|
86
|
+
return chunks.join('\n\n').slice(0, EXCERPT_CHARS);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readStoreDocs(kb, store, kind) {
|
|
90
|
+
const big = path.join(kb, `${store}.big.passages.jsonl`);
|
|
91
|
+
const file = fs.existsSync(big) ? big : path.join(kb, `${store}.passages.jsonl`);
|
|
92
|
+
const byPath = new Map();
|
|
93
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
94
|
+
if (!line) continue;
|
|
95
|
+
let r;
|
|
96
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
97
|
+
if (kind === 'md' && !/\.md$/i.test(r.path)) continue;
|
|
98
|
+
if (!byPath.has(r.path)) byPath.set(r.path, { title: r.title || path.basename(r.path), chunks: [] });
|
|
99
|
+
const d = byPath.get(r.path);
|
|
100
|
+
if (d.chunks.join('').length < EXCERPT_CHARS) d.chunks.push(String(r.text || ''));
|
|
101
|
+
}
|
|
102
|
+
return [...byPath.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
103
|
+
.map(([p, d]) => ({ store, path: p, title: d.title, excerpt: excerptOf(d.chunks) }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function main() {
|
|
107
|
+
const args = process.argv.slice(2);
|
|
108
|
+
const arg = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
|
|
109
|
+
const kb = arg('--kb');
|
|
110
|
+
const out = arg('--out');
|
|
111
|
+
const perFile = Number(arg('--per-file', 3));
|
|
112
|
+
const batchSize = Number(arg('--batch', 25));
|
|
113
|
+
const conc = Number(arg('--conc', 2));
|
|
114
|
+
const model = arg('--model', 'haiku');
|
|
115
|
+
const done = new Set();
|
|
116
|
+
if (fs.existsSync(out)) {
|
|
117
|
+
for (const l of fs.readFileSync(out, 'utf8').split('\n')) { try { const r = JSON.parse(l); done.add(`${r.store}|${r.path}`); } catch { /* partial */ } }
|
|
118
|
+
}
|
|
119
|
+
let docs = String(arg('--stores')).split(',').flatMap((s) => readStoreDocs(kb, s.trim(), arg('--kind', 'md')))
|
|
120
|
+
.filter((d) => !done.has(`${d.store}|${d.path}`));
|
|
121
|
+
if (arg('--limit')) docs = docs.slice(0, Number(arg('--limit')));
|
|
122
|
+
const batches = [];
|
|
123
|
+
for (let i = 0; i < docs.length; i += batchSize) batches.push(docs.slice(i, i + batchSize));
|
|
124
|
+
const env = { ...subscriptionOnlyEnv(), CLAUDE_HOOK: '/usr/bin/true' };
|
|
125
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'd2q-'));
|
|
126
|
+
let next = 0;
|
|
127
|
+
let stop = false;
|
|
128
|
+
let written = 0;
|
|
129
|
+
const worker = async () => {
|
|
130
|
+
while (!stop && next < batches.length) {
|
|
131
|
+
const b = batches[next++];
|
|
132
|
+
const withIds = b.map((d, i) => ({ ...d, docId: `d${i}` }));
|
|
133
|
+
const res = await spawnHost('claude', claudeArgs({ model, effort: 'low', schema: D2Q_SCHEMA, systemPrompt: D2Q_SYSTEM }),
|
|
134
|
+
{ cwd, env, timeoutMs: 600_000 }, d2qPrompt(withIds, perFile));
|
|
135
|
+
let items = null;
|
|
136
|
+
try {
|
|
137
|
+
const env2 = JSON.parse(res.stdout);
|
|
138
|
+
items = (env2.structured_output || JSON.parse(env2.result)).items;
|
|
139
|
+
} catch { items = null; }
|
|
140
|
+
if (!items) {
|
|
141
|
+
if (isQuotaRefusal(res.stdout + res.stderr)) { stop = true; process.stderr.write('\n[d2q] quota refusal: stopping\n'); }
|
|
142
|
+
else process.stderr.write(`\n[d2q] batch failed (status ${res.status}${res.timedOut ? ', timeout' : ''})\n`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const byId = new Map(items.map((it) => [it.docId, it.questions || []]));
|
|
146
|
+
const lines = withIds.map((d) => {
|
|
147
|
+
const qs = byId.get(d.docId) || [];
|
|
148
|
+
const kept = [];
|
|
149
|
+
const rejected = [];
|
|
150
|
+
for (const q of qs) { const why = leakReason(q, d); if (why) rejected.push({ q, why }); else kept.push(q); }
|
|
151
|
+
return JSON.stringify({ store: d.store, path: d.path, questions: kept, rejected });
|
|
152
|
+
});
|
|
153
|
+
fs.appendFileSync(out, `${lines.join('\n')}\n`);
|
|
154
|
+
written += lines.length;
|
|
155
|
+
process.stderr.write(`\r[d2q] ${written}/${docs.length} files`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
await Promise.all(Array.from({ length: conc }, worker));
|
|
159
|
+
console.log(JSON.stringify({ files: docs.length, written, stopped: stop }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|