@rohirik/openltm-core 2.8.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.
Files changed (97) hide show
  1. package/README.md +67 -0
  2. package/assets/opencode/agents/aegis.md +211 -0
  3. package/assets/opencode/plugins/aegis.ts +3 -0
  4. package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
  5. package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
  6. package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
  7. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
  8. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
  9. package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
  10. package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
  11. package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
  12. package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
  13. package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
  14. package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
  15. package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
  16. package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
  17. package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
  18. package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
  19. package/package.json +41 -0
  20. package/src/__tests__/cli/claude.test.ts +122 -0
  21. package/src/__tests__/cli/detect.test.ts +91 -0
  22. package/src/__tests__/cli/install.test.ts +161 -0
  23. package/src/__tests__/cli/opencode.test.ts +169 -0
  24. package/src/__tests__/cli/pi.test.ts +113 -0
  25. package/src/__tests__/cli.test.ts +70 -0
  26. package/src/__tests__/events/crossProcess.test.ts +82 -0
  27. package/src/__tests__/events/index.test.ts +32 -0
  28. package/src/__tests__/extensions.test.ts +81 -0
  29. package/src/__tests__/migrations/retention.test.ts +118 -0
  30. package/src/__tests__/queue/index.test.ts +61 -0
  31. package/src/__tests__/scheduler/index.test.ts +39 -0
  32. package/src/__tests__/vec/index.test.ts +130 -0
  33. package/src/__tests__/vec/parity.test.ts +70 -0
  34. package/src/adapterTypes.ts +23 -0
  35. package/src/cli/_shared.ts +120 -0
  36. package/src/cli/bin.ts +97 -0
  37. package/src/cli/claude.ts +124 -0
  38. package/src/cli/detect.ts +55 -0
  39. package/src/cli/hook.ts +25 -0
  40. package/src/cli/index.ts +22 -0
  41. package/src/cli/install.ts +185 -0
  42. package/src/cli/opencode.ts +193 -0
  43. package/src/cli/pi.ts +74 -0
  44. package/src/cli/types.ts +78 -0
  45. package/src/config.ts +163 -0
  46. package/src/context.ts +172 -0
  47. package/src/dao/conflicts.ts +26 -0
  48. package/src/dao/contextItems.ts +70 -0
  49. package/src/dao/embeddings.ts +78 -0
  50. package/src/dao/index.ts +9 -0
  51. package/src/dao/provenanceAudit.ts +108 -0
  52. package/src/dao/types.ts +142 -0
  53. package/src/db.ts +780 -0
  54. package/src/dedup.ts +12 -0
  55. package/src/embeddings.ts +386 -0
  56. package/src/events/index.ts +130 -0
  57. package/src/extensions.ts +140 -0
  58. package/src/graph.ts +268 -0
  59. package/src/index.ts +95 -0
  60. package/src/janitor/archive.ts +66 -0
  61. package/src/janitor/decay.ts +60 -0
  62. package/src/janitor/dedup.ts +333 -0
  63. package/src/janitor/embeddings.ts +209 -0
  64. package/src/janitor/index.ts +215 -0
  65. package/src/janitor/promote.ts +188 -0
  66. package/src/janitor/providers/anthropic.ts +91 -0
  67. package/src/janitor/providers/cohere.ts +135 -0
  68. package/src/janitor/providers/gemini.ts +156 -0
  69. package/src/janitor/providers/ollama.ts +177 -0
  70. package/src/janitor/providers/openai.ts +121 -0
  71. package/src/janitor/providers/openrouter.ts +182 -0
  72. package/src/janitor/providers/types.ts +154 -0
  73. package/src/janitor/providers/utils.ts +35 -0
  74. package/src/janitor/supersedes.ts +199 -0
  75. package/src/lib/honker.ts +54 -0
  76. package/src/lib/honkerTypes.ts +109 -0
  77. package/src/lib/jsonlLogger.ts +92 -0
  78. package/src/lib/writeQueue.ts +28 -0
  79. package/src/migrations.ts +415 -0
  80. package/src/paths.ts +22 -0
  81. package/src/proposals.ts +120 -0
  82. package/src/providers/disabled.ts +19 -0
  83. package/src/providers/embeddingProvider.ts +49 -0
  84. package/src/providers/gemini.ts +37 -0
  85. package/src/providers/index.ts +2 -0
  86. package/src/providers/ollama.ts +43 -0
  87. package/src/providers/openai.ts +35 -0
  88. package/src/queue/index.ts +53 -0
  89. package/src/queue/worker.ts +77 -0
  90. package/src/recall/categorise.ts +139 -0
  91. package/src/recall/explainer.ts +76 -0
  92. package/src/scheduler/index.ts +97 -0
  93. package/src/schema.sql +191 -0
  94. package/src/secretsScrubber.ts +105 -0
  95. package/src/shared-db.ts +158 -0
  96. package/src/vec/index.ts +161 -0
  97. package/tsconfig.json +9 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * dedup.ts — Semantic deduplication of memories.
