memory-lancedb-pro 1.0.24 → 1.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +14 -1
- package/README_CN.md +14 -1
- package/index.ts +257 -88
- package/openclaw.plugin.json +27 -3
- package/package.json +1 -1
- package/src/access-tracker.ts +330 -0
- package/src/embedder.ts +32 -10
- package/src/retriever.ts +183 -68
- package/src/store.ts +207 -67
- package/src/tools.ts +339 -87
- package/test/access-tracker.test.mjs +770 -0
- package/test/cli-smoke.mjs +22 -0
package/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { createScopeManager } from "./src/scopes.js";
|
|
|
17
17
|
import { createMigrator } from "./src/migrate.js";
|
|
18
18
|
import { registerAllMemoryTools } from "./src/tools.js";
|
|
19
19
|
import { shouldSkipRetrieval } from "./src/adaptive-retrieval.js";
|
|
20
|
+
import { AccessTracker } from "./src/access-tracker.js";
|
|
20
21
|
import { createMemoryCLI } from "./cli.js";
|
|
21
22
|
|
|
22
23
|
// ============================================================================
|
|
@@ -56,6 +57,8 @@ interface PluginConfig {
|
|
|
56
57
|
lengthNormAnchor?: number;
|
|
57
58
|
hardMinScore?: number;
|
|
58
59
|
timeDecayHalfLifeDays?: number;
|
|
60
|
+
reinforcementFactor?: number;
|
|
61
|
+
maxHalfLifeMultiplier?: number;
|
|
59
62
|
};
|
|
60
63
|
scopes?: {
|
|
61
64
|
default?: string;
|
|
@@ -134,12 +137,13 @@ const CAPTURE_EXCLUDE_PATTERNS = [
|
|
|
134
137
|
/(删除|刪除|清理|清除).{0,12}(记忆|記憶|memory)/i,
|
|
135
138
|
];
|
|
136
139
|
|
|
137
|
-
|
|
138
140
|
export function shouldCapture(text: string): boolean {
|
|
139
141
|
const s = text.trim();
|
|
140
142
|
|
|
141
143
|
// CJK characters carry more meaning per character, use lower minimum threshold
|
|
142
|
-
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
|
|
144
|
+
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
|
|
145
|
+
s,
|
|
146
|
+
);
|
|
143
147
|
const minLen = hasCJK ? 4 : 10;
|
|
144
148
|
if (s.length < minLen || s.length > 500) {
|
|
145
149
|
return false;
|
|
@@ -167,18 +171,36 @@ export function shouldCapture(text: string): boolean {
|
|
|
167
171
|
return MEMORY_TRIGGERS.some((r) => r.test(s));
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
export function detectCategory(
|
|
174
|
+
export function detectCategory(
|
|
175
|
+
text: string,
|
|
176
|
+
): "preference" | "fact" | "decision" | "entity" | "other" {
|
|
171
177
|
const lower = text.toLowerCase();
|
|
172
|
-
if (
|
|
178
|
+
if (
|
|
179
|
+
/prefer|radši|like|love|hate|want|偏好|喜歡|喜欢|討厭|讨厌|不喜歡|不喜欢|愛用|爱用|習慣|习惯/i.test(
|
|
180
|
+
lower,
|
|
181
|
+
)
|
|
182
|
+
) {
|
|
173
183
|
return "preference";
|
|
174
184
|
}
|
|
175
|
-
if (
|
|
185
|
+
if (
|
|
186
|
+
/rozhodli|decided|we decided|will use|we will use|we'?ll use|switch(ed)? to|migrate(d)? to|going forward|from now on|budeme|決定|决定|選擇了|选择了|改用|換成|换成|以後用|以后用|規則|流程|SOP/i.test(
|
|
187
|
+
lower,
|
|
188
|
+
)
|
|
189
|
+
) {
|
|
176
190
|
return "decision";
|
|
177
191
|
}
|
|
178
|
-
if (
|
|
192
|
+
if (
|
|
193
|
+
/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se|我的\S+是|叫我|稱呼|称呼/i.test(
|
|
194
|
+
lower,
|
|
195
|
+
)
|
|
196
|
+
) {
|
|
179
197
|
return "entity";
|
|
180
198
|
}
|
|
181
|
-
if (
|
|
199
|
+
if (
|
|
200
|
+
/\b(is|are|has|have|je|má|jsou)\b|總是|总是|從不|从不|一直|每次都|老是/i.test(
|
|
201
|
+
lower,
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
182
204
|
return "fact";
|
|
183
205
|
}
|
|
184
206
|
return "other";
|
|
@@ -199,7 +221,10 @@ function sanitizeForContext(text: string): string {
|
|
|
199
221
|
// Session Content Reading (for session-memory hook)
|
|
200
222
|
// ============================================================================
|
|
201
223
|
|
|
202
|
-
async function readSessionMessages(
|
|
224
|
+
async function readSessionMessages(
|
|
225
|
+
filePath: string,
|
|
226
|
+
messageCount: number,
|
|
227
|
+
): Promise<string | null> {
|
|
203
228
|
try {
|
|
204
229
|
const lines = (await readFile(filePath, "utf-8")).trim().split("\n");
|
|
205
230
|
const messages: string[] = [];
|
|
@@ -214,12 +239,16 @@ async function readSessionMessages(filePath: string, messageCount: number): Prom
|
|
|
214
239
|
const text = Array.isArray(msg.content)
|
|
215
240
|
? msg.content.find((c: any) => c.type === "text")?.text
|
|
216
241
|
: msg.content;
|
|
217
|
-
if (
|
|
242
|
+
if (
|
|
243
|
+
text &&
|
|
244
|
+
!text.startsWith("/") &&
|
|
245
|
+
!text.includes("<relevant-memories>")
|
|
246
|
+
) {
|
|
218
247
|
messages.push(`${role}: ${text}`);
|
|
219
248
|
}
|
|
220
249
|
}
|
|
221
250
|
}
|
|
222
|
-
} catch {
|
|
251
|
+
} catch {}
|
|
223
252
|
}
|
|
224
253
|
|
|
225
254
|
if (messages.length === 0) return null;
|
|
@@ -229,7 +258,10 @@ async function readSessionMessages(filePath: string, messageCount: number): Prom
|
|
|
229
258
|
}
|
|
230
259
|
}
|
|
231
260
|
|
|
232
|
-
async function readSessionContentWithResetFallback(
|
|
261
|
+
async function readSessionContentWithResetFallback(
|
|
262
|
+
sessionFilePath: string,
|
|
263
|
+
messageCount = 15,
|
|
264
|
+
): Promise<string | null> {
|
|
233
265
|
const primary = await readSessionMessages(sessionFilePath, messageCount);
|
|
234
266
|
if (primary) return primary;
|
|
235
267
|
|
|
@@ -238,13 +270,18 @@ async function readSessionContentWithResetFallback(sessionFilePath: string, mess
|
|
|
238
270
|
const dir = dirname(sessionFilePath);
|
|
239
271
|
const resetPrefix = `${basename(sessionFilePath)}.reset.`;
|
|
240
272
|
const files = await readdir(dir);
|
|
241
|
-
const resetCandidates = files
|
|
273
|
+
const resetCandidates = files
|
|
274
|
+
.filter((name) => name.startsWith(resetPrefix))
|
|
275
|
+
.sort();
|
|
242
276
|
|
|
243
277
|
if (resetCandidates.length > 0) {
|
|
244
|
-
const latestResetPath = join(
|
|
278
|
+
const latestResetPath = join(
|
|
279
|
+
dir,
|
|
280
|
+
resetCandidates[resetCandidates.length - 1],
|
|
281
|
+
);
|
|
245
282
|
return await readSessionMessages(latestResetPath, messageCount);
|
|
246
283
|
}
|
|
247
|
-
} catch {
|
|
284
|
+
} catch {}
|
|
248
285
|
|
|
249
286
|
return primary;
|
|
250
287
|
}
|
|
@@ -254,14 +291,21 @@ function stripResetSuffix(fileName: string): string {
|
|
|
254
291
|
return resetIndex === -1 ? fileName : fileName.slice(0, resetIndex);
|
|
255
292
|
}
|
|
256
293
|
|
|
257
|
-
async function findPreviousSessionFile(
|
|
294
|
+
async function findPreviousSessionFile(
|
|
295
|
+
sessionsDir: string,
|
|
296
|
+
currentSessionFile?: string,
|
|
297
|
+
sessionId?: string,
|
|
298
|
+
): Promise<string | undefined> {
|
|
258
299
|
try {
|
|
259
300
|
const files = await readdir(sessionsDir);
|
|
260
301
|
const fileSet = new Set(files);
|
|
261
302
|
|
|
262
303
|
// Try recovering the non-reset base file
|
|
263
|
-
const baseFromReset = currentSessionFile
|
|
264
|
-
|
|
304
|
+
const baseFromReset = currentSessionFile
|
|
305
|
+
? stripResetSuffix(basename(currentSessionFile))
|
|
306
|
+
: undefined;
|
|
307
|
+
if (baseFromReset && fileSet.has(baseFromReset))
|
|
308
|
+
return join(sessionsDir, baseFromReset);
|
|
265
309
|
|
|
266
310
|
// Try canonical session ID file
|
|
267
311
|
const trimmedId = sessionId?.trim();
|
|
@@ -271,19 +315,26 @@ async function findPreviousSessionFile(sessionsDir: string, currentSessionFile?:
|
|
|
271
315
|
|
|
272
316
|
// Try topic variants
|
|
273
317
|
const topicVariants = files
|
|
274
|
-
.filter(
|
|
275
|
-
|
|
318
|
+
.filter(
|
|
319
|
+
(name) =>
|
|
320
|
+
name.startsWith(`${trimmedId}-topic-`) &&
|
|
321
|
+
name.endsWith(".jsonl") &&
|
|
322
|
+
!name.includes(".reset."),
|
|
323
|
+
)
|
|
324
|
+
.sort()
|
|
325
|
+
.reverse();
|
|
276
326
|
if (topicVariants.length > 0) return join(sessionsDir, topicVariants[0]);
|
|
277
327
|
}
|
|
278
328
|
|
|
279
329
|
// Fallback to most recent non-reset JSONL
|
|
280
330
|
if (currentSessionFile) {
|
|
281
331
|
const nonReset = files
|
|
282
|
-
.filter(name => name.endsWith(".jsonl") && !name.includes(".reset."))
|
|
283
|
-
.sort()
|
|
332
|
+
.filter((name) => name.endsWith(".jsonl") && !name.includes(".reset."))
|
|
333
|
+
.sort()
|
|
334
|
+
.reverse();
|
|
284
335
|
if (nonReset.length > 0) return join(sessionsDir, nonReset[0]);
|
|
285
336
|
}
|
|
286
|
-
} catch {
|
|
337
|
+
} catch {}
|
|
287
338
|
}
|
|
288
339
|
|
|
289
340
|
// ============================================================================
|
|
@@ -293,7 +344,9 @@ async function findPreviousSessionFile(sessionsDir: string, currentSessionFile?:
|
|
|
293
344
|
function getPluginVersion(): string {
|
|
294
345
|
try {
|
|
295
346
|
const pkgUrl = new URL("./package.json", import.meta.url);
|
|
296
|
-
const pkg = JSON.parse(readFileSync(pkgUrl, "utf8")) as {
|
|
347
|
+
const pkg = JSON.parse(readFileSync(pkgUrl, "utf8")) as {
|
|
348
|
+
version?: string;
|
|
349
|
+
};
|
|
297
350
|
return pkg.version || "unknown";
|
|
298
351
|
} catch {
|
|
299
352
|
return "unknown";
|
|
@@ -307,7 +360,8 @@ function getPluginVersion(): string {
|
|
|
307
360
|
const memoryLanceDBProPlugin = {
|
|
308
361
|
id: "memory-lancedb-pro",
|
|
309
362
|
name: "Memory (LanceDB Pro)",
|
|
310
|
-
description:
|
|
363
|
+
description:
|
|
364
|
+
"Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, and management CLI",
|
|
311
365
|
kind: "memory" as const,
|
|
312
366
|
|
|
313
367
|
register(api: OpenClawPluginApi) {
|
|
@@ -323,13 +377,13 @@ const memoryLanceDBProPlugin = {
|
|
|
323
377
|
} catch (err) {
|
|
324
378
|
api.logger.warn(
|
|
325
379
|
`memory-lancedb-pro: storage path issue — ${String(err)}\n` +
|
|
326
|
-
|
|
380
|
+
` The plugin will still attempt to start, but writes may fail.`,
|
|
327
381
|
);
|
|
328
382
|
}
|
|
329
383
|
|
|
330
384
|
const vectorDim = getVectorDimensions(
|
|
331
385
|
config.embedding.model || "text-embedding-3-small",
|
|
332
|
-
config.embedding.dimensions
|
|
386
|
+
config.embedding.dimensions,
|
|
333
387
|
);
|
|
334
388
|
|
|
335
389
|
// Initialize core components
|
|
@@ -348,13 +402,22 @@ const memoryLanceDBProPlugin = {
|
|
|
348
402
|
...DEFAULT_RETRIEVAL_CONFIG,
|
|
349
403
|
...config.retrieval,
|
|
350
404
|
});
|
|
405
|
+
|
|
406
|
+
// Access reinforcement tracker (debounced write-back)
|
|
407
|
+
const accessTracker = new AccessTracker({
|
|
408
|
+
store,
|
|
409
|
+
logger: api.logger,
|
|
410
|
+
debounceMs: 5000,
|
|
411
|
+
});
|
|
412
|
+
retriever.setAccessTracker(accessTracker);
|
|
413
|
+
|
|
351
414
|
const scopeManager = createScopeManager(config.scopes);
|
|
352
415
|
const migrator = createMigrator(store);
|
|
353
416
|
|
|
354
417
|
const pluginVersion = getPluginVersion();
|
|
355
418
|
|
|
356
419
|
api.logger.info(
|
|
357
|
-
`memory-lancedb-pro@${pluginVersion}: plugin registered (db: ${resolvedDbPath}, model: ${config.embedding.model || "text-embedding-3-small"})
|
|
420
|
+
`memory-lancedb-pro@${pluginVersion}: plugin registered (db: ${resolvedDbPath}, model: ${config.embedding.model || "text-embedding-3-small"})`,
|
|
358
421
|
);
|
|
359
422
|
|
|
360
423
|
// ========================================================================
|
|
@@ -372,7 +435,7 @@ const memoryLanceDBProPlugin = {
|
|
|
372
435
|
},
|
|
373
436
|
{
|
|
374
437
|
enableManagementTools: config.enableManagementTools,
|
|
375
|
-
}
|
|
438
|
+
},
|
|
376
439
|
);
|
|
377
440
|
|
|
378
441
|
// ========================================================================
|
|
@@ -387,7 +450,7 @@ const memoryLanceDBProPlugin = {
|
|
|
387
450
|
migrator,
|
|
388
451
|
embedder,
|
|
389
452
|
}),
|
|
390
|
-
{ commands: ["memory-pro"] }
|
|
453
|
+
{ commands: ["memory-pro"] },
|
|
391
454
|
);
|
|
392
455
|
|
|
393
456
|
// ========================================================================
|
|
@@ -398,7 +461,10 @@ const memoryLanceDBProPlugin = {
|
|
|
398
461
|
// Default is OFF to prevent the model from accidentally echoing injected context.
|
|
399
462
|
if (config.autoRecall === true) {
|
|
400
463
|
api.on("before_agent_start", async (event, ctx) => {
|
|
401
|
-
if (
|
|
464
|
+
if (
|
|
465
|
+
!event.prompt ||
|
|
466
|
+
shouldSkipRetrieval(event.prompt, config.autoRecallMinLength)
|
|
467
|
+
) {
|
|
402
468
|
return;
|
|
403
469
|
}
|
|
404
470
|
|
|
@@ -411,6 +477,7 @@ const memoryLanceDBProPlugin = {
|
|
|
411
477
|
query: event.prompt,
|
|
412
478
|
limit: 3,
|
|
413
479
|
scopeFilter: accessibleScopes,
|
|
480
|
+
source: "auto-recall",
|
|
414
481
|
});
|
|
415
482
|
|
|
416
483
|
if (results.length === 0) {
|
|
@@ -418,11 +485,14 @@ const memoryLanceDBProPlugin = {
|
|
|
418
485
|
}
|
|
419
486
|
|
|
420
487
|
const memoryContext = results
|
|
421
|
-
.map(
|
|
488
|
+
.map(
|
|
489
|
+
(r) =>
|
|
490
|
+
`- [${r.entry.category}:${r.entry.scope}] ${sanitizeForContext(r.entry.text)} (${(r.score * 100).toFixed(0)}%${r.sources?.bm25 ? ", vector+BM25" : ""}${r.sources?.reranked ? "+reranked" : ""})`,
|
|
491
|
+
)
|
|
422
492
|
.join("\n");
|
|
423
493
|
|
|
424
494
|
api.logger.info?.(
|
|
425
|
-
`memory-lancedb-pro: injecting ${results.length} memories into context for agent ${agentId}
|
|
495
|
+
`memory-lancedb-pro: injecting ${results.length} memories into context for agent ${agentId}`,
|
|
426
496
|
);
|
|
427
497
|
|
|
428
498
|
return {
|
|
@@ -461,7 +531,10 @@ const memoryLanceDBProPlugin = {
|
|
|
461
531
|
|
|
462
532
|
const role = msgObj.role;
|
|
463
533
|
const captureAssistant = config.captureAssistant === true;
|
|
464
|
-
if (
|
|
534
|
+
if (
|
|
535
|
+
role !== "user" &&
|
|
536
|
+
!(captureAssistant && role === "assistant")
|
|
537
|
+
) {
|
|
465
538
|
continue;
|
|
466
539
|
}
|
|
467
540
|
|
|
@@ -501,7 +574,9 @@ const memoryLanceDBProPlugin = {
|
|
|
501
574
|
const vector = await embedder.embedPassage(text);
|
|
502
575
|
|
|
503
576
|
// Check for duplicates using raw vector similarity (bypasses importance/recency weighting)
|
|
504
|
-
const existing = await store.vectorSearch(vector, 1, 0.1, [
|
|
577
|
+
const existing = await store.vectorSearch(vector, 1, 0.1, [
|
|
578
|
+
defaultScope,
|
|
579
|
+
]);
|
|
505
580
|
|
|
506
581
|
if (existing.length > 0 && existing[0].score > 0.95) {
|
|
507
582
|
continue;
|
|
@@ -519,7 +594,7 @@ const memoryLanceDBProPlugin = {
|
|
|
519
594
|
|
|
520
595
|
if (stored > 0) {
|
|
521
596
|
api.logger.info(
|
|
522
|
-
`memory-lancedb-pro: auto-captured ${stored} memories for agent ${agentId} in scope ${defaultScope}
|
|
597
|
+
`memory-lancedb-pro: auto-captured ${stored} memories for agent ${agentId} in scope ${defaultScope}`,
|
|
523
598
|
);
|
|
524
599
|
}
|
|
525
600
|
} catch (err) {
|
|
@@ -544,9 +619,12 @@ const memoryLanceDBProPlugin = {
|
|
|
544
619
|
api.logger.debug("session-memory: hook triggered for /new command");
|
|
545
620
|
|
|
546
621
|
const context = (event.context || {}) as Record<string, unknown>;
|
|
547
|
-
const sessionEntry = (context.previousSessionEntry ||
|
|
622
|
+
const sessionEntry = (context.previousSessionEntry ||
|
|
623
|
+
context.sessionEntry ||
|
|
624
|
+
{}) as Record<string, unknown>;
|
|
548
625
|
const currentSessionId = sessionEntry.sessionId as string | undefined;
|
|
549
|
-
let currentSessionFile =
|
|
626
|
+
let currentSessionFile =
|
|
627
|
+
(sessionEntry.sessionFile as string) || undefined;
|
|
550
628
|
const source = (context.commandSource as string) || "unknown";
|
|
551
629
|
|
|
552
630
|
// Resolve session file (handle reset rotation)
|
|
@@ -558,10 +636,16 @@ const memoryLanceDBProPlugin = {
|
|
|
558
636
|
if (workspaceDir) searchDirs.add(join(workspaceDir, "sessions"));
|
|
559
637
|
|
|
560
638
|
for (const sessionsDir of searchDirs) {
|
|
561
|
-
const recovered = await findPreviousSessionFile(
|
|
639
|
+
const recovered = await findPreviousSessionFile(
|
|
640
|
+
sessionsDir,
|
|
641
|
+
currentSessionFile,
|
|
642
|
+
currentSessionId,
|
|
643
|
+
);
|
|
562
644
|
if (recovered) {
|
|
563
645
|
currentSessionFile = recovered;
|
|
564
|
-
api.logger.debug(
|
|
646
|
+
api.logger.debug(
|
|
647
|
+
`session-memory: recovered session file: ${recovered}`,
|
|
648
|
+
);
|
|
565
649
|
break;
|
|
566
650
|
}
|
|
567
651
|
}
|
|
@@ -573,9 +657,14 @@ const memoryLanceDBProPlugin = {
|
|
|
573
657
|
}
|
|
574
658
|
|
|
575
659
|
// Read session content
|
|
576
|
-
const sessionContent = await readSessionContentWithResetFallback(
|
|
660
|
+
const sessionContent = await readSessionContentWithResetFallback(
|
|
661
|
+
currentSessionFile,
|
|
662
|
+
sessionMessageCount,
|
|
663
|
+
);
|
|
577
664
|
if (!sessionContent) {
|
|
578
|
-
api.logger.debug(
|
|
665
|
+
api.logger.debug(
|
|
666
|
+
"session-memory: no session content found, skipping",
|
|
667
|
+
);
|
|
579
668
|
return;
|
|
580
669
|
}
|
|
581
670
|
|
|
@@ -610,7 +699,9 @@ const memoryLanceDBProPlugin = {
|
|
|
610
699
|
}),
|
|
611
700
|
});
|
|
612
701
|
|
|
613
|
-
api.logger.info(
|
|
702
|
+
api.logger.info(
|
|
703
|
+
`session-memory: stored session summary for ${currentSessionId || "unknown"}`,
|
|
704
|
+
);
|
|
614
705
|
} catch (err) {
|
|
615
706
|
api.logger.warn(`session-memory: failed to save: ${String(err)}`);
|
|
616
707
|
}
|
|
@@ -628,7 +719,9 @@ const memoryLanceDBProPlugin = {
|
|
|
628
719
|
|
|
629
720
|
async function runBackup() {
|
|
630
721
|
try {
|
|
631
|
-
const backupDir = api.resolvePath(
|
|
722
|
+
const backupDir = api.resolvePath(
|
|
723
|
+
join(resolvedDbPath, "..", "backups"),
|
|
724
|
+
);
|
|
632
725
|
await mkdir(backupDir, { recursive: true });
|
|
633
726
|
|
|
634
727
|
const allMemories = await store.list(undefined, undefined, 10000, 0);
|
|
@@ -637,28 +730,34 @@ const memoryLanceDBProPlugin = {
|
|
|
637
730
|
const dateStr = new Date().toISOString().split("T")[0];
|
|
638
731
|
const backupFile = join(backupDir, `memory-backup-${dateStr}.jsonl`);
|
|
639
732
|
|
|
640
|
-
const lines = allMemories.map(m =>
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
733
|
+
const lines = allMemories.map((m) =>
|
|
734
|
+
JSON.stringify({
|
|
735
|
+
id: m.id,
|
|
736
|
+
text: m.text,
|
|
737
|
+
category: m.category,
|
|
738
|
+
scope: m.scope,
|
|
739
|
+
importance: m.importance,
|
|
740
|
+
timestamp: m.timestamp,
|
|
741
|
+
metadata: m.metadata,
|
|
742
|
+
}),
|
|
743
|
+
);
|
|
649
744
|
|
|
650
745
|
await writeFile(backupFile, lines.join("\n") + "\n");
|
|
651
746
|
|
|
652
747
|
// Keep only last 7 backups
|
|
653
|
-
const files = (await readdir(backupDir))
|
|
748
|
+
const files = (await readdir(backupDir))
|
|
749
|
+
.filter((f) => f.startsWith("memory-backup-") && f.endsWith(".jsonl"))
|
|
750
|
+
.sort();
|
|
654
751
|
if (files.length > 7) {
|
|
655
752
|
const { unlink } = await import("node:fs/promises");
|
|
656
753
|
for (const old of files.slice(0, files.length - 7)) {
|
|
657
|
-
await unlink(join(backupDir, old)).catch(() => {
|
|
754
|
+
await unlink(join(backupDir, old)).catch(() => {});
|
|
658
755
|
}
|
|
659
756
|
}
|
|
660
757
|
|
|
661
|
-
api.logger.info(
|
|
758
|
+
api.logger.info(
|
|
759
|
+
`memory-lancedb-pro: backup completed (${allMemories.length} entries → ${backupFile})`,
|
|
760
|
+
);
|
|
662
761
|
} catch (err) {
|
|
663
762
|
api.logger.warn(`memory-lancedb-pro: backup failed: ${String(err)}`);
|
|
664
763
|
}
|
|
@@ -675,10 +774,17 @@ const memoryLanceDBProPlugin = {
|
|
|
675
774
|
// If embedding/retrieval tests hang (bad network / slow provider), the gateway
|
|
676
775
|
// may never bind its HTTP port, causing restart timeouts.
|
|
677
776
|
|
|
678
|
-
const withTimeout = async <T>(
|
|
777
|
+
const withTimeout = async <T>(
|
|
778
|
+
p: Promise<T>,
|
|
779
|
+
ms: number,
|
|
780
|
+
label: string,
|
|
781
|
+
): Promise<T> => {
|
|
679
782
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
680
783
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
681
|
-
timeout = setTimeout(
|
|
784
|
+
timeout = setTimeout(
|
|
785
|
+
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
|
786
|
+
ms,
|
|
787
|
+
);
|
|
682
788
|
});
|
|
683
789
|
try {
|
|
684
790
|
return await Promise.race([p, timeoutPromise]);
|
|
@@ -690,25 +796,39 @@ const memoryLanceDBProPlugin = {
|
|
|
690
796
|
const runStartupChecks = async () => {
|
|
691
797
|
try {
|
|
692
798
|
// Test components (bounded time)
|
|
693
|
-
const embedTest = await withTimeout(
|
|
694
|
-
|
|
799
|
+
const embedTest = await withTimeout(
|
|
800
|
+
embedder.test(),
|
|
801
|
+
8_000,
|
|
802
|
+
"embedder.test()",
|
|
803
|
+
);
|
|
804
|
+
const retrievalTest = await withTimeout(
|
|
805
|
+
retriever.test(),
|
|
806
|
+
8_000,
|
|
807
|
+
"retriever.test()",
|
|
808
|
+
);
|
|
695
809
|
|
|
696
810
|
api.logger.info(
|
|
697
811
|
`memory-lancedb-pro: initialized successfully ` +
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
812
|
+
`(embedding: ${embedTest.success ? "OK" : "FAIL"}, ` +
|
|
813
|
+
`retrieval: ${retrievalTest.success ? "OK" : "FAIL"}, ` +
|
|
814
|
+
`mode: ${retrievalTest.mode}, ` +
|
|
815
|
+
`FTS: ${retrievalTest.hasFtsSupport ? "enabled" : "disabled"})`,
|
|
702
816
|
);
|
|
703
817
|
|
|
704
818
|
if (!embedTest.success) {
|
|
705
|
-
api.logger.warn(
|
|
819
|
+
api.logger.warn(
|
|
820
|
+
`memory-lancedb-pro: embedding test failed: ${embedTest.error}`,
|
|
821
|
+
);
|
|
706
822
|
}
|
|
707
823
|
if (!retrievalTest.success) {
|
|
708
|
-
api.logger.warn(
|
|
824
|
+
api.logger.warn(
|
|
825
|
+
`memory-lancedb-pro: retrieval test failed: ${retrievalTest.error}`,
|
|
826
|
+
);
|
|
709
827
|
}
|
|
710
828
|
} catch (error) {
|
|
711
|
-
api.logger.warn(
|
|
829
|
+
api.logger.warn(
|
|
830
|
+
`memory-lancedb-pro: startup checks failed: ${String(error)}`,
|
|
831
|
+
);
|
|
712
832
|
}
|
|
713
833
|
};
|
|
714
834
|
|
|
@@ -719,7 +839,15 @@ const memoryLanceDBProPlugin = {
|
|
|
719
839
|
setTimeout(() => void runBackup(), 60_000); // 1 min after start
|
|
720
840
|
backupTimer = setInterval(() => void runBackup(), BACKUP_INTERVAL_MS);
|
|
721
841
|
},
|
|
722
|
-
stop: () => {
|
|
842
|
+
stop: async () => {
|
|
843
|
+
// Flush pending access reinforcement data before shutdown
|
|
844
|
+
try {
|
|
845
|
+
await accessTracker.flush();
|
|
846
|
+
} catch (err) {
|
|
847
|
+
api.logger.warn("memory-lancedb-pro: flush failed on stop:", err);
|
|
848
|
+
}
|
|
849
|
+
accessTracker.destroy();
|
|
850
|
+
|
|
723
851
|
if (backupTimer) {
|
|
724
852
|
clearInterval(backupTimer);
|
|
725
853
|
backupTimer = null;
|
|
@@ -728,7 +856,6 @@ const memoryLanceDBProPlugin = {
|
|
|
728
856
|
},
|
|
729
857
|
});
|
|
730
858
|
},
|
|
731
|
-
|
|
732
859
|
};
|
|
733
860
|
|
|
734
861
|
function parsePluginConfig(value: unknown): PluginConfig {
|
|
@@ -743,28 +870,59 @@ function parsePluginConfig(value: unknown): PluginConfig {
|
|
|
743
870
|
}
|
|
744
871
|
|
|
745
872
|
// Accept single key (string) or array of keys for round-robin rotation
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
873
|
+
let apiKey: string | string[];
|
|
874
|
+
if (typeof embedding.apiKey === "string") {
|
|
875
|
+
apiKey = embedding.apiKey;
|
|
876
|
+
} else if (Array.isArray(embedding.apiKey) && embedding.apiKey.length > 0) {
|
|
877
|
+
// Validate every element is a non-empty string
|
|
878
|
+
const invalid = embedding.apiKey.findIndex(
|
|
879
|
+
(k: unknown) => typeof k !== "string" || (k as string).trim().length === 0,
|
|
880
|
+
);
|
|
881
|
+
if (invalid !== -1) {
|
|
882
|
+
throw new Error(
|
|
883
|
+
`embedding.apiKey[${invalid}] is invalid: expected non-empty string`,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
apiKey = embedding.apiKey as string[];
|
|
887
|
+
} else if (embedding.apiKey !== undefined) {
|
|
888
|
+
// apiKey is present but wrong type — throw, don't silently fall back
|
|
889
|
+
throw new Error("embedding.apiKey must be a string or non-empty array of strings");
|
|
890
|
+
} else {
|
|
891
|
+
apiKey = process.env.OPENAI_API_KEY || "";
|
|
892
|
+
}
|
|
751
893
|
|
|
752
894
|
if (!apiKey || (Array.isArray(apiKey) && apiKey.length === 0)) {
|
|
753
895
|
throw new Error("embedding.apiKey is required (set directly or via OPENAI_API_KEY env var)");
|
|
754
896
|
}
|
|
897
|
+
}
|
|
755
898
|
|
|
756
899
|
return {
|
|
757
900
|
embedding: {
|
|
758
901
|
provider: "openai-compatible",
|
|
759
902
|
apiKey,
|
|
760
|
-
model:
|
|
761
|
-
|
|
903
|
+
model:
|
|
904
|
+
typeof embedding.model === "string"
|
|
905
|
+
? embedding.model
|
|
906
|
+
: "text-embedding-3-small",
|
|
907
|
+
baseURL:
|
|
908
|
+
typeof embedding.baseURL === "string"
|
|
909
|
+
? resolveEnvVars(embedding.baseURL)
|
|
910
|
+
: undefined,
|
|
762
911
|
// Accept number, numeric string, or env-var string (e.g. "${EMBED_DIM}").
|
|
763
912
|
// Also accept legacy top-level `dimensions` for convenience.
|
|
764
913
|
dimensions: parsePositiveInt(embedding.dimensions ?? cfg.dimensions),
|
|
765
|
-
taskQuery:
|
|
766
|
-
|
|
767
|
-
|
|
914
|
+
taskQuery:
|
|
915
|
+
typeof embedding.taskQuery === "string"
|
|
916
|
+
? embedding.taskQuery
|
|
917
|
+
: undefined,
|
|
918
|
+
taskPassage:
|
|
919
|
+
typeof embedding.taskPassage === "string"
|
|
920
|
+
? embedding.taskPassage
|
|
921
|
+
: undefined,
|
|
922
|
+
normalized:
|
|
923
|
+
typeof embedding.normalized === "boolean"
|
|
924
|
+
? embedding.normalized
|
|
925
|
+
: undefined,
|
|
768
926
|
},
|
|
769
927
|
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : undefined,
|
|
770
928
|
autoCapture: cfg.autoCapture !== false,
|
|
@@ -772,17 +930,28 @@ function parsePluginConfig(value: unknown): PluginConfig {
|
|
|
772
930
|
autoRecall: cfg.autoRecall === true,
|
|
773
931
|
autoRecallMinLength: parsePositiveInt(cfg.autoRecallMinLength),
|
|
774
932
|
captureAssistant: cfg.captureAssistant === true,
|
|
775
|
-
retrieval:
|
|
776
|
-
|
|
933
|
+
retrieval:
|
|
934
|
+
typeof cfg.retrieval === "object" && cfg.retrieval !== null
|
|
935
|
+
? (cfg.retrieval as any)
|
|
936
|
+
: undefined,
|
|
937
|
+
scopes:
|
|
938
|
+
typeof cfg.scopes === "object" && cfg.scopes !== null
|
|
939
|
+
? (cfg.scopes as any)
|
|
940
|
+
: undefined,
|
|
777
941
|
enableManagementTools: cfg.enableManagementTools === true,
|
|
778
|
-
sessionMemory:
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
942
|
+
sessionMemory:
|
|
943
|
+
typeof cfg.sessionMemory === "object" && cfg.sessionMemory !== null
|
|
944
|
+
? {
|
|
945
|
+
enabled:
|
|
946
|
+
(cfg.sessionMemory as Record<string, unknown>).enabled !== false,
|
|
947
|
+
messageCount:
|
|
948
|
+
typeof (cfg.sessionMemory as Record<string, unknown>)
|
|
949
|
+
.messageCount === "number"
|
|
950
|
+
? ((cfg.sessionMemory as Record<string, unknown>)
|
|
951
|
+
.messageCount as number)
|
|
952
|
+
: undefined,
|
|
953
|
+
}
|
|
954
|
+
: undefined,
|
|
786
955
|
};
|
|
787
956
|
}
|
|
788
957
|
|