memory-lancedb-pro 1.0.29 → 1.0.31

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.31
4
+
5
+ - Fix: `memory-pro import` now preserves provided IDs and is idempotent (skips if ID already exists).
6
+
3
7
  ## 1.0.26
4
8
 
5
9
  **Access Reinforcement for Time Decay**
package/cli.ts CHANGED
@@ -379,25 +379,78 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void {
379
379
  continue;
380
380
  }
381
381
 
382
- // Check for duplicates
383
- const existing = await context.retriever.retrieve({
384
- query: text,
385
- limit: 1,
386
- scopeFilter: [targetScope],
387
- });
388
- if (existing.length > 0 && existing[0].score > 0.95) {
382
+ const categoryRaw = memory.category;
383
+ const category: MemoryEntry["category"] =
384
+ categoryRaw === "preference" ||
385
+ categoryRaw === "fact" ||
386
+ categoryRaw === "decision" ||
387
+ categoryRaw === "entity" ||
388
+ categoryRaw === "other"
389
+ ? categoryRaw
390
+ : "other";
391
+
392
+ const importanceRaw = Number(memory.importance);
393
+ const importance = Number.isFinite(importanceRaw)
394
+ ? Math.max(0, Math.min(1, importanceRaw))
395
+ : 0.7;
396
+
397
+ const timestampRaw = Number(memory.timestamp);
398
+ const timestamp = Number.isFinite(timestampRaw) ? timestampRaw : Date.now();
399
+
400
+ const metadataRaw = memory.metadata;
401
+ const metadata =
402
+ typeof metadataRaw === "string"
403
+ ? metadataRaw
404
+ : metadataRaw != null
405
+ ? JSON.stringify(metadataRaw)
406
+ : "{}";
407
+
408
+ const idRaw = memory.id;
409
+ const id = typeof idRaw === "string" && idRaw.length > 0 ? idRaw : undefined;
410
+
411
+ // Idempotency: if the import file includes an id and we already have it, skip.
412
+ if (id && (await context.store.hasId(id))) {
389
413
  skipped++;
390
414
  continue;
391
415
  }
392
416
 
417
+ // Back-compat dedupe: if no id provided, do a best-effort similarity check.
418
+ if (!id) {
419
+ const existing = await context.retriever.retrieve({
420
+ query: text,
421
+ limit: 1,
422
+ scopeFilter: [targetScope],
423
+ });
424
+ if (existing.length > 0 && existing[0].score > 0.95) {
425
+ skipped++;
426
+ continue;
427
+ }
428
+ }
429
+
393
430
  const vector = await context.embedder.embedPassage(text);
394
- await context.store.store({
395
- text,
396
- vector,
397
- importance: memory.importance ?? 0.7,
398
- category: memory.category || "other",
399
- scope: targetScope,
400
- });
431
+
432
+ if (id) {
433
+ await context.store.importEntry({
434
+ id,
435
+ text,
436
+ vector,
437
+ category,
438
+ scope: targetScope,
439
+ importance,
440
+ timestamp,
441
+ metadata,
442
+ });
443
+ } else {
444
+ await context.store.store({
445
+ text,
446
+ vector,
447
+ importance,
448
+ category,
449
+ scope: targetScope,
450
+ metadata,
451
+ });
452
+ }
453
+
401
454
  imported++;
402
455
  } catch (error) {
403
456
  console.warn(`Failed to import memory: ${error}`);
package/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
7
7
  import { homedir } from "node:os";
8
8
  import { join, dirname, basename } from "node:path";
9
- import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
9
+ import { readFile, readdir, writeFile, mkdir, appendFile } from "node:fs/promises";
10
10
  import { readFileSync } from "node:fs";
11
11
 
12
12
  // Import core components
@@ -16,6 +16,7 @@ import { createRetriever, DEFAULT_RETRIEVAL_CONFIG } from "./src/retriever.js";
16
16
  import { createScopeManager } from "./src/scopes.js";
17
17
  import { createMigrator } from "./src/migrate.js";
18
18
  import { registerAllMemoryTools } from "./src/tools.js";
19
+ import type { MdMirrorWriter } from "./src/tools.js";
19
20
  import { shouldSkipRetrieval } from "./src/adaptive-retrieval.js";
20
21
  import { AccessTracker } from "./src/access-tracker.js";
21
22
  import { createMemoryCLI } from "./cli.js";
@@ -67,6 +68,7 @@ interface PluginConfig {
67
68
  };
68
69
  enableManagementTools?: boolean;
69
70
  sessionMemory?: { enabled?: boolean; messageCount?: number };
71
+ mdMirror?: { enabled?: boolean; dir?: string };
70
72
  }
