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,348 @@
|
|
|
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.DigestWorker = exports.DigestBudgetError = exports.DIGEST_MAX_FAILED_SWEEPS = exports.DIGEST_RETRY_MAX_MS = exports.DIGEST_RETRY_BASE_MS = exports.DIGEST_MIN_INTERVAL_MS = exports.DIGEST_DEBOUNCE_MS = void 0;
|
|
7
|
+
exports.guardedDigestChat = guardedDigestChat;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const sessionRouter_1 = require("../router/sessionRouter");
|
|
10
|
+
const digestCache_1 = require("./digestCache");
|
|
11
|
+
const digests_1 = require("./digests");
|
|
12
|
+
/**
|
|
13
|
+
* The production path of write-time digests (RENOR_DIGESTS, see digests.ts): a background worker that writes the
|
|
14
|
+
* digest of every note that has none, after a save, without ever being on the save's path.
|
|
15
|
+
*
|
|
16
|
+
* - `notify()` (called after a save's index pass) only sets a timer and returns: a save never waits for a model.
|
|
17
|
+
* - Saves are batched: the sweep runs DIGEST_DEBOUNCE_MS after the last save, and no sooner than
|
|
18
|
+
* DIGEST_MIN_INTERVAL_MS after the previous sweep ended, so a burst of saves, or one note appended to all day, is
|
|
19
|
+
* digested once per interval and not once per save. One sweep runs at a time.
|
|
20
|
+
* - A sweep that left notes undigested because the provider failed is tried again after a wait that doubles from
|
|
21
|
+
* DIGEST_RETRY_BASE_MS to DIGEST_RETRY_MAX_MS (with jitter), up to DIGEST_MAX_FAILED_SWEEPS in a row; then the
|
|
22
|
+
* worker stops until the next start (a save tries once more) rather than calling a provider that keeps refusing.
|
|
23
|
+
* The transport inside a call retries on its own too (createOllamaChat, RENOR_DIGEST_RETRIES).
|
|
24
|
+
* - Cost guard, per workspace: every call is counted at the list price in the workspace's own digest cache
|
|
25
|
+
* (`digest_spend`), a call that could take the UTC day past RENOR_DIGEST_MAX_USD_PER_DAY is not made (its worst case
|
|
26
|
+
* is counted: the prompt plus the whole output allowance), and the worker is `paused-budget` until the day after the
|
|
27
|
+
* one that refused the call (a sweep that ends past midnight does not move that). Notes without a digest wait; nothing
|
|
28
|
+
* is lost and search reads the digests that exist. Only this worker is held to the guard: `renor index` and
|
|
29
|
+
* `renor rebuild` are commands someone runs and are not capped (README, "Cost guard").
|
|
30
|
+
* - A digest of text a note no longer holds (edited, forgotten, file gone) is removed at the start of every sweep. A note
|
|
31
|
+
* is edited once the index has seen it (a save, an import, `renor index`): a file changed behind the index's back keeps
|
|
32
|
+
* its digest, as search keeps its old chunks, until then.
|
|
33
|
+
* - The only provider is the digest slot (RENOR_DIGEST_BASE_URL + RENOR_DIGEST_API_KEY). With it unset the worker is
|
|
34
|
+
* `stopped` and nothing is sent: it never falls back to the evidence provider, which the privacy copy does not name.
|
|
35
|
+
* - The sweep is held in the on-demand idle monitor while it runs, so a workspace does not exit in the middle of a call.
|
|
36
|
+
* Waits for a retry are not held: the workspace may sleep, and its next start sweeps again.
|
|
37
|
+
*
|
|
38
|
+
* Nothing here runs unless RENOR_DIGESTS is on, and it is off by default.
|
|
39
|
+
*/
|
|
40
|
+
exports.DIGEST_DEBOUNCE_MS = 15_000;
|
|
41
|
+
exports.DIGEST_MIN_INTERVAL_MS = 60_000;
|
|
42
|
+
exports.DIGEST_RETRY_BASE_MS = 30_000;
|
|
43
|
+
exports.DIGEST_RETRY_MAX_MS = 300_000;
|
|
44
|
+
exports.DIGEST_MAX_FAILED_SWEEPS = 8;
|
|
45
|
+
/** How many days of spend rows are kept. */
|
|
46
|
+
const SPEND_DAYS_KEPT = 14;
|
|
47
|
+
/** Raised, inside the model client, when a call would take the day past its budget. */
|
|
48
|
+
class DigestBudgetError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'DigestBudgetError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.DigestBudgetError = DigestBudgetError;
|
|
55
|
+
const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
56
|
+
/** Roughly three characters a token for the prompt: the worst case a guard must hold, not a measurement. */
|
|
57
|
+
function promptTokensOf(request) {
|
|
58
|
+
return Math.ceil(request.messages.reduce((sum, m) => sum + m.content.length, 0) / 3);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The cost guard around the digest model client: refuses a call whose worst case would take the day past the budget, and
|
|
62
|
+
* records what each call cost. A call that threw is counted at its prompt alone (what is known to have been sent).
|
|
63
|
+
*/
|
|
64
|
+
function guardedDigestChat(args) {
|
|
65
|
+
const { chat, cache, options } = args;
|
|
66
|
+
const usd = (promptTokens, outputTokens) => (promptTokens * options.priceInPerM + outputTokens * options.priceOutPerM) / 1e6;
|
|
67
|
+
return async (request) => {
|
|
68
|
+
const day = args.day();
|
|
69
|
+
const promptEstimate = promptTokensOf(request);
|
|
70
|
+
const worst = usd(promptEstimate, request.max_tokens ?? 8_000);
|
|
71
|
+
if (cache.spentUsd(day) + worst > options.maxUsdPerDay) {
|
|
72
|
+
args.onBudget(day);
|
|
73
|
+
throw new DigestBudgetError(`the digest budget for ${day} (${options.maxUsdPerDay} USD) would be passed`);
|
|
74
|
+
}
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await chat(request);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
cache.addSpend(day, promptEstimate, 0, usd(promptEstimate, 0));
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
const prompt = response.promptTokens ?? promptEstimate;
|
|
84
|
+
const output = (0, sessionRouter_1.billedOutputTokens)(response);
|
|
85
|
+
cache.addSpend(day, prompt, output, usd(prompt, output));
|
|
86
|
+
return response;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
class DigestWorker {
|
|
90
|
+
deps;
|
|
91
|
+
now;
|
|
92
|
+
setTimer;
|
|
93
|
+
clearTimer;
|
|
94
|
+
random;
|
|
95
|
+
log;
|
|
96
|
+
state = 'idle';
|
|
97
|
+
reason;
|
|
98
|
+
timer = null;
|
|
99
|
+
timerAt = 0;
|
|
100
|
+
running = null;
|
|
101
|
+
dirty = false;
|
|
102
|
+
closed = false;
|
|
103
|
+
failures = 0;
|
|
104
|
+
lastEnd = 0;
|
|
105
|
+
budgetHit = false;
|
|
106
|
+
/** The UTC day whose budget refused a call in the sweep in flight: the pause is counted from it, not from when the sweep ends. */
|
|
107
|
+
budgetDay;
|
|
108
|
+
last;
|
|
109
|
+
constructor(deps) {
|
|
110
|
+
this.deps = deps;
|
|
111
|
+
this.now = deps.now ?? Date.now;
|
|
112
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => {
|
|
113
|
+
const handle = setTimeout(fn, ms);
|
|
114
|
+
handle.unref();
|
|
115
|
+
return handle;
|
|
116
|
+
});
|
|
117
|
+
this.clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle));
|
|
118
|
+
this.random = deps.random ?? Math.random;
|
|
119
|
+
this.log = deps.log ?? (line => console.log(line));
|
|
120
|
+
}
|
|
121
|
+
/** Boot: digest what earlier saves, an import, or a workspace that slept left without a digest. */
|
|
122
|
+
start() {
|
|
123
|
+
this.failures = 0;
|
|
124
|
+
if (!this.runnable())
|
|
125
|
+
return;
|
|
126
|
+
this.schedule(exports.DIGEST_DEBOUNCE_MS);
|
|
127
|
+
}
|
|
128
|
+
/** After a save has been indexed. Never throws, never waits: it sets a timer. */
|
|
129
|
+
notify() {
|
|
130
|
+
if (this.closed || !this.runnable())
|
|
131
|
+
return;
|
|
132
|
+
if (this.running) {
|
|
133
|
+
this.dirty = true;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// A retry already waiting keeps its (longer) wait: a save does not shorten a backoff. A stopped worker tries once.
|
|
137
|
+
if (this.state === 'retrying' || this.state === 'paused-budget')
|
|
138
|
+
return;
|
|
139
|
+
this.schedule(exports.DIGEST_DEBOUNCE_MS);
|
|
140
|
+
}
|
|
141
|
+
status() {
|
|
142
|
+
const cache = this.openCache();
|
|
143
|
+
let spent = 0;
|
|
144
|
+
try {
|
|
145
|
+
spent = cache?.spentUsd(utcDay(this.now())) ?? 0;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
cache?.close();
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
state: this.state,
|
|
152
|
+
...(this.reason ? { reason: this.reason } : {}),
|
|
153
|
+
consecutiveFailures: this.failures,
|
|
154
|
+
spentTodayUsd: Math.round(spent * 1e6) / 1e6,
|
|
155
|
+
maxUsdPerDay: this.deps.options.maxUsdPerDay,
|
|
156
|
+
...(this.last ? { lastSweep: this.last } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** Resolves when the sweep in flight, if any, has ended (shutdown, tests). Waits for no timer. */
|
|
160
|
+
async whenSwept() {
|
|
161
|
+
while (this.running)
|
|
162
|
+
await this.running;
|
|
163
|
+
}
|
|
164
|
+
/** Run a sweep now (no debounce, no interval) and wait for it: boot-time catch-up, tests. */
|
|
165
|
+
async sweepNow() {
|
|
166
|
+
if (this.closed || !this.runnable())
|
|
167
|
+
return;
|
|
168
|
+
this.cancelTimer();
|
|
169
|
+
if (this.running)
|
|
170
|
+
await this.running;
|
|
171
|
+
this.launch();
|
|
172
|
+
await this.running;
|
|
173
|
+
}
|
|
174
|
+
/** Stop timers and wait for the sweep in flight; after this the worker does nothing. */
|
|
175
|
+
async close() {
|
|
176
|
+
this.closed = true;
|
|
177
|
+
this.cancelTimer();
|
|
178
|
+
if (this.running)
|
|
179
|
+
await this.running;
|
|
180
|
+
}
|
|
181
|
+
runnable() {
|
|
182
|
+
if (!this.deps.provider) {
|
|
183
|
+
this.stop('no embedding provider: digests are read by meaning, so they are not written');
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (!this.deps.options.chat) {
|
|
187
|
+
// The digest slot is the only provider digests use: another provider's key is never borrowed (see resolveDigestProvider).
|
|
188
|
+
this.stop('no digest model is configured: digests need RENOR_DIGEST_BASE_URL and RENOR_DIGEST_API_KEY, and never use the evidence or Ollama provider instead, so nothing is sent');
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
stop(reason) {
|
|
194
|
+
if (this.state === 'stopped' && this.reason === reason)
|
|
195
|
+
return;
|
|
196
|
+
this.state = 'stopped';
|
|
197
|
+
this.reason = reason;
|
|
198
|
+
this.log(`[digests] worker stopped: ${reason}`);
|
|
199
|
+
}
|
|
200
|
+
cancelTimer() {
|
|
201
|
+
if (this.timer !== null)
|
|
202
|
+
this.clearTimer(this.timer);
|
|
203
|
+
this.timer = null;
|
|
204
|
+
}
|
|
205
|
+
schedule(wait, state = 'scheduled') {
|
|
206
|
+
if (this.closed)
|
|
207
|
+
return;
|
|
208
|
+
const earliest = this.lastEnd + exports.DIGEST_MIN_INTERVAL_MS - this.now();
|
|
209
|
+
const delay = Math.max(wait, state === 'scheduled' ? earliest : 0);
|
|
210
|
+
const at = this.now() + delay;
|
|
211
|
+
// One timer: a nearer request replaces a later one, a later request leaves the nearer one alone.
|
|
212
|
+
if (this.timer !== null && this.timerAt <= at && this.state === state)
|
|
213
|
+
return;
|
|
214
|
+
this.cancelTimer();
|
|
215
|
+
this.state = state;
|
|
216
|
+
if (state === 'scheduled')
|
|
217
|
+
this.reason = undefined;
|
|
218
|
+
this.timerAt = at;
|
|
219
|
+
this.timer = this.setTimer(() => {
|
|
220
|
+
this.timer = null;
|
|
221
|
+
this.launch();
|
|
222
|
+
}, delay);
|
|
223
|
+
}
|
|
224
|
+
launch() {
|
|
225
|
+
if (this.closed || this.running)
|
|
226
|
+
return;
|
|
227
|
+
const track = this.deps.track ?? (work => work());
|
|
228
|
+
this.state = 'running';
|
|
229
|
+
const run = this.sweep().catch((err) => {
|
|
230
|
+
// A sweep that threw (the database closed under it, a disk error) is a failed sweep, said once and retried like any.
|
|
231
|
+
this.failures++;
|
|
232
|
+
this.reason = `sweep failed: ${String(err instanceof Error ? err.message : err).replace(/\s+/g, ' ').slice(0, 160)}`;
|
|
233
|
+
this.log(`[digests] ${this.reason}`);
|
|
234
|
+
}).then(() => this.afterSweep());
|
|
235
|
+
this.running = run.finally(() => { this.running = null; });
|
|
236
|
+
void track(() => this.running).catch(() => undefined);
|
|
237
|
+
}
|
|
238
|
+
afterSweep() {
|
|
239
|
+
this.lastEnd = this.now();
|
|
240
|
+
if (this.closed)
|
|
241
|
+
return;
|
|
242
|
+
if (this.budgetHit) {
|
|
243
|
+
this.budgetHit = false;
|
|
244
|
+
// The day that is spent is the one that refused the call, whenever the sweep ended: one that runs past UTC midnight
|
|
245
|
+
// after the refusal must not push the resume out a whole day further. A resume time already past waits one second.
|
|
246
|
+
const refusedOn = this.budgetDay ?? utcDay(this.now());
|
|
247
|
+
this.budgetDay = undefined;
|
|
248
|
+
const resume = Date.parse(`${refusedOn}T00:00:00Z`) + 86_400_000 + 60_000;
|
|
249
|
+
this.state = 'paused-budget';
|
|
250
|
+
this.reason = `the daily digest budget (${this.deps.options.maxUsdPerDay} USD) is spent; notes without a digest wait until the next UTC day`;
|
|
251
|
+
this.log(`[digests] ${this.reason}`);
|
|
252
|
+
this.dirty = false;
|
|
253
|
+
this.cancelTimer();
|
|
254
|
+
this.timerAt = resume;
|
|
255
|
+
this.timer = this.setTimer(() => {
|
|
256
|
+
this.timer = null;
|
|
257
|
+
this.state = 'idle';
|
|
258
|
+
this.schedule(0);
|
|
259
|
+
}, Math.max(1_000, resume - this.now()));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (this.failures > 0) {
|
|
263
|
+
if (this.failures >= exports.DIGEST_MAX_FAILED_SWEEPS) {
|
|
264
|
+
this.dirty = false;
|
|
265
|
+
this.stop(`the digest model failed ${this.failures} sweeps in a row (${this.reason ?? 'no detail'}); it is tried again at the next start or save`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const wait = Math.min(exports.DIGEST_RETRY_MAX_MS, exports.DIGEST_RETRY_BASE_MS * 2 ** (this.failures - 1)) * (0.8 + 0.4 * this.random());
|
|
269
|
+
this.dirty = false;
|
|
270
|
+
this.log(`[digests] ${this.reason ?? 'a sweep failed'}; trying again in ${Math.round(wait / 1000)} s`);
|
|
271
|
+
this.schedule(wait, 'retrying');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.state = 'idle';
|
|
275
|
+
this.reason = undefined;
|
|
276
|
+
if (this.dirty) {
|
|
277
|
+
this.dirty = false;
|
|
278
|
+
this.schedule(exports.DIGEST_DEBOUNCE_MS);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
openCache() {
|
|
282
|
+
try {
|
|
283
|
+
return new digestCache_1.DigestCache(this.deps.options.cachePath);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
this.log(`[digests] the digest cache cannot be opened: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async sweep() {
|
|
291
|
+
const { db, config, options, provider } = this.deps;
|
|
292
|
+
const cache = this.openCache();
|
|
293
|
+
if (!cache)
|
|
294
|
+
throw new Error('the digest cache cannot be opened');
|
|
295
|
+
try {
|
|
296
|
+
const ledger = config.ledgerRelPath;
|
|
297
|
+
// What search can still reach: notes whose file exists, by the hash the INDEX holds. A digest of any other text is
|
|
298
|
+
// removed. A file edited behind the index's back keeps its index hash, so it keeps its digest, as search keeps its
|
|
299
|
+
// old chunks, until the index sees the change (a save, an import, `renor index`).
|
|
300
|
+
const live = (0, sessionRouter_1.listRouterNotes)(db, config.memoryDir, ledger).filter(note => node_fs_1.default.existsSync(note.file));
|
|
301
|
+
const pruned = cache.retainOnly(new Set(live.map(note => note.noteHash)));
|
|
302
|
+
cache.pruneSpend(SPEND_DAYS_KEPT);
|
|
303
|
+
this.budgetHit = false;
|
|
304
|
+
this.budgetDay = undefined;
|
|
305
|
+
const guarded = {
|
|
306
|
+
...options,
|
|
307
|
+
chat: guardedDigestChat({
|
|
308
|
+
chat: options.chat,
|
|
309
|
+
cache,
|
|
310
|
+
day: () => utcDay(this.now()),
|
|
311
|
+
options,
|
|
312
|
+
onBudget: (day) => { this.budgetHit = true; this.budgetDay = day; },
|
|
313
|
+
}),
|
|
314
|
+
};
|
|
315
|
+
const stats = await (0, digests_1.buildNoteDigests)({
|
|
316
|
+
db,
|
|
317
|
+
vaultPath: config.memoryDir,
|
|
318
|
+
ledgerRelPath: ledger,
|
|
319
|
+
options: guarded,
|
|
320
|
+
cache,
|
|
321
|
+
provider: provider,
|
|
322
|
+
});
|
|
323
|
+
if (stats.failed > 0 && !this.budgetHit) {
|
|
324
|
+
this.failures++;
|
|
325
|
+
this.reason = (stats.error ?? 'the digest model left notes without a digest').replace(/\s+/g, ' ').slice(0, 160);
|
|
326
|
+
}
|
|
327
|
+
else if (stats.failed === 0) {
|
|
328
|
+
this.failures = 0;
|
|
329
|
+
this.reason = undefined;
|
|
330
|
+
}
|
|
331
|
+
this.last = {
|
|
332
|
+
endedAt: new Date(this.now()).toISOString(),
|
|
333
|
+
digested: stats.digested,
|
|
334
|
+
failed: stats.failed,
|
|
335
|
+
gone: stats.gone,
|
|
336
|
+
calls: stats.calls,
|
|
337
|
+
pruned,
|
|
338
|
+
};
|
|
339
|
+
if (stats.digested > 0 || stats.failed > 0 || pruned > 0) {
|
|
340
|
+
this.log(`[digests] swept: ${stats.digested} digested, ${stats.failed} failed, ${stats.cached} already had one, ${pruned} removed, ${stats.calls} call(s)`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
cache.close();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
exports.DigestWorker = DigestWorker;
|
|
@@ -0,0 +1,337 @@
|
|
|
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.CacheOnlyVertex = exports.CountingEmbeddingProvider = exports.CachingEmbeddingProvider = void 0;
|
|
7
|
+
exports.embeddingCallStats = embeddingCallStats;
|
|
8
|
+
exports.resolveEmbeddingCachePath = resolveEmbeddingCachePath;
|
|
9
|
+
exports.describeEmbeddingCache = describeEmbeddingCache;
|
|
10
|
+
exports.invalidateEmbeddingCache = invalidateEmbeddingCache;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
14
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
15
|
+
const IDENTITY_KIND = /^[\w.-]{1,32}$/;
|
|
16
|
+
const IDENTITY_MODEL = /^[\w./:-]{1,80}$/;
|
|
17
|
+
function resolveIdentity(inner, stated) {
|
|
18
|
+
const declared = inner.identity;
|
|
19
|
+
const identity = stated ?? declared;
|
|
20
|
+
if (!identity)
|
|
21
|
+
throw new Error('Embedding cache needs the identity of the provider and model: declare `identity` on the provider or pass options.identity');
|
|
22
|
+
if (!IDENTITY_KIND.test(identity.kind) || !IDENTITY_MODEL.test(identity.model))
|
|
23
|
+
throw new Error('Invalid embedding identity');
|
|
24
|
+
if (stated && declared && (stated.kind !== declared.kind || stated.model !== declared.model)) {
|
|
25
|
+
throw new Error(`Embedding cache identity ${stated.kind}/${stated.model} contradicts the wrapped provider, which declares ${declared.kind}/${declared.model}`);
|
|
26
|
+
}
|
|
27
|
+
return identity;
|
|
28
|
+
}
|
|
29
|
+
const TASK_QUERY = 'RETRIEVAL_QUERY';
|
|
30
|
+
const TASK_DOCUMENT = 'RETRIEVAL_DOCUMENT';
|
|
31
|
+
class CachingEmbeddingProvider {
|
|
32
|
+
dimensions;
|
|
33
|
+
identity;
|
|
34
|
+
/** Lookups: one hit or miss per input occurrence. What the provider received is `sent`. */
|
|
35
|
+
stats = { hits: 0, misses: 0 };
|
|
36
|
+
sent = { texts: 0, calls: 0 };
|
|
37
|
+
inner;
|
|
38
|
+
db;
|
|
39
|
+
namespace;
|
|
40
|
+
insert;
|
|
41
|
+
lookup;
|
|
42
|
+
oldest;
|
|
43
|
+
remove;
|
|
44
|
+
maxEntries;
|
|
45
|
+
maxBytes;
|
|
46
|
+
serving;
|
|
47
|
+
/** Rows and vector bytes held, kept in step with the file so a write does not scan it. */
|
|
48
|
+
entries = 0;
|
|
49
|
+
bytes = 0;
|
|
50
|
+
/** Serving mode only: set by the first cache fault, after which every call goes straight to the provider. */
|
|
51
|
+
disabled = false;
|
|
52
|
+
constructor(inner, options) {
|
|
53
|
+
if ('namespace' in options) {
|
|
54
|
+
throw new Error('CachingEmbeddingProvider no longer takes a namespace: the identity of the provider and model pins the cache (declare `identity` on the provider, or pass options.identity)');
|
|
55
|
+
}
|
|
56
|
+
this.inner = inner;
|
|
57
|
+
this.dimensions = inner.dimensions;
|
|
58
|
+
this.identity = resolveIdentity(inner, options.identity);
|
|
59
|
+
if (options.scope !== undefined && !/^[\w.-]{1,32}$/.test(options.scope))
|
|
60
|
+
throw new Error('Invalid cache scope');
|
|
61
|
+
this.namespace = `${this.identity.kind}/${this.identity.model}/${inner.dimensions}${options.scope ? `/${options.scope}` : ''}`;
|
|
62
|
+
if (!/^[\w./:-]{4,120}$/.test(this.namespace))
|
|
63
|
+
throw new Error('Invalid cache namespace');
|
|
64
|
+
for (const [name, limit] of [['maxEntries', options.maxEntries], ['maxBytes', options.maxBytes]]) {
|
|
65
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
|
|
66
|
+
throw new Error(`${name} must be a positive integer`);
|
|
67
|
+
}
|
|
68
|
+
this.maxEntries = options.maxEntries ?? Infinity;
|
|
69
|
+
this.maxBytes = options.maxBytes ?? Infinity;
|
|
70
|
+
this.serving = options.serving === true;
|
|
71
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(options.cachePath), { recursive: true });
|
|
72
|
+
this.db = new better_sqlite3_1.default(options.cachePath);
|
|
73
|
+
this.db.pragma('journal_mode = WAL');
|
|
74
|
+
// A serving read waits a moment for a lock, not ten seconds: past that the provider is the faster answer.
|
|
75
|
+
this.db.pragma(`busy_timeout = ${this.serving ? 100 : 10000}`);
|
|
76
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS embeddings (
|
|
77
|
+
key TEXT PRIMARY KEY,
|
|
78
|
+
dims INTEGER NOT NULL,
|
|
79
|
+
vec BLOB NOT NULL,
|
|
80
|
+
namespace TEXT NOT NULL,
|
|
81
|
+
created_at TEXT NOT NULL
|
|
82
|
+
)`);
|
|
83
|
+
this.lookup = this.db.prepare('SELECT vec, dims FROM embeddings WHERE key = ? AND namespace = ?');
|
|
84
|
+
this.insert = this.db.prepare('INSERT OR IGNORE INTO embeddings (key, dims, vec, namespace, created_at) VALUES (?, ?, ?, ?, ?)');
|
|
85
|
+
// rowid is insertion order (nothing but the oldest row is ever deleted), so eviction is deterministic.
|
|
86
|
+
this.oldest = this.db.prepare('SELECT rowid AS id, LENGTH(vec) AS n FROM embeddings ORDER BY rowid LIMIT 32');
|
|
87
|
+
this.remove = this.db.prepare('DELETE FROM embeddings WHERE rowid = ?');
|
|
88
|
+
if (this.bounded) {
|
|
89
|
+
const held = this.db.prepare('SELECT COUNT(*) AS entries, COALESCE(SUM(LENGTH(vec)), 0) AS bytes FROM embeddings').get();
|
|
90
|
+
this.entries = held.entries;
|
|
91
|
+
this.bytes = held.bytes;
|
|
92
|
+
this.evict();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
get bounded() {
|
|
96
|
+
return this.maxEntries !== Infinity || this.maxBytes !== Infinity;
|
|
97
|
+
}
|
|
98
|
+
evict() {
|
|
99
|
+
while (this.entries > this.maxEntries || this.bytes > this.maxBytes) {
|
|
100
|
+
const rows = this.oldest.all();
|
|
101
|
+
if (rows.length === 0) {
|
|
102
|
+
this.entries = 0;
|
|
103
|
+
this.bytes = 0;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
if (this.entries <= this.maxEntries && this.bytes <= this.maxBytes)
|
|
108
|
+
return;
|
|
109
|
+
this.remove.run(row.id);
|
|
110
|
+
this.entries -= 1;
|
|
111
|
+
this.bytes -= row.n;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Serving mode: a cache fault ends the cache for this process and the call carries on without it. */
|
|
116
|
+
guard(work, fallback) {
|
|
117
|
+
if (!this.serving)
|
|
118
|
+
return work();
|
|
119
|
+
if (this.disabled)
|
|
120
|
+
return fallback;
|
|
121
|
+
try {
|
|
122
|
+
return work();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
this.disabled = true;
|
|
126
|
+
try {
|
|
127
|
+
this.db.close();
|
|
128
|
+
}
|
|
129
|
+
catch { /* already unusable */ }
|
|
130
|
+
return fallback;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
key(text, taskType) {
|
|
134
|
+
// A serving entry holds float64, a benchmark entry float32: they never share a key.
|
|
135
|
+
return node_crypto_1.default.createHash('sha256')
|
|
136
|
+
.update(`${this.namespace}\0${taskType}\0${this.dimensions}\0${text}${this.serving ? '\0f64' : ''}`)
|
|
137
|
+
.digest('hex');
|
|
138
|
+
}
|
|
139
|
+
read(text, taskType) {
|
|
140
|
+
const row = this.lookup.get(this.key(text, taskType), this.namespace);
|
|
141
|
+
if (!row)
|
|
142
|
+
return null;
|
|
143
|
+
if (row.dims !== this.dimensions)
|
|
144
|
+
return null; // impossible unless the store was corrupted
|
|
145
|
+
const width = this.serving ? 8 : 4;
|
|
146
|
+
if (row.vec.byteLength !== this.dimensions * width)
|
|
147
|
+
return null;
|
|
148
|
+
// Copied out first: a Buffer from the driver is not guaranteed to start on an aligned boundary.
|
|
149
|
+
const bytes = row.vec.buffer.slice(row.vec.byteOffset, row.vec.byteOffset + row.vec.byteLength);
|
|
150
|
+
return Array.from(this.serving ? new Float64Array(bytes) : new Float32Array(bytes));
|
|
151
|
+
}
|
|
152
|
+
write(text, taskType, vector) {
|
|
153
|
+
if (vector.length !== this.dimensions)
|
|
154
|
+
throw new Error('Cached vector has wrong dimensionality');
|
|
155
|
+
const buf = Buffer.from((this.serving ? new Float64Array(vector) : new Float32Array(vector)).buffer);
|
|
156
|
+
const { changes } = this.insert.run(this.key(text, taskType), this.dimensions, buf, this.namespace, new Date().toISOString());
|
|
157
|
+
if (changes > 0 && this.bounded) {
|
|
158
|
+
this.entries += 1;
|
|
159
|
+
this.bytes += buf.length;
|
|
160
|
+
this.evict();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async embed(texts, options) {
|
|
164
|
+
if (texts.length === 0)
|
|
165
|
+
return [];
|
|
166
|
+
if (this.serving)
|
|
167
|
+
return this.inner.embed(texts, options); // documents are not cached on the read path
|
|
168
|
+
const out = [];
|
|
169
|
+
const misses = new Map();
|
|
170
|
+
texts.forEach((text, index) => {
|
|
171
|
+
const cached = this.read(text, TASK_DOCUMENT);
|
|
172
|
+
if (cached) {
|
|
173
|
+
this.stats.hits += 1;
|
|
174
|
+
out[index] = cached;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
this.stats.misses += 1;
|
|
178
|
+
const indexes = misses.get(text);
|
|
179
|
+
if (indexes)
|
|
180
|
+
indexes.push(index);
|
|
181
|
+
else
|
|
182
|
+
misses.set(text, [index]);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (misses.size > 0) {
|
|
186
|
+
const uniqueTexts = [...misses.keys()];
|
|
187
|
+
this.sent.calls += 1;
|
|
188
|
+
this.sent.texts += uniqueTexts.length;
|
|
189
|
+
const fresh = await this.inner.embed(uniqueTexts, options);
|
|
190
|
+
if (fresh.length !== uniqueTexts.length)
|
|
191
|
+
throw new Error('Inner provider returned wrong vector count');
|
|
192
|
+
uniqueTexts.forEach((text, i) => {
|
|
193
|
+
this.write(text, TASK_DOCUMENT, fresh[i]);
|
|
194
|
+
for (const index of misses.get(text))
|
|
195
|
+
out[index] = fresh[i];
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
async embedQuery(text, options) {
|
|
201
|
+
const cached = this.guard(() => this.read(text, TASK_QUERY), null);
|
|
202
|
+
if (cached) {
|
|
203
|
+
this.stats.hits += 1;
|
|
204
|
+
return cached;
|
|
205
|
+
}
|
|
206
|
+
this.stats.misses += 1;
|
|
207
|
+
this.sent.calls += 1;
|
|
208
|
+
this.sent.texts += 1;
|
|
209
|
+
// Providers without a query task type (OpenAI-compatible, hash) are queried
|
|
210
|
+
// through embed(), exactly as retrieval/semantic.ts does when uncached.
|
|
211
|
+
const vector = this.inner.embedQuery
|
|
212
|
+
? await this.inner.embedQuery(text, options)
|
|
213
|
+
: (await this.inner.embed([text], options))[0];
|
|
214
|
+
if (!this.serving)
|
|
215
|
+
this.write(text, TASK_QUERY, vector);
|
|
216
|
+
else
|
|
217
|
+
this.guard(() => this.write(text, TASK_QUERY, vector), undefined);
|
|
218
|
+
return vector;
|
|
219
|
+
}
|
|
220
|
+
get callStats() {
|
|
221
|
+
return { hits: this.stats.hits, misses: this.stats.misses, cached: true, sent: { ...this.sent } };
|
|
222
|
+
}
|
|
223
|
+
close() {
|
|
224
|
+
if (this.db.open)
|
|
225
|
+
this.db.close();
|
|
226
|
+
}
|
|
227
|
+
describe() {
|
|
228
|
+
const inner = this.inner.describe?.() ?? {};
|
|
229
|
+
return { ...inner, cache: { namespace: this.namespace, hits: this.stats.hits, misses: this.stats.misses } };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
exports.CachingEmbeddingProvider = CachingEmbeddingProvider;
|
|
233
|
+
/**
|
|
234
|
+
* Stands in for the cache when it is off, so the texts a run sends to its provider are
|
|
235
|
+
* still counted: an uncached run must not be reported as free.
|
|
236
|
+
*/
|
|
237
|
+
class CountingEmbeddingProvider {
|
|
238
|
+
dimensions;
|
|
239
|
+
identity;
|
|
240
|
+
sent = { texts: 0, calls: 0 };
|
|
241
|
+
inner;
|
|
242
|
+
constructor(inner) {
|
|
243
|
+
this.inner = inner;
|
|
244
|
+
this.dimensions = inner.dimensions;
|
|
245
|
+
this.identity = inner.identity;
|
|
246
|
+
}
|
|
247
|
+
async embed(texts, options) {
|
|
248
|
+
// An empty batch is no provider call, exactly as in CachingEmbeddingProvider.embed: the counter and the real
|
|
249
|
+
// call boundary agree (every shipped provider already returns [] for it without a request).
|
|
250
|
+
if (texts.length === 0)
|
|
251
|
+
return [];
|
|
252
|
+
this.sent.calls += 1;
|
|
253
|
+
this.sent.texts += texts.length;
|
|
254
|
+
return this.inner.embed(texts, options);
|
|
255
|
+
}
|
|
256
|
+
async embedQuery(text, options) {
|
|
257
|
+
this.sent.calls += 1;
|
|
258
|
+
this.sent.texts += 1;
|
|
259
|
+
// Providers without a query task type are queried through embed(), exactly as retrieval/semantic.ts does.
|
|
260
|
+
return this.inner.embedQuery ? this.inner.embedQuery(text, options) : (await this.inner.embed([text], options))[0];
|
|
261
|
+
}
|
|
262
|
+
/** Nothing is looked up, so every text sent is a miss and none is a hit. */
|
|
263
|
+
get callStats() {
|
|
264
|
+
return { hits: 0, misses: this.sent.texts, cached: false, sent: { ...this.sent } };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
exports.CountingEmbeddingProvider = CountingEmbeddingProvider;
|
|
268
|
+
/** A run's embedding figures for the cache report: cache counters, or the count of texts sent when no cache stood in front. */
|
|
269
|
+
function embeddingCallStats(provider) {
|
|
270
|
+
return provider instanceof CachingEmbeddingProvider || provider instanceof CountingEmbeddingProvider ? provider.callStats : undefined;
|
|
271
|
+
}
|
|
272
|
+
/** Resolve the cache setting from the environment. Returns null when disabled. */
|
|
273
|
+
function resolveEmbeddingCachePath(env = process.env) {
|
|
274
|
+
const raw = env.RENOR_EVAL_EMBED_CACHE;
|
|
275
|
+
if (raw === 'off' || raw === '0')
|
|
276
|
+
return null;
|
|
277
|
+
if (!raw || raw === 'on') {
|
|
278
|
+
return node_path_1.default.join(process.env.HOME ?? '.', '.local', 'share', 'renor', 'embed-cache.db');
|
|
279
|
+
}
|
|
280
|
+
return node_path_1.default.resolve(raw);
|
|
281
|
+
}
|
|
282
|
+
/** Cached row count per namespace (a missing DB is an empty cache). */
|
|
283
|
+
function describeEmbeddingCache(cachePath) {
|
|
284
|
+
if (!node_fs_1.default.existsSync(cachePath))
|
|
285
|
+
return [];
|
|
286
|
+
const db = new better_sqlite3_1.default(cachePath, { readonly: true });
|
|
287
|
+
try {
|
|
288
|
+
return db.prepare('SELECT namespace, COUNT(*) AS rows FROM embeddings GROUP BY namespace ORDER BY namespace').all();
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
db.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Invalidate cached vectors by namespace (kind/model/dims), or everything with
|
|
296
|
+
* an explicit `all`. An empty filter is refused so a typo never wipes a paid cache.
|
|
297
|
+
*/
|
|
298
|
+
function invalidateEmbeddingCache(cachePath, filter) {
|
|
299
|
+
if (!filter.namespace && !filter.all)
|
|
300
|
+
throw new Error('invalidateEmbeddingCache: pass a namespace or all: true');
|
|
301
|
+
if (!node_fs_1.default.existsSync(cachePath))
|
|
302
|
+
return 0;
|
|
303
|
+
const db = new better_sqlite3_1.default(cachePath);
|
|
304
|
+
try {
|
|
305
|
+
db.pragma('busy_timeout = 10000');
|
|
306
|
+
return filter.namespace
|
|
307
|
+
? db.prepare('DELETE FROM embeddings WHERE namespace = ?').run(filter.namespace).changes
|
|
308
|
+
: db.prepare('DELETE FROM embeddings').run().changes;
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
db.close();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Read existing benchmark Vertex vectors without creating a cache or supplying an upstream. */
|
|
315
|
+
class CacheOnlyVertex {
|
|
316
|
+
dimensions = 768;
|
|
317
|
+
identity = { kind: 'vertex', model: 'gemini-embedding-001' };
|
|
318
|
+
stats = { hits: 0, misses: 0, upstreamTexts: 0 };
|
|
319
|
+
db;
|
|
320
|
+
constructor(file) { this.db = new better_sqlite3_1.default(file, { readonly: true, fileMustExist: true }); }
|
|
321
|
+
read(text, task) {
|
|
322
|
+
const namespace = 'vertex/gemini-embedding-001/768';
|
|
323
|
+
const key = node_crypto_1.default.createHash('sha256').update(`${namespace}\0${task}\0${this.dimensions}\0${text}`).digest('hex');
|
|
324
|
+
const row = this.db.prepare('SELECT vec, dims FROM embeddings WHERE key = ? AND namespace = ?').get(key, namespace);
|
|
325
|
+
if (!row || row.dims !== 768 || row.vec.length !== 768 * 4) {
|
|
326
|
+
this.stats.misses++;
|
|
327
|
+
throw new Error(`cache-only Vertex miss: task=${task} textSHA256=${node_crypto_1.default.createHash('sha256').update(text).digest('hex')}`);
|
|
328
|
+
}
|
|
329
|
+
this.stats.hits++;
|
|
330
|
+
const bytes = row.vec.buffer.slice(row.vec.byteOffset, row.vec.byteOffset + row.vec.byteLength);
|
|
331
|
+
return Array.from(new Float32Array(bytes));
|
|
332
|
+
}
|
|
333
|
+
async embed(texts) { return texts.map(text => this.read(text, 'RETRIEVAL_DOCUMENT')); }
|
|
334
|
+
async embedQuery(text) { return this.read(text, 'RETRIEVAL_QUERY'); }
|
|
335
|
+
close() { this.db.close(); }
|
|
336
|
+
}
|
|
337
|
+
exports.CacheOnlyVertex = CacheOnlyVertex;
|