memory-braid 0.3.7 → 0.4.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/README.md CHANGED
@@ -5,12 +5,31 @@ Memory Braid is an OpenClaw `kind: "memory"` plugin that augments local memory s
5
5
  ## Features
6
6
 
7
7
  - Hybrid recall: local memory + Mem0, merged with weighted RRF.
8
- - Install-time bootstrap import: indexes existing `MEMORY.md`, `memory.md`, `memory/**/*.md`, and recent sessions.
9
- - Periodic reconcile: keeps remote Mem0 chunks updated and deletes stale remote chunks.
8
+ - Capture-first Mem0 memory: plugin writes only captured memories to Mem0 (no markdown/session indexing).
10
9
  - Capture pipeline modes: `local`, `hybrid`, `ml`.
11
10
  - Optional entity extraction: multilingual NER with canonical `entity://...` URIs in memory metadata.
12
11
  - Structured debug logs for troubleshooting and tuning.
13
12
 
13
+ ## Breaking changes in 0.4.0
14
+
15
+ Memory Braid `0.4.0` is intentionally simplified to capture/recall-only mode.
16
+
17
+ - Removed managed indexing features:
18
+ - `bootstrap` config block removed.
19
+ - `reconcile` config block removed.
20
+ - startup bootstrap/reconcile flows removed.
21
+ - Mem0 is now used only for captured memories.
22
+ - markdown and session indexing is no longer done by this plugin.
23
+ - local markdown/session retrieval remains in core/QMD via `memory_search`.
24
+ - `/memorybraid stats` now reports capture + lifecycle only (no reconcile section).
25
+ - Legacy Mem0 records with `metadata.sourceType` of `markdown` or `session` are ignored during Mem0 recall merge.
26
+
27
+ Migration:
28
+
29
+ - If you relied on bootstrap/reconcile mirroring, pin to `<0.4.0`.
30
+ - For `0.4.0+`, remove `bootstrap` and `reconcile` from your plugin config.
31
+ - Keep core/QMD as the source for markdown/sessions, and use Memory Braid for capture/mem0 recall/lifecycle.
32
+
14
33
  ## Install
15
34
 
16
35
  ### Install from npm (recommended)
@@ -20,7 +39,7 @@ On the target machine:
20
39
  1. Install from npm:
21
40
 
22
41
  ```bash
23
- openclaw plugins install memory-braid@0.3.5
42
+ openclaw plugins install memory-braid@0.4.0
24
43
  ```
25
44
 
26
45
  2. Rebuild native dependencies inside the installed extension:
