memory-braid 0.5.0 → 0.6.1

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
@@ -488,7 +488,17 @@ Use this preset when:
488
488
  "memory-braid": {
489
489
  "recall": {
490
490
  "maxResults": 8,
491
- "injectTopK": 5,
491
+ "injectTopK": 4,
492
+ "user": {
493
+ "enabled": true,
494
+ "injectTopK": 4
495
+ },
496
+ "agent": {
497
+ "enabled": true,
498
+ "injectTopK": 2,
499
+ "minScore": 0.78,
500
+ "onlyPlanning": true
501
+ },
492
502
  "merge": {
493
503
  "rrfK": 60,
494
504
  "localWeight": 1,
@@ -500,6 +510,16 @@ Use this preset when:
500
510
  "mode": "hybrid",
501
511
  "includeAssistant": false,
502
512
  "maxItemsPerRun": 6,
513
+ "assistant": {
514
+ "enabled": true,
515
+ "autoCapture": false,
516
+ "explicitTool": true,
517
+ "maxItemsPerRun": 2,
518
+ "minUtilityScore": 0.8,
519
+ "minNoveltyScore": 0.85,
520
+ "maxWritesPerSessionWindow": 3,
521
+ "cooldownMinutes": 5
522
+ },
503
523
  "ml": {
504
524
  "provider": "openai",
505
525
  "model": "gpt-4o-mini",
@@ -548,8 +568,20 @@ Capture defaults are:
548
568
 
549
569
  - `capture.enabled`: `true`
550
570
  - `capture.mode`: `"local"`
551
- - `capture.includeAssistant`: `false` (default user-only capture)
571
+ - `capture.includeAssistant`: `false` (legacy alias for `capture.assistant.autoCapture`)
552
572
  - `capture.maxItemsPerRun`: `6`
573
+ - `capture.assistant.enabled`: `true`
574
+ - `capture.assistant.autoCapture`: `false`
575
+ - `capture.assistant.explicitTool`: `true`
576
+ - `capture.assistant.maxItemsPerRun`: `2`
577
+ - `capture.assistant.minUtilityScore`: `0.8`
578
+ - `capture.assistant.minNoveltyScore`: `0.85`
579
+ - `capture.assistant.maxWritesPerSessionWindow`: `3`
580
+ - `capture.assistant.cooldownMinutes`: `5`
581
+ - `recall.user.injectTopK`: `5` (legacy `recall.injectTopK` still works)
582
+ - `recall.agent.injectTopK`: `2`
583
+ - `recall.agent.minScore`: `0.78`
584
+ - `recall.agent.onlyPlanning`: `true`
553
585
  - `capture.ml.provider`: unset
554
586
  - `capture.ml.model`: unset
555
587
  - `capture.ml.timeoutMs`: `2500`
@@ -564,14 +596,39 @@ Important behavior:
564
596
  - `capture.mode = "local"`: heuristic-only extraction.
565
597
  - `capture.mode = "hybrid"`: heuristic extraction + ML enrichment when ML config is set.
566
598
  - `capture.mode = "ml"`: ML-first extraction; falls back to heuristic if ML config/call is unavailable.
567
- - `capture.includeAssistant = false` (default): only `user` messages are considered for capture.
568
- - `capture.includeAssistant = true`: both `user` and `assistant` messages are considered for capture.
599
+ - New memories are persisted by `workspace + agent`, not by session. `sessionKey` is kept only as metadata and for assistant-learning cooldown/window logic.
600
+ - Recall still performs a legacy dual-read fallback for older session-scoped Mem0 records, without rewriting them.
601
+ - `capture.includeAssistant = false` (default): assistant auto-capture is off.
602
+ - `capture.includeAssistant = true` or `capture.assistant.autoCapture = true`: assistant messages are eligible for strict agent-learning auto-capture.
603
+ - `capture.assistant.explicitTool = true`: exposes the `remember_learning` tool.
604
+ - `recall.user.*` controls injected user memories.
605
+ - `recall.agent.*` controls injected agent learnings.
569
606
  - ML calls run only when both `capture.ml.provider` and `capture.ml.model` are set.
570
607
  - `timeDecay.enabled = true`: applies temporal decay to Mem0 results using Memory Core's `agents.*.memorySearch.query.hybrid.temporalDecay` settings.
571
608
  - If Memory Core temporal decay is disabled, Mem0 decay is skipped even when `timeDecay.enabled = true`.
572
609
  - `lifecycle.enabled = true`: tracks captured Mem0 IDs, applies TTL cleanup, and exposes `/memorybraid cleanup`.
573
610
  - `lifecycle.reinforceOnRecall = true`: successful recalls refresh lifecycle timestamps, extending TTL survival for frequently used memories.
574
611
 
612
+ ## Agent learnings
613
+
614
+ Memory Braid v2 adds explicit and implicit agent learnings.
615
+
616
+ - `remember_learning` stores compact reusable heuristics, lessons, and strategies for future runs.
617
+ - Use it for operational guidance that helps the agent avoid repeated mistakes or reduce tool cost/noise.
618
+ - Do not use it for long summaries, transient details, or raw reasoning.
619
+ - Assistant auto-capture is still available, but it is stricter than user-memory capture and only persists compact learnings that pass utility, novelty, and cooldown checks.
620
+
621
+ Recall is now split into two dynamic blocks:
622
+
623
+ - `<user-memories>`: user facts, preferences, decisions, and tasks.
624
+ - `<agent-learnings>`: reusable agent heuristics, lessons, and strategies.
625
+
626
+ Cache safety:
627
+
628
+ - Tool awareness for `remember_learning` is injected through a stable `systemPrompt`.
629
+ - Retrieved memories stay in dynamic `prependContext`, not in the stable prompt body.
630
+ - Agent learnings use low `top-k`, high relevance thresholds, and deterministic formatting to avoid unnecessary prompt churn.
631
+
575
632
  ## Entity extraction defaults
576
633
 
577
634
  Entity extraction defaults are:
@@ -30,6 +30,24 @@
30
30
  "properties": {
31
31
  "maxResults": { "type": "integer", "minimum": 1, "maximum": 50, "default": 8 },
32
32
  "injectTopK": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 },
33
+ "user": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "enabled": { "type": "boolean", "default": true },
38
+ "injectTopK": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 }
39
+ }
40
+ },
41
+ "agent": {
42
+ "type": "object",
43
+ "additionalProperties": false,
44
+ "properties": {
45
+ "enabled": { "type": "boolean", "default": true },
46
+ "injectTopK": { "type": "integer", "minimum": 1, "maximum": 20, "default": 2 },
47
+ "minScore": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.78 },
48
+ "onlyPlanning": { "type": "boolean", "default": true }
49
+ }
50
+ },
33
51
  "merge": {
34
52
  "type": "object",
35
53
  "additionalProperties": false,
@@ -54,6 +72,20 @@
54
72
  },
