memory-braid 0.4.7 → 0.6.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 +120 -4
- package/openclaw.plugin.json +32 -0
- package/package.json +1 -1
- package/src/capture.ts +315 -0
- package/src/config.ts +127 -2
- package/src/extract.ts +8 -1
- package/src/index.ts +1288 -188
- package/src/local-memory.ts +9 -4
- package/src/logger.ts +6 -1
- package/src/mem0-client.ts +295 -45
- package/src/observability.ts +269 -0
- package/src/remediation.ts +257 -0
- package/src/state.ts +39 -0
- package/src/types.ts +74 -0
package/README.md
CHANGED
|
@@ -9,6 +9,65 @@ Memory Braid is an OpenClaw `kind: "memory"` plugin that augments local memory s
|
|
|
9
9
|
- Capture pipeline modes: `local`, `hybrid`, `ml`.
|
|
10
10
|
- Optional entity extraction: local multilingual NER or OpenAI NER with canonical `entity://...` URIs in memory metadata.
|
|
11
11
|
- Structured debug logs for troubleshooting and tuning.
|
|
12
|
+
- Debug-only LLM usage observability: per-turn cache usage, rolling windows, and rising/stable/improving trend logs.
|
|
13
|
+
|
|
14
|
+
## Hardening update
|
|
15
|
+
|
|
16
|
+
This release hardens capture and remediation for historical installs.
|
|
17
|
+
|
|
18
|
+
- Bug class: historical prompt or transcript content could be captured as Mem0 memories and later re-injected.
|
|
19
|
+
- Impact: inflated prompt size, noisier recall, and potentially higher Anthropic cache-write costs.
|
|
20
|
+
- Fix: new captures are assembled from the trusted current turn instead of mining the full `agent_end` transcript.
|
|
21
|
+
- Metadata: new captured memories now include additive provenance fields such as `captureOrigin`, `captureMessageHash`, `captureTurnHash`, `capturePath`, and `pluginCaptureVersion`.
|
|
22
|
+
- Historical installs: no startup mutation is performed automatically. Operators should audit first, then explicitly quarantine or delete suspicious captured memories.
|
|
23
|
+
|
|
24
|
+
## Remediation commands
|
|
25
|
+
|
|
26
|
+
Memory Braid now exposes read-only audit and explicit remediation commands:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
/memorybraid audit
|
|
30
|
+
/memorybraid remediate audit
|
|
31
|
+
/memorybraid remediate quarantine
|
|
32
|
+
/memorybraid remediate quarantine --apply
|
|
33
|
+
/memorybraid remediate delete --apply
|
|
34
|
+
/memorybraid remediate purge-all-captured --apply
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Notes:
|
|
38
|
+
|
|
39
|
+
- Dry-run is the default for remediation commands. Nothing mutates until you pass `--apply`.
|
|
40
|
+
- `audit` reports counts by `sourceType`, `captureOrigin`, and `pluginCaptureVersion`, plus suspicious legacy samples.
|
|
41
|
+
- `quarantine --apply` excludes suspicious captured memories from future Mem0 injection. It records quarantine state locally and also tags Mem0 metadata where supported.
|
|
42
|
+
- `delete --apply` deletes suspicious captured memories only.
|
|
43
|
+
- `purge-all-captured --apply` deletes all plugin-captured Mem0 records for the current workspace scope without touching local markdown memory.
|
|
44
|
+
- Optional flags:
|
|
45
|
+
- `--limit N` controls how many Mem0 records are fetched during audit/remediation.
|
|
46
|
+
- `--sample N` controls how many suspicious samples are shown in the audit report.
|
|
47
|
+
|
|
48
|
+
## Debug cost observability
|
|
49
|
+
|
|
50
|
+
When `debug.enabled` is `true`, Memory Braid also emits debug-only LLM usage observability logs from the `llm_output` hook:
|
|
51
|
+
|
|
52
|
+
- `memory_braid.cost.turn`: per-turn input/output/cache tokens, cache ratios, and a best-effort estimated USD cost when the provider/model has a known pricing profile.
|
|
53
|
+
- `memory_braid.cost.window`: rolling 5-turn and 20-turn averages plus `rising|stable|improving` trend labels for prompt size, cache-write rate, cache-hit rate, and estimated cost.
|
|
54
|
+
- `memory_braid.cost.alert`: emitted only when recent cache writes, prompt size, or estimated cost rise materially above the previous short window.
|
|
55
|
+
|
|
56
|
+
Important:
|
|
57
|
+
|
|
58
|
+
- `estimatedCostUsd` is intentionally labeled as an estimate.
|
|
59
|
+
- Unknown models still log token and cache trends, but the cost basis becomes `token_only`.
|
|
60
|
+
|
|
61
|
+
## Self-hosted reset option
|
|
62
|
+
|
|
63
|
+
If you are self-hosting and prefer a full reset instead of selective remediation, you can clear Memory Braid's OSS Mem0 state and restart OpenClaw:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
rm -rf ~/.openclaw/memory-braid
|
|
67
|
+
openclaw gateway restart
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This is intentionally not done by the plugin itself. It is an operator choice.
|
|
12
71
|
|
|
13
72
|
## Breaking changes in 0.4.0
|
|
14
73
|
|
|
@@ -429,7 +488,17 @@ Use this preset when:
|
|
|
429
488
|
"memory-braid": {
|
|
430
489
|
"recall": {
|
|
431
490
|
"maxResults": 8,
|
|
432
|
-
"injectTopK":
|
|
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
|
+
},
|
|
433
502
|
"merge": {
|
|
434
503
|
"rrfK": 60,
|
|
435
504
|
"localWeight": 1,
|
|
@@ -441,6 +510,16 @@ Use this preset when:
|
|
|
441
510
|
"mode": "hybrid",
|
|
442
511
|
"includeAssistant": false,
|
|
443
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
|
+
},
|
|
444
523
|
"ml": {
|
|
445
524
|
"provider": "openai",
|
|
446
525
|
"model": "gpt-4o-mini",
|
|
@@ -489,8 +568,20 @@ Capture defaults are:
|
|
|
489
568
|
|
|
490
569
|
- `capture.enabled`: `true`
|
|
491
570
|
- `capture.mode`: `"local"`
|
|
492
|
-
- `capture.includeAssistant`: `false` (
|
|
571
|
+
- `capture.includeAssistant`: `false` (legacy alias for `capture.assistant.autoCapture`)
|
|
493
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`
|
|
494
585
|
- `capture.ml.provider`: unset
|
|
495
586
|
- `capture.ml.model`: unset
|
|
496
587
|
- `capture.ml.timeoutMs`: `2500`
|
|
@@ -505,14 +596,39 @@ Important behavior:
|
|
|
505
596
|
- `capture.mode = "local"`: heuristic-only extraction.
|
|
506
597
|
- `capture.mode = "hybrid"`: heuristic extraction + ML enrichment when ML config is set.
|
|
507
598
|
- `capture.mode = "ml"`: ML-first extraction; falls back to heuristic if ML config/call is unavailable.
|
|
508
|
-
-
|
|
509
|
-
-
|
|
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.
|
|
510
606
|
- ML calls run only when both `capture.ml.provider` and `capture.ml.model` are set.
|
|
511
607
|
- `timeDecay.enabled = true`: applies temporal decay to Mem0 results using Memory Core's `agents.*.memorySearch.query.hybrid.temporalDecay` settings.
|
|
512
608
|
- If Memory Core temporal decay is disabled, Mem0 decay is skipped even when `timeDecay.enabled = true`.
|
|
513
609
|
- `lifecycle.enabled = true`: tracks captured Mem0 IDs, applies TTL cleanup, and exposes `/memorybraid cleanup`.
|
|
514
610
|
- `lifecycle.reinforceOnRecall = true`: successful recalls refresh lifecycle timestamps, extending TTL survival for frequently used memories.
|
|
515
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
|
+
|
|
516
632
|
## Entity extraction defaults
|
|
517
633
|
|
|
518
634
|
Entity extraction defaults are:
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
package/src/capture.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { normalizeForHash, normalizeWhitespace, sha256 } from "./chunking.js";
|
|
2
|
+
import type {
|
|
3
|
+
AssembledCaptureInput,
|
|
4
|
+
CaptureInputMessage,
|
|
5
|
+
PendingInboundTurn,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
type NormalizedHookMessage = {
|
|
9
|
+
role: string;
|
|
10
|
+
text: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
return value as Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function extractHookMessageText(content: unknown): string {
|
|
21
|
+
if (typeof content === "string") {
|
|
22
|
+
return normalizeWhitespace(content);
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(content)) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
for (const block of content) {
|
|
30
|
+
if (!block || typeof block !== "object") {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const item = block as { type?: unknown; text?: unknown };
|
|
34
|
+
if (item.type === "text" && typeof item.text === "string") {
|
|
35
|
+
const normalized = normalizeWhitespace(item.text);
|
|
36
|
+
if (normalized) {
|
|
37
|
+
parts.push(normalized);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return parts.join(" ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeHookMessages(messages: unknown[]): NormalizedHookMessage[] {
|
|
45
|
+
const out: NormalizedHookMessage[] = [];
|
|
46
|
+
for (const entry of messages) {
|
|
47
|
+
if (!entry || typeof entry !== "object") {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const direct = entry as { role?: unknown; content?: unknown };
|
|
52
|
+
if (typeof direct.role === "string") {
|
|
53
|
+
const text = extractHookMessageText(direct.content);
|
|
54
|
+
if (text) {
|
|
55
|
+
out.push({ role: direct.role, text });
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const wrapped = entry as { message?: { role?: unknown; content?: unknown } };
|
|
61
|
+
if (wrapped.message && typeof wrapped.message.role === "string") {
|
|
62
|
+
const text = extractHookMessageText(wrapped.message.content);
|
|
63
|
+
if (text) {
|
|
64
|
+
out.push({ role: wrapped.message.role, text });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeProvenanceKind(value: unknown): string | undefined {
|
|
72
|
+
const record = asRecord(value);
|
|
73
|
+
const kind = typeof record.kind === "string" ? record.kind.trim().toLowerCase() : "";
|
|
74
|
+
return kind || undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getPendingInboundTurn(message: unknown): PendingInboundTurn | undefined {
|
|
78
|
+
const record = asRecord(message);
|
|
79
|
+
const role = typeof record.role === "string" ? record.role.trim().toLowerCase() : "";
|
|
80
|
+
const provenanceKind = normalizeProvenanceKind(record.provenance);
|
|
81
|
+
if (role !== "user" || provenanceKind !== "external_user") {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const text = extractHookMessageText(record.content);
|
|
86
|
+
if (!text) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
text,
|
|
92
|
+
messageHash: sha256(normalizeForHash(text)),
|
|
93
|
+
receivedAt: Date.now(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildCaptureInputMessage(
|
|
98
|
+
role: "user" | "assistant",
|
|
99
|
+
origin: "external_user" | "assistant_derived",
|
|
100
|
+
text: string,
|
|
101
|
+
): CaptureInputMessage {
|
|
102
|
+
return {
|
|
103
|
+
role,
|
|
104
|
+
origin,
|
|
105
|
+
text,
|
|
106
|
+
messageHash: sha256(normalizeForHash(text)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function assembleCaptureInput(params: {
|
|
111
|
+
messages: unknown[];
|
|
112
|
+
includeAssistant: boolean;
|
|
113
|
+
pendingInboundTurn?: PendingInboundTurn;
|
|
114
|
+
}): AssembledCaptureInput | undefined {
|
|
115
|
+
const normalized = normalizeHookMessages(params.messages);
|
|
116
|
+
const lastUserIndex = (() => {
|
|
117
|
+
for (let i = normalized.length - 1; i >= 0; i -= 1) {
|
|
118
|
+
if (normalized[i]?.role === "user") {
|
|
119
|
+
return i;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return -1;
|
|
123
|
+
})();
|
|
124
|
+
|
|
125
|
+
const userText = params.pendingInboundTurn?.text ?? normalized[lastUserIndex]?.text ?? "";
|
|
126
|
+
if (!userText) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const assembled: CaptureInputMessage[] = [
|
|
131
|
+
buildCaptureInputMessage("user", "external_user", userText),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
if (params.includeAssistant) {
|
|
135
|
+
const assistantStart = lastUserIndex >= 0 ? lastUserIndex + 1 : normalized.length;
|
|
136
|
+
for (let i = assistantStart; i < normalized.length; i += 1) {
|
|
137
|
+
const message = normalized[i];
|
|
138
|
+
if (!message || message.role !== "assistant" || !message.text) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
assembled.push(buildCaptureInputMessage("assistant", "assistant_derived", message.text));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const hashInput = assembled.map((message) => message.messageHash).join("|");
|
|
146
|
+
return {
|
|
147
|
+
messages: assembled,
|
|
148
|
+
capturePath: params.pendingInboundTurn ? "before_message_write" : "agent_end_last_turn",
|
|
149
|
+
turnHash: sha256(hashInput),
|
|
150
|
+
fallbackUsed: !params.pendingInboundTurn,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function tokenize(text: string): Set<string> {
|
|
155
|
+
const tokens = text.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
156
|
+
const out = new Set<string>();
|
|
157
|
+
for (const token of tokens) {
|
|
158
|
+
const normalized = token
|
|
159
|
+
.toLowerCase()
|
|
160
|
+
.normalize("NFKD")
|
|
161
|
+
.replace(/\p{M}+/gu, "");
|
|
162
|
+
if (normalized.length >= 3) {
|
|
163
|
+
out.add(normalized);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function overlapRatio(left: Set<string>, right: Set<string>): number {
|
|
170
|
+
if (left.size === 0 || right.size === 0) {
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
let shared = 0;
|
|
174
|
+
for (const token of left) {
|
|
175
|
+
if (right.has(token)) {
|
|
176
|
+
shared += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return shared / Math.max(left.size, right.size);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function matchCandidateToCaptureInput(
|
|
183
|
+
candidateText: string,
|
|
184
|
+
messages: CaptureInputMessage[],
|
|
185
|
+
): CaptureInputMessage | undefined {
|
|
186
|
+
const candidateHash = sha256(normalizeForHash(candidateText));
|
|
187
|
+
for (const message of messages) {
|
|
188
|
+
if (message.messageHash === candidateHash) {
|
|
189
|
+
return message;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const candidateTokens = tokenize(candidateText);
|
|
194
|
+
let bestMatch: CaptureInputMessage | undefined;
|
|
195
|
+
let bestScore = 0;
|
|
196
|
+
|
|
197
|
+
for (const message of messages) {
|
|
198
|
+
const score = overlapRatio(candidateTokens, tokenize(message.text));
|
|
199
|
+
if (score > bestScore) {
|
|
200
|
+
bestScore = score;
|
|
201
|
+
bestMatch = message;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return bestScore >= 0.24 ? bestMatch : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ROLE_PREFIX_LINE = /^(?:assistant|system|developer|tool|user|human|bot|ai|agent)\s*:/i;
|
|
209
|
+
const INLINE_ROLE_LABEL = /\b(?:assistant|system|developer|tool|user)\s*:/gi;
|
|
210
|
+
const STRUCTURED_METADATA_KEY =
|
|
211
|
+
/^\s*["']?(?:message_id|reply_to_id|sender_id|sender|timestamp|thread|conversation|channel|metadata)\b/i;
|
|
212
|
+
|
|
213
|
+
export function isLikelyTranscriptLikeText(text: string): boolean {
|
|
214
|
+
const normalized = normalizeWhitespace(text);
|
|
215
|
+
if (!normalized) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lines = normalized
|
|
220
|
+
.split(/\r?\n/)
|
|
221
|
+
.map((line) => line.trim())
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
if (lines.length === 0) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rolePrefixedLines = lines.filter((line) => ROLE_PREFIX_LINE.test(line)).length;
|
|
228
|
+
const inlineRoleLabels = normalized.match(INLINE_ROLE_LABEL)?.length ?? 0;
|
|
229
|
+
const fencedBlocks = normalized.match(/```/g)?.length ?? 0;
|
|
230
|
+
const metadataLines = lines.filter((line) => STRUCTURED_METADATA_KEY.test(line)).length;
|
|
231
|
+
|
|
232
|
+
if (rolePrefixedLines >= 2) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (inlineRoleLabels >= 3) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (fencedBlocks >= 2 && metadataLines >= 2) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return metadataLines >= 4 && lines.length >= 6;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isOversizedAtomicMemory(text: string): boolean {
|
|
245
|
+
const normalized = normalizeWhitespace(text);
|
|
246
|
+
if (!normalized) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
250
|
+
return normalized.length > 1600 || lines.length > 18;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const RECAP_PREFIXES = [
|
|
254
|
+
/^the user\b/i,
|
|
255
|
+
/^user\b/i,
|
|
256
|
+
/^usuario\b/i,
|
|
257
|
+
/^in this (?:turn|conversation)\b/i,
|
|
258
|
+
/^(?:we|i) (?:discussed|talked about|went over|covered)\b/i,
|
|
259
|
+
/^(?:summary|recap)\b/i,
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
const TEMPORAL_REFERENCE_PATTERN =
|
|
263
|
+
/\b(?:today|tomorrow|yesterday|this turn|this session|earlier in this session|just now|in this chat)\b/i;
|
|
264
|
+
|
|
265
|
+
export function isLikelyTurnRecap(text: string): boolean {
|
|
266
|
+
const normalized = normalizeWhitespace(text);
|
|
267
|
+
if (!normalized) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
if (normalized.length > 260 && /\b(?:asked|wanted|needed|said|requested)\b/i.test(normalized)) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
return RECAP_PREFIXES.some((pattern) => pattern.test(normalized));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function splitIntoSentences(text: string): string[] {
|
|
277
|
+
return text
|
|
278
|
+
.split(/(?<=[.!?])\s+/)
|
|
279
|
+
.map((sentence) => normalizeWhitespace(sentence))
|
|
280
|
+
.filter(Boolean);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function looksReusableLearning(text: string): boolean {
|
|
284
|
+
if (text.length < 24 || text.length > 220) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
if (TEMPORAL_REFERENCE_PATTERN.test(text)) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
if (isLikelyTranscriptLikeText(text) || isLikelyTurnRecap(text)) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return /\b(?:prefer|avoid|use|keep|store|remember|dedupe|inject|search|persist|reject|limit|filter|only|always|never|when)\b/i.test(
|
|
294
|
+
text,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function compactAgentLearning(text: string): string | undefined {
|
|
299
|
+
const normalized = normalizeWhitespace(text);
|
|
300
|
+
if (!normalized || isOversizedAtomicMemory(normalized) || isLikelyTranscriptLikeText(normalized)) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
if (looksReusableLearning(normalized)) {
|
|
304
|
+
return normalized;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sentences = splitIntoSentences(normalized);
|
|
308
|
+
for (const sentence of sentences) {
|
|
309
|
+
if (looksReusableLearning(sentence)) {
|
|
310
|
+
return sentence;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|