@@ -127,6 +146,7 @@ Add this under `plugins.entries["memory-braid"].config` in your OpenClaw config:
127
146
  "capture": {
128
147
  "enabled": true,
129
148
  "mode": "hybrid",
149
+ "includeAssistant": false,
130
150
  "maxItemsPerRun": 6,
131
151
  "ml": {
132
152
  "provider": "openai",
@@ -220,26 +240,26 @@ Memory Braid supports two self-hosted setups:
220
240
  - `embedder` (provider + credentials/model)
221
241
  - `vectorStore` (provider + connection/config)
222
242
  - `llm` (provider + model; used by Mem0 OSS internals)
243
+ - Partial `ossConfig` is safe: Memory Braid deep-merges your values over OSS defaults.
244
+ - If a section provider changes (for example `embedder.provider: "ollama"`), that section is replaced instead of mixed.
223
245
  3. Restart OpenClaw.
224
246
  4. Send at least one message to trigger capture/recall.
225
247
  5. Check logs for:
226
248
  - `memory_braid.startup`
227
- - `memory_braid.bootstrap.begin|complete`
228
- - `memory_braid.reconcile.begin|complete`
229
249
  - `memory_braid.mem0.request|response`
230
250
 
231
251
  ### Smoke test checklist
232
252
 
233
253
  1. Enable debug:
234
254
  - `plugins.memory-braid.debug.enabled: true`
235
- 2. Start OpenClaw and wait for bootstrap to finish.
236
- 3. Ask a query that should match existing memory (from `MEMORY.md` or recent sessions).
237
- 4. Confirm `memory_search` returns merged results.
238
- 5. Send a preference/decision statement and verify subsequent turns can recall it.
255
+ 2. Start OpenClaw.
256
+ 3. Send a preference/decision statement.
257
+ 4. Confirm later `memory_search` runs return merged local+Mem0 results.
258
+ 5. Run `/memorybraid stats` to verify capture counters increase.
239
259
 
240
260
  ### Notes
241
261
 
242
- - Bootstrap imports existing markdown memory + recent sessions once, then reconcile keeps remote state aligned.
262
+ - Memory Braid 0.4.0 is capture/recall-only by design: markdown and session indexing stay in core/QMD.
243
263
  - If self-hosted infra is down, local memory tools continue working; Mem0 side degrades gracefully.
244
264
  - For Mem0 platform/API specifics, see official docs: [Mem0 OSS quickstart](https://docs.mem0.ai/open-source/node-quickstart) and [Mem0 API reference](https://docs.mem0.ai/api-reference).
245
265
 
@@ -384,7 +404,6 @@ Use this preset when:
384
404
  3. Start OpenClaw with `debug.enabled: true` and verify:
385
405
  - `memory_braid.startup`
386
406
  - `memory_braid.mem0.response` with `mode: "oss"`
387
- - `memory_braid.bootstrap.complete`
388
407
 
389
408
  ## Recommended config
390
409
 
@@ -404,23 +423,10 @@ Use this preset when:
404
423
  "mem0Weight": 1
405
424
  }
406
425
  },
407
- "bootstrap": {
408
- "enabled": true,
409
- "includeMarkdown": true,
410
- "includeSessions": true,
411
- "sessionLookbackDays": 90,
412
- "batchSize": 50,
413
- "concurrency": 3
414
- },
415
- "reconcile": {
416
- "enabled": true,
417
- "intervalMinutes": 30,
418
- "batchSize": 100,
419
- "deleteStale": true
420
- },
421
426
  "capture": {
422
427
  "enabled": true,
423
428
  "mode": "hybrid",
429
+ "includeAssistant": false,
424
430
  "maxItemsPerRun": 6,
425
431
  "ml": {
426
432
  "provider": "openai",
@@ -443,6 +449,15 @@ Use this preset when:
443
449
  "lexical": { "minJaccard": 0.3 },
444
450
  "semantic": { "enabled": true, "minScore": 0.92 }
445
451
  },
452
+ "timeDecay": {
453
+ "enabled": false
454
+ },
455
+ "lifecycle": {
456
+ "enabled": false,
457
+ "captureTtlDays": 90,
458
+ "cleanupIntervalMinutes": 360,
459
+ "reinforceOnRecall": true
460
+ },
446
461
  "debug": {
447
462
  "enabled": false,
448
463
  "includePayloads": false,
@@ -460,17 +475,29 @@ Capture defaults are:
460
475
 
461
476
  - `capture.enabled`: `true`
462
477
  - `capture.mode`: `"local"`
478
+ - `capture.includeAssistant`: `false` (default user-only capture)
463
479
  - `capture.maxItemsPerRun`: `6`
464
480
  - `capture.ml.provider`: unset
465
481
  - `capture.ml.model`: unset
466
482
  - `capture.ml.timeoutMs`: `2500`
483
+ - `timeDecay.enabled`: `false`
484
+ - `lifecycle.enabled`: `false`
485
+ - `lifecycle.captureTtlDays`: `90`
486
+ - `lifecycle.cleanupIntervalMinutes`: `360`
487
+ - `lifecycle.reinforceOnRecall`: `true`
467
488
 
468
489
  Important behavior:
469
490
 
470
491
  - `capture.mode = "local"`: heuristic-only extraction.
471
492
  - `capture.mode = "hybrid"`: heuristic extraction + ML enrichment when ML config is set.
472
493
  - `capture.mode = "ml"`: ML-first extraction; falls back to heuristic if ML config/call is unavailable.
494
+ - `capture.includeAssistant = false` (default): only `user` messages are considered for capture.
495
+ - `capture.includeAssistant = true`: both `user` and `assistant` messages are considered for capture.
473
496
  - ML calls run only when both `capture.ml.provider` and `capture.ml.model` are set.
497
+ - `timeDecay.enabled = true`: applies temporal decay to Mem0 results using Memory Core's `agents.*.memorySearch.query.hybrid.temporalDecay` settings.
498
+ - If Memory Core temporal decay is disabled, Mem0 decay is skipped even when `timeDecay.enabled = true`.
499
+ - `lifecycle.enabled = true`: tracks captured Mem0 IDs, applies TTL cleanup, and exposes `/memorybraid cleanup`.
500
+ - `lifecycle.reinforceOnRecall = true`: successful recalls refresh lifecycle timestamps, extending TTL survival for frequently used memories.
474
501
 
475
502
  ## Entity extraction defaults
476
503
 
@@ -493,6 +520,8 @@ When enabled:
493
520
  Warmup command:
494
521
 
495
522
  - `/memorybraid status`
523
+ - `/memorybraid stats`
524
+ - `/memorybraid cleanup`
496
525
  - `/memorybraid warmup`
497
526
  - `/memorybraid warmup --force`
498
527
 
@@ -516,10 +545,10 @@ Key events:
516
545
 
517
546
  - `memory_braid.startup`
518
547
  - `memory_braid.config`
519
- - `memory_braid.bootstrap.begin|complete|error`
520
- - `memory_braid.reconcile.begin|progress|complete|error`
521
548
  - `memory_braid.search.local|mem0|merge|inject|skip`
549
+ - `memory_braid.search.mem0_decay`
522
550
  - `memory_braid.capture.extract|ml|persist|skip`
551
+ - `memory_braid.lifecycle.reinforce|cleanup`
523
552
  - `memory_braid.entity.model_load|warmup|extract`
524
553
  - `memory_braid.mem0.request|response|error`
525
554
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "memory-braid",
3
3
  "name": "Memory Braid",
4
- "description": "Hybrid memory plugin that augments local core/QMD memory with Mem0 recall, capture, bootstrap import, and reconcile.",
4
+ "description": "Hybrid memory plugin that augments local core/QMD memory with Mem0 capture and recall.",
5
5
  "kind": "memory",
6
6
  "configSchema": {
7
7
  "type": "object",
@@ -52,6 +52,7 @@
52
52
  "enum": ["local", "hybrid", "ml"],
53
53
  "default": "local"
54
54
  },
55
+ "includeAssistant": { "type": "boolean", "default": false },
55
56
  "maxItemsPerRun": { "type": "integer", "minimum": 1, "maximum": 50, "default": 6 },
56
57
  "ml": {
57
58
  "type": "object",
@@ -93,29 +94,6 @@
93
94
  }
94
95
  }
95
96
  },
96
- "bootstrap": {
97
- "type": "object",
98
- "additionalProperties": false,
99
- "properties": {
100
- "enabled": { "type": "boolean", "default": true },
101
- "startupMode": { "type": "string", "enum": ["async"], "default": "async" },
102
- "includeMarkdown": { "type": "boolean", "default": true },
103
- "includeSessions": { "type": "boolean", "default": true },
104
- "sessionLookbackDays": { "type": "integer", "minimum": 1, "maximum": 3650, "default": 90 },
105
- "batchSize": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 50 },
106
- "concurrency": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3 }
107
- }
108
- },
109
- "reconcile": {
110
- "type": "object",
111
- "additionalProperties": false,
112
- "properties": {
113
- "enabled": { "type": "boolean", "default": true },
114
- "intervalMinutes": { "type": "integer", "minimum": 1, "maximum": 1440, "default": 30 },
115
- "batchSize": { "type": "integer", "minimum": 1, "maximum": 5000, "default": 100 },
116
- "deleteStale": { "type": "boolean", "default": true }
117
- }
118
- },
119
97
  "dedupe": {
120
98
  "type": "object",
121
99
  "additionalProperties": false,
@@ -137,6 +115,28 @@
137
115
  }
