pi-mega-compact 0.11.5 → 0.11.6
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/dist/extensions/dashboard-server/api-contracts/cache-stripes.js +8 -0
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +13 -0
- package/dist/extensions/dashboard-server/api-contracts/memory-map.js +1 -0
- package/dist/extensions/dashboard-server/routes-cache.js +28 -0
- package/dist/extensions/dashboard-server/routes-game.js +3 -0
- package/dist/extensions/dashboard-server/routes-memory-map.js +50 -0
- package/dist/extensions/dashboard-server/routes-setup.js +105 -0
- package/dist/extensions/dashboard-server/routes.js +3 -1
- package/dist/extensions/dashboard-server/server.js +7 -1
- package/dist/extensions/mega-compact.js +5 -0
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-events/context-handler.js +10 -2
- package/dist/extensions/mega-events/perf-handler.js +83 -1
- package/dist/extensions/mega-events/separated-prompt.js +248 -0
- package/dist/extensions/mega-runtime/env-loader.js +56 -0
- package/dist/extensions/mega-runtime/reset-runtime.js +1 -0
- package/dist/extensions/mega-runtime/runtime-snapshot.js +1 -0
- package/dist/extensions/mega-runtime/runtime.js +1 -0
- package/dist/extensions/mega-runtime/snapshot.js +1 -0
- package/dist/extensions/mega-runtime/widget.js +5 -0
- package/dist/src/cache-stripe-impl.js +288 -0
- package/dist/src/cache-stripe.js +11 -0
- package/dist/src/config/stopwords.js +24 -0
- package/dist/src/config.js +17 -0
- package/dist/src/memoryGraph.js +299 -0
- package/dist/src/monitoring.js +13 -0
- package/dist/src/queryExpansion.js +163 -0
- package/dist/src/queryReformulation/cache.js +123 -0
- package/dist/src/queryReformulation/rrf.js +29 -0
- package/dist/src/queryReformulation/tfidf.js +61 -0
- package/dist/src/queryReformulation/vagueness.js +33 -0
- package/dist/src/queryReformulation.js +225 -0
- package/dist/src/recallMetrics.js +258 -0
- package/dist/src/store/sqlite/cache-stripes.js +133 -0
- package/dist/src/store/sqlite/fts5-search.js +68 -0
- package/dist/src/store/sqlite/perf-samples.js +1 -0
- package/dist/src/store/sqlite/schema.js +60 -1
- package/dist/src/store/sqlite.js +1 -0
- package/dist/src/store/vectorIndex.js +29 -5
- package/dist/src/tiered-router/types.js +18 -0
- package/dist/src/tieredRouter.js +421 -0
- package/dist/src/vectorStore.js +8 -6
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BoL7qBOJ.js → AchievementTiles-DR9cLGDL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BoL7qBOJ.js.map → AchievementTiles-DR9cLGDL.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BFDE3HYc.js → AchievementsTab-svNUgJ1e.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BFDE3HYc.js.map → AchievementsTab-svNUgJ1e.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js +2 -0
- package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DDFE-ckA.js → ConfigTab-CmmWheuW.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DDFE-ckA.js.map → ConfigTab-CmmWheuW.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-B6UxZ7PM.js → EventsTab-CFE7jLsa.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-B6UxZ7PM.js.map → EventsTab-CFE7jLsa.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/GameTab-CJAX_th_.js +2 -0
- package/extensions/dashboard-client/dist/assets/{GameTab-DehUX-KE.js.map → GameTab-CJAX_th_.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-0GWRt4wn.js → MaintenanceTab-P4SUsvMf.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-0GWRt4wn.js.map → MaintenanceTab-P4SUsvMf.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js +2 -0
- package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MetricsTab-C3rwMVsN.js → MetricsTab-CHQscixy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-C3rwMVsN.js.map → MetricsTab-CHQscixy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-39O9_x5j.js → OverviewTab-DBs4DJdd.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-39O9_x5j.js.map → OverviewTab-DBs4DJdd.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-Cfj6Q5sH.js → ReposTab-DeFz76L5.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-Cfj6Q5sH.js.map → ReposTab-DeFz76L5.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-DXDTCi3r.js → SessionsTab-BWNsj1OB.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-DXDTCi3r.js.map → SessionsTab-BWNsj1OB.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/SetupTab-uS6A0xmw.js +2 -0
- package/extensions/dashboard-client/dist/assets/SetupTab-uS6A0xmw.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cw1dJ4e9.js → TimeSavedCard-CiqCOQuL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cw1dJ4e9.js.map → TimeSavedCard-CiqCOQuL.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BAw972BV.js → TopicsTab-BWWQxltv.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BAw972BV.js.map → TopicsTab-BWWQxltv.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-Cf5mGIdl.js → TurnsTab-CVz4f4-o.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-Cf5mGIdl.js.map → TurnsTab-CVz4f4-o.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/index-CUNn22iV.css +1 -0
- package/extensions/dashboard-client/dist/assets/{index-4gQ7auad.js → index-DrktNOoT.js} +11 -11
- package/extensions/dashboard-client/dist/assets/index-DrktNOoT.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{useSSE-DNgMuyP6.js → useSSE-Bf5LOLSi.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-DNgMuyP6.js.map → useSSE-Bf5LOLSi.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/App.tsx +24 -8
- package/extensions/dashboard-client/src/api/client.ts +15 -0
- package/extensions/dashboard-client/src/components/CacheHitRateTrendCard.tsx +100 -0
- package/extensions/dashboard-client/src/components/StripeDistributionCard.tsx +110 -0
- package/extensions/dashboard-client/src/components/TabBar.tsx +49 -4
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/base.css +30 -0
- package/extensions/dashboard-client/src/styles/cache.css +167 -0
- package/extensions/dashboard-client/src/tabs/CacheTab.tsx +75 -13
- package/extensions/dashboard-client/src/tabs/MemoryMapTab.tsx +440 -0
- package/extensions/dashboard-client/src/tabs/SetupTab.tsx +65 -2
- package/extensions/dashboard-server/api-contracts/cache-stripes.ts +49 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +31 -1
- package/extensions/dashboard-server/api-contracts/index.ts +16 -1
- package/extensions/dashboard-server/api-contracts/memory-map.ts +50 -0
- package/extensions/dashboard-server/api-contracts/setup.ts +22 -0
- package/extensions/dashboard-server/routes-cache.ts +36 -1
- package/extensions/dashboard-server/routes-game.ts +3 -0
- package/extensions/dashboard-server/routes-memory-map.ts +105 -0
- package/extensions/dashboard-server/routes-setup.ts +112 -0
- package/extensions/dashboard-server/routes.ts +3 -1
- package/extensions/dashboard-server/server.ts +6 -0
- package/extensions/mega-compact.ts +5 -0
- package/extensions/mega-config.ts +17 -0
- package/extensions/mega-events/context-handler.ts +10 -2
- package/extensions/mega-events/perf-handler.ts +111 -1
- package/extensions/mega-events/separated-prompt.ts +329 -0
- package/extensions/mega-runtime/env-loader.ts +55 -0
- package/extensions/mega-runtime/helpers.ts +2 -0
- package/extensions/mega-runtime/reset-runtime.ts +1 -0
- package/extensions/mega-runtime/runtime-snapshot.ts +1 -0
- package/extensions/mega-runtime/runtime.ts +1 -0
- package/extensions/mega-runtime/snapshot.ts +6 -0
- package/extensions/mega-runtime/widget-types.ts +3 -0
- package/extensions/mega-runtime/widget.ts +5 -0
- package/package.json +1 -1
- package/src/cache-stripe-impl.ts +385 -0
- package/src/cache-stripe.ts +18 -0
- package/src/config/stopwords.ts +25 -0
- package/src/config.ts +26 -0
- package/src/memoryGraph.ts +438 -0
- package/src/monitoring.ts +35 -0
- package/src/queryExpansion.ts +206 -0
- package/src/queryReformulation/cache.ts +169 -0
- package/src/queryReformulation/rrf.ts +51 -0
- package/src/queryReformulation/tfidf.ts +79 -0
- package/src/queryReformulation/vagueness.ts +38 -0
- package/src/queryReformulation.ts +355 -0
- package/src/recallMetrics.ts +388 -0
- package/src/store/sqlite/cache-stripes.ts +196 -0
- package/src/store/sqlite/fts5-search.ts +90 -0
- package/src/store/sqlite/perf-samples.ts +3 -1
- package/src/store/sqlite/schema.ts +60 -1
- package/src/store/sqlite.ts +1 -0
- package/src/store/vectorIndex.ts +38 -7
- package/src/tiered-router/types.ts +64 -0
- package/src/tieredRouter.ts +489 -0
- package/src/vectorStore.ts +9 -5
- package/extensions/dashboard-client/dist/assets/CacheTab-eKVc-NV1.js +0 -2
- package/extensions/dashboard-client/dist/assets/CacheTab-eKVc-NV1.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/GameTab-DehUX-KE.js +0 -2
- package/extensions/dashboard-client/dist/assets/SetupTab-mMlfunJa.js +0 -2
- package/extensions/dashboard-client/dist/assets/SetupTab-mMlfunJa.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/index-4gQ7auad.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +0 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import type { DatabaseSync } from "node:sqlite";
|
|
5
5
|
import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
|
|
6
6
|
|
|
7
|
-
const SCHEMA_VERSION =
|
|
7
|
+
const SCHEMA_VERSION = 4;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Add `column` (with `decl`, e.g. "INTEGER") to `table` if it does not already
|
|
@@ -366,6 +366,65 @@ export function initSchema(db: DatabaseSync): void {
|
|
|
366
366
|
);
|
|
367
367
|
CREATE INDEX IF NOT EXISTS idx_perf_samples_ts ON perf_samples(ts);
|
|
368
368
|
CREATE INDEX IF NOT EXISTS idx_perf_samples_kind_ts ON perf_samples(kind, ts);
|
|
369
|
+
|
|
370
|
+
-- A1 PLAN_V2 Phase 2: Message Separation tables (SCHEMA_VERSION 3).
|
|
371
|
+
-- Stable conversation thread: user + assistant turns ONLY. Tool results are
|
|
372
|
+
-- stored in tool_results to keep the conversation thread cache-predictable.
|
|
373
|
+
-- Parameterized queries only (PREVENT-002). CREATE TABLE IF NOT EXISTS is
|
|
374
|
+
-- a no-op on re-open — no migration needed.
|
|
375
|
+
CREATE TABLE IF NOT EXISTS conversation_thread (
|
|
376
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
377
|
+
conversation_id TEXT NOT NULL,
|
|
378
|
+
role TEXT NOT NULL CHECK(role IN ('user','assistant')),
|
|
379
|
+
content TEXT NOT NULL,
|
|
380
|
+
turn_index INTEGER NOT NULL,
|
|
381
|
+
timestamp INTEGER NOT NULL
|
|
382
|
+
);
|
|
383
|
+
CREATE INDEX IF NOT EXISTS idx_conv_thread_conv_turn
|
|
384
|
+
ON conversation_thread(conversation_id, turn_index);
|
|
385
|
+
|
|
386
|
+
-- Volatile tool results: appended at END so they never disrupt the
|
|
387
|
+
-- conversation thread prefix (the prompt-cache-friendly prefix).
|
|
388
|
+
CREATE TABLE IF NOT EXISTS tool_results (
|
|
389
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
390
|
+
conversation_id TEXT NOT NULL,
|
|
391
|
+
tool_call_id TEXT NOT NULL,
|
|
392
|
+
tool_result TEXT NOT NULL,
|
|
393
|
+
turn_index INTEGER NOT NULL,
|
|
394
|
+
timestamp INTEGER NOT NULL
|
|
395
|
+
);
|
|
396
|
+
CREATE INDEX IF NOT EXISTS idx_tool_results_conv_turn
|
|
397
|
+
ON tool_results(conversation_id, turn_index);
|
|
398
|
+
|
|
399
|
+
-- A2 PLAN_V2 Phase 3: Vector-Aware Cache Striping (SCHEMA_VERSION 4).
|
|
400
|
+
-- Cache stripe assignments: maps each context chunk to a cache stability
|
|
401
|
+
-- tier (stripe). Layer 0=permanent, 1=epoch, 2=topic, 3=thread, 4=volatile.
|
|
402
|
+
-- stability is the composite score (0.0-1.0) from computeStabilityScore.
|
|
403
|
+
-- Primary key is (chunk_id, epoch_id) so the same chunk can be reassigned
|
|
404
|
+
-- across epochs without conflict. See cache-stripe.ts for scoring details.
|
|
405
|
+
-- Parameterized queries only (PREVENT-002). IF NOT EXISTS is a no-op on re-open.
|
|
406
|
+
CREATE TABLE IF NOT EXISTS cache_stripes (
|
|
407
|
+
chunk_id TEXT NOT NULL,
|
|
408
|
+
stripe INTEGER NOT NULL,
|
|
409
|
+
stability REAL NOT NULL,
|
|
410
|
+
assigned_at INTEGER NOT NULL,
|
|
411
|
+
epoch_id TEXT,
|
|
412
|
+
PRIMARY KEY (chunk_id, epoch_id)
|
|
413
|
+
);
|
|
414
|
+
CREATE INDEX IF NOT EXISTS idx_cs_stripe_stability
|
|
415
|
+
ON cache_stripes(stripe, stability DESC);
|
|
416
|
+
|
|
417
|
+
-- Embedding computation cache: avoids re-embedding content every time
|
|
418
|
+
-- stability scores are recomputed across epochs. Keyed by content_hash
|
|
419
|
+
-- (SHA-256 64-hex-char string). Embeddings are serialized via
|
|
420
|
+
-- Float64Array-to-Buffer to match the embedding_blob convention.
|
|
421
|
+
CREATE TABLE IF NOT EXISTS embedding_cache (
|
|
422
|
+
content_hash TEXT PRIMARY KEY,
|
|
423
|
+
embedding BLOB NOT NULL,
|
|
424
|
+
computed_at INTEGER NOT NULL
|
|
425
|
+
);
|
|
426
|
+
CREATE INDEX IF NOT EXISTS idx_emb_cache_computed
|
|
427
|
+
ON embedding_cache(computed_at DESC);
|
|
369
428
|
`);
|
|
370
429
|
// Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
|
|
371
430
|
// pre-existing table, so new columns added to context_chunks after a store was
|
package/src/store/sqlite.ts
CHANGED
package/src/store/vectorIndex.ts
CHANGED
|
@@ -230,13 +230,21 @@ export async function upsertEmbedding(
|
|
|
230
230
|
const pg = await initVectorIndex();
|
|
231
231
|
if (!pg) return;
|
|
232
232
|
const lit = toVectorLiteral(embedding);
|
|
233
|
-
await
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
233
|
+
const upserted = await withOpenTimeout(
|
|
234
|
+
pg.query(
|
|
235
|
+
`INSERT INTO vector_index (repo_id, session_id, checkpoint_id, embedding)
|
|
236
|
+
VALUES ($1, $2, $3, $4::vector)
|
|
237
|
+
ON CONFLICT (repo_id, session_id, checkpoint_id)
|
|
238
|
+
DO UPDATE SET embedding = EXCLUDED.embedding;`,
|
|
239
|
+
[repoId, sessionId, checkpointId, lit],
|
|
240
|
+
),
|
|
241
|
+
(reason) => {
|
|
242
|
+
disabled = true;
|
|
243
|
+
logWarn(`upsert query timed out after ${queryTimeoutMs()}ms: ${reason}`);
|
|
244
|
+
},
|
|
245
|
+
queryTimeoutMs(),
|
|
239
246
|
);
|
|
247
|
+
if (upserted === undefined) return; // timed out — non-fatal
|
|
240
248
|
} catch (err) {
|
|
241
249
|
disabled = true;
|
|
242
250
|
logWarn(`upsert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -274,7 +282,15 @@ export async function searchAsync(
|
|
|
274
282
|
params.push(repoId);
|
|
275
283
|
}
|
|
276
284
|
sql += " ORDER BY embedding <=> $1::vector LIMIT $2";
|
|
277
|
-
const res = await
|
|
285
|
+
const res = await withOpenTimeout(
|
|
286
|
+
pg.query(sql, params),
|
|
287
|
+
(reason) => {
|
|
288
|
+
disabled = true;
|
|
289
|
+
logWarn(`search query timed out after ${queryTimeoutMs()}ms: ${reason}`);
|
|
290
|
+
},
|
|
291
|
+
queryTimeoutMs(),
|
|
292
|
+
);
|
|
293
|
+
if (!res) return []; // query timed out — degrade to empty (non-fatal)
|
|
278
294
|
return res.rows.map((r: any) => ({
|
|
279
295
|
repoId: r.repo_id as string,
|
|
280
296
|
sessionId: r.session_id as string,
|
|
@@ -300,6 +316,21 @@ function closeTimeoutMs(): number {
|
|
|
300
316
|
return DEFAULT_CLOSE_TIMEOUT_MS;
|
|
301
317
|
}
|
|
302
318
|
|
|
319
|
+
/** Default cap for a PGlite search query. A WASM query that never resolves would
|
|
320
|
+
* otherwise hang the calling turn (and node --test) forever — the catch below
|
|
321
|
+
* only handles thrown errors, not a stalled promise. 0 disables the guard
|
|
322
|
+
* (preserve original unbounded behavior). Mirrors the open/close timeouts. */
|
|
323
|
+
export const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
|
|
324
|
+
|
|
325
|
+
function queryTimeoutMs(): number {
|
|
326
|
+
const raw = process.env.MEGACOMPACT_PGLITE_QUERY_TIMEOUT_MS;
|
|
327
|
+
if (raw !== undefined) {
|
|
328
|
+
const n = Number(raw);
|
|
329
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
330
|
+
}
|
|
331
|
+
return DEFAULT_QUERY_TIMEOUT_MS;
|
|
332
|
+
}
|
|
333
|
+
|
|
303
334
|
/**
|
|
304
335
|
* Race a promise against a timeout. If the inner promise never settles, the
|
|
305
336
|
* timer fires, resolves with `undefined`, and the caller proceeds. This mirrors
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tiered-router/types.ts — S44 tiered router types, interfaces, and defaults.
|
|
3
|
+
*
|
|
4
|
+
* Shell file: pure data/type definitions. Zero runtime dependencies on store
|
|
5
|
+
* internals. Imported by tieredRouter.ts and any consumer that needs the
|
|
6
|
+
* RecallResult / TieredRouterMetrics shapes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SearchHit } from "../vectorStore.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Public interfaces
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface TieredRecallOpts {
|
|
16
|
+
/** Session ID to scope search. When absent, searches cross-session. */
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
/** Max number of results to return (default 10). */
|
|
19
|
+
k?: number;
|
|
20
|
+
/** Skip LRU-cache lookup (force L1 hit). */
|
|
21
|
+
bypassCache?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* FTS5 max BM25 score for the L1 confidence gate.
|
|
24
|
+
* Read from calibration.json at construction; overridable per-call.
|
|
25
|
+
*/
|
|
26
|
+
overrideFts5MaxBm25?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RecallResult {
|
|
30
|
+
/** Checkpoint search hits, ranked by relevance. */
|
|
31
|
+
hits: SearchHit[];
|
|
32
|
+
/** Which tier produced the result. */
|
|
33
|
+
tier: "L0" | "L1" | "L2";
|
|
34
|
+
/** Per-tier latency deltas in ms (contains only tiers that ran). */
|
|
35
|
+
latencyMs: { l0?: number; l1?: number; l2?: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TieredRouterMetrics {
|
|
39
|
+
l0Hits: number;
|
|
40
|
+
l0Misses: number;
|
|
41
|
+
l1Hits: number;
|
|
42
|
+
l1Misses: number;
|
|
43
|
+
l2Hits: number;
|
|
44
|
+
l2Misses: number;
|
|
45
|
+
totalQueries: number;
|
|
46
|
+
avgLatencyMs: { l0: number; l1: number; l2: number };
|
|
47
|
+
fts5MaxBm25: number | null;
|
|
48
|
+
uncalibrated: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Defaults
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Default LRU cache size. */
|
|
56
|
+
export const DEFAULT_CACHE_SIZE = 256;
|
|
57
|
+
|
|
58
|
+
/** Default latency budgets per tier (ms). */
|
|
59
|
+
export const DEFAULT_BUDGET_L0_MS = 1;
|
|
60
|
+
export const DEFAULT_BUDGET_L1_MS = 50;
|
|
61
|
+
export const DEFAULT_BUDGET_L2_MS = 500;
|
|
62
|
+
|
|
63
|
+
/** Default cadence for tiered-metrics logging (every N queries). */
|
|
64
|
+
export const DEFAULT_LOG_CADENCE = 100;
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tieredRouter.ts — S44 three-tier latency-aware recall routing (impl).
|
|
3
|
+
*
|
|
4
|
+
* Three tiers:
|
|
5
|
+
* L0: in-memory LRU cache (recent queries -> results).
|
|
6
|
+
* L1: FTS5 trigram BM25 search (fast, approximate).
|
|
7
|
+
* L2: PGlite HNSW vector search via vectorStore.searchAsync (accurate, slower).
|
|
8
|
+
*
|
|
9
|
+
* Tiered fallback: try L0, miss -> L1, insufficient -> L2, L2 throw -> sync call.
|
|
10
|
+
* Latency-budget configurable per tier. Real `Date.now()` delta instrumentation.
|
|
11
|
+
*
|
|
12
|
+
* Pi-agnostic: no pi runtime types. Uses `openStore` for L1 and optionally
|
|
13
|
+
* the existing VectorStore / vectorSearchAsync for L2.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { openStore } from "./store/sqlite/utils.js";
|
|
20
|
+
import { fts5SearchScoped } from "./store/sqlite/fts5-search.js";
|
|
21
|
+
import { listCheckpoints } from "./store/sqlite.js";
|
|
22
|
+
import { getStateDir } from "./store.js";
|
|
23
|
+
import type { VectorStore, SearchHit } from "./vectorStore.js";
|
|
24
|
+
import { vectorSearchAsync } from "./vector-search.js";
|
|
25
|
+
import {
|
|
26
|
+
type TieredRecallOpts,
|
|
27
|
+
type RecallResult,
|
|
28
|
+
type TieredRouterMetrics,
|
|
29
|
+
DEFAULT_CACHE_SIZE,
|
|
30
|
+
DEFAULT_BUDGET_L0_MS,
|
|
31
|
+
DEFAULT_BUDGET_L1_MS,
|
|
32
|
+
DEFAULT_BUDGET_L2_MS,
|
|
33
|
+
DEFAULT_LOG_CADENCE,
|
|
34
|
+
} from "./tiered-router/types.js";
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Env-based config helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function envBool(name: string, def: boolean): boolean {
|
|
41
|
+
const v = process.env[name];
|
|
42
|
+
if (v === undefined) return def;
|
|
43
|
+
return v === "true" || v === "1";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load fts5MaxBm25 from calibration.json or env override.
|
|
48
|
+
*/
|
|
49
|
+
function loadFts5MaxBm25(stateDir: string): {
|
|
50
|
+
fts5MaxBm25: number | null;
|
|
51
|
+
uncalibrated: boolean;
|
|
52
|
+
} {
|
|
53
|
+
// Env override wins.
|
|
54
|
+
const envVal = process.env.MEGACOMPACT_FTS5_MAX_BM25;
|
|
55
|
+
if (envVal !== undefined) {
|
|
56
|
+
const n = Number(envVal);
|
|
57
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
58
|
+
return { fts5MaxBm25: n, uncalibrated: false };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Try calibration.json.
|
|
63
|
+
const calPath = join(stateDir, "calibration.json");
|
|
64
|
+
if (existsSync(calPath)) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(calPath, "utf-8");
|
|
67
|
+
// PREVENT-001: null check on JSON.parse
|
|
68
|
+
const parsed: unknown = JSON.parse(raw);
|
|
69
|
+
const cal = parsed as Record<string, unknown>;
|
|
70
|
+
if (typeof cal.fts5MaxBm25 === "number" && cal.fts5MaxBm25 >= 0) {
|
|
71
|
+
return { fts5MaxBm25: cal.fts5MaxBm25, uncalibrated: false };
|
|
72
|
+
}
|
|
73
|
+
return { fts5MaxBm25: null, uncalibrated: true };
|
|
74
|
+
} catch {
|
|
75
|
+
// Malformed calibration file -- treat as uncalibrated.
|
|
76
|
+
return { fts5MaxBm25: null, uncalibrated: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { fts5MaxBm25: null, uncalibrated: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Cache helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Deterministic cache key from (sessionId, query, k).
|
|
89
|
+
* Uses first 12 hex chars of SHA-256 (collision probability negligible for a
|
|
90
|
+
* local cache -- full collisions across different queries produce identical keys
|
|
91
|
+
* only if the model IDs and query text collide, which is a cache-containment
|
|
92
|
+
* artifact, not a correctness bug).
|
|
93
|
+
*/
|
|
94
|
+
function cacheKey(sessionId: string | undefined, query: string, k: number): string {
|
|
95
|
+
const payload = `${sessionId ?? "__cross__"}||${query}||${k}`;
|
|
96
|
+
return createHash("sha256").update(payload).digest("hex").slice(0, 12);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Entry in the LRU cache. */
|
|
100
|
+
interface CacheEntry {
|
|
101
|
+
key: string;
|
|
102
|
+
hits: SearchHit[];
|
|
103
|
+
tier: "L0" | "L1" | "L2";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// TieredRouter
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export class TieredRouter {
|
|
111
|
+
// ---------- LRU cache ----------
|
|
112
|
+
private readonly cacheSize: number;
|
|
113
|
+
private cacheMap: Map<string, CacheEntry> = new Map();
|
|
114
|
+
|
|
115
|
+
// ---------- Per-tier latency budgets ----------
|
|
116
|
+
private readonly budgetL0Ms: number;
|
|
117
|
+
private readonly budgetL1Ms: number;
|
|
118
|
+
private readonly budgetL2Ms: number;
|
|
119
|
+
|
|
120
|
+
// ---------- Calibration ----------
|
|
121
|
+
/** Max BM25 score achievable on this corpus (null = uncalibrated). */
|
|
122
|
+
readonly fts5MaxBm25: number | null;
|
|
123
|
+
/** True when no calibration file or env override is available. */
|
|
124
|
+
readonly uncalibrated: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Conservative fallback gate fraction for the L1 confidence check.
|
|
127
|
+
* When uncalibrated, we require L1 score >= fraction * (some heuristic ceiling)
|
|
128
|
+
* before skipping L2. A real calibration sets this dynamically.
|
|
129
|
+
*/
|
|
130
|
+
private readonly l1ConfidenceFraction: number;
|
|
131
|
+
|
|
132
|
+
// ---------- State dir + log path ----------
|
|
133
|
+
private readonly stateDir: string;
|
|
134
|
+
private readonly logPath: string;
|
|
135
|
+
|
|
136
|
+
// ---------- Metrics ----------
|
|
137
|
+
private l0Hits = 0;
|
|
138
|
+
private l0Misses = 0;
|
|
139
|
+
private l1Hits = 0;
|
|
140
|
+
private l1Misses = 0;
|
|
141
|
+
private l2Hits = 0;
|
|
142
|
+
private l2Misses = 0;
|
|
143
|
+
private totalQueries = 0;
|
|
144
|
+
private accLatencyL0 = 0;
|
|
145
|
+
private accLatencyL1 = 0;
|
|
146
|
+
private accLatencyL2 = 0;
|
|
147
|
+
private logCadence: number;
|
|
148
|
+
/** Hashed query -> embedding cache (avoids re-embedding L1 hits for L2). */
|
|
149
|
+
private embeddingCache: Map<string, number[]> = new Map();
|
|
150
|
+
|
|
151
|
+
constructor(opts?: {
|
|
152
|
+
stateDir?: string;
|
|
153
|
+
cacheSize?: number;
|
|
154
|
+
budgetL0Ms?: number;
|
|
155
|
+
budgetL1Ms?: number;
|
|
156
|
+
budgetL2Ms?: number;
|
|
157
|
+
logCadence?: number;
|
|
158
|
+
fts5MaxBm25?: number | null;
|
|
159
|
+
}) {
|
|
160
|
+
const sd = opts?.stateDir ?? getStateDir();
|
|
161
|
+
this.stateDir = sd;
|
|
162
|
+
this.logPath = join(sd, "tiered-router-events.log");
|
|
163
|
+
this.cacheSize = opts?.cacheSize ?? DEFAULT_CACHE_SIZE;
|
|
164
|
+
this.budgetL0Ms = opts?.budgetL0Ms ?? DEFAULT_BUDGET_L0_MS;
|
|
165
|
+
this.budgetL1Ms = opts?.budgetL1Ms ?? DEFAULT_BUDGET_L1_MS;
|
|
166
|
+
this.budgetL2Ms = opts?.budgetL2Ms ?? DEFAULT_BUDGET_L2_MS;
|
|
167
|
+
this.logCadence = opts?.logCadence ?? DEFAULT_LOG_CADENCE;
|
|
168
|
+
|
|
169
|
+
// Calibration: explicit override > env/file > uncalibrated.
|
|
170
|
+
if (opts?.fts5MaxBm25 !== undefined) {
|
|
171
|
+
this.fts5MaxBm25 = opts.fts5MaxBm25;
|
|
172
|
+
this.uncalibrated = opts.fts5MaxBm25 === null;
|
|
173
|
+
} else {
|
|
174
|
+
const cal = loadFts5MaxBm25(this.stateDir);
|
|
175
|
+
this.fts5MaxBm25 = cal.fts5MaxBm25;
|
|
176
|
+
this.uncalibrated = cal.uncalibrated;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Conservative fallback: when uncalibrated, use a small fraction
|
|
180
|
+
// (10% of an assumed ceiling 0.5) to bias toward L2 fallback.
|
|
181
|
+
// A real calibration enables the full confidence gate.
|
|
182
|
+
this.l1ConfidenceFraction = this.fts5MaxBm25 != null ? 0.7 : 0.1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
// Public API
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Three-tier recall routing: L0 (cache) -> L1 (FTS5) -> L2 (PGlite vec).
|
|
191
|
+
*
|
|
192
|
+
* @param query The search query text.
|
|
193
|
+
* @param opts Options including sessionId, k, flag overrides.
|
|
194
|
+
* @param store The VectorStore instance (needed for L2 hydration).
|
|
195
|
+
* @returns Hits ranked by the tier that satisfied the query.
|
|
196
|
+
*/
|
|
197
|
+
async route(
|
|
198
|
+
query: string,
|
|
199
|
+
opts: TieredRecallOpts,
|
|
200
|
+
store: VectorStore,
|
|
201
|
+
): Promise<RecallResult> {
|
|
202
|
+
this.totalQueries++;
|
|
203
|
+
const k = opts.k ?? 10;
|
|
204
|
+
const sessionId = opts.sessionId;
|
|
205
|
+
|
|
206
|
+
// ---- L0: in-memory LRU cache ----
|
|
207
|
+
const ck = cacheKey(sessionId, query, k);
|
|
208
|
+
if (!opts.bypassCache) {
|
|
209
|
+
try {
|
|
210
|
+
const t0L0 = Date.now();
|
|
211
|
+
const cached = this.cacheMap.get(ck);
|
|
212
|
+
const latL0 = Date.now() - t0L0;
|
|
213
|
+
this.accLatencyL0 += latL0;
|
|
214
|
+
|
|
215
|
+
if (cached) {
|
|
216
|
+
this.l0Hits++;
|
|
217
|
+
// Promote to front (delete + set).
|
|
218
|
+
this.cacheMap.delete(ck);
|
|
219
|
+
this.cacheMap.set(ck, cached);
|
|
220
|
+
this.maybeLogMetrics();
|
|
221
|
+
if (latL0 > this.budgetL0Ms) {
|
|
222
|
+
this.logBudgetWarn("L0", latL0, this.budgetL0Ms);
|
|
223
|
+
}
|
|
224
|
+
return { hits: cached.hits, tier: "L0", latencyMs: { l0: latL0 } };
|
|
225
|
+
}
|
|
226
|
+
this.l0Misses++;
|
|
227
|
+
} catch (err) {
|
|
228
|
+
this.l0Misses++;
|
|
229
|
+
this.logTierThrew("L0", err);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- L1: FTS5 trigram BM25 search ----
|
|
234
|
+
let fts5Hits: Array<{ id: string; score: number }> = [];
|
|
235
|
+
try {
|
|
236
|
+
const t0L1 = Date.now();
|
|
237
|
+
const reader = openStore(this.stateDir);
|
|
238
|
+
fts5Hits = fts5SearchScoped(query, reader, sessionId, k);
|
|
239
|
+
const latL1 = Date.now() - t0L1;
|
|
240
|
+
this.accLatencyL1 += latL1;
|
|
241
|
+
if (latL1 > this.budgetL1Ms) {
|
|
242
|
+
this.logBudgetWarn("L1", latL1, this.budgetL1Ms);
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
this.logTierThrew("L1", err);
|
|
246
|
+
// Fall through to L2.
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const l1Score = fts5Hits.length > 0 ? fts5Hits[0]!.score : 0;
|
|
250
|
+
const l1HighEnough =
|
|
251
|
+
fts5Hits.length > 0 &&
|
|
252
|
+
this.fts5MaxBm25 != null &&
|
|
253
|
+
l1Score >= this.l1ConfidenceFraction * this.fts5MaxBm25;
|
|
254
|
+
|
|
255
|
+
if (l1HighEnough && fts5Hits.length >= k) {
|
|
256
|
+
this.l1Hits++;
|
|
257
|
+
const hits = await this.hydrateHits(fts5Hits.slice(0, k), sessionId);
|
|
258
|
+
this.cacheResult(ck, hits, "L1");
|
|
259
|
+
this.maybeLogMetrics();
|
|
260
|
+
return {
|
|
261
|
+
hits,
|
|
262
|
+
tier: "L1",
|
|
263
|
+
latencyMs: { l0: 0, l1: 0 },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (fts5Hits.length > 0) {
|
|
268
|
+
this.l1Misses++; // L1 returned results but insufficient confidence
|
|
269
|
+
} else {
|
|
270
|
+
this.l1Misses++;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- L2: PGlite HNSW vector search ----
|
|
274
|
+
try {
|
|
275
|
+
const t0L2 = Date.now();
|
|
276
|
+
const asyncHits = await vectorSearchAsync(store, sessionId ?? "", query, k);
|
|
277
|
+
const latL2 = Date.now() - t0L2;
|
|
278
|
+
this.accLatencyL2 += latL2;
|
|
279
|
+
|
|
280
|
+
if (asyncHits.length > 0) {
|
|
281
|
+
this.l2Hits++;
|
|
282
|
+
const hits = asyncHits.slice(0, k);
|
|
283
|
+
this.cacheResult(ck, hits, "L2");
|
|
284
|
+
this.maybeLogMetrics();
|
|
285
|
+
if (latL2 > this.budgetL2Ms) {
|
|
286
|
+
this.logBudgetWarn("L2", latL2, this.budgetL2Ms);
|
|
287
|
+
}
|
|
288
|
+
return { hits, tier: "L2", latencyMs: { l2: latL2 } };
|
|
289
|
+
}
|
|
290
|
+
this.l2Misses++;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
this.l2Misses++;
|
|
293
|
+
this.logTierThrew("L2", err);
|
|
294
|
+
// Fall through -- caller should fall back to sync searchRecall().
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.maybeLogMetrics();
|
|
298
|
+
return { hits: [], tier: "L2", latencyMs: {} };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Synchronous L0 cache peek. Returns cached hits for (sessionId, query, k)
|
|
303
|
+
* and promotes the entry to the front of the LRU, or null on miss. Use this
|
|
304
|
+
* from synchronous recall paths that cannot await route() (route() is async).
|
|
305
|
+
* Does not touch L1/L2 — only the in-memory LRU.
|
|
306
|
+
*/
|
|
307
|
+
peekCache(sessionId: string | undefined, query: string, k: number): SearchHit[] | null {
|
|
308
|
+
const ck = cacheKey(sessionId, query, k);
|
|
309
|
+
const cached = this.cacheMap.get(ck);
|
|
310
|
+
if (!cached) return null;
|
|
311
|
+
this.l0Hits++;
|
|
312
|
+
this.cacheMap.delete(ck);
|
|
313
|
+
this.cacheMap.set(ck, cached); // promote to front
|
|
314
|
+
return cached.hits;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Invalidate cached results for a given session.
|
|
319
|
+
* Called when new messages arrive (session context changed).
|
|
320
|
+
*/
|
|
321
|
+
invalidateSession(sessionId?: string): void {
|
|
322
|
+
if (sessionId) {
|
|
323
|
+
const prefix = `${sessionId}||`;
|
|
324
|
+
for (const [key] of this.cacheMap) {
|
|
325
|
+
// cacheKey format: <sessionId>||<query>||<k>
|
|
326
|
+
if (key.startsWith(prefix)) {
|
|
327
|
+
this.cacheMap.delete(key);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
// Cross-session entries start with "__cross__||"
|
|
332
|
+
for (const [key] of this.cacheMap) {
|
|
333
|
+
if (key.startsWith("__cross__||")) {
|
|
334
|
+
this.cacheMap.delete(key);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Force-clear the entire result cache. */
|
|
341
|
+
clearCache(): void {
|
|
342
|
+
this.cacheMap.clear();
|
|
343
|
+
this.embeddingCache.clear();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Snapshot of current metrics for dashboard / logging. */
|
|
347
|
+
getMetrics(): TieredRouterMetrics {
|
|
348
|
+
const q = Math.max(this.totalQueries, 1);
|
|
349
|
+
return {
|
|
350
|
+
l0Hits: this.l0Hits,
|
|
351
|
+
l0Misses: this.l0Misses,
|
|
352
|
+
l1Hits: this.l1Hits,
|
|
353
|
+
l1Misses: this.l1Misses,
|
|
354
|
+
l2Hits: this.l2Hits,
|
|
355
|
+
l2Misses: this.l2Misses,
|
|
356
|
+
totalQueries: this.totalQueries,
|
|
357
|
+
avgLatencyMs: {
|
|
358
|
+
l0: Math.round((this.accLatencyL0 / q) * 100) / 100,
|
|
359
|
+
l1: Math.round((this.accLatencyL1 / q) * 100) / 100,
|
|
360
|
+
l2: Math.round((this.accLatencyL2 / q) * 100) / 100,
|
|
361
|
+
},
|
|
362
|
+
fts5MaxBm25: this.fts5MaxBm25,
|
|
363
|
+
uncalibrated: this.uncalibrated,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Reset all metrics counters (for testing). */
|
|
368
|
+
resetMetrics(): void {
|
|
369
|
+
this.l0Hits = 0;
|
|
370
|
+
this.l0Misses = 0;
|
|
371
|
+
this.l1Hits = 0;
|
|
372
|
+
this.l1Misses = 0;
|
|
373
|
+
this.l2Hits = 0;
|
|
374
|
+
this.l2Misses = 0;
|
|
375
|
+
this.totalQueries = 0;
|
|
376
|
+
this.accLatencyL0 = 0;
|
|
377
|
+
this.accLatencyL1 = 0;
|
|
378
|
+
this.accLatencyL2 = 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// -----------------------------------------------------------------------
|
|
382
|
+
// Internal helpers
|
|
383
|
+
// -----------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Hydrate FTS5 hits (which only carry checkpoint `id` + `score`) into
|
|
387
|
+
* full SearchHit objects by fetching from the sync store.
|
|
388
|
+
*/
|
|
389
|
+
private async hydrateHits(
|
|
390
|
+
fts5Hits: Array<{ id: string; score: number }>,
|
|
391
|
+
sessionId?: string,
|
|
392
|
+
): Promise<SearchHit[]> {
|
|
393
|
+
const cps = listCheckpoints(sessionId ?? "", this.stateDir);
|
|
394
|
+
const cpMap = new Map(cps.map((cp) => [cp.checkpointId, cp]));
|
|
395
|
+
const hits: SearchHit[] = [];
|
|
396
|
+
for (const fh of fts5Hits) {
|
|
397
|
+
const cp = cpMap.get(fh.id);
|
|
398
|
+
if (cp) {
|
|
399
|
+
hits.push({ checkpoint: cp, score: fh.score });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return hits;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Insert into the LRU cache, evicting oldest entry if over capacity. */
|
|
406
|
+
private cacheResult(key: string, hits: SearchHit[], tier: "L0" | "L1" | "L2"): void {
|
|
407
|
+
if (this.cacheMap.has(key)) {
|
|
408
|
+
this.cacheMap.delete(key);
|
|
409
|
+
} else if (this.cacheMap.size >= this.cacheSize) {
|
|
410
|
+
const oldest = this.cacheMap.keys().next();
|
|
411
|
+
if (oldest.value !== undefined) {
|
|
412
|
+
this.cacheMap.delete(oldest.value);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
this.cacheMap.set(key, { key, hits, tier });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Append a structured event to the tiered-router events log (best-effort). */
|
|
419
|
+
private appendLog(ev: Record<string, unknown>): void {
|
|
420
|
+
try {
|
|
421
|
+
mkdirSync(dirname(this.logPath), { recursive: true });
|
|
422
|
+
appendFileSync(this.logPath, JSON.stringify(ev) + "\n");
|
|
423
|
+
} catch {
|
|
424
|
+
/* best-effort -- never break extension on log failure */
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Log periodic tiered metrics at the configured cadence. */
|
|
429
|
+
private maybeLogMetrics(): void {
|
|
430
|
+
if (this.totalQueries % this.logCadence !== 0) return;
|
|
431
|
+
const m = this.getMetrics();
|
|
432
|
+
const payload: Record<string, unknown> = {
|
|
433
|
+
event: "tiered_routing_metrics",
|
|
434
|
+
l0Hits: m.l0Hits,
|
|
435
|
+
l0Misses: m.l0Misses,
|
|
436
|
+
l1Hits: m.l1Hits,
|
|
437
|
+
l1Misses: m.l1Misses,
|
|
438
|
+
l2Hits: m.l2Hits,
|
|
439
|
+
l2Misses: m.l2Misses,
|
|
440
|
+
avgLatencyMs: m.avgLatencyMs,
|
|
441
|
+
totalQueries: m.totalQueries,
|
|
442
|
+
fts5MaxBm25: m.fts5MaxBm25,
|
|
443
|
+
uncalibrated: m.uncalibrated,
|
|
444
|
+
};
|
|
445
|
+
if (m.uncalibrated) {
|
|
446
|
+
(payload as Record<string, string>).note = "run scripts/calibrate-fts5.mjs";
|
|
447
|
+
}
|
|
448
|
+
this.appendLog(payload);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Log a tier-threw event (non-fatal fall-through). */
|
|
452
|
+
private logTierThrew(tier: string, err: unknown): void {
|
|
453
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
454
|
+
this.appendLog({ event: "tier_threw", tier, error: message });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Log a budget-warning event. */
|
|
458
|
+
private logBudgetWarn(tier: string, actualMs: number, budgetMs: number): void {
|
|
459
|
+
this.appendLog({ event: "tier_budget", tier, actualMs, budgetMs });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Factory / singleton
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
let _defaultRouter: TieredRouter | null = null;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Get or create the singleton tiered router.
|
|
471
|
+
* Feature-gated on TIERED_ROUTING_ENABLED (default true).
|
|
472
|
+
* Returns null when the flag is OFF, so callers can fall through cleanly.
|
|
473
|
+
*/
|
|
474
|
+
export function getTieredRouter(): TieredRouter | null {
|
|
475
|
+
const enabled = envBool("MEGACOMPACT_TIERED_ROUTING_ENABLED", true);
|
|
476
|
+
if (!enabled) return null;
|
|
477
|
+
|
|
478
|
+
if (!_defaultRouter) {
|
|
479
|
+
_defaultRouter = new TieredRouter({
|
|
480
|
+
stateDir: getStateDir(),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return _defaultRouter;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Reset the singleton (for testing). */
|
|
487
|
+
export function resetTieredRouter(): void {
|
|
488
|
+
_defaultRouter = null;
|
|
489
|
+
}
|