@clawmem-ai/clawmem 0.1.14 → 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 +10 -5
- package/openclaw.plugin.json +30 -3
- package/package.json +4 -1
- package/skills/clawmem/SKILL.md +13 -8
- package/src/config.test.ts +4 -1
- package/src/config.ts +4 -1
- package/src/conversation.ts +217 -2
- package/src/memory.test.ts +95 -40
- package/src/memory.ts +237 -21
- package/src/recall-sanitize.ts +143 -0
- package/src/runtime-env.ts +12 -0
- package/src/service.test.ts +37 -29
- package/src/service.ts +418 -127
- 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,9 +136,12 @@ 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
|
-
memoryAutoRecallLimit:
|
|
144
|
+
memoryAutoRecallLimit: 3
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
}
|
|
@@ -151,10 +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
|
-
-
|
|
155
|
-
-
|
|
156
|
-
-
|
|
157
|
-
-
|
|
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.
|
|
159
|
+
- Memory search and auto-recall only return open `type:memory` issues. Closed memory issues are treated as stale.
|
|
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.
|
|
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.
|
|
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.
|
|
158
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.
|
|
159
164
|
- Route resolution is now: agent identity supplies credentials, `defaultRepo` is the fallback memory space, and explicit tool calls may override repo per operation.
|
|
160
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",
|
|
@@ -127,7 +154,7 @@
|
|
|
127
154
|
},
|
|
128
155
|
"memoryAutoRecallLimit": {
|
|
129
156
|
"label": "Auto Recall Limit",
|
|
130
|
-
"help": "Maximum number of active memories injected
|
|
157
|
+
"help": "Maximum number of active memories automatically injected before each turn."
|
|
131
158
|
}
|
|
132
159
|
}
|
|
133
160
|
}
|
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,20 +25,23 @@ 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
|
-
-
|
|
29
|
-
-
|
|
28
|
+
- Rolling conversation digests during the session plus a final issue summary/title after finalize
|
|
29
|
+
- Best-effort automatic memory recall before each turn, scoped to the current agent's `defaultRepo`
|
|
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
|
-
Automatic recall is only a bootstrap. You still need to retrieve before answering when memory may matter, and save after learning something durable.
|
|
33
|
-
|
|
34
33
|
## Mandatory turn loop
|
|
35
34
|
|
|
36
35
|
On every user turn, run this loop:
|
|
37
36
|
|
|
38
37
|
1. Before answering, ask: could ClawMem improve this answer?
|
|
39
38
|
- Default to yes for user preferences, project history, prior decisions, lessons, conventions, terminology, recurring problems, and active tasks.
|
|
40
|
-
-
|
|
41
|
-
-
|
|
39
|
+
- Auto-recall may already inject useful context from the current agent's `defaultRepo`, but it is only a hint. Do not treat missing auto-recall context as proof that no relevant memory exists.
|
|
40
|
+
- If the injected context already answers the question, you do not need to immediately call `memory_recall` again.
|
|
41
|
+
- Auto-recall does not currently fan out across every accessible repo. Shared organization memory, team memory, and project memory outside the current `defaultRepo` will not be recalled automatically.
|
|
42
|
+
- Before explicit memory work, choose the right repo. If unclear, inspect `memory_repos` and fall back to the agent's `defaultRepo`. If the likely memory lives outside the default repo, use explicit repo selection instead of relying on auto-recall.
|
|
43
|
+
- Use `memory_recall` when injected context is missing, weak, cross-repo, high-stakes, or when you need an explicit retrieval trace.
|
|
44
|
+
- Write `memory_recall.query` as a short natural-language intent. Do not paste long code blocks, full logs, tool chatter, or system prompt text unless the exact wording is necessary.
|
|
42
45
|
- When the question spans more than one angle, run more than one recall query across keywords, topics, synonyms, and likely project phrasing.
|
|
43
46
|
- If `memory_recall` is weak or empty and the answer depends on whether a memory exists, cross-check with `memory_list`.
|
|
44
47
|
- If the first recall pass is weak, broaden with shorter terms, adjacent topics, or alternate phrasing before concluding a miss.
|
|
@@ -46,6 +49,7 @@ On every user turn, run this loop:
|
|
|
46
49
|
- Never treat a `memory_recall` miss by itself as proof that no relevant memory exists.
|
|
47
50
|
2. After answering, ask: did this turn create durable knowledge?
|
|
48
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.
|
|
49
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.
|
|
50
54
|
- Use `memory_update` when the same canonical fact or ongoing task should keep evolving as one node.
|
|
51
55
|
- When updating an existing memory, preserve that node's current language unless the user explicitly asks for a rewrite.
|
|
@@ -55,11 +59,11 @@ On every user turn, run this loop:
|
|
|
55
59
|
- For new memories, write the memory title and body in the user's current language by default.
|
|
56
60
|
- Use `memory_forget` when a memory is stale, superseded, or harmful if reused.
|
|
57
61
|
3. Keep the user posted.
|
|
58
|
-
- If a retrieved memory materially shaped the answer,
|
|
62
|
+
- If a retrieved memory materially shaped the answer, briefly surface that fact in the user's current language.
|
|
59
63
|
- Include the memory id and title only when they help with debugging, traceability, or an explicit user request.
|
|
60
64
|
- After creating or updating a memory, give a short confirmation in the user's current language instead of forcing fixed English phrasing.
|
|
61
65
|
|
|
62
|
-
Bias toward
|
|
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.
|
|
63
67
|
|
|
64
68
|
## Retrieval and storage rules
|
|
65
69
|
|
|
@@ -69,6 +73,7 @@ Bias toward retrieving and saving. A missed search or missed memory is worse tha
|
|
|
69
73
|
- Private personal memory usually belongs in the agent's `defaultRepo`.
|
|
70
74
|
- Project memory belongs in the relevant project repo.
|
|
71
75
|
- Shared or team knowledge belongs in the shared repo for that group.
|
|
76
|
+
- Shared or team knowledge in another repo is not part of default auto-recall today. To use it, select that repo explicitly with `memory_recall`, `memory_list`, or `memory_get`.
|
|
72
77
|
- Memory titles and bodies default to the user's current language for new memories.
|
|
73
78
|
- Prefer a short standalone title plus a fuller `detail` body instead of stuffing the whole memory into the title.
|
|
74
79
|
- If you omit `title`, the plugin may derive it from `detail`, but providing an explicit title is preferred for readability in the Console.
|
package/src/config.test.ts
CHANGED
|
@@ -27,9 +27,12 @@ function baseConfig(): ClawMemPluginConfig {
|
|
|
27
27
|
},
|
|
28
28
|
},
|
|
29
29
|
memoryRecallLimit: 5,
|
|
30
|
-
memoryAutoRecallLimit:
|
|
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
|
@@ -46,9 +46,12 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
|
|
|
46
46
|
authScheme: raw.authScheme === "bearer" ? "bearer" : "token",
|
|
47
47
|
agents,
|
|
48
48
|
memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
|
|
49
|
-
memoryAutoRecallLimit: clamp(num(raw.memoryAutoRecallLimit,
|
|
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 {
|