138
116
  }
139
117
  },
118
+ "timeDecay": {
119
+ "type": "object",
120
+ "additionalProperties": false,
121
+ "properties": {
122
+ "enabled": { "type": "boolean", "default": false }
123
+ }
124
+ },
125
+ "lifecycle": {
126
+ "type": "object",
127
+ "additionalProperties": false,
128
+ "properties": {
129
+ "enabled": { "type": "boolean", "default": false },
130
+ "captureTtlDays": { "type": "integer", "minimum": 1, "maximum": 3650, "default": 90 },
131
+ "cleanupIntervalMinutes": {
132
+ "type": "integer",
133
+ "minimum": 1,
134
+ "maximum": 10080,
135
+ "default": 360
136
+ },
137
+ "reinforceOnRecall": { "type": "boolean", "default": true }
138
+ }
139
+ },
140
140
  "debug": {
141
141
  "type": "object",
142
142
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memory-braid",
3
- "version": "0.3.7",
4
- "description": "OpenClaw memory plugin that augments local memory with Mem0, bootstrap import, reconcile, and capture.",
3
+ "version": "0.4.0",
4
+ "description": "OpenClaw memory plugin that augments local memory with Mem0 capture and recall.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "exports": {
package/src/chunking.ts CHANGED
@@ -1,7 +1,4 @@
1
1
  import crypto from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
4
- import type { ManagedSourceType, ManifestChunk, TargetWorkspace } from "./types.js";
5
2
 
6
3
  export function sha256(value: string): string {
7
4
  return crypto.createHash("sha256").update(value).digest("hex");
@@ -14,267 +11,3 @@ export function normalizeWhitespace(value: string): string {
14
11
  export function normalizeForHash(value: string): string {
15
12
  return normalizeWhitespace(value).toLowerCase();
16
13
  }
17
-
18
- function buildChunkKey(params: {
19
- workspaceHash: string;
20
- agentId: string;
21
- sourceType: ManagedSourceType;
22
- path: string;
23
- index: number;
24
- text: string;
25
- }): string {
26
- return sha256(
27
- [
28
- params.workspaceHash,
29
- params.agentId,
30
- params.sourceType,
31
- params.path,
32
- String(params.index),
33
- normalizeForHash(params.text),
34
- ].join("|"),
35
- );
36
- }
37
-
38
- export function chunkText(value: string, chunkSize = 1200, overlap = 180): string[] {
39
- const text = value.trim();
40
- if (!text) {
41
- return [];
42
- }
43
- const out: string[] = [];
44
- let cursor = 0;
45
- while (cursor < text.length) {
46
- const end = Math.min(text.length, cursor + chunkSize);
47
- out.push(text.slice(cursor, end).trim());
48
- if (end >= text.length) {
49
- break;
50
- }
51
- cursor = Math.max(cursor + 1, end - overlap);
52
- }
53
- return out.filter(Boolean);
54
- }
55
-
56
- async function walkMarkdownFiles(dir: string, out: string[]): Promise<void> {
57
- const entries = await fs.readdir(dir, { withFileTypes: true });
58
- for (const entry of entries) {
59
- if (entry.isSymbolicLink()) {
60
- continue;
61
- }
62
- const full = path.join(dir, entry.name);
63
- if (entry.isDirectory()) {
64
- await walkMarkdownFiles(full, out);
65
- continue;
66
- }
67
- if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
68
- out.push(full);
69
- }
70
- }
71
- }
72
-
73
- export async function listCanonicalMarkdownMemoryFiles(workspaceDir: string): Promise<string[]> {
74
- const result: string[] = [];
75
- const candidates = [path.join(workspaceDir, "MEMORY.md"), path.join(workspaceDir, "memory.md")];
76
- for (const candidate of candidates) {
77
- try {
78
- const stat = await fs.lstat(candidate);
79
- if (stat.isFile() && !stat.isSymbolicLink()) {
80
- result.push(candidate);
81
- }
82
- } catch {
83
- // ignore
84
- }
85
- }
86
-
87
- const memoryDir = path.join(workspaceDir, "memory");
88
- try {
89
- const stat = await fs.lstat(memoryDir);
90
- if (stat.isDirectory() && !stat.isSymbolicLink()) {
91
- await walkMarkdownFiles(memoryDir, result);
92
- }
93
- } catch {
94
- // ignore
95
- }
96
-
97
- return Array.from(new Set(result.map((filePath) => path.resolve(filePath))));
98
- }
99
-
100
- function normalizeSessionMessageText(content: unknown): string | null {
101
- if (typeof content === "string") {
102
- const normalized = normalizeWhitespace(content);
103
- return normalized || null;
104
- }
105
- if (!Array.isArray(content)) {
106
- return null;
107
- }
108
-
109
- const parts: string[] = [];
110
- for (const block of content) {
111
- if (!block || typeof block !== "object") {
112
- continue;
113
- }
114
- const item = block as { type?: unknown; text?: unknown };
115
- if (item.type === "text" && typeof item.text === "string") {
116
- const normalized = normalizeWhitespace(item.text);
117
- if (normalized) {
118
- parts.push(normalized);
119
- }
120
- }
121
- }
122
- if (parts.length === 0) {
123
- return null;
124
- }
125
- return parts.join(" ");
126
- }
127
-
128
- export async function listRecentSessionFiles(
129
- stateDir: string,
130
- agentId: string,
131
- lookbackDays: number,
132
- ): Promise<string[]> {
133
- const dir = path.join(stateDir, "agents", agentId, "sessions");
134
- const threshold = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
135
- try {
136
- const entries = await fs.readdir(dir, { withFileTypes: true });
137
- const files: string[] = [];
138
- for (const entry of entries) {
139
- if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
140
- continue;
141
- }
142
- const abs = path.join(dir, entry.name);
143
- try {
144
- const stat = await fs.stat(abs);
145
- if (stat.mtimeMs >= threshold) {
146
- files.push(abs);
147
- }
148
- } catch {
149
- // ignore single file failure
150
- }
151
- }
152
- return files;
153
- } catch {
154
- return [];
155
- }
156
- }
157
-
158
- export async function buildMarkdownChunks(target: TargetWorkspace): Promise<ManifestChunk[]> {
159
- const files = await listCanonicalMarkdownMemoryFiles(target.workspaceDir);
160
- const chunks: ManifestChunk[] = [];
161
-
162
- for (const filePath of files) {
163
- let raw = "";
164
- let statMtime = Date.now();
165
- try {
166
- raw = await fs.readFile(filePath, "utf8");
167
- const stat = await fs.stat(filePath);
168
- statMtime = stat.mtimeMs;
169
- } catch {
170
- continue;
171
- }
172
-
173
- const relPath = path.relative(target.workspaceDir, filePath).replace(/\\/g, "/");
174
- const pieces = chunkText(raw);
175
- pieces.forEach((piece, index) => {
176
- chunks.push({
177
- chunkKey: buildChunkKey({
178
- workspaceHash: target.workspaceHash,
179
- agentId: target.agentId,
180
- sourceType: "markdown",
181
- path: relPath,
182
- index,
183
- text: piece,
184
- }),
185
- contentHash: sha256(normalizeForHash(piece)),
186
- sourceType: "markdown",
187
- text: piece,
188
- path: relPath,
189
- workspaceHash: target.workspaceHash,
190
- agentId: target.agentId,
191
- updatedAt: statMtime,
192
- });
193
- });
194
- }
195
-
196
- return chunks;
197
- }
198
-
199
- export async function buildSessionChunks(
200
- target: TargetWorkspace,
201
- lookbackDays: number,
202
- ): Promise<ManifestChunk[]> {
203
- const files = await listRecentSessionFiles(target.stateDir, target.agentId, lookbackDays);
204
- const chunks: ManifestChunk[] = [];
205
-
206
- for (const filePath of files) {
207
- let raw = "";
208
- let statMtime = Date.now();
209
- try {
210
- raw = await fs.readFile(filePath, "utf8");
211
- const stat = await fs.stat(filePath);
212
- statMtime = stat.mtimeMs;
213
- } catch {
214
- continue;
215
- }
216
-
217
- const lines = raw.split("\n");
218
- const conversationParts: string[] = [];
219
- for (const line of lines) {
220
- if (!line.trim()) {
221
- continue;
222
- }
223
- let parsed: unknown;
224
- try {
225
- parsed = JSON.parse(line);
226
- } catch {
227
- continue;
228
- }
229
- if (!parsed || typeof parsed !== "object") {
230
- continue;
231
- }
232
- const record = parsed as { type?: unknown; message?: unknown };
233
- if (record.type !== "message") {
234
- continue;
235
- }
236
- const message = record.message as { role?: unknown; content?: unknown } | undefined;
237
- if (!message || typeof message.role !== "string") {
238
- continue;
239
- }
240
- if (message.role !== "user" && message.role !== "assistant") {
241
- continue;
242
- }
243
- const text = normalizeSessionMessageText(message.content);
244
- if (!text) {
245
- continue;
246
- }
247
- const roleLabel = message.role === "user" ? "User" : "Assistant";
248
- conversationParts.push(`${roleLabel}: ${text}`);
249
- }
250
-
251
- if (conversationParts.length === 0) {
252
- continue;
253
- }
254
-
255
- const sessionText = conversationParts.join("\n");
256
- const sessionRelPath = path.join("sessions", target.agentId, path.basename(filePath)).replace(/\\/g, "/");
257
- const pieces = chunkText(sessionText);
258
- pieces.forEach((piece, index) => {
259
- chunks.push({
260
- chunkKey: buildChunkKey({
261
- workspaceHash: target.workspaceHash,
262
- agentId: target.agentId,
263
- sourceType: "session",
264
- path: sessionRelPath,
265
- index,
266
- text: piece,
267
- }),
268
- contentHash: sha256(normalizeForHash(piece)),
269
- sourceType: "session",
270
- text: piece,
271
- path: sessionRelPath,
272
- workspaceHash: target.workspaceHash,
273
- agentId: target.agentId,
274
- updatedAt: statMtime,
275
- });
276
- });
277
- }
278
-
279
- return chunks;
280
- }