71
73
 
72
74
  // ============================================================================
@@ -337,6 +339,92 @@ async function findPreviousSessionFile(
337
339
  } catch {}
338
340
  }
339
341
 
342
+ // ============================================================================
343
+ // Markdown Mirror (dual-write)
344
+ // ============================================================================
345
+
346
+ type AgentWorkspaceMap = Record<string, string>;
347
+
348
+ function resolveAgentWorkspaceMap(api: OpenClawPluginApi): AgentWorkspaceMap {
349
+ const map: AgentWorkspaceMap = {};
350
+
351
+ // Try api.config first (runtime config)
352
+ const agents = Array.isArray((api as any).config?.agents?.list)
353
+ ? (api as any).config.agents.list
354
+ : [];
355
+
356
+ for (const agent of agents) {
357
+ if (agent?.id && typeof agent.workspace === "string") {
358
+ map[String(agent.id)] = agent.workspace;
359
+ }
360
+ }
361
+
362
+ // Fallback: read from openclaw.json (respect OPENCLAW_HOME if set)
363
+ if (Object.keys(map).length === 0) {
364
+ try {
365
+ const openclawHome = process.env.OPENCLAW_HOME || join(homedir(), ".openclaw");
366
+ const configPath = join(openclawHome, "openclaw.json");
367
+ const raw = readFileSync(configPath, "utf8");
368
+ const parsed = JSON.parse(raw);
369
+ const list = parsed?.agents?.list;
370
+ if (Array.isArray(list)) {
371
+ for (const agent of list) {
372
+ if (agent?.id && typeof agent.workspace === "string") {
373
+ map[String(agent.id)] = agent.workspace;
374
+ }
375
+ }
376
+ }
377
+ } catch {
378
+ /* silent */
379
+ }
380
+ }
381
+
382
+ return map;
383
+ }
384
+
385
+ function createMdMirrorWriter(
386
+ api: OpenClawPluginApi,
387
+ config: PluginConfig,
388
+ ): MdMirrorWriter | null {
389
+ if (config.mdMirror?.enabled !== true) return null;
390
+
391
+ const fallbackDir = api.resolvePath(config.mdMirror.dir || "memory-md");
392
+ const workspaceMap = resolveAgentWorkspaceMap(api);
393
+
394
+ if (Object.keys(workspaceMap).length > 0) {
395
+ api.logger.info(
396
+ `mdMirror: resolved ${Object.keys(workspaceMap).length} agent workspace(s)`,
397
+ );
398
+ } else {
399
+ api.logger.warn(
400
+ `mdMirror: no agent workspaces found, writes will use fallback dir: ${fallbackDir}`,
401
+ );
402
+ }
403
+
404
+ return async (entry, meta) => {
405
+ try {
406
+ const ts = new Date(entry.timestamp || Date.now());
407
+ const dateStr = ts.toISOString().split("T")[0];
408
+
409
+ let mirrorDir = fallbackDir;
410
+ if (meta?.agentId && workspaceMap[meta.agentId]) {
411
+ mirrorDir = join(workspaceMap[meta.agentId], "memory");
412
+ }
413
+
414
+ const filePath = join(mirrorDir, `${dateStr}.md`);
415
+ const agentLabel = meta?.agentId ? ` agent=${meta.agentId}` : "";
416
+ const sourceLabel = meta?.source ? ` source=${meta.source}` : "";
417
+ const safeText = entry.text.replace(/\n/g, " ").slice(0, 500);
418
+ const line = `- ${ts.toISOString()} [${entry.category}:${entry.scope}]${agentLabel}${sourceLabel} ${safeText}\n`;
419
+
420
+ await mkdir(mirrorDir, { recursive: true });
421
+ await appendFile(filePath, line, "utf8");
422
+ } catch (err) {
423
+ api.logger.warn(`mdMirror: write failed: ${String(err)}`);
424
+ }
425
+ };
426
+ }
427
+
340
428
  // ============================================================================
