@brianmichel/pi-noodle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/index.ts +1 -0
- package/package.json +70 -0
- package/src/AGENTS.md +33 -0
- package/src/commands/index.ts +51 -0
- package/src/commands/memory-crud.ts +136 -0
- package/src/commands/review.ts +291 -0
- package/src/commands/setup.ts +189 -0
- package/src/commands/status.ts +32 -0
- package/src/commands/ui.ts +14 -0
- package/src/commands/web.ts +40 -0
- package/src/commands.ts +1 -0
- package/src/config/schema.ts +234 -0
- package/src/config-screen.ts +439 -0
- package/src/config.ts +159 -0
- package/src/constants.ts +1 -0
- package/src/debug-overlay.ts +230 -0
- package/src/extension.ts +166 -0
- package/src/index.ts +1 -0
- package/src/memory/backend.ts +22 -0
- package/src/memory/embedder.ts +7 -0
- package/src/memory/embedders/lm-studio.ts +25 -0
- package/src/memory/embedders/openai.ts +66 -0
- package/src/memory/extractor.ts +189 -0
- package/src/memory/policy.ts +325 -0
- package/src/memory/project-identity.ts +51 -0
- package/src/memory/runtime.ts +70 -0
- package/src/memory/service.ts +761 -0
- package/src/memory/turso-backend.ts +716 -0
- package/src/memory/types.ts +192 -0
- package/src/notifications.ts +11 -0
- package/src/queue.ts +42 -0
- package/src/session.ts +72 -0
- package/src/tools.ts +172 -0
- package/src/types.ts +81 -0
- package/src/utils.ts +68 -0
- package/src/web/dev.ts +7 -0
- package/src/web/index.html +1963 -0
- package/src/web/manager.ts +92 -0
- package/src/web/run.ts +33 -0
- package/src/web/server.ts +212 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import { DEFAULT_AGENT_ID } from "../constants.ts";
|
|
2
|
+
import type { JsonObject } from "../types.ts";
|
|
3
|
+
import { asFiniteNumber, asStringArray, isJsonObject, parseJsonObject, parseJsonStringArray } from "../utils.ts";
|
|
4
|
+
import type { MemoryBackend } from "./backend.ts";
|
|
5
|
+
import type { Embedder } from "./embedder.ts";
|
|
6
|
+
import type {
|
|
7
|
+
AddMemoryInput,
|
|
8
|
+
ConsolidationReport,
|
|
9
|
+
ConversationCaptureInput,
|
|
10
|
+
MemoryCategory,
|
|
11
|
+
MemoryListInput,
|
|
12
|
+
MemoryRecord,
|
|
13
|
+
MemoryScope,
|
|
14
|
+
MemorySearchInput,
|
|
15
|
+
UpdateMemoryInput,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
function normalizeScope(scope?: MemoryScope): MemoryScope {
|
|
24
|
+
return {
|
|
25
|
+
...(scope?.userId ? { userId: scope.userId } : {}),
|
|
26
|
+
assistantId: scope?.assistantId ?? DEFAULT_AGENT_ID,
|
|
27
|
+
...(scope?.sessionId ? { sessionId: scope.sessionId } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build WHERE clause + positional args for scope filtering. */
|
|
32
|
+
function buildScopeFilter(scope?: MemoryScope): {
|
|
33
|
+
clause?: string;
|
|
34
|
+
args: string[];
|
|
35
|
+
} {
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
const args: string[] = [];
|
|
38
|
+
|
|
39
|
+
if (scope?.assistantId) {
|
|
40
|
+
parts.push("(assistant_id = ? OR assistant_id IS NULL)");
|
|
41
|
+
args.push(scope.assistantId);
|
|
42
|
+
}
|
|
43
|
+
if (scope?.userId) {
|
|
44
|
+
parts.push("(user_id = ? OR user_id IS NULL)");
|
|
45
|
+
args.push(scope.userId);
|
|
46
|
+
}
|
|
47
|
+
if (scope?.sessionId) {
|
|
48
|
+
parts.push("session_id = ?");
|
|
49
|
+
args.push(scope.sessionId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return parts.length > 0 ? { clause: parts.join(" AND "), args } : { args };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractText(
|
|
56
|
+
input: AddMemoryInput | ConversationCaptureInput,
|
|
57
|
+
): string {
|
|
58
|
+
if (input.messages && input.messages.length > 0) {
|
|
59
|
+
return input.messages.map((m) => m.content).join("\n");
|
|
60
|
+
}
|
|
61
|
+
const addInput = input as Partial<AddMemoryInput>;
|
|
62
|
+
if (addInput.text) return addInput.text;
|
|
63
|
+
throw new Error("Provide text or messages.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Serialize a Float32Array into a JSON number array string for libSQL vector32(). */
|
|
67
|
+
function toVectorJson(vec: Float32Array): string {
|
|
68
|
+
return JSON.stringify(Array.from(vec));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// SQLite row → MemoryRecord
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function rowToRecord(row: Record<string, unknown>): MemoryRecord {
|
|
76
|
+
const scope: MemoryScope = {};
|
|
77
|
+
if (typeof row.user_id === "string") scope.userId = row.user_id;
|
|
78
|
+
if (typeof row.assistant_id === "string") scope.assistantId = row.assistant_id;
|
|
79
|
+
if (typeof row.session_id === "string") scope.sessionId = row.session_id;
|
|
80
|
+
|
|
81
|
+
const record: MemoryRecord = {
|
|
82
|
+
text: typeof row.text === "string" ? row.text : "",
|
|
83
|
+
categories: parseJsonStringArray(row.categories),
|
|
84
|
+
metadata: parseJsonObject(row.metadata),
|
|
85
|
+
scope,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (typeof row.id === "string") record.id = row.id;
|
|
89
|
+
if (typeof row.category === "string" && row.category.length > 0) {
|
|
90
|
+
record.category = row.category as MemoryCategory;
|
|
91
|
+
}
|
|
92
|
+
if (typeof row.created_at === "number") record.createdAt = row.created_at;
|
|
93
|
+
if (typeof row.last_retrieved === "number") record.lastRetrieved = row.last_retrieved;
|
|
94
|
+
if (typeof row.retrieval_count === "number") record.retrievalCount = row.retrieval_count;
|
|
95
|
+
|
|
96
|
+
return record;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Fallback: cosine similarity in pure JS
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
104
|
+
let dot = 0;
|
|
105
|
+
let normA = 0;
|
|
106
|
+
let normB = 0;
|
|
107
|
+
for (let i = 0; i < a.length; i++) {
|
|
108
|
+
dot += a[i]! * b[i]!;
|
|
109
|
+
normA += a[i]! * a[i]!;
|
|
110
|
+
normB += b[i]! * b[i]!;
|
|
111
|
+
}
|
|
112
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
113
|
+
return denom === 0 ? 0 : dot / denom;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
function metadataMatches(recordMetadata: JsonObject, expected: JsonObject): boolean {
|
|
118
|
+
return Object.entries(expected).every(([key, value]) => {
|
|
119
|
+
const current = recordMetadata[key];
|
|
120
|
+
if (Array.isArray(value) || (value && typeof value === "object")) {
|
|
121
|
+
return JSON.stringify(current) === JSON.stringify(value);
|
|
122
|
+
}
|
|
123
|
+
return current === value;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mergeJsonObjects(primary: JsonObject, secondary: JsonObject): JsonObject {
|
|
128
|
+
const merged: JsonObject = { ...secondary, ...primary };
|
|
129
|
+
|
|
130
|
+
const reasons = new Set<string>();
|
|
131
|
+
for (const value of [primary["trigger_reasons"], secondary["trigger_reasons"]]) {
|
|
132
|
+
if (!Array.isArray(value)) continue;
|
|
133
|
+
for (const item of value) {
|
|
134
|
+
if (typeof item === "string" && item.trim()) reasons.add(item);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (reasons.size > 0) merged["trigger_reasons"] = Array.from(reasons);
|
|
138
|
+
|
|
139
|
+
const signalCounts = [primary["signal_count"], secondary["signal_count"]].filter((value): value is number => typeof value === "number");
|
|
140
|
+
if (signalCounts.length > 0) merged["signal_count"] = Math.max(...signalCounts);
|
|
141
|
+
|
|
142
|
+
const confidences = [primary["confidence"], secondary["confidence"]].filter((value): value is number => typeof value === "number");
|
|
143
|
+
if (confidences.length > 0) merged["confidence"] = Math.max(...confidences);
|
|
144
|
+
|
|
145
|
+
const consolidatedFrom = new Set<string>();
|
|
146
|
+
for (const value of [primary["consolidated_from"], secondary["consolidated_from"]]) {
|
|
147
|
+
if (!Array.isArray(value)) continue;
|
|
148
|
+
for (const item of value) {
|
|
149
|
+
if (typeof item === "string" && item.trim()) consolidatedFrom.add(item);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (consolidatedFrom.size > 0) merged["consolidated_from"] = Array.from(consolidatedFrom);
|
|
153
|
+
|
|
154
|
+
return merged;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function applySearchFilters(records: Array<MemoryRecord & { _score: number }>, filters?: JsonObject): Array<MemoryRecord & { _score: number }> {
|
|
158
|
+
if (!filters) return records;
|
|
159
|
+
|
|
160
|
+
const sourceFilter = asStringArray(filters["source"]);
|
|
161
|
+
const autoSaved = typeof filters["auto_saved"] === "boolean" ? filters["auto_saved"] : undefined;
|
|
162
|
+
const createdAfter = asFiniteNumber(filters["createdAfter"]);
|
|
163
|
+
const createdBefore = asFiniteNumber(filters["createdBefore"]);
|
|
164
|
+
const lastRetrievedAfter = asFiniteNumber(filters["lastRetrievedAfter"]);
|
|
165
|
+
const lastRetrievedBefore = asFiniteNumber(filters["lastRetrievedBefore"]);
|
|
166
|
+
const minRetrievalCount = asFiniteNumber(filters["minRetrievalCount"]);
|
|
167
|
+
const maxRetrievalCount = asFiniteNumber(filters["maxRetrievalCount"]);
|
|
168
|
+
const minConfidence = asFiniteNumber(filters["minConfidence"]);
|
|
169
|
+
const metadataFilter = isJsonObject(filters["metadata"])
|
|
170
|
+
? filters["metadata"] as JsonObject
|
|
171
|
+
: undefined;
|
|
172
|
+
|
|
173
|
+
return records.filter((record) => {
|
|
174
|
+
if (sourceFilter.length > 0) {
|
|
175
|
+
const source = typeof record.metadata.source === "string" ? record.metadata.source : undefined;
|
|
176
|
+
if (!source || !sourceFilter.includes(source)) return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (autoSaved !== undefined) {
|
|
180
|
+
if (record.metadata.auto_saved !== autoSaved) return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (createdAfter !== undefined && (record.createdAt ?? 0) < createdAfter) return false;
|
|
184
|
+
if (createdBefore !== undefined && (record.createdAt ?? Number.MAX_SAFE_INTEGER) > createdBefore) return false;
|
|
185
|
+
if (lastRetrievedAfter !== undefined && (record.lastRetrieved ?? 0) < lastRetrievedAfter) return false;
|
|
186
|
+
if (lastRetrievedBefore !== undefined && (record.lastRetrieved ?? Number.MAX_SAFE_INTEGER) > lastRetrievedBefore) return false;
|
|
187
|
+
if (minRetrievalCount !== undefined && (record.retrievalCount ?? 0) < minRetrievalCount) return false;
|
|
188
|
+
if (maxRetrievalCount !== undefined && (record.retrievalCount ?? 0) > maxRetrievalCount) return false;
|
|
189
|
+
if (minConfidence !== undefined) {
|
|
190
|
+
const confidence = typeof record.metadata.confidence === "number" ? record.metadata.confidence : undefined;
|
|
191
|
+
if (confidence === undefined || confidence < minConfidence) return false;
|
|
192
|
+
}
|
|
193
|
+
if (metadataFilter && !metadataMatches(record.metadata, metadataFilter)) return false;
|
|
194
|
+
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// TursoBackend
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* SQLite/libSQL-backed memory store with vector search.
|
|
205
|
+
*
|
|
206
|
+
* Requires an `Embedder` (OpenAI, LM Studio, etc.) injected at construction.
|
|
207
|
+
* The `@libsql/client` WASM build has no native deps — works in Node, Bun, and
|
|
208
|
+
* edge runtimes.
|
|
209
|
+
*/
|
|
210
|
+
export class TursoBackend implements MemoryBackend {
|
|
211
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
212
|
+
private readonly db: any; // @libsql/client Client
|
|
213
|
+
private readonly embedder: Embedder;
|
|
214
|
+
private readonly embeddingSignature: string;
|
|
215
|
+
private initialized = false;
|
|
216
|
+
private resolvedDimensions?: number;
|
|
217
|
+
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
219
|
+
constructor(db: any, embedder: Embedder, options?: { provider?: string; model?: string; baseUrl?: string }) {
|
|
220
|
+
this.db = db;
|
|
221
|
+
this.embedder = embedder;
|
|
222
|
+
this.embeddingSignature = JSON.stringify({
|
|
223
|
+
provider: options?.provider ?? "unknown",
|
|
224
|
+
model: options?.model ?? "",
|
|
225
|
+
baseUrl: options?.baseUrl ?? "",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- schema ----
|
|
230
|
+
|
|
231
|
+
private async ensureSchema(observedDimensions?: number): Promise<void> {
|
|
232
|
+
if (this.initialized) return;
|
|
233
|
+
|
|
234
|
+
const dimensions = this.resolveDimensions(observedDimensions);
|
|
235
|
+
await this.db.executeMultiple(`
|
|
236
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
237
|
+
id TEXT PRIMARY KEY,
|
|
238
|
+
text TEXT NOT NULL,
|
|
239
|
+
embedding F32_BLOB(${dimensions}),
|
|
240
|
+
category TEXT,
|
|
241
|
+
categories TEXT DEFAULT '[]',
|
|
242
|
+
user_id TEXT,
|
|
243
|
+
assistant_id TEXT,
|
|
244
|
+
session_id TEXT,
|
|
245
|
+
metadata TEXT DEFAULT '{}',
|
|
246
|
+
created_at INTEGER NOT NULL,
|
|
247
|
+
last_retrieved INTEGER,
|
|
248
|
+
retrieval_count INTEGER DEFAULT 0
|
|
249
|
+
);
|
|
250
|
+
CREATE INDEX IF NOT EXISTS idx_memories_scope
|
|
251
|
+
ON memories(assistant_id, user_id);
|
|
252
|
+
CREATE TABLE IF NOT EXISTS noodle_meta (
|
|
253
|
+
key TEXT PRIMARY KEY,
|
|
254
|
+
value TEXT NOT NULL
|
|
255
|
+
);
|
|
256
|
+
`);
|
|
257
|
+
|
|
258
|
+
await this.assertEmbeddingCompatibility(dimensions);
|
|
259
|
+
this.initialized = true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ===================================================================
|
|
263
|
+
// MemoryBackend implementation
|
|
264
|
+
// ===================================================================
|
|
265
|
+
|
|
266
|
+
async add(input: AddMemoryInput): Promise<void> {
|
|
267
|
+
const text = extractText(input);
|
|
268
|
+
const embedding = await this.embedChecked(text);
|
|
269
|
+
await this.ensureSchema(embedding.length);
|
|
270
|
+
const scope = normalizeScope(input.scope);
|
|
271
|
+
|
|
272
|
+
const categories = [
|
|
273
|
+
...(input.categories ?? []),
|
|
274
|
+
...(input.category ? [input.category] : []),
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
await this.db.execute({
|
|
278
|
+
sql: `INSERT INTO memories
|
|
279
|
+
(id, text, embedding, category, categories,
|
|
280
|
+
user_id, assistant_id, session_id, metadata, created_at)
|
|
281
|
+
VALUES (?, ?, vector32(?), ?, ?, ?, ?, ?, ?, ?)`,
|
|
282
|
+
args: [
|
|
283
|
+
crypto.randomUUID(),
|
|
284
|
+
text,
|
|
285
|
+
toVectorJson(embedding),
|
|
286
|
+
input.category ?? null,
|
|
287
|
+
JSON.stringify([...new Set(categories)]),
|
|
288
|
+
scope.userId ?? null,
|
|
289
|
+
scope.assistantId ?? null,
|
|
290
|
+
scope.sessionId ?? null,
|
|
291
|
+
JSON.stringify(input.metadata ?? {}),
|
|
292
|
+
Date.now(),
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async search(input: MemorySearchInput): Promise<MemoryRecord[]> {
|
|
298
|
+
const queryEmbedding = await this.embedChecked(input.query);
|
|
299
|
+
await this.ensureSchema(queryEmbedding.length);
|
|
300
|
+
const scope = normalizeScope(input.scope);
|
|
301
|
+
const scopeFilter = buildScopeFilter(scope);
|
|
302
|
+
const limit = Math.max(1, input.limit ?? 5);
|
|
303
|
+
const candidateLimit = Math.max(limit * 5, 25);
|
|
304
|
+
|
|
305
|
+
// Build parameterised async. Positional: vector JSON, then scope args, then limit.
|
|
306
|
+
const args: unknown[] = [toVectorJson(queryEmbedding)];
|
|
307
|
+
if (scopeFilter.args.length > 0) args.push(...scopeFilter.args);
|
|
308
|
+
args.push(candidateLimit);
|
|
309
|
+
|
|
310
|
+
const whereClause = scopeFilter.clause
|
|
311
|
+
? ` WHERE embedding IS NOT NULL AND ${scopeFilter.clause}`
|
|
312
|
+
: "";
|
|
313
|
+
|
|
314
|
+
// Query spatial plan --------------------------------------------------
|
|
315
|
+
// We embed fresh memories as F32_BLOB so that libSQL family operates natively.
|
|
316
|
+
// If the runtime supplies vector_distance_cos (libSQL / Turso) we use it;
|
|
317
|
+
// otherwise we fall back to loading embeddings as raw blobs and computing
|
|
318
|
+
// cosine similarity in JavaScript (which is fine for < 10K memories).
|
|
319
|
+
const result = await this.db.execute({
|
|
320
|
+
sql: `SELECT id, text, category, categories,
|
|
321
|
+
user_id, assistant_id, session_id,
|
|
322
|
+
metadata, created_at, last_retrieved, retrieval_count,
|
|
323
|
+
vector_distance_cos(embedding, vector32(?1)) AS distance
|
|
324
|
+
FROM memories${whereClause}
|
|
325
|
+
ORDER BY distance
|
|
326
|
+
LIMIT ?${scopeFilter.args.length > 0 ? scopeFilter.args.length + 2 : 2}`,
|
|
327
|
+
args,
|
|
328
|
+
});
|
|
329
|
+
// ------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
let records: Array<MemoryRecord & { _score: number }> = [];
|
|
332
|
+
|
|
333
|
+
for (const raw of result.rows as Record<string, unknown>[]) {
|
|
334
|
+
const record = rowToRecord(raw) as MemoryRecord & { _score: number };
|
|
335
|
+
if (typeof raw.distance === "number") {
|
|
336
|
+
// cosine-distance ∈ [0, 2]; map to score ∈ [1, 0]
|
|
337
|
+
record._score = 1 - Math.max(0, Math.min(1, raw.distance / 2));
|
|
338
|
+
} else {
|
|
339
|
+
// Fallback: compute in JS from the raw blob (libSQL encodes length-prefixed F32_BLOB)
|
|
340
|
+
const vec = await this.readEmbedding(
|
|
341
|
+
typeof raw.id === "string" ? raw.id : "",
|
|
342
|
+
);
|
|
343
|
+
record._score = vec ? cosineSimilarity(queryEmbedding, vec) : 0;
|
|
344
|
+
}
|
|
345
|
+
records.push(record);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Post-filter ----------------------------------------------------------
|
|
349
|
+
if (input.threshold !== undefined) {
|
|
350
|
+
records = records.filter((r) => r._score >= input.threshold!);
|
|
351
|
+
}
|
|
352
|
+
if (input.categories?.length) {
|
|
353
|
+
const catSet = new Set(input.categories);
|
|
354
|
+
records = records.filter((r) =>
|
|
355
|
+
r.categories.some((c) => catSet.has(c)),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
records = applySearchFilters(records, input.filters);
|
|
359
|
+
|
|
360
|
+
const finalRecords = records
|
|
361
|
+
.sort((a, b) => b._score - a._score)
|
|
362
|
+
.slice(0, limit);
|
|
363
|
+
|
|
364
|
+
await this.recordRetrievals(finalRecords.map((record) => record.id).filter((id): id is string => typeof id === "string"));
|
|
365
|
+
|
|
366
|
+
return finalRecords.map(({ _score: score, ...rec }) => ({ ...rec, score }));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async recordRetrievals(ids: string[]): Promise<void> {
|
|
370
|
+
await this.ensureSchema();
|
|
371
|
+
if (ids.length === 0) return;
|
|
372
|
+
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
for (const id of ids) {
|
|
375
|
+
await this.db.execute({
|
|
376
|
+
sql: `UPDATE memories
|
|
377
|
+
SET retrieval_count = COALESCE(retrieval_count, 0) + 1,
|
|
378
|
+
last_retrieved = ?
|
|
379
|
+
WHERE id = ?`,
|
|
380
|
+
args: [now, id],
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async list(input?: MemoryListInput): Promise<MemoryRecord[]> {
|
|
386
|
+
await this.ensureSchema();
|
|
387
|
+
|
|
388
|
+
const scope = normalizeScope(input?.scope);
|
|
389
|
+
const { clause, args } = buildScopeFilter(scope);
|
|
390
|
+
|
|
391
|
+
const result = await this.db.execute({
|
|
392
|
+
sql: `SELECT id, text, category, categories,
|
|
393
|
+
user_id, assistant_id, session_id, metadata,
|
|
394
|
+
created_at, last_retrieved, retrieval_count
|
|
395
|
+
FROM memories
|
|
396
|
+
${clause ? `WHERE ${clause}` : ""}
|
|
397
|
+
ORDER BY created_at DESC`,
|
|
398
|
+
args,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return (result.rows as Record<string, unknown>[]).map(rowToRecord);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async get(id: string): Promise<MemoryRecord | null> {
|
|
405
|
+
await this.ensureSchema();
|
|
406
|
+
|
|
407
|
+
const result = await this.db.execute({
|
|
408
|
+
sql: `SELECT id, text, category, categories,
|
|
409
|
+
user_id, assistant_id, session_id, metadata,
|
|
410
|
+
created_at, last_retrieved, retrieval_count
|
|
411
|
+
FROM memories
|
|
412
|
+
WHERE id = ?`,
|
|
413
|
+
args: [id],
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
if (result.rows.length === 0) return null;
|
|
417
|
+
return rowToRecord(result.rows[0] as Record<string, unknown>);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async update(id: string, input: UpdateMemoryInput): Promise<void> {
|
|
421
|
+
let nextEmbedding: Float32Array | undefined;
|
|
422
|
+
if (input.text !== undefined) {
|
|
423
|
+
nextEmbedding = await this.embedChecked(input.text);
|
|
424
|
+
await this.ensureSchema(nextEmbedding.length);
|
|
425
|
+
} else {
|
|
426
|
+
await this.ensureSchema();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!input.text && !input.metadata) {
|
|
430
|
+
throw new Error("Provide text or metadata for the update.");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const sets: string[] = [];
|
|
434
|
+
const args: unknown[] = [];
|
|
435
|
+
|
|
436
|
+
if (input.text !== undefined) {
|
|
437
|
+
sets.push("text = ?");
|
|
438
|
+
sets.push("embedding = vector32(?)");
|
|
439
|
+
args.push(input.text);
|
|
440
|
+
args.push(toVectorJson(nextEmbedding!));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (input.metadata !== undefined) {
|
|
444
|
+
sets.push("metadata = ?");
|
|
445
|
+
args.push(JSON.stringify(input.metadata));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
args.push(id);
|
|
449
|
+
|
|
450
|
+
await this.db.execute({
|
|
451
|
+
sql: `UPDATE memories SET ${sets.join(", ")} WHERE id = ?`,
|
|
452
|
+
args,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async delete(id: string): Promise<void> {
|
|
457
|
+
await this.ensureSchema();
|
|
458
|
+
await this.db.execute({
|
|
459
|
+
sql: "DELETE FROM memories WHERE id = ?",
|
|
460
|
+
args: [id],
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async captureConversation(input: ConversationCaptureInput): Promise<void> {
|
|
465
|
+
const text = extractText(input);
|
|
466
|
+
const embedding = await this.embedChecked(text);
|
|
467
|
+
await this.ensureSchema(embedding.length);
|
|
468
|
+
const scope = normalizeScope(input.scope);
|
|
469
|
+
|
|
470
|
+
await this.db.execute({
|
|
471
|
+
sql: `INSERT INTO memories
|
|
472
|
+
(id, text, embedding, category, categories,
|
|
473
|
+
user_id, assistant_id, session_id, metadata, created_at)
|
|
474
|
+
VALUES (?, ?, vector32(?), NULL, '[]', ?, ?, ?, ?, ?)`,
|
|
475
|
+
args: [
|
|
476
|
+
crypto.randomUUID(),
|
|
477
|
+
text,
|
|
478
|
+
toVectorJson(embedding),
|
|
479
|
+
scope.userId ?? null,
|
|
480
|
+
scope.assistantId ?? null,
|
|
481
|
+
scope.sessionId ?? null,
|
|
482
|
+
JSON.stringify(input.metadata ?? {}),
|
|
483
|
+
Date.now(),
|
|
484
|
+
],
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async consolidate(): Promise<ConsolidationReport> {
|
|
489
|
+
await this.ensureSchema();
|
|
490
|
+
|
|
491
|
+
const report: ConsolidationReport = { merged: 0, deleted: 0 };
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
// Find near-duplicate pairs using a vector self-join.
|
|
495
|
+
// Distance < 0.04 (cosine) is effectively the same memory.
|
|
496
|
+
// We only scan the 500 most recent memories to bound query time.
|
|
497
|
+
const result = await this.db.execute(`
|
|
498
|
+
SELECT a.id AS id_a,
|
|
499
|
+
b.id AS id_b,
|
|
500
|
+
a.text AS text_a,
|
|
501
|
+
b.text AS text_b,
|
|
502
|
+
a.category AS category_a,
|
|
503
|
+
b.category AS category_b,
|
|
504
|
+
a.categories AS categories_a,
|
|
505
|
+
b.categories AS categories_b,
|
|
506
|
+
a.metadata AS metadata_a,
|
|
507
|
+
b.metadata AS metadata_b,
|
|
508
|
+
a.created_at AS created_a,
|
|
509
|
+
b.created_at AS created_b,
|
|
510
|
+
a.last_retrieved AS last_retrieved_a,
|
|
511
|
+
b.last_retrieved AS last_retrieved_b,
|
|
512
|
+
a.retrieval_count AS retrieval_count_a,
|
|
513
|
+
b.retrieval_count AS retrieval_count_b,
|
|
514
|
+
vector_distance_cos(a.embedding, b.embedding) AS dist
|
|
515
|
+
FROM (SELECT * FROM memories ORDER BY created_at DESC LIMIT 500) a
|
|
516
|
+
JOIN (SELECT * FROM memories ORDER BY created_at DESC LIMIT 500) b
|
|
517
|
+
ON a.id < b.id
|
|
518
|
+
WHERE a.embedding IS NOT NULL
|
|
519
|
+
AND b.embedding IS NOT NULL
|
|
520
|
+
AND vector_distance_cos(a.embedding, b.embedding) < 0.04
|
|
521
|
+
ORDER BY dist
|
|
522
|
+
LIMIT 50
|
|
523
|
+
`);
|
|
524
|
+
|
|
525
|
+
const toDelete = new Set<string>();
|
|
526
|
+
|
|
527
|
+
for (const raw of result.rows as Record<string, unknown>[]) {
|
|
528
|
+
const idA = raw.id_a as string;
|
|
529
|
+
const idB = raw.id_b as string;
|
|
530
|
+
if (toDelete.has(idA) || toDelete.has(idB)) continue;
|
|
531
|
+
|
|
532
|
+
const retrievalA = typeof raw.retrieval_count_a === "number" ? raw.retrieval_count_a : 0;
|
|
533
|
+
const retrievalB = typeof raw.retrieval_count_b === "number" ? raw.retrieval_count_b : 0;
|
|
534
|
+
const createdA = raw.created_a as number;
|
|
535
|
+
const createdB = raw.created_b as number;
|
|
536
|
+
|
|
537
|
+
const keepA = retrievalA > retrievalB || (retrievalA === retrievalB && createdA >= createdB);
|
|
538
|
+
const keepId = keepA ? idA : idB;
|
|
539
|
+
const dropId = keepA ? idB : idA;
|
|
540
|
+
const keepCategory = keepA ? raw.category_a : raw.category_b;
|
|
541
|
+
const keepCategories = keepA ? raw.categories_a : raw.categories_b;
|
|
542
|
+
const keepMetadata = keepA ? raw.metadata_a : raw.metadata_b;
|
|
543
|
+
const keepLastRetrieved = keepA ? raw.last_retrieved_a : raw.last_retrieved_b;
|
|
544
|
+
const keepRetrievalCount = keepA ? retrievalA : retrievalB;
|
|
545
|
+
const dropText = keepA ? raw.text_b : raw.text_a;
|
|
546
|
+
const dropCategory = keepA ? raw.category_b : raw.category_a;
|
|
547
|
+
const dropCategories = keepA ? raw.categories_b : raw.categories_a;
|
|
548
|
+
const dropMetadata = keepA ? raw.metadata_b : raw.metadata_a;
|
|
549
|
+
const dropLastRetrieved = keepA ? raw.last_retrieved_b : raw.last_retrieved_a;
|
|
550
|
+
const dropRetrievalCount = keepA ? retrievalB : retrievalA;
|
|
551
|
+
|
|
552
|
+
const mergedCategories = Array.from(new Set([
|
|
553
|
+
...parseJsonStringArray(keepCategories),
|
|
554
|
+
...parseJsonStringArray(dropCategories),
|
|
555
|
+
...(typeof keepCategory === "string" && keepCategory.length > 0 ? [keepCategory] : []),
|
|
556
|
+
...(typeof dropCategory === "string" && dropCategory.length > 0 ? [dropCategory] : []),
|
|
557
|
+
]));
|
|
558
|
+
|
|
559
|
+
const mergedMetadata = {
|
|
560
|
+
...mergeJsonObjects(
|
|
561
|
+
parseJsonObject(keepMetadata),
|
|
562
|
+
{
|
|
563
|
+
...parseJsonObject(dropMetadata),
|
|
564
|
+
consolidated_into: keepId,
|
|
565
|
+
consolidated_from: [dropId, dropText],
|
|
566
|
+
},
|
|
567
|
+
),
|
|
568
|
+
source: "consolidated",
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
await this.db.execute({
|
|
572
|
+
sql: `UPDATE memories
|
|
573
|
+
SET category = ?,
|
|
574
|
+
categories = ?,
|
|
575
|
+
metadata = ?,
|
|
576
|
+
retrieval_count = ?,
|
|
577
|
+
last_retrieved = ?
|
|
578
|
+
WHERE id = ?`,
|
|
579
|
+
args: [
|
|
580
|
+
typeof keepCategory === "string" && keepCategory.length > 0
|
|
581
|
+
? keepCategory
|
|
582
|
+
: (typeof dropCategory === "string" && dropCategory.length > 0 ? dropCategory : null),
|
|
583
|
+
JSON.stringify(mergedCategories),
|
|
584
|
+
JSON.stringify(mergedMetadata),
|
|
585
|
+
keepRetrievalCount + dropRetrievalCount,
|
|
586
|
+
Math.max(
|
|
587
|
+
typeof keepLastRetrieved === "number" ? keepLastRetrieved : 0,
|
|
588
|
+
typeof dropLastRetrieved === "number" ? dropLastRetrieved : 0,
|
|
589
|
+
) || null,
|
|
590
|
+
keepId,
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
await this.db.execute({
|
|
595
|
+
sql: `UPDATE memories
|
|
596
|
+
SET metadata = json_patch(metadata, ?)
|
|
597
|
+
WHERE id = ?`,
|
|
598
|
+
args: [JSON.stringify({ consolidated_into: keepId }), dropId],
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
toDelete.add(dropId);
|
|
602
|
+
report.merged++;
|
|
603
|
+
report.deleted++;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
for (const id of toDelete) {
|
|
607
|
+
await this.db.execute({
|
|
608
|
+
sql: "DELETE FROM memories WHERE id = ?",
|
|
609
|
+
args: [id],
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
// vector_distance_cos in self-join may not be available in all libSQL builds;
|
|
614
|
+
// fail silently so consolidation never crashes the extension.
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return report;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---- internals --------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
private resolveDimensions(observedDimensions?: number): number {
|
|
623
|
+
const configured = this.embedder.dimensions;
|
|
624
|
+
const resolved = observedDimensions ?? configured ?? this.resolvedDimensions;
|
|
625
|
+
|
|
626
|
+
if (!resolved || !Number.isFinite(resolved) || resolved < 1) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
"Embedding dimensions are unknown for the configured provider/model. Set noodle embedding.dimensions in config.json or EMBEDDING_DIMENSIONS in the environment.",
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (configured && observedDimensions && configured !== observedDimensions) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`Embedding dimension mismatch: config/provider expected ${configured}, but the provider returned ${observedDimensions}. Update noodle embedding.dimensions or switch to a matching model/provider.`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
this.resolvedDimensions = resolved;
|
|
638
|
+
return resolved;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private async assertEmbeddingCompatibility(dimensions: number): Promise<void> {
|
|
642
|
+
const existingDimensions = await this.readMeta("embedding_dimensions");
|
|
643
|
+
const existingSignature = await this.readMeta("embedding_signature");
|
|
644
|
+
|
|
645
|
+
if (existingDimensions && parseInt(existingDimensions, 10) !== dimensions) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`Noodle DB was created with embedding dimension ${existingDimensions}, but the current provider/model uses ${dimensions}. Use a fresh DB or switch back to the original embedding configuration.`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
if (existingSignature && existingSignature !== this.embeddingSignature) {
|
|
651
|
+
throw new Error(
|
|
652
|
+
"Noodle DB was created with a different embedding provider/model/base URL. Use a fresh DB or switch back to the original embedding configuration to avoid mixed-vector search corruption.",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!existingDimensions) {
|
|
657
|
+
await this.writeMeta("embedding_dimensions", String(dimensions));
|
|
658
|
+
}
|
|
659
|
+
if (!existingSignature) {
|
|
660
|
+
await this.writeMeta("embedding_signature", this.embeddingSignature);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private async readMeta(key: string): Promise<string | null> {
|
|
665
|
+
const result = await this.db.execute({
|
|
666
|
+
sql: "SELECT value FROM noodle_meta WHERE key = ?",
|
|
667
|
+
args: [key],
|
|
668
|
+
});
|
|
669
|
+
if (result.rows.length === 0) return null;
|
|
670
|
+
const value = (result.rows[0] as Record<string, unknown>).value;
|
|
671
|
+
return typeof value === "string" ? value : null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private async writeMeta(key: string, value: string): Promise<void> {
|
|
675
|
+
await this.db.execute({
|
|
676
|
+
sql: `INSERT INTO noodle_meta(key, value) VALUES(?, ?)
|
|
677
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
678
|
+
args: [key, value],
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private async embedChecked(text: string): Promise<Float32Array> {
|
|
683
|
+
const embedding = await this.embedder.embed(text);
|
|
684
|
+
this.resolveDimensions(embedding.length);
|
|
685
|
+
return embedding;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Read stored embedding as Float32Array. Returns null on missing row. */
|
|
689
|
+
private async readEmbedding(id: string): Promise<Float32Array | null> {
|
|
690
|
+
const result = await this.db.execute({
|
|
691
|
+
sql: "SELECT embedding FROM memories WHERE id = ?",
|
|
692
|
+
args: [id],
|
|
693
|
+
});
|
|
694
|
+
if (result.rows.length === 0) return null;
|
|
695
|
+
const blob: unknown = (result.rows[0] as Record<string, unknown>).embedding;
|
|
696
|
+
if (blob instanceof Uint8Array) {
|
|
697
|
+
// Assumption: F32_BLOB is stored with 4 bytes per f32, little-endian.
|
|
698
|
+
return new Float32Array(
|
|
699
|
+
new Uint8Array(blob).buffer.slice(
|
|
700
|
+
blob.byteOffset,
|
|
701
|
+
blob.byteOffset + blob.byteLength,
|
|
702
|
+
),
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Convenience factory. Creates a TursoBackend wired to a libSQL client and
|
|
711
|
+
* the given embedding function.
|
|
712
|
+
*/
|
|
713
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
714
|
+
export function createTursoBackend(db: any, embedder: Embedder): TursoBackend {
|
|
715
|
+
return new TursoBackend(db, embedder);
|
|
716
|
+
}
|