@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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadState } from "./state.js";
|
|
5
|
+
|
|
6
|
+
function assert(condition: unknown, message: string): void {
|
|
7
|
+
if (!condition) throw new Error(message);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function withTempStateFile(payload: unknown, fn: (filePath: string) => Promise<void>): Promise<void> {
|
|
11
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawmem-state-"));
|
|
12
|
+
const filePath = path.join(dir, "state.json");
|
|
13
|
+
try {
|
|
14
|
+
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
15
|
+
await fn(filePath);
|
|
16
|
+
} finally {
|
|
17
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function testMigratesLegacyV2State(): Promise<void> {
|
|
22
|
+
await withTempStateFile({
|
|
23
|
+
version: 2,
|
|
24
|
+
sessions: {
|
|
25
|
+
"main:s-1": {
|
|
26
|
+
sessionId: "s-1",
|
|
27
|
+
agentId: "main",
|
|
28
|
+
issueNumber: 10,
|
|
29
|
+
lastMirroredCount: 6,
|
|
30
|
+
turnCount: 6,
|
|
31
|
+
lastMemorySyncCount: 4,
|
|
32
|
+
summaryStatus: "pending",
|
|
33
|
+
finalizedAt: "2026-04-03T10:00:00.000Z",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}, async (filePath) => {
|
|
37
|
+
const state = await loadState(filePath);
|
|
38
|
+
const session = state.sessions["main:s-1"];
|
|
39
|
+
assert(state.version === 3, "expected state version 3 after migration");
|
|
40
|
+
assert(Boolean(session), "expected migrated session to exist");
|
|
41
|
+
assert(session?.derived?.digest.cursor === 0, "expected legacy sessions to rebuild digest from cursor 0");
|
|
42
|
+
assert(session?.derived?.digest.status === "pending", "expected digest to become pending after migration");
|
|
43
|
+
assert(session?.derived?.summary.status === "pending", "expected finalized legacy sessions to keep summary pending");
|
|
44
|
+
assert(session?.derived?.memory.appliedCursor === 4, "expected legacy memory sync cursor to migrate into appliedCursor");
|
|
45
|
+
assert(session?.lastMemorySyncCount === 4, "expected compatibility field to mirror appliedCursor");
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function testNormalizesRunningTaskStates(): Promise<void> {
|
|
50
|
+
await withTempStateFile({
|
|
51
|
+
version: 3,
|
|
52
|
+
sessions: {
|
|
53
|
+
"main:s-2": {
|
|
54
|
+
sessionId: "s-2",
|
|
55
|
+
agentId: "main",
|
|
56
|
+
lastMirroredCount: 3,
|
|
57
|
+
turnCount: 3,
|
|
58
|
+
derived: {
|
|
59
|
+
digest: { cursor: 1, status: "running", attempt: 2, text: "digest" },
|
|
60
|
+
summary: { basedOnCursor: 0, status: "running" },
|
|
61
|
+
memory: {
|
|
62
|
+
extractCursor: 1,
|
|
63
|
+
appliedCursor: 0,
|
|
64
|
+
extractStatus: "running",
|
|
65
|
+
reconcileStatus: "running",
|
|
66
|
+
attempt: 1,
|
|
67
|
+
pendingCandidates: [
|
|
68
|
+
{ candidateId: "abc", detail: "Remember Redis is atomic with Lua." },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}, async (filePath) => {
|
|
75
|
+
const state = await loadState(filePath);
|
|
76
|
+
const session = state.sessions["main:s-2"];
|
|
77
|
+
assert(session?.derived?.digest.status === "pending", "expected running digest tasks to normalize to pending on load");
|
|
78
|
+
assert(session?.derived?.summary.status === "pending", "expected running summary tasks to normalize to pending on load");
|
|
79
|
+
assert(session?.derived?.memory.extractStatus === "pending", "expected running memory extract tasks to normalize to pending");
|
|
80
|
+
assert(session?.derived?.memory.reconcileStatus === "pending", "expected running memory reconcile tasks to normalize to pending");
|
|
81
|
+
assert(session?.derived?.memory.pendingCandidates.length === 1, "expected pending candidates to survive state reload");
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await testMigratesLegacyV2State();
|
|
86
|
+
await testNormalizesRunningTaskStates();
|
|
87
|
+
|
|
88
|
+
console.log("state tests passed");
|
package/src/state.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { PluginState } from "./types.js";
|
|
3
|
+
import type { MemoryCandidate, PluginState, SessionDerivedState, SessionMirrorState, SessionTaskStatus } from "./types.js";
|
|
4
4
|
import { normalizeAgentId, sessionScopeKey } from "./utils.js";
|
|
5
5
|
|
|
6
6
|
const EMPTY_STATE: PluginState = {
|
|
7
|
-
version:
|
|
7
|
+
version: 3,
|
|
8
8
|
sessions: {},
|
|
9
9
|
};
|
|
10
10
|
|
|
@@ -50,7 +50,7 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
const out: PluginState = {
|
|
53
|
-
version:
|
|
53
|
+
version: 3,
|
|
54
54
|
sessions: {},
|
|
55
55
|
...(Object.keys(migrations).length > 0 ? { migrations } : {}),
|
|
56
56
|
};
|
|
@@ -64,6 +64,16 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
64
64
|
continue;
|
|
65
65
|
}
|
|
66
66
|
const agentId = normalizeAgentId(readString(rawSession.agentId));
|
|
67
|
+
const lastMirroredCount = readNumber(rawSession.lastMirroredCount) ?? 0;
|
|
68
|
+
const finalizedAt = readString(rawSession.finalizedAt);
|
|
69
|
+
const summaryStatus = readEnum(rawSession.summaryStatus, ["pending", "complete"]);
|
|
70
|
+
const lastMemorySyncCount = readNumber(rawSession.lastMemorySyncCount);
|
|
71
|
+
const derived = sanitizeDerivedState(rawSession.derived, {
|
|
72
|
+
lastMirroredCount,
|
|
73
|
+
finalizedAt,
|
|
74
|
+
summaryStatus,
|
|
75
|
+
lastMemorySyncCount,
|
|
76
|
+
});
|
|
67
77
|
out.sessions[sessionScopeKey(sessionId, agentId)] = {
|
|
68
78
|
sessionId,
|
|
69
79
|
sessionKey: readString(rawSession.sessionKey),
|
|
@@ -71,14 +81,15 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
71
81
|
agentId,
|
|
72
82
|
issueNumber: readNumber(rawSession.issueNumber),
|
|
73
83
|
issueTitle: readString(rawSession.issueTitle),
|
|
74
|
-
titleSource: readEnum(rawSession.titleSource, ["placeholder", "llm"]),
|
|
75
|
-
lastMirroredCount
|
|
84
|
+
titleSource: readEnum(rawSession.titleSource, ["placeholder", "digest", "llm"]),
|
|
85
|
+
lastMirroredCount,
|
|
76
86
|
turnCount: readNumber(rawSession.turnCount) ?? 0,
|
|
77
|
-
lastMemorySyncCount:
|
|
78
|
-
summaryStatus:
|
|
79
|
-
finalizedAt
|
|
87
|
+
lastMemorySyncCount: derived.memory.appliedCursor,
|
|
88
|
+
summaryStatus: derived.summary.status === "complete" ? "complete" : finalizedAt ? "pending" : undefined,
|
|
89
|
+
finalizedAt,
|
|
80
90
|
lastSummaryHash: readString(rawSession.lastSummaryHash),
|
|
81
91
|
lastTurnHash: readString(rawSession.lastTurnHash),
|
|
92
|
+
derived,
|
|
82
93
|
createdAt: readString(rawSession.createdAt),
|
|
83
94
|
updatedAt: readString(rawSession.updatedAt),
|
|
84
95
|
};
|
|
@@ -86,6 +97,89 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
86
97
|
return out;
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
function sanitizeDerivedState(
|
|
101
|
+
value: unknown,
|
|
102
|
+
fallback: {
|
|
103
|
+
lastMirroredCount: number;
|
|
104
|
+
finalizedAt?: string;
|
|
105
|
+
summaryStatus?: "pending" | "complete";
|
|
106
|
+
lastMemorySyncCount?: number;
|
|
107
|
+
},
|
|
108
|
+
): SessionDerivedState {
|
|
109
|
+
if (!value || typeof value !== "object") {
|
|
110
|
+
return migrateDerivedState(fallback);
|
|
111
|
+
}
|
|
112
|
+
const record = value as Record<string, unknown>;
|
|
113
|
+
const digest = asRecord(record.digest);
|
|
114
|
+
const summary = asRecord(record.summary);
|
|
115
|
+
const memory = asRecord(record.memory);
|
|
116
|
+
const lastMirroredCount = fallback.lastMirroredCount;
|
|
117
|
+
const extractCursor = clampCursor(readNumber(memory?.extractCursor), lastMirroredCount);
|
|
118
|
+
const appliedCursor = clampCursor(readNumber(memory?.appliedCursor), extractCursor);
|
|
119
|
+
return {
|
|
120
|
+
digest: {
|
|
121
|
+
cursor: clampCursor(readNumber(digest?.cursor), lastMirroredCount),
|
|
122
|
+
status: readTaskStatus(digest?.status, lastMirroredCount > 0 ? "pending" : "idle"),
|
|
123
|
+
attempt: readNumber(digest?.attempt) ?? 0,
|
|
124
|
+
text: readString(digest?.text),
|
|
125
|
+
title: readString(digest?.title),
|
|
126
|
+
lastError: readString(digest?.lastError),
|
|
127
|
+
updatedAt: readString(digest?.updatedAt),
|
|
128
|
+
},
|
|
129
|
+
summary: {
|
|
130
|
+
basedOnCursor: clampCursor(readNumber(summary?.basedOnCursor), lastMirroredCount),
|
|
131
|
+
status: readTaskStatus(summary?.status, fallback.finalizedAt ? "pending" : "idle"),
|
|
132
|
+
text: readString(summary?.text),
|
|
133
|
+
lastError: readString(summary?.lastError),
|
|
134
|
+
updatedAt: readString(summary?.updatedAt),
|
|
135
|
+
},
|
|
136
|
+
memory: {
|
|
137
|
+
extractCursor,
|
|
138
|
+
appliedCursor,
|
|
139
|
+
extractStatus: readTaskStatus(memory?.extractStatus, extractCursor < lastMirroredCount ? "pending" : "idle"),
|
|
140
|
+
reconcileStatus: readTaskStatus(memory?.reconcileStatus, appliedCursor < extractCursor ? "pending" : "idle"),
|
|
141
|
+
attempt: readNumber(memory?.attempt) ?? 0,
|
|
142
|
+
pendingCandidates: Array.isArray(memory?.pendingCandidates)
|
|
143
|
+
? memory.pendingCandidates.map(sanitizeCandidate).filter((candidate): candidate is MemoryCandidate => Boolean(candidate))
|
|
144
|
+
: [],
|
|
145
|
+
lastError: readString(memory?.lastError),
|
|
146
|
+
updatedAt: readString(memory?.updatedAt),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function migrateDerivedState(fallback: {
|
|
152
|
+
lastMirroredCount: number;
|
|
153
|
+
finalizedAt?: string;
|
|
154
|
+
summaryStatus?: "pending" | "complete";
|
|
155
|
+
lastMemorySyncCount?: number;
|
|
156
|
+
}): SessionDerivedState {
|
|
157
|
+
const mirrorCursor = fallback.lastMirroredCount;
|
|
158
|
+
const finalized = Boolean(fallback.finalizedAt);
|
|
159
|
+
const summaryComplete = fallback.summaryStatus === "complete";
|
|
160
|
+
const appliedCursor = clampCursor(fallback.lastMemorySyncCount, mirrorCursor);
|
|
161
|
+
const digestCursor = finalized && summaryComplete ? mirrorCursor : 0;
|
|
162
|
+
return {
|
|
163
|
+
digest: {
|
|
164
|
+
cursor: digestCursor,
|
|
165
|
+
status: digestCursor < mirrorCursor ? "pending" : "idle",
|
|
166
|
+
attempt: 0,
|
|
167
|
+
},
|
|
168
|
+
summary: {
|
|
169
|
+
basedOnCursor: summaryComplete ? mirrorCursor : 0,
|
|
170
|
+
status: finalized ? (summaryComplete ? "complete" : "pending") : "idle",
|
|
171
|
+
},
|
|
172
|
+
memory: {
|
|
173
|
+
extractCursor: appliedCursor,
|
|
174
|
+
appliedCursor,
|
|
175
|
+
extractStatus: appliedCursor < mirrorCursor ? "pending" : "idle",
|
|
176
|
+
reconcileStatus: "idle",
|
|
177
|
+
attempt: 0,
|
|
178
|
+
pendingCandidates: [],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
89
183
|
function readString(value: unknown): string | undefined {
|
|
90
184
|
if (typeof value !== "string") {
|
|
91
185
|
return undefined;
|
|
@@ -105,3 +199,40 @@ function readNumber(value: unknown): number | undefined {
|
|
|
105
199
|
}
|
|
106
200
|
return Math.max(0, Math.floor(value));
|
|
107
201
|
}
|
|
202
|
+
|
|
203
|
+
function readTaskStatus(value: unknown, fallback: SessionTaskStatus): SessionTaskStatus {
|
|
204
|
+
const status = readEnum(value, ["idle", "pending", "running", "complete", "error"]);
|
|
205
|
+
if (!status) return fallback;
|
|
206
|
+
return status === "running" ? "pending" : status;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sanitizeCandidate(value: unknown): MemoryCandidate | null {
|
|
210
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
211
|
+
const record = value as Record<string, unknown>;
|
|
212
|
+
const detail = readString(record.detail);
|
|
213
|
+
const candidateId = readString(record.candidateId);
|
|
214
|
+
if (!detail) return null;
|
|
215
|
+
return {
|
|
216
|
+
candidateId: candidateId ?? detail,
|
|
217
|
+
detail,
|
|
218
|
+
...(readString(record.title) ? { title: readString(record.title) } : {}),
|
|
219
|
+
...(readString(record.kind) ? { kind: readString(record.kind) } : {}),
|
|
220
|
+
...(Array.isArray(record.topics)
|
|
221
|
+
? {
|
|
222
|
+
topics: record.topics
|
|
223
|
+
.map((topic) => readString(topic))
|
|
224
|
+
.filter((topic): topic is string => Boolean(topic)),
|
|
225
|
+
}
|
|
226
|
+
: {}),
|
|
227
|
+
...(readString(record.evidence) ? { evidence: readString(record.evidence) } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function clampCursor(value: number | undefined, max: number): number {
|
|
232
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
233
|
+
return Math.min(max, Math.max(0, Math.floor(value)));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
237
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
238
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -17,7 +17,10 @@ export type ClawMemPluginConfig = {
|
|
|
17
17
|
memoryRecallLimit: number;
|
|
18
18
|
memoryAutoRecallLimit: number;
|
|
19
19
|
turnCommentDelayMs: number;
|
|
20
|
+
digestWaitTimeoutMs: number;
|
|
20
21
|
summaryWaitTimeoutMs: number;
|
|
22
|
+
memoryExtractWaitTimeoutMs: number;
|
|
23
|
+
memoryReconcileWaitTimeoutMs: number;
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
export type ClawMemResolvedRoute = {
|
|
@@ -32,16 +35,57 @@ export type ClawMemResolvedRoute = {
|
|
|
32
35
|
export type BootstrapIdentityResponse = { token: string; repo_full_name: string };
|
|
33
36
|
export type AgentRegistrationResponse = BootstrapIdentityResponse & { login: string };
|
|
34
37
|
export type AnonymousSessionResponse = BootstrapIdentityResponse & { owner_login: string; repo_name: string };
|
|
38
|
+
export type SessionTaskStatus = "idle" | "pending" | "running" | "complete" | "error";
|
|
39
|
+
export type MemoryCandidate = {
|
|
40
|
+
candidateId: string;
|
|
41
|
+
detail: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
kind?: string;
|
|
44
|
+
topics?: string[];
|
|
45
|
+
evidence?: string;
|
|
46
|
+
};
|
|
47
|
+
export type SessionDigestState = {
|
|
48
|
+
cursor: number;
|
|
49
|
+
status: SessionTaskStatus;
|
|
50
|
+
attempt: number;
|
|
51
|
+
text?: string;
|
|
52
|
+
title?: string;
|
|
53
|
+
lastError?: string;
|
|
54
|
+
updatedAt?: string;
|
|
55
|
+
};
|
|
56
|
+
export type SessionSummaryState = {
|
|
57
|
+
basedOnCursor: number;
|
|
58
|
+
status: SessionTaskStatus;
|
|
59
|
+
text?: string;
|
|
60
|
+
lastError?: string;
|
|
61
|
+
updatedAt?: string;
|
|
62
|
+
};
|
|
63
|
+
export type SessionMemoryState = {
|
|
64
|
+
extractCursor: number;
|
|
65
|
+
appliedCursor: number;
|
|
66
|
+
extractStatus: SessionTaskStatus;
|
|
67
|
+
reconcileStatus: SessionTaskStatus;
|
|
68
|
+
attempt: number;
|
|
69
|
+
pendingCandidates: MemoryCandidate[];
|
|
70
|
+
lastError?: string;
|
|
71
|
+
updatedAt?: string;
|
|
72
|
+
};
|
|
73
|
+
export type SessionDerivedState = {
|
|
74
|
+
digest: SessionDigestState;
|
|
75
|
+
summary: SessionSummaryState;
|
|
76
|
+
memory: SessionMemoryState;
|
|
77
|
+
};
|
|
35
78
|
export type SessionMirrorState = {
|
|
36
79
|
sessionId: string; sessionKey?: string; sessionFile?: string; agentId?: string;
|
|
37
|
-
issueNumber?: number; issueTitle?: string; titleSource?: "placeholder" | "llm";
|
|
80
|
+
issueNumber?: number; issueTitle?: string; titleSource?: "placeholder" | "digest" | "llm";
|
|
38
81
|
lastMirroredCount: number; turnCount: number; lastAssistantText?: string;
|
|
39
82
|
lastMemorySyncCount?: number;
|
|
40
83
|
summaryStatus?: "pending" | "complete";
|
|
41
84
|
finalizedAt?: string; lastSummaryHash?: string; lastTurnHash?: string;
|
|
85
|
+
derived?: SessionDerivedState;
|
|
42
86
|
createdAt?: string; updatedAt?: string;
|
|
43
87
|
};
|
|
44
|
-
export type PluginState = { version:
|
|
88
|
+
export type PluginState = { version: 3; sessions: Record<string, SessionMirrorState>; migrations?: Record<string, string> };
|
|
45
89
|
export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
|
|
46
90
|
export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
|
|
47
91
|
export type MemoryDraft = { title?: string; detail: string; kind?: string; topics?: string[] };
|
package/src/utils.ts
CHANGED
|
@@ -46,6 +46,25 @@ export function fmtTranscript(msgs: NormalizedMessage[]): string {
|
|
|
46
46
|
return msgs.map((m, i) => `${i + 1}. ${m.role === "assistant" ? "assistant" : "user"}: ${m.text}`).join("\n\n");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function fmtTranscriptFrom(msgs: NormalizedMessage[], startIndex: number): string {
|
|
50
|
+
return msgs.map((m, i) => `${startIndex + i + 1}. ${m.role === "assistant" ? "assistant" : "user"}: ${m.text}`).join("\n\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sliceTranscriptDelta(
|
|
54
|
+
msgs: NormalizedMessage[],
|
|
55
|
+
fromIndex: number,
|
|
56
|
+
anchorCount = 2,
|
|
57
|
+
): { anchorStart: number; deltaStart: number; anchorMessages: NormalizedMessage[]; deltaMessages: NormalizedMessage[] } {
|
|
58
|
+
const deltaStart = Math.min(Math.max(0, Math.floor(fromIndex)), msgs.length);
|
|
59
|
+
const anchorStart = Math.max(0, deltaStart - Math.max(0, Math.floor(anchorCount)));
|
|
60
|
+
return {
|
|
61
|
+
anchorStart,
|
|
62
|
+
deltaStart,
|
|
63
|
+
anchorMessages: msgs.slice(anchorStart, deltaStart),
|
|
64
|
+
deltaMessages: msgs.slice(deltaStart),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
export function localDate(d: Date = new Date()): string {
|
|
50
69
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
51
70
|
}
|