55
73
  "includeAssistant": { "type": "boolean", "default": false },
56
74
  "maxItemsPerRun": { "type": "integer", "minimum": 1, "maximum": 50, "default": 6 },
75
+ "assistant": {
76
+ "type": "object",
77
+ "additionalProperties": false,
78
+ "properties": {
79
+ "enabled": { "type": "boolean", "default": true },
80
+ "autoCapture": { "type": "boolean", "default": false },
81
+ "explicitTool": { "type": "boolean", "default": true },
82
+ "maxItemsPerRun": { "type": "integer", "minimum": 1, "maximum": 10, "default": 2 },
83
+ "minUtilityScore": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.8 },
84
+ "minNoveltyScore": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.85 },
85
+ "maxWritesPerSessionWindow": { "type": "integer", "minimum": 1, "maximum": 20, "default": 3 },
86
+ "cooldownMinutes": { "type": "integer", "minimum": 0, "maximum": 240, "default": 5 }
87
+ }
88
+ },
57
89
  "ml": {
58
90
  "type": "object",
59
91
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-braid",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "OpenClaw memory plugin that augments local memory with Mem0 capture and recall.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/capture.ts CHANGED
@@ -17,9 +17,70 @@ function asRecord(value: unknown): Record<string, unknown> {
17
17
  return value as Record<string, unknown>;
18
18
  }
19
19
 
20
+ function extractStructuredTextCandidate(value: unknown, depth = 0): string {
21
+ if (depth > 5) {
22
+ return "";
23
+ }
24
+ if (typeof value === "string") {
25
+ return normalizeWhitespace(value);
26
+ }
27
+ if (!value || typeof value !== "object") {
28
+ return "";
29
+ }
30
+ if (Array.isArray(value)) {
31
+ const parts = value
32
+ .map((entry) => extractStructuredTextCandidate(entry, depth + 1))
33
+ .filter(Boolean);
34
+ return normalizeWhitespace(parts.join(" "));
35
+ }
36
+
37
+ const record = value as Record<string, unknown>;
38
+ const directText = typeof record.text === "string" ? normalizeWhitespace(record.text) : "";
39
+ if (directText) {
40
+ return directText;
41
+ }
42
+ const caption = typeof record.caption === "string" ? normalizeWhitespace(record.caption) : "";
43
+ if (caption) {
44
+ return caption;
45
+ }
46
+
47
+ const nestedCandidates = [
48
+ record.message,
49
+ record.data,
50
+ record.payload,
51
+ record.update,
52
+ record.edited_message,
53
+ record.channel_post,
54
+ record.callback_query,
55
+ ];
56
+ for (const candidate of nestedCandidates) {
57
+ const extracted = extractStructuredTextCandidate(candidate, depth + 1);
58
+ if (extracted) {
59
+ return extracted;
60
+ }
61
+ }
62
+
63
+ return "";
64
+ }
65
+
66
+ export function extractStructuredTextFromString(content: string): string | undefined {
67
+ const normalized = normalizeWhitespace(content);
68
+ if (!normalized || !/^[{\[]/.test(normalized)) {
69
+ return undefined;
70
+ }
71
+
72
+ try {
73
+ const parsed = JSON.parse(normalized) as unknown;
74
+ const extracted = extractStructuredTextCandidate(parsed);
75
+ return extracted || undefined;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
20
81
  export function extractHookMessageText(content: unknown): string {
21
82
  if (typeof content === "string") {
22
- return normalizeWhitespace(content);
83
+ return extractStructuredTextFromString(content) ?? normalizeWhitespace(content);
23
84
  }
24
85
  if (!Array.isArray(content)) {
25
86
  return "";
@@ -32,7 +93,8 @@ export function extractHookMessageText(content: unknown): string {
32
93
  }
33
94
  const item = block as { type?: unknown; text?: unknown };
34
95
  if (item.type === "text" && typeof item.text === "string") {
35
- const normalized = normalizeWhitespace(item.text);
96
+ const normalized =
97
+ extractStructuredTextFromString(item.text) ?? normalizeWhitespace(item.text);
36
98
  if (normalized) {
37
99
  parts.push(normalized);
38
100
  }
@@ -249,3 +311,67 @@ export function isOversizedAtomicMemory(text: string): boolean {
249
311
  const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0);
250
312
  return normalized.length > 1600 || lines.length > 18;
251
313
  }
314
+
315
+ const RECAP_PREFIXES = [
316
+ /^the user\b/i,
317
+ /^user\b/i,
318
+ /^usuario\b/i,
319
+ /^in this (?:turn|conversation)\b/i,
320
+ /^(?:we|i) (?:discussed|talked about|went over|covered)\b/i,
321
+ /^(?:summary|recap)\b/i,
322
+ ];
323
+
324
+ const TEMPORAL_REFERENCE_PATTERN =
325
+ /\b(?:today|tomorrow|yesterday|this turn|this session|earlier in this session|just now|in this chat)\b/i;
326
+
327
+ export function isLikelyTurnRecap(text: string): boolean {
328
+ const normalized = normalizeWhitespace(text);
329
+ if (!normalized) {
330
+ return false;
331
+ }
332
+ if (normalized.length > 260 && /\b(?:asked|wanted|needed|said|requested)\b/i.test(normalized)) {
333
+ return true;
334
+ }
335
+ return RECAP_PREFIXES.some((pattern) => pattern.test(normalized));
336
+ }
337
+
338
+ function splitIntoSentences(text: string): string[] {
339
+ return text
340
+ .split(/(?<=[.!?])\s+/)
341
+ .map((sentence) => normalizeWhitespace(sentence))
342
+ .filter(Boolean);
343
+ }
344
+
345
+ function looksReusableLearning(text: string): boolean {
346
+ if (text.length < 24 || text.length > 220) {
347
+ return false;
348
+ }
349
+ if (TEMPORAL_REFERENCE_PATTERN.test(text)) {
350
+ return false;
351
+ }
352
+ if (isLikelyTranscriptLikeText(text) || isLikelyTurnRecap(text)) {
353
+ return false;
354
+ }
355
+ return /\b(?:prefer|avoid|use|keep|store|remember|dedupe|inject|search|persist|reject|limit|filter|only|always|never|when)\b/i.test(
356
+ text,
357
+ );
358
+ }
359
+
360
+ export function compactAgentLearning(text: string): string | undefined {
361
+ const normalized = normalizeWhitespace(text);
362
+ if (!normalized || isOversizedAtomicMemory(normalized) || isLikelyTranscriptLikeText(normalized)) {
363
+ return undefined;
364
+ }
365
+ if (looksReusableLearning(normalized)) {
366
+ return normalized;
367
+ }
368
+
369
+ const sentences = splitIntoSentences(normalized);
370
+ for (const sentence of sentences) {
371
+ if (looksReusableLearning(sentence)) {
372
+ return sentence;
373
+ }
374
+ }
375
+
376
+ return undefined;
377
+ }
package/src/config.ts CHANGED
@@ -11,6 +11,16 @@ export type MemoryBraidConfig = {
11
11
  recall: {
12
12
  maxResults: number;
13
13
  injectTopK: number;
14
+ user: {
15
+ enabled: boolean;
16
+ injectTopK: number;
17
+ };
18
+ agent: {
19
+ enabled: boolean;
20
+ injectTopK: number;
21
+ minScore: number;
22
+ onlyPlanning: boolean;
23
+ };
14
24
  merge: {
15
25
  strategy: "rrf";
16
26
  rrfK: number;
@@ -23,6 +33,16 @@ export type MemoryBraidConfig = {
23
33
  mode: "local" | "hybrid" | "ml";
24
34
  includeAssistant: boolean;
25
35
  maxItemsPerRun: number;
36
+ assistant: {
37
+ enabled: boolean;
38
+ autoCapture: boolean;
39
+ explicitTool: boolean;
40
+ maxItemsPerRun: number;
41
+ minUtilityScore: number;
42
+ minNoveltyScore: number;
43
+ maxWritesPerSessionWindow: number;
44
+ cooldownMinutes: number;
45
+ };
26
46
  ml: {
27
47
  provider?: "openai" | "anthropic" | "gemini";
28
48
  model?: string;
@@ -80,6 +100,16 @@ const DEFAULTS: MemoryBraidConfig = {
80
100
  recall: {
81
101
  maxResults: 8,
82
102
  injectTopK: 5,
103
+ user: {
104
+ enabled: true,
105
+ injectTopK: 5,
106
+ },
107
+ agent: {
108
+ enabled: true,
109
+ injectTopK: 2,
110
+ minScore: 0.78,
111
+ onlyPlanning: true,
112
+ },
83
113
  merge: {
84
114
  strategy: "rrf",
85
115
  rrfK: 60,
@@ -92,6 +122,16 @@ const DEFAULTS: MemoryBraidConfig = {
92
122
  mode: "local",
93
123
  includeAssistant: false,
94
124
  maxItemsPerRun: 6,
125
+ assistant: {
126
+ enabled: true,
127
+ autoCapture: false,
128
+ explicitTool: true,
129
+ maxItemsPerRun: 2,
130
+ minUtilityScore: 0.8,
131
+ minNoveltyScore: 0.85,
132
+ maxWritesPerSessionWindow: 3,
133
+ cooldownMinutes: 5,
134
+ },
95
135
  ml: {
96
136
  provider: undefined,
97
137
  model: undefined,
@@ -168,8 +208,11 @@ export function parseConfig(raw: unknown): MemoryBraidConfig {
168
208
  const root = asRecord(raw);
169
209
  const mem0 = asRecord(root.mem0);
170
210
  const recall = asRecord(root.recall);
211
+ const recallUser = asRecord(recall.user);
212
+ const recallAgent = asRecord(recall.agent);
171
213
  const merge = asRecord(recall.merge);
172
214
  const capture = asRecord(root.capture);
215
+ const captureAssistant = asRecord(capture.assistant);
173
216
  const entityExtraction = asRecord(root.entityExtraction);
174
217
  const entityStartup = asRecord(entityExtraction.startup);
175
218
  const ml = asRecord(capture.ml);
@@ -194,6 +237,16 @@ export function parseConfig(raw: unknown): MemoryBraidConfig {
194
237
  ? parsedEntityModel
195
238
  : "gpt-4o-mini"
196
239
  : parsedEntityModel ?? DEFAULTS.entityExtraction.model;
240
+ const includeAssistant = asBoolean(
241
+ capture.includeAssistant,
242
+ DEFAULTS.capture.includeAssistant,
243
+ );
244
+ const legacyInjectTopK = asInt(
245
+ recall.injectTopK,
246
+ DEFAULTS.recall.injectTopK,
247
+ 1,
248
+ 20,
249
+ );
197
250
 
198
251
  return {
199
252
  enabled: asBoolean(root.enabled, DEFAULTS.enabled),
@@ -207,7 +260,35 @@ export function parseConfig(raw: unknown): MemoryBraidConfig {
207
260
  },
208
261
  recall: {
209
262
  maxResults: asInt(recall.maxResults, DEFAULTS.recall.maxResults, 1, 50),
210
- injectTopK: asInt(recall.injectTopK, DEFAULTS.recall.injectTopK, 1, 20),
263
+ injectTopK: legacyInjectTopK,
264
+ user: {
265
+ enabled: asBoolean(recallUser.enabled, DEFAULTS.recall.user.enabled),
266
+ injectTopK: asInt(
267
+ recallUser.injectTopK,
268
+ legacyInjectTopK,
269
+ 1,
270
+ 20,
271
+ ),
272
+ },
273
+ agent: {
274
+ enabled: asBoolean(recallAgent.enabled, DEFAULTS.recall.agent.enabled),
275
+ injectTopK: asInt(
276
+ recallAgent.injectTopK,
277
+ DEFAULTS.recall.agent.injectTopK,
278
+ 1,
279
+ 20,
280
+ ),
281
+ minScore: asNumber(
282
+ recallAgent.minScore,
283
+ DEFAULTS.recall.agent.minScore,
284
+ 0,
285
+ 1,
286
+ ),
287
+ onlyPlanning: asBoolean(
288
+ recallAgent.onlyPlanning,
289
+ DEFAULTS.recall.agent.onlyPlanning,
290
+ ),
291
+ },
211
292
  merge: {
212
293
  strategy: "rrf",
213
294
  rrfK: asInt(merge.rrfK, DEFAULTS.recall.merge.rrfK, 1, 500),
@@ -218,8 +299,52 @@ export function parseConfig(raw: unknown): MemoryBraidConfig {
218
299
  capture: {
219
300
  enabled: asBoolean(capture.enabled, DEFAULTS.capture.enabled),
220
301
  mode: captureMode,
221
- includeAssistant: asBoolean(capture.includeAssistant, DEFAULTS.capture.includeAssistant),
302
+ includeAssistant,
222
303
  maxItemsPerRun: asInt(capture.maxItemsPerRun, DEFAULTS.capture.maxItemsPerRun, 1, 50),
304
+ assistant: {
305
+ enabled: asBoolean(
306
+ captureAssistant.enabled,
307
+ DEFAULTS.capture.assistant.enabled,
308
+ ),
309
+ autoCapture: asBoolean(
310
+ captureAssistant.autoCapture,
311
+ includeAssistant,
312
+ ),
313
+ explicitTool: asBoolean(
314
+ captureAssistant.explicitTool,
315
+ DEFAULTS.capture.assistant.explicitTool,
316
+ ),
317
+ maxItemsPerRun: asInt(
318
+ captureAssistant.maxItemsPerRun,
319
+ DEFAULTS.capture.assistant.maxItemsPerRun,
320
+ 1,
321
+ 10,
322
+ ),
323
+ minUtilityScore: asNumber(
324
+ captureAssistant.minUtilityScore,
325
+ DEFAULTS.capture.assistant.minUtilityScore,
326
+ 0,
327
+ 1,
328
+ ),
329
+ minNoveltyScore: asNumber(
330
+ captureAssistant.minNoveltyScore,
331
+ DEFAULTS.capture.assistant.minNoveltyScore,
332
+ 0,
333
+ 1,
334
+ ),
335
+ maxWritesPerSessionWindow: asInt(
336
+ captureAssistant.maxWritesPerSessionWindow,
337
+ DEFAULTS.capture.assistant.maxWritesPerSessionWindow,
338
+ 1,
339
+ 20,
340
+ ),
341
+ cooldownMinutes: asInt(
342
+ captureAssistant.cooldownMinutes,
343
+ DEFAULTS.capture.assistant.cooldownMinutes,
344
+ 0,
345
+ 240,
346
+ ),
347
+ },
223
348
  ml: {
224
349
  provider:
225
350
  ml.provider === "openai" || ml.provider === "anthropic" || ml.provider === "gemini"
package/src/extract.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { normalizeForHash, normalizeWhitespace, sha256 } from "./chunking.js";
2
2
  import type { MemoryBraidConfig } from "./config.js";
3
- import { isLikelyTranscriptLikeText, isOversizedAtomicMemory } from "./capture.js";
3
+ import {
4
+ extractStructuredTextFromString,
5
+ isLikelyTranscriptLikeText,
6
+ isOversizedAtomicMemory,
7
+ } from "./capture.js";
4
8
  import { MemoryBraidLogger } from "./logger.js";
5
9
  import type { ExtractedCandidate } from "./types.js";
6
10
 
@@ -47,7 +51,7 @@ function isLikelyFeedOrImportedText(text: string): boolean {
47
51
 
48
52
  function extractMessageText(content: unknown): string {
49
53
  if (typeof content === "string") {
50
- return normalizeWhitespace(content);
54
+ return extractStructuredTextFromString(content) ?? normalizeWhitespace(content);
51
55
  }
52
56
  if (!Array.isArray(content)) {
53
57
  return "";
@@ -59,7 +63,8 @@ function extractMessageText(content: unknown): string {
59
63
  }
60
64
  const item = block as { type?: unknown; text?: unknown };
61
65
  if (item.type === "text" && typeof item.text === "string") {
62
- const normalized = normalizeWhitespace(item.text);
66
+ const normalized =
67
+ extractStructuredTextFromString(item.text) ?? normalizeWhitespace(item.text);
63
68
  if (normalized) {
64
69
  parts.push(normalized);
65
70
  }
@@ -432,7 +437,7 @@ export async function extractCandidates(params: {
432
437
  runId?: string;
433
438
  }): Promise<ExtractedCandidate[]> {
434
439
  const normalized = normalizeMessages(params.messages);
435
- const captureFromAssistant = params.cfg.capture.includeAssistant;
440
+ const captureFromAssistant = params.cfg.capture.assistant.autoCapture;
436
441
  const candidatesInput = normalized.filter((message) =>
437
442
  captureFromAssistant
438
443
  ? message.role === "user" || message.role === "assistant"