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/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(s);
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(text: string): "preference" | "fact" | "decision" | "entity" | "other" {
174
+ export function detectCategory(
175
+ text: string,
176
+ ): "preference" | "fact" | "decision" | "entity" | "other" {
171
177
  const lower = text.toLowerCase();
172
- if (/prefer|radši|like|love|hate|want|偏好|喜歡|喜欢|討厭|讨厌|不喜歡|不喜欢|愛用|爱用|習慣|习惯/i.test(lower)) {
178
+ if (
179
+ /prefer|radši|like|love|hate|want|偏好|喜歡|喜欢|討厭|讨厌|不喜歡|不喜欢|愛用|爱用|習慣|习惯/i.test(
180
+ lower,
181
+ )
182
+ ) {
173
183
  return "preference";
174
184
  }
175
- if (/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(lower)) {
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 (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se|我的\S+是|叫我|稱呼|称呼/i.test(lower)) {
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 (/\b(is|are|has|have|je|má|jsou)\b|總是|总是|從不|从不|一直|每次都|老是/i.test(lower)) {
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(filePath: string, messageCount: number): Promise<string | null> {
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 (text && !text.startsWith("/") && !text.includes("<relevant-memories>")) {
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(sessionFilePath: string, messageCount = 15): Promise<string | null> {
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.filter(name => name.startsWith(resetPrefix)).sort();
273
+ const resetCandidates = files
274
+ .filter((name) => name.startsWith(resetPrefix))
275
+ .sort();
242
276
 
243
277
  if (resetCandidates.length > 0) {
244
- const latestResetPath = join(dir, resetCandidates[resetCandidates.length - 1]);
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(sessionsDir: string, currentSessionFile?: string, sessionId?: string): Promise<string | undefined> {
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 ? stripResetSuffix(basename(currentSessionFile)) : undefined;
264
- if (baseFromReset && fileSet.has(baseFromReset)) return join(sessionsDir, baseFromReset);
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(name => name.startsWith(`${trimmedId}-topic-`) && name.endsWith(".jsonl") && !name.includes(".reset."))
275
- .sort().reverse();
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().reverse();
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 { version?: string };
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: "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, and management CLI",
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
- ` The plugin will still attempt to start, but writes may fail.`
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 (!event.prompt || shouldSkipRetrieval(event.prompt, config.autoRecallMinLength)) {
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((r) => `- [${r.entry.category}:${r.entry.scope}] ${sanitizeForContext(r.entry.text)} (${(r.score * 100).toFixed(0)}%${r.sources?.bm25 ? ', vector+BM25' : ''}${r.sources?.reranked ? '+reranked' : ''})`)
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 (role !== "user" && !(captureAssistant && role === "assistant")) {
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, [defaultScope]);
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 || context.sessionEntry || {}) as Record<string, unknown>;
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 = (sessionEntry.sessionFile as string) || undefined;
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(sessionsDir, currentSessionFile, currentSessionId);
639
+ const recovered = await findPreviousSessionFile(
640
+ sessionsDir,
641
+ currentSessionFile,
642
+ currentSessionId,
643
+ );
562
644
  if (recovered) {
563
645
  currentSessionFile = recovered;
564
- api.logger.debug(`session-memory: recovered session file: ${recovered}`);
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(currentSessionFile, sessionMessageCount);
660
+ const sessionContent = await readSessionContentWithResetFallback(
661
+ currentSessionFile,
662
+ sessionMessageCount,
663
+ );
577
664
  if (!sessionContent) {
578
- api.logger.debug("session-memory: no session content found, skipping");
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(`session-memory: stored session summary for ${currentSessionId || "unknown"}`);
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(join(resolvedDbPath, "..", "backups"));
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 => JSON.stringify({
641
- id: m.id,
642
- text: m.text,
643
- category: m.category,
644
- scope: m.scope,
645
- importance: m.importance,
646
- timestamp: m.timestamp,
647
- metadata: m.metadata,
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)).filter(f => f.startsWith("memory-backup-") && f.endsWith(".jsonl")).sort();
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(`memory-lancedb-pro: backup completed (${allMemories.length} entries → ${backupFile})`);
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>(p: Promise<T>, ms: number, label: string): Promise<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(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
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(embedder.test(), 8_000, "embedder.test()");
694
- const retrievalTest = await withTimeout(retriever.test(), 8_000, "retriever.test()");
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
- `(embedding: ${embedTest.success ? "OK" : "FAIL"}, ` +
699
- `retrieval: ${retrievalTest.success ? "OK" : "FAIL"}, ` +
700
- `mode: ${retrievalTest.mode}, ` +
701
- `FTS: ${retrievalTest.hasFtsSupport ? "enabled" : "disabled"})`
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(`memory-lancedb-pro: embedding test failed: ${embedTest.error}`);
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(`memory-lancedb-pro: retrieval test failed: ${retrievalTest.error}`);
824
+ api.logger.warn(
825
+ `memory-lancedb-pro: retrieval test failed: ${retrievalTest.error}`,
826
+ );
709
827
  }
710
828
  } catch (error) {
711
- api.logger.warn(`memory-lancedb-pro: startup checks failed: ${String(error)}`);
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
- const apiKey: string | string[] = typeof embedding.apiKey === "string"
747
- ? embedding.apiKey
748
- : Array.isArray(embedding.apiKey) && embedding.apiKey.length > 0 && embedding.apiKey.every((k: unknown) => typeof k === "string")
749
- ? (embedding.apiKey as string[])
750
- : process.env.OPENAI_API_KEY || "";
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: typeof embedding.model === "string" ? embedding.model : "text-embedding-3-small",
761
- baseURL: typeof embedding.baseURL === "string" ? resolveEnvVars(embedding.baseURL) : undefined,
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: typeof embedding.taskQuery === "string" ? embedding.taskQuery : undefined,
766
- taskPassage: typeof embedding.taskPassage === "string" ? embedding.taskPassage : undefined,
767
- normalized: typeof embedding.normalized === "boolean" ? embedding.normalized : undefined,
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: typeof cfg.retrieval === "object" && cfg.retrieval !== null ? cfg.retrieval as any : undefined,
776
- scopes: typeof cfg.scopes === "object" && cfg.scopes !== null ? cfg.scopes as any : undefined,
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: typeof cfg.sessionMemory === "object" && cfg.sessionMemory !== null
779
- ? {
780
- enabled: (cfg.sessionMemory as Record<string, unknown>).enabled !== false,
781
- messageCount: typeof (cfg.sessionMemory as Record<string, unknown>).messageCount === "number"
782
- ? (cfg.sessionMemory as Record<string, unknown>).messageCount as number
783
- : undefined,
784
- }
785
- : undefined,
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