opencode-memory-pro 1.3.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 +24 -0
- package/README.md +409 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +398 -0
- package/dist/embedder.d.ts +26 -0
- package/dist/embedder.js +260 -0
- package/dist/extract.d.ts +4 -0
- package/dist/extract.js +181 -0
- package/dist/graph.js +701 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +953 -0
- package/dist/llm.d.ts +14 -0
- package/dist/llm.js +212 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +126 -0
- package/dist/ports.d.ts +34 -0
- package/dist/ports.js +129 -0
- package/dist/preference.d.ts +10 -0
- package/dist/preference.js +125 -0
- package/dist/scope.d.ts +2 -0
- package/dist/scope.js +48 -0
- package/dist/store.d.ts +194 -0
- package/dist/store.js +2738 -0
- package/dist/summarize.d.ts +52 -0
- package/dist/summarize.js +350 -0
- package/dist/tools/episodic.d.ts +68 -0
- package/dist/tools/episodic.js +145 -0
- package/dist/tools/feedback.d.ts +51 -0
- package/dist/tools/feedback.js +112 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/memory.d.ts +293 -0
- package/dist/tools/memory.js +1487 -0
- package/dist/types.d.ts +489 -0
- package/dist/types.js +54 -0
- package/dist/utils.d.ts +18 -0
- package/dist/utils.js +214 -0
- package/package.json +49 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,2738 @@
|
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { validateEpisodicRecord, validateEpisodicRecordArray } from "./types.js";
|
|
4
|
+
import { tokenize } from "./utils.js";
|
|
5
|
+
import { log } from "./logger.js";
|
|
6
|
+
const TABLE_NAME = "memories";
|
|
7
|
+
const EVENTS_TABLE_NAME = "effectiveness_events";
|
|
8
|
+
const EVENTS_SOURCE_COLUMN = "source";
|
|
9
|
+
const DEFAULT_CACHE_CONFIG = {
|
|
10
|
+
maxScopes: 10,
|
|
11
|
+
maxRecordsPerScope: 1000,
|
|
12
|
+
enabled: true,
|
|
13
|
+
};
|
|
14
|
+
// ANN_TUNABLES (1.3.0): nprobes controls IVF recall-vs-latency on filtered
|
|
15
|
+
// vector searches; the consolidation query batch controls how many ANN
|
|
16
|
+
// queries each batched vectorSearch call carries. Both were build-time
|
|
17
|
+
// guesses (nprobes=40, batch=16) — now env-overridable with the same
|
|
18
|
+
// conservative defaults, so tuning no longer requires a rebuild.
|
|
19
|
+
function envInt(name, fallback, min, max) {
|
|
20
|
+
const raw = Number(process.env[name]);
|
|
21
|
+
return Number.isFinite(raw) ? Math.min(max, Math.max(min, Math.floor(raw))) : fallback;
|
|
22
|
+
}
|
|
23
|
+
const NPROBES = envInt("OPENCODE_MEMORY_PRO_NPROBES", 40, 1, 500);
|
|
24
|
+
const ANN_QUERY_BATCH = envInt("OPENCODE_MEMORY_PRO_QUERY_BATCH", 16, 1, 256);
|
|
25
|
+
// Exported for use by consolidateDuplicates
|
|
26
|
+
export function storeFastCosine(a, b, normA, normB) {
|
|
27
|
+
if (a.length === 0 || b.length === 0 || a.length !== b.length)
|
|
28
|
+
return 0;
|
|
29
|
+
const denom = normA * normB;
|
|
30
|
+
if (denom === 0)
|
|
31
|
+
return 0;
|
|
32
|
+
let dot = 0;
|
|
33
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
34
|
+
dot += a[i] * b[i];
|
|
35
|
+
}
|
|
36
|
+
return dot / denom;
|
|
37
|
+
}
|
|
38
|
+
export class MemoryStore {
|
|
39
|
+
dbPath;
|
|
40
|
+
static MIN_ROWS_FOR_INDEX = 256;
|
|
41
|
+
lancedb = null;
|
|
42
|
+
connection = null;
|
|
43
|
+
table = null;
|
|
44
|
+
eventTable = null;
|
|
45
|
+
episodicTaskTable = null;
|
|
46
|
+
indexState = {
|
|
47
|
+
vector: false,
|
|
48
|
+
fts: false,
|
|
49
|
+
ftsError: "",
|
|
50
|
+
vectorRetries: 0,
|
|
51
|
+
ftsRetries: 0,
|
|
52
|
+
};
|
|
53
|
+
scopeCache = new Map();
|
|
54
|
+
// SCOPE_CACHE_LAZY (1.1.7): per-scope write counter. invalidateScope()
|
|
55
|
+
// bumps this instead of deleting the cache entry, so a burst of writes
|
|
56
|
+
// (one chat turn = several commits) no longer thrashes the cache — the
|
|
57
|
+
// entry is only reloaded when a query actually observes a stale version.
|
|
58
|
+
scopeVersions = new Map();
|
|
59
|
+
cacheConfig;
|
|
60
|
+
cacheStats = { hits: 0, misses: 0, evictions: 0 };
|
|
61
|
+
graph = null;
|
|
62
|
+
// LANCE_COMPACTION (1.1.2): Lance keeps one immutable version per write
|
|
63
|
+
// (add/delete/update) plus its fragment files forever; without periodic
|
|
64
|
+
// compaction this fork grew to ~21k _versions + data files (1.2GB with an
|
|
65
|
+
// 8192 fd limit) → EMFILE, cancelled native tasks, OOM. optimize() compacts
|
|
66
|
+
// fragments and prunes old versions; Lance serializes writers with an
|
|
67
|
+
// exclusive table lock, so running it live is safe, and a failure is
|
|
68
|
+
// logged but never fatal.
|
|
69
|
+
static OPTIMIZE_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
70
|
+
static OPTIMIZE_MIN_VERSIONS = 500;
|
|
71
|
+
optimizing = false;
|
|
72
|
+
lastOptimizeAt = 0;
|
|
73
|
+
constructor(dbPath, cacheConfig) {
|
|
74
|
+
this.dbPath = dbPath;
|
|
75
|
+
this.cacheConfig = { ...DEFAULT_CACHE_CONFIG, ...cacheConfig };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Version-count-gated Lance compaction. Non-blocking: reads the _versions
|
|
79
|
+
* directory for each open table and optimizes the ones that crossed the
|
|
80
|
+
* threshold (or all when force=true), throttled by an interval so chatty
|
|
81
|
+
* sessions can't trigger it every turn. cleanupOlderThan=1h keeps
|
|
82
|
+
* in-flight recent versions; deleteUnverified removes orphaned fragment
|
|
83
|
+
* files (safe under Lance's exclusive table write lock).
|
|
84
|
+
*/
|
|
85
|
+
async maybeOptimizeAll(force = false) {
|
|
86
|
+
if (this.optimizing)
|
|
87
|
+
return;
|
|
88
|
+
const elapsed = Date.now() - this.lastOptimizeAt;
|
|
89
|
+
if (!force && elapsed < MemoryStore.OPTIMIZE_INTERVAL_MS)
|
|
90
|
+
return;
|
|
91
|
+
const tables = [this.table, this.eventTable, this.episodicTaskTable].filter(Boolean);
|
|
92
|
+
const candidates = [];
|
|
93
|
+
for (const table of tables) {
|
|
94
|
+
let count = 0;
|
|
95
|
+
// LANCE_COMPACTION_FIX (1.1.6): LanceDB stores each table on disk as
|
|
96
|
+
// "<name>.lance", but Table.name only carries the bare name — so the
|
|
97
|
+
// old readdir(.../table.name/_versions) always hit ENOENT, the catch
|
|
98
|
+
// swallowed it, and optimize() NEVER ran. Result: 13k+ _versions and
|
|
99
|
+
// 11k+ fragment files accumulated (disk + native handle/cache growth
|
|
100
|
+
// per write, EMFILE/OOM risk). Try the real on-disk dir first.
|
|
101
|
+
for (const dirName of [`${table.name}.lance`, table.name]) {
|
|
102
|
+
try {
|
|
103
|
+
const entries = await readdir(join(this.dbPath, dirName, "_versions"), { withFileTypes: true });
|
|
104
|
+
count = entries.filter((e) => e.isFile()).length;
|
|
105
|
+
if (count > 0)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
}
|
|
110
|
+
if (force || count >= MemoryStore.OPTIMIZE_MIN_VERSIONS) {
|
|
111
|
+
candidates.push({ table, count });
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
log("debug", `[store] optimize skipped for ${table.name}: ${count} versions (min ${MemoryStore.OPTIMIZE_MIN_VERSIONS})`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (force) {
|
|
118
|
+
this.lastOptimizeAt = Date.now();
|
|
119
|
+
}
|
|
120
|
+
if (candidates.length === 0)
|
|
121
|
+
return;
|
|
122
|
+
this.optimizing = true;
|
|
123
|
+
try {
|
|
124
|
+
const olderThan = new Date(Date.now() - 60 * 60 * 1000);
|
|
125
|
+
log("debug", `[store] optimize candidates: ${candidates.map((c) => `${c.table.name}(${c.count})`).join(", ")}`);
|
|
126
|
+
for (const { table, count } of candidates) {
|
|
127
|
+
try {
|
|
128
|
+
const stats = await table.optimize({ cleanupOlderThan: olderThan, deleteUnverified: true });
|
|
129
|
+
log("info", `[store] optimized ${table.name}: ${count} versions before, pruned=${stats.prune.oldVersionsRemoved}, bytesRemoved=${stats.prune.bytesRemoved}`);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
log("warn", `[store] optimize failed for ${table.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
this.optimizing = false;
|
|
138
|
+
this.lastOptimizeAt = Date.now();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async init(vectorDim) {
|
|
142
|
+
await mkdir(this.dbPath, { recursive: true });
|
|
143
|
+
await mkdir(dirname(this.dbPath), { recursive: true });
|
|
144
|
+
this.lancedb = await import("@lancedb/lancedb");
|
|
145
|
+
this.connection = (await this.lancedb.connect(this.dbPath));
|
|
146
|
+
try {
|
|
147
|
+
this.table = await this.connection.openTable(TABLE_NAME);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
const bootstrap = {
|
|
151
|
+
id: "__bootstrap__",
|
|
152
|
+
text: "",
|
|
153
|
+
vector: new Array(vectorDim).fill(0),
|
|
154
|
+
category: "other",
|
|
155
|
+
scope: "global",
|
|
156
|
+
importance: 0,
|
|
157
|
+
timestamp: 0,
|
|
158
|
+
lastRecalled: 0,
|
|
159
|
+
recallCount: 0,
|
|
160
|
+
projectCount: 0,
|
|
161
|
+
schemaVersion: 2,
|
|
162
|
+
embeddingModel: "bootstrap",
|
|
163
|
+
vectorDim,
|
|
164
|
+
metadataJson: "{}",
|
|
165
|
+
userId: undefined,
|
|
166
|
+
teamId: undefined,
|
|
167
|
+
sourceSessionId: undefined,
|
|
168
|
+
confidence: undefined,
|
|
169
|
+
tags: undefined,
|
|
170
|
+
status: "active",
|
|
171
|
+
parentId: undefined,
|
|
172
|
+
citationSource: undefined,
|
|
173
|
+
citationTimestamp: undefined,
|
|
174
|
+
citationStatus: undefined,
|
|
175
|
+
citationChain: undefined,
|
|
176
|
+
};
|
|
177
|
+
this.table = await this.connection.createTable(TABLE_NAME, [bootstrap]);
|
|
178
|
+
await this.table.delete("id = '__bootstrap__'");
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
this.eventTable = await this.connection.openTable(EVENTS_TABLE_NAME);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
const bootstrapEvent = {
|
|
185
|
+
id: "__bootstrap__",
|
|
186
|
+
type: "capture",
|
|
187
|
+
scope: "global",
|
|
188
|
+
sessionID: "",
|
|
189
|
+
timestamp: 0,
|
|
190
|
+
memoryId: "",
|
|
191
|
+
text: "",
|
|
192
|
+
outcome: "considered",
|
|
193
|
+
skipReason: "",
|
|
194
|
+
resultCount: 0,
|
|
195
|
+
injected: false,
|
|
196
|
+
source: "",
|
|
197
|
+
feedbackType: "",
|
|
198
|
+
helpful: -1,
|
|
199
|
+
reason: "",
|
|
200
|
+
labelsJson: "[]",
|
|
201
|
+
metadataJson: "{}",
|
|
202
|
+
};
|
|
203
|
+
this.eventTable = await this.connection.createTable(EVENTS_TABLE_NAME, [bootstrapEvent]);
|
|
204
|
+
await this.eventTable.delete("id = '__bootstrap__'");
|
|
205
|
+
}
|
|
206
|
+
await this.ensureMemoriesTableCompatibility();
|
|
207
|
+
await this.ensureEventTableCompatibility();
|
|
208
|
+
await this.ensureIndexes();
|
|
209
|
+
const retentionDays = this.retentionConfig?.effectivenessEventsDays;
|
|
210
|
+
if (retentionDays !== undefined && retentionDays > 0) {
|
|
211
|
+
await this.cleanupExpiredEvents(undefined, retentionDays);
|
|
212
|
+
}
|
|
213
|
+
// LANCE_COMPACTION_FIX (1.1.6): fire-and-forget so a first-run
|
|
214
|
+
// compaction of a backlogged store (13k+ versions) doesn't block init —
|
|
215
|
+
// it compacts in the background once the version gate passes.
|
|
216
|
+
void this.maybeOptimizeAll(false).catch((error) => {
|
|
217
|
+
log("warn", `[store] startup optimize failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// GRACEFUL_SHUTDOWN: lance's commit path spawns a background
|
|
221
|
+
// auto_cleanup_hook task; if the process exits without closing the
|
|
222
|
+
// connection, tokio drops the runtime mid-task and lance logs "task ... was
|
|
223
|
+
// cancelled" at shutdown. close() is synchronous (native binding) and
|
|
224
|
+
// idempotent, so it can run from a process.on("exit") listener. Tables are
|
|
225
|
+
// independent of the connection, so this is safe even with in-flight ops.
|
|
226
|
+
close() {
|
|
227
|
+
try {
|
|
228
|
+
this.connection?.close();
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
log("warn", `[store] close failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
232
|
+
}
|
|
233
|
+
this.connection = null;
|
|
234
|
+
this.table = null;
|
|
235
|
+
this.eventTable = null;
|
|
236
|
+
this.episodicTaskTable = null;
|
|
237
|
+
this.lancedb = null;
|
|
238
|
+
}
|
|
239
|
+
retentionConfig;
|
|
240
|
+
setRetentionConfig(config) {
|
|
241
|
+
this.retentionConfig = config;
|
|
242
|
+
}
|
|
243
|
+
// GRAPH_STORE_PHASE1: attach the offline entity graph for provenance
|
|
244
|
+
// cleanup on memory removal/merge. Safe no-op if never attached.
|
|
245
|
+
attachGraph(graph) {
|
|
246
|
+
this.graph = graph;
|
|
247
|
+
}
|
|
248
|
+
notifyGraphRemoved(id) {
|
|
249
|
+
try {
|
|
250
|
+
this.graph?.onMemoryRemoved(id);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
log("warn", `[store] graph onMemoryRemoved failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
notifyGraphMerged(olderId, newerId) {
|
|
257
|
+
try {
|
|
258
|
+
this.graph?.onMemoryMerged(olderId, newerId);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
log("warn", `[store] graph onMemoryMerged failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async cleanupExpiredEvents(scopes, retentionDaysOverride) {
|
|
265
|
+
const table = this.requireEventTable();
|
|
266
|
+
const retentionDays = retentionDaysOverride ?? this.retentionConfig?.effectivenessEventsDays ?? 90;
|
|
267
|
+
if (retentionDays <= 0) {
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
271
|
+
// TTL_BATCH_DELETE (1.1.7): was a per-row delete loop (up to 1000
|
|
272
|
+
// commits at startup, each spawning a new LanceDB version). Now one
|
|
273
|
+
// batched `id IN (...)` delete per 1000 rows → a handful of commits.
|
|
274
|
+
// SCOPE_FILTER_FIX (1.2.0): events are always scoped with exact
|
|
275
|
+
// strings ("global" / "project:<hash>"); the old `scope LIKE
|
|
276
|
+
// 'project:%'` matched EVERY project scope, so cleaning one project
|
|
277
|
+
// also deleted every other project's expired events. Now exact-match
|
|
278
|
+
// `scope IN (...)` for the given scopes (undefined = all scopes).
|
|
279
|
+
let deletedCount = 0;
|
|
280
|
+
const ttlBatchSize = 1000;
|
|
281
|
+
for (;;) {
|
|
282
|
+
let filter = `timestamp < ${cutoffTimestamp}`;
|
|
283
|
+
if (Array.isArray(scopes) && scopes.length > 0) {
|
|
284
|
+
const scopeExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
285
|
+
filter = `(${filter}) AND (${scopeExpr})`;
|
|
286
|
+
}
|
|
287
|
+
const toDelete = await table.query().where(filter).limit(ttlBatchSize).toArray();
|
|
288
|
+
if (toDelete.length === 0)
|
|
289
|
+
break;
|
|
290
|
+
const idsToDelete = toDelete.map((row) => row.id);
|
|
291
|
+
try {
|
|
292
|
+
const idIn = idsToDelete.map((id) => `'${escapeSql(id)}'`).join(", ");
|
|
293
|
+
await table.delete(`id IN (${idIn})`);
|
|
294
|
+
deletedCount += idsToDelete.length;
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
log("warn", `[store] Failed to batch-delete ${idsToDelete.length} expired events: ${error}`);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
if (idsToDelete.length < ttlBatchSize)
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
if (deletedCount > 0) {
|
|
304
|
+
log("info", `[store] Event TTL cleanup completed, deleted=${deletedCount}, retentionDays=${retentionDays}`);
|
|
305
|
+
}
|
|
306
|
+
await this.maybeOptimizeAll(false);
|
|
307
|
+
return deletedCount;
|
|
308
|
+
}
|
|
309
|
+
async getEventTtlStatus() {
|
|
310
|
+
const retentionDays = this.retentionConfig?.effectivenessEventsDays ?? 90;
|
|
311
|
+
const enabled = retentionDays > 0;
|
|
312
|
+
if (!enabled) {
|
|
313
|
+
return { enabled: false, retentionDays: 0, expiredCount: 0, scopeBreakdown: {} };
|
|
314
|
+
}
|
|
315
|
+
const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
316
|
+
const table = this.requireEventTable();
|
|
317
|
+
const allExpired = await table.query().where(`timestamp < ${cutoffTimestamp}`).toArray();
|
|
318
|
+
const expiredCount = allExpired.length;
|
|
319
|
+
const scopeBreakdown = {};
|
|
320
|
+
for (const row of allExpired) {
|
|
321
|
+
const scope = row.scope || "unknown";
|
|
322
|
+
scopeBreakdown[scope] = (scopeBreakdown[scope] || 0) + 1;
|
|
323
|
+
}
|
|
324
|
+
return { enabled, retentionDays, expiredCount, scopeBreakdown };
|
|
325
|
+
}
|
|
326
|
+
async put(record) {
|
|
327
|
+
const table = this.requireTable();
|
|
328
|
+
const recordWithDefaults = {
|
|
329
|
+
...record,
|
|
330
|
+
userId: record.userId ?? undefined,
|
|
331
|
+
teamId: record.teamId ?? undefined,
|
|
332
|
+
sourceSessionId: record.sourceSessionId ?? undefined,
|
|
333
|
+
confidence: record.confidence ?? undefined,
|
|
334
|
+
tags: record.tags ?? undefined,
|
|
335
|
+
status: record.status ?? "active",
|
|
336
|
+
parentId: record.parentId ?? undefined,
|
|
337
|
+
};
|
|
338
|
+
await table.add([recordWithDefaults]);
|
|
339
|
+
this.invalidateScope(record.scope);
|
|
340
|
+
}
|
|
341
|
+
async putEvent(event) {
|
|
342
|
+
const feedbackEvent = event.type === "feedback" ? event : null;
|
|
343
|
+
const captureEvent = event.type === "capture" ? event : null;
|
|
344
|
+
// EVENT_TEXT_BOUND (1.1.7): capture events carry the whole transcript
|
|
345
|
+
// (up to 60k chars) into the events table — the reason
|
|
346
|
+
// effectiveness_events.lance was 636MB. Telemetry stats only ever use
|
|
347
|
+
// outcome/skipReason, never the full text, so bound it here at the
|
|
348
|
+
// chokepoint for every event type.
|
|
349
|
+
const eventText = typeof event.text === "string" ? event.text.slice(0, 4000) : "";
|
|
350
|
+
await this.requireEventTable().add([
|
|
351
|
+
{
|
|
352
|
+
id: event.id,
|
|
353
|
+
type: event.type,
|
|
354
|
+
scope: event.scope,
|
|
355
|
+
sessionID: event.sessionID ?? "",
|
|
356
|
+
timestamp: event.timestamp,
|
|
357
|
+
memoryId: event.memoryId ?? "",
|
|
358
|
+
text: eventText,
|
|
359
|
+
outcome: event.type === "capture" ? event.outcome : "",
|
|
360
|
+
skipReason: event.type === "capture" ? event.skipReason ?? "" : "",
|
|
361
|
+
resultCount: event.type === "recall" ? event.resultCount : 0,
|
|
362
|
+
injected: event.type === "recall" ? event.injected : false,
|
|
363
|
+
source: event.type === "recall" ? event.source ?? "" : "",
|
|
364
|
+
feedbackType: event.type === "feedback" ? event.feedbackType : "",
|
|
365
|
+
helpful: event.type === "feedback" ? (event.helpful === undefined ? -1 : event.helpful ? 1 : 0) : -1,
|
|
366
|
+
reason: event.type === "feedback" ? event.reason ?? "" : "",
|
|
367
|
+
labelsJson: event.type === "feedback" ? JSON.stringify(event.labels ?? []) : "[]",
|
|
368
|
+
metadataJson: event.metadataJson,
|
|
369
|
+
sourceSessionId: feedbackEvent?.sourceSessionId ?? captureEvent?.sourceSessionId ?? "",
|
|
370
|
+
confidenceDelta: feedbackEvent?.confidenceDelta ?? null,
|
|
371
|
+
relatedMemoryId: feedbackEvent?.relatedMemoryId ?? "",
|
|
372
|
+
context: feedbackEvent?.context ? JSON.stringify(feedbackEvent.context) : null,
|
|
373
|
+
},
|
|
374
|
+
]);
|
|
375
|
+
}
|
|
376
|
+
async search(params) {
|
|
377
|
+
const cached = await this.getCachedScopes(params.scopes);
|
|
378
|
+
if (cached.records.length === 0)
|
|
379
|
+
return [];
|
|
380
|
+
const queryTokens = tokenize(params.query);
|
|
381
|
+
const queryNorm = vecNorm(params.queryVector);
|
|
382
|
+
const useVectorChannel = params.queryVector.length > 0 && params.vectorWeight > 0;
|
|
383
|
+
const useBm25Channel = queryTokens.length > 0 && params.bm25Weight > 0;
|
|
384
|
+
const { vectorWeight, bm25Weight } = normalizeChannelWeights(useVectorChannel ? params.vectorWeight : 0, useBm25Channel ? params.bm25Weight : 0);
|
|
385
|
+
const rrfK = Math.max(1, Math.floor(params.rrfK ?? 60));
|
|
386
|
+
const recencyBoostEnabled = params.recencyBoost ?? true;
|
|
387
|
+
const recencyHalfLifeHours = Math.max(1, params.recencyHalfLifeHours ?? 72);
|
|
388
|
+
const importanceWeight = clampImportanceWeight(params.importanceWeight ?? 0.4);
|
|
389
|
+
const feedbackWeight = Math.max(0, Math.min(1, params.feedbackWeight ?? 0));
|
|
390
|
+
const globalDiscountFactor = params.globalDiscountFactor ?? 1.0;
|
|
391
|
+
const candidates = cached.records
|
|
392
|
+
.filter((record) => params.queryVector.length === 0 || record.vector.length === params.queryVector.length)
|
|
393
|
+
.map((record, index) => {
|
|
394
|
+
const recordNorm = cached.norms.get(record.id) ?? vecNorm(record.vector);
|
|
395
|
+
const vectorScore = useVectorChannel ? fastCosine(params.queryVector, record.vector, queryNorm, recordNorm) : 0;
|
|
396
|
+
const bm25Score = useBm25Channel ? bm25LikeScore(queryTokens, cached.tokenized[index], cached.idf) : 0;
|
|
397
|
+
const isGlobal = record.scope === "global";
|
|
398
|
+
return { record, vectorScore, bm25Score, isGlobal };
|
|
399
|
+
});
|
|
400
|
+
if (candidates.length === 0)
|
|
401
|
+
return [];
|
|
402
|
+
const vectorRanks = useVectorChannel ? buildRankMap(candidates, (item) => item.vectorScore) : null;
|
|
403
|
+
const bm25Ranks = useBm25Channel ? buildRankMap(candidates, (item) => item.bm25Score) : null;
|
|
404
|
+
const feedbackStatsMap = feedbackWeight > 0
|
|
405
|
+
? await this.getMemoryFeedbackStatsMap(candidates.map((c) => c.record.id), params.scopes)
|
|
406
|
+
: new Map();
|
|
407
|
+
const scored = candidates
|
|
408
|
+
.map((item) => {
|
|
409
|
+
let rrfScore = 0;
|
|
410
|
+
if (vectorRanks) {
|
|
411
|
+
const rank = vectorRanks.get(item.record.id);
|
|
412
|
+
if (rank !== undefined)
|
|
413
|
+
rrfScore += vectorWeight / (rrfK + rank);
|
|
414
|
+
}
|
|
415
|
+
if (bm25Ranks) {
|
|
416
|
+
const rank = bm25Ranks.get(item.record.id);
|
|
417
|
+
if (rank !== undefined)
|
|
418
|
+
rrfScore += bm25Weight / (rrfK + rank);
|
|
419
|
+
}
|
|
420
|
+
rrfScore *= rrfK + 1;
|
|
421
|
+
const recencyFactor = recencyBoostEnabled
|
|
422
|
+
? computeRecencyMultiplier(item.record.timestamp, recencyHalfLifeHours)
|
|
423
|
+
: 1;
|
|
424
|
+
const importanceFactor = 1 + importanceWeight * clampImportance(item.record.importance);
|
|
425
|
+
const scopeFactor = item.isGlobal ? globalDiscountFactor : 1.0;
|
|
426
|
+
const feedbackStats = feedbackStatsMap.get(item.record.id);
|
|
427
|
+
const feedbackFactor = feedbackWeight > 0 && feedbackStats
|
|
428
|
+
? 1 + feedbackWeight * (feedbackStats.feedbackFactor - 1)
|
|
429
|
+
: 1;
|
|
430
|
+
const score = rrfScore * recencyFactor * importanceFactor * scopeFactor * feedbackFactor;
|
|
431
|
+
return {
|
|
432
|
+
record: item.record,
|
|
433
|
+
score,
|
|
434
|
+
vectorScore: item.vectorScore,
|
|
435
|
+
bm25Score: item.bm25Score,
|
|
436
|
+
};
|
|
437
|
+
})
|
|
438
|
+
.filter((item) => item.score >= params.minScore)
|
|
439
|
+
.sort((a, b) => b.score - a.score)
|
|
440
|
+
.slice(0, params.limit);
|
|
441
|
+
return scored;
|
|
442
|
+
}
|
|
443
|
+
async deleteById(id, scopes) {
|
|
444
|
+
const rows = await this.readByScopes(scopes);
|
|
445
|
+
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
446
|
+
if (!match)
|
|
447
|
+
return false;
|
|
448
|
+
await this.requireTable().delete(`id = '${escapeSql(match.id)}'`);
|
|
449
|
+
this.invalidateScope(match.scope);
|
|
450
|
+
this.notifyGraphRemoved(match.id);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
async softDeleteMemory(id, scopes) {
|
|
454
|
+
const rows = await this.readByScopes(scopes);
|
|
455
|
+
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
456
|
+
if (!match)
|
|
457
|
+
return false;
|
|
458
|
+
// ATOMIC_UPDATE (1.1.7): was delete+add (2 non-atomic commits); a single
|
|
459
|
+
// table.update is one commit and can't leave stale+new copies.
|
|
460
|
+
await this.requireTable().update({
|
|
461
|
+
where: `id = '${escapeSql(match.id)}'`,
|
|
462
|
+
values: { status: "disabled" },
|
|
463
|
+
});
|
|
464
|
+
this.invalidateScope(match.scope);
|
|
465
|
+
this.notifyGraphRemoved(match.id);
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
async updateMemoryScope(id, newScope, scopes) {
|
|
469
|
+
const rows = await this.readByScopes(scopes);
|
|
470
|
+
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
471
|
+
if (!match)
|
|
472
|
+
return false;
|
|
473
|
+
await this.requireTable().update({
|
|
474
|
+
where: `id = '${escapeSql(match.id)}'`,
|
|
475
|
+
values: { scope: newScope },
|
|
476
|
+
});
|
|
477
|
+
this.invalidateScope(match.scope);
|
|
478
|
+
this.invalidateScope(newScope);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
async readGlobalMemories(limit = 100) {
|
|
482
|
+
const rows = await this.readByScopes(["global"]);
|
|
483
|
+
return rows.sort((a, b) => b.timestamp - a.timestamp).slice(0, limit);
|
|
484
|
+
}
|
|
485
|
+
async getUnusedGlobalMemories(unusedDaysThreshold, limit = 100) {
|
|
486
|
+
const cutoffTime = Date.now() - unusedDaysThreshold * 24 * 60 * 60 * 1000;
|
|
487
|
+
const rows = await this.readByScopes(["global"]);
|
|
488
|
+
return rows.filter((row) => row.lastRecalled > 0 && row.lastRecalled < cutoffTime).slice(0, limit);
|
|
489
|
+
}
|
|
490
|
+
async clearScope(scope) {
|
|
491
|
+
const rows = await this.readByScopes([scope]);
|
|
492
|
+
if (rows.length === 0)
|
|
493
|
+
return 0;
|
|
494
|
+
await this.requireTable().delete(`scope = '${escapeSql(scope)}'`);
|
|
495
|
+
this.invalidateScope(scope);
|
|
496
|
+
for (const row of rows) {
|
|
497
|
+
this.notifyGraphRemoved(row.id);
|
|
498
|
+
}
|
|
499
|
+
return rows.length;
|
|
500
|
+
}
|
|
501
|
+
async list(scope, limit) {
|
|
502
|
+
const rows = await this.readByScopes([scope]);
|
|
503
|
+
return rows.sort((a, b) => b.timestamp - a.timestamp).slice(0, limit);
|
|
504
|
+
}
|
|
505
|
+
async listSince(scope, sinceTimestamp, limit = 100) {
|
|
506
|
+
const rows = await this.readByScopesIncludingMerged([scope]);
|
|
507
|
+
return rows
|
|
508
|
+
.filter((row) => row.timestamp >= sinceTimestamp)
|
|
509
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
510
|
+
.slice(0, limit);
|
|
511
|
+
}
|
|
512
|
+
async pruneScope(scope, maxEntries) {
|
|
513
|
+
const rows = await this.list(scope, 100000);
|
|
514
|
+
if (rows.length <= maxEntries)
|
|
515
|
+
return 0;
|
|
516
|
+
const flagged = rows.filter((r) => {
|
|
517
|
+
const meta = parseMetadata(r.metadataJson);
|
|
518
|
+
return meta.isPotentialDuplicate === true;
|
|
519
|
+
});
|
|
520
|
+
const unflagged = rows.filter((r) => {
|
|
521
|
+
const meta = parseMetadata(r.metadataJson);
|
|
522
|
+
return meta.isPotentialDuplicate !== true;
|
|
523
|
+
});
|
|
524
|
+
const sortedFlagged = flagged.sort((a, b) => a.timestamp - b.timestamp);
|
|
525
|
+
const sortedUnflagged = unflagged.sort((a, b) => a.timestamp - b.timestamp);
|
|
526
|
+
const toDeleteCount = rows.length - maxEntries;
|
|
527
|
+
const deleteFromFlagged = Math.min(sortedFlagged.length, toDeleteCount);
|
|
528
|
+
const toDelete = [
|
|
529
|
+
...sortedFlagged.slice(0, deleteFromFlagged),
|
|
530
|
+
...sortedUnflagged.slice(0, toDeleteCount - deleteFromFlagged),
|
|
531
|
+
];
|
|
532
|
+
for (const row of toDelete) {
|
|
533
|
+
await this.requireTable().delete(`id = '${escapeSql(row.id)}'`);
|
|
534
|
+
this.notifyGraphRemoved(row.id);
|
|
535
|
+
}
|
|
536
|
+
this.invalidateScope(scope);
|
|
537
|
+
await this.maybeOptimizeAll(false);
|
|
538
|
+
return toDelete.length;
|
|
539
|
+
}
|
|
540
|
+
async consolidateDuplicates(scope, threshold, candidateLimit = 50) {
|
|
541
|
+
const rows = await this.readByScopesIncludingMerged([scope]);
|
|
542
|
+
if (rows.length === 0) {
|
|
543
|
+
return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
|
|
544
|
+
}
|
|
545
|
+
const BATCH_SIZE = 100;
|
|
546
|
+
const FALLBACK_THRESHOLD = 500;
|
|
547
|
+
const QUERY_BATCH = ANN_QUERY_BATCH;
|
|
548
|
+
let mergedPairs = 0;
|
|
549
|
+
let updatedRecords = 0;
|
|
550
|
+
let skippedRecords = 0;
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
const FIVE_MINUTES_MS = 5 * 60 * 1000;
|
|
553
|
+
const startTime = Date.now();
|
|
554
|
+
const rowsWithNorms = rows.map((row) => ({
|
|
555
|
+
row,
|
|
556
|
+
norm: this.scopeCache.get(scope)?.norms.get(row.id) ?? vecNorm(row.vector),
|
|
557
|
+
}));
|
|
558
|
+
log("debug", `[consolidate] scope=${scope} rows=${rows.length} threshold=${threshold} candidateLimit=${candidateLimit} batchSize=${BATCH_SIZE} fallbackThreshold=${FALLBACK_THRESHOLD}`);
|
|
559
|
+
const processWithANN = async () => {
|
|
560
|
+
let localMerged = 0;
|
|
561
|
+
let localUpdated = 0;
|
|
562
|
+
let localSkipped = 0;
|
|
563
|
+
const mergedIds = new Set();
|
|
564
|
+
const totalChunks = Math.ceil(rowsWithNorms.length / BATCH_SIZE);
|
|
565
|
+
for (let chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) {
|
|
566
|
+
const chunkStart = chunkIdx * BATCH_SIZE;
|
|
567
|
+
const chunkEnd = Math.min(chunkStart + BATCH_SIZE, rowsWithNorms.length);
|
|
568
|
+
const chunk = rowsWithNorms.slice(chunkStart, chunkEnd);
|
|
569
|
+
for (let i = 0; i < chunk.length; i += QUERY_BATCH) {
|
|
570
|
+
const sub = chunk.slice(i, i + QUERY_BATCH).filter((a) => !mergedIds.has(a.row.id));
|
|
571
|
+
if (sub.length === 0)
|
|
572
|
+
continue;
|
|
573
|
+
let candidatesByQuery;
|
|
574
|
+
try {
|
|
575
|
+
// BATCHED_ANN (1.1.8): was one vectorSearch per row
|
|
576
|
+
// (O(n) indexed queries); now up to QUERY_BATCH per call,
|
|
577
|
+
// results tagged with query_index by LanceDB.
|
|
578
|
+
candidatesByQuery = await this.findSimilarVectorsBatch(sub.map((a) => a.row.vector), scope, candidateLimit + 1);
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
log("warn", `[consolidate] batched ANN search failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
for (let k = 0; k < sub.length; k++) {
|
|
585
|
+
const a = sub[k];
|
|
586
|
+
if (mergedIds.has(a.row.id))
|
|
587
|
+
continue;
|
|
588
|
+
const candidates = candidatesByQuery[k] ?? [];
|
|
589
|
+
for (const candidate of candidates) {
|
|
590
|
+
if (candidate.id === a.row.id)
|
|
591
|
+
continue;
|
|
592
|
+
if (mergedIds.has(candidate.id))
|
|
593
|
+
continue;
|
|
594
|
+
const b = rowsWithNorms.find((r) => r.row.id === candidate.id);
|
|
595
|
+
if (!b)
|
|
596
|
+
continue;
|
|
597
|
+
if (mergedIds.has(b.row.id))
|
|
598
|
+
continue;
|
|
599
|
+
const sim = storeFastCosine(a.row.vector, b.row.vector, a.norm, b.norm);
|
|
600
|
+
if (sim < threshold)
|
|
601
|
+
continue;
|
|
602
|
+
const aMeta = parseMetadata(a.row.metadataJson);
|
|
603
|
+
if (aMeta.status === "merged") {
|
|
604
|
+
localSkipped += 1;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (a.row.lastRecalled > 0 && now - a.row.lastRecalled < FIVE_MINUTES_MS) {
|
|
608
|
+
localSkipped += 1;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const bMeta = parseMetadata(b.row.metadataJson);
|
|
612
|
+
if (bMeta.status === "merged" || bMeta.mergedFrom) {
|
|
613
|
+
localSkipped += 1;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (b.row.lastRecalled > 0 && now - b.row.lastRecalled < FIVE_MINUTES_MS) {
|
|
617
|
+
localSkipped += 1;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const older = a.row.timestamp <= b.row.timestamp ? a.row : b.row;
|
|
621
|
+
const newer = a.row.timestamp <= b.row.timestamp ? b.row : a.row;
|
|
622
|
+
if (older.id === newer.id) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const newerMeta = parseMetadata(newer.metadataJson);
|
|
626
|
+
const mergedIntoId = newer.id;
|
|
627
|
+
const updatedOlderMeta = { status: "merged", mergedInto: mergedIntoId };
|
|
628
|
+
// ATOMIC_UPDATE (1.1.7): was 2× delete+add (4 commits);
|
|
629
|
+
// now one update per row (2 commits total).
|
|
630
|
+
await this.requireTable().update({
|
|
631
|
+
where: `id = '${escapeSql(older.id)}'`,
|
|
632
|
+
values: {
|
|
633
|
+
status: "merged",
|
|
634
|
+
metadataJson: JSON.stringify({ ...parseMetadata(older.metadataJson), ...updatedOlderMeta }),
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
const updatedNewerMeta = { ...newerMeta, mergedFrom: older.id };
|
|
638
|
+
await this.requireTable().update({
|
|
639
|
+
where: `id = '${escapeSql(newer.id)}'`,
|
|
640
|
+
values: { metadataJson: JSON.stringify(updatedNewerMeta) },
|
|
641
|
+
});
|
|
642
|
+
this.notifyGraphMerged(older.id, newer.id);
|
|
643
|
+
mergedIds.add(older.id);
|
|
644
|
+
mergedIds.add(newer.id);
|
|
645
|
+
localMerged += 1;
|
|
646
|
+
localUpdated += 2;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const chunkStartTime = Date.now();
|
|
651
|
+
log("info", "consolidate:chunk", {
|
|
652
|
+
scope,
|
|
653
|
+
chunk: chunkIdx + 1,
|
|
654
|
+
total: totalChunks,
|
|
655
|
+
processed: chunkEnd,
|
|
656
|
+
merged: localMerged,
|
|
657
|
+
candidates: candidateLimit,
|
|
658
|
+
elapsedMs: chunkStartTime - startTime,
|
|
659
|
+
});
|
|
660
|
+
if (chunkIdx < totalChunks - 1) {
|
|
661
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
662
|
+
const lag = Date.now() - chunkStartTime;
|
|
663
|
+
if (lag > 100) {
|
|
664
|
+
log("warn", `[consolidate] event loop delay detected: ${lag}ms at chunk ${chunkIdx + 1}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return { merged: localMerged, updated: localUpdated, skipped: localSkipped };
|
|
669
|
+
};
|
|
670
|
+
const processWithFallback = async () => {
|
|
671
|
+
let localMerged = 0;
|
|
672
|
+
let localUpdated = 0;
|
|
673
|
+
let localSkipped = 0;
|
|
674
|
+
const mergedIds = new Set();
|
|
675
|
+
for (let i = 0; i < rowsWithNorms.length; i += 1) {
|
|
676
|
+
const a = rowsWithNorms[i];
|
|
677
|
+
if (mergedIds.has(a.row.id))
|
|
678
|
+
continue;
|
|
679
|
+
for (let j = i + 1; j < rowsWithNorms.length; j += 1) {
|
|
680
|
+
const b = rowsWithNorms[j];
|
|
681
|
+
if (mergedIds.has(b.row.id))
|
|
682
|
+
continue;
|
|
683
|
+
const sim = storeFastCosine(a.row.vector, b.row.vector, a.norm, b.norm);
|
|
684
|
+
if (sim < threshold)
|
|
685
|
+
continue;
|
|
686
|
+
const aMeta = parseMetadata(a.row.metadataJson);
|
|
687
|
+
if (aMeta.status === "merged" || aMeta.mergedFrom) {
|
|
688
|
+
localSkipped += 1;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (a.row.lastRecalled > 0 && now - a.row.lastRecalled < FIVE_MINUTES_MS) {
|
|
692
|
+
localSkipped += 1;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const bMeta = parseMetadata(b.row.metadataJson);
|
|
696
|
+
if (bMeta.status === "merged" || bMeta.mergedFrom) {
|
|
697
|
+
localSkipped += 1;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (b.row.lastRecalled > 0 && now - b.row.lastRecalled < FIVE_MINUTES_MS) {
|
|
701
|
+
localSkipped += 1;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const older = a.row.timestamp <= b.row.timestamp ? a.row : b.row;
|
|
705
|
+
const newer = a.row.timestamp <= b.row.timestamp ? b.row : a.row;
|
|
706
|
+
if (older.id === newer.id) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const newerMeta = parseMetadata(newer.metadataJson);
|
|
710
|
+
const mergedIntoId = newer.id;
|
|
711
|
+
const updatedOlderMeta = { status: "merged", mergedInto: mergedIntoId };
|
|
712
|
+
await this.requireTable().update({
|
|
713
|
+
where: `id = '${escapeSql(older.id)}'`,
|
|
714
|
+
values: {
|
|
715
|
+
status: "merged",
|
|
716
|
+
metadataJson: JSON.stringify({ ...parseMetadata(older.metadataJson), ...updatedOlderMeta }),
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
const updatedNewerMeta = { ...newerMeta, mergedFrom: older.id };
|
|
720
|
+
await this.requireTable().update({
|
|
721
|
+
where: `id = '${escapeSql(newer.id)}'`,
|
|
722
|
+
values: { metadataJson: JSON.stringify(updatedNewerMeta) },
|
|
723
|
+
});
|
|
724
|
+
this.notifyGraphMerged(older.id, newer.id);
|
|
725
|
+
mergedIds.add(older.id);
|
|
726
|
+
mergedIds.add(newer.id);
|
|
727
|
+
localMerged += 1;
|
|
728
|
+
localUpdated += 2;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { merged: localMerged, updated: localUpdated, skipped: localSkipped };
|
|
732
|
+
};
|
|
733
|
+
try {
|
|
734
|
+
const annResult = await processWithANN();
|
|
735
|
+
mergedPairs = annResult.merged;
|
|
736
|
+
updatedRecords = annResult.updated;
|
|
737
|
+
skippedRecords = annResult.skipped;
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
log("error", `[consolidate] ANN-based consolidation failed:`, error);
|
|
741
|
+
if (rows.length < FALLBACK_THRESHOLD) {
|
|
742
|
+
log("warn", `[consolidate] Falling back to O(N²) for small scope (${rows.length} memories)`);
|
|
743
|
+
const fbResult = await processWithFallback();
|
|
744
|
+
mergedPairs = fbResult.merged;
|
|
745
|
+
updatedRecords = fbResult.updated;
|
|
746
|
+
skippedRecords = fbResult.skipped;
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
log("warn", `[consolidate] Skipping fallback for large scope (${rows.length} >= ${FALLBACK_THRESHOLD})`);
|
|
750
|
+
return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (mergedPairs > 0) {
|
|
754
|
+
this.invalidateScope(scope);
|
|
755
|
+
}
|
|
756
|
+
await this.maybeOptimizeAll(false);
|
|
757
|
+
return { mergedPairs, updatedRecords, skippedRecords };
|
|
758
|
+
}
|
|
759
|
+
// ANN_CONSOLIDATION (1.1.7): previously this did
|
|
760
|
+
// query().where(scope).limit(limit).toArray() — which returns the FIRST N
|
|
761
|
+
// rows in scan order and only THEN ranked them. With a vector index available
|
|
762
|
+
// it now runs a real vectorSearch (IVF), so consolidation actually compares
|
|
763
|
+
// against the most-similar neighbors (probes boosted to improve recall on
|
|
764
|
+
// filtered queries). Without an index (small stores) it falls back to a
|
|
765
|
+
// CORRECT brute-force scan of the whole scope instead of a truncated one.
|
|
766
|
+
async findSimilarVectors(queryVector, scope, limit) {
|
|
767
|
+
try {
|
|
768
|
+
const table = this.requireTable();
|
|
769
|
+
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
770
|
+
if (this.indexState.vector) {
|
|
771
|
+
const results = await table.vectorSearch(queryVector)
|
|
772
|
+
.where(`scope = '${escapeSql(scope)}'`)
|
|
773
|
+
.nprobes(NPROBES)
|
|
774
|
+
.limit(Math.max(safeLimit, 100))
|
|
775
|
+
.toArray();
|
|
776
|
+
const scored = results.map((r) => {
|
|
777
|
+
const distance = Number(r._distance);
|
|
778
|
+
const relevance = Number(r._relevance_score);
|
|
779
|
+
const sim = typeof relevance === "number" && Number.isFinite(relevance)
|
|
780
|
+
? relevance
|
|
781
|
+
: Number.isFinite(distance) ? 1 - distance : 0;
|
|
782
|
+
// Arrow Vector → plain number[] (same normalization as normalizeRow)
|
|
783
|
+
const vec = Array.from(r.vector ?? []).map((item) => Number(item));
|
|
784
|
+
return { id: r.id, vector: vec, score: sim };
|
|
785
|
+
});
|
|
786
|
+
scored.sort((a, b) => b.score - a.score);
|
|
787
|
+
return scored.slice(0, safeLimit);
|
|
788
|
+
}
|
|
789
|
+
const results = await table.query()
|
|
790
|
+
.where(`scope = '${escapeSql(scope)}'`)
|
|
791
|
+
.select(["id", "vector"])
|
|
792
|
+
.toArray();
|
|
793
|
+
const queryNorm = vecNorm(queryVector);
|
|
794
|
+
const scored = results.map((r) => {
|
|
795
|
+
const vec = Array.from(r.vector ?? []).map((item) => Number(item));
|
|
796
|
+
return {
|
|
797
|
+
id: r.id,
|
|
798
|
+
vector: vec,
|
|
799
|
+
score: storeFastCosine(queryVector, vec, queryNorm, vecNorm(vec)),
|
|
800
|
+
};
|
|
801
|
+
});
|
|
802
|
+
scored.sort((a, b) => b.score - a.score);
|
|
803
|
+
return scored.slice(0, safeLimit);
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
log("debug", `[store] findSimilarVectors failed for scope=${scope} limit=${limit}: ${error instanceof Error ? error.message : String(error)}`);
|
|
807
|
+
return [];
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// BATCHED_ANN (1.1.8): one vectorSearch call per QUERY_BATCH vectors
|
|
811
|
+
// (LanceDB tags results with query_index); no-index path reads the scope
|
|
812
|
+
// once and ranks top-k for every query vector instead of N rescans.
|
|
813
|
+
async findSimilarVectorsBatch(queryVectors, scope, limit) {
|
|
814
|
+
const table = this.requireTable();
|
|
815
|
+
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
816
|
+
if (queryVectors.length === 0)
|
|
817
|
+
return [];
|
|
818
|
+
if (this.indexState.vector) {
|
|
819
|
+
const results = await table.vectorSearch(queryVectors)
|
|
820
|
+
.where(`scope = '${escapeSql(scope)}'`)
|
|
821
|
+
.nprobes(NPROBES)
|
|
822
|
+
.limit(Math.max(safeLimit, 100))
|
|
823
|
+
.toArray();
|
|
824
|
+
const byQuery = new Map();
|
|
825
|
+
for (const r of results) {
|
|
826
|
+
const qi = Number(r.query_index ?? 0);
|
|
827
|
+
const distance = Number(r._distance);
|
|
828
|
+
const relevance = Number(r._relevance_score);
|
|
829
|
+
const sim = typeof relevance === "number" && Number.isFinite(relevance)
|
|
830
|
+
? relevance
|
|
831
|
+
: Number.isFinite(distance) ? 1 - distance : 0;
|
|
832
|
+
const vec = Array.from(r.vector ?? []).map((item) => Number(item));
|
|
833
|
+
if (!byQuery.has(qi))
|
|
834
|
+
byQuery.set(qi, []);
|
|
835
|
+
byQuery.get(qi).push({ id: r.id, vector: vec, score: sim });
|
|
836
|
+
}
|
|
837
|
+
const out = [];
|
|
838
|
+
for (let i = 0; i < queryVectors.length; i++) {
|
|
839
|
+
const scored = (byQuery.get(i) ?? []).slice();
|
|
840
|
+
scored.sort((a, b) => b.score - a.score);
|
|
841
|
+
out.push(scored.slice(0, safeLimit));
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
const results = await table.query()
|
|
846
|
+
.where(`scope = '${escapeSql(scope)}'`)
|
|
847
|
+
.select(["id", "vector"])
|
|
848
|
+
.toArray();
|
|
849
|
+
const allRows = results.map((r) => ({
|
|
850
|
+
id: r.id,
|
|
851
|
+
vector: Array.from(r.vector ?? []).map((item) => Number(item)),
|
|
852
|
+
}));
|
|
853
|
+
const out = [];
|
|
854
|
+
for (const qv of queryVectors) {
|
|
855
|
+
const queryNorm = vecNorm(qv);
|
|
856
|
+
const scored = allRows.map((r) => ({
|
|
857
|
+
id: r.id,
|
|
858
|
+
vector: r.vector,
|
|
859
|
+
score: storeFastCosine(qv, r.vector, queryNorm, vecNorm(r.vector)),
|
|
860
|
+
}));
|
|
861
|
+
scored.sort((a, b) => b.score - a.score);
|
|
862
|
+
out.push(scored.slice(0, safeLimit));
|
|
863
|
+
}
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
async countIncompatibleVectors(scopes, expectedDim) {
|
|
867
|
+
const rows = await this.readByScopes(scopes);
|
|
868
|
+
return rows.filter((row) => row.vectorDim !== expectedDim).length;
|
|
869
|
+
}
|
|
870
|
+
// ID_MATCH_TIGHTEN (1.1.7): prefix matching is only allowed for
|
|
871
|
+
// sufficiently long queries (>= 8 chars of a UUID) to keep ambiguity
|
|
872
|
+
// astronomically unlikely; a full 36-char UUID is exact. Sub-8-char
|
|
873
|
+
// queries never match anything (previously a 2-char prefix could resolve
|
|
874
|
+
// the wrong row via find()).
|
|
875
|
+
matchesId(candidateId, query) {
|
|
876
|
+
if (candidateId === query)
|
|
877
|
+
return true;
|
|
878
|
+
if (typeof query !== "string" || query.length < 8)
|
|
879
|
+
return false;
|
|
880
|
+
return candidateId.startsWith(query);
|
|
881
|
+
}
|
|
882
|
+
async hasMemory(id, scopes) {
|
|
883
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
884
|
+
const rows = await this.readByScopes(scopes);
|
|
885
|
+
if (rows.some((row) => this.matchesId(row.id, id))) {
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
if (attempt < 2) {
|
|
889
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
async updateMemoryUsage(id, projectScope, scopes) {
|
|
895
|
+
const rows = await this.readByScopes(scopes);
|
|
896
|
+
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
897
|
+
if (!match)
|
|
898
|
+
return;
|
|
899
|
+
const now = Date.now();
|
|
900
|
+
const newRecallCount = match.recallCount + 1;
|
|
901
|
+
let newProjectCount = match.projectCount;
|
|
902
|
+
let metadataJson = match.metadataJson;
|
|
903
|
+
if (match.scope === "global" && projectScope) {
|
|
904
|
+
const projects = extractRecalledProjects(metadataJson);
|
|
905
|
+
if (!projects.has(projectScope)) {
|
|
906
|
+
projects.add(projectScope);
|
|
907
|
+
if (projects.size > 100) {
|
|
908
|
+
const arr = Array.from(projects);
|
|
909
|
+
arr.splice(0, arr.length - 100);
|
|
910
|
+
metadataJson = JSON.stringify({ recalledProjects: arr });
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
metadataJson = JSON.stringify({ recalledProjects: Array.from(projects) });
|
|
914
|
+
}
|
|
915
|
+
newProjectCount = projects.size;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// ATOMIC_UPDATE_MEMORY_USAGE: previously this used table.delete() followed
|
|
919
|
+
// by table.add() to simulate an update. Those are two separate non-atomic
|
|
920
|
+
// ops against LanceDB's versioned/fragment storage, with no compaction
|
|
921
|
+
// (table.optimize()) ever called afterward. Because updateMemoryUsage runs
|
|
922
|
+
// on every recall (i.e. every chat turn), concurrent/rapid calls could
|
|
923
|
+
// leave stale+new physical rows for the same id both scannable at once —
|
|
924
|
+
// confirmed in practice: 4 memory ids each had 2 physical row copies after
|
|
925
|
+
// normal recall traffic, none of which memory_consolidate could clean up
|
|
926
|
+
// (that only merges near-duplicate CONTENT across different ids, not
|
|
927
|
+
// literal same-id row duplication). table.update() is a single atomic op
|
|
928
|
+
// (predicate + column values), so use that instead of delete+add.
|
|
929
|
+
await this.requireTable().update({
|
|
930
|
+
where: `id = '${escapeSql(match.id)}'`,
|
|
931
|
+
values: {
|
|
932
|
+
lastRecalled: now,
|
|
933
|
+
recallCount: newRecallCount,
|
|
934
|
+
projectCount: newProjectCount ?? null,
|
|
935
|
+
metadataJson: metadataJson ?? null,
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
this.invalidateScope(match.scope);
|
|
939
|
+
}
|
|
940
|
+
async getCitation(id, scopes) {
|
|
941
|
+
const rows = await this.readByScopes(scopes);
|
|
942
|
+
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
943
|
+
if (!match)
|
|
944
|
+
return null;
|
|
945
|
+
if (!match.citationSource)
|
|
946
|
+
return null;
|
|
947
|
+
return {
|
|
948
|
+
source: match.citationSource,
|
|
949
|
+
timestamp: match.citationTimestamp ?? match.timestamp,
|
|
950
|
+
status: match.citationStatus ?? "pending",
|
|
951
|
+
chain: match.citationChain ?? [],
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
async updateCitation(id, scopes, updates) {
|
|
955
|
+
const rows = await this.readByScopes(scopes);
|
|
956
|
+
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
957
|
+
if (!match)
|
|
958
|
+
return false;
|
|
959
|
+
const existingChain = match.citationChain ?? [];
|
|
960
|
+
const currentMeta = parseMetadata(match.metadataJson);
|
|
961
|
+
const newMeta = {
|
|
962
|
+
...currentMeta,
|
|
963
|
+
citationStatus: updates.status,
|
|
964
|
+
citationVerifiedAt: updates.status === "verified" ? Date.now() : currentMeta.citationVerifiedAt,
|
|
965
|
+
};
|
|
966
|
+
// CITATION_CHAIN_SERIALIZE (1.1.7): citationChain is a STRING column
|
|
967
|
+
// (normalizeRow JSON.parses it on read); passing a raw array stored
|
|
968
|
+
// "src" via Array.prototype.toString, so chains never survived a
|
|
969
|
+
// round trip. Stringify explicitly.
|
|
970
|
+
const nextChain = updates.chain ? [...existingChain, ...updates.chain] : existingChain;
|
|
971
|
+
await this.requireTable().update({
|
|
972
|
+
where: `id = '${escapeSql(match.id)}'`,
|
|
973
|
+
values: {
|
|
974
|
+
citationStatus: updates.status ?? match.citationStatus,
|
|
975
|
+
citationChain: JSON.stringify(nextChain),
|
|
976
|
+
metadataJson: JSON.stringify(newMeta),
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
this.invalidateScope(match.scope);
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
async validateCitation(id, scopes) {
|
|
983
|
+
const citation = await this.getCitation(id, scopes);
|
|
984
|
+
if (!citation) {
|
|
985
|
+
return { valid: false, status: "invalid", reason: "No citation found" };
|
|
986
|
+
}
|
|
987
|
+
if (citation.status === "verified") {
|
|
988
|
+
return { valid: true, status: "verified" };
|
|
989
|
+
}
|
|
990
|
+
if (citation.status === "invalid") {
|
|
991
|
+
return { valid: false, status: "invalid", reason: "Citation was marked invalid" };
|
|
992
|
+
}
|
|
993
|
+
if (citation.status === "pending") {
|
|
994
|
+
const ageMs = Date.now() - citation.timestamp;
|
|
995
|
+
const autoExpireMs = 7 * 24 * 60 * 60 * 1000;
|
|
996
|
+
if (ageMs > autoExpireMs) {
|
|
997
|
+
await this.updateCitation(id, scopes, { status: "expired" });
|
|
998
|
+
return { valid: false, status: "expired", reason: "Citation expired (pending too long)" };
|
|
999
|
+
}
|
|
1000
|
+
return { valid: true, status: "pending" };
|
|
1001
|
+
}
|
|
1002
|
+
if (citation.status === "expired") {
|
|
1003
|
+
return { valid: false, status: "expired", reason: "Citation has expired" };
|
|
1004
|
+
}
|
|
1005
|
+
return { valid: false, status: citation.status, reason: "Unknown citation status" };
|
|
1006
|
+
}
|
|
1007
|
+
async explainMemory(id, scopes, currentScope, recencyHalfLifeHours = 72, globalDiscountFactor = 0.7) {
|
|
1008
|
+
const rows = await this.readByScopes(scopes);
|
|
1009
|
+
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
1010
|
+
if (!match)
|
|
1011
|
+
return null;
|
|
1012
|
+
const now = Date.now();
|
|
1013
|
+
const ageHours = (now - match.timestamp) / (1000 * 60 * 60);
|
|
1014
|
+
const halfLifeMs = recencyHalfLifeHours * 60 * 60 * 1000;
|
|
1015
|
+
const decayFactor = Math.exp(-ageHours / recencyHalfLifeHours);
|
|
1016
|
+
const isGlobal = match.scope === "global";
|
|
1017
|
+
const citation = match.citationSource
|
|
1018
|
+
? {
|
|
1019
|
+
source: match.citationSource,
|
|
1020
|
+
status: match.citationStatus,
|
|
1021
|
+
timestamp: match.citationTimestamp,
|
|
1022
|
+
}
|
|
1023
|
+
: undefined;
|
|
1024
|
+
const factors = {
|
|
1025
|
+
relevance: {
|
|
1026
|
+
overall: 0,
|
|
1027
|
+
vectorScore: 0,
|
|
1028
|
+
bm25Score: 0,
|
|
1029
|
+
},
|
|
1030
|
+
recency: {
|
|
1031
|
+
timestamp: match.timestamp,
|
|
1032
|
+
ageHours,
|
|
1033
|
+
withinHalfLife: ageHours <= recencyHalfLifeHours,
|
|
1034
|
+
decayFactor,
|
|
1035
|
+
},
|
|
1036
|
+
citation,
|
|
1037
|
+
importance: match.importance,
|
|
1038
|
+
scope: {
|
|
1039
|
+
memoryScope: match.scope,
|
|
1040
|
+
matchesCurrentScope: match.scope === currentScope,
|
|
1041
|
+
isGlobal,
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
return {
|
|
1045
|
+
memoryId: match.id,
|
|
1046
|
+
text: match.text,
|
|
1047
|
+
factors,
|
|
1048
|
+
generatedAt: now,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
async refreshExpiredCitations(scope, maxAgeDays = 7) {
|
|
1052
|
+
const rows = await this.readByScopes([scope]);
|
|
1053
|
+
let expiredCount = 0;
|
|
1054
|
+
const cutoffTime = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
1055
|
+
for (const row of rows) {
|
|
1056
|
+
if (row.citationStatus === "pending" && row.citationTimestamp && row.citationTimestamp < cutoffTime) {
|
|
1057
|
+
const updated = await this.updateCitation(row.id, [scope], { status: "expired" });
|
|
1058
|
+
if (updated)
|
|
1059
|
+
expiredCount++;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return expiredCount;
|
|
1063
|
+
}
|
|
1064
|
+
async listEvents(scopes, limit) {
|
|
1065
|
+
const rows = await this.readEventsByScopes(scopes);
|
|
1066
|
+
return rows.sort((a, b) => b.timestamp - a.timestamp).slice(0, limit);
|
|
1067
|
+
}
|
|
1068
|
+
async summarizeEvents(scope, includeGlobalScope) {
|
|
1069
|
+
const scopes = includeGlobalScope && scope !== "global" ? [scope, "global"] : [scope];
|
|
1070
|
+
const events = await this.readEventsByScopes(scopes);
|
|
1071
|
+
// Read all memories including merged for duplicate counts
|
|
1072
|
+
const memories = await this.readByScopesIncludingMerged(scopes);
|
|
1073
|
+
const captureSkipReasons = {};
|
|
1074
|
+
let captureConsidered = 0;
|
|
1075
|
+
let captureStored = 0;
|
|
1076
|
+
let captureSkipped = 0;
|
|
1077
|
+
let recallRequested = 0;
|
|
1078
|
+
let recallInjected = 0;
|
|
1079
|
+
let recallReturnedResults = 0;
|
|
1080
|
+
let autoRecallRequested = 0;
|
|
1081
|
+
let autoRecallInjected = 0;
|
|
1082
|
+
let autoRecallReturnedResults = 0;
|
|
1083
|
+
let manualRecallRequested = 0;
|
|
1084
|
+
let manualRecallReturnedResults = 0;
|
|
1085
|
+
let feedbackMissing = 0;
|
|
1086
|
+
let feedbackWrong = 0;
|
|
1087
|
+
let feedbackUsefulPositive = 0;
|
|
1088
|
+
let feedbackUsefulNegative = 0;
|
|
1089
|
+
for (const event of events) {
|
|
1090
|
+
if (event.type === "capture") {
|
|
1091
|
+
if (event.outcome === "considered")
|
|
1092
|
+
captureConsidered += 1;
|
|
1093
|
+
if (event.outcome === "stored")
|
|
1094
|
+
captureStored += 1;
|
|
1095
|
+
if (event.outcome === "skipped") {
|
|
1096
|
+
captureSkipped += 1;
|
|
1097
|
+
if (event.skipReason) {
|
|
1098
|
+
captureSkipReasons[event.skipReason] = (captureSkipReasons[event.skipReason] ?? 0) + 1;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (event.type === "recall") {
|
|
1103
|
+
recallRequested += 1;
|
|
1104
|
+
if (event.resultCount > 0)
|
|
1105
|
+
recallReturnedResults += 1;
|
|
1106
|
+
if (event.injected)
|
|
1107
|
+
recallInjected += 1;
|
|
1108
|
+
const recallSource = event.source ?? "system-transform";
|
|
1109
|
+
if (recallSource === "manual-search") {
|
|
1110
|
+
manualRecallRequested += 1;
|
|
1111
|
+
if (event.resultCount > 0)
|
|
1112
|
+
manualRecallReturnedResults += 1;
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
autoRecallRequested += 1;
|
|
1116
|
+
if (event.resultCount > 0)
|
|
1117
|
+
autoRecallReturnedResults += 1;
|
|
1118
|
+
if (event.injected)
|
|
1119
|
+
autoRecallInjected += 1;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (event.type === "feedback") {
|
|
1123
|
+
if (event.feedbackType === "missing")
|
|
1124
|
+
feedbackMissing += 1;
|
|
1125
|
+
if (event.feedbackType === "wrong")
|
|
1126
|
+
feedbackWrong += 1;
|
|
1127
|
+
if (event.feedbackType === "useful") {
|
|
1128
|
+
if (event.helpful)
|
|
1129
|
+
feedbackUsefulPositive += 1;
|
|
1130
|
+
else
|
|
1131
|
+
feedbackUsefulNegative += 1;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
const totalCaptureAttempts = captureStored + captureSkipped;
|
|
1136
|
+
const totalUsefulFeedback = feedbackUsefulPositive + feedbackUsefulNegative;
|
|
1137
|
+
// Count flagged (isPotentialDuplicate) and consolidated (status=merged) from memories table
|
|
1138
|
+
const flaggedCount = memories.filter((r) => {
|
|
1139
|
+
const meta = parseMetadata(r.metadataJson);
|
|
1140
|
+
return meta.isPotentialDuplicate === true;
|
|
1141
|
+
}).length;
|
|
1142
|
+
const consolidatedCount = memories.filter((r) => {
|
|
1143
|
+
const meta = parseMetadata(r.metadataJson);
|
|
1144
|
+
return meta.status === "merged";
|
|
1145
|
+
}).length;
|
|
1146
|
+
return {
|
|
1147
|
+
scope,
|
|
1148
|
+
totalEvents: events.length,
|
|
1149
|
+
capture: {
|
|
1150
|
+
considered: captureConsidered,
|
|
1151
|
+
stored: captureStored,
|
|
1152
|
+
skipped: captureSkipped,
|
|
1153
|
+
successRate: totalCaptureAttempts === 0 ? 0 : captureStored / totalCaptureAttempts,
|
|
1154
|
+
skipReasons: captureSkipReasons,
|
|
1155
|
+
},
|
|
1156
|
+
recall: {
|
|
1157
|
+
requested: recallRequested,
|
|
1158
|
+
injected: recallInjected,
|
|
1159
|
+
returnedResults: recallReturnedResults,
|
|
1160
|
+
hitRate: recallRequested === 0 ? 0 : recallReturnedResults / recallRequested,
|
|
1161
|
+
injectionRate: recallRequested === 0 ? 0 : recallInjected / recallRequested,
|
|
1162
|
+
auto: {
|
|
1163
|
+
requested: autoRecallRequested,
|
|
1164
|
+
injected: autoRecallInjected,
|
|
1165
|
+
returnedResults: autoRecallReturnedResults,
|
|
1166
|
+
hitRate: autoRecallRequested === 0 ? 0 : autoRecallReturnedResults / autoRecallRequested,
|
|
1167
|
+
injectionRate: autoRecallRequested === 0 ? 0 : autoRecallInjected / autoRecallRequested,
|
|
1168
|
+
},
|
|
1169
|
+
manual: {
|
|
1170
|
+
requested: manualRecallRequested,
|
|
1171
|
+
returnedResults: manualRecallReturnedResults,
|
|
1172
|
+
hitRate: manualRecallRequested === 0 ? 0 : manualRecallReturnedResults / manualRecallRequested,
|
|
1173
|
+
},
|
|
1174
|
+
manualRescueRatio: autoRecallRequested === 0 ? 0 : manualRecallRequested / autoRecallRequested,
|
|
1175
|
+
},
|
|
1176
|
+
feedback: {
|
|
1177
|
+
missing: feedbackMissing,
|
|
1178
|
+
wrong: feedbackWrong,
|
|
1179
|
+
useful: {
|
|
1180
|
+
positive: feedbackUsefulPositive,
|
|
1181
|
+
negative: feedbackUsefulNegative,
|
|
1182
|
+
helpfulRate: totalUsefulFeedback === 0 ? 0 : feedbackUsefulPositive / totalUsefulFeedback,
|
|
1183
|
+
},
|
|
1184
|
+
falsePositiveRate: captureStored === 0 ? 0 : feedbackWrong / captureStored,
|
|
1185
|
+
falseNegativeRate: totalCaptureAttempts === 0 ? 0 : feedbackMissing / totalCaptureAttempts,
|
|
1186
|
+
},
|
|
1187
|
+
duplicates: {
|
|
1188
|
+
flaggedCount,
|
|
1189
|
+
consolidatedCount,
|
|
1190
|
+
},
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
async getWeeklyEffectivenessSummary(scope, includeGlobalScope, days = 7) {
|
|
1194
|
+
const scopes = includeGlobalScope && scope !== "global" ? [scope, "global"] : [scope];
|
|
1195
|
+
const allEvents = await this.readEventsByScopes(scopes);
|
|
1196
|
+
const allMemories = await this.readByScopesIncludingMerged(scopes);
|
|
1197
|
+
const now = Date.now();
|
|
1198
|
+
const periodMs = days * 24 * 60 * 60 * 1000;
|
|
1199
|
+
const currentPeriodStart = now - periodMs;
|
|
1200
|
+
const previousPeriodStart = currentPeriodStart - periodMs;
|
|
1201
|
+
const currentEvents = allEvents.filter((e) => e.timestamp >= currentPeriodStart);
|
|
1202
|
+
const previousEvents = allEvents.filter((e) => e.timestamp >= previousPeriodStart && e.timestamp < currentPeriodStart);
|
|
1203
|
+
const current = this.aggregateEvents(scope, currentEvents, allMemories);
|
|
1204
|
+
const previous = previousEvents.length > 0 ? this.aggregateEvents(scope, previousEvents, []) : null;
|
|
1205
|
+
const trends = {
|
|
1206
|
+
captureSuccessRate: this.calculateTrend(current.capture.successRate, previous?.capture.successRate, currentEvents.length, previousEvents.length),
|
|
1207
|
+
recallHitRate: this.calculateTrend(current.recall.hitRate, previous?.recall.hitRate, currentEvents.length, previousEvents.length),
|
|
1208
|
+
feedbackHelpfulRate: this.calculateTrend(current.feedback.useful.helpfulRate, previous?.feedback.useful.helpfulRate, currentEvents.length, previousEvents.length),
|
|
1209
|
+
};
|
|
1210
|
+
const insights = this.generateInsights(current);
|
|
1211
|
+
const recentMemories = allMemories.filter((m) => m.timestamp >= currentPeriodStart);
|
|
1212
|
+
const byCategory = {};
|
|
1213
|
+
for (const mem of recentMemories) {
|
|
1214
|
+
const cat = mem.category ?? "other";
|
|
1215
|
+
if (!byCategory[cat]) {
|
|
1216
|
+
byCategory[cat] = { count: 0, samples: [] };
|
|
1217
|
+
}
|
|
1218
|
+
byCategory[cat].count += 1;
|
|
1219
|
+
if (byCategory[cat].samples.length < 3) {
|
|
1220
|
+
byCategory[cat].samples.push(mem.text.slice(0, 60));
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return {
|
|
1224
|
+
scope,
|
|
1225
|
+
periodDays: days,
|
|
1226
|
+
currentPeriodStart,
|
|
1227
|
+
currentPeriodEnd: now,
|
|
1228
|
+
previousPeriodStart,
|
|
1229
|
+
previousPeriodEnd: currentPeriodStart,
|
|
1230
|
+
current,
|
|
1231
|
+
previous,
|
|
1232
|
+
trends,
|
|
1233
|
+
insights,
|
|
1234
|
+
recentMemories: {
|
|
1235
|
+
total: recentMemories.length,
|
|
1236
|
+
byCategory,
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
aggregateEvents(scope, events, memories) {
|
|
1241
|
+
const captureSkipReasons = {};
|
|
1242
|
+
let captureConsidered = 0;
|
|
1243
|
+
let captureStored = 0;
|
|
1244
|
+
let captureSkipped = 0;
|
|
1245
|
+
let recallRequested = 0;
|
|
1246
|
+
let recallInjected = 0;
|
|
1247
|
+
let recallReturnedResults = 0;
|
|
1248
|
+
let autoRecallRequested = 0;
|
|
1249
|
+
let autoRecallInjected = 0;
|
|
1250
|
+
let autoRecallReturnedResults = 0;
|
|
1251
|
+
let manualRecallRequested = 0;
|
|
1252
|
+
let manualRecallReturnedResults = 0;
|
|
1253
|
+
let feedbackMissing = 0;
|
|
1254
|
+
let feedbackWrong = 0;
|
|
1255
|
+
let feedbackUsefulPositive = 0;
|
|
1256
|
+
let feedbackUsefulNegative = 0;
|
|
1257
|
+
for (const event of events) {
|
|
1258
|
+
if (event.type === "capture") {
|
|
1259
|
+
if (event.outcome === "considered")
|
|
1260
|
+
captureConsidered += 1;
|
|
1261
|
+
if (event.outcome === "stored")
|
|
1262
|
+
captureStored += 1;
|
|
1263
|
+
if (event.outcome === "skipped") {
|
|
1264
|
+
captureSkipped += 1;
|
|
1265
|
+
if (event.skipReason) {
|
|
1266
|
+
captureSkipReasons[event.skipReason] = (captureSkipReasons[event.skipReason] ?? 0) + 1;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (event.type === "recall") {
|
|
1271
|
+
recallRequested += 1;
|
|
1272
|
+
if (event.resultCount > 0)
|
|
1273
|
+
recallReturnedResults += 1;
|
|
1274
|
+
if (event.injected)
|
|
1275
|
+
recallInjected += 1;
|
|
1276
|
+
const recallSource = event.source ?? "system-transform";
|
|
1277
|
+
if (recallSource === "manual-search") {
|
|
1278
|
+
manualRecallRequested += 1;
|
|
1279
|
+
if (event.resultCount > 0)
|
|
1280
|
+
manualRecallReturnedResults += 1;
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
autoRecallRequested += 1;
|
|
1284
|
+
if (event.resultCount > 0)
|
|
1285
|
+
autoRecallReturnedResults += 1;
|
|
1286
|
+
if (event.injected)
|
|
1287
|
+
autoRecallInjected += 1;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (event.type === "feedback") {
|
|
1291
|
+
if (event.feedbackType === "missing")
|
|
1292
|
+
feedbackMissing += 1;
|
|
1293
|
+
if (event.feedbackType === "wrong")
|
|
1294
|
+
feedbackWrong += 1;
|
|
1295
|
+
if (event.feedbackType === "useful") {
|
|
1296
|
+
if (event.helpful)
|
|
1297
|
+
feedbackUsefulPositive += 1;
|
|
1298
|
+
else
|
|
1299
|
+
feedbackUsefulNegative += 1;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const totalCaptureAttempts = captureStored + captureSkipped;
|
|
1304
|
+
const totalUsefulFeedback = feedbackUsefulPositive + feedbackUsefulNegative;
|
|
1305
|
+
const flaggedCount = memories.filter((r) => {
|
|
1306
|
+
const meta = parseMetadata(r.metadataJson);
|
|
1307
|
+
return meta.isPotentialDuplicate === true;
|
|
1308
|
+
}).length;
|
|
1309
|
+
const consolidatedCount = memories.filter((r) => {
|
|
1310
|
+
const meta = parseMetadata(r.metadataJson);
|
|
1311
|
+
return meta.status === "merged";
|
|
1312
|
+
}).length;
|
|
1313
|
+
return {
|
|
1314
|
+
scope,
|
|
1315
|
+
totalEvents: events.length,
|
|
1316
|
+
capture: {
|
|
1317
|
+
considered: captureConsidered,
|
|
1318
|
+
stored: captureStored,
|
|
1319
|
+
skipped: captureSkipped,
|
|
1320
|
+
successRate: totalCaptureAttempts === 0 ? 0 : captureStored / totalCaptureAttempts,
|
|
1321
|
+
skipReasons: captureSkipReasons,
|
|
1322
|
+
},
|
|
1323
|
+
recall: {
|
|
1324
|
+
requested: recallRequested,
|
|
1325
|
+
injected: recallInjected,
|
|
1326
|
+
returnedResults: recallReturnedResults,
|
|
1327
|
+
hitRate: recallRequested === 0 ? 0 : recallReturnedResults / recallRequested,
|
|
1328
|
+
injectionRate: recallRequested === 0 ? 0 : recallInjected / recallRequested,
|
|
1329
|
+
auto: {
|
|
1330
|
+
requested: autoRecallRequested,
|
|
1331
|
+
injected: autoRecallInjected,
|
|
1332
|
+
returnedResults: autoRecallReturnedResults,
|
|
1333
|
+
hitRate: autoRecallRequested === 0 ? 0 : autoRecallReturnedResults / autoRecallRequested,
|
|
1334
|
+
injectionRate: autoRecallRequested === 0 ? 0 : autoRecallInjected / autoRecallRequested,
|
|
1335
|
+
},
|
|
1336
|
+
manual: {
|
|
1337
|
+
requested: manualRecallRequested,
|
|
1338
|
+
returnedResults: manualRecallReturnedResults,
|
|
1339
|
+
hitRate: manualRecallRequested === 0 ? 0 : manualRecallReturnedResults / manualRecallRequested,
|
|
1340
|
+
},
|
|
1341
|
+
manualRescueRatio: autoRecallRequested === 0 ? 0 : manualRecallRequested / autoRecallRequested,
|
|
1342
|
+
},
|
|
1343
|
+
feedback: {
|
|
1344
|
+
missing: feedbackMissing,
|
|
1345
|
+
wrong: feedbackWrong,
|
|
1346
|
+
useful: {
|
|
1347
|
+
positive: feedbackUsefulPositive,
|
|
1348
|
+
negative: feedbackUsefulNegative,
|
|
1349
|
+
helpfulRate: totalUsefulFeedback === 0 ? 0 : feedbackUsefulPositive / totalUsefulFeedback,
|
|
1350
|
+
},
|
|
1351
|
+
falsePositiveRate: captureStored === 0 ? 0 : feedbackWrong / captureStored,
|
|
1352
|
+
falseNegativeRate: totalCaptureAttempts === 0 ? 0 : feedbackMissing / totalCaptureAttempts,
|
|
1353
|
+
},
|
|
1354
|
+
duplicates: {
|
|
1355
|
+
flaggedCount,
|
|
1356
|
+
consolidatedCount,
|
|
1357
|
+
},
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
calculateTrend(current, previous, currentSamples, previousSamples) {
|
|
1361
|
+
const MIN_SAMPLES = 5;
|
|
1362
|
+
if (previous === undefined || currentSamples < MIN_SAMPLES || previousSamples < MIN_SAMPLES) {
|
|
1363
|
+
return { direction: "insufficient-data", percentageChange: 0 };
|
|
1364
|
+
}
|
|
1365
|
+
if (previous === 0) {
|
|
1366
|
+
return current > 0
|
|
1367
|
+
? { direction: "improving", percentageChange: 100 }
|
|
1368
|
+
: { direction: "stable", percentageChange: 0 };
|
|
1369
|
+
}
|
|
1370
|
+
const pctChange = ((current - previous) / previous) * 100;
|
|
1371
|
+
if (Math.abs(pctChange) <= 5) {
|
|
1372
|
+
return { direction: "stable", percentageChange: Math.round(pctChange * 10) / 10 };
|
|
1373
|
+
}
|
|
1374
|
+
const direction = pctChange > 0 ? "improving" : "declining";
|
|
1375
|
+
return { direction, percentageChange: Math.round(pctChange * 10) / 10 };
|
|
1376
|
+
}
|
|
1377
|
+
generateInsights(summary) {
|
|
1378
|
+
const insights = [];
|
|
1379
|
+
if (summary.recall.requested > 0 && summary.recall.hitRate < 0.5) {
|
|
1380
|
+
insights.push("Consider refining memory capture quality or query specificity");
|
|
1381
|
+
}
|
|
1382
|
+
if (summary.capture.considered > 0) {
|
|
1383
|
+
const skipRate = summary.capture.skipped / (summary.capture.stored + summary.capture.skipped);
|
|
1384
|
+
if (skipRate > 0.5) {
|
|
1385
|
+
insights.push("High skip rate may indicate duplicate content or embedding issues");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (summary.feedback.useful.positive + summary.feedback.useful.negative > 0) {
|
|
1389
|
+
if (summary.feedback.useful.helpfulRate < 0.7) {
|
|
1390
|
+
insights.push("Memory quality could improve with more explicit feedback");
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (insights.length === 0) {
|
|
1394
|
+
insights.push("Learning effectiveness is within healthy ranges");
|
|
1395
|
+
}
|
|
1396
|
+
return insights;
|
|
1397
|
+
}
|
|
1398
|
+
getIndexHealth() {
|
|
1399
|
+
return {
|
|
1400
|
+
vector: this.indexState.vector,
|
|
1401
|
+
fts: this.indexState.fts,
|
|
1402
|
+
ftsError: this.indexState.ftsError || undefined,
|
|
1403
|
+
vectorRetries: this.indexState.vectorRetries,
|
|
1404
|
+
ftsRetries: this.indexState.ftsRetries,
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
invalidateScope(scope) {
|
|
1408
|
+
this.scopeVersions.set(scope, (this.scopeVersions.get(scope) ?? 0) + 1);
|
|
1409
|
+
}
|
|
1410
|
+
async getCachedScopes(scopes) {
|
|
1411
|
+
if (!this.cacheConfig.enabled) {
|
|
1412
|
+
const allRecords = [];
|
|
1413
|
+
const allTokenized = [];
|
|
1414
|
+
const allNorms = new Map();
|
|
1415
|
+
for (const scope of scopes) {
|
|
1416
|
+
const records = await this.readByScopes([scope]);
|
|
1417
|
+
allRecords.push(...records);
|
|
1418
|
+
const tokenized = records.map((record) => tokenize(record.text));
|
|
1419
|
+
allTokenized.push(...tokenized);
|
|
1420
|
+
for (const record of records) {
|
|
1421
|
+
allNorms.set(record.id, vecNorm(record.vector));
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const idf = computeIdf(allTokenized);
|
|
1425
|
+
return { records: allRecords, tokenized: allTokenized, idf, norms: allNorms, lastAccessTimestamp: Date.now() };
|
|
1426
|
+
}
|
|
1427
|
+
const allRecords = [];
|
|
1428
|
+
const allTokenized = [];
|
|
1429
|
+
const allNorms = new Map();
|
|
1430
|
+
for (const scope of scopes) {
|
|
1431
|
+
const currentVersion = this.scopeVersions.get(scope) ?? 0;
|
|
1432
|
+
let entry = this.scopeCache.get(scope);
|
|
1433
|
+
if (!entry || entry.version !== currentVersion) {
|
|
1434
|
+
if (entry) {
|
|
1435
|
+
this.cacheStats.evictions++;
|
|
1436
|
+
}
|
|
1437
|
+
const records = await this.readByScopes([scope]);
|
|
1438
|
+
let sortedRecords = records;
|
|
1439
|
+
if (records.length > this.cacheConfig.maxRecordsPerScope) {
|
|
1440
|
+
sortedRecords = [...records].sort((a, b) => b.timestamp - a.timestamp).slice(0, this.cacheConfig.maxRecordsPerScope);
|
|
1441
|
+
}
|
|
1442
|
+
const tokenized = sortedRecords.map((record) => tokenize(record.text));
|
|
1443
|
+
const idf = computeIdf(tokenized);
|
|
1444
|
+
const norms = new Map();
|
|
1445
|
+
for (const record of sortedRecords) {
|
|
1446
|
+
norms.set(record.id, vecNorm(record.vector));
|
|
1447
|
+
}
|
|
1448
|
+
entry = { records: sortedRecords, tokenized, idf, norms, lastAccessTimestamp: Date.now(), version: currentVersion };
|
|
1449
|
+
this.scopeCache.set(scope, entry);
|
|
1450
|
+
this.cacheStats.misses++;
|
|
1451
|
+
this.enforceMaxScopes();
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
entry.lastAccessTimestamp = Date.now();
|
|
1455
|
+
this.cacheStats.hits++;
|
|
1456
|
+
}
|
|
1457
|
+
allRecords.push(...entry.records);
|
|
1458
|
+
allTokenized.push(...entry.tokenized);
|
|
1459
|
+
for (const [id, norm] of entry.norms) {
|
|
1460
|
+
allNorms.set(id, norm);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const idf = scopes.length === 1 && this.scopeCache.has(scopes[0])
|
|
1464
|
+
? this.scopeCache.get(scopes[0]).idf
|
|
1465
|
+
: computeIdf(allTokenized);
|
|
1466
|
+
return { records: allRecords, tokenized: allTokenized, idf, norms: allNorms, lastAccessTimestamp: Date.now() };
|
|
1467
|
+
}
|
|
1468
|
+
enforceMaxScopes() {
|
|
1469
|
+
while (this.scopeCache.size > this.cacheConfig.maxScopes) {
|
|
1470
|
+
let lruScope = null;
|
|
1471
|
+
let lruTimestamp = Infinity;
|
|
1472
|
+
for (const [scope, entry] of this.scopeCache) {
|
|
1473
|
+
if (entry.lastAccessTimestamp < lruTimestamp) {
|
|
1474
|
+
lruTimestamp = entry.lastAccessTimestamp;
|
|
1475
|
+
lruScope = scope;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (lruScope) {
|
|
1479
|
+
this.scopeCache.delete(lruScope);
|
|
1480
|
+
this.cacheStats.evictions++;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
requireTable() {
|
|
1485
|
+
if (!this.table) {
|
|
1486
|
+
throw new Error("MemoryStore is not initialized");
|
|
1487
|
+
}
|
|
1488
|
+
return this.table;
|
|
1489
|
+
}
|
|
1490
|
+
requireEventTable() {
|
|
1491
|
+
if (!this.eventTable) {
|
|
1492
|
+
throw new Error("MemoryStore event table is not initialized");
|
|
1493
|
+
}
|
|
1494
|
+
return this.eventTable;
|
|
1495
|
+
}
|
|
1496
|
+
async ensureEpisodicTaskTable(vectorDim) {
|
|
1497
|
+
const EPISODIC_TABLE_NAME = "episodic_tasks";
|
|
1498
|
+
if (this.episodicTaskTable)
|
|
1499
|
+
return;
|
|
1500
|
+
try {
|
|
1501
|
+
this.episodicTaskTable = await this.connection.openTable(EPISODIC_TABLE_NAME);
|
|
1502
|
+
const schema = await this.episodicTaskTable.schema();
|
|
1503
|
+
const fieldNames = schema.fields.map((f) => f.name);
|
|
1504
|
+
if (!fieldNames.includes("taskDescriptionVector")) {
|
|
1505
|
+
await this.episodicTaskTable.addColumns([{ name: "taskDescriptionVector", valueSql: "NULL" }]);
|
|
1506
|
+
}
|
|
1507
|
+
// EPISODIC_SCHEMA (1.1.7): failureType/errorMessage are declared in
|
|
1508
|
+
// the record zod type and read by suggestRetryBudget, but never
|
|
1509
|
+
// existed as columns — table.add() used to silently widen the
|
|
1510
|
+
// schema on write, while table.update() (used since ATOMIC_UPDATE)
|
|
1511
|
+
// requires the column to already exist. Migrate them explicitly.
|
|
1512
|
+
const missingEpisodic = [];
|
|
1513
|
+
if (!fieldNames.includes("failureType")) {
|
|
1514
|
+
missingEpisodic.push({ name: "failureType", valueSql: "CAST(NULL AS STRING)" });
|
|
1515
|
+
}
|
|
1516
|
+
if (!fieldNames.includes("errorMessage")) {
|
|
1517
|
+
missingEpisodic.push({ name: "errorMessage", valueSql: "CAST(NULL AS STRING)" });
|
|
1518
|
+
}
|
|
1519
|
+
if (missingEpisodic.length > 0) {
|
|
1520
|
+
await this.episodicTaskTable.addColumns(missingEpisodic);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
const bootstrap = {
|
|
1525
|
+
id: "__bootstrap__",
|
|
1526
|
+
sessionId: "",
|
|
1527
|
+
scope: "global",
|
|
1528
|
+
taskId: "",
|
|
1529
|
+
state: "pending",
|
|
1530
|
+
startTime: 0,
|
|
1531
|
+
endTime: 0,
|
|
1532
|
+
commandsJson: "[]",
|
|
1533
|
+
validationOutcomesJson: "[]",
|
|
1534
|
+
successPatternsJson: "[]",
|
|
1535
|
+
retryAttemptsJson: "[]",
|
|
1536
|
+
recoveryStrategiesJson: "[]",
|
|
1537
|
+
metadataJson: "{}",
|
|
1538
|
+
taskDescriptionVector: undefined,
|
|
1539
|
+
failureType: undefined,
|
|
1540
|
+
errorMessage: undefined,
|
|
1541
|
+
};
|
|
1542
|
+
this.episodicTaskTable = await this.connection.createTable(EPISODIC_TABLE_NAME, [bootstrap]);
|
|
1543
|
+
await this.episodicTaskTable.delete("id = '__bootstrap__'");
|
|
1544
|
+
// undefined-valued bootstrap fields are dropped by Arrow schema
|
|
1545
|
+
// inference, so create the nullable columns explicitly.
|
|
1546
|
+
await this.episodicTaskTable.addColumns([
|
|
1547
|
+
{ name: "taskDescriptionVector", valueSql: "NULL" },
|
|
1548
|
+
{ name: "failureType", valueSql: "CAST(NULL AS STRING)" },
|
|
1549
|
+
{ name: "errorMessage", valueSql: "CAST(NULL AS STRING)" },
|
|
1550
|
+
]);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
requireEpisodicTaskTable() {
|
|
1554
|
+
if (!this.episodicTaskTable) {
|
|
1555
|
+
throw new Error("MemoryStore episodic task table is not initialized");
|
|
1556
|
+
}
|
|
1557
|
+
return this.episodicTaskTable;
|
|
1558
|
+
}
|
|
1559
|
+
async createTaskEpisode(record) {
|
|
1560
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1561
|
+
await this.requireEpisodicTaskTable().add([record]);
|
|
1562
|
+
}
|
|
1563
|
+
async updateTaskState(taskId, state, scope, failureType, errorMessage) {
|
|
1564
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1565
|
+
const table = this.requireEpisodicTaskTable();
|
|
1566
|
+
const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray();
|
|
1567
|
+
if (rows.length === 0)
|
|
1568
|
+
return false;
|
|
1569
|
+
const existing = rows[0];
|
|
1570
|
+
// ATOMIC_UPDATE (1.1.7): was delete+add; single update commit now.
|
|
1571
|
+
const values = {
|
|
1572
|
+
state,
|
|
1573
|
+
failureType: failureType ?? null,
|
|
1574
|
+
errorMessage: errorMessage ?? null,
|
|
1575
|
+
};
|
|
1576
|
+
if (state !== "running" && state !== "pending") {
|
|
1577
|
+
values.endTime = Date.now();
|
|
1578
|
+
}
|
|
1579
|
+
await table.update({
|
|
1580
|
+
where: `id = '${escapeSql(existing.id)}'`,
|
|
1581
|
+
values,
|
|
1582
|
+
});
|
|
1583
|
+
return true;
|
|
1584
|
+
}
|
|
1585
|
+
async getTaskEpisode(taskId, scope) {
|
|
1586
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1587
|
+
const rows = await this.requireEpisodicTaskTable()
|
|
1588
|
+
.query()
|
|
1589
|
+
.where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`)
|
|
1590
|
+
.toArray();
|
|
1591
|
+
if (rows.length === 0)
|
|
1592
|
+
return null;
|
|
1593
|
+
return validateEpisodicRecord(rows[0]);
|
|
1594
|
+
}
|
|
1595
|
+
async queryTaskEpisodes(scope, state, sinceTimestamp) {
|
|
1596
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1597
|
+
const table = this.requireEpisodicTaskTable();
|
|
1598
|
+
let whereClause = `scope = '${escapeSql(scope)}'`;
|
|
1599
|
+
if (state) {
|
|
1600
|
+
whereClause += ` AND state = '${escapeSql(state)}'`;
|
|
1601
|
+
}
|
|
1602
|
+
if (sinceTimestamp) {
|
|
1603
|
+
whereClause += ` AND startTime >= ${sinceTimestamp}`;
|
|
1604
|
+
}
|
|
1605
|
+
const rows = await table.query().where(whereClause).toArray();
|
|
1606
|
+
return validateEpisodicRecordArray(rows);
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Generic helper for appending items to an episodic task's JSON array field.
|
|
1610
|
+
* Centralizes the read-parse-push-write pattern across all add*Episode
|
|
1611
|
+
* methods. ATOMIC_UPDATE (1.1.7): write is a single table.update (one
|
|
1612
|
+
* commit) instead of read → delete → add (two commits).
|
|
1613
|
+
*/
|
|
1614
|
+
async appendToEpisodeField(taskId, scope, fieldName, parser, serializer, newItem, itemEnricher) {
|
|
1615
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1616
|
+
const table = this.requireEpisodicTaskTable();
|
|
1617
|
+
const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray();
|
|
1618
|
+
if (rows.length === 0)
|
|
1619
|
+
return false;
|
|
1620
|
+
const existing = validateEpisodicRecord(rows[0]);
|
|
1621
|
+
const items = parser(existing[fieldName] || "[]");
|
|
1622
|
+
const enrichedItem = itemEnricher ? itemEnricher(newItem) : newItem;
|
|
1623
|
+
items.push(enrichedItem);
|
|
1624
|
+
await table.update({
|
|
1625
|
+
where: `id = '${escapeSql(existing.id)}'`,
|
|
1626
|
+
values: { [fieldName]: serializer(items) },
|
|
1627
|
+
});
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
async addCommandToEpisode(taskId, scope, command) {
|
|
1631
|
+
return this.appendToEpisodeField(taskId, scope, "commandsJson", (raw) => (raw ? JSON.parse(raw) : []), (items) => JSON.stringify(items), command);
|
|
1632
|
+
}
|
|
1633
|
+
async addValidationOutcome(taskId, scope, outcome) {
|
|
1634
|
+
return this.appendToEpisodeField(taskId, scope, "validationOutcomesJson", (raw) => (raw ? JSON.parse(raw) : []), (items) => JSON.stringify(items), outcome);
|
|
1635
|
+
}
|
|
1636
|
+
async addSuccessPatterns(taskId, scope, patterns) {
|
|
1637
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1638
|
+
const table = this.requireEpisodicTaskTable();
|
|
1639
|
+
const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray();
|
|
1640
|
+
if (rows.length === 0)
|
|
1641
|
+
return false;
|
|
1642
|
+
const existing = rows[0];
|
|
1643
|
+
const existingPatterns = existing.successPatternsJson ? JSON.parse(existing.successPatternsJson) : [];
|
|
1644
|
+
const allPatterns = [...existingPatterns, ...patterns];
|
|
1645
|
+
await table.update({
|
|
1646
|
+
where: `id = '${escapeSql(existing.id)}'`,
|
|
1647
|
+
values: { successPatternsJson: JSON.stringify(allPatterns) },
|
|
1648
|
+
});
|
|
1649
|
+
return true;
|
|
1650
|
+
}
|
|
1651
|
+
async findSimilarTasks(scope, taskDescription, minSimilarity = 0.85, queryVector) {
|
|
1652
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1653
|
+
const table = this.requireEpisodicTaskTable();
|
|
1654
|
+
const rows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'success'`).toArray();
|
|
1655
|
+
const episodes = validateEpisodicRecordArray(rows);
|
|
1656
|
+
// TASK_VECTOR_DEAD_CODE (1.1.7): the taskDescriptionVector branch was
|
|
1657
|
+
// never reachable — createTaskEpisode never writes a vector, and the
|
|
1658
|
+
// column is declared at 384 dims while the real embedder is 1536, so
|
|
1659
|
+
// `length === queryVector.length` never matched. Keyword matching is
|
|
1660
|
+
// the only live path; the vector branch is removed.
|
|
1661
|
+
const keywords = taskDescription.toLowerCase().split(/\s+/).filter((k) => k.length > 2);
|
|
1662
|
+
const scored = episodes.map((ep) => {
|
|
1663
|
+
const metadata = (JSON.parse(ep.metadataJson || "{}"));
|
|
1664
|
+
const description = (metadata.description || "").toLowerCase();
|
|
1665
|
+
const taskId = ep.taskId.toLowerCase();
|
|
1666
|
+
const commands = JSON.parse(ep.commandsJson || "[]").join(" ").toLowerCase();
|
|
1667
|
+
const text = `${taskId} ${description} ${commands}`;
|
|
1668
|
+
let matchCount = 0;
|
|
1669
|
+
for (const kw of keywords) {
|
|
1670
|
+
if (text.includes(kw))
|
|
1671
|
+
matchCount++;
|
|
1672
|
+
}
|
|
1673
|
+
const similarity = keywords.length > 0 ? matchCount / keywords.length : 0;
|
|
1674
|
+
return { episode: ep, similarity };
|
|
1675
|
+
});
|
|
1676
|
+
return scored
|
|
1677
|
+
.filter((s) => s.similarity >= minSimilarity)
|
|
1678
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
1679
|
+
.map((s) => s.episode);
|
|
1680
|
+
}
|
|
1681
|
+
async extractSuccessPatternsFromScope(scope) {
|
|
1682
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1683
|
+
const table = this.requireEpisodicTaskTable();
|
|
1684
|
+
const rows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'success'`).toArray();
|
|
1685
|
+
const episodes = validateEpisodicRecordArray(rows);
|
|
1686
|
+
const commandSequenceCount = new Map();
|
|
1687
|
+
const toolCount = new Map();
|
|
1688
|
+
for (const ep of episodes) {
|
|
1689
|
+
const commands = JSON.parse(ep.commandsJson || "[]");
|
|
1690
|
+
if (commands.length > 0) {
|
|
1691
|
+
const seq = commands.join(" | ");
|
|
1692
|
+
commandSequenceCount.set(seq, (commandSequenceCount.get(seq) || 0) + 1);
|
|
1693
|
+
}
|
|
1694
|
+
// Extract tools from commands (simple heuristic)
|
|
1695
|
+
for (const cmd of commands) {
|
|
1696
|
+
const toolMatch = cmd.match(/^(npm|yarn|pnpm|npx|yarn|cargo|go|pytest|jest|tsc|eslint|prettier)/);
|
|
1697
|
+
if (toolMatch) {
|
|
1698
|
+
toolCount.set(toolMatch[1], (toolCount.get(toolMatch[1]) || 0) + 1);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
const patterns = [];
|
|
1703
|
+
// Create patterns from frequent command sequences
|
|
1704
|
+
for (const [seq, count] of commandSequenceCount) {
|
|
1705
|
+
const commands = seq.split(" | ");
|
|
1706
|
+
const confidence = Math.min(0.5 + (count * 0.1), 1.0);
|
|
1707
|
+
patterns.push({
|
|
1708
|
+
pattern: {
|
|
1709
|
+
commands,
|
|
1710
|
+
tools: commands.map(c => c.split(" ")[0]).filter(Boolean),
|
|
1711
|
+
confidence,
|
|
1712
|
+
extractedAt: Date.now(),
|
|
1713
|
+
},
|
|
1714
|
+
count,
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
return patterns.sort((a, b) => b.count - a.count);
|
|
1718
|
+
}
|
|
1719
|
+
async addRetryAttempt(taskId, scope, attempt) {
|
|
1720
|
+
return this.appendToEpisodeField(taskId, scope, "retryAttemptsJson", (raw) => JSON.parse(raw || "[]"), (items) => JSON.stringify(items), attempt, (item) => ({ ...item, timestamp: Date.now() }));
|
|
1721
|
+
}
|
|
1722
|
+
async addRecoveryStrategy(taskId, scope, strategy) {
|
|
1723
|
+
return this.appendToEpisodeField(taskId, scope, "recoveryStrategiesJson", (raw) => JSON.parse(raw || "[]"), (items) => JSON.stringify(items), strategy, (item) => ({ ...item, attemptedAt: Date.now() }));
|
|
1724
|
+
}
|
|
1725
|
+
async suggestRetryBudget(scope, minSamples = 3) {
|
|
1726
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1727
|
+
const table = this.requireEpisodicTaskTable();
|
|
1728
|
+
const rows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'failed'`).toArray();
|
|
1729
|
+
const failedEpisodes = validateEpisodicRecordArray(rows);
|
|
1730
|
+
if (failedEpisodes.length < minSamples) {
|
|
1731
|
+
return null;
|
|
1732
|
+
}
|
|
1733
|
+
const retryCounts = [];
|
|
1734
|
+
let sameErrorCount = 0;
|
|
1735
|
+
const firstError = failedEpisodes[0]?.errorMessage;
|
|
1736
|
+
for (const ep of failedEpisodes) {
|
|
1737
|
+
const attempts = ep.retryAttemptsJson;
|
|
1738
|
+
retryCounts.push(attempts.length);
|
|
1739
|
+
if (ep.errorMessage === firstError && attempts.length > 0) {
|
|
1740
|
+
sameErrorCount++;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
if (retryCounts.length === 0) {
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
const sorted = [...retryCounts].sort((a, b) => a - b);
|
|
1747
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
1748
|
+
const suggestedRetries = median + 1;
|
|
1749
|
+
const confidence = Math.min(0.5 + (retryCounts.length * 0.1), 1.0);
|
|
1750
|
+
const shouldStop = sameErrorCount >= 3;
|
|
1751
|
+
const stopReason = shouldStop ? "Multiple retries failed with same error" : undefined;
|
|
1752
|
+
return {
|
|
1753
|
+
suggestedRetries,
|
|
1754
|
+
confidence,
|
|
1755
|
+
basedOnCount: retryCounts.length,
|
|
1756
|
+
shouldStop,
|
|
1757
|
+
stopReason,
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
async suggestRecoveryStrategies(scope, taskId) {
|
|
1761
|
+
await this.ensureEpisodicTaskTable(384);
|
|
1762
|
+
const table = this.requireEpisodicTaskTable();
|
|
1763
|
+
const suggestions = [];
|
|
1764
|
+
const failedRows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'failed'`).toArray();
|
|
1765
|
+
const failedEpisodes = validateEpisodicRecordArray(failedRows);
|
|
1766
|
+
const successRows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'success'`).toArray();
|
|
1767
|
+
const successEpisodes = validateEpisodicRecordArray(successRows);
|
|
1768
|
+
if (failedEpisodes.length >= 3 && successEpisodes.length > 0) {
|
|
1769
|
+
const failedTaskIds = failedEpisodes.map(e => e.taskId);
|
|
1770
|
+
const similarSuccess = successEpisodes.find(e => {
|
|
1771
|
+
const eId = e.taskId.toLowerCase();
|
|
1772
|
+
return failedTaskIds.some(fId => eId.includes(fId) || fId.includes(eId));
|
|
1773
|
+
});
|
|
1774
|
+
if (similarSuccess) {
|
|
1775
|
+
const commands = similarSuccess.commandsJson;
|
|
1776
|
+
if (commands.length > 0) {
|
|
1777
|
+
suggestions.push({
|
|
1778
|
+
strategy: `Try: ${commands[0]}`,
|
|
1779
|
+
reason: "Similar task succeeded with this approach",
|
|
1780
|
+
confidence: 0.7,
|
|
1781
|
+
basedOnTask: similarSuccess.taskId,
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
const recentFailed = failedEpisodes.filter(e => Date.now() - e.startTime < 3600000);
|
|
1787
|
+
if (recentFailed.length >= 2) {
|
|
1788
|
+
suggestions.push({
|
|
1789
|
+
strategy: "Consider exponential backoff",
|
|
1790
|
+
reason: "Multiple failures in short timeframe",
|
|
1791
|
+
confidence: 0.6,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
return suggestions;
|
|
1795
|
+
}
|
|
1796
|
+
async calculateRetryToSuccessRate(scope, days = 30) {
|
|
1797
|
+
const sinceTimestamp = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1798
|
+
const failedTasks = await this.queryTaskEpisodes(scope, "failed", sinceTimestamp);
|
|
1799
|
+
const successTasks = await this.queryTaskEpisodes(scope, "success", sinceTimestamp);
|
|
1800
|
+
if (failedTasks.length === 0) {
|
|
1801
|
+
return { status: "no-failed-tasks", rate: 0, totalFailedTasks: 0, succeededAfterRetry: 0, sampleCount: 0 };
|
|
1802
|
+
}
|
|
1803
|
+
const totalFailed = failedTasks.length;
|
|
1804
|
+
const succeededAfterRetry = successTasks.filter((t) => {
|
|
1805
|
+
const retries = JSON.parse(t.retryAttemptsJson || "[]");
|
|
1806
|
+
return retries.some((r) => r.outcome === "success");
|
|
1807
|
+
}).length;
|
|
1808
|
+
const sampleCount = totalFailed + succeededAfterRetry;
|
|
1809
|
+
if (sampleCount < 5) {
|
|
1810
|
+
return { status: "insufficient-data", rate: 0, totalFailedTasks: totalFailed, succeededAfterRetry, sampleCount };
|
|
1811
|
+
}
|
|
1812
|
+
const rate = totalFailed > 0 ? succeededAfterRetry / totalFailed : 0;
|
|
1813
|
+
return { status: "ok", rate, totalFailedTasks: totalFailed, succeededAfterRetry, sampleCount };
|
|
1814
|
+
}
|
|
1815
|
+
async calculateMemoryLift(scope, days = 30) {
|
|
1816
|
+
const sinceTimestamp = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1817
|
+
const allTasks = await this.queryTaskEpisodes(scope, undefined, sinceTimestamp);
|
|
1818
|
+
const withRecall = [];
|
|
1819
|
+
const withoutRecall = [];
|
|
1820
|
+
for (const task of allTasks) {
|
|
1821
|
+
const usedRecall = this.taskUsedRecall(task);
|
|
1822
|
+
const isSuccess = task.state === "success";
|
|
1823
|
+
if (usedRecall) {
|
|
1824
|
+
withRecall.push({ success: isSuccess });
|
|
1825
|
+
}
|
|
1826
|
+
else {
|
|
1827
|
+
withoutRecall.push({ success: isSuccess });
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
if (withRecall.length === 0) {
|
|
1831
|
+
return { status: "no-recall-data", lift: 0, successRateWithRecall: 0, successRateWithoutRecall: 0, withRecallCount: 0, withoutRecallCount: withoutRecall.length };
|
|
1832
|
+
}
|
|
1833
|
+
if (withRecall.length < 5 || withoutRecall.length < 5) {
|
|
1834
|
+
return { status: "insufficient-data", lift: 0, successRateWithRecall: 0, successRateWithoutRecall: 0, withRecallCount: withRecall.length, withoutRecallCount: withoutRecall.length };
|
|
1835
|
+
}
|
|
1836
|
+
const rateWith = withRecall.filter((t) => t.success).length / withRecall.length;
|
|
1837
|
+
const rateWithout = withoutRecall.length > 0 ? withoutRecall.filter((t) => t.success).length / withoutRecall.length : 0;
|
|
1838
|
+
const lift = rateWithout > 0 ? (rateWith - rateWithout) / rateWithout : 0;
|
|
1839
|
+
return { status: "ok", lift, successRateWithRecall: rateWith, successRateWithoutRecall: rateWithout, withRecallCount: withRecall.length, withoutRecallCount: withoutRecall.length };
|
|
1840
|
+
}
|
|
1841
|
+
taskUsedRecall(task) {
|
|
1842
|
+
const metadata = parseMetadata(task.metadataJson);
|
|
1843
|
+
if (metadata.recallUsed === true)
|
|
1844
|
+
return true;
|
|
1845
|
+
try {
|
|
1846
|
+
const outcomes = JSON.parse(task.validationOutcomesJson || "[]");
|
|
1847
|
+
return outcomes.some((o) => o.type === "recall");
|
|
1848
|
+
}
|
|
1849
|
+
catch {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
async getKpiSummary(scope, days = 30) {
|
|
1854
|
+
const retryToSuccess = await this.calculateRetryToSuccessRate(scope, days);
|
|
1855
|
+
const memoryLift = await this.calculateMemoryLift(scope, days);
|
|
1856
|
+
return {
|
|
1857
|
+
scope,
|
|
1858
|
+
periodDays: days,
|
|
1859
|
+
retryToSuccess,
|
|
1860
|
+
memoryLift,
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
async readEventsByScopes(scopes) {
|
|
1864
|
+
const table = this.requireEventTable();
|
|
1865
|
+
if (scopes.length === 0)
|
|
1866
|
+
return [];
|
|
1867
|
+
const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
1868
|
+
const rows = await table
|
|
1869
|
+
.query()
|
|
1870
|
+
.where(`(${whereExpr})`)
|
|
1871
|
+
.select([
|
|
1872
|
+
"id",
|
|
1873
|
+
"type",
|
|
1874
|
+
"scope",
|
|
1875
|
+
"sessionID",
|
|
1876
|
+
"timestamp",
|
|
1877
|
+
"memoryId",
|
|
1878
|
+
"text",
|
|
1879
|
+
"outcome",
|
|
1880
|
+
"skipReason",
|
|
1881
|
+
"resultCount",
|
|
1882
|
+
"injected",
|
|
1883
|
+
"source",
|
|
1884
|
+
"feedbackType",
|
|
1885
|
+
"helpful",
|
|
1886
|
+
"reason",
|
|
1887
|
+
"labelsJson",
|
|
1888
|
+
"metadataJson",
|
|
1889
|
+
"sourceSessionId",
|
|
1890
|
+
"confidenceDelta",
|
|
1891
|
+
"relatedMemoryId",
|
|
1892
|
+
"context",
|
|
1893
|
+
])
|
|
1894
|
+
.limit(100000)
|
|
1895
|
+
.toArray();
|
|
1896
|
+
return rows
|
|
1897
|
+
.map((row) => normalizeEventRow(row))
|
|
1898
|
+
.filter((row) => row !== null);
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Get feedback stats for a set of memory IDs.
|
|
1902
|
+
* Returns a map of memoryId -> feedback stats.
|
|
1903
|
+
* Only considers feedback within the last 30 days.
|
|
1904
|
+
*/
|
|
1905
|
+
async getMemoryFeedbackStatsMap(memoryIds, scopes) {
|
|
1906
|
+
const feedbackStats = new Map();
|
|
1907
|
+
if (memoryIds.length === 0 || scopes.length === 0)
|
|
1908
|
+
return feedbackStats;
|
|
1909
|
+
// Default feedback window: 30 days
|
|
1910
|
+
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
|
1911
|
+
const table = this.requireEventTable();
|
|
1912
|
+
const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
1913
|
+
const memoryIdExpr = memoryIds.map((id) => `memoryId = '${escapeSql(id)}'`).join(" OR ");
|
|
1914
|
+
const rows = await table
|
|
1915
|
+
.query()
|
|
1916
|
+
.where(`(${whereExpr}) AND (${memoryIdExpr}) AND type = 'feedback' AND timestamp >= ${thirtyDaysAgo}`)
|
|
1917
|
+
.select([
|
|
1918
|
+
"memoryId",
|
|
1919
|
+
"feedbackType",
|
|
1920
|
+
"helpful",
|
|
1921
|
+
])
|
|
1922
|
+
.limit(100000)
|
|
1923
|
+
.toArray();
|
|
1924
|
+
// Aggregate feedback per memory
|
|
1925
|
+
const feedbackMap = new Map();
|
|
1926
|
+
for (const row of rows) {
|
|
1927
|
+
const memoryId = row.memoryId;
|
|
1928
|
+
const feedbackType = row.feedbackType;
|
|
1929
|
+
const helpful = row.helpful;
|
|
1930
|
+
if (!feedbackMap.has(memoryId)) {
|
|
1931
|
+
feedbackMap.set(memoryId, { helpful: 0, unhelpful: 0, wrong: 0 });
|
|
1932
|
+
}
|
|
1933
|
+
const stats = feedbackMap.get(memoryId);
|
|
1934
|
+
if (feedbackType === "wrong") {
|
|
1935
|
+
stats.wrong += 1;
|
|
1936
|
+
}
|
|
1937
|
+
else if (feedbackType === "useful") {
|
|
1938
|
+
if (helpful === 1) {
|
|
1939
|
+
stats.helpful += 1;
|
|
1940
|
+
}
|
|
1941
|
+
else if (helpful === 0) {
|
|
1942
|
+
stats.unhelpful += 1;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
// Calculate feedback factor for each memory
|
|
1947
|
+
for (const [memoryId, stats] of feedbackMap) {
|
|
1948
|
+
const totalFeedback = stats.helpful + stats.unhelpful;
|
|
1949
|
+
const helpfulRate = totalFeedback > 0 ? stats.helpful / totalFeedback : 0.5; // Neutral if no feedback
|
|
1950
|
+
const wrongPenalty = Math.min(0.3, stats.wrong * 0.1);
|
|
1951
|
+
const feedbackFactor = 1 + (helpfulRate - 0.5) * 2 - wrongPenalty;
|
|
1952
|
+
feedbackStats.set(memoryId, {
|
|
1953
|
+
memoryId,
|
|
1954
|
+
helpful: stats.helpful,
|
|
1955
|
+
unhelpful: stats.unhelpful,
|
|
1956
|
+
wrong: stats.wrong,
|
|
1957
|
+
helpfulRate,
|
|
1958
|
+
feedbackFactor,
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
return feedbackStats;
|
|
1962
|
+
}
|
|
1963
|
+
async readByScopesIncludingMerged(scopes) {
|
|
1964
|
+
const table = this.requireTable();
|
|
1965
|
+
if (scopes.length === 0)
|
|
1966
|
+
return [];
|
|
1967
|
+
const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
1968
|
+
const rows = await table
|
|
1969
|
+
.query()
|
|
1970
|
+
.where(`(${whereExpr})`)
|
|
1971
|
+
.select([
|
|
1972
|
+
"id",
|
|
1973
|
+
"text",
|
|
1974
|
+
"vector",
|
|
1975
|
+
"category",
|
|
1976
|
+
"scope",
|
|
1977
|
+
"importance",
|
|
1978
|
+
"timestamp",
|
|
1979
|
+
"lastRecalled",
|
|
1980
|
+
"recallCount",
|
|
1981
|
+
"projectCount",
|
|
1982
|
+
"schemaVersion",
|
|
1983
|
+
"embeddingModel",
|
|
1984
|
+
"vectorDim",
|
|
1985
|
+
"metadataJson",
|
|
1986
|
+
"userId",
|
|
1987
|
+
"teamId",
|
|
1988
|
+
"sourceSessionId",
|
|
1989
|
+
"confidence",
|
|
1990
|
+
"tags",
|
|
1991
|
+
"status",
|
|
1992
|
+
"parentId",
|
|
1993
|
+
"citationSource",
|
|
1994
|
+
"citationTimestamp",
|
|
1995
|
+
"citationStatus",
|
|
1996
|
+
"citationChain",
|
|
1997
|
+
])
|
|
1998
|
+
.limit(100000)
|
|
1999
|
+
.toArray();
|
|
2000
|
+
return rows
|
|
2001
|
+
.map((row) => normalizeRow(row))
|
|
2002
|
+
.filter((row) => row !== null);
|
|
2003
|
+
}
|
|
2004
|
+
// GRAPH_STORE_PHASE2B: fetch full records for a small id set (graph
|
|
2005
|
+
// expansion candidates) — same normalization + status filtering as
|
|
2006
|
+
// readByScopes, but the query is bounded by id instead of scanning the
|
|
2007
|
+
// whole scope, so a recall never pays for a full-scope read.
|
|
2008
|
+
async findRecordsByIds(ids, scopes) {
|
|
2009
|
+
const unique = ids && ids.length > 0 ? [...new Set(ids)] : [];
|
|
2010
|
+
if (unique.length === 0 || !scopes || scopes.length === 0)
|
|
2011
|
+
return [];
|
|
2012
|
+
const table = this.requireTable();
|
|
2013
|
+
const idExpr = unique.map((id) => `id = '${escapeSql(id)}'`).join(" OR ");
|
|
2014
|
+
const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
2015
|
+
const rows = await table
|
|
2016
|
+
.query()
|
|
2017
|
+
.where(`(${idExpr}) AND (${whereExpr}) AND (status != 'disabled' OR status IS NULL OR status = '') AND NOT (status = 'merged') AND NOT (status = 'digested') AND NOT (metadataJson LIKE '%"status":"merged"%')`)
|
|
2018
|
+
.select([
|
|
2019
|
+
"id",
|
|
2020
|
+
"text",
|
|
2021
|
+
"vector",
|
|
2022
|
+
"category",
|
|
2023
|
+
"scope",
|
|
2024
|
+
"importance",
|
|
2025
|
+
"timestamp",
|
|
2026
|
+
"lastRecalled",
|
|
2027
|
+
"recallCount",
|
|
2028
|
+
"projectCount",
|
|
2029
|
+
"schemaVersion",
|
|
2030
|
+
"embeddingModel",
|
|
2031
|
+
"vectorDim",
|
|
2032
|
+
"metadataJson",
|
|
2033
|
+
"userId",
|
|
2034
|
+
"teamId",
|
|
2035
|
+
"sourceSessionId",
|
|
2036
|
+
"confidence",
|
|
2037
|
+
"tags",
|
|
2038
|
+
"status",
|
|
2039
|
+
"parentId",
|
|
2040
|
+
"citationSource",
|
|
2041
|
+
"citationTimestamp",
|
|
2042
|
+
"citationStatus",
|
|
2043
|
+
"citationChain",
|
|
2044
|
+
])
|
|
2045
|
+
.limit(unique.length)
|
|
2046
|
+
.toArray();
|
|
2047
|
+
return rows
|
|
2048
|
+
.map((row) => normalizeRow(row))
|
|
2049
|
+
.filter((row) => row !== null);
|
|
2050
|
+
}
|
|
2051
|
+
// MEMORY_LIFECYCLE_TOOLS: export/import/summarize support (0.9).
|
|
2052
|
+
// exportAllRecords — full-fidelity dump of EVERY row in the given scopes,
|
|
2053
|
+
// including disabled / merged / digested. This is the ONLY read path that
|
|
2054
|
+
// does not filter on status (a backup must capture everything).
|
|
2055
|
+
async exportAllRecords(scopes) {
|
|
2056
|
+
const table = this.requireTable();
|
|
2057
|
+
if (scopes.length === 0)
|
|
2058
|
+
return [];
|
|
2059
|
+
const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
2060
|
+
const rows = await table
|
|
2061
|
+
.query()
|
|
2062
|
+
.where(`(${whereExpr})`)
|
|
2063
|
+
.select([
|
|
2064
|
+
"id",
|
|
2065
|
+
"text",
|
|
2066
|
+
"vector",
|
|
2067
|
+
"category",
|
|
2068
|
+
"scope",
|
|
2069
|
+
"importance",
|
|
2070
|
+
"timestamp",
|
|
2071
|
+
"lastRecalled",
|
|
2072
|
+
"recallCount",
|
|
2073
|
+
"projectCount",
|
|
2074
|
+
"schemaVersion",
|
|
2075
|
+
"embeddingModel",
|
|
2076
|
+
"vectorDim",
|
|
2077
|
+
"metadataJson",
|
|
2078
|
+
"userId",
|
|
2079
|
+
"teamId",
|
|
2080
|
+
"sourceSessionId",
|
|
2081
|
+
"confidence",
|
|
2082
|
+
"tags",
|
|
2083
|
+
"status",
|
|
2084
|
+
"parentId",
|
|
2085
|
+
"citationSource",
|
|
2086
|
+
"citationTimestamp",
|
|
2087
|
+
"citationStatus",
|
|
2088
|
+
"citationChain",
|
|
2089
|
+
])
|
|
2090
|
+
.limit(100000)
|
|
2091
|
+
.toArray();
|
|
2092
|
+
return rows
|
|
2093
|
+
.map((row) => normalizeRow(row))
|
|
2094
|
+
.filter((row) => row !== null);
|
|
2095
|
+
}
|
|
2096
|
+
// findRawRecordsByIds — id-bounded fetch with NO status filtering (raw
|
|
2097
|
+
// rows, used by markDigested / import-replace, which must see rows that
|
|
2098
|
+
// the filtered read paths hide).
|
|
2099
|
+
async findRawRecordsByIds(ids, scopes) {
|
|
2100
|
+
const unique = ids && ids.length > 0 ? [...new Set(ids)] : [];
|
|
2101
|
+
if (unique.length === 0 || !scopes || scopes.length === 0)
|
|
2102
|
+
return [];
|
|
2103
|
+
const table = this.requireTable();
|
|
2104
|
+
const idExpr = unique.map((id) => `id = '${escapeSql(id)}'`).join(" OR ");
|
|
2105
|
+
const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
2106
|
+
const rows = await table
|
|
2107
|
+
.query()
|
|
2108
|
+
.where(`(${idExpr}) AND (${whereExpr})`)
|
|
2109
|
+
.select([
|
|
2110
|
+
"id",
|
|
2111
|
+
"text",
|
|
2112
|
+
"vector",
|
|
2113
|
+
"category",
|
|
2114
|
+
"scope",
|
|
2115
|
+
"importance",
|
|
2116
|
+
"timestamp",
|
|
2117
|
+
"lastRecalled",
|
|
2118
|
+
"recallCount",
|
|
2119
|
+
"projectCount",
|
|
2120
|
+
"schemaVersion",
|
|
2121
|
+
"embeddingModel",
|
|
2122
|
+
"vectorDim",
|
|
2123
|
+
"metadataJson",
|
|
2124
|
+
"userId",
|
|
2125
|
+
"teamId",
|
|
2126
|
+
"sourceSessionId",
|
|
2127
|
+
"confidence",
|
|
2128
|
+
"tags",
|
|
2129
|
+
"status",
|
|
2130
|
+
"parentId",
|
|
2131
|
+
"citationSource",
|
|
2132
|
+
"citationTimestamp",
|
|
2133
|
+
"citationStatus",
|
|
2134
|
+
"citationChain",
|
|
2135
|
+
])
|
|
2136
|
+
.limit(unique.length)
|
|
2137
|
+
.toArray();
|
|
2138
|
+
return rows
|
|
2139
|
+
.map((row) => normalizeRow(row))
|
|
2140
|
+
.filter((row) => row !== null);
|
|
2141
|
+
}
|
|
2142
|
+
// markDigested — flip N memories to status "digested" (hidden from recall)
|
|
2143
|
+
// and stamp metadata with which digest absorbed them. Atomic per-row
|
|
2144
|
+
// table.update, matches the ATOMIC_UPDATE_MEMORY_USAGE pattern.
|
|
2145
|
+
async markDigested(ids, digestId, scopes) {
|
|
2146
|
+
const unique = ids && ids.length > 0 ? [...new Set(ids)] : [];
|
|
2147
|
+
if (unique.length === 0 || !digestId || !scopes || scopes.length === 0)
|
|
2148
|
+
return 0;
|
|
2149
|
+
const existing = await this.findRawRecordsByIds(unique, scopes);
|
|
2150
|
+
let updated = 0;
|
|
2151
|
+
for (const record of existing) {
|
|
2152
|
+
let metadata = {};
|
|
2153
|
+
try {
|
|
2154
|
+
metadata = JSON.parse(record.metadataJson || "{}");
|
|
2155
|
+
}
|
|
2156
|
+
catch {
|
|
2157
|
+
metadata = {};
|
|
2158
|
+
}
|
|
2159
|
+
metadata.digestedInto = digestId;
|
|
2160
|
+
metadata.digestedAt = Date.now();
|
|
2161
|
+
await this.requireTable().update({
|
|
2162
|
+
where: `id = '${escapeSql(record.id)}'`,
|
|
2163
|
+
values: { status: "digested", metadataJson: JSON.stringify(metadata) },
|
|
2164
|
+
});
|
|
2165
|
+
this.invalidateScope(record.scope);
|
|
2166
|
+
updated += 1;
|
|
2167
|
+
this.notifyGraphRemoved(record.id);
|
|
2168
|
+
}
|
|
2169
|
+
return updated;
|
|
2170
|
+
}
|
|
2171
|
+
async readByScopes(scopes) {
|
|
2172
|
+
const table = this.requireTable();
|
|
2173
|
+
if (scopes.length === 0)
|
|
2174
|
+
return [];
|
|
2175
|
+
const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR ");
|
|
2176
|
+
const rows = await table
|
|
2177
|
+
.query()
|
|
2178
|
+
.where(`(${whereExpr}) AND (status != 'disabled' OR status IS NULL OR status = '') AND NOT (status = 'merged') AND NOT (status = 'digested') AND NOT (metadataJson LIKE '%"status":"merged"%')`)
|
|
2179
|
+
.select([
|
|
2180
|
+
"id",
|
|
2181
|
+
"text",
|
|
2182
|
+
"vector",
|
|
2183
|
+
"category",
|
|
2184
|
+
"scope",
|
|
2185
|
+
"importance",
|
|
2186
|
+
"timestamp",
|
|
2187
|
+
"lastRecalled",
|
|
2188
|
+
"recallCount",
|
|
2189
|
+
"projectCount",
|
|
2190
|
+
"schemaVersion",
|
|
2191
|
+
"embeddingModel",
|
|
2192
|
+
"vectorDim",
|
|
2193
|
+
"metadataJson",
|
|
2194
|
+
"userId",
|
|
2195
|
+
"teamId",
|
|
2196
|
+
"sourceSessionId",
|
|
2197
|
+
"confidence",
|
|
2198
|
+
"tags",
|
|
2199
|
+
"status",
|
|
2200
|
+
"parentId",
|
|
2201
|
+
"citationSource",
|
|
2202
|
+
"citationTimestamp",
|
|
2203
|
+
"citationStatus",
|
|
2204
|
+
"citationChain",
|
|
2205
|
+
])
|
|
2206
|
+
.limit(100000)
|
|
2207
|
+
.toArray();
|
|
2208
|
+
return rows
|
|
2209
|
+
.map((row) => normalizeRow(row))
|
|
2210
|
+
.filter((row) => row !== null);
|
|
2211
|
+
}
|
|
2212
|
+
async ensureIndexes() {
|
|
2213
|
+
const table = this.requireTable();
|
|
2214
|
+
// INDEX_USAGE_FIX (1.1.7): the FTS "text" index was created here but
|
|
2215
|
+
// NO code path ever issued an ftsSearch()/fullTextSearch() against it
|
|
2216
|
+
// (all search runs in-memory over the scope cache), so it was pure
|
|
2217
|
+
// build + per-write rebuild overhead. Removed. The vector index IS
|
|
2218
|
+
// consumed by findSimilarVectors (consolidation ANN), so it stays.
|
|
2219
|
+
await this.createVectorIndexWithRetry(table);
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Returns true if the error message indicates a LanceDB retryable commit conflict,
|
|
2223
|
+
* meaning another concurrent process may have already created the same index.
|
|
2224
|
+
*/
|
|
2225
|
+
isCommitConflict(errorMsg) {
|
|
2226
|
+
return (errorMsg.includes("Retryable commit conflict") ||
|
|
2227
|
+
errorMsg.includes("preempted by concurrent transaction"));
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* Create vector index with exponential backoff retry and existence check.
|
|
2231
|
+
* Handles concurrent-process commit conflicts by re-verifying index existence
|
|
2232
|
+
* after each conflict error, and adds jitter to avoid thundering-herd re-collision.
|
|
2233
|
+
*/
|
|
2234
|
+
async createVectorIndexWithRetry(table) {
|
|
2235
|
+
const maxRetries = 3;
|
|
2236
|
+
const baseDelay = 500;
|
|
2237
|
+
const existingIndices = await table.listIndices();
|
|
2238
|
+
if (existingIndices.some(idx => idx.name.startsWith("vector"))) {
|
|
2239
|
+
log("info", "[store] Vector index already exists, skipping creation");
|
|
2240
|
+
this.indexState.vector = true;
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
const rowCount = await table.countRows();
|
|
2244
|
+
if (rowCount < MemoryStore.MIN_ROWS_FOR_INDEX) {
|
|
2245
|
+
log("debug", `[store] Vector index deferral (below ${MemoryStore.MIN_ROWS_FOR_INDEX}): ${rowCount} rows`);
|
|
2246
|
+
log("info", `[store] Deferring vector index creation: ${rowCount} rows found (need ≥ ${MemoryStore.MIN_ROWS_FOR_INDEX})`);
|
|
2247
|
+
this.indexState.vector = false;
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
log("debug", `[store] Vector index creation eligible: ${rowCount} rows (min ${MemoryStore.MIN_ROWS_FOR_INDEX})`);
|
|
2251
|
+
let lastErrorMsg = "";
|
|
2252
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
2253
|
+
this.indexState.vectorRetries = attempt + 1;
|
|
2254
|
+
const attemptStart = Date.now();
|
|
2255
|
+
try {
|
|
2256
|
+
// Scale IVF partitions to dataset size (sqrt heuristic). LanceDB's
|
|
2257
|
+
// 256-partition default trains 256-way KMeans even on tiny stores,
|
|
2258
|
+
// producing "more than 10% of clusters are empty" warnings.
|
|
2259
|
+
const numPartitions = Math.min(256, Math.max(4, Math.pow(2, Math.round(Math.log2(Math.sqrt(rowCount))))));
|
|
2260
|
+
if (this.lancedb && "Index" in this.lancedb) {
|
|
2261
|
+
const anyLance = this.lancedb;
|
|
2262
|
+
const cfg = anyLance.Index?.ivfPq
|
|
2263
|
+
? { config: anyLance.Index.ivfPq({ numPartitions }) }
|
|
2264
|
+
: undefined;
|
|
2265
|
+
await table.createIndex("vector", cfg);
|
|
2266
|
+
}
|
|
2267
|
+
else {
|
|
2268
|
+
await table.createIndex("vector");
|
|
2269
|
+
}
|
|
2270
|
+
log("debug", `[store] Vector index createIndex returned on attempt ${attempt + 1} in ${Date.now() - attemptStart}ms (${numPartitions} partitions)`);
|
|
2271
|
+
log("info", `[store] Vector index created successfully on attempt ${attempt + 1}`);
|
|
2272
|
+
this.indexState.vector = true;
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
catch (error) {
|
|
2276
|
+
lastErrorMsg = error instanceof Error ? error.message : String(error);
|
|
2277
|
+
log("debug", `[store] Vector index createIndex attempt ${attempt + 1}/${maxRetries} failed after ${Date.now() - attemptStart}ms: ${lastErrorMsg}`);
|
|
2278
|
+
// Commit conflict: another process may have just created the index — re-verify.
|
|
2279
|
+
if (this.isCommitConflict(lastErrorMsg)) {
|
|
2280
|
+
const updatedIndices = await table.listIndices();
|
|
2281
|
+
if (updatedIndices.some(idx => idx.name.startsWith("vector"))) {
|
|
2282
|
+
log("info", `[store] Vector index created by concurrent process, adopting it (attempt ${attempt + 1})`);
|
|
2283
|
+
this.indexState.vector = true;
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
if (attempt < maxRetries - 1) {
|
|
2288
|
+
// Jitter prevents thundering-herd re-collision among concurrent processes.
|
|
2289
|
+
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * baseDelay;
|
|
2290
|
+
log("debug", `[store] Vector index retry ${attempt + 1}/${maxRetries} scheduled in ${Math.round(delay)}ms`);
|
|
2291
|
+
log("warn", `[store] Vector index creation failed (attempt ${attempt + 1}/${maxRetries}): ${lastErrorMsg}. Retrying in ${Math.round(delay)}ms...`);
|
|
2292
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
// Final-pass existence check: the last retry's conflict may have caused another
|
|
2297
|
+
// process to succeed even though our own call threw.
|
|
2298
|
+
const finalIndices = await table.listIndices();
|
|
2299
|
+
if (finalIndices.some(idx => idx.name.startsWith("vector"))) {
|
|
2300
|
+
log("info", "[store] Vector index found on final check (created by concurrent process), adopting it");
|
|
2301
|
+
this.indexState.vector = true;
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
log("error", `[store] Vector index creation failed after ${maxRetries} attempts: ${lastErrorMsg}. Falling back to in-memory search.`);
|
|
2305
|
+
this.indexState.vector = false;
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* FTS index creation was removed in 1.1.7 (INDEX_USAGE_FIX): nothing in
|
|
2309
|
+
* the codebase ever ran an ftsSearch()/fullTextSearch(), so the "text"
|
|
2310
|
+
* index was dead weight (build cost + per-write index maintenance).
|
|
2311
|
+
* indexState.fts fields are retained for getIndexHealth() consumers.
|
|
2312
|
+
*/
|
|
2313
|
+
async ensureMemoriesTableCompatibility() {
|
|
2314
|
+
const table = this.requireTable();
|
|
2315
|
+
const schema = await table.schema();
|
|
2316
|
+
const fieldNames = new Set(schema.fields.map((field) => field.name));
|
|
2317
|
+
const missing = [];
|
|
2318
|
+
if (!fieldNames.has("lastRecalled")) {
|
|
2319
|
+
missing.push({ name: "lastRecalled", valueSql: "CAST(0 AS BIGINT)" });
|
|
2320
|
+
}
|
|
2321
|
+
if (!fieldNames.has("recallCount")) {
|
|
2322
|
+
missing.push({ name: "recallCount", valueSql: "CAST(0 AS INT)" });
|
|
2323
|
+
}
|
|
2324
|
+
if (!fieldNames.has("projectCount")) {
|
|
2325
|
+
missing.push({ name: "projectCount", valueSql: "CAST(0 AS INT)" });
|
|
2326
|
+
}
|
|
2327
|
+
if (!fieldNames.has("userId")) {
|
|
2328
|
+
missing.push({ name: "userId", valueSql: "CAST(NULL AS STRING)" });
|
|
2329
|
+
}
|
|
2330
|
+
if (!fieldNames.has("teamId")) {
|
|
2331
|
+
missing.push({ name: "teamId", valueSql: "CAST(NULL AS STRING)" });
|
|
2332
|
+
}
|
|
2333
|
+
if (!fieldNames.has("sourceSessionId")) {
|
|
2334
|
+
missing.push({ name: "sourceSessionId", valueSql: "CAST(NULL AS STRING)" });
|
|
2335
|
+
}
|
|
2336
|
+
if (!fieldNames.has("confidence")) {
|
|
2337
|
+
missing.push({ name: "confidence", valueSql: "CAST(NULL AS DOUBLE)" });
|
|
2338
|
+
}
|
|
2339
|
+
if (!fieldNames.has("tags")) {
|
|
2340
|
+
missing.push({ name: "tags", valueSql: "CAST(NULL AS STRING)" });
|
|
2341
|
+
}
|
|
2342
|
+
if (!fieldNames.has("status")) {
|
|
2343
|
+
missing.push({ name: "status", valueSql: "CAST('active' AS STRING)" });
|
|
2344
|
+
}
|
|
2345
|
+
if (!fieldNames.has("parentId")) {
|
|
2346
|
+
missing.push({ name: "parentId", valueSql: "CAST(NULL AS STRING)" });
|
|
2347
|
+
}
|
|
2348
|
+
if (!fieldNames.has("citationSource")) {
|
|
2349
|
+
missing.push({ name: "citationSource", valueSql: "CAST(NULL AS STRING)" });
|
|
2350
|
+
}
|
|
2351
|
+
if (!fieldNames.has("citationTimestamp")) {
|
|
2352
|
+
missing.push({ name: "citationTimestamp", valueSql: "CAST(NULL AS BIGINT)" });
|
|
2353
|
+
}
|
|
2354
|
+
if (!fieldNames.has("citationStatus")) {
|
|
2355
|
+
missing.push({ name: "citationStatus", valueSql: "CAST(NULL AS STRING)" });
|
|
2356
|
+
}
|
|
2357
|
+
if (!fieldNames.has("citationChain")) {
|
|
2358
|
+
missing.push({ name: "citationChain", valueSql: "CAST(NULL AS STRING)" });
|
|
2359
|
+
}
|
|
2360
|
+
if (missing.length === 0) {
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
try {
|
|
2364
|
+
await table.addColumns(missing);
|
|
2365
|
+
}
|
|
2366
|
+
catch (error) {
|
|
2367
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2368
|
+
const names = missing.map((col) => col.name).join(", ");
|
|
2369
|
+
throw new Error(`Failed to patch ${TABLE_NAME} schema for columns [${names}]: ${reason}`);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
async ensureEventTableCompatibility() {
|
|
2373
|
+
const table = this.requireEventTable();
|
|
2374
|
+
const schema = await table.schema();
|
|
2375
|
+
const fieldNames = new Set(schema.fields.map((field) => field.name));
|
|
2376
|
+
const missing = [];
|
|
2377
|
+
if (!fieldNames.has(EVENTS_SOURCE_COLUMN)) {
|
|
2378
|
+
missing.push({ name: EVENTS_SOURCE_COLUMN, valueSql: "CAST(NULL AS STRING)" });
|
|
2379
|
+
}
|
|
2380
|
+
if (!fieldNames.has("sourceSessionId")) {
|
|
2381
|
+
missing.push({ name: "sourceSessionId", valueSql: "CAST(NULL AS STRING)" });
|
|
2382
|
+
}
|
|
2383
|
+
if (!fieldNames.has("confidenceDelta")) {
|
|
2384
|
+
missing.push({ name: "confidenceDelta", valueSql: "CAST(NULL AS DOUBLE)" });
|
|
2385
|
+
}
|
|
2386
|
+
if (!fieldNames.has("relatedMemoryId")) {
|
|
2387
|
+
missing.push({ name: "relatedMemoryId", valueSql: "CAST(NULL AS STRING)" });
|
|
2388
|
+
}
|
|
2389
|
+
if (!fieldNames.has("context")) {
|
|
2390
|
+
missing.push({ name: "context", valueSql: "CAST(NULL AS STRING)" });
|
|
2391
|
+
}
|
|
2392
|
+
if (missing.length === 0) {
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
try {
|
|
2396
|
+
await table.addColumns(missing);
|
|
2397
|
+
}
|
|
2398
|
+
catch (error) {
|
|
2399
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2400
|
+
const names = missing.map((col) => col.name).join(", ");
|
|
2401
|
+
throw new Error(`Failed to patch ${EVENTS_TABLE_NAME} schema for columns [${names}]: ${reason}`);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
function normalizeRow(row) {
|
|
2406
|
+
const vectorRaw = row.vector;
|
|
2407
|
+
const vector = Array.isArray(vectorRaw) ? vectorRaw.map((item) => Number(item)) : Array.from((vectorRaw ?? []));
|
|
2408
|
+
if (typeof row.id !== "string" || typeof row.text !== "string" || typeof row.scope !== "string") {
|
|
2409
|
+
return null;
|
|
2410
|
+
}
|
|
2411
|
+
const tagsRaw = row.tags;
|
|
2412
|
+
const parsedTags = typeof tagsRaw === "string" && tagsRaw.length > 0
|
|
2413
|
+
? JSON.parse(tagsRaw)
|
|
2414
|
+
: Array.isArray(tagsRaw)
|
|
2415
|
+
? tagsRaw
|
|
2416
|
+
: undefined;
|
|
2417
|
+
return {
|
|
2418
|
+
id: row.id,
|
|
2419
|
+
text: row.text,
|
|
2420
|
+
vector,
|
|
2421
|
+
category: row.category ?? "other",
|
|
2422
|
+
scope: row.scope,
|
|
2423
|
+
importance: Number(row.importance ?? 0.5),
|
|
2424
|
+
timestamp: Number(row.timestamp ?? Date.now()),
|
|
2425
|
+
lastRecalled: Number(row.lastRecalled ?? 0),
|
|
2426
|
+
recallCount: Number(row.recallCount ?? 0),
|
|
2427
|
+
projectCount: Number(row.projectCount ?? 0),
|
|
2428
|
+
schemaVersion: Number(row.schemaVersion ?? 1),
|
|
2429
|
+
embeddingModel: String(row.embeddingModel ?? "unknown"),
|
|
2430
|
+
vectorDim: Number(row.vectorDim ?? vector.length),
|
|
2431
|
+
metadataJson: String(row.metadataJson ?? "{}"),
|
|
2432
|
+
userId: typeof row.userId === "string" && row.userId.length > 0 ? row.userId : undefined,
|
|
2433
|
+
teamId: typeof row.teamId === "string" && row.teamId.length > 0 ? row.teamId : undefined,
|
|
2434
|
+
sourceSessionId: typeof row.sourceSessionId === "string" && row.sourceSessionId.length > 0 ? row.sourceSessionId : undefined,
|
|
2435
|
+
confidence: typeof row.confidence === "number" ? row.confidence : undefined,
|
|
2436
|
+
tags: parsedTags,
|
|
2437
|
+
status: row.status ?? "active",
|
|
2438
|
+
parentId: typeof row.parentId === "string" && row.parentId.length > 0 ? row.parentId : undefined,
|
|
2439
|
+
citationSource: typeof row.citationSource === "string" && row.citationSource.length > 0 ? row.citationSource : undefined,
|
|
2440
|
+
citationTimestamp: typeof row.citationTimestamp === "number" ? row.citationTimestamp : undefined,
|
|
2441
|
+
citationStatus: typeof row.citationStatus === "string" && row.citationStatus.length > 0 ? row.citationStatus : undefined,
|
|
2442
|
+
citationChain: (() => {
|
|
2443
|
+
if (!row.citationChain)
|
|
2444
|
+
return undefined;
|
|
2445
|
+
if (Array.isArray(row.citationChain))
|
|
2446
|
+
return row.citationChain;
|
|
2447
|
+
if (typeof row.citationChain === "string" && row.citationChain.length > 0) {
|
|
2448
|
+
try {
|
|
2449
|
+
return JSON.parse(row.citationChain);
|
|
2450
|
+
}
|
|
2451
|
+
catch {
|
|
2452
|
+
return undefined;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
return undefined;
|
|
2456
|
+
})(),
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
function normalizeEventRow(row) {
|
|
2460
|
+
if (typeof row.id !== "string" || typeof row.type !== "string" || typeof row.scope !== "string") {
|
|
2461
|
+
return null;
|
|
2462
|
+
}
|
|
2463
|
+
const base = {
|
|
2464
|
+
id: row.id,
|
|
2465
|
+
scope: row.scope,
|
|
2466
|
+
sessionID: typeof row.sessionID === "string" && row.sessionID.length > 0 ? row.sessionID : undefined,
|
|
2467
|
+
timestamp: Number(row.timestamp ?? Date.now()),
|
|
2468
|
+
memoryId: typeof row.memoryId === "string" && row.memoryId.length > 0 ? row.memoryId : undefined,
|
|
2469
|
+
text: typeof row.text === "string" && row.text.length > 0 ? row.text : undefined,
|
|
2470
|
+
metadataJson: String(row.metadataJson ?? "{}"),
|
|
2471
|
+
};
|
|
2472
|
+
if (row.type === "capture") {
|
|
2473
|
+
return {
|
|
2474
|
+
...base,
|
|
2475
|
+
type: "capture",
|
|
2476
|
+
outcome: row.outcome === "stored" || row.outcome === "skipped" ? row.outcome : "considered",
|
|
2477
|
+
skipReason: typeof row.skipReason === "string" && row.skipReason.length > 0
|
|
2478
|
+
? row.skipReason
|
|
2479
|
+
: undefined,
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
if (row.type === "recall") {
|
|
2483
|
+
const sourceRaw = typeof row.source === "string" && row.source.length > 0 ? row.source : "system-transform";
|
|
2484
|
+
const source = sourceRaw === "manual-search" ? "manual-search" : "system-transform";
|
|
2485
|
+
return {
|
|
2486
|
+
...base,
|
|
2487
|
+
type: "recall",
|
|
2488
|
+
resultCount: Number(row.resultCount ?? 0),
|
|
2489
|
+
injected: Boolean(row.injected),
|
|
2490
|
+
source,
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
if (row.type === "feedback") {
|
|
2494
|
+
const labelsJson = typeof row.labelsJson === "string" ? row.labelsJson : "[]";
|
|
2495
|
+
const labels = JSON.parse(labelsJson);
|
|
2496
|
+
const helpfulValue = Number(row.helpful ?? -1);
|
|
2497
|
+
const contextRaw = row.context;
|
|
2498
|
+
const parsedContext = typeof contextRaw === "string" && contextRaw.length > 0
|
|
2499
|
+
? JSON.parse(contextRaw)
|
|
2500
|
+
: undefined;
|
|
2501
|
+
return {
|
|
2502
|
+
...base,
|
|
2503
|
+
type: "feedback",
|
|
2504
|
+
feedbackType: row.feedbackType === "missing" || row.feedbackType === "wrong" ? row.feedbackType : "useful",
|
|
2505
|
+
helpful: helpfulValue < 0 ? undefined : helpfulValue === 1,
|
|
2506
|
+
labels: Array.isArray(labels) ? labels.filter((item) => typeof item === "string") : [],
|
|
2507
|
+
reason: typeof row.reason === "string" && row.reason.length > 0 ? row.reason : undefined,
|
|
2508
|
+
sourceSessionId: typeof row.sourceSessionId === "string" && row.sourceSessionId.length > 0 ? row.sourceSessionId : undefined,
|
|
2509
|
+
confidenceDelta: typeof row.confidenceDelta === "number" ? row.confidenceDelta : undefined,
|
|
2510
|
+
relatedMemoryId: typeof row.relatedMemoryId === "string" && row.relatedMemoryId.length > 0 ? row.relatedMemoryId : undefined,
|
|
2511
|
+
context: parsedContext,
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
return null;
|
|
2515
|
+
}
|
|
2516
|
+
function escapeSql(value) {
|
|
2517
|
+
return value.replace(/'/g, "''");
|
|
2518
|
+
}
|
|
2519
|
+
function buildRankMap(items, scoreOf) {
|
|
2520
|
+
const ranked = [...items].sort((a, b) => scoreOf(b) - scoreOf(a));
|
|
2521
|
+
const ranks = new Map();
|
|
2522
|
+
for (let i = 0; i < ranked.length; i += 1) {
|
|
2523
|
+
ranks.set(ranked[i].record.id, i + 1);
|
|
2524
|
+
}
|
|
2525
|
+
return ranks;
|
|
2526
|
+
}
|
|
2527
|
+
function normalizeChannelWeights(vectorWeight, bm25Weight) {
|
|
2528
|
+
const sum = vectorWeight + bm25Weight;
|
|
2529
|
+
if (sum <= 0) {
|
|
2530
|
+
return { vectorWeight: 0.5, bm25Weight: 0.5 };
|
|
2531
|
+
}
|
|
2532
|
+
return {
|
|
2533
|
+
vectorWeight: vectorWeight / sum,
|
|
2534
|
+
bm25Weight: bm25Weight / sum,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
function computeRecencyMultiplier(timestamp, halfLifeHours) {
|
|
2538
|
+
const now = Date.now();
|
|
2539
|
+
const ageMs = Math.max(0, now - timestamp);
|
|
2540
|
+
const ageHours = ageMs / 3_600_000;
|
|
2541
|
+
if (ageHours === 0)
|
|
2542
|
+
return 1;
|
|
2543
|
+
const decay = Math.pow(0.5, ageHours / halfLifeHours);
|
|
2544
|
+
return 0.5 + 0.5 * decay;
|
|
2545
|
+
}
|
|
2546
|
+
function clampImportance(value) {
|
|
2547
|
+
if (!Number.isFinite(value))
|
|
2548
|
+
return 0;
|
|
2549
|
+
return Math.max(0, Math.min(1, value));
|
|
2550
|
+
}
|
|
2551
|
+
function clampImportanceWeight(value) {
|
|
2552
|
+
if (!Number.isFinite(value))
|
|
2553
|
+
return 0.4;
|
|
2554
|
+
return Math.max(0, Math.min(2, value));
|
|
2555
|
+
}
|
|
2556
|
+
function computeIdf(docs) {
|
|
2557
|
+
const df = new Map();
|
|
2558
|
+
for (const doc of docs) {
|
|
2559
|
+
const seen = new Set(doc);
|
|
2560
|
+
for (const token of seen) {
|
|
2561
|
+
df.set(token, (df.get(token) ?? 0) + 1);
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
const totalDocs = Math.max(1, docs.length);
|
|
2565
|
+
const idf = new Map();
|
|
2566
|
+
for (const [token, count] of df.entries()) {
|
|
2567
|
+
idf.set(token, Math.log(1 + (totalDocs - count + 0.5) / (count + 0.5)));
|
|
2568
|
+
}
|
|
2569
|
+
return idf;
|
|
2570
|
+
}
|
|
2571
|
+
function vecNorm(v) {
|
|
2572
|
+
let sum = 0;
|
|
2573
|
+
for (let i = 0; i < v.length; i += 1) {
|
|
2574
|
+
sum += v[i] * v[i];
|
|
2575
|
+
}
|
|
2576
|
+
return Math.sqrt(sum);
|
|
2577
|
+
}
|
|
2578
|
+
function fastCosine(a, b, normA, normB) {
|
|
2579
|
+
if (a.length === 0 || b.length === 0 || a.length !== b.length)
|
|
2580
|
+
return 0;
|
|
2581
|
+
const denom = normA * normB;
|
|
2582
|
+
if (denom === 0)
|
|
2583
|
+
return 0;
|
|
2584
|
+
let dot = 0;
|
|
2585
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
2586
|
+
dot += a[i] * b[i];
|
|
2587
|
+
}
|
|
2588
|
+
return dot / denom;
|
|
2589
|
+
}
|
|
2590
|
+
function bm25LikeScore(query, doc, idf) {
|
|
2591
|
+
if (query.length === 0 || doc.length === 0)
|
|
2592
|
+
return 0;
|
|
2593
|
+
const tf = new Map();
|
|
2594
|
+
for (const token of doc) {
|
|
2595
|
+
tf.set(token, (tf.get(token) ?? 0) + 1);
|
|
2596
|
+
}
|
|
2597
|
+
const avgDocLen = 120;
|
|
2598
|
+
const k1 = 1.2;
|
|
2599
|
+
const b = 0.75;
|
|
2600
|
+
let score = 0;
|
|
2601
|
+
for (const token of query) {
|
|
2602
|
+
const freq = tf.get(token) ?? 0;
|
|
2603
|
+
if (freq === 0)
|
|
2604
|
+
continue;
|
|
2605
|
+
const tokenIdf = idf.get(token) ?? 0.1;
|
|
2606
|
+
const numerator = freq * (k1 + 1);
|
|
2607
|
+
const denominator = freq + k1 * (1 - b + (b * doc.length) / avgDocLen);
|
|
2608
|
+
score += tokenIdf * (numerator / denominator);
|
|
2609
|
+
}
|
|
2610
|
+
return 1 - Math.exp(-score);
|
|
2611
|
+
}
|
|
2612
|
+
function extractRecalledProjects(metadataJson) {
|
|
2613
|
+
try {
|
|
2614
|
+
const metadata = JSON.parse(metadataJson);
|
|
2615
|
+
if (metadata && Array.isArray(metadata.recalledProjects)) {
|
|
2616
|
+
return new Set(metadata.recalledProjects);
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
catch {
|
|
2620
|
+
// ignore parse errors
|
|
2621
|
+
}
|
|
2622
|
+
return new Set();
|
|
2623
|
+
}
|
|
2624
|
+
function parseMetadata(metadataJson) {
|
|
2625
|
+
try {
|
|
2626
|
+
return JSON.parse(metadataJson);
|
|
2627
|
+
}
|
|
2628
|
+
catch {
|
|
2629
|
+
return {};
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
// MEMORY_LIFECYCLE_TOOLS: offline extractive summarization for store-level
|
|
2633
|
+
// digests (no LLM — sentence scoring by entity density / length / position).
|
|
2634
|
+
// Returns { text, sentenceCount, sourceCount } or null when no usable text.
|
|
2635
|
+
export function extractiveDigest(texts, targetChars = 500, entityNames = [], sourceLabel = "") {
|
|
2636
|
+
const safeTexts = Array.isArray(texts)
|
|
2637
|
+
? texts.filter((t) => typeof t === "string" && t.trim().length > 0)
|
|
2638
|
+
: [];
|
|
2639
|
+
if (safeTexts.length === 0)
|
|
2640
|
+
return null;
|
|
2641
|
+
const entitySet = new Set(Array.isArray(entityNames) ? entityNames : []);
|
|
2642
|
+
const sentences = [];
|
|
2643
|
+
for (let textIndex = 0; textIndex < safeTexts.length; textIndex += 1) {
|
|
2644
|
+
const parts = safeTexts[textIndex]
|
|
2645
|
+
.split(/(?<=[.!?])\s+/)
|
|
2646
|
+
.map((p) => p.trim())
|
|
2647
|
+
.filter((p) => p.length > 0);
|
|
2648
|
+
const isFirstText = textIndex === 0;
|
|
2649
|
+
const isLastText = textIndex === safeTexts.length - 1;
|
|
2650
|
+
for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
|
|
2651
|
+
const part = parts[partIndex];
|
|
2652
|
+
const words = part.split(/\s+/).filter(Boolean);
|
|
2653
|
+
const wordCount = words.length;
|
|
2654
|
+
if (wordCount < 4 || wordCount > 60)
|
|
2655
|
+
continue;
|
|
2656
|
+
let score = 1;
|
|
2657
|
+
if (isFirstText && partIndex === 0)
|
|
2658
|
+
score += 1.5;
|
|
2659
|
+
if (isLastText && partIndex === parts.length - 1)
|
|
2660
|
+
score += 1.2;
|
|
2661
|
+
if (partIndex === 0)
|
|
2662
|
+
score += 0.8;
|
|
2663
|
+
for (const w of words) {
|
|
2664
|
+
if (entitySet.has(w.toLowerCase()) || entitySet.has(w.replace(/[^a-z0-9._-]/gi, "").toLowerCase()))
|
|
2665
|
+
score += 0.5;
|
|
2666
|
+
}
|
|
2667
|
+
for (const keyword of ["config", "fix", "bug", "shipped", "deploy", "restart", "works", "failed", "install", "default", "memory", "graph", "plugin"]) {
|
|
2668
|
+
if (part.toLowerCase().includes(keyword))
|
|
2669
|
+
score += 0.15;
|
|
2670
|
+
}
|
|
2671
|
+
sentences.push({ text: part, score });
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
sentences.sort((a, b) => b.score - a.score);
|
|
2675
|
+
const header = `SUMMARY${sourceLabel ? ` (${sourceLabel})` : ""} — ${safeTexts.length} memories`;
|
|
2676
|
+
const headerPart = `${header}\n`;
|
|
2677
|
+
let budget = Math.max(120, targetChars - headerPart.length);
|
|
2678
|
+
const chosen = [];
|
|
2679
|
+
let used = 0;
|
|
2680
|
+
for (const sentence of sentences) {
|
|
2681
|
+
if (used + sentence.text.length + 3 > budget)
|
|
2682
|
+
continue;
|
|
2683
|
+
chosen.push(sentence.text);
|
|
2684
|
+
used += sentence.text.length + 3;
|
|
2685
|
+
if (chosen.length >= 12)
|
|
2686
|
+
break;
|
|
2687
|
+
}
|
|
2688
|
+
if (chosen.length === 0) {
|
|
2689
|
+
const fallback = safeTexts[0].slice(0, budget);
|
|
2690
|
+
return { text: `${headerPart}${fallback}`, sentenceCount: 1, sourceCount: safeTexts.length };
|
|
2691
|
+
}
|
|
2692
|
+
return { text: `${headerPart}${chosen.map((s) => `- ${s}`).join("\n")}`, sentenceCount: chosen.length, sourceCount: safeTexts.length };
|
|
2693
|
+
}
|
|
2694
|
+
// MEMORY_RETENTION (1.0): pure candidate selection for the digest-then-hide
|
|
2695
|
+
// expiry sweep. A memory is expired when ALL of:
|
|
2696
|
+
// - status is unset/"active" (never disabled/merged/digested)
|
|
2697
|
+
// - category is not protected (default: "digest") and metadataJson.pinned !== true
|
|
2698
|
+
// - importance >= minImportance
|
|
2699
|
+
// - older than minAgeDays AND unused for unusedDays, where "unused" is
|
|
2700
|
+
// measured from lastRecalled, or from timestamp when never recalled
|
|
2701
|
+
// (so junk that was never surfaced IS expirable — unlike getUnusedGlobalMemories).
|
|
2702
|
+
export function retentionCandidates(records, opts = {}) {
|
|
2703
|
+
const unusedDays = Math.max(1, Number(opts.unusedDays ?? 60));
|
|
2704
|
+
const minAgeDays = Math.max(1, Number(opts.minAgeDays ?? 180));
|
|
2705
|
+
const minImportance = Number(opts.minImportance ?? 0);
|
|
2706
|
+
const rawProtected = opts.protectedCategories;
|
|
2707
|
+
const protectedCategories = new Set(Array.isArray(rawProtected)
|
|
2708
|
+
? rawProtected.filter((c) => typeof c === "string")
|
|
2709
|
+
: (typeof rawProtected === "string" ? [rawProtected] : ["digest"]));
|
|
2710
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2711
|
+
const now = Date.now();
|
|
2712
|
+
const ageCutoff = now - minAgeDays * DAY_MS;
|
|
2713
|
+
const unusedCutoff = now - unusedDays * DAY_MS;
|
|
2714
|
+
return records.filter((r) => {
|
|
2715
|
+
if (r.status && r.status !== "active")
|
|
2716
|
+
return false;
|
|
2717
|
+
if (protectedCategories.has(r.category))
|
|
2718
|
+
return false;
|
|
2719
|
+
const timestamp = Number(r.timestamp ?? 0);
|
|
2720
|
+
if (timestamp <= 0 || timestamp > ageCutoff)
|
|
2721
|
+
return false;
|
|
2722
|
+
const importance = Number(r.importance ?? 0);
|
|
2723
|
+
if (importance < minImportance)
|
|
2724
|
+
return false;
|
|
2725
|
+
const lastRecalled = Number(r.lastRecalled ?? 0);
|
|
2726
|
+
const lastUse = lastRecalled > 0 ? lastRecalled : timestamp;
|
|
2727
|
+
if (lastUse > unusedCutoff)
|
|
2728
|
+
return false;
|
|
2729
|
+
let metadata = {};
|
|
2730
|
+
try {
|
|
2731
|
+
metadata = JSON.parse(r.metadataJson || "{}");
|
|
2732
|
+
}
|
|
2733
|
+
catch { }
|
|
2734
|
+
if (metadata.pinned === true)
|
|
2735
|
+
return false;
|
|
2736
|
+
return true;
|
|
2737
|
+
});
|
|
2738
|
+
}
|