@clawmem-ai/clawmem 0.1.15 → 0.1.16
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 +6 -2
- package/openclaw.plugin.json +29 -2
- package/package.json +4 -1
- package/skills/clawmem/SKILL.md +4 -2
- package/src/config.test.ts +3 -0
- package/src/config.ts +3 -0
- package/src/conversation.ts +217 -2
- package/src/memory.test.ts +33 -3
- package/src/memory.ts +206 -2
- package/src/runtime-env.ts +12 -0
- package/src/service.ts +344 -88
- package/src/state.test.ts +88 -0
- package/src/state.ts +139 -8
- package/src/types.ts +46 -2
- package/src/utils.ts +19 -0
package/README.md
CHANGED
|
@@ -136,7 +136,10 @@ Full config with all options:
|
|
|
136
136
|
}
|
|
137
137
|
},
|
|
138
138
|
turnCommentDelayMs: 1000,
|
|
139
|
+
digestWaitTimeoutMs: 30000,
|
|
139
140
|
summaryWaitTimeoutMs: 120000,
|
|
141
|
+
memoryExtractWaitTimeoutMs: 45000,
|
|
142
|
+
memoryReconcileWaitTimeoutMs: 45000,
|
|
140
143
|
memoryRecallLimit: 5,
|
|
141
144
|
memoryAutoRecallLimit: 3
|
|
142
145
|
}
|
|
@@ -151,11 +154,12 @@ Full config with all options:
|
|
|
151
154
|
## Notes
|
|
152
155
|
|
|
153
156
|
- Conversation comments exclude tool calls, tool results, system messages, and heartbeat noise.
|
|
154
|
-
-
|
|
157
|
+
- Finalization now uses a staged pipeline: rolling digest updates during the session, then a final issue summary/title after finalize, plus a separate extract/reconcile memory pipeline.
|
|
158
|
+
- Summary failures do not block finalization; the conversation issue remains finalized with `summary: pending`, and ClawMem retries the final summary through background recovery scheduling.
|
|
155
159
|
- Memory search and auto-recall only return open `type:memory` issues. Closed memory issues are treated as stale.
|
|
156
160
|
- ClawMem automatically injects a small set of relevant memories before each turn using the agent's default repo and the backend recall API. Auto-recall is best-effort and quietly skips injection when backend recall is unavailable.
|
|
157
161
|
- `memory_recall` uses the backend `/api/v3/search/issues` endpoint scoped to the current repo plus `label:"type:memory"`. When backend recall is unavailable, use `memory_list` or `memory_get` to inspect memories explicitly.
|
|
158
|
-
- Durable memories are
|
|
162
|
+
- Durable memories are captured in two stages after each mirrored turn: extract atomic candidates from new conversation deltas, then reconcile them against existing memories before writing durable updates. If either stage fails, ClawMem retries it through background recovery scheduling rather than the request-start path.
|
|
159
163
|
- The plugin exposes `memory_repos`, `memory_repo_create`, `memory_list`, `memory_get`, `memory_labels`, `memory_recall`, `memory_store`, `memory_update`, and `memory_forget` for mid-session use.
|
|
160
164
|
- Route resolution is now: agent identity supplies credentials, `defaultRepo` is the fallback memory space, and explicit tool calls may override repo per operation.
|
|
161
165
|
- `memory_store` accepts optional schema hints such as kind and topics; the plugin normalizes them into managed `kind:*` and `topic:*` labels.
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "clawmem",
|
|
3
3
|
"name": "ClawMem",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.16",
|
|
5
5
|
"description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"skills": [
|
|
@@ -67,11 +67,26 @@
|
|
|
67
67
|
"minimum": 0,
|
|
68
68
|
"maximum": 60000
|
|
69
69
|
},
|
|
70
|
+
"digestWaitTimeoutMs": {
|
|
71
|
+
"type": "integer",
|
|
72
|
+
"minimum": 1000,
|
|
73
|
+
"maximum": 600000
|
|
74
|
+
},
|
|
70
75
|
"summaryWaitTimeoutMs": {
|
|
71
76
|
"type": "integer",
|
|
72
77
|
"minimum": 1000,
|
|
73
78
|
"maximum": 600000
|
|
74
79
|
},
|
|
80
|
+
"memoryExtractWaitTimeoutMs": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"minimum": 1000,
|
|
83
|
+
"maximum": 600000
|
|
84
|
+
},
|
|
85
|
+
"memoryReconcileWaitTimeoutMs": {
|
|
86
|
+
"type": "integer",
|
|
87
|
+
"minimum": 1000,
|
|
88
|
+
"maximum": 600000
|
|
89
|
+
},
|
|
75
90
|
"memoryRecallLimit": {
|
|
76
91
|
"type": "integer",
|
|
77
92
|
"minimum": 1,
|
|
@@ -117,9 +132,21 @@
|
|
|
117
132
|
"label": "Turn Sync Delay (ms)",
|
|
118
133
|
"help": "Small delay so transcript writes settle before a turn comment is mirrored."
|
|
119
134
|
},
|
|
135
|
+
"digestWaitTimeoutMs": {
|
|
136
|
+
"label": "Digest Wait Timeout (ms)",
|
|
137
|
+
"help": "How long clawmem waits for the rolling conversation digest subagent before retrying later."
|
|
138
|
+
},
|
|
120
139
|
"summaryWaitTimeoutMs": {
|
|
121
140
|
"label": "Summary Wait Timeout (ms)",
|
|
122
|
-
"help": "How long clawmem waits for the
|
|
141
|
+
"help": "How long clawmem waits for the final conversation summary subagent during session finalization."
|
|
142
|
+
},
|
|
143
|
+
"memoryExtractWaitTimeoutMs": {
|
|
144
|
+
"label": "Memory Extract Timeout (ms)",
|
|
145
|
+
"help": "How long clawmem waits while extracting atomic memory candidates from new conversation deltas."
|
|
146
|
+
},
|
|
147
|
+
"memoryReconcileWaitTimeoutMs": {
|
|
148
|
+
"label": "Memory Reconcile Timeout (ms)",
|
|
149
|
+
"help": "How long clawmem waits while reconciling extracted memory candidates against existing memories."
|
|
123
150
|
},
|
|
124
151
|
"memoryRecallLimit": {
|
|
125
152
|
"label": "Memory Recall Limit",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawmem-ai/clawmem",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"openclaw": {
|
|
26
|
+
"hooks": [
|
|
27
|
+
"./index.ts"
|
|
28
|
+
],
|
|
26
29
|
"extensions": [
|
|
27
30
|
"./index.ts"
|
|
28
31
|
]
|
package/skills/clawmem/SKILL.md
CHANGED
|
@@ -25,8 +25,9 @@ Memory hygiene matters: lock important insights deliberately, update canonical f
|
|
|
25
25
|
The ClawMem plugin automatically handles:
|
|
26
26
|
- Per-agent provisioning of credentials plus a default memory repo
|
|
27
27
|
- Session mirroring into `type:conversation` issues
|
|
28
|
+
- Rolling conversation digests during the session plus a final issue summary/title after finalize
|
|
28
29
|
- Best-effort automatic memory recall before each turn, scoped to the current agent's `defaultRepo`
|
|
29
|
-
- Best-effort durable memory extraction
|
|
30
|
+
- Best-effort durable memory extraction after mirrored turns, with background recovery retries if extract/reconcile work fails
|
|
30
31
|
- Mid-session memory tools: `memory_repos`, `memory_repo_create`, `memory_list`, `memory_get`, `memory_labels`, `memory_recall`, `memory_store`, `memory_update`, and `memory_forget`
|
|
31
32
|
|
|
32
33
|
## Mandatory turn loop
|
|
@@ -48,6 +49,7 @@ On every user turn, run this loop:
|
|
|
48
49
|
- Never treat a `memory_recall` miss by itself as proof that no relevant memory exists.
|
|
49
50
|
2. After answering, ask: did this turn create durable knowledge?
|
|
50
51
|
- Default to yes for corrections, preferences, decisions, workflows, lessons, and status changes.
|
|
52
|
+
- Auto-extraction is asynchronous and best-effort. If a fact must be durable immediately, or the next turn will depend on it, write it explicitly with `memory_store` or `memory_update` instead of waiting for background capture.
|
|
51
53
|
- Prefer one durable fact per memory. If a turn contains several independent facts, save them separately instead of bundling them into one summary memory.
|
|
52
54
|
- Use `memory_update` when the same canonical fact or ongoing task should keep evolving as one node.
|
|
53
55
|
- When updating an existing memory, preserve that node's current language unless the user explicitly asks for a rewrite.
|
|
@@ -61,7 +63,7 @@ On every user turn, run this loop:
|
|
|
61
63
|
- Include the memory id and title only when they help with debugging, traceability, or an explicit user request.
|
|
62
64
|
- After creating or updating a memory, give a short confirmation in the user's current language instead of forcing fixed English phrasing.
|
|
63
65
|
|
|
64
|
-
Bias toward saving, and use explicit retrieval whenever auto-recall is absent, weak, cross-repo, or too ambiguous to trust on its own.
|
|
66
|
+
Bias toward saving, and use explicit retrieval whenever auto-recall is absent, weak, cross-repo, or too ambiguous to trust on its own. Do not assume a just-finished turn has already been captured as durable memory unless you explicitly wrote it or later verified it.
|
|
65
67
|
|
|
66
68
|
## Retrieval and storage rules
|
|
67
69
|
|
package/src/config.test.ts
CHANGED
|
@@ -29,7 +29,10 @@ function baseConfig(): ClawMemPluginConfig {
|
|
|
29
29
|
memoryRecallLimit: 5,
|
|
30
30
|
memoryAutoRecallLimit: 3,
|
|
31
31
|
turnCommentDelayMs: 1000,
|
|
32
|
+
digestWaitTimeoutMs: 30000,
|
|
32
33
|
summaryWaitTimeoutMs: 120000,
|
|
34
|
+
memoryExtractWaitTimeoutMs: 45000,
|
|
35
|
+
memoryReconcileWaitTimeoutMs: 45000,
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
|
package/src/config.ts
CHANGED
|
@@ -48,7 +48,10 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
|
|
|
48
48
|
memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
|
|
49
49
|
memoryAutoRecallLimit: clamp(num(raw.memoryAutoRecallLimit, 3), 1, 20),
|
|
50
50
|
turnCommentDelayMs: num(raw.turnCommentDelayMs, 1000),
|
|
51
|
+
digestWaitTimeoutMs: clamp(num(raw.digestWaitTimeoutMs, 30000), 1000, 600000),
|
|
51
52
|
summaryWaitTimeoutMs: clamp(num(raw.summaryWaitTimeoutMs, 120000), 1000, 600000),
|
|
53
|
+
memoryExtractWaitTimeoutMs: clamp(num(raw.memoryExtractWaitTimeoutMs, 45000), 1000, 600000),
|
|
54
|
+
memoryReconcileWaitTimeoutMs: clamp(num(raw.memoryReconcileWaitTimeoutMs, 45000), 1000, 600000),
|
|
52
55
|
};
|
|
53
56
|
}
|
|
54
57
|
|
package/src/conversation.ts
CHANGED
|
@@ -4,9 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
5
5
|
import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
|
|
6
6
|
import type { GitHubIssueClient } from "./github-client.js";
|
|
7
|
+
import { parseCandidates } from "./memory.js";
|
|
7
8
|
import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
|
|
8
|
-
import type { ClawMemPluginConfig, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
9
|
-
import { fmtTranscript, localDate, localDateTime, sha256, subKey } from "./utils.js";
|
|
9
|
+
import type { ClawMemPluginConfig, MemoryCandidate, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
10
|
+
import { fmtTranscript, fmtTranscriptFrom, localDate, localDateTime, sha256, sliceTranscriptDelta, subKey } from "./utils.js";
|
|
10
11
|
import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
|
|
11
12
|
|
|
12
13
|
export class ConversationMirror {
|
|
@@ -18,6 +19,10 @@ export class ConversationMirror {
|
|
|
18
19
|
if (first.includes("generate a short 1-2 word filename slug") && first.includes("Reply with ONLY the slug")) return false;
|
|
19
20
|
if (first.includes("Summarize the following conversation.") && first.includes('Return valid JSON only in the form {"summary":"..."}')) return false;
|
|
20
21
|
if (first.includes("Extract durable memories from the conversation below.") && first.includes('Return JSON only in the form {"save":')) return false;
|
|
22
|
+
if (first.includes("Maintain a rolling digest of the conversation below.") && first.includes('Return valid JSON only in the form {"digest":"...","title":"..."}')) return false;
|
|
23
|
+
if (first.includes("Write the final issue summary for the conversation below.") && first.includes('Return valid JSON only in the form {"summary":"...","title":"..."}')) return false;
|
|
24
|
+
if (first.includes("Extract atomic durable memory candidates from the conversation delta below.")) return false;
|
|
25
|
+
if (first.includes("Reconcile extracted durable memory candidates against existing memories.")) return false;
|
|
21
26
|
return true;
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -90,6 +95,173 @@ export class ConversationMirror {
|
|
|
90
95
|
return count;
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
async generateRollingDigest(
|
|
99
|
+
session: SessionMirrorState,
|
|
100
|
+
snapshot: TranscriptSnapshot,
|
|
101
|
+
fromCursor: number,
|
|
102
|
+
previousDigest?: string,
|
|
103
|
+
): Promise<{ digest: string; title?: string }> {
|
|
104
|
+
const { anchorStart, deltaStart, anchorMessages, deltaMessages } = sliceTranscriptDelta(snapshot.messages, fromCursor, 2);
|
|
105
|
+
if (deltaMessages.length === 0) {
|
|
106
|
+
return { digest: previousDigest?.trim() || "" };
|
|
107
|
+
}
|
|
108
|
+
const subagent = this.api.runtime.subagent;
|
|
109
|
+
const sessionKey = subKey(session, "digest");
|
|
110
|
+
const message = [
|
|
111
|
+
"Maintain a rolling digest of the conversation below.",
|
|
112
|
+
'Return valid JSON only in the form {"digest":"...","title":"..."}',
|
|
113
|
+
"Update the digest so it accurately represents the conversation so far in 4-8 concise factual sentences.",
|
|
114
|
+
"Focus on decisions, constraints, preferences, open workstreams, and concrete outcomes worth carrying forward.",
|
|
115
|
+
"Use the anchor messages only for context resolution. The new messages are the only part that must be incorporated now.",
|
|
116
|
+
"Title is optional. If provided, keep it under 50 characters and accurately describe the overall conversation.",
|
|
117
|
+
"",
|
|
118
|
+
"<previous-digest>",
|
|
119
|
+
previousDigest?.trim() || "None.",
|
|
120
|
+
"</previous-digest>",
|
|
121
|
+
"",
|
|
122
|
+
"<anchor-messages>",
|
|
123
|
+
anchorMessages.length > 0 ? fmtTranscriptFrom(anchorMessages, anchorStart) : "None.",
|
|
124
|
+
"</anchor-messages>",
|
|
125
|
+
"",
|
|
126
|
+
"<new-messages>",
|
|
127
|
+
fmtTranscriptFrom(deltaMessages, deltaStart),
|
|
128
|
+
"</new-messages>",
|
|
129
|
+
].join("\n");
|
|
130
|
+
try {
|
|
131
|
+
const run = await subagent.run({
|
|
132
|
+
sessionKey,
|
|
133
|
+
message,
|
|
134
|
+
deliver: false,
|
|
135
|
+
lane: "clawmem-digest",
|
|
136
|
+
idempotencyKey: sha256(`${session.sessionId}:${fromCursor}:${snapshot.messages.length}:digest-v1`),
|
|
137
|
+
extraSystemPrompt: "You maintain rolling conversation digests for ClawMem. Output JSON only with string fields digest and optional title.",
|
|
138
|
+
});
|
|
139
|
+
const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.digestWaitTimeoutMs });
|
|
140
|
+
if (wait.status === "timeout") throw new Error("digest subagent timed out");
|
|
141
|
+
if (wait.status === "error") throw new Error(wait.error || "digest subagent failed");
|
|
142
|
+
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
|
|
143
|
+
const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
|
|
144
|
+
if (!text) throw new Error("digest subagent returned no assistant text");
|
|
145
|
+
return parseDigestAndTitle(text);
|
|
146
|
+
} finally {
|
|
147
|
+
subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async deriveDelta(
|
|
152
|
+
session: SessionMirrorState,
|
|
153
|
+
snapshot: TranscriptSnapshot,
|
|
154
|
+
fromCursor: number,
|
|
155
|
+
previousDigest?: string,
|
|
156
|
+
): Promise<{ digest: string; title?: string; candidates: MemoryCandidate[] }> {
|
|
157
|
+
const { anchorStart, deltaStart, anchorMessages, deltaMessages } = sliceTranscriptDelta(snapshot.messages, fromCursor, 2);
|
|
158
|
+
if (deltaMessages.length === 0) {
|
|
159
|
+
return { digest: previousDigest?.trim() || "", candidates: [] };
|
|
160
|
+
}
|
|
161
|
+
const subagent = this.api.runtime.subagent;
|
|
162
|
+
const sessionKey = subKey(session, "derive-delta");
|
|
163
|
+
const message = [
|
|
164
|
+
"Maintain a rolling digest and extract atomic durable memory candidates from the conversation delta below.",
|
|
165
|
+
'Return valid JSON only in the form {"digest":"...","title":"...","candidates":[{"title":"...","detail":"...","kind":"...","topics":["..."],"evidence":"..."}]}.',
|
|
166
|
+
"Update the digest so it accurately represents the conversation so far in 4-8 concise factual sentences.",
|
|
167
|
+
"Focus the digest on decisions, constraints, preferences, open workstreams, and concrete outcomes worth carrying forward.",
|
|
168
|
+
"Extract only durable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
|
|
169
|
+
"Use the anchor messages only for context resolution. The new messages are the only source that may add new candidates now.",
|
|
170
|
+
"Each candidate must represent one durable fact. Split independent facts into separate candidates.",
|
|
171
|
+
"Do not extract temporary requests, tool chatter, startup boilerplate, or summaries about internal helper sessions.",
|
|
172
|
+
"Kind and topics are optional. Keep them short, reusable, and low-cardinality.",
|
|
173
|
+
"Evidence is optional. If present, keep it short and quote-free.",
|
|
174
|
+
"Title is optional. If provided, keep it under 50 characters and accurately describe the overall conversation.",
|
|
175
|
+
"Prefer an empty candidates array when nothing durable was added.",
|
|
176
|
+
"",
|
|
177
|
+
"<previous-digest>",
|
|
178
|
+
previousDigest?.trim() || "None.",
|
|
179
|
+
"</previous-digest>",
|
|
180
|
+
"",
|
|
181
|
+
"<anchor-messages>",
|
|
182
|
+
anchorMessages.length > 0 ? fmtTranscriptFrom(anchorMessages, anchorStart) : "None.",
|
|
183
|
+
"</anchor-messages>",
|
|
184
|
+
"",
|
|
185
|
+
"<new-messages>",
|
|
186
|
+
fmtTranscriptFrom(deltaMessages, deltaStart),
|
|
187
|
+
"</new-messages>",
|
|
188
|
+
].join("\n");
|
|
189
|
+
try {
|
|
190
|
+
const run = await subagent.run({
|
|
191
|
+
sessionKey,
|
|
192
|
+
message,
|
|
193
|
+
deliver: false,
|
|
194
|
+
lane: "clawmem-derive-delta",
|
|
195
|
+
idempotencyKey: sha256(`${session.sessionId}:${fromCursor}:${snapshot.messages.length}:derive-delta-v1`),
|
|
196
|
+
extraSystemPrompt: "You maintain rolling conversation digests and extract atomic durable memory candidates for ClawMem. Output JSON only with digest, optional title, and candidates.",
|
|
197
|
+
});
|
|
198
|
+
const wait = await subagent.waitForRun({
|
|
199
|
+
runId: run.runId,
|
|
200
|
+
timeoutMs: Math.max(this.config.digestWaitTimeoutMs, this.config.memoryExtractWaitTimeoutMs),
|
|
201
|
+
});
|
|
202
|
+
if (wait.status === "timeout") throw new Error("derive delta subagent timed out");
|
|
203
|
+
if (wait.status === "error") throw new Error(wait.error || "derive delta subagent failed");
|
|
204
|
+
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
|
|
205
|
+
const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
|
|
206
|
+
if (!text) throw new Error("derive delta subagent returned no assistant text");
|
|
207
|
+
return parseDerivedDelta(text);
|
|
208
|
+
} finally {
|
|
209
|
+
subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async generateFinalSummaryFromDigest(
|
|
214
|
+
session: SessionMirrorState,
|
|
215
|
+
snapshot: TranscriptSnapshot,
|
|
216
|
+
digestText: string,
|
|
217
|
+
): Promise<{ summary: string; title?: string }> {
|
|
218
|
+
if (!digestText.trim() && snapshot.messages.length === 0) throw new Error("no conversation content to summarize");
|
|
219
|
+
const tailStart = Math.max(0, snapshot.messages.length - 6);
|
|
220
|
+
const tailMessages = snapshot.messages.slice(tailStart);
|
|
221
|
+
const subagent = this.api.runtime.subagent;
|
|
222
|
+
const sessionKey = subKey(session, "summary-final");
|
|
223
|
+
const message = [
|
|
224
|
+
"Write the final issue summary for the conversation below.",
|
|
225
|
+
'Return valid JSON only in the form {"summary":"...","title":"..."}',
|
|
226
|
+
"The summary should be concise, factual, and written in 2-4 sentences.",
|
|
227
|
+
"Use the rolling digest as the primary source of truth, and use the recent tail only to preserve freshness and wording accuracy.",
|
|
228
|
+
"Do not include markdown, bullet points, or analysis.",
|
|
229
|
+
"",
|
|
230
|
+
"Title rules:",
|
|
231
|
+
"- Under 50 characters, accurately describe the main topic or task.",
|
|
232
|
+
"- Should let someone immediately know what the conversation is about.",
|
|
233
|
+
"- Must be in the same language as the majority of the conversation content.",
|
|
234
|
+
"- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
|
|
235
|
+
"",
|
|
236
|
+
"<rolling-digest>",
|
|
237
|
+
digestText.trim() || "None.",
|
|
238
|
+
"</rolling-digest>",
|
|
239
|
+
"",
|
|
240
|
+
"<recent-tail>",
|
|
241
|
+
tailMessages.length > 0 ? fmtTranscriptFrom(tailMessages, tailStart) : "None.",
|
|
242
|
+
"</recent-tail>",
|
|
243
|
+
].join("\n");
|
|
244
|
+
try {
|
|
245
|
+
const run = await subagent.run({
|
|
246
|
+
sessionKey,
|
|
247
|
+
message,
|
|
248
|
+
deliver: false,
|
|
249
|
+
lane: "clawmem-summary",
|
|
250
|
+
idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary-final-v1`),
|
|
251
|
+
extraSystemPrompt: "You write final conversation issue summaries for ClawMem. Output JSON only with string fields summary and title.",
|
|
252
|
+
});
|
|
253
|
+
const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
|
|
254
|
+
if (wait.status === "timeout") throw new Error("summary subagent timed out");
|
|
255
|
+
if (wait.status === "error") throw new Error(wait.error || "summary subagent failed");
|
|
256
|
+
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
|
|
257
|
+
const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
|
|
258
|
+
if (!text) throw new Error("summary subagent returned no assistant text");
|
|
259
|
+
return parseSummaryAndTitle(text);
|
|
260
|
+
} finally {
|
|
261
|
+
subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
93
265
|
async generateSummaryAndTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<{ summary: string; title?: string }> {
|
|
94
266
|
if (snapshot.messages.length === 0) throw new Error("no conversation messages to summarize");
|
|
95
267
|
const subagent = this.api.runtime.subagent;
|
|
@@ -367,6 +539,49 @@ function parseSummaryAndTitle(raw: string): { summary: string; title?: string }
|
|
|
367
539
|
return { summary: t };
|
|
368
540
|
}
|
|
369
541
|
|
|
542
|
+
function parseDigestAndTitle(raw: string): { digest: string; title?: string } {
|
|
543
|
+
const tryParse = (s: string): { digest: string; title?: string } | null => {
|
|
544
|
+
try {
|
|
545
|
+
const p = JSON.parse(s) as { digest?: unknown; title?: unknown };
|
|
546
|
+
const digest = typeof p?.digest === "string" && p.digest.trim() ? p.digest.trim() : null;
|
|
547
|
+
if (!digest) return null;
|
|
548
|
+
const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
|
|
549
|
+
return { digest, title };
|
|
550
|
+
} catch {
|
|
551
|
+
const i = s.indexOf("{"), j = s.lastIndexOf("}");
|
|
552
|
+
if (i >= 0 && j > i) {
|
|
553
|
+
try {
|
|
554
|
+
const p = JSON.parse(s.slice(i, j + 1)) as { digest?: unknown; title?: unknown };
|
|
555
|
+
const digest = typeof p?.digest === "string" && p.digest.trim() ? p.digest.trim() : null;
|
|
556
|
+
if (!digest) return null;
|
|
557
|
+
const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
|
|
558
|
+
return { digest, title };
|
|
559
|
+
} catch { return null; }
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const t = raw.trim();
|
|
565
|
+
const direct = tryParse(t);
|
|
566
|
+
if (direct) return direct;
|
|
567
|
+
const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
|
|
568
|
+
if (f?.[1]) {
|
|
569
|
+
const nested = tryParse(f[1].trim());
|
|
570
|
+
if (nested) return nested;
|
|
571
|
+
}
|
|
572
|
+
return { digest: t };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function parseDerivedDelta(raw: string): { digest: string; title?: string; candidates: MemoryCandidate[] } {
|
|
576
|
+
const parsedDigest = parseDigestAndTitle(raw);
|
|
577
|
+
const candidates = parseCandidates(raw);
|
|
578
|
+
return {
|
|
579
|
+
digest: parsedDigest.digest,
|
|
580
|
+
...(parsedDigest.title ? { title: parsedDigest.title } : {}),
|
|
581
|
+
candidates,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
370
585
|
function parseTitle(raw: string): string | undefined {
|
|
371
586
|
const tryParse = (s: string): string | undefined => {
|
|
372
587
|
try {
|
package/src/memory.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MemoryStore, scoreMemoryMatch } from "./memory.js";
|
|
1
|
+
import { MemoryStore, mergeMemoryCandidates, scoreMemoryMatch } from "./memory.js";
|
|
2
2
|
import type { ParsedMemoryIssue } from "./types.js";
|
|
3
3
|
import { stringifyFlatYaml } from "./yaml.js";
|
|
4
4
|
|
|
@@ -46,7 +46,10 @@ function testConfig(): never {
|
|
|
46
46
|
memoryRecallLimit: 5,
|
|
47
47
|
memoryAutoRecallLimit: 3,
|
|
48
48
|
turnCommentDelayMs: 1000,
|
|
49
|
+
digestWaitTimeoutMs: 30000,
|
|
49
50
|
summaryWaitTimeoutMs: 120000,
|
|
51
|
+
memoryExtractWaitTimeoutMs: 45000,
|
|
52
|
+
memoryReconcileWaitTimeoutMs: 45000,
|
|
50
53
|
} as never;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -173,6 +176,32 @@ function testCjkScoring(): void {
|
|
|
173
176
|
assert(billingScore > 0, "expected Chinese query to produce a positive match score");
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
function testMergeMemoryCandidates(): void {
|
|
180
|
+
const merged = mergeMemoryCandidates(
|
|
181
|
+
[
|
|
182
|
+
{
|
|
183
|
+
candidateId: "abc",
|
|
184
|
+
detail: "Redis Lua scripts keep rate limiting atomic.",
|
|
185
|
+
topics: ["redis"],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
[
|
|
189
|
+
{
|
|
190
|
+
candidateId: "abc",
|
|
191
|
+
detail: "Redis Lua scripts keep rate limiting atomic.",
|
|
192
|
+
kind: "lesson",
|
|
193
|
+
topics: ["rate-limit"],
|
|
194
|
+
evidence: "User confirmed the production path uses Lua.",
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
assert(merged.length === 1, "expected duplicate candidates to merge by candidateId");
|
|
200
|
+
assert(merged[0]?.kind === "lesson", "expected merged candidates to preserve new schema hints");
|
|
201
|
+
assert(JSON.stringify(merged[0]?.topics) === JSON.stringify(["rate-limit", "redis"]), "expected merged candidates to union topics");
|
|
202
|
+
assert(merged[0]?.evidence === "User confirmed the production path uses Lua.", "expected merged candidates to preserve evidence");
|
|
203
|
+
}
|
|
204
|
+
|
|
176
205
|
async function testStructuredStoreAndSchema(): Promise<void> {
|
|
177
206
|
const created: Array<{ title: string; body: string; labels: string[] }> = [];
|
|
178
207
|
const ensured: string[][] = [];
|
|
@@ -456,8 +485,9 @@ async function main(): Promise<void> {
|
|
|
456
485
|
await testBackendSearchPreferredForRecall();
|
|
457
486
|
await testBackendSearchReturnsEmptyWithoutLexicalFallback();
|
|
458
487
|
await testBackendSearchPropagatesErrors();
|
|
459
|
-
|
|
460
|
-
|
|
488
|
+
testCjkScoring();
|
|
489
|
+
testMergeMemoryCandidates();
|
|
490
|
+
await testStructuredStoreAndSchema();
|
|
461
491
|
await testStoreKeepsFullAutoTitleAndSupportsExplicitTitle();
|
|
462
492
|
await testGetAndListMemories();
|
|
463
493
|
await testLegacyMemoriesWithoutSessionOrDate();
|