@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.
- package/README.md +67 -0
- package/assets/opencode/agents/aegis.md +211 -0
- package/assets/opencode/plugins/aegis.ts +3 -0
- package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
- package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
- package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
- package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
- package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
- package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
- package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
- package/package.json +41 -0
- package/src/__tests__/cli/claude.test.ts +122 -0
- package/src/__tests__/cli/detect.test.ts +91 -0
- package/src/__tests__/cli/install.test.ts +161 -0
- package/src/__tests__/cli/opencode.test.ts +169 -0
- package/src/__tests__/cli/pi.test.ts +113 -0
- package/src/__tests__/cli.test.ts +70 -0
- package/src/__tests__/events/crossProcess.test.ts +82 -0
- package/src/__tests__/events/index.test.ts +32 -0
- package/src/__tests__/extensions.test.ts +81 -0
- package/src/__tests__/migrations/retention.test.ts +118 -0
- package/src/__tests__/queue/index.test.ts +61 -0
- package/src/__tests__/scheduler/index.test.ts +39 -0
- package/src/__tests__/vec/index.test.ts +130 -0
- package/src/__tests__/vec/parity.test.ts +70 -0
- package/src/adapterTypes.ts +23 -0
- package/src/cli/_shared.ts +120 -0
- package/src/cli/bin.ts +97 -0
- package/src/cli/claude.ts +124 -0
- package/src/cli/detect.ts +55 -0
- package/src/cli/hook.ts +25 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/install.ts +185 -0
- package/src/cli/opencode.ts +193 -0
- package/src/cli/pi.ts +74 -0
- package/src/cli/types.ts +78 -0
- package/src/config.ts +163 -0
- package/src/context.ts +172 -0
- package/src/dao/conflicts.ts +26 -0
- package/src/dao/contextItems.ts +70 -0
- package/src/dao/embeddings.ts +78 -0
- package/src/dao/index.ts +9 -0
- package/src/dao/provenanceAudit.ts +108 -0
- package/src/dao/types.ts +142 -0
- package/src/db.ts +780 -0
- package/src/dedup.ts +12 -0
- package/src/embeddings.ts +386 -0
- package/src/events/index.ts +130 -0
- package/src/extensions.ts +140 -0
- package/src/graph.ts +268 -0
- package/src/index.ts +95 -0
- package/src/janitor/archive.ts +66 -0
- package/src/janitor/decay.ts +60 -0
- package/src/janitor/dedup.ts +333 -0
- package/src/janitor/embeddings.ts +209 -0
- package/src/janitor/index.ts +215 -0
- package/src/janitor/promote.ts +188 -0
- package/src/janitor/providers/anthropic.ts +91 -0
- package/src/janitor/providers/cohere.ts +135 -0
- package/src/janitor/providers/gemini.ts +156 -0
- package/src/janitor/providers/ollama.ts +177 -0
- package/src/janitor/providers/openai.ts +121 -0
- package/src/janitor/providers/openrouter.ts +182 -0
- package/src/janitor/providers/types.ts +154 -0
- package/src/janitor/providers/utils.ts +35 -0
- package/src/janitor/supersedes.ts +199 -0
- package/src/lib/honker.ts +54 -0
- package/src/lib/honkerTypes.ts +109 -0
- package/src/lib/jsonlLogger.ts +92 -0
- package/src/lib/writeQueue.ts +28 -0
- package/src/migrations.ts +415 -0
- package/src/paths.ts +22 -0
- package/src/proposals.ts +120 -0
- package/src/providers/disabled.ts +19 -0
- package/src/providers/embeddingProvider.ts +49 -0
- package/src/providers/gemini.ts +37 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/ollama.ts +43 -0
- package/src/providers/openai.ts +35 -0
- package/src/queue/index.ts +53 -0
- package/src/queue/worker.ts +77 -0
- package/src/recall/categorise.ts +139 -0
- package/src/recall/explainer.ts +76 -0
- package/src/scheduler/index.ts +97 -0
- package/src/schema.sql +191 -0
- package/src/secretsScrubber.ts +105 -0
- package/src/shared-db.ts +158 -0
- package/src/vec/index.ts +161 -0
- 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
|
+
}
|