agim-cli 1.1.11 → 1.2.1
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/CHANGELOG.md +169 -0
- package/dist/cli.js +78 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/approval-bus.d.ts +18 -0
- package/dist/core/approval-bus.d.ts.map +1 -1
- package/dist/core/approval-bus.js +111 -0
- package/dist/core/approval-bus.js.map +1 -1
- package/dist/core/approval-router.d.ts.map +1 -1
- package/dist/core/approval-router.js +12 -0
- package/dist/core/approval-router.js.map +1 -1
- package/dist/core/audit-log.d.ts +39 -0
- package/dist/core/audit-log.d.ts.map +1 -1
- package/dist/core/audit-log.js +124 -0
- package/dist/core/audit-log.js.map +1 -1
- package/dist/core/boot-state.d.ts +17 -0
- package/dist/core/boot-state.d.ts.map +1 -0
- package/dist/core/boot-state.js +77 -0
- package/dist/core/boot-state.js.map +1 -0
- package/dist/core/job-recovery.d.ts +41 -1
- package/dist/core/job-recovery.d.ts.map +1 -1
- package/dist/core/job-recovery.js +216 -4
- package/dist/core/job-recovery.js.map +1 -1
- package/dist/core/memory-consolidate.d.ts +12 -0
- package/dist/core/memory-consolidate.d.ts.map +1 -0
- package/dist/core/memory-consolidate.js +242 -0
- package/dist/core/memory-consolidate.js.map +1 -0
- package/dist/core/memory-distill.d.ts +30 -0
- package/dist/core/memory-distill.d.ts.map +1 -0
- package/dist/core/memory-distill.js +213 -0
- package/dist/core/memory-distill.js.map +1 -0
- package/dist/core/memory-rpc.d.ts +11 -0
- package/dist/core/memory-rpc.d.ts.map +1 -0
- package/dist/core/memory-rpc.js +94 -0
- package/dist/core/memory-rpc.js.map +1 -0
- package/dist/core/memory-vector.d.ts +47 -0
- package/dist/core/memory-vector.d.ts.map +1 -0
- package/dist/core/memory-vector.js +386 -0
- package/dist/core/memory-vector.js.map +1 -0
- package/dist/core/memory.d.ts +140 -0
- package/dist/core/memory.d.ts.map +1 -0
- package/dist/core/memory.js +714 -0
- package/dist/core/memory.js.map +1 -0
- package/dist/core/persona.d.ts +24 -0
- package/dist/core/persona.d.ts.map +1 -0
- package/dist/core/persona.js +80 -0
- package/dist/core/persona.js.map +1 -0
- package/dist/core/push-rpc.d.ts +26 -0
- package/dist/core/push-rpc.d.ts.map +1 -0
- package/dist/core/push-rpc.js +123 -0
- package/dist/core/push-rpc.js.map +1 -0
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +26 -1
- package/dist/core/router.js.map +1 -1
- package/dist/core/types.d.ts +41 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/plugins/agents/claude-code/index.d.ts +9 -0
- package/dist/plugins/agents/claude-code/index.d.ts.map +1 -1
- package/dist/plugins/agents/claude-code/index.js +37 -0
- package/dist/plugins/agents/claude-code/index.js.map +1 -1
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +8 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -1
- package/dist/plugins/agents/claude-code/mcp-approval-server.js +181 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -1
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +5 -1
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -1
- package/dist/plugins/messengers/telegram/telegram-adapter.js +85 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -1
- package/dist/web/public/settings.html +106 -10
- package/dist/web/public/tasks.html +992 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +433 -6
- package/dist/web/server.js.map +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// memory — long-term agent memory: persona profiles + fact store.
|
|
2
|
+
//
|
|
3
|
+
// Two tables, one SQLite file (~/.agim/memory.db):
|
|
4
|
+
//
|
|
5
|
+
// persona_profile (user_key, summary, updated_at)
|
|
6
|
+
// One row per user. `summary` is a 150-300 token Chinese/English
|
|
7
|
+
// distilled snapshot rebuilt by daily consolidation. Always injected
|
|
8
|
+
// into agent prompts (when present) via router.ts → buildPersonaSnippet.
|
|
9
|
+
//
|
|
10
|
+
// facts (id, user_key, what, who, when_text, where_label, why, category,
|
|
11
|
+
// confidence, last_referenced_at, source, created_at)
|
|
12
|
+
// Append-only fact store. Records written by memory-distill.ts after
|
|
13
|
+
// each agent turn + manual saves from /memo or memory_save MCP. Read
|
|
14
|
+
// via memory_query (hybrid FTS5 + recency) when agent decides to query.
|
|
15
|
+
//
|
|
16
|
+
// `user_key` = `${platform}:${userId}` so a single human across multiple
|
|
17
|
+
// IMs converges to the same memory if userId is consistent. Otherwise
|
|
18
|
+
// each IM persona is independent.
|
|
19
|
+
//
|
|
20
|
+
// Vector index (sqlite-vss) is **optional** for v1.5 — when the extension
|
|
21
|
+
// isn't loaded the helper falls back to FTS5-only retrieval. Vector adds
|
|
22
|
+
// quality, not correctness.
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { logger as rootLogger } from './logger.js';
|
|
25
|
+
import { createSqliteHelper } from './sqlite-helper.js';
|
|
26
|
+
import { AGIM_HOME } from './agim-paths.js';
|
|
27
|
+
const MEMORY_DB = join(AGIM_HOME, 'memory.db');
|
|
28
|
+
const log = rootLogger.child({ component: 'memory' });
|
|
29
|
+
const SCHEMA = `
|
|
30
|
+
CREATE TABLE IF NOT EXISTS persona_profile (
|
|
31
|
+
user_key TEXT PRIMARY KEY,
|
|
32
|
+
summary TEXT NOT NULL,
|
|
33
|
+
updated_at INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
user_key TEXT NOT NULL,
|
|
39
|
+
what TEXT NOT NULL,
|
|
40
|
+
who TEXT DEFAULT '',
|
|
41
|
+
when_text TEXT DEFAULT '',
|
|
42
|
+
where_label TEXT DEFAULT '',
|
|
43
|
+
why TEXT DEFAULT '',
|
|
44
|
+
category TEXT NOT NULL DEFAULT 'fact',
|
|
45
|
+
confidence REAL NOT NULL DEFAULT 0.6,
|
|
46
|
+
last_referenced_at INTEGER NOT NULL,
|
|
47
|
+
source TEXT DEFAULT '',
|
|
48
|
+
created_at INTEGER NOT NULL
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_facts_user ON facts(user_key, created_at DESC);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_facts_cat ON facts(user_key, category);
|
|
53
|
+
|
|
54
|
+
-- FTS5 virtual table for keyword retrieval. Tokenizer porter handles
|
|
55
|
+
-- English stemming; CJK characters are indexed as bigrams via unicode61
|
|
56
|
+
-- if available. We compose the indexed text from the 5W1H slots.
|
|
57
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
|
|
58
|
+
user_key UNINDEXED,
|
|
59
|
+
text,
|
|
60
|
+
content='',
|
|
61
|
+
tokenize='unicode61'
|
|
62
|
+
);
|
|
63
|
+
`;
|
|
64
|
+
/** v1.6 — vector retrieval columns (added via ALTER, idempotent). */
|
|
65
|
+
function migrateVectorColumns(db) {
|
|
66
|
+
const cols = db.prepare('PRAGMA table_info(facts)').all();
|
|
67
|
+
const have = new Set(cols.map((c) => c.name));
|
|
68
|
+
const adds = [];
|
|
69
|
+
if (!have.has('embedding'))
|
|
70
|
+
adds.push(['embedding', 'BLOB']);
|
|
71
|
+
if (!have.has('embedding_model'))
|
|
72
|
+
adds.push(['embedding_model', 'TEXT']);
|
|
73
|
+
if (!have.has('embedding_dims'))
|
|
74
|
+
adds.push(['embedding_dims', 'INTEGER']);
|
|
75
|
+
if (!have.has('embedded_at'))
|
|
76
|
+
adds.push(['embedded_at', 'INTEGER']);
|
|
77
|
+
for (const [col, type] of adds) {
|
|
78
|
+
try {
|
|
79
|
+
db.prepare(`ALTER TABLE facts ADD COLUMN ${col} ${type}`).run();
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// Concurrent migration (different process) — ignore "duplicate column" error.
|
|
83
|
+
if (!/duplicate column name/i.test(String(err))) {
|
|
84
|
+
rootLogger.warn({ event: 'memory.migrate_failed', col, err: String(err) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const helper = createSqliteHelper({
|
|
90
|
+
file: MEMORY_DB,
|
|
91
|
+
schema: SCHEMA,
|
|
92
|
+
logger: log,
|
|
93
|
+
component: 'memory',
|
|
94
|
+
init: (d) => { migrateVectorColumns(d); },
|
|
95
|
+
});
|
|
96
|
+
export function userKey(platform, userId) {
|
|
97
|
+
return `${platform}:${userId || 'anon'}`;
|
|
98
|
+
}
|
|
99
|
+
function nowSec() {
|
|
100
|
+
return Math.floor(Date.now() / 1000);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* CJK tokenization helper. FTS5's `unicode61` tokenizer treats a run of
|
|
104
|
+
* adjacent CJK characters as a single token (e.g. "用户在杭州工作" → one
|
|
105
|
+
* token), so a query for "杭州" never matches. We force per-character
|
|
106
|
+
* boundaries on both the indexed text AND the query string so each CJK
|
|
107
|
+
* character becomes its own token; multi-char queries then match via
|
|
108
|
+
* FTS5's implicit AND across tokens.
|
|
109
|
+
*
|
|
110
|
+
* Range covers: CJK Unified Ideographs (4E00–9FFF), CJK Extension A
|
|
111
|
+
* (3400–4DBF), Hiragana (3040–309F), Katakana (30A0–30FF),
|
|
112
|
+
* Hangul Syllables (AC00–D7AF).
|
|
113
|
+
*/
|
|
114
|
+
const CJK_RE = /[㐀-䶿一-鿿-ヿ가-]/;
|
|
115
|
+
function cjkSeparate(s) {
|
|
116
|
+
// Insert a space after every CJK char that is immediately followed by
|
|
117
|
+
// another non-space character (CJK or ASCII). Cheap; runs over short text.
|
|
118
|
+
let out = '';
|
|
119
|
+
for (let i = 0; i < s.length; i++) {
|
|
120
|
+
out += s[i];
|
|
121
|
+
if (CJK_RE.test(s[i]) && i + 1 < s.length && s[i + 1] !== ' ')
|
|
122
|
+
out += ' ';
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Flatten a fact's 5W1H slots into a single string for FTS5 indexing.
|
|
128
|
+
* Repeats `what` twice so it dominates rank — that's the primary content.
|
|
129
|
+
*/
|
|
130
|
+
function indexableText(f) {
|
|
131
|
+
const raw = [f.what, f.what, f.who, f.when_text, f.where_label, f.why]
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join(' · ');
|
|
134
|
+
return cjkSeparate(raw);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build a safe FTS5 MATCH expression from arbitrary user input.
|
|
138
|
+
* Wrapping in a quoted phrase neutralizes operators (`AND`, `OR`, `:`,
|
|
139
|
+
* `(`, `*`, `+`, etc.) that would otherwise crash the prepared statement.
|
|
140
|
+
* Internal double-quotes are replaced with spaces (rare in fact text).
|
|
141
|
+
* Also CJK-separates so per-char tokens match per-char indexed tokens.
|
|
142
|
+
*/
|
|
143
|
+
function buildFtsMatch(q) {
|
|
144
|
+
const sep = cjkSeparate(q.replace(/"/g, ' '))
|
|
145
|
+
.replace(/\s+/g, ' ')
|
|
146
|
+
.trim();
|
|
147
|
+
if (!sep)
|
|
148
|
+
return '';
|
|
149
|
+
return `"${sep}"`;
|
|
150
|
+
}
|
|
151
|
+
export function saveFact(input) {
|
|
152
|
+
const db = helper.get();
|
|
153
|
+
if (!db)
|
|
154
|
+
return null;
|
|
155
|
+
const ts = nowSec();
|
|
156
|
+
try {
|
|
157
|
+
const text = indexableText(input);
|
|
158
|
+
// Wrap the two inserts in a transaction — without this, a crash
|
|
159
|
+
// between row and FTS insert leaves the FTS index out of sync.
|
|
160
|
+
const id = db.transaction(() => {
|
|
161
|
+
const r = db.prepare(`
|
|
162
|
+
INSERT INTO facts (user_key, what, who, when_text, where_label, why, category, confidence, last_referenced_at, source, created_at)
|
|
163
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
164
|
+
`).run(input.user_key, input.what, input.who ?? '', input.when_text ?? '', input.where_label ?? '', input.why ?? '', input.category ?? 'fact', Math.max(0, Math.min(1, input.confidence ?? 0.6)), ts, input.source ?? '', ts);
|
|
165
|
+
const factId = Number(r.lastInsertRowid);
|
|
166
|
+
db.prepare('INSERT INTO facts_fts(rowid, user_key, text) VALUES (?, ?, ?)')
|
|
167
|
+
.run(factId, input.user_key, text);
|
|
168
|
+
return factId;
|
|
169
|
+
})();
|
|
170
|
+
// v1.6 — async embed + write to embedding column. Non-blocking — fact
|
|
171
|
+
// is already persisted, vector is a soft add. If the backend is off /
|
|
172
|
+
// not ready / network fails, the fact still works (falls back to FTS5
|
|
173
|
+
// on query). setImmediate ensures we don't hold the caller.
|
|
174
|
+
setImmediate(() => { void embedAndStoreFact(id, text); });
|
|
175
|
+
return id;
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
log.warn({ event: 'memory.save_failed', err: String(err) });
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Process-lifetime flag so we warn at most once when the vector backend
|
|
183
|
+
// is enabled in env but not actually usable (model not downloaded, no key,
|
|
184
|
+
// etc.). Without this, every saveFact would silently skip the embed step
|
|
185
|
+
// and users would be confused why memory_query falls back to FTS5-only.
|
|
186
|
+
let _embedNotReadyWarned = false;
|
|
187
|
+
async function embedAndStoreFact(factId, text) {
|
|
188
|
+
try {
|
|
189
|
+
const { getActiveBackend, float32ToBuffer } = await import('./memory-vector.js');
|
|
190
|
+
const backend = getActiveBackend();
|
|
191
|
+
if (!backend.ready) {
|
|
192
|
+
if (backend.name !== 'off' && !_embedNotReadyWarned) {
|
|
193
|
+
_embedNotReadyWarned = true;
|
|
194
|
+
log.info({
|
|
195
|
+
event: 'memory.embed.backend_not_ready',
|
|
196
|
+
backend: backend.name, modelId: backend.modelId,
|
|
197
|
+
}, 'vector backend configured but not ready — facts saved without embeddings; run /memory backfill once it becomes ready');
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const vecs = await backend.embed([text]);
|
|
202
|
+
const v = vecs[0];
|
|
203
|
+
if (!v || v.length === 0)
|
|
204
|
+
return;
|
|
205
|
+
const db = helper.get();
|
|
206
|
+
if (!db)
|
|
207
|
+
return;
|
|
208
|
+
db.prepare(`
|
|
209
|
+
UPDATE facts SET embedding = ?, embedding_model = ?, embedding_dims = ?, embedded_at = ?
|
|
210
|
+
WHERE id = ?
|
|
211
|
+
`).run(float32ToBuffer(v), `${backend.name}:${backend.modelId}`, v.length, nowSec(), factId);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
// Embedding failures must never break fact storage. Already persisted.
|
|
215
|
+
log.debug({ event: 'memory.embed_async_failed', factId, err: String(err) });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export function deleteFact(id, user_key) {
|
|
219
|
+
const db = helper.get();
|
|
220
|
+
if (!db)
|
|
221
|
+
return false;
|
|
222
|
+
try {
|
|
223
|
+
return db.transaction(() => {
|
|
224
|
+
const r = db.prepare('DELETE FROM facts WHERE id = ? AND user_key = ?').run(id, user_key);
|
|
225
|
+
if (r.changes > 0) {
|
|
226
|
+
db.prepare('DELETE FROM facts_fts WHERE rowid = ?').run(id);
|
|
227
|
+
}
|
|
228
|
+
return r.changes > 0;
|
|
229
|
+
})();
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
log.warn({ event: 'memory.delete_failed', err: String(err) });
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Hybrid retrieval. v1.5 first cut uses FTS5 + recency boost; v1.6 will
|
|
238
|
+
* layer in sqlite-vss dense vectors and rerank.
|
|
239
|
+
*
|
|
240
|
+
* - Empty query → most-recent N facts.
|
|
241
|
+
* - With query → FTS5 MATCH, sorted by FTS5's bm25() with a recency tie-
|
|
242
|
+
* breaker (newer first).
|
|
243
|
+
*
|
|
244
|
+
* Side effect: bumps `last_referenced_at` for returned rows so weak /
|
|
245
|
+
* unused facts can be retired by future consolidation.
|
|
246
|
+
*/
|
|
247
|
+
export function queryFacts(opts) {
|
|
248
|
+
const db = helper.get();
|
|
249
|
+
if (!db)
|
|
250
|
+
return { facts: [], matched: 0 };
|
|
251
|
+
const k = Math.min(Math.max(opts.k ?? 5, 1), 50);
|
|
252
|
+
const cat = opts.category;
|
|
253
|
+
const q = (opts.query || '').trim();
|
|
254
|
+
try {
|
|
255
|
+
let rows;
|
|
256
|
+
if (!q) {
|
|
257
|
+
rows = db.prepare(`
|
|
258
|
+
SELECT * FROM facts
|
|
259
|
+
WHERE user_key = ? ${cat ? 'AND category = ?' : ''}
|
|
260
|
+
ORDER BY created_at DESC LIMIT ?
|
|
261
|
+
`).all(...(cat ? [opts.user_key, cat, k] : [opts.user_key, k]));
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const safeQ = buildFtsMatch(q);
|
|
265
|
+
if (!safeQ)
|
|
266
|
+
return { facts: [], matched: 0 };
|
|
267
|
+
rows = db.prepare(`
|
|
268
|
+
SELECT f.* FROM facts_fts t
|
|
269
|
+
JOIN facts f ON f.id = t.rowid
|
|
270
|
+
WHERE t.user_key = ? AND facts_fts MATCH ?
|
|
271
|
+
${cat ? 'AND f.category = ?' : ''}
|
|
272
|
+
ORDER BY bm25(facts_fts), f.created_at DESC
|
|
273
|
+
LIMIT ?
|
|
274
|
+
`).all(...(cat
|
|
275
|
+
? [opts.user_key, safeQ, cat, k]
|
|
276
|
+
: [opts.user_key, safeQ, k * 3])); // pull 3x for RRF headroom
|
|
277
|
+
}
|
|
278
|
+
// Bump last_referenced_at for everything we returned.
|
|
279
|
+
if (rows.length > 0) {
|
|
280
|
+
const ts = nowSec();
|
|
281
|
+
const stmt = db.prepare('UPDATE facts SET last_referenced_at = ? WHERE id = ?');
|
|
282
|
+
for (const r of rows) {
|
|
283
|
+
try {
|
|
284
|
+
stmt.run(ts, r.id);
|
|
285
|
+
}
|
|
286
|
+
catch { /* ignore */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { facts: rows.slice(0, k), matched: rows.length };
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
log.warn({ event: 'memory.query_failed', err: String(err), q });
|
|
293
|
+
return { facts: [], matched: 0 };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* v1.6 — async hybrid retrieval. When a vector backend is ready, run FTS5
|
|
298
|
+
* + vector top-K in parallel, fuse with reciprocal-rank-fusion, and return
|
|
299
|
+
* the merged top-K. Falls back to plain queryFacts when backend is off or
|
|
300
|
+
* the embedding model isn't loaded.
|
|
301
|
+
*
|
|
302
|
+
* Use this from memory-rpc.handleMemoryOp('query') so MCP callers get
|
|
303
|
+
* better recall automatically without changing the call site.
|
|
304
|
+
*/
|
|
305
|
+
export async function queryFactsHybrid(opts) {
|
|
306
|
+
const k = Math.min(Math.max(opts.k ?? 5, 1), 50);
|
|
307
|
+
const q = (opts.query || '').trim();
|
|
308
|
+
if (!q)
|
|
309
|
+
return queryFacts(opts);
|
|
310
|
+
let backend;
|
|
311
|
+
try {
|
|
312
|
+
backend = (await import('./memory-vector.js')).getActiveBackend();
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return queryFacts(opts);
|
|
316
|
+
}
|
|
317
|
+
if (!backend.ready)
|
|
318
|
+
return queryFacts(opts);
|
|
319
|
+
// FTS5 candidates first (synchronous, fast).
|
|
320
|
+
const ftsResult = queryFacts({ ...opts, k: Math.min(k * 3, 30) });
|
|
321
|
+
// Vector candidates next (async, may time out).
|
|
322
|
+
let vecResult = [];
|
|
323
|
+
try {
|
|
324
|
+
const { cosineSim, bufferToFloat32, getHybridWeight } = await import('./memory-vector.js');
|
|
325
|
+
void getHybridWeight; // (currently fixed weight in RRF; reserved for future tuning)
|
|
326
|
+
const queryVecArr = await backend.embed([q]);
|
|
327
|
+
const queryVec = queryVecArr[0];
|
|
328
|
+
if (queryVec && queryVec.length > 0) {
|
|
329
|
+
const db = helper.get();
|
|
330
|
+
if (db) {
|
|
331
|
+
// Pull candidates that match the ACTIVE backend's model + dims.
|
|
332
|
+
// Mixing embeddings from different models in one cosine ranking
|
|
333
|
+
// produces garbage similarities (different vector spaces).
|
|
334
|
+
const expectedModel = `${backend.name}:${backend.modelId}`;
|
|
335
|
+
const candidates = db.prepare(`
|
|
336
|
+
SELECT * FROM facts
|
|
337
|
+
WHERE user_key = ?
|
|
338
|
+
AND embedding IS NOT NULL
|
|
339
|
+
AND embedding_dims = ?
|
|
340
|
+
AND embedding_model = ?
|
|
341
|
+
${opts.category ? 'AND category = ?' : ''}
|
|
342
|
+
`).all(...(opts.category
|
|
343
|
+
? [opts.user_key, queryVec.length, expectedModel, opts.category]
|
|
344
|
+
: [opts.user_key, queryVec.length, expectedModel]));
|
|
345
|
+
const scored = candidates.map((row) => ({
|
|
346
|
+
row, score: cosineSim(queryVec, bufferToFloat32(row.embedding)),
|
|
347
|
+
}));
|
|
348
|
+
scored.sort((a, b) => b.score - a.score);
|
|
349
|
+
vecResult = scored.slice(0, k * 3).map((s) => s.row);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
log.debug({ event: 'memory.hybrid.vector_leg_failed', err: String(err) });
|
|
355
|
+
}
|
|
356
|
+
// RRF fusion. k_const=60 is the standard RRF damping.
|
|
357
|
+
const RRF_K = 60;
|
|
358
|
+
const scores = new Map();
|
|
359
|
+
const idToFact = new Map();
|
|
360
|
+
ftsResult.facts.forEach((f, i) => {
|
|
361
|
+
scores.set(f.id, (scores.get(f.id) || 0) + 1 / (RRF_K + i + 1));
|
|
362
|
+
idToFact.set(f.id, f);
|
|
363
|
+
});
|
|
364
|
+
vecResult.forEach((f, i) => {
|
|
365
|
+
scores.set(f.id, (scores.get(f.id) || 0) + 1 / (RRF_K + i + 1));
|
|
366
|
+
idToFact.set(f.id, f);
|
|
367
|
+
});
|
|
368
|
+
const ranked = Array.from(scores.entries())
|
|
369
|
+
.sort((a, b) => b[1] - a[1])
|
|
370
|
+
.slice(0, k)
|
|
371
|
+
.map(([id]) => idToFact.get(id))
|
|
372
|
+
.filter(Boolean);
|
|
373
|
+
// Bump last_referenced_at for the merged result set.
|
|
374
|
+
const db = helper.get();
|
|
375
|
+
if (db && ranked.length > 0) {
|
|
376
|
+
const ts = nowSec();
|
|
377
|
+
const stmt = db.prepare('UPDATE facts SET last_referenced_at = ? WHERE id = ?');
|
|
378
|
+
for (const r of ranked) {
|
|
379
|
+
try {
|
|
380
|
+
stmt.run(ts, r.id);
|
|
381
|
+
}
|
|
382
|
+
catch { /* ignore */ }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return { facts: ranked, matched: ranked.length };
|
|
386
|
+
}
|
|
387
|
+
/** v1.6 — backfill embeddings for facts that don't have one (e.g. existed
|
|
388
|
+
* before vector backend was enabled). Returns counts; safe to call again.
|
|
389
|
+
* Stops early on backend errors (returns partial). */
|
|
390
|
+
export async function backfillEmbeddings(opts) {
|
|
391
|
+
const db = helper.get();
|
|
392
|
+
if (!db)
|
|
393
|
+
return { processed: 0, succeeded: 0, failed: 0, errored: 'db unavailable' };
|
|
394
|
+
let backend;
|
|
395
|
+
try {
|
|
396
|
+
backend = (await import('./memory-vector.js')).getActiveBackend();
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return { processed: 0, succeeded: 0, failed: 0, errored: String(err) };
|
|
400
|
+
}
|
|
401
|
+
if (!backend.ready)
|
|
402
|
+
return { processed: 0, succeeded: 0, failed: 0, errored: 'backend not ready' };
|
|
403
|
+
const { float32ToBuffer, getBatchSize } = await import('./memory-vector.js');
|
|
404
|
+
const batchSize = opts.batchSize ?? getBatchSize();
|
|
405
|
+
const maxRows = opts.maxRows ?? 5000;
|
|
406
|
+
const where = opts.user_key
|
|
407
|
+
? 'WHERE user_key = ? AND (embedding IS NULL OR embedding_model != ?)'
|
|
408
|
+
: 'WHERE embedding IS NULL OR embedding_model != ?';
|
|
409
|
+
const expectedModel = `${backend.name}:${backend.modelId}`;
|
|
410
|
+
const selectParams = opts.user_key
|
|
411
|
+
? [opts.user_key, expectedModel]
|
|
412
|
+
: [expectedModel];
|
|
413
|
+
const rows = db.prepare(`
|
|
414
|
+
SELECT id, user_key, what, who, when_text, where_label, why FROM facts
|
|
415
|
+
${where} LIMIT ?
|
|
416
|
+
`).all(...selectParams, maxRows);
|
|
417
|
+
let processed = 0, succeeded = 0, failed = 0;
|
|
418
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
419
|
+
const batch = rows.slice(i, i + batchSize);
|
|
420
|
+
const texts = batch.map((r) => indexableText(r));
|
|
421
|
+
let vecs = [];
|
|
422
|
+
try {
|
|
423
|
+
vecs = await backend.embed(texts);
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
log.warn({ event: 'memory.backfill.batch_failed', err: String(err) });
|
|
427
|
+
failed += batch.length;
|
|
428
|
+
processed += batch.length;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const ts = nowSec();
|
|
432
|
+
const stmt = db.prepare(`
|
|
433
|
+
UPDATE facts SET embedding = ?, embedding_model = ?, embedding_dims = ?, embedded_at = ?
|
|
434
|
+
WHERE id = ?
|
|
435
|
+
`);
|
|
436
|
+
for (let j = 0; j < batch.length; j++) {
|
|
437
|
+
const v = vecs[j];
|
|
438
|
+
if (!v || v.length === 0) {
|
|
439
|
+
failed++;
|
|
440
|
+
processed++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
stmt.run(float32ToBuffer(v), expectedModel, v.length, ts, batch[j].id);
|
|
445
|
+
succeeded++;
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
log.debug({ event: 'memory.backfill.write_failed', id: batch[j].id, err: String(err) });
|
|
449
|
+
failed++;
|
|
450
|
+
}
|
|
451
|
+
processed++;
|
|
452
|
+
}
|
|
453
|
+
// Yield between batches so we don't hog the event loop on large backfills.
|
|
454
|
+
await new Promise((r) => setImmediate(r));
|
|
455
|
+
}
|
|
456
|
+
return { processed, succeeded, failed };
|
|
457
|
+
}
|
|
458
|
+
export function getVectorCoverage(user_key) {
|
|
459
|
+
const db = helper.get();
|
|
460
|
+
if (!db)
|
|
461
|
+
return { total: 0, withEmbedding: 0, withDifferentModel: 0 };
|
|
462
|
+
try {
|
|
463
|
+
const where = user_key ? 'WHERE user_key = ?' : '';
|
|
464
|
+
const params = user_key ? [user_key] : [];
|
|
465
|
+
const total = db.prepare(`SELECT COUNT(*) AS n FROM facts ${where}`).get(...params).n;
|
|
466
|
+
const withEmb = db.prepare(`SELECT COUNT(*) AS n FROM facts ${where} ${where ? 'AND' : 'WHERE'} embedding IS NOT NULL`).get(...params).n;
|
|
467
|
+
return { total, withEmbedding: withEmb, withDifferentModel: 0 };
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return { total: 0, withEmbedding: 0, withDifferentModel: 0 };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** Clear all embedding fields for a user (or all). Useful when switching
|
|
474
|
+
* backends — embeddings from different models can't be compared. */
|
|
475
|
+
export function clearEmbeddings(user_key) {
|
|
476
|
+
const db = helper.get();
|
|
477
|
+
if (!db)
|
|
478
|
+
return 0;
|
|
479
|
+
try {
|
|
480
|
+
const where = user_key ? 'WHERE user_key = ?' : '';
|
|
481
|
+
const params = user_key ? [user_key] : [];
|
|
482
|
+
const r = db.prepare(`
|
|
483
|
+
UPDATE facts SET embedding = NULL, embedding_model = NULL, embedding_dims = NULL, embedded_at = NULL
|
|
484
|
+
${where}
|
|
485
|
+
`).run(...params);
|
|
486
|
+
return r.changes;
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
log.warn({ event: 'memory.clear_embeddings_failed', err: String(err) });
|
|
490
|
+
return 0;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
export function listRecentFacts(user_key, limit = 50) {
|
|
494
|
+
const db = helper.get();
|
|
495
|
+
if (!db)
|
|
496
|
+
return [];
|
|
497
|
+
try {
|
|
498
|
+
return db.prepare(`
|
|
499
|
+
SELECT * FROM facts WHERE user_key = ? ORDER BY created_at DESC LIMIT ?
|
|
500
|
+
`).all(user_key, Math.min(Math.max(limit, 1), 500));
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
export function countFacts(user_key) {
|
|
507
|
+
const db = helper.get();
|
|
508
|
+
if (!db)
|
|
509
|
+
return 0;
|
|
510
|
+
try {
|
|
511
|
+
const r = db.prepare('SELECT COUNT(*) AS n FROM facts WHERE user_key = ?').get(user_key);
|
|
512
|
+
return r.n;
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
export function getPersona(user_key) {
|
|
519
|
+
const db = helper.get();
|
|
520
|
+
if (!db)
|
|
521
|
+
return null;
|
|
522
|
+
try {
|
|
523
|
+
return db.prepare('SELECT * FROM persona_profile WHERE user_key = ?')
|
|
524
|
+
.get(user_key) ?? null;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
export function upsertPersona(user_key, summary) {
|
|
531
|
+
const db = helper.get();
|
|
532
|
+
if (!db)
|
|
533
|
+
return false;
|
|
534
|
+
try {
|
|
535
|
+
db.prepare(`
|
|
536
|
+
INSERT INTO persona_profile (user_key, summary, updated_at) VALUES (?, ?, ?)
|
|
537
|
+
ON CONFLICT(user_key) DO UPDATE SET summary = excluded.summary, updated_at = excluded.updated_at
|
|
538
|
+
`).run(user_key, summary, nowSec());
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
log.warn({ event: 'memory.persona_upsert_failed', err: String(err) });
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Enumerate every user_key the memory system has touched, with quick stats.
|
|
547
|
+
* Used by /api/memory/users to populate the admin selector. */
|
|
548
|
+
export function listUsers() {
|
|
549
|
+
const db = helper.get();
|
|
550
|
+
if (!db)
|
|
551
|
+
return [];
|
|
552
|
+
try {
|
|
553
|
+
const factsRows = db.prepare(`
|
|
554
|
+
SELECT
|
|
555
|
+
user_key,
|
|
556
|
+
COUNT(*) AS fact_count,
|
|
557
|
+
MIN(created_at) AS oldest_fact_at,
|
|
558
|
+
MAX(created_at) AS newest_fact_at
|
|
559
|
+
FROM facts GROUP BY user_key
|
|
560
|
+
`).all();
|
|
561
|
+
const personaRows = db.prepare('SELECT user_key, updated_at FROM persona_profile').all();
|
|
562
|
+
const personaMap = new Map(personaRows.map((p) => [p.user_key, p.updated_at]));
|
|
563
|
+
const out = [];
|
|
564
|
+
const seen = new Set();
|
|
565
|
+
for (const f of factsRows) {
|
|
566
|
+
seen.add(f.user_key);
|
|
567
|
+
out.push({
|
|
568
|
+
user_key: f.user_key,
|
|
569
|
+
fact_count: f.fact_count,
|
|
570
|
+
oldest_fact_at: f.oldest_fact_at,
|
|
571
|
+
newest_fact_at: f.newest_fact_at,
|
|
572
|
+
has_persona: personaMap.has(f.user_key),
|
|
573
|
+
persona_updated_at: personaMap.get(f.user_key) ?? null,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
// Users with a persona but no facts (rare — manual upsertPersona)
|
|
577
|
+
for (const p of personaRows) {
|
|
578
|
+
if (seen.has(p.user_key))
|
|
579
|
+
continue;
|
|
580
|
+
out.push({
|
|
581
|
+
user_key: p.user_key,
|
|
582
|
+
fact_count: 0,
|
|
583
|
+
oldest_fact_at: null,
|
|
584
|
+
newest_fact_at: null,
|
|
585
|
+
has_persona: true,
|
|
586
|
+
persona_updated_at: p.updated_at,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
out.sort((a, b) => (b.newest_fact_at ?? 0) - (a.newest_fact_at ?? 0));
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
log.warn({ event: 'memory.list_users_failed', err: String(err) });
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** Paged fact listing with optional filters. Mirrors queryFacts but adds
|
|
598
|
+
* offset + total count for table pagination. */
|
|
599
|
+
export function listFacts(opts) {
|
|
600
|
+
const db = helper.get();
|
|
601
|
+
if (!db)
|
|
602
|
+
return { total: 0, limit: 0, offset: 0, facts: [] };
|
|
603
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
|
|
604
|
+
const offset = Math.max(opts.offset ?? 0, 0);
|
|
605
|
+
const cat = opts.category;
|
|
606
|
+
const q = (opts.query || '').trim();
|
|
607
|
+
try {
|
|
608
|
+
let totalSql;
|
|
609
|
+
let totalParams;
|
|
610
|
+
let rowsSql;
|
|
611
|
+
let rowsParams;
|
|
612
|
+
if (!q) {
|
|
613
|
+
totalSql = `SELECT COUNT(*) AS n FROM facts WHERE user_key = ? ${cat ? 'AND category = ?' : ''}`;
|
|
614
|
+
totalParams = cat ? [opts.user_key, cat] : [opts.user_key];
|
|
615
|
+
rowsSql = `SELECT * FROM facts WHERE user_key = ? ${cat ? 'AND category = ?' : ''} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
|
616
|
+
rowsParams = cat ? [opts.user_key, cat, limit, offset] : [opts.user_key, limit, offset];
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
const safeQ = buildFtsMatch(q);
|
|
620
|
+
if (!safeQ)
|
|
621
|
+
return { total: 0, limit, offset, facts: [] };
|
|
622
|
+
totalSql = `
|
|
623
|
+
SELECT COUNT(*) AS n FROM facts_fts t JOIN facts f ON f.id = t.rowid
|
|
624
|
+
WHERE t.user_key = ? AND facts_fts MATCH ? ${cat ? 'AND f.category = ?' : ''}
|
|
625
|
+
`;
|
|
626
|
+
totalParams = cat ? [opts.user_key, safeQ, cat] : [opts.user_key, safeQ];
|
|
627
|
+
rowsSql = `
|
|
628
|
+
SELECT f.* FROM facts_fts t JOIN facts f ON f.id = t.rowid
|
|
629
|
+
WHERE t.user_key = ? AND facts_fts MATCH ? ${cat ? 'AND f.category = ?' : ''}
|
|
630
|
+
ORDER BY bm25(facts_fts), f.created_at DESC LIMIT ? OFFSET ?
|
|
631
|
+
`;
|
|
632
|
+
rowsParams = cat ? [opts.user_key, safeQ, cat, limit, offset] : [opts.user_key, safeQ, limit, offset];
|
|
633
|
+
}
|
|
634
|
+
const total = db.prepare(totalSql).get(...totalParams).n;
|
|
635
|
+
const facts = db.prepare(rowsSql).all(...rowsParams);
|
|
636
|
+
return { total, limit, offset, facts };
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
log.warn({ event: 'memory.list_facts_failed', err: String(err) });
|
|
640
|
+
return { total: 0, limit, offset, facts: [] };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/** Bulk delete by user + optional filters. Returns row count actually deleted.
|
|
644
|
+
* Defaults are intentionally inert ({user_key} alone deletes ALL facts for
|
|
645
|
+
* that user) — caller must explicitly opt in to clear-all by passing
|
|
646
|
+
* confirm_clear: true. Otherwise needs at least one filter. */
|
|
647
|
+
export function bulkDeleteFacts(opts) {
|
|
648
|
+
const db = helper.get();
|
|
649
|
+
if (!db)
|
|
650
|
+
return 0;
|
|
651
|
+
const filters = ['user_key = ?'];
|
|
652
|
+
const params = [opts.user_key];
|
|
653
|
+
if (opts.ids && opts.ids.length > 0) {
|
|
654
|
+
const placeholders = opts.ids.map(() => '?').join(',');
|
|
655
|
+
filters.push(`id IN (${placeholders})`);
|
|
656
|
+
params.push(...opts.ids);
|
|
657
|
+
}
|
|
658
|
+
if (opts.category) {
|
|
659
|
+
filters.push('category = ?');
|
|
660
|
+
params.push(opts.category);
|
|
661
|
+
}
|
|
662
|
+
if (typeof opts.max_confidence === 'number') {
|
|
663
|
+
filters.push('confidence <= ?');
|
|
664
|
+
params.push(opts.max_confidence);
|
|
665
|
+
}
|
|
666
|
+
// Safety: if no filter beyond user_key, require explicit clear flag.
|
|
667
|
+
if (filters.length === 1 && !opts.confirm_clear) {
|
|
668
|
+
log.warn({ event: 'memory.bulk_delete.refused_unfiltered', user_key: opts.user_key });
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
const where = filters.join(' AND ');
|
|
672
|
+
try {
|
|
673
|
+
// Need ids first to also drop from FTS.
|
|
674
|
+
const targets = db.prepare(`SELECT id FROM facts WHERE ${where}`).all(...params);
|
|
675
|
+
if (targets.length === 0)
|
|
676
|
+
return 0;
|
|
677
|
+
const ids = targets.map((t) => t.id);
|
|
678
|
+
const idPlaceholders = ids.map(() => '?').join(',');
|
|
679
|
+
db.prepare(`DELETE FROM facts_fts WHERE rowid IN (${idPlaceholders})`).run(...ids);
|
|
680
|
+
const r = db.prepare(`DELETE FROM facts WHERE id IN (${idPlaceholders})`).run(...ids);
|
|
681
|
+
return r.changes;
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
log.warn({ event: 'memory.bulk_delete_failed', err: String(err) });
|
|
685
|
+
return 0;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
export function deletePersona(user_key) {
|
|
689
|
+
const db = helper.get();
|
|
690
|
+
if (!db)
|
|
691
|
+
return false;
|
|
692
|
+
try {
|
|
693
|
+
const r = db.prepare('DELETE FROM persona_profile WHERE user_key = ?').run(user_key);
|
|
694
|
+
return r.changes > 0;
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
log.warn({ event: 'memory.persona_delete_failed', err: String(err) });
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/** Full export: persona + all facts for one user, JSON-safe shape. */
|
|
702
|
+
export function exportUserMemory(user_key) {
|
|
703
|
+
return {
|
|
704
|
+
user_key,
|
|
705
|
+
persona: getPersona(user_key),
|
|
706
|
+
facts: listRecentFacts(user_key, 10000),
|
|
707
|
+
exported_at: new Date().toISOString(),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
export function closeMemoryDb() {
|
|
711
|
+
helper.close();
|
|
712
|
+
}
|
|
713
|
+
export const MEMORY_DB_PATH = MEMORY_DB;
|
|
714
|
+
//# sourceMappingURL=memory.js.map
|