pi-mega-compact 0.11.7 → 0.11.9

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.
Files changed (93) hide show
  1. package/dist/extensions/dashboard-server/gather-debug-keys.js +18 -0
  2. package/dist/extensions/dashboard-server/gather-debug.js +129 -0
  3. package/dist/extensions/dashboard-server/routes-maintenance.js +6 -0
  4. package/dist/extensions/dashboard-server/routes-memory-map.js +8 -15
  5. package/dist/extensions/mega-commands.js +65 -5
  6. package/dist/extensions/mega-config.js +7 -0
  7. package/dist/extensions/mega-events/agent-handlers.js +74 -5
  8. package/dist/extensions/mega-events/context-handler.js +132 -3
  9. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  10. package/dist/extensions/mega-trim.js +10 -0
  11. package/dist/src/config/turns.js +2 -1
  12. package/dist/src/memoryGraph/embedding.js +50 -0
  13. package/dist/src/memoryGraph/gates.js +379 -0
  14. package/dist/src/memoryGraph/sources.js +390 -0
  15. package/dist/src/memoryGraph.js +133 -259
  16. package/dist/src/store/sqlite/embedding-cache.js +71 -0
  17. package/dist/src/store/sqlite.js +1 -0
  18. package/dist/src/topics/cluster.js +10 -2
  19. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DR9cLGDL.js → AchievementTiles-DFitrb4Y.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DR9cLGDL.js.map → AchievementTiles-DFitrb4Y.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/{AchievementsTab-svNUgJ1e.js → AchievementsTab-PJrmq8pa.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{AchievementsTab-svNUgJ1e.js.map → AchievementsTab-PJrmq8pa.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/CacheTab-C1WE7dEx.js +2 -0
  24. package/extensions/dashboard-client/dist/assets/CacheTab-C1WE7dEx.js.map +1 -0
  25. package/extensions/dashboard-client/dist/assets/{ConfigTab-CmmWheuW.js → ConfigTab-Dx-RpO8C.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{ConfigTab-CmmWheuW.js.map → ConfigTab-Dx-RpO8C.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/{EventsTab-CFE7jLsa.js → EventsTab-C63BEWEs.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{EventsTab-CFE7jLsa.js.map → EventsTab-C63BEWEs.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{GameTab-CJAX_th_.js → GameTab-DHpJE6km.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{GameTab-CJAX_th_.js.map → GameTab-DHpJE6km.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/MaintenanceTab-BSv0GfBb.js +2 -0
  32. package/extensions/dashboard-client/dist/assets/MaintenanceTab-BSv0GfBb.js.map +1 -0
  33. package/extensions/dashboard-client/dist/assets/MemoryMapTab-Cg1fgEx_.js +2 -0
  34. package/extensions/dashboard-client/dist/assets/MemoryMapTab-Cg1fgEx_.js.map +1 -0
  35. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHQscixy.js → MetricsTab-DCahxNOQ.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHQscixy.js.map → MetricsTab-DCahxNOQ.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{OverviewTab-DBs4DJdd.js → OverviewTab-RYvO1SZL.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{OverviewTab-DBs4DJdd.js.map → OverviewTab-RYvO1SZL.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{ReposTab-DeFz76L5.js → ReposTab-BDGr_UXb.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{ReposTab-DeFz76L5.js.map → ReposTab-BDGr_UXb.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{SessionsTab-BWNsj1OB.js → SessionsTab-ByrTnHaB.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{SessionsTab-BWNsj1OB.js.map → SessionsTab-ByrTnHaB.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{SetupTab-uS6A0xmw.js → SetupTab-CELafKis.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{SetupTab-uS6A0xmw.js.map → SetupTab-CELafKis.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-CiqCOQuL.js → TimeSavedCard-BLWtmfr_.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-CiqCOQuL.js.map → TimeSavedCard-BLWtmfr_.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/TopicsTab-8Fsh_Igx.js +2 -0
  48. package/extensions/dashboard-client/dist/assets/TopicsTab-8Fsh_Igx.js.map +1 -0
  49. package/extensions/dashboard-client/dist/assets/{TurnsTab-CVz4f4-o.js → TurnsTab-D7mgXDe2.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{TurnsTab-CVz4f4-o.js.map → TurnsTab-D7mgXDe2.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/{index-DrktNOoT.js → index-h3jgEs6G.js} +10 -10
  52. package/extensions/dashboard-client/dist/assets/{index-DrktNOoT.js.map → index-h3jgEs6G.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{useSSE-Bf5LOLSi.js → useSSE-C5lmipBF.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{useSSE-Bf5LOLSi.js.map → useSSE-C5lmipBF.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/index.html +1 -1
  56. package/extensions/dashboard-client/src/api/client.ts +5 -0
  57. package/extensions/dashboard-client/src/memory-map-layout.ts +161 -0
  58. package/extensions/dashboard-client/src/memory-map-shapes.tsx +126 -0
  59. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +29 -2
  60. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +101 -0
  61. package/extensions/dashboard-client/src/tabs/MemoryMapTab.tsx +201 -165
  62. package/extensions/dashboard-client/src/tabs/ReposTab.tsx +21 -0
  63. package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +1 -1
  64. package/extensions/dashboard-server/api-contracts/index.ts +3 -1
  65. package/extensions/dashboard-server/api-contracts/maintenance.ts +13 -0
  66. package/extensions/dashboard-server/api-contracts/memory-map.ts +14 -6
  67. package/extensions/dashboard-server/gather-debug-keys.ts +18 -0
  68. package/extensions/dashboard-server/gather-debug.ts +136 -0
  69. package/extensions/dashboard-server/routes-maintenance.ts +7 -0
  70. package/extensions/dashboard-server/routes-memory-map.ts +21 -29
  71. package/extensions/mega-commands.ts +63 -6
  72. package/extensions/mega-config.ts +22 -0
  73. package/extensions/mega-events/agent-handlers.ts +88 -5
  74. package/extensions/mega-events/context-handler.ts +152 -3
  75. package/extensions/mega-runtime/snapshot.ts +1 -1
  76. package/extensions/mega-trim.ts +15 -0
  77. package/package.json +1 -1
  78. package/src/config/turns.ts +5 -2
  79. package/src/memoryGraph/embedding.ts +63 -0
  80. package/src/memoryGraph/gates.ts +451 -0
  81. package/src/memoryGraph/sources.ts +484 -0
  82. package/src/memoryGraph.ts +188 -350
  83. package/src/store/sqlite/embedding-cache.ts +81 -0
  84. package/src/store/sqlite.ts +1 -0
  85. package/src/topics/cluster.ts +11 -1
  86. package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js +0 -2
  87. package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js.map +0 -1
  88. package/extensions/dashboard-client/dist/assets/MaintenanceTab-P4SUsvMf.js +0 -2
  89. package/extensions/dashboard-client/dist/assets/MaintenanceTab-P4SUsvMf.js.map +0 -1
  90. package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js +0 -2
  91. package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js.map +0 -1
  92. package/extensions/dashboard-client/dist/assets/TopicsTab-BWWQxltv.js +0 -2
  93. package/extensions/dashboard-client/dist/assets/TopicsTab-BWWQxltv.js.map +0 -1
@@ -29,6 +29,8 @@ import {
29
29
  createTopicStore,
30
30
  bumpWikiCompactCounter,
31
31
  } from "../../src/topics/index.js";
32
+ import { TrigramEmbedder } from "../../src/embedder.js";
33
+ import type { EmbeddedChunk } from "../../src/topics/types.js";
32
34
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "../mega-trim.js";
33
35
  import {
34
36
  pressureFromPct,
@@ -37,7 +39,21 @@ import {
37
39
  } from "../mega-config.js";
38
40
  import { appendMirrorMessages } from "./mirror-append.js";
39
41
  import { stagedForTail, withRecallTail } from "./recall-tail.js";
40
- import { buildSeparatedPrompt } from "./separated-prompt.js";
42
+ import { buildSeparatedPrompt, buildCacheOptimizedPrompt } from "./separated-prompt.js";
43
+
44
+ /** Best-effort text extraction from an AgentMessage for analytics/logging.
45
+ * AgentMessage is a discriminated union; .content exists only on some
46
+ * variants (user/assistant/toolResult/custom). Narrow by role, never assume. */
47
+ function messageContentText(m: AgentMessage): string {
48
+ const c = (m as { content?: unknown }).content;
49
+ if (typeof c === "string") return c;
50
+ if (Array.isArray(c)) {
51
+ return c
52
+ .map((b) => (b && typeof b === "object" && "text" in b ? String((b as { text?: string }).text ?? "") : ""))
53
+ .join(" ");
54
+ }
55
+ return "";
56
+ }
41
57
 
42
58
  /** Register the context event handler (live-trim auto-trigger). */
43
59
  export function registerContextHandler(
@@ -71,13 +87,33 @@ export function registerContextHandler(
71
87
  // reaches raw_transcript (PREVENT-PI: append-only, view-only).
72
88
  const tailResult = (msgs?: readonly AgentMessage[]) => {
73
89
  const base = msgs ?? messages;
74
- if (!stagedForTail(runtime, config) && !config.messageSeparation) return undefined;
90
+ if (!stagedForTail(runtime, config) && !config.messageSeparation && !config.cacheStriping) return undefined;
75
91
  let result: AgentMessage[] = stagedForTail(runtime, config)
76
92
  ? withRecallTail(base, runtime, config)
77
93
  : (base as AgentMessage[]);
78
- if (config.messageSeparation) {
94
+ if (config.cacheStriping) {
95
+ result = buildCacheOptimizedPrompt(result);
96
+ } else if (config.messageSeparation) {
79
97
  result = buildSeparatedPrompt(result);
80
98
  }
99
+ // P2.5: log prefix stability (fire-and-forget, non-fatal).
100
+ // tailResult is sync, so use .then().catch() on the dynamic import.
101
+ if (result.length > 1) {
102
+ import("../../src/cache-stripe-impl.js").then(({ computeStabilityScore }) => {
103
+ const stableScore = computeStabilityScore(
104
+ { content: messageContentText(result[0] ?? result[0]), chunkId: "prefix", accessCount: 0, lastAccessedAt: 0 },
105
+ result.slice(0, 2).map((m) => ({ content: messageContentText(m), chunkId: "prefix", accessCount: 0, lastAccessedAt: 0 })),
106
+ );
107
+ runtime.logger.info("prefix_stability", {
108
+ stableScore: Number.isFinite(stableScore) ? stableScore : 0,
109
+ prefixMessages: result.length,
110
+ separation: config.messageSeparation ? "v2" : "off",
111
+ striping: config.cacheStriping ? "v3" : "off",
112
+ });
113
+ }).catch(() => {
114
+ // Non-fatal: stability logging is best-effort.
115
+ });
116
+ }
81
117
  return { messages: result };
82
118
  };
83
119
  // Always track context for the dashboard/widget, even when auto is off.
@@ -127,6 +163,33 @@ export function registerContextHandler(
127
163
  epochIdFor(runtime.rt.sessionId),
128
164
  runtime.currentTurn,
129
165
  );
166
+ // P2.2: populate conversation_thread + tool_results tables for
167
+ // prompt-cache analytics and durable separation. The live-array
168
+ // separation (buildSeparatedPrompt / buildCacheOptimizedPrompt in
169
+ // tailResult above) is sufficient for the prompt-construction path;
170
+ // these DB writes persist the split for post-hoc analysis, dashboard
171
+ // queries, and future readers. Non-fatal — failure here never breaks
172
+ // the agent loop (PREVENT-PI-004: zero network, local SQLite only).
173
+ {
174
+ const sid = runtime.rt.sessionId;
175
+ const turn = runtime.currentTurn;
176
+ const now = Date.now();
177
+ const threadStmt = db.prepare(
178
+ "INSERT OR IGNORE INTO conversation_thread (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
179
+ );
180
+ const toolStmt = db.prepare(
181
+ "INSERT OR IGNORE INTO tool_results (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
182
+ );
183
+ for (const m of messages) {
184
+ const role = m.role;
185
+ const content = messageContentText(m);
186
+ if (role === "user" || role === "assistant") {
187
+ threadStmt.run(sid, role, content, turn, now);
188
+ } else if (role === "toolResult" || role === "bashExecution") {
189
+ toolStmt.run(sid, role, content, turn, now);
190
+ }
191
+ }
192
+ }
130
193
  } catch (e) {
131
194
  runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
132
195
  }
@@ -306,6 +369,84 @@ export function registerContextHandler(
306
369
  error: String(wikiErr),
307
370
  });
308
371
  }
372
+
373
+ // D1: seed the topic model from raw_transcript when context_chunks is
374
+ // thin (pre-compaction). Gated on WIKI_SEED_FROM_TURNS; non-fatal.
375
+ // Seeds buildTopicModel with on-the-fly trigram embeddings from
376
+ // recent raw_transcript rows for the current session.
377
+ try {
378
+ if (
379
+ config.autoWikiEnabled &&
380
+ config.turnsDbEnabled &&
381
+ TurnsConfig.WIKI_SEED_FROM_TURNS
382
+ ) {
383
+ const floor = 50;
384
+ const countRow = db
385
+ .prepare(
386
+ `SELECT COUNT(*) AS cnt FROM context_chunks WHERE session_id = ?`,
387
+ )
388
+ .get(runtime.rt.sessionId) as { cnt: number } | undefined;
389
+ const chunkCount = countRow?.cnt ?? 0;
390
+ if (chunkCount < floor) {
391
+ const transcriptRows = db
392
+ .prepare(
393
+ `SELECT DISTINCT content_bytes
394
+ FROM raw_transcript
395
+ WHERE session_id = ?
396
+ AND length(content_bytes) > 10
397
+ ORDER BY seq ASC
398
+ LIMIT 200`,
399
+ )
400
+ .all(runtime.rt.sessionId) as Array<{
401
+ content_bytes: string;
402
+ }>;
403
+ if (transcriptRows.length > 0) {
404
+ const embedder = new TrigramEmbedder();
405
+ const seedChunks: EmbeddedChunk[] = [];
406
+ for (let i = 0; i < transcriptRows.length; i++) {
407
+ const text = transcriptRows[i].content_bytes.trim();
408
+ if (text.length === 0) continue;
409
+ const vec = embedder.embed(text);
410
+ seedChunks.push({
411
+ chunkId: `seed_transcript_${i}`,
412
+ sessionId: runtime.rt.sessionId,
413
+ vec,
414
+ text,
415
+ });
416
+ }
417
+ if (seedChunks.length > 0) {
418
+ const model = buildTopicModel(
419
+ db,
420
+ {
421
+ kRange: [
422
+ TurnsConfig.WIKI_K_MIN,
423
+ TurnsConfig.WIKI_K_MAX,
424
+ ],
425
+ labelTopTerms:
426
+ TurnsConfig.WIKI_LABEL_TOP_TERMS,
427
+ restarts: 5,
428
+ seed: 0x9e3779b9,
429
+ },
430
+ seedChunks,
431
+ );
432
+ createTopicStore(
433
+ runtime.currentStateDir,
434
+ ).replaceTopicModel(model);
435
+ runtime.logger.info("wiki_seed", {
436
+ clusterCount: model.k,
437
+ sourceChunks: seedChunks.length,
438
+ totalChunks: model.totalChunks,
439
+ method: "kmeans+tfidf",
440
+ });
441
+ }
442
+ }
443
+ }
444
+ }
445
+ } catch (seedErr) {
446
+ runtime.logger.warn("wiki_seed_failed", {
447
+ error: String(seedErr),
448
+ });
449
+ }
309
450
  // S27 Task 6: Fire-and-forget dedup pipeline.
310
451
  // Deduplicates raw_transcript rows for the compacted range.
311
452
  try {
@@ -394,6 +535,13 @@ export function registerContextHandler(
394
535
  compactedFrom: ran.result.compactedFrom,
395
536
  summary: ran.result.summary,
396
537
  anchorUserMessages,
538
+ // CRITICAL-OVER ESCAPE HATCH: when context is at/over ~90% of the
539
+ // window, relief takes priority over the anchor floor. Without this,
540
+ // computeLiveTrimCut bails to null (can't satisfy the floor) and the
541
+ // model is fed a raw overflow that errors every turn — the
542
+ // "Already compacted" + overflow death-spiral (2026-08-01 incident).
543
+ // A thin anchor is recoverable; an overflowed session is not.
544
+ criticalOver: (pct ?? 0) >= 90,
397
545
  });
398
546
  if (cut === null) {
399
547
  runtime.diagCtxCutNull++;
@@ -402,6 +550,7 @@ export function registerContextHandler(
402
550
  compactedFrom: ran.result.compactedFrom,
403
551
  viewLen: view.length,
404
552
  anchorUserMessages,
553
+ criticalOver: (pct ?? 0) >= 90,
405
554
  });
406
555
  return tailResult() ?? undefined; // unsafe / below anchor floor — no trim this call
407
556
  }
@@ -225,7 +225,7 @@ export function computeMegaSnapshot(p: SnapshotInput): SnapshotResult {
225
225
  cachePct,
226
226
  megaCacheFlare: p.megaCacheFlare,
227
227
  megaCacheFlarePct: p.megaCacheFlarePct,
228
- perTurnCacheHitPct: p.perTurnCacheHitPct ?? cachePct,
228
+ perTurnCacheHitPct: p.perTurnCacheHitPct,
229
229
  levelUpFlare: p.levelUpFlare,
230
230
  achievementFlare: p.achievementFlare,
231
231
  achievementFlareTitles: p.achievementFlareTitles,
@@ -25,6 +25,13 @@ export interface BuildLiveTrimViewOpts {
25
25
  summary: string;
26
26
  /** Min recent user messages to keep as the anchor (PREVENT-PI-001). */
27
27
  anchorUserMessages: number;
28
+ /** CRITICAL-OVER ESCAPE HATCH: when true, context is over ~90% of the window
29
+ * and relief takes priority over the anchor floor. computeLiveTrimCut returns
30
+ * the boundary-safe cut even if it keeps fewer than `anchorUserMessages` user
31
+ * messages, rather than bailing to null (which would feed the model a raw
32
+ * overflow). A compacted session with a thin anchor is recoverable; an
33
+ * overflowed session that errors every turn is not. Default false. */
34
+ criticalOver?: boolean;
28
35
  }
29
36
 
30
37
  /**
@@ -68,9 +75,17 @@ export function computeLiveTrimCut(view: EngineMessage[], opts: BuildLiveTrimVie
68
75
  if (cut > 1) {
69
76
  const finalRecent = view.slice(cut);
70
77
  if (finalRecent.filter((m) => m.role === "user").length < opts.anchorUserMessages) {
78
+ // CRITICAL-OVER ESCAPE HATCH: when context is critically over the window,
79
+ // return the boundary-safe cut even though it's below the anchor floor.
80
+ // Bailing to null here would feed the model the raw overflow and trap the
81
+ // session in an unrecoverable error loop ("Already compacted" + overflow).
82
+ // A thin anchor is recoverable; an overflowed session is not.
83
+ if (opts.criticalOver && cut > 0) return cut;
71
84
  return null; // cannot satisfy the floor without dropping too much — retry next call
72
85
  }
73
86
  } else {
87
+ // Same escape hatch for the cut > 1 false branch.
88
+ if (opts.criticalOver && cut > 0) return cut;
74
89
  return null;
75
90
  }
76
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.11.7",
3
+ "version": "0.11.9",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -37,8 +37,10 @@ export interface TurnsConfigShape {
37
37
  WIKI_K_MAX: number;
38
38
  /** S51: how many TF-IDF terms form a topic label (default 5). */
39
39
  WIKI_LABEL_TOP_TERMS: number;
40
- /** S51: rebuild the topic model every Nth compaction (default 10). */
40
+ /** S51: rebuild the topic model every Nth compaction (default 3). */
41
41
  WIKI_REBUILD_EVERY_N_COMPACTS: number;
42
+ /** D1: seed the topic model from raw_transcript when context_chunks is thin (default true). */
43
+ WIKI_SEED_FROM_TURNS: boolean;
42
44
  }
43
45
 
44
46
  function envKRange(defMin: number, defMax: number): [number, number] {
@@ -62,7 +64,8 @@ export function loadTurnsConfig(): TurnsConfigShape {
62
64
  WIKI_K_MIN: kMin,
63
65
  WIKI_K_MAX: kMax,
64
66
  WIKI_LABEL_TOP_TERMS: envNum("MEGACOMPACT_WIKI_LABEL_TOP_TERMS", 5),
65
- WIKI_REBUILD_EVERY_N_COMPACTS: envNum("MEGACOMPACT_WIKI_REBUILD_EVERY", 10),
67
+ WIKI_REBUILD_EVERY_N_COMPACTS: envNum("MEGACOMPACT_WIKI_REBUILD_EVERY", 3),
68
+ WIKI_SEED_FROM_TURNS: envBool("MEGACOMPACT_WIKI_SEED_FROM_TURNS", true),
66
69
  };
67
70
  }
68
71
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * embedding.ts — D3 embedding helpers for memoryGraph source builders.
3
+ *
4
+ * Uses TrigramEmbedder with an in-memory SQLite cache table.
5
+ * Isolated here so the main module stays under the 500-line limit.
6
+ */
7
+ import type { DatabaseSync } from "node:sqlite";
8
+ import { TrigramEmbedder } from "../embedder.js";
9
+
10
+ let _trigramEmbedder: TrigramEmbedder | null = null;
11
+
12
+ function lazyEmbedder(): TrigramEmbedder {
13
+ if (!_trigramEmbedder) {
14
+ _trigramEmbedder = new TrigramEmbedder();
15
+ }
16
+ return _trigramEmbedder;
17
+ }
18
+
19
+ function simpleHash(s: string): string {
20
+ let h = 0x811c9dc5;
21
+ for (let i = 0; i < s.length; i++) {
22
+ h ^= s.charCodeAt(i);
23
+ h = Math.imul(h, 0x01000193);
24
+ }
25
+ return h.toString(36);
26
+ }
27
+
28
+ /** Compute or retrieve a cached embedding vector for a text string.
29
+ * Uses the embedding_cache table (created by the A2 migration) for
30
+ * persistence. Falls back to uncached TrigramEmbedder on table error. */
31
+ export function getOrComputeEmbedding(db: DatabaseSync, content: string): number[] {
32
+ try {
33
+ const hash = simpleHash(content);
34
+ const cached = db
35
+ .prepare("SELECT embedding FROM embedding_cache WHERE content_hash = ?")
36
+ .get(hash) as { embedding: string } | undefined;
37
+ if (cached && typeof cached.embedding === "string") {
38
+ return JSON.parse(cached.embedding) as number[];
39
+ }
40
+ const vec = lazyEmbedder().embed(content);
41
+ db.prepare(
42
+ "INSERT OR REPLACE INTO embedding_cache (content_hash, content, embedding) VALUES (?, ?, ?)",
43
+ ).run(hash, content, JSON.stringify(vec));
44
+ return vec;
45
+ } catch {
46
+ return lazyEmbedder().embed(content);
47
+ }
48
+ }
49
+
50
+ /** Cosine similarity between two equal-length vectors. */
51
+ export function cosineSimilarity(a: number[], b: number[]): number {
52
+ let dot = 0;
53
+ let na = 0;
54
+ let nb = 0;
55
+ const len = Math.min(a.length, b.length);
56
+ for (let i = 0; i < len; i++) {
57
+ dot += a[i] * b[i];
58
+ na += a[i] * a[i];
59
+ nb += b[i] * b[i];
60
+ }
61
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
62
+ return denom > 0 ? dot / denom : 0;
63
+ }