341
429
  // Version
342
430
  // ============================================================================
@@ -427,6 +515,12 @@ const memoryLanceDBProPlugin = {
427
515
  `memory-lancedb-pro@${pluginVersion}: plugin registered (db: ${resolvedDbPath}, model: ${config.embedding.model || "text-embedding-3-small"})`,
428
516
  );
429
517
 
518
+ // ========================================================================
519
+ // Markdown Mirror
520
+ // ========================================================================
521
+
522
+ const mdMirror = createMdMirrorWriter(api, config);
523
+
430
524
  // ========================================================================
431
525
  // Register Tools
432
526
  // ========================================================================
@@ -439,6 +533,7 @@ const memoryLanceDBProPlugin = {
439
533
  scopeManager,
440
534
  embedder,
441
535
  agentId: undefined, // Will be determined at runtime from context
536
+ mdMirror,
442
537
  },
443
538
  {
444
539
  enableManagementTools: config.enableManagementTools,
@@ -625,9 +720,17 @@ const memoryLanceDBProPlugin = {
625
720
  const vector = await embedder.embedPassage(text);
626
721
 
627
722
  // Check for duplicates using raw vector similarity (bypasses importance/recency weighting)
628
- const existing = await store.vectorSearch(vector, 1, 0.1, [
629
- defaultScope,
630
- ]);
723
+ // Fail-open by design: dedup should not block auto-capture writes.
724
+ let existing: Awaited<ReturnType<typeof store.vectorSearch>> = [];
725
+ try {
726
+ existing = await store.vectorSearch(vector, 1, 0.1, [
727
+ defaultScope,
728
+ ]);
729
+ } catch (err) {
730
+ api.logger.warn(
731
+ `memory-lancedb-pro: auto-capture duplicate pre-check failed, continue store: ${String(err)}`,
732
+ );
733
+ }
631
734
 
632
735
  if (existing.length > 0 && existing[0].score > 0.95) {
633
736
  continue;
@@ -641,6 +744,14 @@ const memoryLanceDBProPlugin = {
641
744
  scope: defaultScope,
642
745
  });
643
746
  stored++;
747
+
748
+ // Dual-write to Markdown mirror if enabled
749
+ if (mdMirror) {
750
+ await mdMirror(
751
+ { text, category, scope: defaultScope, timestamp: Date.now() },
752
+ { source: "auto-capture", agentId },
753
+ );
754
+ }
644
755
  }
645
756
 
646
757
  if (stored > 0) {
@@ -750,6 +861,14 @@ const memoryLanceDBProPlugin = {
750
861
  }),
751
862
  });
752
863
 
864
+ // Dual-write to Markdown mirror if enabled
865
+ if (mdMirror) {
866
+ await mdMirror(
867
+ { text: memoryText.replace(/\n/g, " ").slice(0, 500), category: "fact", scope: "global", timestamp: Date.now() },
868
+ { source: "session-memory" },
869
+ );
870
+ }
871
+
753
872
  api.logger.info(
754
873
  `session-memory: stored session summary for ${currentSessionId || "unknown"}`,
755
874
  );
@@ -1002,6 +1121,17 @@ function parsePluginConfig(value: unknown): PluginConfig {
1002
1121
  : undefined,
1003
1122
  }
1004
1123
  : undefined,
