mnueron 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/ARCHITECTURE.md +161 -0
- package/INSTALL.md +262 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dashboard/index.html +838 -0
- package/dist/cli.js +685 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +44 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard/server.js +234 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/detectors/claude_code.js +72 -0
- package/dist/detectors/claude_code.js.map +1 -0
- package/dist/detectors/claude_desktop.js +37 -0
- package/dist/detectors/claude_desktop.js.map +1 -0
- package/dist/detectors/cursor.js +36 -0
- package/dist/detectors/cursor.js.map +1 -0
- package/dist/detectors/extra.js +59 -0
- package/dist/detectors/extra.js.map +1 -0
- package/dist/detectors/index.js +14 -0
- package/dist/detectors/index.js.map +1 -0
- package/dist/detectors/json_detector.js +95 -0
- package/dist/detectors/json_detector.js.map +1 -0
- package/dist/detectors/types.js +13 -0
- package/dist/detectors/types.js.map +1 -0
- package/dist/import/claude.js +82 -0
- package/dist/import/claude.js.map +1 -0
- package/dist/import/openai.js +102 -0
- package/dist/import/openai.js.map +1 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/loader.js +175 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/types.js +24 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/setup.js +123 -0
- package/dist/setup.js.map +1 -0
- package/dist/store/chunking.js +150 -0
- package/dist/store/chunking.js.map +1 -0
- package/dist/store/embeddings.js +126 -0
- package/dist/store/embeddings.js.map +1 -0
- package/dist/store/local.js +720 -0
- package/dist/store/local.js.map +1 -0
- package/dist/store/provider.js +7 -0
- package/dist/store/provider.js.map +1 -0
- package/dist/store/redactor.js +114 -0
- package/dist/store/redactor.js.map +1 -0
- package/dist/store/remote.js +62 -0
- package/dist/store/remote.js.map +1 -0
- package/dist/tools.js +312 -0
- package/dist/tools.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import * as sqliteVec from 'sqlite-vec';
|
|
6
|
+
import { embed, embedBatch, EMBEDDING_DIM, preload } from './embeddings.js';
|
|
7
|
+
import { chunkContent, shouldChunk, DEFAULT_CHUNK_THRESHOLD } from './chunking.js';
|
|
8
|
+
import { redact } from './redactor.js';
|
|
9
|
+
/**
|
|
10
|
+
* Run pre-save transforms in fixed order:
|
|
11
|
+
* 1. Redact secrets — never store API keys / JWTs / etc.
|
|
12
|
+
* 2. (Later) plugin processors can hook here.
|
|
13
|
+
* Returns the (possibly modified) input. Metadata is augmented with
|
|
14
|
+
* `redacted_count` and `redacted_kinds` when redaction fired.
|
|
15
|
+
*/
|
|
16
|
+
function preSaveTransform(input) {
|
|
17
|
+
const r = redact(input.content);
|
|
18
|
+
if (r.count === 0)
|
|
19
|
+
return input;
|
|
20
|
+
return {
|
|
21
|
+
...input,
|
|
22
|
+
content: r.content,
|
|
23
|
+
metadata: {
|
|
24
|
+
...(input.metadata ?? {}),
|
|
25
|
+
redacted_count: r.count,
|
|
26
|
+
redacted_kinds: r.kinds,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// Stop words dropped from FTS5 queries before they hit the index. We keep
|
|
31
|
+
// this list small and English-only on purpose — every word we drop is a
|
|
32
|
+
// word a user can't search for, so this is a precision/recall trade.
|
|
33
|
+
const FTS_STOP_WORDS = new Set([
|
|
34
|
+
'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
35
|
+
'of', 'to', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'about', 'as', 'into', 'over', 'under', 'through', 'during',
|
|
36
|
+
'and', 'or', 'but', 'if', 'then', 'else', 'so', 'than',
|
|
37
|
+
'do', 'does', 'did', 'done', 'doing', 'have', 'has', 'had', 'having',
|
|
38
|
+
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'whose', 'whom',
|
|
39
|
+
'this', 'that', 'these', 'those', 'there', 'here',
|
|
40
|
+
'i', 'me', 'my', 'mine', 'you', 'your', 'yours', 'he', 'him', 'his', 'she', 'her', 'hers', 'it', 'its', 'we', 'us', 'our', 'they', 'them', 'their', 'theirs',
|
|
41
|
+
'can', 'could', 'should', 'would', 'may', 'might', 'must', 'will', 'shall',
|
|
42
|
+
'not', 'no', 'yes', 'some', 'any', 'all', 'each', 'every', 'either', 'neither',
|
|
43
|
+
]);
|
|
44
|
+
/**
|
|
45
|
+
* Translate a natural-language query into an FTS5 MATCH expression.
|
|
46
|
+
* - strips FTS5 control characters
|
|
47
|
+
* - lowercases
|
|
48
|
+
* - drops stop words and 1-character tokens
|
|
49
|
+
* - prefix-matches each surviving token (`token*`) so "stores" matches "stored"
|
|
50
|
+
* - ORs the tokens — any one is enough, BM25 ranks multi-hit rows higher
|
|
51
|
+
*/
|
|
52
|
+
function buildFtsQuery(raw) {
|
|
53
|
+
const cleaned = raw.replace(/["()*:^~]/g, ' ').toLowerCase().trim();
|
|
54
|
+
if (!cleaned)
|
|
55
|
+
return '';
|
|
56
|
+
const tokens = cleaned
|
|
57
|
+
.split(/\s+/)
|
|
58
|
+
.map(t => t.replace(/^[^a-z0-9_]+|[^a-z0-9_]+$/g, ''))
|
|
59
|
+
.filter(t => t.length >= 2 && !FTS_STOP_WORDS.has(t));
|
|
60
|
+
if (tokens.length === 0)
|
|
61
|
+
return '';
|
|
62
|
+
return tokens.map(t => `${t}*`).join(' OR ');
|
|
63
|
+
}
|
|
64
|
+
// Reciprocal-rank-fusion constant. 60 is the value the literature uses and
|
|
65
|
+
// is what both Elasticsearch and our hosted-backend already use.
|
|
66
|
+
const RRF_K = 60;
|
|
67
|
+
/**
|
|
68
|
+
* Pull a human-readable title out of a memory's content. Used by
|
|
69
|
+
* `listThreads` to label conversations in the dashboard.
|
|
70
|
+
* Strategy: prefer the first `# Heading`, then the first non-empty line,
|
|
71
|
+
* then a sentence-aware truncation at 100 chars.
|
|
72
|
+
*/
|
|
73
|
+
function extractTitle(content) {
|
|
74
|
+
if (!content)
|
|
75
|
+
return '(empty)';
|
|
76
|
+
const trimmed = content.trim();
|
|
77
|
+
// Markdown H1 / H2 anywhere near the top
|
|
78
|
+
const hMatch = trimmed.slice(0, 600).match(/^#{1,3}\s+(.+)$/m);
|
|
79
|
+
if (hMatch)
|
|
80
|
+
return hMatch[1].trim().slice(0, 100);
|
|
81
|
+
// Strip role-header markdown if present
|
|
82
|
+
const firstLine = trimmed.split(/\r?\n/).map(s => s.trim()).find(Boolean) ?? trimmed;
|
|
83
|
+
const noRole = firstLine.replace(/^\*\*(?:User|Assistant|Claude|ChatGPT|Gemini|System|Human):\*\*\s*/i, '');
|
|
84
|
+
if (noRole.length <= 100)
|
|
85
|
+
return noRole;
|
|
86
|
+
// Otherwise: cut at sentence boundary near 100 chars
|
|
87
|
+
const cut = noRole.slice(0, 100);
|
|
88
|
+
const lastDot = cut.lastIndexOf('. ');
|
|
89
|
+
return (lastDot > 40 ? cut.slice(0, lastDot + 1) : cut).trim() + '…';
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Local SQLite provider. Uses FTS5 for keyword search (ships with SQLite)
|
|
93
|
+
* and sqlite-vec + Transformers.js for local vector search. Search is
|
|
94
|
+
* hybrid: BM25 keyword + cosine vector, blended via reciprocal-rank fusion.
|
|
95
|
+
* Everything runs offline; no external API calls.
|
|
96
|
+
*/
|
|
97
|
+
export class LocalProvider {
|
|
98
|
+
db;
|
|
99
|
+
vecAvailable = false;
|
|
100
|
+
constructor(dbPath) {
|
|
101
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
102
|
+
this.db = new Database(dbPath);
|
|
103
|
+
this.db.pragma('journal_mode = WAL');
|
|
104
|
+
this.db.pragma('foreign_keys = ON');
|
|
105
|
+
// Load sqlite-vec as a SQLite extension. If anything goes wrong we
|
|
106
|
+
// continue without vector support — FTS5 still works.
|
|
107
|
+
try {
|
|
108
|
+
sqliteVec.load(this.db);
|
|
109
|
+
this.vecAvailable = true;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
process.stderr.write(`[mnueron] sqlite-vec failed to load — semantic search disabled. ${e.message}\n`);
|
|
113
|
+
this.vecAvailable = false;
|
|
114
|
+
}
|
|
115
|
+
this.migrate();
|
|
116
|
+
// Warm the embedding model in the background — the first query will
|
|
117
|
+
// hit it; nice if it's already there.
|
|
118
|
+
preload();
|
|
119
|
+
}
|
|
120
|
+
migrate() {
|
|
121
|
+
this.db.exec(`
|
|
122
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
123
|
+
id TEXT PRIMARY KEY,
|
|
124
|
+
namespace TEXT NOT NULL DEFAULT 'default',
|
|
125
|
+
content TEXT NOT NULL,
|
|
126
|
+
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
127
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
128
|
+
source_ref TEXT,
|
|
129
|
+
meta_json TEXT,
|
|
130
|
+
created_at INTEGER NOT NULL,
|
|
131
|
+
updated_at INTEGER NOT NULL
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_memories_namespace
|
|
135
|
+
ON memories(namespace);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_memories_created
|
|
137
|
+
ON memories(created_at DESC);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_memories_source
|
|
139
|
+
ON memories(source);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_memories_source_ref
|
|
141
|
+
ON memories(source_ref);
|
|
142
|
+
|
|
143
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
|
144
|
+
USING fts5(content, tags, namespace UNINDEXED, content_id UNINDEXED);
|
|
145
|
+
|
|
146
|
+
-- Keep FTS in sync. We do this manually rather than via triggers so
|
|
147
|
+
-- the FTS row's content column holds raw text (FTS can't reach
|
|
148
|
+
-- inside JSON for tags otherwise).
|
|
149
|
+
`);
|
|
150
|
+
if (this.vecAvailable) {
|
|
151
|
+
// vec0 virtual table. Each row carries the memory_id as an auxiliary
|
|
152
|
+
// column so we can JOIN back to memories without managing rowids.
|
|
153
|
+
this.db.exec(`
|
|
154
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec
|
|
155
|
+
USING vec0(
|
|
156
|
+
memory_id TEXT PRIMARY KEY,
|
|
157
|
+
embedding float[${EMBEDDING_DIM}]
|
|
158
|
+
);
|
|
159
|
+
`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ─── write path ──────────────────────────────────────────────────────────
|
|
163
|
+
async save(input) {
|
|
164
|
+
// 1. Redact secrets BEFORE chunking — so partial secret tokens at chunk
|
|
165
|
+
// boundaries can't slip through. Single source of truth for what
|
|
166
|
+
// hits SQLite.
|
|
167
|
+
const transformed = preSaveTransform(input);
|
|
168
|
+
// 2. Long content gets auto-chunked into multiple memories. Each chunk
|
|
169
|
+
// becomes a searchable atomic memory; the original conversation is
|
|
170
|
+
// linkable via `parent_ref` (= source_ref + chunk_index in metadata).
|
|
171
|
+
if (shouldChunk(transformed.content)) {
|
|
172
|
+
const result = await this.saveChunked(transformed);
|
|
173
|
+
return result.first;
|
|
174
|
+
}
|
|
175
|
+
return this.saveOne(transformed);
|
|
176
|
+
}
|
|
177
|
+
/** Save a single, non-chunked memory. The common path. */
|
|
178
|
+
async saveOne(input) {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const id = randomUUID();
|
|
181
|
+
const ns = input.namespace ?? 'default';
|
|
182
|
+
const tags = input.tags ?? [];
|
|
183
|
+
// Generate the embedding outside the transaction since it's async.
|
|
184
|
+
// Failure here is non-fatal — we just skip the vec insert.
|
|
185
|
+
const vector = this.vecAvailable ? await embed(input.content) : null;
|
|
186
|
+
const tx = this.db.transaction(() => {
|
|
187
|
+
this.db.prepare(`
|
|
188
|
+
INSERT INTO memories (id, namespace, content, tags_json, source, source_ref, meta_json, created_at, updated_at)
|
|
189
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
190
|
+
`).run(id, ns, input.content, JSON.stringify(tags), input.source ?? 'manual', input.source_ref ?? null, input.metadata ? JSON.stringify(input.metadata) : null, now, now);
|
|
191
|
+
this.db.prepare(`
|
|
192
|
+
INSERT INTO memories_fts (content, tags, namespace, content_id)
|
|
193
|
+
VALUES (?, ?, ?, ?)
|
|
194
|
+
`).run(input.content, tags.join(' '), ns, id);
|
|
195
|
+
if (vector && this.vecAvailable) {
|
|
196
|
+
this.db.prepare(`
|
|
197
|
+
INSERT INTO memories_vec (memory_id, embedding) VALUES (?, ?)
|
|
198
|
+
`).run(id, Buffer.from(vector.buffer));
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
tx();
|
|
202
|
+
return this.rowToMemory({
|
|
203
|
+
id, namespace: ns, content: input.content,
|
|
204
|
+
tags_json: JSON.stringify(tags),
|
|
205
|
+
source: input.source ?? 'manual',
|
|
206
|
+
source_ref: input.source_ref ?? null,
|
|
207
|
+
meta_json: input.metadata ? JSON.stringify(input.metadata) : null,
|
|
208
|
+
created_at: now, updated_at: now,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Split long content into chunks and save each as a separate memory.
|
|
213
|
+
* Each chunk carries metadata.parent_ref + chunk_index so the agent
|
|
214
|
+
* (or the dashboard) can reassemble the original thread.
|
|
215
|
+
*/
|
|
216
|
+
async saveChunked(input) {
|
|
217
|
+
const chunks = chunkContent(input.content);
|
|
218
|
+
if (chunks.length === 0)
|
|
219
|
+
return { first: await this.saveOne(input), count: 1 };
|
|
220
|
+
if (chunks.length === 1)
|
|
221
|
+
return { first: await this.saveOne(input), count: 1 };
|
|
222
|
+
// The parent reference: prefer the caller's source_ref if present, else
|
|
223
|
+
// generate a stable id so siblings can find each other.
|
|
224
|
+
const parentRef = input.source_ref ?? `chunked:${randomUUID()}`;
|
|
225
|
+
const total = chunks.length;
|
|
226
|
+
const baseTags = input.tags ?? [];
|
|
227
|
+
const saves = chunks.map((c, i) => ({
|
|
228
|
+
content: c.content,
|
|
229
|
+
namespace: input.namespace,
|
|
230
|
+
tags: [...baseTags, 'chunk', ...(c.role ? [`role:${c.role}`] : [])],
|
|
231
|
+
source: input.source ?? 'manual',
|
|
232
|
+
source_ref: parentRef,
|
|
233
|
+
metadata: {
|
|
234
|
+
...(input.metadata ?? {}),
|
|
235
|
+
parent_ref: parentRef,
|
|
236
|
+
chunk_index: i,
|
|
237
|
+
chunk_count: total,
|
|
238
|
+
...(c.role ? { role: c.role } : {}),
|
|
239
|
+
},
|
|
240
|
+
}));
|
|
241
|
+
const result = await this.bulkSaveOne(saves);
|
|
242
|
+
if (result.length === 0) {
|
|
243
|
+
// Shouldn't happen, but fall back gracefully.
|
|
244
|
+
return { first: await this.saveOne(input), count: 1 };
|
|
245
|
+
}
|
|
246
|
+
return { first: result[0], count: result.length };
|
|
247
|
+
}
|
|
248
|
+
/** Internal: bulkSave-like path that returns Memory[] rather than counts. */
|
|
249
|
+
async bulkSaveOne(inputs) {
|
|
250
|
+
const vectors = this.vecAvailable ? await embedBatch(inputs.map(i => i.content)) : inputs.map(() => null);
|
|
251
|
+
const out = [];
|
|
252
|
+
const insertMem = this.db.prepare(`
|
|
253
|
+
INSERT INTO memories (id, namespace, content, tags_json, source, source_ref, meta_json, created_at, updated_at)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
255
|
+
`);
|
|
256
|
+
const insertFts = this.db.prepare(`
|
|
257
|
+
INSERT INTO memories_fts (content, tags, namespace, content_id)
|
|
258
|
+
VALUES (?, ?, ?, ?)
|
|
259
|
+
`);
|
|
260
|
+
const insertVec = this.vecAvailable
|
|
261
|
+
? this.db.prepare(`INSERT INTO memories_vec (memory_id, embedding) VALUES (?, ?)`)
|
|
262
|
+
: null;
|
|
263
|
+
const tx = this.db.transaction((items) => {
|
|
264
|
+
for (let i = 0; i < items.length; i++) {
|
|
265
|
+
const input = items[i];
|
|
266
|
+
const id = randomUUID();
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const ns = input.namespace ?? 'default';
|
|
269
|
+
const tags = input.tags ?? [];
|
|
270
|
+
const metaJson = input.metadata ? JSON.stringify(input.metadata) : null;
|
|
271
|
+
insertMem.run(id, ns, input.content, JSON.stringify(tags), input.source ?? 'manual', input.source_ref ?? null, metaJson, now, now);
|
|
272
|
+
insertFts.run(input.content, tags.join(' '), ns, id);
|
|
273
|
+
const vec = vectors[i];
|
|
274
|
+
if (vec && insertVec) {
|
|
275
|
+
insertVec.run(id, Buffer.from(vec.buffer));
|
|
276
|
+
}
|
|
277
|
+
out.push(this.rowToMemory({
|
|
278
|
+
id, namespace: ns, content: input.content,
|
|
279
|
+
tags_json: JSON.stringify(tags),
|
|
280
|
+
source: input.source ?? 'manual',
|
|
281
|
+
source_ref: input.source_ref ?? null,
|
|
282
|
+
meta_json: metaJson,
|
|
283
|
+
created_at: now, updated_at: now,
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
tx(inputs);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
async bulkSave(inputs) {
|
|
291
|
+
let saved = 0, errors = 0;
|
|
292
|
+
// 1. Redact secrets up front, same as save().
|
|
293
|
+
const redactedInputs = inputs.map(preSaveTransform);
|
|
294
|
+
// 2. Expand long inputs into per-chunk memories before we save. A backfill
|
|
295
|
+
// of 50 chats where each is 100KB becomes ~500 small memories,
|
|
296
|
+
// searchable independently. The original conversation is linkable via
|
|
297
|
+
// metadata.parent_ref.
|
|
298
|
+
const expanded = [];
|
|
299
|
+
for (const input of redactedInputs) {
|
|
300
|
+
if (shouldChunk(input.content)) {
|
|
301
|
+
const chunks = chunkContent(input.content);
|
|
302
|
+
if (chunks.length > 1) {
|
|
303
|
+
const parentRef = input.source_ref ?? `chunked:${randomUUID()}`;
|
|
304
|
+
const baseTags = input.tags ?? [];
|
|
305
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
306
|
+
const c = chunks[i];
|
|
307
|
+
expanded.push({
|
|
308
|
+
content: c.content,
|
|
309
|
+
namespace: input.namespace,
|
|
310
|
+
tags: [...baseTags, 'chunk', ...(c.role ? [`role:${c.role}`] : [])],
|
|
311
|
+
source: input.source ?? 'manual',
|
|
312
|
+
source_ref: parentRef,
|
|
313
|
+
metadata: {
|
|
314
|
+
...(input.metadata ?? {}),
|
|
315
|
+
parent_ref: parentRef,
|
|
316
|
+
chunk_index: i,
|
|
317
|
+
chunk_count: chunks.length,
|
|
318
|
+
...(c.role ? { role: c.role } : {}),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
expanded.push(input);
|
|
326
|
+
}
|
|
327
|
+
// Pre-compute embeddings for the whole (expanded) batch in one go —
|
|
328
|
+
// much faster than calling embed() N times because Transformers.js
|
|
329
|
+
// batches the forward pass.
|
|
330
|
+
const vectors = this.vecAvailable
|
|
331
|
+
? await embedBatch(expanded.map(i => i.content))
|
|
332
|
+
: expanded.map(() => null);
|
|
333
|
+
const insertMem = this.db.prepare(`
|
|
334
|
+
INSERT INTO memories (id, namespace, content, tags_json, source, source_ref, meta_json, created_at, updated_at)
|
|
335
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
336
|
+
`);
|
|
337
|
+
const insertFts = this.db.prepare(`
|
|
338
|
+
INSERT INTO memories_fts (content, tags, namespace, content_id)
|
|
339
|
+
VALUES (?, ?, ?, ?)
|
|
340
|
+
`);
|
|
341
|
+
const insertVec = this.vecAvailable
|
|
342
|
+
? this.db.prepare(`INSERT INTO memories_vec (memory_id, embedding) VALUES (?, ?)`)
|
|
343
|
+
: null;
|
|
344
|
+
const tx = this.db.transaction((items) => {
|
|
345
|
+
for (let i = 0; i < items.length; i++) {
|
|
346
|
+
const input = items[i];
|
|
347
|
+
try {
|
|
348
|
+
const id = randomUUID();
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
const ns = input.namespace ?? 'default';
|
|
351
|
+
const tags = input.tags ?? [];
|
|
352
|
+
insertMem.run(id, ns, input.content, JSON.stringify(tags), input.source ?? 'manual', input.source_ref ?? null, input.metadata ? JSON.stringify(input.metadata) : null, now, now);
|
|
353
|
+
insertFts.run(input.content, tags.join(' '), ns, id);
|
|
354
|
+
const vec = vectors[i];
|
|
355
|
+
if (vec && insertVec) {
|
|
356
|
+
insertVec.run(id, Buffer.from(vec.buffer));
|
|
357
|
+
}
|
|
358
|
+
saved++;
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
errors++;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
tx(expanded);
|
|
366
|
+
return { saved, errors };
|
|
367
|
+
}
|
|
368
|
+
// ─── read path: hybrid keyword + vector with RRF ─────────────────────────
|
|
369
|
+
async search(input) {
|
|
370
|
+
const k = input.k ?? 10;
|
|
371
|
+
// FTS5 leg
|
|
372
|
+
const safeQuery = buildFtsQuery(input.query);
|
|
373
|
+
const ftsRanks = new Map(); // id → 1-based rank
|
|
374
|
+
if (safeQuery) {
|
|
375
|
+
let sql = `
|
|
376
|
+
SELECT m.id
|
|
377
|
+
FROM memories_fts f
|
|
378
|
+
JOIN memories m ON m.id = f.content_id
|
|
379
|
+
WHERE memories_fts MATCH ?
|
|
380
|
+
`;
|
|
381
|
+
const params = [safeQuery];
|
|
382
|
+
if (input.namespace) {
|
|
383
|
+
sql += ` AND m.namespace = ?`;
|
|
384
|
+
params.push(input.namespace);
|
|
385
|
+
}
|
|
386
|
+
sql += ` ORDER BY bm25(memories_fts) LIMIT 50`;
|
|
387
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
388
|
+
rows.forEach((r, i) => ftsRanks.set(r.id, i + 1));
|
|
389
|
+
}
|
|
390
|
+
// Vector leg.
|
|
391
|
+
//
|
|
392
|
+
// sqlite-vec requires the k value to be expressed *inside* the WHERE
|
|
393
|
+
// clause as `AND k = ?` — a plain SQL LIMIT is not enough. We also
|
|
394
|
+
// can't JOIN into the same statement without confusing the vec0
|
|
395
|
+
// planner. So: run the bare KNN query first, then filter by namespace
|
|
396
|
+
// in a second SELECT against the regular memories table.
|
|
397
|
+
const vecRanks = new Map();
|
|
398
|
+
if (this.vecAvailable && input.query.trim()) {
|
|
399
|
+
const qvec = await embed(input.query);
|
|
400
|
+
if (qvec) {
|
|
401
|
+
try {
|
|
402
|
+
const rows = this.db.prepare(`
|
|
403
|
+
SELECT memory_id AS id, distance
|
|
404
|
+
FROM memories_vec
|
|
405
|
+
WHERE embedding MATCH ?
|
|
406
|
+
AND k = ?
|
|
407
|
+
ORDER BY distance
|
|
408
|
+
`).all(Buffer.from(qvec.buffer), 50);
|
|
409
|
+
let candidates = rows.map(r => r.id);
|
|
410
|
+
// Namespace filter (after the KNN — sqlite-vec doesn't let us
|
|
411
|
+
// attach this inside the vec0 query).
|
|
412
|
+
if (input.namespace && candidates.length > 0) {
|
|
413
|
+
const placeholders = candidates.map(() => '?').join(',');
|
|
414
|
+
const allowed = this.db.prepare(`SELECT id FROM memories WHERE namespace = ? AND id IN (${placeholders})`).all(input.namespace, ...candidates);
|
|
415
|
+
const allowedSet = new Set(allowed.map(a => a.id));
|
|
416
|
+
candidates = candidates.filter(id => allowedSet.has(id));
|
|
417
|
+
}
|
|
418
|
+
candidates.forEach((id, i) => vecRanks.set(id, i + 1));
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
// sqlite-vec may not be loaded or syntax mismatch — log and skip.
|
|
422
|
+
process.stderr.write(`[mnueron] vector search skipped: ${e.message}\n`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Fuse via Reciprocal Rank Fusion.
|
|
427
|
+
const fused = new Map();
|
|
428
|
+
for (const [id, r] of ftsRanks) {
|
|
429
|
+
fused.set(id, (fused.get(id) ?? 0) + 1 / (RRF_K + r));
|
|
430
|
+
}
|
|
431
|
+
for (const [id, r] of vecRanks) {
|
|
432
|
+
fused.set(id, (fused.get(id) ?? 0) + 1 / (RRF_K + r));
|
|
433
|
+
}
|
|
434
|
+
if (fused.size === 0)
|
|
435
|
+
return [];
|
|
436
|
+
const sorted = [...fused.entries()]
|
|
437
|
+
.sort((a, b) => b[1] - a[1])
|
|
438
|
+
.slice(0, k);
|
|
439
|
+
const placeholders = sorted.map(() => '?').join(',');
|
|
440
|
+
const rows = this.db.prepare(`SELECT * FROM memories WHERE id IN (${placeholders})`).all(...sorted.map(s => s[0]));
|
|
441
|
+
const byId = new Map(rows.map(r => [r.id, r]));
|
|
442
|
+
let memories = sorted
|
|
443
|
+
.map(([id, score]) => {
|
|
444
|
+
const row = byId.get(id);
|
|
445
|
+
return row ? this.rowToMemory(row, score) : null;
|
|
446
|
+
})
|
|
447
|
+
.filter((m) => m !== null);
|
|
448
|
+
if (input.tags && input.tags.length > 0) {
|
|
449
|
+
const wanted = new Set(input.tags);
|
|
450
|
+
memories = memories.filter(m => m.tags.some(t => wanted.has(t)));
|
|
451
|
+
}
|
|
452
|
+
return memories;
|
|
453
|
+
}
|
|
454
|
+
async list(input) {
|
|
455
|
+
let sql = `SELECT * FROM memories WHERE 1=1`;
|
|
456
|
+
const params = [];
|
|
457
|
+
if (input.namespace) {
|
|
458
|
+
sql += ` AND namespace = ?`;
|
|
459
|
+
params.push(input.namespace);
|
|
460
|
+
}
|
|
461
|
+
if (input.before) {
|
|
462
|
+
sql += ` AND created_at < ?`;
|
|
463
|
+
params.push(input.before);
|
|
464
|
+
}
|
|
465
|
+
sql += ` ORDER BY created_at DESC LIMIT ?`;
|
|
466
|
+
params.push(input.limit ?? 50);
|
|
467
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
468
|
+
let memories = rows.map(r => this.rowToMemory(r));
|
|
469
|
+
if (input.tags && input.tags.length > 0) {
|
|
470
|
+
const wanted = new Set(input.tags);
|
|
471
|
+
memories = memories.filter(m => m.tags.some(t => wanted.has(t)));
|
|
472
|
+
}
|
|
473
|
+
return memories;
|
|
474
|
+
}
|
|
475
|
+
async get(id) {
|
|
476
|
+
const row = this.db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id);
|
|
477
|
+
return row ? this.rowToMemory(row) : null;
|
|
478
|
+
}
|
|
479
|
+
async delete(id) {
|
|
480
|
+
const tx = this.db.transaction(() => {
|
|
481
|
+
this.db.prepare(`DELETE FROM memories_fts WHERE content_id = ?`).run(id);
|
|
482
|
+
if (this.vecAvailable) {
|
|
483
|
+
this.db.prepare(`DELETE FROM memories_vec WHERE memory_id = ?`).run(id);
|
|
484
|
+
}
|
|
485
|
+
const r = this.db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
|
486
|
+
return r.changes > 0;
|
|
487
|
+
});
|
|
488
|
+
return tx();
|
|
489
|
+
}
|
|
490
|
+
async namespaces() {
|
|
491
|
+
const rows = this.db.prepare(`
|
|
492
|
+
SELECT namespace AS name,
|
|
493
|
+
COUNT(*) AS count,
|
|
494
|
+
MAX(updated_at) AS last_updated
|
|
495
|
+
FROM memories
|
|
496
|
+
GROUP BY namespace
|
|
497
|
+
ORDER BY last_updated DESC
|
|
498
|
+
`).all();
|
|
499
|
+
return rows.map(r => ({
|
|
500
|
+
name: r.name,
|
|
501
|
+
count: r.count,
|
|
502
|
+
last_updated: r.last_updated ?? 0,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
async close() {
|
|
506
|
+
this.db.close();
|
|
507
|
+
}
|
|
508
|
+
// ─── helpers used by CLI / maintenance ───────────────────────────────────
|
|
509
|
+
/**
|
|
510
|
+
* Count of memories that don't have a vector yet. Used by the CLI to
|
|
511
|
+
* decide whether `mnueron rebuild-embeddings` should run.
|
|
512
|
+
*
|
|
513
|
+
* Implementation note: sqlite-vec's vec0 virtual table doesn't support
|
|
514
|
+
* LEFT JOIN with IS NULL predicates the way a normal table would (its
|
|
515
|
+
* xBestIndex implementation rejects the plan). We use a NOT IN subquery
|
|
516
|
+
* against memories_vec, which vec0 does handle.
|
|
517
|
+
*/
|
|
518
|
+
countMissingEmbeddings() {
|
|
519
|
+
if (!this.vecAvailable)
|
|
520
|
+
return 0;
|
|
521
|
+
const r = this.db.prepare(`
|
|
522
|
+
SELECT COUNT(*) AS c
|
|
523
|
+
FROM memories
|
|
524
|
+
WHERE id NOT IN (SELECT memory_id FROM memories_vec)
|
|
525
|
+
`).get();
|
|
526
|
+
return r?.c ?? 0;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Generate embeddings for every memory that doesn't have one. Run once
|
|
530
|
+
* after upgrading from a pre-vector version. Streams progress through
|
|
531
|
+
* the callback so the CLI can show a progress bar.
|
|
532
|
+
*/
|
|
533
|
+
async rebuildEmbeddings(onProgress) {
|
|
534
|
+
if (!this.vecAvailable)
|
|
535
|
+
return { updated: 0, skipped: 0, errors: 0 };
|
|
536
|
+
const rows = this.db.prepare(`
|
|
537
|
+
SELECT id, content
|
|
538
|
+
FROM memories
|
|
539
|
+
WHERE id NOT IN (SELECT memory_id FROM memories_vec)
|
|
540
|
+
ORDER BY created_at ASC
|
|
541
|
+
`).all();
|
|
542
|
+
const total = rows.length;
|
|
543
|
+
let updated = 0, skipped = 0, errors = 0;
|
|
544
|
+
// Embed in batches of 16 for throughput without spiking memory.
|
|
545
|
+
const BATCH = 16;
|
|
546
|
+
const insertVec = this.db.prepare(`
|
|
547
|
+
INSERT OR REPLACE INTO memories_vec (memory_id, embedding) VALUES (?, ?)
|
|
548
|
+
`);
|
|
549
|
+
for (let i = 0; i < rows.length; i += BATCH) {
|
|
550
|
+
const chunk = rows.slice(i, i + BATCH);
|
|
551
|
+
const vecs = await embedBatch(chunk.map(r => r.content));
|
|
552
|
+
const tx = this.db.transaction(() => {
|
|
553
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
554
|
+
const vec = vecs[j];
|
|
555
|
+
if (!vec) {
|
|
556
|
+
skipped++;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
insertVec.run(chunk[j].id, Buffer.from(vec.buffer));
|
|
561
|
+
updated++;
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
errors++;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
tx();
|
|
569
|
+
onProgress?.(Math.min(i + BATCH, total), total, chunk[chunk.length - 1]?.content?.slice(0, 60));
|
|
570
|
+
}
|
|
571
|
+
return { updated, skipped, errors };
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Look up by source_ref — used by importers and the dashboard's upsert
|
|
575
|
+
* endpoint to avoid double-saving the same chat.
|
|
576
|
+
*/
|
|
577
|
+
findBySourceRef(sourceRef, namespace) {
|
|
578
|
+
let sql = `SELECT * FROM memories WHERE source_ref = ?`;
|
|
579
|
+
const params = [sourceRef];
|
|
580
|
+
if (namespace) {
|
|
581
|
+
sql += ` AND namespace = ?`;
|
|
582
|
+
params.push(namespace);
|
|
583
|
+
}
|
|
584
|
+
sql += ` LIMIT 1`;
|
|
585
|
+
const row = this.db.prepare(sql).get(...params);
|
|
586
|
+
return row ? this.rowToMemory(row) : null;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Return every chunk of a thread (i.e. every memory whose
|
|
590
|
+
* metadata.parent_ref matches), ordered by chunk_index. Used by
|
|
591
|
+
* `memory_get_thread` so agents can reassemble a long conversation
|
|
592
|
+
* after finding one relevant turn via memory_recall.
|
|
593
|
+
*
|
|
594
|
+
* `parentRef` can be either the literal parent_ref value or any chunk's
|
|
595
|
+
* memory id (we look up parent_ref from that chunk's metadata first).
|
|
596
|
+
*/
|
|
597
|
+
findThread(parentRef) {
|
|
598
|
+
// If the caller passed a memory id, resolve to its parent_ref first.
|
|
599
|
+
let ref = parentRef;
|
|
600
|
+
const maybeChild = this.db.prepare(`SELECT meta_json FROM memories WHERE id = ?`).get(parentRef);
|
|
601
|
+
if (maybeChild?.meta_json) {
|
|
602
|
+
try {
|
|
603
|
+
const meta = JSON.parse(maybeChild.meta_json);
|
|
604
|
+
if (typeof meta?.parent_ref === 'string')
|
|
605
|
+
ref = meta.parent_ref;
|
|
606
|
+
}
|
|
607
|
+
catch { /* ignore */ }
|
|
608
|
+
}
|
|
609
|
+
// Now fetch every memory whose metadata.parent_ref equals ref.
|
|
610
|
+
// JSON field path syntax: json_extract(meta_json, '$.parent_ref')
|
|
611
|
+
const rows = this.db.prepare(`
|
|
612
|
+
SELECT *
|
|
613
|
+
FROM memories
|
|
614
|
+
WHERE json_extract(meta_json, '$.parent_ref') = ?
|
|
615
|
+
ORDER BY COALESCE(json_extract(meta_json, '$.chunk_index'), 0) ASC, created_at ASC
|
|
616
|
+
`).all(ref);
|
|
617
|
+
// Also try a fallback against source_ref for memories chunked via
|
|
618
|
+
// source_ref-as-parent_ref (this is the common case for backfills).
|
|
619
|
+
if (rows.length === 0) {
|
|
620
|
+
const alt = this.db.prepare(`
|
|
621
|
+
SELECT *
|
|
622
|
+
FROM memories
|
|
623
|
+
WHERE source_ref = ?
|
|
624
|
+
ORDER BY COALESCE(json_extract(meta_json, '$.chunk_index'), 0) ASC, created_at ASC
|
|
625
|
+
`).all(ref);
|
|
626
|
+
return alt.map(r => this.rowToMemory(r));
|
|
627
|
+
}
|
|
628
|
+
return rows.map(r => this.rowToMemory(r));
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* List "threads" — distinct conversations as represented by their
|
|
632
|
+
* parent_ref. Each row in the output represents a conversation that the
|
|
633
|
+
* dashboard can render collapsed (one row = one conversation, expandable
|
|
634
|
+
* into per-turn chunks).
|
|
635
|
+
*
|
|
636
|
+
* Returns: { parent_ref, namespace, count, first_at, last_at, title }
|
|
637
|
+
* — `title` is the content preview of the lowest-chunk_index member
|
|
638
|
+
* (usually the human-readable header at the top of a transcript).
|
|
639
|
+
*/
|
|
640
|
+
listThreads(opts = {}) {
|
|
641
|
+
const limit = opts.limit ?? 100;
|
|
642
|
+
const offset = opts.offset ?? 0;
|
|
643
|
+
// We use COALESCE(parent_ref-from-metadata, id) as the bucket key so
|
|
644
|
+
// standalone (non-chunked) memories show up as single-row threads too.
|
|
645
|
+
const sql = `
|
|
646
|
+
WITH grouped AS (
|
|
647
|
+
SELECT
|
|
648
|
+
COALESCE(json_extract(meta_json, '$.parent_ref'), id) AS pref,
|
|
649
|
+
namespace,
|
|
650
|
+
COUNT(*) AS cnt,
|
|
651
|
+
MIN(created_at) AS first_at,
|
|
652
|
+
MAX(updated_at) AS last_at,
|
|
653
|
+
SUM(CASE WHEN json_extract(meta_json, '$.chunk_index') IS NOT NULL THEN 1 ELSE 0 END) AS chunked_n
|
|
654
|
+
FROM memories
|
|
655
|
+
${opts.namespace ? 'WHERE namespace = ?' : ''}
|
|
656
|
+
GROUP BY pref, namespace
|
|
657
|
+
)
|
|
658
|
+
SELECT
|
|
659
|
+
g.pref AS parent_ref,
|
|
660
|
+
g.namespace,
|
|
661
|
+
g.cnt AS count,
|
|
662
|
+
g.first_at,
|
|
663
|
+
g.last_at,
|
|
664
|
+
g.chunked_n > 0 AS has_chunks,
|
|
665
|
+
(
|
|
666
|
+
SELECT m.content
|
|
667
|
+
FROM memories m
|
|
668
|
+
WHERE COALESCE(json_extract(m.meta_json, '$.parent_ref'), m.id) = g.pref
|
|
669
|
+
AND m.namespace = g.namespace
|
|
670
|
+
ORDER BY COALESCE(json_extract(m.meta_json, '$.chunk_index'), 0) ASC, m.created_at ASC
|
|
671
|
+
LIMIT 1
|
|
672
|
+
) AS title_source
|
|
673
|
+
FROM grouped g
|
|
674
|
+
ORDER BY g.last_at DESC
|
|
675
|
+
LIMIT ? OFFSET ?
|
|
676
|
+
`;
|
|
677
|
+
const params = opts.namespace ? [opts.namespace, limit, offset] : [limit, offset];
|
|
678
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
679
|
+
return rows.map(r => ({
|
|
680
|
+
parent_ref: r.parent_ref,
|
|
681
|
+
namespace: r.namespace,
|
|
682
|
+
count: r.count,
|
|
683
|
+
first_at: r.first_at ?? 0,
|
|
684
|
+
last_at: r.last_at ?? 0,
|
|
685
|
+
title: extractTitle(r.title_source ?? ''),
|
|
686
|
+
has_chunks: !!r.has_chunks,
|
|
687
|
+
}));
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Find memories whose content exceeds `threshold` chars — i.e. ones that
|
|
691
|
+
* predate chunking. Used by `mnueron rechunk` to backfill the new shape.
|
|
692
|
+
*/
|
|
693
|
+
findOversizedMemories(threshold = DEFAULT_CHUNK_THRESHOLD) {
|
|
694
|
+
return this.db.prepare(`
|
|
695
|
+
SELECT id, content, namespace, tags_json, source, source_ref, meta_json, created_at
|
|
696
|
+
FROM memories
|
|
697
|
+
WHERE LENGTH(content) > ?
|
|
698
|
+
AND (
|
|
699
|
+
meta_json IS NULL
|
|
700
|
+
OR json_extract(meta_json, '$.chunk_index') IS NULL
|
|
701
|
+
)
|
|
702
|
+
ORDER BY LENGTH(content) DESC
|
|
703
|
+
`).all(threshold);
|
|
704
|
+
}
|
|
705
|
+
rowToMemory(row, score) {
|
|
706
|
+
return {
|
|
707
|
+
id: row.id,
|
|
708
|
+
namespace: row.namespace,
|
|
709
|
+
content: row.content,
|
|
710
|
+
tags: JSON.parse(row.tags_json ?? '[]'),
|
|
711
|
+
source: row.source,
|
|
712
|
+
source_ref: row.source_ref ?? undefined,
|
|
713
|
+
metadata: row.meta_json ? JSON.parse(row.meta_json) : undefined,
|
|
714
|
+
score,
|
|
715
|
+
created_at: row.created_at,
|
|
716
|
+
updated_at: row.updated_at,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
//# sourceMappingURL=local.js.map
|