@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/src/memory.ts CHANGED
@@ -1,16 +1,11 @@
1
- // Memory CRUD, sha256 dedup, and AI-driven memory extraction.
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 { normalizeMessages } from "./transcript.js";
6
- import type { ClawMemPluginConfig, MemoryDraft, MemoryListOptions, MemorySchema, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
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, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
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 (await this.listByStatus(status)).find((m) => m.memoryId === id || String(m.issueNumber) === id) ?? null;
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
- return (await this.listByStatus(status))
70
- .filter((memory) => {
71
- if (kind && memory.kind !== kind) return false;
72
- if (topic && !(memory.topics ?? []).includes(topic)) return false;
73
- return true;
74
- })
75
- .sort((a, b) => b.issueNumber - a.issueNumber)
76
- .slice(0, limit);
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 = allActive.find((m) => (m.memoryHash || sha256(norm(m.detail))) === hash);
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 = (await this.listByStatus("active")).find((memory) => {
127
- if (memory.issueNumber === current.issueNumber) return false;
128
- return (memory.memoryHash || sha256(norm(memory.detail))) === nextHash;
129
- });
130
- if (duplicate) throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
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 buildSearchIndex(memory: ParsedMemoryIssue): SearchIndex {
380
- return {
381
- title: normalizeSearch(memory.title),
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 overlapRatio(left: Set<string>, right: Set<string>): number {
411
- if (left.size === 0 || right.size === 0) return 0;
412
- let hits = 0;
413
- for (const token of left) if (right.has(token)) hits++;
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 buildMemorySearchQuery(query: string, repo: string): string {
418
- const parts = [buildRecallSearchText(query), `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].filter(Boolean);
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 parseDecision(raw: string): MemoryDecision {
514
- const tryParse = (s: string): MemoryDecision | null => {
365
+ export function parseCandidates(raw: string): MemoryCandidate[] {
366
+ const tryParse = (s: string): MemoryCandidate[] | null => {
515
367
  try {
516
- const p = JSON.parse(s) as Record<string, unknown>;
517
- return {
518
- save: Array.isArray(p.save) ? p.save.map(parseSaveItem).filter((v): v is MemoryDraft => Boolean(v)) : [],
519
- stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [],
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 t = raw.trim();
526
- return tryParse(t) ?? (() => {
527
- const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
528
- const nested = f?.[1] ? tryParse(f[1].trim()) : null;
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
- throw new Error("memory decision subagent returned invalid JSON");
531
- })();
384
+ }
385
+ throw new Error("finalize memory candidates returned invalid JSON");
532
386
  }
533
387
 
534
- function parseSaveItem(value: unknown): MemoryDraft | null {
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((v): v is string => typeof v === "string") : undefined;
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
- return normalizeDraft({ ...(title ? { title } : {}), detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
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
+ }
@@ -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");