3
+ * Uses embedding similarity to find and merge duplicate memories.
4
+ * Two modes: automatic (high-confidence merges) and suggested (for review).
5
+ */
6
+ import { getDb, getSetting } from "../shared-db.js";
7
+ import {
8
+ blobToVector,
9
+ cosineSimilarity,
10
+ } from "./embeddings.js";
11
+ import type { EmbeddingVector } from "./providers/types.js";
12
+ import { anthropicLLM } from "./providers/anthropic.js";
13
+ import { cohereLLM } from "./providers/cohere.js";
14
+ import { geminiLLM } from "./providers/gemini.js";
15
+ import { ollamaLLM } from "./providers/ollama.js";
16
+ import { openaiLLM } from "./providers/openai.js";
17
+ import { openrouterLLM } from "./providers/openrouter.js";
18
+ import {
19
+ SETTING_KEYS,
20
+ getDefault,
21
+ type LLMProvider,
22
+ type ProviderType,
23
+ } from "./providers/types.js";
24
+
25
+ /** Resolve the active LLM provider from settings. */
26
+ function getLLMProvider(): LLMProvider {
27
+ const provider = (getSetting(SETTING_KEYS.LLM_PROVIDER) ||
28
+ getDefault(SETTING_KEYS.LLM_PROVIDER)) as ProviderType;
29
+
30
+ switch (provider) {
31
+ case "gemini": return geminiLLM;
32
+ case "openai": return openaiLLM;
33
+ case "anthropic": return anthropicLLM;
34
+ case "cohere": return cohereLLM;
35
+ case "openrouter": return openrouterLLM;
36
+ case "ollama": return ollamaLLM;
37
+ default:
38
+ throw new Error(`Unknown LLM provider: ${provider}`);
39
+ }
40
+ }
41
+
42
+ /** A pair of memories that may be duplicates. */
43
+ export interface DedupCandidate {
44
+ memoryA: { id: number; content: string; category: string };
45
+ memoryB: { id: number; content: string; category: string };
46
+ similarity: number;
47
+ /** LLM verdict: "duplicate", "related", "distinct" */
48
+ verdict?: string;
49
+ /** LLM reasoning for the verdict */
50
+ reasoning?: string;
51
+ /** Suggested merged content (if duplicate) */
52
+ mergedContent?: string;
53
+ }
54
+
55
+ export interface DedupResult {
56
+ /** Total pairs compared. */
57
+ pairsCompared: number;
58
+ /** Candidate duplicates found. */
59
+ candidates: DedupCandidate[];
60
+ /** Number of auto-merged pairs (high confidence). */
61
+ autoMerged: number;
62
+ }
63
+
64
+ /**
65
+ * Scan all active memories with embeddings and find potential duplicates.
66
+ * Uses a two-phase approach:
67
+ * 1. Vector similarity to find candidates (fast, O(n^2) but n is small)
68
+ * 2. Optional LLM verification for borderline cases
69
+ *
70
+ * @param similarityThreshold - Minimum cosine similarity to consider (default 0.85)
71
+ * @param useLLM - Whether to use LLM for verification (default false)
72
+ */
73
+ export async function findDuplicates(
74
+ similarityThreshold = 0.85,
75
+ useLLM = false,
76
+ ): Promise<DedupResult> {
77
+ const db = getDb();
78
+ const result: DedupResult = {
79
+ pairsCompared: 0,
80
+ candidates: [],
81
+ autoMerged: 0,
82
+ };
83
+
84
+ // Load all active memories with embeddings
85
+ const memories = db
86
+ .query<
87
+ { id: number; content: string; category: string; embedding: Buffer },
88
+ []
89
+ >(
90
+ `SELECT id, content, category, embedding FROM memories
91
+ WHERE embedding IS NOT NULL AND status = 'active'
92
+ ORDER BY id ASC`,
93
+ )
94
+ .all();
95
+
96
+ if (memories.length < 2) return result;
97
+
98
+ // Convert embeddings upfront
99
+ const vectors: Map<number, EmbeddingVector> = new Map();
100
+ for (const mem of memories) {
101
+ vectors.set(mem.id, blobToVector(mem.embedding));
102
+ }
103
+
104
+ // Pairwise comparison (upper triangle only)
105
+ for (let i = 0; i < memories.length; i++) {
106
+ const memA = memories[i]!;
107
+ for (let j = i + 1; j < memories.length; j++) {
108
+ const memB = memories[j]!;
109
+ result.pairsCompared++;
110
+ const vecA = vectors.get(memA.id)!;
111
+ const vecB = vectors.get(memB.id)!;
112
+ const similarity = cosineSimilarity(vecA, vecB);
113
+
114
+ if (similarity >= similarityThreshold) {
115
+ const candidate: DedupCandidate = {
116
+ memoryA: {
117
+ id: memA.id,
118
+ content: memA.content,
119
+ category: memA.category,
120
+ },
121
+ memoryB: {
122
+ id: memB.id,
123
+ content: memB.content,
124
+ category: memB.category,
125
+ },
126
+ similarity,
127
+ };
128
+
129
+ // Use LLM to verify and suggest merge (per-pair errors are non-fatal)
130
+ if (useLLM) {
131
+ try {
132
+ const llmResult = await verifyWithLLM(candidate);
133
+ candidate.verdict = llmResult.verdict;
134
+ candidate.reasoning = llmResult.reasoning;
135
+ candidate.mergedContent = llmResult.mergedContent;
136
+ } catch {
137
+ // LLM unavailable — save candidate without verdict
138
+ }
139
+ }
140
+
141
+ result.candidates.push(candidate);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Sort by similarity descending
147
+ result.candidates.sort((a, b) => b.similarity - a.similarity);
148
+
149
+ return result;
150
+ }
151
+
152
+ /** Use LLM to verify a duplicate candidate and suggest merged content. */
153
+ async function verifyWithLLM(candidate: DedupCandidate): Promise<{
154
+ verdict: string;
155
+ reasoning: string;
156
+ mergedContent?: string;
157
+ }> {
158
+ const llm = getLLMProvider();
159
+
160
+ const response = await llm.chat({
161
+ messages: [
162
+ {
163
+ role: "system",
164
+ content: `You are a memory deduplication assistant. Given two memories, determine if they are duplicates, related, or distinct.
165
+ Respond in JSON format: { "verdict": "duplicate"|"related"|"distinct", "reasoning": "brief explanation", "mergedContent": "merged version if duplicate" }
166
+ - "duplicate": Same core insight, possibly different wording. Provide mergedContent that combines both.
167
+ - "related": Complementary but distinct insights. No mergedContent.
168
+ - "distinct": Unrelated despite surface similarity. No mergedContent.`,
169
+ },
170
+ {
171
+ role: "user",
172
+ content: `Memory A [${candidate.memoryA.category}]: ${candidate.memoryA.content}\n\nMemory B [${candidate.memoryB.category}]: ${candidate.memoryB.content}\n\nCosine similarity: ${candidate.similarity.toFixed(3)}`,
173
+ },
174
+ ],
175
+ jsonMode: true,
176
+ temperature: 0.1,
177
+ maxTokens: 300,
178
+ });
179
+
180
+ try {
181
+ const parsed = JSON.parse(response.content) as {
182
+ verdict: string;
183
+ reasoning: string;
184
+ mergedContent?: string;
185
+ };
186
+ return {
187
+ verdict: parsed.verdict || "distinct",
188
+ reasoning: parsed.reasoning || "",
189
+ mergedContent: parsed.mergedContent,
190
+ };
191
+ } catch {
192
+ return { verdict: "distinct", reasoning: "Failed to parse LLM response" };
193
+ }
194
+ }
195
+
196
+ /** Parse a "dedup:<idA>:<idB>" source string. Returns null if not a dedup source. */
197
+ export function parseDedupSource(source: string): { idA: number; idB: number } | null {
198
+ if (!source.startsWith("dedup:")) return null;
199
+ const parts = source.split(":");
200
+ if (parts.length !== 3) return null;
201
+ const idA = parseInt(parts[1]!, 10);
202
+ const idB = parseInt(parts[2]!, 10);
203
+ if (isNaN(idA) || isNaN(idB)) return null;
204
+ return { idA, idB };
205
+ }
206
+
207
+ /**
208
+ * Persist dedup candidates as pending memories for UI review.
209
+ * source encodes "dedup:<idA>:<idB>" so the approve route can call mergeMemories.
210
+ */
211
+ export function saveDedupCandidates(candidates: DedupCandidate[]): number {
212
+ const db = getDb();
213
+ let saved = 0;
214
+
215
+ for (const c of candidates) {
216
+ const source = `dedup:${c.memoryA.id}:${c.memoryB.id}`;
217
+ // Skip if already pending for this pair
218
+ const exists = db
219
+ .query<{ id: number }, [string]>(
220
+ "SELECT id FROM memories WHERE source = ? AND status = 'pending'",
221
+ )
222
+ .get(source);
223
+ if (exists) continue;
224
+
225
+ const pct = Math.round(c.similarity * 100);
226
+ const verdict = c.verdict ? ` | ${c.verdict}` : "";
227
+ const reasoning = c.reasoning ? `\nWhy: ${c.reasoning}` : "";
228
+ const suggested = c.mergedContent ? `\nSuggested merge: ${c.mergedContent}` : "";
229
+ const mergedContent = c.mergedContent
230
+ ? `[${pct}% similar${verdict}]${reasoning}${suggested}\n\nA: ${c.memoryA.content}\nB: ${c.memoryB.content}`
231
+ : `[${pct}% similar — no LLM verdict]\nA: ${c.memoryA.content}\nB: ${c.memoryB.content}`;
232
+
233
+ db.run(
234
+ `INSERT INTO memories (content, category, importance, confidence, source, project_scope, dedup_key, status)
235
+ VALUES (?, ?, 3, ?, ?, NULL, NULL, 'pending')`,
236
+ [mergedContent, c.memoryA.category, c.similarity, source],
237
+ );
238
+ saved++;
239
+ }
240
+
241
+ return saved;
242
+ }
243
+
244
+ /**
245
+ * Merge two memories: keep the one with higher importance/confidence,
246
+ * supersede the other, and optionally update content.
247
+ */
248
+ export function mergeMemories(
249
+ keepId: number,
250
+ supersededId: number,
251
+ mergedContent?: string,
252
+ ): void {
253
+ const db = getDb();
254
+
255
+ db.transaction(() => {
256
+ // Optionally update the kept memory's content
257
+ if (mergedContent) {
258
+ db.run("UPDATE memories SET content = ? WHERE id = ?", [
259
+ mergedContent,
260
+ keepId,
261
+ ]);
262
+ }
263
+
264
+ // Mark the other as superseded
265
+ db.run("UPDATE memories SET status = 'superseded' WHERE id = ?", [
266
+ supersededId,
267
+ ]);
268
+
269
+ // Create a supersedes relation
270
+ db.run(
271
+ `INSERT OR IGNORE INTO memory_relations (source_memory_id, target_memory_id, relationship_type)
272
+ VALUES (?, ?, 'supersedes')`,
273
+ [keepId, supersededId],
274
+ );
275
+
276
+ // Transfer any tags from superseded to kept
277
+ db.run(
278
+ `INSERT OR IGNORE INTO memory_tags (memory_id, tag_id)
279
+ SELECT ?, tag_id FROM memory_tags WHERE memory_id = ?`,
280
+ [keepId, supersededId],
281
+ );
282
+
283
+ // Repoint any relations targeting the superseded memory.
284
+ // Delete rows that would collide on the unique constraint before updating.
285
+ db.run(
286
+ `DELETE FROM memory_relations
287
+ WHERE target_memory_id = ? AND source_memory_id != ?
288
+ AND EXISTS (
289
+ SELECT 1 FROM memory_relations r2
290
+ WHERE r2.target_memory_id = ?
291
+ AND r2.source_memory_id = memory_relations.source_memory_id
292
+ AND r2.relationship_type = memory_relations.relationship_type
293
+ )`,
294
+ [supersededId, keepId, keepId],
295
+ );
296
+ db.run(
297
+ `UPDATE memory_relations SET target_memory_id = ?
298
+ WHERE target_memory_id = ? AND source_memory_id != ?`,
299
+ [keepId, supersededId, keepId],
300
+ );
301
+ db.run(
302
+ `DELETE FROM memory_relations
303
+ WHERE source_memory_id = ? AND target_memory_id != ?
304
+ AND EXISTS (
305
+ SELECT 1 FROM memory_relations r2
306
+ WHERE r2.source_memory_id = ?
307
+ AND r2.target_memory_id = memory_relations.target_memory_id
308
+ AND r2.relationship_type = memory_relations.relationship_type
309
+ )`,
310
+ [supersededId, keepId, keepId],
311
+ );
312
+ db.run(
313
+ `UPDATE memory_relations SET source_memory_id = ?
314
+ WHERE source_memory_id = ? AND target_memory_id != ?`,
315
+ [keepId, supersededId, keepId],
316
+ );
317
+
318
+ // Repoint context_items
319
+ db.run(
320
+ "UPDATE context_items SET memory_id = ? WHERE memory_id = ?",
321
+ [keepId, supersededId],
322
+ );
323
+
324
+ // Boost confidence of kept memory
325
+ db.run(
326
+ `UPDATE memories SET confidence = MIN(1.0, confidence + 0.1),
327
+ confirm_count = confirm_count + 1,
328
+ last_confirmed_at = datetime('now')
329
+ WHERE id = ?`,
330
+ [keepId],
331
+ );
332
+ })();
333
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * embeddings.ts — Embedding generation + cosine similarity for semantic search.
3
+ * Stores embeddings in the memory_embeddings side-table (since migration 010).
4
+ * Provider-agnostic: delegates to whichever EmbeddingProvider is configured.
5
+ */
6
+ import { getDb, getSetting } from "../shared-db.js";
7
+ import { setEmbedding, getEmbedding, listMemoryIdsMissingEmbedding } from "../dao/embeddings.js";
8
+ import { cohereEmbedding } from "./providers/cohere.js";
9
+ import { geminiEmbedding } from "./providers/gemini.js";
10
+ import { ollamaEmbedding } from "./providers/ollama.js";
11
+ import { openaiEmbedding } from "./providers/openai.js";
12
+ import { openrouterEmbedding } from "./providers/openrouter.js";
13
+ import {
14
+ SETTING_KEYS,
15
+ getDefault,
16
+ type EmbeddingProvider,
17
+ type EmbeddingVector,
18
+ type ProviderType,
19
+ } from "./providers/types.js";
20
+
21
+ /** Resolve the active embedding provider from settings. */
22
+ export function getEmbeddingProvider(): EmbeddingProvider {
23
+ const provider = (getSetting(SETTING_KEYS.EMBED_PROVIDER) ||
24
+ getDefault(SETTING_KEYS.EMBED_PROVIDER)) as ProviderType;
25
+
26
+ switch (provider) {
27
+ case "gemini":
28
+ return geminiEmbedding;
29
+ case "openrouter":
30
+ return openrouterEmbedding;
31
+ case "ollama":
32
+ return ollamaEmbedding;
33
+ case "openai":
34
+ return openaiEmbedding;
35
+ case "cohere":
36
+ return cohereEmbedding;
37
+ default:
38
+ throw new Error(`Unknown embedding provider: ${provider}`);
39
+ }
40
+ }
41
+
42
+ /** Convert Float32Array to Buffer for SQLite BLOB storage. */
43
+ export function vectorToBlob(vector: EmbeddingVector): Buffer {
44
+ return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
45
+ }
46
+
47
+ /** Convert SQLite BLOB back to Float32Array. */
48
+ export function blobToVector(blob: Buffer): EmbeddingVector {
49
+ const arrayBuf = blob.buffer.slice(
50
+ blob.byteOffset,
51
+ blob.byteOffset + blob.byteLength,
52
+ );
53
+ return new Float32Array(arrayBuf);
54
+ }
55
+
56
+ /**
57
+ * Cosine similarity between two vectors.
58
+ * Returns a value between -1.0 and 1.0 (1.0 = identical).
59
+ */
60
+ export function cosineSimilarity(a: EmbeddingVector, b: EmbeddingVector): number {
61
+ if (a.length !== b.length) {
62
+ // Incompatible embeddings (model changed) — treat as unrelated
63
+ return 0;
64
+ }
65
+
66
+ let dotProduct = 0;
67
+ let normA = 0;
68
+ let normB = 0;
69
+
70
+ for (let i = 0; i < a.length; i++) {
71
+ dotProduct += a[i]! * b[i]!;
72
+ normA += a[i]! * a[i]!;
73
+ normB += b[i]! * b[i]!;
74
+ }
75
+
76
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
77
+ if (denominator === 0) return 0;
78
+ return dotProduct / denominator;
79
+ }
80
+
81
+ /**
82
+ * Generate and store embeddings for memories that don't have them yet.
83
+ * Processes in batches to stay within provider limits.
84
+ * @returns Number of memories that were embedded.
85
+ */
86
+ export async function embedMissingMemories(
87
+ batchSize = 50,
88
+ ): Promise<number> {
89
+ const db = getDb();
90
+ const provider = getEmbeddingProvider();
91
+
92
+ const missingIds = listMemoryIdsMissingEmbedding(db, 10_000);
93
+ if (missingIds.length === 0) return 0;
94
+
95
+ // Fetch content for missing IDs in one query
96
+ const placeholders = missingIds.map(() => "?").join(",");
97
+ const rows = db
98
+ .query<{ id: number; content: string }, number[]>(
99
+ `SELECT id, content FROM memories WHERE id IN (${placeholders}) AND status IN ('active', 'pending') ORDER BY id ASC`,
100
+ )
101
+ .all(...missingIds);
102
+
103
+ if (rows.length === 0) return 0;
104
+
105
+ let totalEmbedded = 0;
106
+
107
+ for (let i = 0; i < rows.length; i += batchSize) {
108
+ const batch = rows.slice(i, i + batchSize);
109
+ const texts = batch.map((r) => r.content);
110
+
111
+ const result = await provider.embed({ texts });
112
+
113
+ for (let j = 0; j < batch.length; j++) {
114
+ const vector = result.vectors[j];
115
+ if (!vector) continue;
116
+ const blob = vectorToBlob(vector);
117
+ await setEmbedding(db, batch[j]!.id, blob, result.model, result.dimensions);
118
+ }
119
+
120
+ totalEmbedded += batch.length;
121
+ }
122
+
123
+ return totalEmbedded;
124
+ }
125
+
126
+ /**
127
+ * Find memories semantically similar to a query string.
128
+ * @param query - Text to search for
129
+ * @param topK - Max number of results
130
+ * @param minSimilarity - Minimum cosine similarity threshold (0.0 to 1.0)
131
+ * @returns Array of {id, content, similarity} sorted by similarity desc
132
+ */
133
+ export async function semanticSearch(
134
+ query: string,
135
+ topK = 10,
136
+ minSimilarity = 0.5,
137
+ ): Promise<Array<{ id: number; content: string; category: string; importance: number; project_scope: string | null; similarity: number }>> {
138
+ const db = getDb();
139
+ const provider = getEmbeddingProvider();
140
+
141
+ // Generate embedding for the query
142
+ const result = await provider.embed({ texts: [query] });
143
+ const queryVector = result.vectors[0];
144
+ if (!queryVector) throw new Error("Failed to generate query embedding");
145
+
146
+ // Load all memories that have embeddings (join with side-table)
147
+ const rows = db
148
+ .query<
149
+ { id: number; content: string; category: string; importance: number; project_scope: string | null; embedding: Buffer },
150
+ []
151
+ >(
152
+ `SELECT m.id, m.content, m.category, m.importance, m.project_scope, e.embedding
153
+ FROM memories m JOIN memory_embeddings e ON e.memory_id = m.id
154
+ WHERE m.status = 'active'`,
155
+ )
156
+ .all();
157
+
158
+ // Compute similarities
159
+ const scored = rows
160
+ .map((row) => {
161
+ const memVector = blobToVector(row.embedding);
162
+ const similarity = cosineSimilarity(queryVector, memVector);
163
+ return { id: row.id, content: row.content, category: row.category, importance: row.importance, project_scope: row.project_scope, similarity };
164
+ })
165
+ .filter((r) => r.similarity >= minSimilarity)
166
+ .sort((a, b) => b.similarity - a.similarity)
167
+ .slice(0, topK);
168
+
169
+ return scored;
170
+ }
171
+
172
+ /**
173
+ * Find the most similar memory to a given memory ID.
174
+ * Used by dedup to find potential duplicates.
175
+ * @returns Array of {id, content, similarity} sorted desc, excluding the source memory
176
+ */
177
+ export function findSimilarMemories(
178
+ memoryId: number,
179
+ topK = 5,
180
+ minSimilarity = 0.8,
181
+ ): Array<{ id: number; content: string; category: string; similarity: number }> {
182
+ const db = getDb();
183
+
184
+ const sourceBlob = getEmbedding(db, memoryId);
185
+ if (!sourceBlob) return [];
186
+
187
+ const sourceVector = blobToVector(sourceBlob);
188
+
189
+ const rows = db
190
+ .query<
191
+ { id: number; content: string; category: string; embedding: Buffer },
192
+ [number]
193
+ >(
194
+ `SELECT m.id, m.content, m.category, e.embedding
195
+ FROM memories m JOIN memory_embeddings e ON e.memory_id = m.id
196
+ WHERE m.id != ? AND m.status = 'active'`,
197
+ )
198
+ .all(memoryId);
199
+
200
+ return rows
201
+ .map((row) => {
202
+ const memVector = blobToVector(row.embedding);
203
+ const similarity = cosineSimilarity(sourceVector, memVector);
204
+ return { id: row.id, content: row.content, category: row.category, similarity };
205
+ })
206
+ .filter((r) => r.similarity >= minSimilarity)
207
+ .sort((a, b) => b.similarity - a.similarity)
208
+ .slice(0, topK);
209
+ }