1124
+ mdMirror:
1125
+ typeof cfg.mdMirror === "object" && cfg.mdMirror !== null
1126
+ ? {
1127
+ enabled:
1128
+ (cfg.mdMirror as Record<string, unknown>).enabled === true,
1129
+ dir:
1130
+ typeof (cfg.mdMirror as Record<string, unknown>).dir === "string"
1131
+ ? ((cfg.mdMirror as Record<string, unknown>).dir as string)
1132
+ : undefined,
1133
+ }
1134
+ : undefined,
1005
1135
  };
1006
1136
  }
1007
1137
 
@@ -2,7 +2,7 @@
2
2
  "id": "memory-lancedb-pro",
3
3
  "name": "Memory (LanceDB Pro)",
4
4
  "description": "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, long-context chunking, and management CLI",
5
- "version": "1.0.26",
5
+ "version": "1.0.31",
6
6
  "kind": "memory",
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -270,6 +270,21 @@
270
270
  }
271
271
  }
272
272
  }
273
+ },
274
+ "mdMirror": {
275
+ "type": "object",
276
+ "additionalProperties": false,
277
+ "properties": {
278
+ "enabled": {
279
+ "type": "boolean",
280
+ "default": false,
281
+ "description": "Enable dual-write: store memories in both LanceDB and human-readable Markdown files"
282
+ },
283
+ "dir": {
284
+ "type": "string",
285
+ "description": "Fallback directory for Markdown mirror files when agent workspace is unknown"
286
+ }
287
+ }
273
288
  }
274
289
  },
275
290
  "required": [
@@ -448,6 +463,15 @@
448
463
  "label": "Management Tools",
449
464
  "help": "Enable memory_list and memory_stats tools for debugging and auditing",
450
465
  "advanced": true
466
+ },
467
+ "mdMirror.enabled": {
468
+ "label": "Markdown Mirror",
469
+ "help": "Write a human-readable Markdown copy alongside LanceDB storage (dual-write mode)"
470
+ },
471
+ "mdMirror.dir": {
472
+ "label": "Mirror Fallback Directory",
473
+ "help": "Fallback directory when agent workspace mapping is unavailable",
474
+ "advanced": true
451
475
  }
452
476
  }
453
477
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-lancedb-pro",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "OpenClaw enhanced LanceDB memory plugin with hybrid retrieval (Vector + BM25), cross-encoder rerank, multi-scope isolation, long-context chunking, and management CLI",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/tools.ts CHANGED
@@ -24,12 +24,18 @@ export const MEMORY_CATEGORIES = [
24
24
  "other",
25
25
  ] as const;
26
26
 
27
+ export type MdMirrorWriter = (
28
+ entry: { text: string; category: string; scope: string; timestamp?: number },
29
+ meta?: { source?: string; agentId?: string },
30
+ ) => Promise<void>;
31
+
27
32
  interface ToolContext {
28
33
  retriever: MemoryRetriever;
29
34
  store: MemoryStore;
30
35
  scopeManager: MemoryScopeManager;
31
36
  embedder: Embedder;
32
37
  agentId?: string;
38
+ mdMirror?: MdMirrorWriter | null;
33
39
  }
34
40
 
