@clawmem-ai/clawmem 0.1.15 → 0.1.17
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 -4
- package/openclaw.plugin.json +11 -11
- package/package.json +12 -2
- package/skills/clawmem/SKILL.md +5 -3
- package/skills/clawmem/references/collaboration.md +43 -1
- package/skills/clawmem/references/schema.md +2 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +1 -1
- package/src/conversation.test.ts +63 -13
- package/src/conversation.ts +100 -188
- package/src/github-client.test.ts +101 -0
- package/src/github-client.ts +59 -0
- package/src/memory.test.ts +154 -39
- package/src/memory.ts +139 -246
- package/src/runtime-env.ts +12 -0
- package/src/service.test.ts +118 -0
- package/src/service.ts +765 -200
- package/src/state.test.ts +119 -0
- package/src/state.ts +124 -25
- package/src/types.ts +33 -6
- package/src/utils.ts +19 -0
package/src/memory.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
// Memory CRUD,
|
|
2
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
1
|
+
// Memory CRUD, recall search helpers, and candidate parsing.
|
|
3
2
|
import { LABEL_MEMORY_STALE, MEMORY_TITLE_PREFIX, extractLabelNames, labelVal } from "./config.js";
|
|
4
3
|
import type { GitHubIssueClient } from "./github-client.js";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import { fmtTranscript, localDate, sha256, subKey } from "./utils.js";
|
|
4
|
+
import type { MemoryCandidate, MemoryDraft, MemoryListOptions, MemorySchema, ParsedMemoryIssue } from "./types.js";
|
|
5
|
+
import { localDate, sha256 } from "./utils.js";
|
|
8
6
|
import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
|
|
9
7
|
import { sanitizeRecallQueryInput } from "./recall-sanitize.js";
|
|
10
8
|
|
|
11
|
-
type MemoryDecision = { save: MemoryDraft[]; stale: string[] };
|
|
12
|
-
type SearchIndex = { title: string; detail: string; kind?: string; topics: string[] };
|
|
13
|
-
|
|
14
9
|
const MAX_BACKEND_QUERY_CHARS = 1500;
|
|
15
10
|
|
|
16
11
|
const RECALL_INJECTED_BLOCKS = [
|
|
@@ -22,7 +17,7 @@ const RECALL_INJECTED_BLOCKS = [
|
|
|
22
17
|
const URL_RE = /https?:\/\/\S+/gi;
|
|
23
18
|
|
|
24
19
|
export class MemoryStore {
|
|
25
|
-
constructor(private readonly client: GitHubIssueClient
|
|
20
|
+
constructor(private readonly client: GitHubIssueClient) {}
|
|
26
21
|
|
|
27
22
|
async search(query: string, limit: number): Promise<ParsedMemoryIssue[]> {
|
|
28
23
|
const q = normalizeSearch(query);
|
|
@@ -48,17 +43,13 @@ export class MemoryStore {
|
|
|
48
43
|
}
|
|
49
44
|
if (batch.length < 100) break;
|
|
50
45
|
}
|
|
51
|
-
for (const memory of await this.listByStatus("all")) {
|
|
52
|
-
if (memory.kind) kinds.add(memory.kind);
|
|
53
|
-
for (const topic of memory.topics ?? []) topics.add(topic);
|
|
54
|
-
}
|
|
55
46
|
return { kinds: [...kinds].sort(), topics: [...topics].sort() };
|
|
56
47
|
}
|
|
57
48
|
|
|
58
49
|
async get(memoryId: string, status: "active" | "stale" | "all" = "all"): Promise<ParsedMemoryIssue | null> {
|
|
59
50
|
const id = memoryId.trim();
|
|
60
51
|
if (!id) throw new Error("memoryId is empty");
|
|
61
|
-
return
|
|
52
|
+
return this.findByRef(id, status);
|
|
62
53
|
}
|
|
63
54
|
|
|
64
55
|
async listMemories(options: MemoryListOptions = {}): Promise<ParsedMemoryIssue[]> {
|
|
@@ -66,22 +57,28 @@ export class MemoryStore {
|
|
|
66
57
|
const kind = normalizeOptionalLabelValue(options.kind, "kind:");
|
|
67
58
|
const topic = normalizeOptionalLabelValue(options.topic, "topic:");
|
|
68
59
|
const limit = Math.min(200, Math.max(1, options.limit ?? 20));
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
60
|
+
const labels = ["type:memory", ...(kind ? [`kind:${kind}`] : []), ...(topic ? [`topic:${topic}`] : [])];
|
|
61
|
+
const state = status === "active" ? "open" : "all";
|
|
62
|
+
const out: ParsedMemoryIssue[] = [];
|
|
63
|
+
for (let page = 1; page <= 20 && out.length < limit; page++) {
|
|
64
|
+
const batch = await this.client.listIssues({ labels, state, page, perPage: Math.min(100, limit) });
|
|
65
|
+
for (const issue of batch) {
|
|
66
|
+
const memory = this.parseIssue(issue);
|
|
67
|
+
if (!memory) continue;
|
|
68
|
+
if (status !== "all" && memory.status !== status) continue;
|
|
69
|
+
out.push(memory);
|
|
70
|
+
if (out.length >= limit) break;
|
|
71
|
+
}
|
|
72
|
+
if (batch.length < Math.min(100, limit)) break;
|
|
73
|
+
}
|
|
74
|
+
return out.sort((a, b) => b.issueNumber - a.issueNumber).slice(0, limit);
|
|
77
75
|
}
|
|
78
76
|
|
|
79
77
|
async store(draft: MemoryDraft): Promise<{ created: boolean; memory: ParsedMemoryIssue }> {
|
|
80
78
|
const normalized = normalizeDraft(draft);
|
|
81
79
|
const detail = norm(normalized.detail);
|
|
82
|
-
const allActive = await this.listByStatus("active");
|
|
83
80
|
const hash = sha256(detail);
|
|
84
|
-
const existing =
|
|
81
|
+
const existing = await this.findActiveByHash(hash);
|
|
85
82
|
if (existing) {
|
|
86
83
|
const memory = await this.mergeSchema(existing, normalized);
|
|
87
84
|
return { created: false, memory };
|
|
@@ -123,11 +120,12 @@ export class MemoryStore {
|
|
|
123
120
|
? uniqueNormalized(patch.topics.map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[])
|
|
124
121
|
: uniqueNormalized(current.topics ?? []);
|
|
125
122
|
const nextHash = sha256(nextDetail);
|
|
126
|
-
const duplicate =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
})
|
|
130
|
-
|
|
123
|
+
const duplicate = await this.findActiveByHash(nextHash);
|
|
124
|
+
if (duplicate?.issueNumber === current.issueNumber) {
|
|
125
|
+
// Updating schema/title without changing the underlying detail is always safe.
|
|
126
|
+
} else if (duplicate) {
|
|
127
|
+
throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
|
|
128
|
+
}
|
|
131
129
|
const nextBody = renderMemoryBody(nextDetail, nextHash, current.date);
|
|
132
130
|
const nextLabels = memLabels(nextKind, nextTopics);
|
|
133
131
|
await this.client.ensureLabels(nextLabels);
|
|
@@ -153,36 +151,6 @@ export class MemoryStore {
|
|
|
153
151
|
return { ...mem, status: "stale" };
|
|
154
152
|
}
|
|
155
153
|
|
|
156
|
-
async syncFromConversation(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<boolean> {
|
|
157
|
-
try {
|
|
158
|
-
const decision = await this.generateDecision(session, snapshot);
|
|
159
|
-
const { savedCount, staledCount } = await this.applyDecision(decision);
|
|
160
|
-
if (savedCount > 0 || staledCount > 0)
|
|
161
|
-
this.api.logger.info?.(`clawmem: synced memories for ${session.sessionId} (saved=${savedCount}, stale=${staledCount})`);
|
|
162
|
-
return true;
|
|
163
|
-
} catch (error) {
|
|
164
|
-
this.api.logger.warn(`clawmem: memory capture failed: ${String(error)}`);
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private async listByStatus(status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue[]> {
|
|
170
|
-
const labels = ["type:memory"];
|
|
171
|
-
const state = status === "active" ? "open" : "all";
|
|
172
|
-
const out: ParsedMemoryIssue[] = [];
|
|
173
|
-
for (let page = 1; page <= 20; page++) {
|
|
174
|
-
const batch = await this.client.listIssues({ labels, state, page, perPage: 100 });
|
|
175
|
-
for (const issue of batch) {
|
|
176
|
-
const parsed = this.parseIssue(issue);
|
|
177
|
-
if (!parsed) continue;
|
|
178
|
-
if (status !== "all" && parsed.status !== status) continue;
|
|
179
|
-
out.push(parsed);
|
|
180
|
-
}
|
|
181
|
-
if (batch.length < 100) break;
|
|
182
|
-
}
|
|
183
|
-
return out;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
154
|
private async searchViaBackend(query: string, limit: number): Promise<ParsedMemoryIssue[]> {
|
|
187
155
|
const repo = this.client.repo();
|
|
188
156
|
if (!repo) throw new Error("ClawMem memory recall requires a configured repo.");
|
|
@@ -194,6 +162,46 @@ export class MemoryStore {
|
|
|
194
162
|
.slice(0, limit);
|
|
195
163
|
}
|
|
196
164
|
|
|
165
|
+
private async findActiveByHash(hash: string): Promise<ParsedMemoryIssue | null> {
|
|
166
|
+
const repo = this.client.repo?.();
|
|
167
|
+
if (!repo) return null;
|
|
168
|
+
const query = buildMemoryHashSearchQuery(hash, repo);
|
|
169
|
+
const batch = await this.client.searchIssues(query, { perPage: 10 });
|
|
170
|
+
return batch
|
|
171
|
+
.map((issue) => this.parseIssue(issue))
|
|
172
|
+
.find((memory): memory is ParsedMemoryIssue =>
|
|
173
|
+
memory !== null && memory.status === "active" && (memory.memoryHash || sha256(norm(memory.detail))) === hash,
|
|
174
|
+
) ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async findByRef(id: string, status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue | null> {
|
|
178
|
+
const trimmed = id.trim();
|
|
179
|
+
if (!trimmed) return null;
|
|
180
|
+
if (/^\d+$/.test(trimmed)) {
|
|
181
|
+
try {
|
|
182
|
+
const issue = await this.client.getIssue(Number(trimmed));
|
|
183
|
+
const parsed = this.parseIssue(issue);
|
|
184
|
+
if (!parsed) return null;
|
|
185
|
+
if (status !== "all" && parsed.status !== status) return null;
|
|
186
|
+
return parsed;
|
|
187
|
+
} catch {
|
|
188
|
+
// Fall through to memory-id search for nonstandard repos that expose custom memory ids.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const repo = this.client.repo?.();
|
|
192
|
+
if (!repo) return null;
|
|
193
|
+
const batch = await this.client.searchIssues(buildMemoryRefSearchQuery(trimmed, repo, status), { perPage: 10 });
|
|
194
|
+
return batch
|
|
195
|
+
.map((issue) => this.parseIssue(issue))
|
|
196
|
+
.find((memory): memory is ParsedMemoryIssue =>
|
|
197
|
+
memory !== null && (status === "all" || memory.status === status) && (memory.memoryId === trimmed || String(memory.issueNumber) === trimmed),
|
|
198
|
+
) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async findActiveByRef(id: string): Promise<ParsedMemoryIssue | null> {
|
|
202
|
+
return this.findByRef(id, "active");
|
|
203
|
+
}
|
|
204
|
+
|
|
197
205
|
private parseIssue(issue: { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> }): ParsedMemoryIssue | null {
|
|
198
206
|
const labels = extractLabelNames(issue.labels);
|
|
199
207
|
if (!labels.includes("type:memory")) return null;
|
|
@@ -217,103 +225,6 @@ export class MemoryStore {
|
|
|
217
225
|
};
|
|
218
226
|
}
|
|
219
227
|
|
|
220
|
-
private async applyDecision(decision: MemoryDecision): Promise<{ savedCount: number; staledCount: number }> {
|
|
221
|
-
const allActive = await this.listByStatus("active");
|
|
222
|
-
const activeById = new Map(allActive.map((m) => [m.memoryId, m]));
|
|
223
|
-
const activeByHash = new Map(allActive.map((m) => [m.memoryHash || sha256(norm(m.detail)), m]));
|
|
224
|
-
let savedCount = 0;
|
|
225
|
-
for (const raw of decision.save) {
|
|
226
|
-
const draft = normalizeDraft(raw);
|
|
227
|
-
const detail = norm(draft.detail);
|
|
228
|
-
if (!detail) continue;
|
|
229
|
-
const hash = sha256(detail);
|
|
230
|
-
const existing = activeByHash.get(hash);
|
|
231
|
-
if (existing) {
|
|
232
|
-
const merged = await this.mergeSchema(existing, draft);
|
|
233
|
-
activeByHash.set(hash, merged);
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
const labels = memLabels(draft.kind, draft.topics);
|
|
237
|
-
const date = localDate();
|
|
238
|
-
const title = renderMemoryTitle(draft);
|
|
239
|
-
const body = renderMemoryBody(detail, hash, date);
|
|
240
|
-
await this.client.ensureLabels(labels);
|
|
241
|
-
const issue = await this.client.createIssue({ title, body, labels });
|
|
242
|
-
activeByHash.set(hash, {
|
|
243
|
-
issueNumber: issue.number,
|
|
244
|
-
title,
|
|
245
|
-
memoryId: String(issue.number),
|
|
246
|
-
memoryHash: hash,
|
|
247
|
-
date,
|
|
248
|
-
detail,
|
|
249
|
-
...(draft.kind ? { kind: draft.kind } : {}),
|
|
250
|
-
...(draft.topics && draft.topics.length > 0 ? { topics: draft.topics } : {}),
|
|
251
|
-
status: "active",
|
|
252
|
-
});
|
|
253
|
-
savedCount++;
|
|
254
|
-
}
|
|
255
|
-
let staledCount = 0;
|
|
256
|
-
for (const id of [...new Set(decision.stale.map((s) => s.trim()).filter(Boolean))]) {
|
|
257
|
-
const mem = activeById.get(id);
|
|
258
|
-
if (!mem) continue;
|
|
259
|
-
await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.kind, mem.topics));
|
|
260
|
-
await this.client.updateIssue(mem.issueNumber, { state: "closed" });
|
|
261
|
-
staledCount++;
|
|
262
|
-
}
|
|
263
|
-
return { savedCount, staledCount };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
private async generateDecision(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<MemoryDecision> {
|
|
267
|
-
if (snapshot.messages.length === 0) return { save: [], stale: [] };
|
|
268
|
-
const recent = (await this.listByStatus("active")).sort((a, b) => b.issueNumber - a.issueNumber).slice(0, 20);
|
|
269
|
-
const existingBlock = recent.length === 0 ? "None." : recent.map((m) => {
|
|
270
|
-
const schema = [m.kind ? `kind=${m.kind}` : "", ...(m.topics ?? []).map((topic) => `topic=${topic}`)].filter(Boolean).join(", ");
|
|
271
|
-
return `[${m.memoryId}] ${schema ? `${schema} | ` : ""}${m.detail}`;
|
|
272
|
-
}).join("\n");
|
|
273
|
-
const schema = await this.listSchema();
|
|
274
|
-
const schemaBlock = [
|
|
275
|
-
`Existing kinds: ${schema.kinds.length > 0 ? schema.kinds.join(", ") : "None."}`,
|
|
276
|
-
`Existing topics: ${schema.topics.length > 0 ? schema.topics.join(", ") : "None."}`,
|
|
277
|
-
].join("\n");
|
|
278
|
-
const subagent = this.api.runtime.subagent;
|
|
279
|
-
const sessionKey = subKey(session, "memory");
|
|
280
|
-
const message = [
|
|
281
|
-
"Extract durable memories from the conversation below.",
|
|
282
|
-
'Return JSON only in the form {"save":[{"title":"...","detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
|
|
283
|
-
"Each save item must contain one durable fact. If a turn contains several independent facts, save them separately instead of bundling them into one summary memory.",
|
|
284
|
-
"Use save for stable, reusable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
|
|
285
|
-
"Title is optional. If you provide one, make it concise and human-readable.",
|
|
286
|
-
"Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
|
|
287
|
-
"Infer kind and topics when they would help future retrieval. Reuse existing kinds and topics when possible.",
|
|
288
|
-
"If no existing kind fits, you may propose a new short kind label. Keep kinds concise and reusable.",
|
|
289
|
-
"Topics should be short reusable tags, not sentences. Prefer 0-3 topics per memory.",
|
|
290
|
-
"Do not save temporary requests, startup boilerplate, tool chatter, summaries about internal helper sessions, or one-off operational details.",
|
|
291
|
-
"Prefer empty arrays when nothing durable should be remembered.",
|
|
292
|
-
"", "<existing-schema>", schemaBlock, "</existing-schema>",
|
|
293
|
-
"", "<existing-active-memories>", existingBlock, "</existing-active-memories>",
|
|
294
|
-
"", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
|
|
295
|
-
].join("\n");
|
|
296
|
-
try {
|
|
297
|
-
const run = await subagent.run({
|
|
298
|
-
sessionKey,
|
|
299
|
-
message,
|
|
300
|
-
deliver: false,
|
|
301
|
-
lane: "clawmem-memory",
|
|
302
|
-
idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:memory-decision`),
|
|
303
|
-
extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with save objects containing detail, optional title, optional kind, and optional topics, plus stale string ids. Keep each save item to one durable fact.",
|
|
304
|
-
});
|
|
305
|
-
const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
|
|
306
|
-
if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
|
|
307
|
-
if (wait.status === "error") throw new Error(wait.error || "memory decision subagent failed");
|
|
308
|
-
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
|
|
309
|
-
const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
|
|
310
|
-
if (!text) throw new Error("memory decision subagent returned no assistant text");
|
|
311
|
-
return parseDecision(text);
|
|
312
|
-
} finally {
|
|
313
|
-
subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
228
|
private async mergeSchema(memory: ParsedMemoryIssue, draft: MemoryDraft): Promise<ParsedMemoryIssue> {
|
|
318
229
|
const normalized = normalizeDraft(draft);
|
|
319
230
|
const nextKind = normalized.kind ?? memory.kind;
|
|
@@ -376,46 +287,23 @@ function normalizeSearch(v: string): string {
|
|
|
376
287
|
return v.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
|
|
377
288
|
}
|
|
378
289
|
|
|
379
|
-
function
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
detail: normalizeSearch(memory.detail),
|
|
383
|
-
...(memory.kind ? { kind: normalizeSearch(memory.kind) } : {}),
|
|
384
|
-
topics: (memory.topics ?? []).map(normalizeSearch).filter(Boolean),
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function searchTokens(v: string): string[] {
|
|
389
|
-
const seen = new Set<string>();
|
|
390
|
-
for (const token of v.split(/[^0-9\p{L}]+/u)) {
|
|
391
|
-
if (token.length > 1) seen.add(token);
|
|
392
|
-
}
|
|
393
|
-
for (const chunk of v.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]{2,}/gu) ?? []) {
|
|
394
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
395
|
-
seen.add(chunk[i]!);
|
|
396
|
-
if (i + 1 < chunk.length) seen.add(chunk.slice(i, i + 2));
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
return [...seen];
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function charBigrams(v: string): Set<string> {
|
|
403
|
-
const compact = v.replace(/\s+/g, "");
|
|
404
|
-
if (compact.length < 2) return new Set(compact ? [compact] : []);
|
|
405
|
-
const out = new Set<string>();
|
|
406
|
-
for (let i = 0; i < compact.length - 1; i++) out.add(compact.slice(i, i + 2));
|
|
407
|
-
return out;
|
|
290
|
+
function buildMemorySearchQuery(query: string, repo: string): string {
|
|
291
|
+
const parts = [buildRecallSearchText(query), `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].filter(Boolean);
|
|
292
|
+
return parts.join(" ");
|
|
408
293
|
}
|
|
409
294
|
|
|
410
|
-
function
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return hits / Math.max(left.size, right.size);
|
|
295
|
+
function buildMemoryHashSearchQuery(hash: string, repo: string): string {
|
|
296
|
+
const needle = hash.trim();
|
|
297
|
+
if (!needle) return "";
|
|
298
|
+
return [`"${needle}"`, `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].join(" ");
|
|
415
299
|
}
|
|
416
300
|
|
|
417
|
-
function
|
|
418
|
-
const
|
|
301
|
+
function buildMemoryRefSearchQuery(memoryId: string, repo: string, status: "active" | "stale" | "all"): string {
|
|
302
|
+
const needle = memoryId.trim();
|
|
303
|
+
if (!needle) return "";
|
|
304
|
+
const parts = [`"${needle}"`, `repo:${repo}`, "is:issue", 'label:"type:memory"'];
|
|
305
|
+
if (status === "active") parts.push("state:open");
|
|
306
|
+
if (status === "stale") parts.push("state:closed");
|
|
419
307
|
return parts.join(" ");
|
|
420
308
|
}
|
|
421
309
|
|
|
@@ -436,42 +324,6 @@ function truncateRecallQuery(text: string, maxLen: number): string {
|
|
|
436
324
|
return compact.length <= maxLen ? compact : compact.slice(0, maxLen).trimEnd();
|
|
437
325
|
}
|
|
438
326
|
|
|
439
|
-
export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): number {
|
|
440
|
-
const query = normalizeSearch(rawQuery);
|
|
441
|
-
if (!query) return 0;
|
|
442
|
-
const idx = buildSearchIndex(memory);
|
|
443
|
-
const tokens = searchTokens(query);
|
|
444
|
-
const queryTokenSet = new Set(tokens);
|
|
445
|
-
const titleTokenSet = new Set(searchTokens(idx.title));
|
|
446
|
-
const detailTokenSet = new Set(searchTokens(idx.detail));
|
|
447
|
-
const kindTokenSet = new Set(searchTokens(idx.kind ?? ""));
|
|
448
|
-
const topicTokenSet = new Set(idx.topics.flatMap(searchTokens));
|
|
449
|
-
let score = 0;
|
|
450
|
-
|
|
451
|
-
if (idx.title.includes(query)) score += 18;
|
|
452
|
-
if (idx.detail.includes(query)) score += 12;
|
|
453
|
-
if (idx.kind?.includes(query)) score += 8;
|
|
454
|
-
for (const topic of idx.topics) if (topic.includes(query)) score += 10;
|
|
455
|
-
|
|
456
|
-
for (const token of tokens) {
|
|
457
|
-
if (idx.title.includes(token)) score += 4;
|
|
458
|
-
if (idx.detail.includes(token)) score += 2;
|
|
459
|
-
if (idx.kind?.includes(token)) score += 3;
|
|
460
|
-
if (idx.topics.some((topic) => topic.includes(token))) score += 3;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
score += overlapRatio(queryTokenSet, titleTokenSet) * 10;
|
|
464
|
-
score += overlapRatio(queryTokenSet, detailTokenSet) * 6;
|
|
465
|
-
score += overlapRatio(queryTokenSet, kindTokenSet) * 6;
|
|
466
|
-
score += overlapRatio(queryTokenSet, topicTokenSet) * 8;
|
|
467
|
-
|
|
468
|
-
const queryBigrams = charBigrams(query);
|
|
469
|
-
score += overlapRatio(queryBigrams, charBigrams(idx.title)) * 6;
|
|
470
|
-
score += overlapRatio(queryBigrams, charBigrams(idx.detail)) * 3;
|
|
471
|
-
|
|
472
|
-
return score;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
327
|
function normalizeDraft(input: MemoryDraft): MemoryDraft {
|
|
476
328
|
const detail = norm(input.detail);
|
|
477
329
|
if (!detail) throw new Error("memory detail is empty");
|
|
@@ -510,42 +362,83 @@ function uniqueNormalized(values: string[]): string[] {
|
|
|
510
362
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
|
511
363
|
}
|
|
512
364
|
|
|
513
|
-
function
|
|
514
|
-
const tryParse = (s: string):
|
|
365
|
+
export function parseCandidates(raw: string): MemoryCandidate[] {
|
|
366
|
+
const tryParse = (s: string): MemoryCandidate[] | null => {
|
|
515
367
|
try {
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
368
|
+
const payload = JSON.parse(s) as Record<string, unknown>;
|
|
369
|
+
const candidates = Array.isArray(payload.candidates)
|
|
370
|
+
? payload.candidates.map(parseCandidateItem).filter((candidate): candidate is MemoryCandidate => Boolean(candidate))
|
|
371
|
+
: [];
|
|
372
|
+
return mergeMemoryCandidates([], candidates);
|
|
521
373
|
} catch {
|
|
522
374
|
return null;
|
|
523
375
|
}
|
|
524
376
|
};
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
377
|
+
const trimmed = raw.trim();
|
|
378
|
+
const direct = tryParse(trimmed);
|
|
379
|
+
if (direct) return direct;
|
|
380
|
+
const fenced = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(trimmed);
|
|
381
|
+
if (fenced?.[1]) {
|
|
382
|
+
const nested = tryParse(fenced[1].trim());
|
|
529
383
|
if (nested) return nested;
|
|
530
|
-
|
|
531
|
-
|
|
384
|
+
}
|
|
385
|
+
throw new Error("finalize memory candidates returned invalid JSON");
|
|
532
386
|
}
|
|
533
387
|
|
|
534
|
-
function
|
|
388
|
+
function parseCandidateItem(value: unknown): MemoryCandidate | null {
|
|
535
389
|
if (typeof value === "string") {
|
|
536
390
|
const detail = norm(value);
|
|
537
|
-
return detail ? { detail } : null;
|
|
391
|
+
return detail ? { candidateId: sha256(detail), detail } : null;
|
|
538
392
|
}
|
|
539
393
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
540
394
|
const record = value as Record<string, unknown>;
|
|
541
|
-
const title = typeof record.title === "string" ? record.title : undefined;
|
|
542
395
|
const detail = typeof record.detail === "string" ? norm(record.detail) : "";
|
|
543
396
|
if (!detail) return null;
|
|
397
|
+
const title = typeof record.title === "string" ? record.title : undefined;
|
|
544
398
|
const kind = typeof record.kind === "string" ? record.kind : undefined;
|
|
545
|
-
const topics = Array.isArray(record.topics) ? record.topics.filter((
|
|
399
|
+
const topics = Array.isArray(record.topics) ? record.topics.filter((topic): topic is string => typeof topic === "string") : undefined;
|
|
400
|
+
const evidence = typeof record.evidence === "string" ? norm(record.evidence) : undefined;
|
|
546
401
|
try {
|
|
547
|
-
|
|
402
|
+
const draft = normalizeDraft({
|
|
403
|
+
...(title ? { title } : {}),
|
|
404
|
+
detail,
|
|
405
|
+
...(kind ? { kind } : {}),
|
|
406
|
+
...(topics ? { topics } : {}),
|
|
407
|
+
});
|
|
408
|
+
return {
|
|
409
|
+
candidateId: sha256(draft.detail),
|
|
410
|
+
detail: draft.detail,
|
|
411
|
+
...(draft.title ? { title: draft.title } : {}),
|
|
412
|
+
...(draft.kind ? { kind: draft.kind } : {}),
|
|
413
|
+
...(draft.topics ? { topics: draft.topics } : {}),
|
|
414
|
+
...(evidence ? { evidence } : {}),
|
|
415
|
+
};
|
|
548
416
|
} catch {
|
|
549
417
|
return null;
|
|
550
418
|
}
|
|
551
419
|
}
|
|
420
|
+
|
|
421
|
+
export function mergeMemoryCandidates(base: MemoryCandidate[], next: MemoryCandidate[]): MemoryCandidate[] {
|
|
422
|
+
const out = new Map<string, MemoryCandidate>();
|
|
423
|
+
for (const candidate of [...base, ...next]) {
|
|
424
|
+
const existing = out.get(candidate.candidateId);
|
|
425
|
+
if (!existing) {
|
|
426
|
+
out.set(candidate.candidateId, {
|
|
427
|
+
...candidate,
|
|
428
|
+
...(candidate.topics ? { topics: uniqueNormalized(candidate.topics) } : {}),
|
|
429
|
+
});
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
out.set(candidate.candidateId, {
|
|
433
|
+
candidateId: candidate.candidateId,
|
|
434
|
+
detail: candidate.detail || existing.detail,
|
|
435
|
+
...(candidate.title || existing.title ? { title: candidate.title || existing.title } : {}),
|
|
436
|
+
...(candidate.kind || existing.kind ? { kind: candidate.kind || existing.kind } : {}),
|
|
437
|
+
...((candidate.topics || existing.topics)
|
|
438
|
+
? { topics: uniqueNormalized([...(existing.topics ?? []), ...(candidate.topics ?? [])]) }
|
|
439
|
+
: {}),
|
|
440
|
+
...(candidate.evidence || existing.evidence ? { evidence: candidate.evidence || existing.evidence } : {}),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return [...out.values()];
|
|
444
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getOpenClawAgentIdFromEnv(): string | undefined {
|
|
2
|
+
const value = process.env.OPENCLAW_AGENT_ID;
|
|
3
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function getOpenClawHostVersionFromEnv(): string | undefined {
|
|
7
|
+
for (const candidate of [process.env.OPENCLAW_VERSION, process.env.OPENCLAW_SERVICE_VERSION]) {
|
|
8
|
+
const trimmed = candidate?.trim();
|
|
9
|
+
if (trimmed) return trimmed;
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
package/src/service.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
buildClawMemPromptSection,
|
|
2
3
|
buildAutoRecallContext,
|
|
4
|
+
createClawMemPlugin,
|
|
3
5
|
extractPromptTextForRecall,
|
|
4
6
|
resolveOpenClawHostVersion,
|
|
5
7
|
resolvePromptHookMode,
|
|
@@ -75,6 +77,118 @@ function testBuildAutoRecallContext(): void {
|
|
|
75
77
|
assert(context.includes("- [11] OpenClaw main agent identity uses Gandalf."), "expected memories to be listed as bullets");
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
function testBuildClawMemPromptSection(): void {
|
|
81
|
+
const lines = buildClawMemPromptSection({
|
|
82
|
+
availableTools: new Set([
|
|
83
|
+
"memory_recall",
|
|
84
|
+
"memory_list",
|
|
85
|
+
"memory_get",
|
|
86
|
+
"memory_repos",
|
|
87
|
+
"memory_labels",
|
|
88
|
+
"memory_store",
|
|
89
|
+
"memory_update",
|
|
90
|
+
"memory_forget",
|
|
91
|
+
]),
|
|
92
|
+
});
|
|
93
|
+
const prompt = lines.join("\n");
|
|
94
|
+
|
|
95
|
+
assert(lines[0] === "## ClawMem", "expected a stable heading for always-on ClawMem guidance");
|
|
96
|
+
assert(prompt.includes("active long-term memory system"), "expected the prompt to frame ClawMem as the active memory system");
|
|
97
|
+
assert(prompt.includes("`memory_recall`, `memory_list`, and `memory_get`"), "expected explicit retrieval guidance");
|
|
98
|
+
assert(prompt.includes("`memory_store` and `memory_update`"), "expected explicit save guidance");
|
|
99
|
+
assert(prompt.includes("`memory_forget`"), "expected explicit stale-memory guidance");
|
|
100
|
+
assert(prompt.includes("Store one durable fact per memory."), "expected one-fact-per-memory guidance");
|
|
101
|
+
assert(prompt.includes("Skip temporary requests, tool chatter"), "expected anti-noise write guardrails");
|
|
102
|
+
assert(prompt.includes("explicit short `title` plus a fuller `detail`"), "expected explicit title guidance");
|
|
103
|
+
assert(prompt.includes("user's current language"), "expected language guidance for new memories");
|
|
104
|
+
assert(prompt.includes("`memory_labels`"), "expected schema reuse guidance to mention memory_labels");
|
|
105
|
+
assert(prompt.includes("translated or near-duplicate variant"), "expected anti-duplication schema guidance");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createFakePluginApi(options?: {
|
|
109
|
+
slot?: string;
|
|
110
|
+
exposeCapability?: boolean;
|
|
111
|
+
}) {
|
|
112
|
+
let registeredCapability: { promptBuilder?: typeof buildClawMemPromptSection } | undefined;
|
|
113
|
+
let registeredPromptSection: typeof buildClawMemPromptSection | undefined;
|
|
114
|
+
const api = {
|
|
115
|
+
id: "clawmem",
|
|
116
|
+
name: "ClawMem",
|
|
117
|
+
source: "test",
|
|
118
|
+
registrationMode: "test",
|
|
119
|
+
config: {},
|
|
120
|
+
pluginConfig: {},
|
|
121
|
+
logger: {
|
|
122
|
+
info: () => {},
|
|
123
|
+
warn: () => {},
|
|
124
|
+
},
|
|
125
|
+
runtime: {
|
|
126
|
+
version: "2026.4.9",
|
|
127
|
+
config: {
|
|
128
|
+
loadConfig: () => ({
|
|
129
|
+
plugins: {
|
|
130
|
+
slots: {
|
|
131
|
+
memory: options?.slot ?? "clawmem",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
events: {
|
|
137
|
+
onSessionTranscriptUpdate: () => () => {},
|
|
138
|
+
},
|
|
139
|
+
subagent: {},
|
|
140
|
+
},
|
|
141
|
+
on: () => {},
|
|
142
|
+
registerTool: () => {},
|
|
143
|
+
registerService: () => {},
|
|
144
|
+
...(options?.exposeCapability === false
|
|
145
|
+
? {}
|
|
146
|
+
: {
|
|
147
|
+
registerMemoryCapability: (capability: { promptBuilder?: typeof buildClawMemPromptSection }) => {
|
|
148
|
+
registeredCapability = capability;
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
registerMemoryPromptSection: (builder: typeof buildClawMemPromptSection) => {
|
|
152
|
+
registeredPromptSection = builder;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
api,
|
|
158
|
+
getRegisteredCapability: () => registeredCapability,
|
|
159
|
+
getRegisteredPromptSection: () => registeredPromptSection,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function testRegistersAlwaysOnMemoryPromptCapability(): void {
|
|
164
|
+
const fake = createFakePluginApi();
|
|
165
|
+
createClawMemPlugin(fake.api as never);
|
|
166
|
+
|
|
167
|
+
const capability = fake.getRegisteredCapability();
|
|
168
|
+
assert(Boolean(capability?.promptBuilder), "expected ClawMem to register a memory prompt builder");
|
|
169
|
+
const prompt = capability?.promptBuilder?.({ availableTools: new Set(["memory_recall", "memory_store"]) }).join("\n") ?? "";
|
|
170
|
+
assert(prompt.includes("## ClawMem"), "expected the registered prompt builder to emit ClawMem guidance");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function testFallsBackToLegacyMemoryPromptSectionRegistration(): void {
|
|
174
|
+
const fake = createFakePluginApi({ exposeCapability: false });
|
|
175
|
+
createClawMemPlugin(fake.api as never);
|
|
176
|
+
|
|
177
|
+
assert(!fake.getRegisteredCapability(), "expected no memory capability registration when the host lacks that API");
|
|
178
|
+
const builder = fake.getRegisteredPromptSection();
|
|
179
|
+
assert(Boolean(builder), "expected fallback registration through registerMemoryPromptSection");
|
|
180
|
+
const prompt = builder?.({ availableTools: new Set(["memory_recall"]) }).join("\n") ?? "";
|
|
181
|
+
assert(prompt.includes("## ClawMem"), "expected the fallback builder to emit ClawMem guidance");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function testSkipsAlwaysOnPromptWhenClawMemIsNotSelectedMemoryPlugin(): void {
|
|
185
|
+
const fake = createFakePluginApi({ slot: "other-memory" });
|
|
186
|
+
createClawMemPlugin(fake.api as never);
|
|
187
|
+
|
|
188
|
+
assert(!fake.getRegisteredCapability(), "expected no memory prompt registration when ClawMem is not the selected memory plugin");
|
|
189
|
+
assert(!fake.getRegisteredPromptSection(), "expected no legacy prompt registration when ClawMem is not selected");
|
|
190
|
+
}
|
|
191
|
+
|
|
78
192
|
function testResolveHostVersionFromRuntime(): void {
|
|
79
193
|
const version = resolveOpenClawHostVersion({ runtime: { version: "2026.3.28" } } as never);
|
|
80
194
|
assert(version === "2026.3.28", "expected runtime.version to take precedence");
|
|
@@ -139,11 +253,15 @@ testExtractPromptFallsBackToLatestUserMessage();
|
|
|
139
253
|
testExtractPromptFromPromptField();
|
|
140
254
|
testExtractPromptFromStructuredContent();
|
|
141
255
|
testBuildAutoRecallContext();
|
|
256
|
+
testBuildClawMemPromptSection();
|
|
142
257
|
testResolveHostVersionFromRuntime();
|
|
143
258
|
testResolveHostVersionFromEnvFallback();
|
|
144
259
|
testIgnoresNpmPackageVersionFallback();
|
|
145
260
|
testResolvePromptHookModeModern();
|
|
146
261
|
testResolvePromptHookModeLegacy();
|
|
147
262
|
testResolvePromptHookModeLegacyForUnknownVersion();
|
|
263
|
+
testRegistersAlwaysOnMemoryPromptCapability();
|
|
264
|
+
testFallsBackToLegacyMemoryPromptSectionRegistration();
|
|
265
|
+
testSkipsAlwaysOnPromptWhenClawMemIsNotSelectedMemoryPlugin();
|
|
148
266
|
|
|
149
267
|
console.log("service tests passed");
|