renor-engine 0.0.0-stage → 1.0.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/LICENSE +661 -0
- package/NOTICE +73 -0
- package/README.md +105 -2
- package/dist/BUILD.json +6 -0
- package/dist/api/apiAuth.js +43 -0
- package/dist/api/server.js +172 -0
- package/dist/buildIdentity.js +132 -0
- package/dist/cli-extractors.js +46 -0
- package/dist/cli.js +491 -0
- package/dist/config.js +65 -0
- package/dist/context/composer.js +320 -0
- package/dist/context/readHonesty.js +478 -0
- package/dist/context/rowStore.js +238 -0
- package/dist/conversation/formatter.js +81 -0
- package/dist/conversation/parser.js +189 -0
- package/dist/conversation/types.js +2 -0
- package/dist/conversation/writer.js +42 -0
- package/dist/db/client.js +147 -0
- package/dist/db/indexSalvage.js +81 -0
- package/dist/db/migrations.js +642 -0
- package/dist/db/readCache.js +109 -0
- package/dist/db/schema.js +5 -0
- package/dist/db/statements.js +89 -0
- package/dist/digests/digestCache.js +142 -0
- package/dist/digests/digests.js +597 -0
- package/dist/digests/worker.js +348 -0
- package/dist/embeddings/caching.js +337 -0
- package/dist/embeddings/coalescing.js +177 -0
- package/dist/embeddings/interactive.js +103 -0
- package/dist/embeddings/local.js +68 -0
- package/dist/embeddings/openai.js +105 -0
- package/dist/embeddings/rebuild.js +166 -0
- package/dist/embeddings/simdScan.js +258 -0
- package/dist/embeddings/space.js +21 -0
- package/dist/embeddings/types.js +14 -0
- package/dist/embeddings/vectorIndex.js +675 -0
- package/dist/embeddings/vertex.js +224 -0
- package/dist/eval/artifacts.js +42 -0
- package/dist/eval/cacheReport.js +139 -0
- package/dist/eval/comparison.js +182 -0
- package/dist/eval/context700-candidate.js +145 -0
- package/dist/eval/evalCache.js +525 -0
- package/dist/eval/hashEmbedding.js +35 -0
- package/dist/eval/longmemeval.js +56 -0
- package/dist/eval/metrics.js +181 -0
- package/dist/eval/renorbenchBaseline.js +486 -0
- package/dist/eval/renorbenchDocuments.js +134 -0
- package/dist/eval/renorbenchProduct.js +182 -0
- package/dist/eval/runLedger.js +279 -0
- package/dist/eval/runManifest.js +479 -0
- package/dist/eval/scoring.js +197 -0
- package/dist/evidence/attach.js +267 -0
- package/dist/evidence/background.js +147 -0
- package/dist/evidence/evidenceCache.js +182 -0
- package/dist/evidence/gate.js +37 -0
- package/dist/evidence/keyPool.js +85 -0
- package/dist/evidence/ollamaChat.js +84 -0
- package/dist/evidence/optin.js +165 -0
- package/dist/evidence/options.js +130 -0
- package/dist/evidence/secondPass.js +456 -0
- package/dist/extraction/classifyCapture.js +34 -0
- package/dist/extraction/decision-model.js +174 -0
- package/dist/extraction/eventExtractor.js +113 -0
- package/dist/extraction/extractionClient.js +122 -0
- package/dist/extraction/factExtractor.js +46 -0
- package/dist/extraction/factsWritePath.js +179 -0
- package/dist/extraction/preferenceExtractor.js +153 -0
- package/dist/extraction/secretGuard.js +176 -0
- package/dist/extraction/secretScanner.js +665 -0
- package/dist/extraction/typedFacts.js +224 -0
- package/dist/extraction/writePathAdjudication.js +110 -0
- package/dist/importing/apply.js +644 -0
- package/dist/importing/bundle-write.js +80 -0
- package/dist/importing/cli.js +407 -0
- package/dist/importing/existing.js +49 -0
- package/dist/importing/facts.js +123 -0
- package/dist/importing/forgetImported.js +79 -0
- package/dist/importing/hosted.js +467 -0
- package/dist/importing/index.js +29 -0
- package/dist/importing/journalLog.js +96 -0
- package/dist/importing/markdown.js +98 -0
- package/dist/importing/plan.js +206 -0
- package/dist/importing/secretScreen.js +113 -0
- package/dist/importing/serialize.js +188 -0
- package/dist/importing/source.js +463 -0
- package/dist/importing/types.js +6 -0
- package/dist/indexer/chunker.js +319 -0
- package/dist/indexer/dailyEntries.js +63 -0
- package/dist/indexer/hasher.js +10 -0
- package/dist/indexer/laneRuns.js +48 -0
- package/dist/indexer/o200k.js +481 -0
- package/dist/indexer/o200k.json +8 -0
- package/dist/indexer/outsideRoot.js +77 -0
- package/dist/indexer/parser.js +167 -0
- package/dist/indexer/pipeline.js +1168 -0
- package/dist/indexer/staleness.js +201 -0
- package/dist/indexer/tokenizer.js +77 -0
- package/dist/indexer/walker.js +184 -0
- package/dist/mcp/hive.js +88 -0
- package/dist/mcp/requestErrors.js +47 -0
- package/dist/mcp/server.js +396 -0
- package/dist/memory/agentLease.js +43 -0
- package/dist/memory/confinedWrite.js +315 -0
- package/dist/memory/controls.js +73 -0
- package/dist/memory/dailyNote.js +265 -0
- package/dist/memory/durableAppend.js +57 -0
- package/dist/memory/factConflicts.js +203 -0
- package/dist/memory/factTerms.js +321 -0
- package/dist/memory/hiveNameHeader.cjs +10 -0
- package/dist/memory/inventory.js +55 -0
- package/dist/memory/invisibleText.js +20 -0
- package/dist/memory/lifecycle.js +1756 -0
- package/dist/memory/limits.js +13 -0
- package/dist/memory/requests.js +183 -0
- package/dist/memory/sourceName.js +200 -0
- package/dist/memory/whatsNew.js +158 -0
- package/dist/memory/writer.js +175 -0
- package/dist/memory/writerConflicts.js +85 -0
- package/dist/memorymap/memoryMap.js +1568 -0
- package/dist/memorymap/replacedJournal.js +58 -0
- package/dist/memorymap/replacements.js +405 -0
- package/dist/mvp/activity.js +86 -0
- package/dist/mvp/agentMemory.js +1744 -0
- package/dist/mvp/auth.js +531 -0
- package/dist/mvp/cli.js +73 -0
- package/dist/mvp/client-store.js +462 -0
- package/dist/mvp/compileCache.js +249 -0
- package/dist/mvp/compileSeed.js +27 -0
- package/dist/mvp/config.js +288 -0
- package/dist/mvp/connectionStatus.js +80 -0
- package/dist/mvp/contextRelevance.js +413 -0
- package/dist/mvp/engine.js +1065 -0
- package/dist/mvp/exportAll.js +359 -0
- package/dist/mvp/factLanePack.js +77 -0
- package/dist/mvp/factMarkers.js +49 -0
- package/dist/mvp/factsLane.js +203 -0
- package/dist/mvp/hiddenSpans.js +585 -0
- package/dist/mvp/history.js +413 -0
- package/dist/mvp/hitDates.js +31 -0
- package/dist/mvp/hitFloor.js +36 -0
- package/dist/mvp/identity.js +18 -0
- package/dist/mvp/idle.js +113 -0
- package/dist/mvp/memoryDir.js +98 -0
- package/dist/mvp/posthogMcp.js +210 -0
- package/dist/mvp/readScope.js +104 -0
- package/dist/mvp/readUsefulness.js +222 -0
- package/dist/mvp/sanitize.js +48 -0
- package/dist/mvp/schemaRejections.js +91 -0
- package/dist/mvp/scope.js +127 -0
- package/dist/mvp/server.js +2089 -0
- package/dist/mvp/serverCard.js +29 -0
- package/dist/mvp/storedSize.js +139 -0
- package/dist/mvp/telemetry.js +441 -0
- package/dist/retrieval/adviceLines.js +195 -0
- package/dist/retrieval/adviceQuestion.js +44 -0
- package/dist/retrieval/conversationShape.js +44 -0
- package/dist/retrieval/dailyEntryTime.js +55 -0
- package/dist/retrieval/entityLookup.js +113 -0
- package/dist/retrieval/factsFirst.js +188 -0
- package/dist/retrieval/keyword.js +84 -0
- package/dist/retrieval/ledgerEventTime.js +51 -0
- package/dist/retrieval/ledgerLiveness.js +83 -0
- package/dist/retrieval/ledgerPresentation.js +161 -0
- package/dist/retrieval/noteIdentity.js +57 -0
- package/dist/retrieval/orchestrator.js +1086 -0
- package/dist/retrieval/queryHelpers.js +142 -0
- package/dist/retrieval/questionSpan.js +475 -0
- package/dist/retrieval/readerEvents.js +690 -0
- package/dist/retrieval/readerLayout.js +503 -0
- package/dist/retrieval/relativeTimeHints.js +113 -0
- package/dist/retrieval/semantic.js +67 -0
- package/dist/router/routerCache.js +106 -0
- package/dist/router/sessionRouter.js +726 -0
- package/dist/toolRefusal.js +120 -0
- package/dist/watcher/watcher.js +75 -0
- package/package.json +120 -4
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canonicalSegment = exports.COMPILER_VERSION = exports.isHeaderOnly = void 0;
|
|
4
|
+
exports.compileContext = compileContext;
|
|
5
|
+
/**
|
|
6
|
+
* Context700 campaign — MUTABLE context compiler.
|
|
7
|
+
*
|
|
8
|
+
* Campaign contract (docs/agents/autoresearch/context700/program.md):
|
|
9
|
+
* - Target: <= 700 mean Renor memory-evidence tokens/query on LongMemEval-S full-500.
|
|
10
|
+
* - Frozen quality floor: >= 428/500, no category regression > 2.0 pp vs N22 (430/500).
|
|
11
|
+
* - ONLY this file is mutable after harness freeze.
|
|
12
|
+
*
|
|
13
|
+
* The compiler is gold-blind: it may use the question text/date, retrieval hits,
|
|
14
|
+
* and structured extraction tables. It may NOT use gold answers, benchmark
|
|
15
|
+
* question IDs (as decision keys), judge outputs, or correctness labels.
|
|
16
|
+
*
|
|
17
|
+
* v3 (structured-first + date-preserving compaction + recency raw fill):
|
|
18
|
+
* Lesson from v1 (31/118 screen KILL): raw-first 55% budget + aggressive
|
|
19
|
+
* compaction starved date-bearing stubs and chronicle breadth. v3:
|
|
20
|
+
* 1. Query-gated structured rows FIRST (facts cap 12, chronicle cap 10, prefs cap 6).
|
|
21
|
+
* 2. Raw excerpts: compact turn markers, PRESERVE Timestamp/date lines and
|
|
22
|
+
* header stubs (they carry session dates), recency-ordered like N22.
|
|
23
|
+
* 3. Fill remaining budget by rank; no minimum raw count (tables win ties).
|
|
24
|
+
*/
|
|
25
|
+
const gpt_tokenizer_1 = require("gpt-tokenizer");
|
|
26
|
+
const canonicalSegment = (notePath) => notePath.replace(/^Sessions\//, '').replace(/-occ\d+\.md$/, '.md').replace(/\.md$/, '');
|
|
27
|
+
exports.canonicalSegment = canonicalSegment;
|
|
28
|
+
const isHeaderOnly = (text) => !text.includes('### Turn') && text.trim().length < 250;
|
|
29
|
+
exports.isHeaderOnly = isHeaderOnly;
|
|
30
|
+
exports.COMPILER_VERSION = 'v3-struct-first-date-keeping';
|
|
31
|
+
const CONTENT_STOP = new Set('the a an of to for and or in on my i with is are was be that this it at as by from have has had do does did not no you your me we our they their he she his her about into over under then than so if but'.split(' '));
|
|
32
|
+
const contentWords = (s) => {
|
|
33
|
+
const out = new Set();
|
|
34
|
+
for (const w of s.toLowerCase().matchAll(/[a-z0-9]{3,}/g)) {
|
|
35
|
+
if (!CONTENT_STOP.has(w[0]))
|
|
36
|
+
out.add(w[0]);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
/** Compact date: "2023/05/30 (Tue) 23:40" -> "2023/05/30". */
|
|
41
|
+
const compactDate = (d) => d ? d.replace(/\s*\((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\).*/, '') : '';
|
|
42
|
+
/**
|
|
43
|
+
* v3 compaction: shorten turn markers to `[N-role]`, drop long session headers,
|
|
44
|
+
* but PRESERVE Timestamp lines (dates are load-bearing for temporal/multi-session).
|
|
45
|
+
*/
|
|
46
|
+
const compactText = (t) => t
|
|
47
|
+
.replace(/^### Turn (\d+) — (User|Assistant)\s*\|?\s*/gim, '[$1-$2] ')
|
|
48
|
+
.replace(/^Timestamp: (.*)$/gim, '@$1')
|
|
49
|
+
.replace(/^# Session .*\n?/gim, '')
|
|
50
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
51
|
+
.trim();
|
|
52
|
+
const gateRows = (rows, qWords, cap) => {
|
|
53
|
+
if (!rows || rows.length === 0)
|
|
54
|
+
return [];
|
|
55
|
+
const scored = [];
|
|
56
|
+
for (const row of rows) {
|
|
57
|
+
const text = Object.values(row).map(v => (typeof v === 'string' ? v : JSON.stringify(v) ?? '')).join(' ');
|
|
58
|
+
let overlap = 0;
|
|
59
|
+
for (const w of contentWords(text))
|
|
60
|
+
if (qWords.has(w))
|
|
61
|
+
overlap++;
|
|
62
|
+
if (overlap > 0)
|
|
63
|
+
scored.push({ score: overlap, row });
|
|
64
|
+
}
|
|
65
|
+
scored.sort((a, b) => b.score - a.score);
|
|
66
|
+
return scored.slice(0, cap);
|
|
67
|
+
};
|
|
68
|
+
function compileContext(args) {
|
|
69
|
+
const { question, hits, tables, dateMap, maxContextTokens } = args;
|
|
70
|
+
const qWords = contentWords(question.question);
|
|
71
|
+
const parts = { rawTokens: 0, factsTokens: 0, chronicleTokens: 0, prefsTokens: 0 };
|
|
72
|
+
const excerpts = [];
|
|
73
|
+
// --- Structured rows first (gated) ---
|
|
74
|
+
let tokens = 0;
|
|
75
|
+
let remaining = maxContextTokens;
|
|
76
|
+
const factScored = gateRows(tables.facts ?? [], qWords, 12);
|
|
77
|
+
if (factScored.length) {
|
|
78
|
+
const lines = factScored.map(({ row: f }) => {
|
|
79
|
+
const co = f.coexist ? ` (also: ${f.coexist})` : '';
|
|
80
|
+
return `- ${f.value} ${f.unit ?? ''} ${f.object ?? ''}${f.event ? ` — ${f.event}` : ''}${co}`.replace(/\s+/g, ' ').trim();
|
|
81
|
+
});
|
|
82
|
+
const block = `Numeric facts:\n${lines.join('\n')}\nCite only values present here; otherwise use excerpts.`;
|
|
83
|
+
const blockTokens = (0, gpt_tokenizer_1.encode)(block).length;
|
|
84
|
+
if (blockTokens <= remaining) {
|
|
85
|
+
excerpts.push({ text: block, date: null });
|
|
86
|
+
tokens += blockTokens;
|
|
87
|
+
parts.factsTokens = blockTokens;
|
|
88
|
+
remaining -= blockTokens;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const chronScored = gateRows(tables.chronicle ?? [], qWords, 10);
|
|
92
|
+
if (chronScored.length) {
|
|
93
|
+
const lines = chronScored.map(({ row: e }) => `- ${e.when ?? ''}: ${e.event ?? ''}`.replace(/\s+/g, ' ').trim());
|
|
94
|
+
const block = `Events:\n${lines.join('\n')}\nCite only events present here; otherwise use excerpts.`;
|
|
95
|
+
const blockTokens = (0, gpt_tokenizer_1.encode)(block).length;
|
|
96
|
+
if (blockTokens <= remaining) {
|
|
97
|
+
excerpts.push({ text: block, date: null });
|
|
98
|
+
tokens += blockTokens;
|
|
99
|
+
parts.chronicleTokens = blockTokens;
|
|
100
|
+
remaining -= blockTokens;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const prefScored = gateRows(tables.prefs ?? [], qWords, 6);
|
|
104
|
+
if (prefScored.length) {
|
|
105
|
+
const lines = prefScored.map(({ row: p }) => `- ${p.statement ?? ''}`.replace(/\s+/g, ' ').trim());
|
|
106
|
+
const block = `Standing preferences:\n${lines.join('\n')}\nApply matching entries; otherwise use excerpts.`;
|
|
107
|
+
const blockTokens = (0, gpt_tokenizer_1.encode)(block).length;
|
|
108
|
+
if (blockTokens <= remaining) {
|
|
109
|
+
excerpts.push({ text: block, date: null });
|
|
110
|
+
tokens += blockTokens;
|
|
111
|
+
parts.prefsTokens = blockTokens;
|
|
112
|
+
remaining -= blockTokens;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const raws = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
for (const hit of args.hits) {
|
|
118
|
+
const text = compactText((hit.text ?? '').trim());
|
|
119
|
+
if (!text || seen.has(hit.text ?? ''))
|
|
120
|
+
continue;
|
|
121
|
+
const sessionDate = dateMap?.get(canonicalSegment(hit.notePath)) ?? null;
|
|
122
|
+
seen.add(hit.text ?? '');
|
|
123
|
+
raws.push({ text, date: sessionDate });
|
|
124
|
+
}
|
|
125
|
+
// N22 recency ordering (newest-first) before selection.
|
|
126
|
+
if (args.recencyOrder) {
|
|
127
|
+
raws.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
|
|
128
|
+
}
|
|
129
|
+
for (const raw of raws) {
|
|
130
|
+
const dateMark = raw.date ? `(${compactDate(raw.date)}) ` : '';
|
|
131
|
+
const candidate = `${dateMark}${raw.text}`;
|
|
132
|
+
const candidateTokens = (0, gpt_tokenizer_1.encode)(candidate).length;
|
|
133
|
+
if (tokens + candidateTokens > maxContextTokens)
|
|
134
|
+
break;
|
|
135
|
+
tokens += candidateTokens;
|
|
136
|
+
excerpts.push({ text: candidate, date: raw.date });
|
|
137
|
+
}
|
|
138
|
+
parts.rawTokens = tokens - parts.factsTokens - parts.chronicleTokens - parts.prefsTokens;
|
|
139
|
+
return {
|
|
140
|
+
context: excerpts.map(excerpt => excerpt.text).join('\n\n'),
|
|
141
|
+
tokens,
|
|
142
|
+
parts,
|
|
143
|
+
diagnostics: { compilerVersion: args.compilerVersion },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.WorkerPool = exports.KeyPool = exports.EvalCache = exports.CACHE_SCHEMA_VERSION = void 0;
|
|
7
|
+
exports.summarizeQaTokenUsage = summarizeQaTokenUsage;
|
|
8
|
+
exports.runWithKeyPoolRetries = runWithKeyPoolRetries;
|
|
9
|
+
exports.parseRetryAfterMs = parseRetryAfterMs;
|
|
10
|
+
exports.resolveWorkerConcurrency = resolveWorkerConcurrency;
|
|
11
|
+
exports.resolveCacheScope = resolveCacheScope;
|
|
12
|
+
exports.resolveEvalCachePath = resolveEvalCachePath;
|
|
13
|
+
exports.resolveJudgeKeys = resolveJudgeKeys;
|
|
14
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
17
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
18
|
+
/**
|
|
19
|
+
* Content-addressed eval cache for LongMemEval QA runs.
|
|
20
|
+
*
|
|
21
|
+
* Architecture: full-request hashing → raw-response replay.
|
|
22
|
+
*
|
|
23
|
+
* Key = sha256(CACHE_SCHEMA_VERSION + providerNamespace + JSON.stringify(requestBody)).
|
|
24
|
+
* No .trim(), no selected-field hashing. Any prompt edit, temperature change,
|
|
25
|
+
* or model swap automatically invalidates the cache because the full request
|
|
26
|
+
* body changes.
|
|
27
|
+
*
|
|
28
|
+
* Stores raw model responses (not parsed verdicts). Parsing happens at read
|
|
29
|
+
* time so future parser improvements don't require re-running models.
|
|
30
|
+
*
|
|
31
|
+
* The cache is "frozen replay": it stores the exact bytes a model returned for
|
|
32
|
+
* a given request. It does NOT assume LLM calls are deterministic — even at
|
|
33
|
+
* temperature 0, provider drift can produce different outputs for identical
|
|
34
|
+
* prompts. The cache replays the first-seen draw. Authoritative promotion
|
|
35
|
+
* runs should use --no-cache for a cold full-500.
|
|
36
|
+
*
|
|
37
|
+
* Design follows the existing embedding cache (src/embeddings/caching.ts):
|
|
38
|
+
* SQLite + WAL + busy_timeout=10000 + INSERT OR IGNORE.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Bump this on any change to key computation or stored schema.
|
|
42
|
+
*
|
|
43
|
+
* v2 (2026-09-18): invalidated all v1 entries after the degraded shared-session
|
|
44
|
+
* incident.
|
|
45
|
+
*
|
|
46
|
+
* v3 (2026-09-19): adds an explicit cache scope to key computation. The QA
|
|
47
|
+
* runner passes the effective x-opencode-session as that scope, so a response
|
|
48
|
+
* produced by one routed backend session can never be replayed into another.
|
|
49
|
+
*
|
|
50
|
+
* v4 (2026-09-22 / integrity fix 2026-09-23): documents the campaign finding
|
|
51
|
+
* that cross-run replay needs a pinned cache scope + shared --cache-dir (and
|
|
52
|
+
* shared evidence cache for evidence arms) — NOT body rewriting. The
|
|
53
|
+
* path-variance diagnostic (scripts/eval/diagnose-evalcache-path-variance.ts)
|
|
54
|
+
* measured runner-assembly answer/judge bodies as ALREADY byte-identical across
|
|
55
|
+
* different per-case temp dirs (notePaths relativized; evidence tables emit
|
|
56
|
+
* item/action/amount/date only). A brief experiment that rewrote
|
|
57
|
+
* /tmp/renor-product-XXXXXX tokens inside the hashed JSON was rejected before
|
|
58
|
+
* merge: model-visible path-shaped strings must remain part of the key, or two
|
|
59
|
+
* different contexts can collide and replay the wrong answer.
|
|
60
|
+
*
|
|
61
|
+
* Key formula is unchanged from v3 aside from the version string: sha256 of
|
|
62
|
+
* CACHE_SCHEMA_VERSION \0 providerNamespace \0 cacheScope \0
|
|
63
|
+
* JSON.stringify(requestBody) with NO body mutation. Keeping the v4 label
|
|
64
|
+
* intentionally invalidates v3 rows: a brief pre-merge canonicalize experiment
|
|
65
|
+
* may have written experimental v4 keys on developer machines, and a clean
|
|
66
|
+
* version cut avoids mixed semantics (cheaper than subtle collisions between
|
|
67
|
+
* v3 and any experimental v4 rows). Scoring stays fail-closed on the schema
|
|
68
|
+
* recorded in the run manifest.
|
|
69
|
+
*/
|
|
70
|
+
exports.CACHE_SCHEMA_VERSION = 'evalcache-v4';
|
|
71
|
+
function summarizeQaTokenUsage(records) {
|
|
72
|
+
return {
|
|
73
|
+
live: {
|
|
74
|
+
answerPrompt: records.reduce((n, c) => n + (!c.answer?.fromCache ? (c.answer?.usage?.promptTokens ?? 0) : 0), 0),
|
|
75
|
+
answerCompletion: records.reduce((n, c) => n + (!c.answer?.fromCache ? (c.answer?.usage?.completionTokens ?? 0) : 0), 0),
|
|
76
|
+
judgePrompt: records.reduce((n, c) => n + (!c.judge?.fromCache ? (c.judge?.usage?.promptTokens ?? 0) : 0), 0),
|
|
77
|
+
judgeCompletion: records.reduce((n, c) => n + (!c.judge?.fromCache ? (c.judge?.usage?.completionTokens ?? 0) : 0), 0),
|
|
78
|
+
},
|
|
79
|
+
avoided: {
|
|
80
|
+
answerPrompt: records.reduce((n, c) => n + (c.answer?.fromCache ? (c.answer?.usage?.promptTokens ?? 0) : 0), 0),
|
|
81
|
+
answerCompletion: records.reduce((n, c) => n + (c.answer?.fromCache ? (c.answer?.usage?.completionTokens ?? 0) : 0), 0),
|
|
82
|
+
judgePrompt: records.reduce((n, c) => n + (c.judge?.fromCache ? (c.judge?.usage?.promptTokens ?? 0) : 0), 0),
|
|
83
|
+
judgeCompletion: records.reduce((n, c) => n + (c.judge?.fromCache ? (c.judge?.usage?.completionTokens ?? 0) : 0), 0),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
class EvalCache {
|
|
88
|
+
db;
|
|
89
|
+
stats = {
|
|
90
|
+
answerHits: 0, answerMisses: 0,
|
|
91
|
+
judgeHits: 0, judgeMisses: 0,
|
|
92
|
+
avoidedAnswerPromptTokens: 0, avoidedAnswerCompletionTokens: 0,
|
|
93
|
+
avoidedJudgePromptTokens: 0, avoidedJudgeCompletionTokens: 0,
|
|
94
|
+
liveAnswerPromptTokens: 0, liveAnswerCompletionTokens: 0,
|
|
95
|
+
liveJudgePromptTokens: 0, liveJudgeCompletionTokens: 0,
|
|
96
|
+
cacheLookupTotalMs: 0, cacheLookupCount: 0,
|
|
97
|
+
};
|
|
98
|
+
lookupStmt;
|
|
99
|
+
insertStmt;
|
|
100
|
+
constructor(dbPath) {
|
|
101
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(dbPath), { recursive: true });
|
|
102
|
+
this.db = new better_sqlite3_1.default(dbPath);
|
|
103
|
+
this.db.pragma('journal_mode = WAL');
|
|
104
|
+
this.db.pragma('busy_timeout = 10000');
|
|
105
|
+
this.db.exec(`
|
|
106
|
+
CREATE TABLE IF NOT EXISTS eval_responses (
|
|
107
|
+
cache_key TEXT PRIMARY KEY,
|
|
108
|
+
cache_schema_version TEXT NOT NULL,
|
|
109
|
+
provider_namespace TEXT NOT NULL,
|
|
110
|
+
call_type TEXT NOT NULL,
|
|
111
|
+
question_id TEXT NOT NULL,
|
|
112
|
+
raw_content TEXT NOT NULL,
|
|
113
|
+
model_echo TEXT NOT NULL,
|
|
114
|
+
finish_reason TEXT NOT NULL,
|
|
115
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
request_body_json TEXT NOT NULL,
|
|
119
|
+
created_at TEXT NOT NULL
|
|
120
|
+
)
|
|
121
|
+
`);
|
|
122
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_eval_responses_qid ON eval_responses(question_id)`);
|
|
123
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_eval_responses_type ON eval_responses(call_type)`);
|
|
124
|
+
this.lookupStmt = this.db.prepare('SELECT raw_content, model_echo, finish_reason, prompt_tokens, completion_tokens, latency_ms, provider_namespace FROM eval_responses WHERE cache_key = ?');
|
|
125
|
+
this.insertStmt = this.db.prepare(`INSERT OR IGNORE INTO eval_responses
|
|
126
|
+
(cache_key, cache_schema_version, provider_namespace, call_type, question_id,
|
|
127
|
+
raw_content, model_echo, finish_reason, prompt_tokens, completion_tokens,
|
|
128
|
+
latency_ms, request_body_json, created_at)
|
|
129
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Compute a content-addressed cache key from the full API request plus an
|
|
133
|
+
* optional routing scope.
|
|
134
|
+
*
|
|
135
|
+
* Key = sha256(CACHE_SCHEMA_VERSION \0 providerNamespace \0 cacheScope
|
|
136
|
+
* \0 requestBodyJSON).
|
|
137
|
+
*
|
|
138
|
+
* The request body is hashed as-is (no path rewriting). Cross-run replay
|
|
139
|
+
* relies on pinned RENOR_QA_CACHE_SCOPE + shared --cache-dir, not on
|
|
140
|
+
* mutating model-visible text before the key (v4 integrity fix).
|
|
141
|
+
*
|
|
142
|
+
* The QA runner uses the effective x-opencode-session as cacheScope because
|
|
143
|
+
* that header has been empirically shown to affect backend routing/results.
|
|
144
|
+
* Callers that have no routing-sensitive context may leave the scope empty.
|
|
145
|
+
*/
|
|
146
|
+
static computeKey(providerNamespace, requestBody, cacheScope = '') {
|
|
147
|
+
// Hash the raw request body. Never rewrite model-visible text — path-shaped
|
|
148
|
+
// tokens in context must produce distinct keys (integrity).
|
|
149
|
+
return node_crypto_1.default.createHash('sha256')
|
|
150
|
+
.update(`${exports.CACHE_SCHEMA_VERSION}\0${providerNamespace}\0${cacheScope}\0${JSON.stringify(requestBody)}`)
|
|
151
|
+
.digest('hex');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Look up a cached response. Returns null on miss.
|
|
155
|
+
*/
|
|
156
|
+
get(cacheKey, callType) {
|
|
157
|
+
const started = Date.now();
|
|
158
|
+
const row = this.lookupStmt.get(cacheKey);
|
|
159
|
+
this.stats.cacheLookupTotalMs += Date.now() - started;
|
|
160
|
+
this.stats.cacheLookupCount++;
|
|
161
|
+
if (!row) {
|
|
162
|
+
if (callType === 'answer')
|
|
163
|
+
this.stats.answerMisses++;
|
|
164
|
+
else
|
|
165
|
+
this.stats.judgeMisses++;
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (callType === 'answer') {
|
|
169
|
+
this.stats.answerHits++;
|
|
170
|
+
this.stats.avoidedAnswerPromptTokens += row.prompt_tokens;
|
|
171
|
+
this.stats.avoidedAnswerCompletionTokens += row.completion_tokens;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.stats.judgeHits++;
|
|
175
|
+
this.stats.avoidedJudgePromptTokens += row.prompt_tokens;
|
|
176
|
+
this.stats.avoidedJudgeCompletionTokens += row.completion_tokens;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
rawContent: row.raw_content,
|
|
180
|
+
modelEcho: row.model_echo,
|
|
181
|
+
finishReason: row.finish_reason,
|
|
182
|
+
promptTokens: row.prompt_tokens,
|
|
183
|
+
completionTokens: row.completion_tokens,
|
|
184
|
+
latencyMs: row.latency_ms,
|
|
185
|
+
providerNamespace: row.provider_namespace,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
insertEntry(params) {
|
|
189
|
+
const result = this.insertStmt.run(params.cacheKey, exports.CACHE_SCHEMA_VERSION, params.providerNamespace, params.callType, params.questionId, params.rawContent, params.modelEcho, params.finishReason, params.promptTokens, params.completionTokens, params.latencyMs, JSON.stringify(params.requestBody), new Date().toISOString());
|
|
190
|
+
return result.changes;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Store a live response in the cache. INSERT OR IGNORE preserves first-seen semantics.
|
|
194
|
+
*/
|
|
195
|
+
put(params) {
|
|
196
|
+
this.insertEntry(params);
|
|
197
|
+
// Track live usage
|
|
198
|
+
if (params.callType === 'answer') {
|
|
199
|
+
this.stats.liveAnswerPromptTokens += params.promptTokens;
|
|
200
|
+
this.stats.liveAnswerCompletionTokens += params.completionTokens;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
this.stats.liveJudgePromptTokens += params.promptTokens;
|
|
204
|
+
this.stats.liveJudgeCompletionTokens += params.completionTokens;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Promote an already-observed response into another cache identity without
|
|
209
|
+
* counting it as a second live provider call. Returns true only when a new
|
|
210
|
+
* cache row was inserted.
|
|
211
|
+
*/
|
|
212
|
+
promote(params) {
|
|
213
|
+
return this.insertEntry(params) > 0;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Invalidate recorded model calls. Filters combine (AND); an empty filter is
|
|
217
|
+
* refused unless `all` is explicit. Returns the number of rows removed.
|
|
218
|
+
*/
|
|
219
|
+
invalidate(filter) {
|
|
220
|
+
const clauses = [];
|
|
221
|
+
const args = [];
|
|
222
|
+
if (filter.providerNamespace) {
|
|
223
|
+
clauses.push('provider_namespace = ?');
|
|
224
|
+
args.push(filter.providerNamespace);
|
|
225
|
+
}
|
|
226
|
+
if (filter.callType) {
|
|
227
|
+
clauses.push('call_type = ?');
|
|
228
|
+
args.push(filter.callType);
|
|
229
|
+
}
|
|
230
|
+
if (clauses.length === 0 && !filter.all)
|
|
231
|
+
throw new Error('EvalCache.invalidate: pass providerNamespace, callType, or all: true');
|
|
232
|
+
const where = clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : '';
|
|
233
|
+
return this.db.prepare(`DELETE FROM eval_responses${where}`).run(...args).changes;
|
|
234
|
+
}
|
|
235
|
+
/** Cached row count per provider namespace and call type. */
|
|
236
|
+
summary() {
|
|
237
|
+
return (this.db.prepare('SELECT provider_namespace AS providerNamespace, call_type AS callType, COUNT(*) AS rows FROM eval_responses GROUP BY provider_namespace, call_type ORDER BY provider_namespace, call_type').all());
|
|
238
|
+
}
|
|
239
|
+
/** Row count for diagnostics. */
|
|
240
|
+
rowCount() {
|
|
241
|
+
const row = this.db.prepare('SELECT COUNT(*) as cnt FROM eval_responses').get();
|
|
242
|
+
return row.cnt;
|
|
243
|
+
}
|
|
244
|
+
close() {
|
|
245
|
+
this.db.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
exports.EvalCache = EvalCache;
|
|
249
|
+
class KeyPool {
|
|
250
|
+
slots;
|
|
251
|
+
maxPerKey;
|
|
252
|
+
nextIndex = 0;
|
|
253
|
+
waiters = [];
|
|
254
|
+
constructor(apiKeys, maxPerKey = 1) {
|
|
255
|
+
if (apiKeys.length === 0)
|
|
256
|
+
throw new Error('KeyPool requires at least one API key');
|
|
257
|
+
this.slots = apiKeys.map((key, i) => ({
|
|
258
|
+
key,
|
|
259
|
+
inFlight: 0,
|
|
260
|
+
disabled: false,
|
|
261
|
+
cooldownUntil: 0,
|
|
262
|
+
label: `key-${i + 1}`,
|
|
263
|
+
}));
|
|
264
|
+
this.maxPerKey = maxPerKey;
|
|
265
|
+
}
|
|
266
|
+
get size() { return this.slots.filter(s => !s.disabled).length; }
|
|
267
|
+
get totalInFlight() { return this.slots.reduce((n, s) => n + s.inFlight, 0); }
|
|
268
|
+
findAvailable(now) {
|
|
269
|
+
for (let i = 0; i < this.slots.length; i++) {
|
|
270
|
+
const idx = (this.nextIndex + i) % this.slots.length;
|
|
271
|
+
const slot = this.slots[idx];
|
|
272
|
+
if (!slot.disabled && slot.inFlight < this.maxPerKey && now >= slot.cooldownUntil) {
|
|
273
|
+
this.nextIndex = (idx + 1) % this.slots.length;
|
|
274
|
+
return slot;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
lease(slot) {
|
|
280
|
+
slot.inFlight++;
|
|
281
|
+
let released = false;
|
|
282
|
+
return {
|
|
283
|
+
key: slot.key,
|
|
284
|
+
label: slot.label,
|
|
285
|
+
release: () => {
|
|
286
|
+
if (released)
|
|
287
|
+
return;
|
|
288
|
+
released = true;
|
|
289
|
+
slot.inFlight = Math.max(0, slot.inFlight - 1);
|
|
290
|
+
this.drainWaiters();
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Acquire a key slot. Returns { key, label, release }.
|
|
296
|
+
* release() is idempotent and should still be called in a finally block.
|
|
297
|
+
*/
|
|
298
|
+
async acquire() {
|
|
299
|
+
if (this.size === 0) {
|
|
300
|
+
throw new Error('All API keys have been permanently disabled (401/403)');
|
|
301
|
+
}
|
|
302
|
+
const slot = this.findAvailable(Date.now());
|
|
303
|
+
if (slot)
|
|
304
|
+
return this.lease(slot);
|
|
305
|
+
// All usable keys are busy or cooling down. Queue and immediately drain so
|
|
306
|
+
// cooldown-only waits get a wake-up timer even when no in-flight request
|
|
307
|
+
// remains to call release().
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
this.waiters.push({ resolve, reject });
|
|
310
|
+
this.drainWaiters();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
rejectAllWaiters(error) {
|
|
314
|
+
while (this.waiters.length > 0) {
|
|
315
|
+
this.waiters.shift()?.reject(error);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
drainWaiters() {
|
|
319
|
+
if (this.waiters.length === 0)
|
|
320
|
+
return;
|
|
321
|
+
if (this.size === 0) {
|
|
322
|
+
this.rejectAllWaiters(new Error('All API keys have been permanently disabled (401/403)'));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const now = Date.now();
|
|
326
|
+
const slot = this.findAvailable(now);
|
|
327
|
+
if (slot) {
|
|
328
|
+
const waiter = this.waiters.shift();
|
|
329
|
+
if (waiter)
|
|
330
|
+
waiter.resolve(this.lease(slot));
|
|
331
|
+
if (this.waiters.length > 0)
|
|
332
|
+
queueMicrotask(() => this.drainWaiters());
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// All usable keys are still busy or in cooldown. Busy keys will wake us on
|
|
336
|
+
// release; cooldown-only waits need a timer for the nearest expiry.
|
|
337
|
+
const nearestCooldown = this.slots
|
|
338
|
+
.filter(s => !s.disabled && s.inFlight < this.maxPerKey && s.cooldownUntil > now)
|
|
339
|
+
.reduce((min, s) => Math.min(min, s.cooldownUntil), Infinity);
|
|
340
|
+
if (nearestCooldown < Infinity) {
|
|
341
|
+
setTimeout(() => this.drainWaiters(), Math.max(0, nearestCooldown - now) + 10);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Report an HTTP error for a key. Handles cooldown and permanent disable.
|
|
346
|
+
* Call this BEFORE release() if the error warrants special handling.
|
|
347
|
+
*/
|
|
348
|
+
reportError(label, statusCode, retryAfterMs) {
|
|
349
|
+
const slot = this.slots.find(s => s.label === label);
|
|
350
|
+
if (!slot)
|
|
351
|
+
return;
|
|
352
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
353
|
+
slot.disabled = true;
|
|
354
|
+
process.stderr.write(`[KeyPool] ${label} permanently disabled (HTTP ${statusCode})\n`);
|
|
355
|
+
if (this.slots.every(s => s.disabled)) {
|
|
356
|
+
const error = new Error('All API keys have been permanently disabled (401/403)');
|
|
357
|
+
this.rejectAllWaiters(error);
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (statusCode === 429) {
|
|
362
|
+
const cooldown = retryAfterMs !== undefined && Number.isFinite(retryAfterMs) && retryAfterMs >= 0
|
|
363
|
+
? retryAfterMs
|
|
364
|
+
: 60_000;
|
|
365
|
+
slot.cooldownUntil = Date.now() + cooldown;
|
|
366
|
+
process.stderr.write(`[KeyPool] ${label} rate-limited, cooldown ${cooldown}ms\n`);
|
|
367
|
+
}
|
|
368
|
+
this.drainWaiters();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
exports.KeyPool = KeyPool;
|
|
372
|
+
/**
|
|
373
|
+
* Run a pooled operation with a fresh key acquisition on every attempt.
|
|
374
|
+
* This is intentionally outside chatCall's internal retries so a 429/401 on
|
|
375
|
+
* one credential cannot pin all retries to the same bad key.
|
|
376
|
+
*/
|
|
377
|
+
async function runWithKeyPoolRetries(pool, maxAttempts, fn, backoffMs = attempt => Math.min(1000 * 2 ** attempt, 8000)) {
|
|
378
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
379
|
+
throw new Error('maxAttempts must be an integer >= 1');
|
|
380
|
+
}
|
|
381
|
+
let lastError;
|
|
382
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
383
|
+
const lease = await pool.acquire();
|
|
384
|
+
try {
|
|
385
|
+
const value = await fn({ key: lease.key, label: lease.label }, attempt);
|
|
386
|
+
return { value, attempts: attempt };
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
lastError = error;
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
lease.release();
|
|
393
|
+
}
|
|
394
|
+
if (attempt < maxAttempts) {
|
|
395
|
+
const delay = backoffMs(attempt);
|
|
396
|
+
if (delay > 0)
|
|
397
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
throw lastError instanceof Error
|
|
401
|
+
? lastError
|
|
402
|
+
: new Error(`Pooled API failed after ${maxAttempts} attempts: ${String(lastError ?? 'unknown')}`);
|
|
403
|
+
}
|
|
404
|
+
/** Parse Retry-After as either delta-seconds or an HTTP date. */
|
|
405
|
+
function parseRetryAfterMs(value, nowMs = Date.now()) {
|
|
406
|
+
if (!value)
|
|
407
|
+
return undefined;
|
|
408
|
+
const seconds = Number(value);
|
|
409
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
410
|
+
return seconds * 1000;
|
|
411
|
+
const at = Date.parse(value);
|
|
412
|
+
if (!Number.isFinite(at))
|
|
413
|
+
return undefined;
|
|
414
|
+
return Math.max(0, at - nowMs);
|
|
415
|
+
}
|
|
416
|
+
/** Resolve runner concurrency, defaulting to usable judge-key capacity (capped at 3). */
|
|
417
|
+
function resolveWorkerConcurrency(configured, activeJudgeKeys, defaultCap = 3) {
|
|
418
|
+
if (!Number.isInteger(activeJudgeKeys) || activeJudgeKeys < 1) {
|
|
419
|
+
throw new Error('activeJudgeKeys must be an integer >= 1');
|
|
420
|
+
}
|
|
421
|
+
if (configured === undefined || configured.trim() === '') {
|
|
422
|
+
return Math.min(defaultCap, activeJudgeKeys);
|
|
423
|
+
}
|
|
424
|
+
const parsed = Number(configured);
|
|
425
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
426
|
+
throw new Error(`--concurrency must be an integer >= 1 (got ${configured})`);
|
|
427
|
+
}
|
|
428
|
+
return parsed;
|
|
429
|
+
}
|
|
430
|
+
// ─── Bounded Worker Pool ───────────────────────────────────────────────────────
|
|
431
|
+
/**
|
|
432
|
+
* Simple bounded worker pool. Runs up to `concurrency` tasks at once.
|
|
433
|
+
* Unlike p-limit, this is zero-dependency and supports our needs.
|
|
434
|
+
*/
|
|
435
|
+
class WorkerPool {
|
|
436
|
+
concurrency;
|
|
437
|
+
running = 0;
|
|
438
|
+
queue = [];
|
|
439
|
+
constructor(concurrency) {
|
|
440
|
+
if (concurrency < 1)
|
|
441
|
+
throw new Error('WorkerPool concurrency must be >= 1');
|
|
442
|
+
this.concurrency = concurrency;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Run a task when a slot is available.
|
|
446
|
+
* The returned promise resolves/rejects with the task's result.
|
|
447
|
+
*/
|
|
448
|
+
async run(fn) {
|
|
449
|
+
if (this.running >= this.concurrency) {
|
|
450
|
+
await new Promise(resolve => this.queue.push(resolve));
|
|
451
|
+
}
|
|
452
|
+
this.running++;
|
|
453
|
+
try {
|
|
454
|
+
return await fn();
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
this.running--;
|
|
458
|
+
const next = this.queue.shift();
|
|
459
|
+
if (next)
|
|
460
|
+
next();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
exports.WorkerPool = WorkerPool;
|
|
465
|
+
// ─── Cache Path Resolution ─────────────────────────────────────────────────────
|
|
466
|
+
/**
|
|
467
|
+
* Resolve the cache scope that becomes part of the eval-cache key.
|
|
468
|
+
*
|
|
469
|
+
* Default: the effective routing session (`x-opencode-session:<session>`). The
|
|
470
|
+
* session header affects backend routing, so it is part of cache identity and a
|
|
471
|
+
* degraded routed session cannot poison later healthy runs (2026-09-18 incident).
|
|
472
|
+
*
|
|
473
|
+
* Benchmark runs that deliberately want cross-run reuse pin a canonical scope
|
|
474
|
+
* via RENOR_QA_CACHE_SCOPE. The canonical registry is maintained by the
|
|
475
|
+
* benchmark-infrastructure work (PR #67); the routing session stays unique for
|
|
476
|
+
* health even when cache identity is shared.
|
|
477
|
+
*/
|
|
478
|
+
function resolveCacheScope(args) {
|
|
479
|
+
const explicit = (args.explicit ?? '').trim();
|
|
480
|
+
return explicit ? `scope:${explicit}` : `x-opencode-session:${args.session}`;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Resolve the eval cache path from environment.
|
|
484
|
+
* RENOR_EVAL_CACHE=off → null (disabled).
|
|
485
|
+
* RENOR_EVAL_CACHE=on or unset → default path.
|
|
486
|
+
* RENOR_EVAL_CACHE=/path → explicit path.
|
|
487
|
+
*/
|
|
488
|
+
function resolveEvalCachePath(env = process.env) {
|
|
489
|
+
const raw = env.RENOR_EVAL_CACHE;
|
|
490
|
+
if (raw === 'off' || raw === '0')
|
|
491
|
+
return null;
|
|
492
|
+
if (!raw || raw === 'on') {
|
|
493
|
+
return node_path_1.default.join(env.HOME ?? '.', '.local', 'share', 'renor', 'eval-cache.db');
|
|
494
|
+
}
|
|
495
|
+
return node_path_1.default.resolve(raw);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Parse judge API keys from environment.
|
|
499
|
+
* Supports JUDGE_API_KEY_1, JUDGE_API_KEY_2, ... or falls back to single key.
|
|
500
|
+
*/
|
|
501
|
+
function resolveJudgeKeys(env, fallbackKeyEnv, prefixEnv) {
|
|
502
|
+
const keys = [];
|
|
503
|
+
const prefixes = [prefixEnv, fallbackKeyEnv, 'JUDGE_API_KEY', 'OLLAMA_MAX_KEY', 'OLLAMA_API_KEY'].filter(Boolean);
|
|
504
|
+
const seenPrefixes = new Set();
|
|
505
|
+
for (const prefix of prefixes) {
|
|
506
|
+
if (seenPrefixes.has(prefix))
|
|
507
|
+
continue;
|
|
508
|
+
seenPrefixes.add(prefix);
|
|
509
|
+
for (let i = 1; i <= 10; i++) {
|
|
510
|
+
const val = env[`${prefix}_${i}`];
|
|
511
|
+
if (val && val.trim())
|
|
512
|
+
keys.push(val.trim());
|
|
513
|
+
}
|
|
514
|
+
if (keys.length > 0)
|
|
515
|
+
return [...new Set(keys)];
|
|
516
|
+
}
|
|
517
|
+
// Check if prefixEnv or fallbackKeyEnv holds a comma-separated list or single key
|
|
518
|
+
const single = (prefixEnv && env[prefixEnv]) || env[fallbackKeyEnv];
|
|
519
|
+
if (single && single.trim()) {
|
|
520
|
+
const split = single.split(',').map(s => s.trim()).filter(Boolean);
|
|
521
|
+
if (split.length > 0)
|
|
522
|
+
return split;
|
|
523
|
+
}
|
|
524
|
+
return [];
|
|
525
|
+
}
|