35
41
  function resolveAgentId(runtimeAgentId: unknown, fallback?: string): string | undefined {
@@ -263,9 +269,17 @@ export function registerMemoryStoreTool(
263
269
  const vector = await context.embedder.embedPassage(text);
264
270
 
265
271
  // Check for duplicates using raw vector similarity (bypasses importance/recency weighting)
266
- const existing = await context.store.vectorSearch(vector, 1, 0.1, [
267
- targetScope,
268
- ]);
272
+ // Fail-open by design: dedup must never block a legitimate memory write.
273
+ let existing: Awaited<ReturnType<typeof context.store.vectorSearch>> = [];
274
+ try {
275
+ existing = await context.store.vectorSearch(vector, 1, 0.1, [
276
+ targetScope,
277
+ ]);
278
+ } catch (err) {
279
+ console.warn(
280
+ `memory-lancedb-pro: duplicate pre-check failed, continue store: ${String(err)}`,
281
+ );
282
+ }
269
283
 
270
284
  if (existing.length > 0 && existing[0].score > 0.98) {
271
285
  return {
@@ -293,6 +307,14 @@ export function registerMemoryStoreTool(
293
307
  scope: targetScope,
294
308
  });
295
309
 
310
+ // Dual-write to Markdown mirror if enabled
311
+ if (context.mdMirror) {
312
+ await context.mdMirror(
313
+ { text, category: category as string, scope: targetScope, timestamp: entry.timestamp },
314
+ { source: "memory_store", agentId },
315
+ );
316
+ }
317
+
296
318
  return {
297
319
  content: [
298
320
  {
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
 
@@ -47,14 +47,23 @@ async function runCliSmoke() {
47
47
  const program = new Command();
48
48
  program.exitOverride();
49
49
 
50
+ const { MemoryStore } = jiti("../src/store.ts");
51
+
52
+ const store = new MemoryStore({
53
+ dbPath: path.join(workDir, "target-db"),
54
+ vectorDim: 4,
55
+ });
56
+
50
57
  const context = {
51
- // Minimal store interface for reembed dry-run.
52
- store: { dbPath: path.join(workDir, "target-db") },
53
- retriever: {},
54
- scopeManager: {},
58
+ store,
59
+ // Only used for similarity-based dedupe when the import file has no id.
60
+ retriever: { retrieve: async () => [] },
61
+ scopeManager: { getDefaultScope: () => "global" },
55
62
  migrator: {},
56
- // Presence required, but dry-run exits before embeddings.
57
- embedder: {},
63
+ // Stub embedder used by import/reembed.
64
+ embedder: {
65
+ embedPassage: async () => [0, 0, 0, 0],
66
+ },
58
67
  };
59
68
 
60
69
  // Register commands under `memory-pro`
@@ -78,7 +87,71 @@ async function runCliSmoke() {
78
87
  "--dry-run",
79
88
  ]);
80
89
 
81
- // 3) Access reinforcement formula smoke test
90
+ // 3) import should preserve id and be idempotent (skip on second import)
91
+ const importId = "smoke_import_id_1";
92
+ const importPhrase = `smoke-import-${Date.now()}`;
93
+ const importFile = path.join(workDir, "import-test.json");
94
+
95
+ writeFileSync(
96
+ importFile,
97
+ JSON.stringify(
98
+ {
99
+ version: "1.0",
100
+ exportedAt: new Date().toISOString(),
101
+ count: 1,
102
+ filters: {},
103
+ memories: [
104
+ {
105
+ id: importId,
106
+ text: `Import smoke test. UniquePhrase=${importPhrase}.`,
107
+ category: "other",
108
+ scope: "global",
109
+ importance: 0.3,
110
+ timestamp: Date.now(),
111
+ metadata: "{}",
112
+ },
113
+ ],
114
+ },
115
+ null,
116
+ 2,
117
+ ),
118
+ );
119
+
120
+ const captureLogs = async (argv) => {
121
+ const logs = [];
122
+ const origLog = console.log;
123
+ console.log = (...args) => logs.push(args.join(" "));
124
+ try {
125
+ await program.parseAsync(argv);
126
+ } finally {
127
+ console.log = origLog;
128
+ }
129
+ return logs.join("\n");
130
+ };
131
+
132
+ const out1 = await captureLogs([
133
+ "node",
134
+ "openclaw",
135
+ "memory-pro",
136
+ "import",
137
+ importFile,
138
+ "--scope",
139
+ "agent:smoke",
140
+ ]);
141
+ assert.match(out1, /Import completed: 1 imported/, out1);
142
+
143
+ const out2 = await captureLogs([
144
+ "node",
145
+ "openclaw",
146
+ "memory-pro",
147
+ "import",
148
+ importFile,
149
+ "--scope",
150
+ "agent:smoke",
151
+ ]);
152
+ assert.match(out2, /Import completed: 0 imported, 1 skipped/, out2);
153
+
154
+ // 4) Access reinforcement formula smoke test
82
155
  const { parseAccessMetadata, buildUpdatedMetadata, computeEffectiveHalfLife } =
83
156
  jiti("../src/access-tracker.ts");
84
157