memory-braid 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-braid",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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/entities.ts CHANGED
@@ -11,6 +11,8 @@ type NerRecord = {
11
11
  entity_group?: unknown;
12
12
  entity?: unknown;
13
13
  score?: unknown;
14
+ start?: unknown;
15
+ end?: unknown;
14
16
  };
15
17
 
16
18
  export type ExtractedEntity = {
@@ -79,6 +81,76 @@ function normalizeEntityText(raw: unknown): string {
79
81
  return normalizeWhitespace(raw.replace(/^##/, "").replace(/^▁/, ""));
80
82
  }
81
83
 
84
+ type NormalizedEntityToken = {
85
+ text: string;
86
+ type: ExtractedEntity["type"];
87
+ score: number;
88
+ start?: number;
89
+ end?: number;
90
+ };
91
+
92
+ function asFiniteNumber(value: unknown): number | undefined {
93
+ if (typeof value !== "number" || !Number.isFinite(value)) {
94
+ return undefined;
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function joinEntityText(left: NormalizedEntityToken, right: NormalizedEntityToken): string {
100
+ const leftEnd = left.end;
101
+ const rightStart = right.start;
102
+ if (typeof leftEnd === "number" && typeof rightStart === "number") {
103
+ const gap = rightStart - leftEnd;
104
+ if (gap <= 0) {
105
+ return `${left.text}${right.text}`;
106
+ }
107
+ }
108
+ return `${left.text} ${right.text}`;
109
+ }
110
+
111
+ function shouldMergeEntityTokens(left: NormalizedEntityToken, right: NormalizedEntityToken): boolean {
112
+ if (left.type !== right.type || !left.text || !right.text) {
113
+ return false;
114
+ }
115
+
116
+ const leftEnd = left.end;
117
+ const rightStart = right.start;
118
+ if (typeof leftEnd === "number" && typeof rightStart === "number") {
119
+ const gap = rightStart - leftEnd;
120
+ if (gap < 0) {
121
+ return false;
122
+ }
123
+ return gap <= 1;
124
+ }
125
+
126
+ if (/[.,!?;:]$/.test(left.text) || /^[.,!?;:]/.test(right.text)) {
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ function collapseAdjacentEntityTokens(tokens: NormalizedEntityToken[]): NormalizedEntityToken[] {
133
+ if (tokens.length <= 1) {
134
+ return tokens;
135
+ }
136
+
137
+ const collapsed: NormalizedEntityToken[] = [];
138
+ for (const token of tokens) {
139
+ const previous = collapsed[collapsed.length - 1];
140
+ if (!previous || !shouldMergeEntityTokens(previous, token)) {
141
+ collapsed.push({ ...token });
142
+ continue;
143
+ }
144
+
145
+ previous.text = normalizeWhitespace(joinEntityText(previous, token));
146
+ previous.score = Math.min(previous.score, token.score);
147
+ previous.start = typeof previous.start === "number" ? previous.start : token.start;
148
+ previous.end = typeof token.end === "number" ? token.end : previous.end;
149
+ }
150
+
151
+ return collapsed;
152
+ }
153
+
82
154
  type EntityExtractionOptions = {
83
155
  stateDir?: string;
84
156
  };
@@ -319,7 +391,7 @@ export class EntityExtractionManager {
319
391
  });
320
392
  const rows = Array.isArray(raw) ? raw : [];
321
393
 
322
- const deduped = new Map<string, ExtractedEntity>();
394
+ const normalized: NormalizedEntityToken[] = [];
323
395
  for (const row of rows) {
324
396
  if (!row || typeof row !== "object") {
325
397
  continue;
@@ -335,13 +407,25 @@ export class EntityExtractionManager {
335
407
  }
336
408
 
337
409
  const type = normalizeEntityType(record.entity_group ?? record.entity);
338
- const canonicalUri = buildCanonicalEntityUri(type, entityText);
410
+ normalized.push({
411
+ text: entityText,
412
+ type,
413
+ score,
414
+ start: asFiniteNumber(record.start),
415
+ end: asFiniteNumber(record.end),
416
+ });
417
+ }
418
+
419
+ const collapsed = collapseAdjacentEntityTokens(normalized);
420
+ const deduped = new Map<string, ExtractedEntity>();
421
+ for (const token of collapsed) {
422
+ const canonicalUri = buildCanonicalEntityUri(token.type, token.text);
339
423
  const current = deduped.get(canonicalUri);
340
- if (!current || score > current.score) {
424
+ if (!current || token.score > current.score) {
341
425
  deduped.set(canonicalUri, {
342
- text: entityText,
343
- type,
344
- score,
426
+ text: token.text,
427
+ type: token.type,
428
+ score: token.score,
345
429
  canonicalUri,
346
430
  });
347
431
  }
package/src/index.ts CHANGED
@@ -105,6 +105,315 @@ function asRecord(value: unknown): Record<string, unknown> {
105
105
  return value as Record<string, unknown>;
106
106
  }
107
107
 
108
+ const OVERLAP_STOPWORDS = new Set([
109
+ "a",
110
+ "an",
111
+ "and",
112
+ "are",
113
+ "as",
114
+ "at",
115
+ "be",
116
+ "by",
117
+ "for",
118
+ "from",
119
+ "how",
120
+ "i",
121
+ "in",
122
+ "is",
123
+ "it",
124
+ "my",
125
+ "of",
126
+ "on",
127
+ "or",
128
+ "our",
129
+ "that",
130
+ "the",
131
+ "this",
132
+ "to",
133
+ "we",
134
+ "with",
135
+ "you",
136
+ "your",
137
+ "de",
138
+ "del",
139
+ "el",
140
+ "en",
141
+ "es",
142
+ "la",
143
+ "las",
144
+ "los",
145
+ "mi",
146
+ "mis",
147
+ "para",
148
+ "por",
149
+ "que",
150
+ "se",
151
+ "su",
152
+ "sus",
153
+ "un",
154
+ "una",
155
+ "y",
156
+ ]);
157
+
158
+ function normalizeToken(value: string): string {
159
+ return value
160
+ .toLowerCase()
161
+ .normalize("NFKD")
162
+ .replace(/\p{M}+/gu, "");
163
+ }
164
+
165
+ function tokenizeForOverlap(text: string): Set<string> {
166
+ const tokens = text.match(/[\p{L}\p{N}]+/gu) ?? [];
167
+ const out = new Set<string>();
168
+ for (const token of tokens) {
169
+ const normalized = normalizeToken(token);
170
+ if (normalized.length < 3 || OVERLAP_STOPWORDS.has(normalized)) {
171
+ continue;
172
+ }
173
+ out.add(normalized);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ function lexicalOverlap(queryTokens: Set<string>, text: string): { shared: number; ratio: number } {
179
+ if (queryTokens.size === 0) {
180
+ return { shared: 0, ratio: 0 };
181
+ }
182
+ const textTokens = tokenizeForOverlap(text);
183
+ let shared = 0;
184
+ for (const token of queryTokens) {
185
+ if (textTokens.has(token)) {
186
+ shared += 1;
187
+ }
188
+ }
189
+ return {
190
+ shared,
191
+ ratio: shared / queryTokens.size,
192
+ };
193
+ }
194
+
195
+ function normalizeCategory(raw: unknown): "preference" | "decision" | "fact" | "task" | "other" | undefined {
196
+ if (typeof raw !== "string") {
197
+ return undefined;
198
+ }
199
+ const normalized = raw.trim().toLowerCase();
200
+ if (
201
+ normalized === "preference" ||
202
+ normalized === "decision" ||
203
+ normalized === "fact" ||
204
+ normalized === "task" ||
205
+ normalized === "other"
206
+ ) {
207
+ return normalized;
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ function normalizeSessionKey(raw: unknown): string | undefined {
213
+ if (typeof raw !== "string") {
214
+ return undefined;
215
+ }
216
+ const trimmed = raw.trim();
217
+ return trimmed || undefined;
218
+ }
219
+
220
+ function isGenericUserSummary(text: string): boolean {
221
+ const normalized = text.trim().toLowerCase();
222
+ return (
223
+ /^(the user|user|usuario)\b/.test(normalized) ||
224
+ /\b(user|usuario)\s+(asked|wants|needs|prefers|likes|said)\b/.test(normalized)
225
+ );
226
+ }
227
+
228
+ function applyMem0QualityAdjustments(params: {
229
+ results: MemoryBraidResult[];
230
+ query: string;
231
+ scope: ScopeKey;
232
+ nowMs: number;
233
+ }): {
234
+ results: MemoryBraidResult[];
235
+ adjusted: number;
236
+ overlapBoosted: number;
237
+ overlapPenalized: number;
238
+ categoryPenalized: number;
239
+ sessionBoosted: number;
240
+ sessionPenalized: number;
241
+ genericPenalized: number;
242
+ } {
243
+ if (params.results.length === 0) {
244
+ return {
245
+ results: params.results,
246
+ adjusted: 0,
247
+ overlapBoosted: 0,
248
+ overlapPenalized: 0,
249
+ categoryPenalized: 0,
250
+ sessionBoosted: 0,
251
+ sessionPenalized: 0,
252
+ genericPenalized: 0,
253
+ };
254
+ }
255
+
256
+ const queryTokens = tokenizeForOverlap(params.query);
257
+ let adjusted = 0;
258
+ let overlapBoosted = 0;
259
+ let overlapPenalized = 0;
260
+ let categoryPenalized = 0;
261
+ let sessionBoosted = 0;
262
+ let sessionPenalized = 0;
263
+ let genericPenalized = 0;
264
+
265
+ const next = params.results.map((result, index) => {
266
+ let multiplier = 1;
267
+ const metadata = asRecord(result.metadata);
268
+ const overlap = lexicalOverlap(queryTokens, result.snippet);
269
+ const category = normalizeCategory(metadata.category);
270
+ const isGeneric = isGenericUserSummary(result.snippet);
271
+ const ts = resolveTimestampMs(result);
272
+ const ageDays = ts ? Math.max(0, (params.nowMs - ts) / (24 * 60 * 60 * 1000)) : undefined;
273
+
274
+ if ((category === "task" || category === "other") && typeof ageDays === "number") {
275
+ if (ageDays >= 30) {
276
+ multiplier *= 0.5;
277
+ categoryPenalized += 1;
278
+ } else if (ageDays >= 7) {
279
+ multiplier *= category === "task" ? 0.65 : 0.72;
280
+ categoryPenalized += 1;
281
+ }
282
+ } else if (typeof ageDays === "number" && ageDays >= 180) {
283
+ multiplier *= 0.9;
284
+ categoryPenalized += 1;
285
+ }
286
+
287
+ if (queryTokens.size > 0) {
288
+ if (overlap.shared >= 2 || overlap.ratio >= 0.45) {
289
+ multiplier *= 1.25;
290
+ overlapBoosted += 1;
291
+ } else if (overlap.shared === 1 || overlap.ratio >= 0.2) {
292
+ multiplier *= 1.1;
293
+ overlapBoosted += 1;
294
+ } else {
295
+ multiplier *= 0.62;
296
+ overlapPenalized += 1;
297
+ }
298
+ }
299
+
300
+ const metadataSession =
301
+ normalizeSessionKey(metadata.sessionKey) ??
302
+ normalizeSessionKey(metadata.runId) ??
303
+ normalizeSessionKey(metadata.run_id);
304
+ if (params.scope.sessionKey && metadataSession) {
305
+ if (metadataSession === params.scope.sessionKey) {
306
+ multiplier *= 1.1;
307
+ sessionBoosted += 1;
308
+ } else {
309
+ multiplier *= 0.82;
310
+ sessionPenalized += 1;
311
+ }
312
+ }
313
+
314
+ if (isGeneric && overlap.ratio < 0.2 && overlap.shared < 2) {
315
+ multiplier *= 0.6;
316
+ genericPenalized += 1;
317
+ }
318
+
319
+ const normalizedMultiplier = Math.min(2.5, Math.max(0.1, multiplier));
320
+ const nextScore = result.score * normalizedMultiplier;
321
+ if (nextScore !== result.score) {
322
+ adjusted += 1;
323
+ }
324
+
325
+ return {
326
+ index,
327
+ result: {
328
+ ...result,
329
+ score: nextScore,
330
+ },
331
+ };
332
+ });
333
+
334
+ next.sort((left, right) => {
335
+ const scoreDelta = right.result.score - left.result.score;
336
+ if (scoreDelta !== 0) {
337
+ return scoreDelta;
338
+ }
339
+ return left.index - right.index;
340
+ });
341
+
342
+ return {
343
+ results: next.map((entry) => entry.result),
344
+ adjusted,
345
+ overlapBoosted,
346
+ overlapPenalized,
347
+ categoryPenalized,
348
+ sessionBoosted,
349
+ sessionPenalized,
350
+ genericPenalized,
351
+ };
352
+ }
353
+
354
+ function selectMemoriesForInjection(params: {
355
+ query: string;
356
+ results: MemoryBraidResult[];
357
+ limit: number;
358
+ }): {
359
+ injected: MemoryBraidResult[];
360
+ queryTokens: number;
361
+ filteredOut: number;
362
+ genericRejected: number;
363
+ } {
364
+ const limit = Math.max(0, Math.floor(params.limit));
365
+ if (limit === 0 || params.results.length === 0) {
366
+ return {
367
+ injected: [],
368
+ queryTokens: 0,
369
+ filteredOut: 0,
370
+ genericRejected: 0,
371
+ };
372
+ }
373
+
374
+ const queryTokens = tokenizeForOverlap(params.query);
375
+ if (queryTokens.size === 0) {
376
+ return {
377
+ injected: params.results.slice(0, limit),
378
+ queryTokens: 0,
379
+ filteredOut: Math.max(0, params.results.length - Math.min(limit, params.results.length)),
380
+ genericRejected: 0,
381
+ };
382
+ }
383
+
384
+ const injected: MemoryBraidResult[] = [];
385
+ let filteredOut = 0;
386
+ let genericRejected = 0;
387
+
388
+ for (const result of params.results) {
389
+ if (injected.length >= limit) {
390
+ break;
391
+ }
392
+ const overlap = lexicalOverlap(queryTokens, result.snippet);
393
+ const generic = isGenericUserSummary(result.snippet);
394
+ const strongThreshold = result.source === "local" ? 0.26 : 0.34;
395
+ const weakThreshold = result.source === "local" ? 0.12 : 0.18;
396
+ const strongMatch = overlap.shared >= 2 || overlap.ratio >= strongThreshold;
397
+ const weakMatch = overlap.shared >= 1 && overlap.ratio >= weakThreshold;
398
+ const keep = generic ? overlap.shared >= 2 || overlap.ratio >= 0.5 : strongMatch || weakMatch;
399
+ if (keep) {
400
+ injected.push(result);
401
+ continue;
402
+ }
403
+ filteredOut += 1;
404
+ if (generic) {
405
+ genericRejected += 1;
406
+ }
407
+ }
408
+
409
+ return {
410
+ injected,
411
+ queryTokens: queryTokens.size,
412
+ filteredOut,
413
+ genericRejected,
414
+ };
415
+ }
416
+
108
417
  function resolveCoreTemporalDecay(params: {
109
418
  config?: unknown;
110
419
  agentId?: string;
@@ -528,6 +837,27 @@ async function runHybridRecall(params: {
528
837
  });
529
838
  }
530
839
  }
840
+ const qualityAdjusted = applyMem0QualityAdjustments({
841
+ results: mem0ForMerge,
842
+ query: params.query,
843
+ scope,
844
+ nowMs: Date.now(),
845
+ });
846
+ mem0ForMerge = qualityAdjusted.results;
847
+ params.log.debug("memory_braid.search.mem0_quality", {
848
+ runId: params.runId,
849
+ agentId: scope.agentId,
850
+ sessionKey: scope.sessionKey,
851
+ workspaceHash: scope.workspaceHash,
852
+ inputCount: mem0Search.length,
853
+ adjusted: qualityAdjusted.adjusted,
854
+ overlapBoosted: qualityAdjusted.overlapBoosted,
855
+ overlapPenalized: qualityAdjusted.overlapPenalized,
856
+ categoryPenalized: qualityAdjusted.categoryPenalized,
857
+ sessionBoosted: qualityAdjusted.sessionBoosted,
858
+ sessionPenalized: qualityAdjusted.sessionPenalized,
859
+ genericPenalized: qualityAdjusted.genericPenalized,
860
+ });
531
861
  params.log.debug("memory_braid.search.mem0", {
532
862
  runId: params.runId,
533
863
  agentId: scope.agentId,
@@ -913,19 +1243,38 @@ const memoryBraidPlugin = {
913
1243
  runId,
914
1244
  });
915
1245
 
916
- const injected = recall.merged.slice(0, cfg.recall.injectTopK);
917
- if (injected.length === 0) {
1246
+ const selected = selectMemoriesForInjection({
1247
+ query: event.prompt,
1248
+ results: recall.merged,
1249
+ limit: cfg.recall.injectTopK,
1250
+ });
1251
+ if (selected.injected.length === 0) {
1252
+ const scope = resolveScopeFromHookContext(ctx);
1253
+ log.debug("memory_braid.search.inject", {
1254
+ runId,
1255
+ agentId: scope.agentId,
1256
+ sessionKey: scope.sessionKey,
1257
+ workspaceHash: scope.workspaceHash,
1258
+ count: 0,
1259
+ queryTokens: selected.queryTokens,
1260
+ filteredOut: selected.filteredOut,
1261
+ genericRejected: selected.genericRejected,
1262
+ reason: "no_relevant_memories",
1263
+ });
918
1264
  return;
919
1265
  }
920
1266
 
921
- const prependContext = formatRelevantMemories(injected, cfg.debug.maxSnippetChars);
1267
+ const prependContext = formatRelevantMemories(selected.injected, cfg.debug.maxSnippetChars);
922
1268
  const scope = resolveScopeFromHookContext(ctx);
923
1269
  log.debug("memory_braid.search.inject", {
924
1270
  runId,
925
1271
  agentId: scope.agentId,
926
1272
  sessionKey: scope.sessionKey,
927
1273
  workspaceHash: scope.workspaceHash,
928
- count: injected.length,
1274
+ count: selected.injected.length,
1275
+ queryTokens: selected.queryTokens,
1276
+ filteredOut: selected.filteredOut,
1277
+ genericRejected: selected.genericRejected,
929
1278
  injectedTextPreview: prependContext,
930
1279
  });
931
1280