@clawmem-ai/clawmem 0.1.12 → 0.1.14

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
@@ -3,7 +3,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
3
  import { LABEL_MEMORY_STALE, MEMORY_TITLE_PREFIX, extractLabelNames, labelVal } from "./config.js";
4
4
  import type { GitHubIssueClient } from "./github-client.js";
5
5
  import { normalizeMessages } from "./transcript.js";
6
- import type { ClawMemPluginConfig, MemoryDraft, MemoryListOptions, MemorySchema, NormalizedMessage, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
6
+ import type { ClawMemPluginConfig, MemoryDraft, MemoryListOptions, MemorySchema, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
7
7
  import { fmtTranscript, localDate, sha256, subKey } from "./utils.js";
8
8
  import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
9
9
 
@@ -84,8 +84,8 @@ export class MemoryStore {
84
84
 
85
85
  const date = localDate();
86
86
  const labels = memLabels(normalized.kind, normalized.topics);
87
- const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
88
- const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
87
+ const title = renderMemoryTitle(normalized);
88
+ const body = renderMemoryBody(detail, hash, date);
89
89
  await this.client.ensureLabels(labels);
90
90
  const issue = await this.client.createIssue({ title, body, labels });
91
91
  return {
@@ -104,10 +104,15 @@ export class MemoryStore {
104
104
  };
105
105
  }
106
106
 
107
- async update(memoryId: string, patch: { detail?: string; kind?: string; topics?: string[] }): Promise<ParsedMemoryIssue | null> {
107
+ async update(memoryId: string, patch: { title?: string; detail?: string; kind?: string; topics?: string[] }): Promise<ParsedMemoryIssue | null> {
108
108
  const current = await this.get(memoryId, "all");
109
109
  if (!current) return null;
110
110
  const nextDetail = typeof patch.detail === "string" && patch.detail.trim() ? norm(patch.detail) : current.detail;
111
+ const nextTitle = typeof patch.title === "string" && patch.title.trim()
112
+ ? renderMemoryTitle({ title: patch.title.trim(), detail: nextDetail })
113
+ : patch.detail !== undefined
114
+ ? renderMemoryTitle({ detail: nextDetail })
115
+ : current.title || renderMemoryTitle({ detail: nextDetail });
111
116
  const nextKind = patch.kind !== undefined ? normalizeLabelValue(patch.kind, "kind:") : current.kind;
112
117
  const nextTopics = patch.topics !== undefined
113
118
  ? uniqueNormalized(patch.topics.map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[])
@@ -118,8 +123,7 @@ export class MemoryStore {
118
123
  return (memory.memoryHash || sha256(norm(memory.detail))) === nextHash;
119
124
  });
120
125
  if (duplicate) throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
121
- const nextTitle = `${MEMORY_TITLE_PREFIX}${trunc(nextDetail, 72)}`;
122
- const nextBody = stringifyFlatYaml([["memory_hash", nextHash], ["date", current.date], ["detail", nextDetail]]);
126
+ const nextBody = renderMemoryBody(nextDetail, nextHash, current.date);
123
127
  const nextLabels = memLabels(nextKind, nextTopics);
124
128
  await this.client.ensureLabels(nextLabels);
125
129
  await this.client.updateIssue(current.issueNumber, { title: nextTitle, body: nextBody });
@@ -201,15 +205,16 @@ export class MemoryStore {
201
205
  const kind = labelVal(labels, "kind:");
202
206
  const topics = labels.filter((l) => l.startsWith("topic:")).map((l) => l.slice(6).trim()).filter(Boolean);
203
207
  const rawBody = (issue.body ?? "").trim();
204
- const body = rawBody ? parseFlatYaml(rawBody) : {};
205
- const detail = body.detail?.trim() || rawBody;
208
+ const parsed = parseStoredMemoryBody(rawBody);
209
+ const detail = parsed.detail?.trim() || rawBody;
206
210
  const status = issue.state === "closed" || labels.includes(LABEL_MEMORY_STALE) ? "stale" : "active";
207
211
  if (!detail) return null;
208
212
  return {
209
- issueNumber: issue.number, title: issue.title?.trim() || "",
210
- memoryId: body.memory_id?.trim() || String(issue.number),
211
- memoryHash: body.memory_hash?.trim() || undefined,
212
- date: body.date?.trim() || "1970-01-01",
213
+ issueNumber: issue.number,
214
+ title: issue.title?.trim() || "",
215
+ memoryId: parsed.meta.memory_id?.trim() || String(issue.number),
216
+ memoryHash: parsed.meta.memory_hash?.trim() || undefined,
217
+ date: parsed.meta.date?.trim() || "1970-01-01",
213
218
  detail,
214
219
  ...(kind ? { kind } : {}),
215
220
  ...(topics.length > 0 ? { topics } : {}),
@@ -235,8 +240,8 @@ export class MemoryStore {
235
240
  }
236
241
  const labels = memLabels(draft.kind, draft.topics);
237
242
  const date = localDate();
238
- const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
239
- const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
243
+ const title = renderMemoryTitle(draft);
244
+ const body = renderMemoryBody(detail, hash, date);
240
245
  await this.client.ensureLabels(labels);
241
246
  const issue = await this.client.createIssue({ title, body, labels });
242
247
  activeByHash.set(hash, {
@@ -279,8 +284,10 @@ export class MemoryStore {
279
284
  const sessionKey = subKey(session, "memory");
280
285
  const message = [
281
286
  "Extract durable memories from the conversation below.",
282
- 'Return JSON only in the form {"save":[{"detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
287
+ 'Return JSON only in the form {"save":[{"title":"...","detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
288
+ "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.",
283
289
  "Use save for stable, reusable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
290
+ "Title is optional. If you provide one, make it concise and human-readable.",
284
291
  "Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
285
292
  "Infer kind and topics when they would help future retrieval. Reuse existing kinds and topics when possible.",
286
293
  "If no existing kind fits, you may propose a new short kind label. Keep kinds concise and reusable.",
@@ -293,9 +300,12 @@ export class MemoryStore {
293
300
  ].join("\n");
294
301
  try {
295
302
  const run = await subagent.run({
296
- sessionKey, message, deliver: false, lane: "clawmem-memory",
303
+ sessionKey,
304
+ message,
305
+ deliver: false,
306
+ lane: "clawmem-memory",
297
307
  idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:memory-decision`),
298
- extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with save objects containing detail, optional kind, and optional topics, plus stale string ids.",
308
+ 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.",
299
309
  });
300
310
  const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
301
311
  if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
@@ -304,7 +314,9 @@ export class MemoryStore {
304
314
  const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
305
315
  if (!text) throw new Error("memory decision subagent returned no assistant text");
306
316
  return parseDecision(text);
307
- } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
317
+ } finally {
318
+ subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
319
+ }
308
320
  }
309
321
 
310
322
  private async mergeSchema(memory: ParsedMemoryIssue, draft: MemoryDraft): Promise<ParsedMemoryIssue> {
@@ -333,11 +345,42 @@ function memLabels(kind?: string, topics?: string[]): string[] {
333
345
  ...((topics ?? []).map((topic) => `topic:${topic}`)),
334
346
  ];
335
347
  }
348
+
349
+ function renderMemoryTitle(draft: Pick<MemoryDraft, "detail" | "title">): string {
350
+ const raw = typeof draft.title === "string" && draft.title.trim() ? draft.title : draft.detail;
351
+ const normalized = norm(raw);
352
+ return normalized.startsWith(MEMORY_TITLE_PREFIX) ? normalized : `${MEMORY_TITLE_PREFIX}${normalized}`;
353
+ }
354
+
355
+ function renderMemoryBody(detail: string, memoryHash: string, date: string): string {
356
+ return stringifyFlatYaml([["memory_hash", memoryHash], ["date", date], ["detail", norm(detail)]]);
357
+ }
358
+
359
+ function parseStoredMemoryBody(rawBody: string): { detail: string; meta: Record<string, string> } {
360
+ const trimmed = rawBody.trim();
361
+ if (!trimmed) return { detail: "", meta: {} };
362
+
363
+ const legacyYaml = parseFlatYaml(trimmed);
364
+ if (legacyYaml.detail?.trim()) {
365
+ return { detail: legacyYaml.detail.trim(), meta: legacyYaml };
366
+ }
367
+
368
+ const hiddenMeta = /(?:^|\n)<!--\s*clawmem-meta\s*\n([\s\S]*?)\n-->\s*$/.exec(trimmed);
369
+ if (!hiddenMeta) {
370
+ return { detail: trimmed, meta: {} };
371
+ }
372
+
373
+ const meta = parseFlatYaml(hiddenMeta[1] ?? "");
374
+ const detail = trimmed.slice(0, hiddenMeta.index).trim() || meta.detail?.trim() || "";
375
+ return { detail, meta };
376
+ }
377
+
336
378
  function norm(v: string): string { return v.replace(/\s+/g, " ").trim(); }
337
379
  function trunc(v: string, max: number): string { const s = norm(v); return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`; }
338
380
  function normalizeSearch(v: string): string {
339
381
  return v.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
340
382
  }
383
+
341
384
  function buildSearchIndex(memory: ParsedMemoryIssue): SearchIndex {
342
385
  return {
343
386
  title: normalizeSearch(memory.title),
@@ -346,6 +389,7 @@ function buildSearchIndex(memory: ParsedMemoryIssue): SearchIndex {
346
389
  topics: (memory.topics ?? []).map(normalizeSearch).filter(Boolean),
347
390
  };
348
391
  }
392
+
349
393
  function searchTokens(v: string): string[] {
350
394
  const seen = new Set<string>();
351
395
  for (const token of v.split(/[^0-9\p{L}]+/u)) {
@@ -359,6 +403,7 @@ function searchTokens(v: string): string[] {
359
403
  }
360
404
  return [...seen];
361
405
  }
406
+
362
407
  function charBigrams(v: string): Set<string> {
363
408
  const compact = v.replace(/\s+/g, "");
364
409
  if (compact.length < 2) return new Set(compact ? [compact] : []);
@@ -366,16 +411,19 @@ function charBigrams(v: string): Set<string> {
366
411
  for (let i = 0; i < compact.length - 1; i++) out.add(compact.slice(i, i + 2));
367
412
  return out;
368
413
  }
414
+
369
415
  function overlapRatio(left: Set<string>, right: Set<string>): number {
370
416
  if (left.size === 0 || right.size === 0) return 0;
371
417
  let hits = 0;
372
418
  for (const token of left) if (right.has(token)) hits++;
373
419
  return hits / Math.max(left.size, right.size);
374
420
  }
421
+
375
422
  function buildMemorySearchQuery(query: string, repo: string): string {
376
423
  const parts = [query.trim(), `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].filter(Boolean);
377
424
  return parts.join(" ");
378
425
  }
426
+
379
427
  export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): number {
380
428
  const query = normalizeSearch(rawQuery);
381
429
  if (!query) return 0;
@@ -411,17 +459,21 @@ export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): n
411
459
 
412
460
  return score;
413
461
  }
462
+
414
463
  function normalizeDraft(input: MemoryDraft): MemoryDraft {
415
464
  const detail = norm(input.detail);
416
465
  if (!detail) throw new Error("memory detail is empty");
466
+ const title = typeof input.title === "string" && input.title.trim() ? norm(input.title) : undefined;
417
467
  const kind = normalizeLabelValue(input.kind, "kind:");
418
468
  const topics = uniqueNormalized((input.topics ?? []).map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[]);
419
469
  return {
470
+ ...(title ? { title } : {}),
420
471
  detail,
421
472
  ...(kind ? { kind } : {}),
422
473
  ...(topics.length > 0 ? { topics } : {}),
423
474
  };
424
475
  }
476
+
425
477
  function normalizeLabelValue(value: string | undefined, prefix: string): string | undefined {
426
478
  if (!value) return undefined;
427
479
  const raw = value.trim().replace(new RegExp(`^${prefix}`, "i"), "");
@@ -433,6 +485,7 @@ function normalizeLabelValue(value: string | undefined, prefix: string): string
433
485
  .replace(/^-+|-+$/g, "");
434
486
  return normalized || undefined;
435
487
  }
488
+
436
489
  function normalizeOptionalLabelValue(value: string | undefined, prefix: string): string | undefined {
437
490
  try {
438
491
  return normalizeLabelValue(value, prefix);
@@ -440,16 +493,22 @@ function normalizeOptionalLabelValue(value: string | undefined, prefix: string):
440
493
  return undefined;
441
494
  }
442
495
  }
496
+
443
497
  function uniqueNormalized(values: string[]): string[] {
444
498
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
445
499
  }
500
+
446
501
  function parseDecision(raw: string): MemoryDecision {
447
502
  const tryParse = (s: string): MemoryDecision | null => {
448
503
  try {
449
504
  const p = JSON.parse(s) as Record<string, unknown>;
450
- return { save: Array.isArray(p.save) ? p.save.map(parseSaveItem).filter((v): v is MemoryDraft => Boolean(v)) : [],
451
- stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [] };
452
- } catch { return null; }
505
+ return {
506
+ save: Array.isArray(p.save) ? p.save.map(parseSaveItem).filter((v): v is MemoryDraft => Boolean(v)) : [],
507
+ stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [],
508
+ };
509
+ } catch {
510
+ return null;
511
+ }
453
512
  };
454
513
  const t = raw.trim();
455
514
  return tryParse(t) ?? (() => {
@@ -459,6 +518,7 @@ function parseDecision(raw: string): MemoryDecision {
459
518
  throw new Error("memory decision subagent returned invalid JSON");
460
519
  })();
461
520
  }
521
+
462
522
  function parseSaveItem(value: unknown): MemoryDraft | null {
463
523
  if (typeof value === "string") {
464
524
  const detail = norm(value);
@@ -466,12 +526,13 @@ function parseSaveItem(value: unknown): MemoryDraft | null {
466
526
  }
467
527
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
468
528
  const record = value as Record<string, unknown>;
529
+ const title = typeof record.title === "string" ? record.title : undefined;
469
530
  const detail = typeof record.detail === "string" ? norm(record.detail) : "";
470
531
  if (!detail) return null;
471
532
  const kind = typeof record.kind === "string" ? record.kind : undefined;
472
533
  const topics = Array.isArray(record.topics) ? record.topics.filter((v): v is string => typeof v === "string") : undefined;
473
534
  try {
474
- return normalizeDraft({ detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
535
+ return normalizeDraft({ ...(title ? { title } : {}), detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
475
536
  } catch {
476
537
  return null;
477
538
  }
@@ -0,0 +1,141 @@
1
+ import {
2
+ buildLegacyRelevantMemoriesContext,
3
+ buildRelevantMemoriesSystemContext,
4
+ extractPromptTextForRecall,
5
+ resolveOpenClawHostVersion,
6
+ resolvePromptHookMode,
7
+ } from "./service.js";
8
+
9
+ function assert(condition: unknown, message: string): void {
10
+ if (!condition) throw new Error(message);
11
+ }
12
+
13
+ function testExtractPromptFromString(): void {
14
+ assert(extractPromptTextForRecall(" help me fix redis ") === "help me fix redis", "expected direct string prompts to be trimmed");
15
+ }
16
+
17
+ function testExtractPromptFromPromptField(): void {
18
+ assert(
19
+ extractPromptTextForRecall({ prompt: "Summarize the release notes." }) === "Summarize the release notes.",
20
+ "expected prompt field to be used when present",
21
+ );
22
+ }
23
+
24
+ function testExtractPromptFromLatestUserMessage(): void {
25
+ const prompt = extractPromptTextForRecall({
26
+ messages: [
27
+ { role: "assistant", text: "How can I help?" },
28
+ { role: "user", text: "Please fix the login bug." },
29
+ ],
30
+ });
31
+ assert(prompt === "Please fix the login bug.", "expected the latest user message to drive recall");
32
+ }
33
+
34
+ function testExtractPromptFromStructuredContent(): void {
35
+ const prompt = extractPromptTextForRecall({
36
+ messages: [
37
+ {
38
+ role: "user",
39
+ content: [
40
+ { type: "text", text: "Check the deployment logs" },
41
+ { type: "text", text: "and verify nginx." },
42
+ ],
43
+ },
44
+ ],
45
+ });
46
+ assert(prompt === "Check the deployment logs\nand verify nginx.", "expected structured text content to be flattened");
47
+ }
48
+
49
+ function testBuildRelevantMemoriesSystemContext(): void {
50
+ const context = buildRelevantMemoriesSystemContext([
51
+ { detail: "OpenClaw main agent identity uses Gandalf." },
52
+ { detail: "Shared memories can break if the repo path changes." },
53
+ ]);
54
+
55
+ assert(context.includes("ClawMem relevant memories:"), "expected a human-readable heading");
56
+ assert(context.includes("- OpenClaw main agent identity uses Gandalf."), "expected memories to be listed as bullets");
57
+ assert(!context.includes("<relevant-memories>"), "expected the legacy XML wrapper to be removed");
58
+ }
59
+
60
+ function testBuildLegacyRelevantMemoriesContext(): void {
61
+ const context = buildLegacyRelevantMemoriesContext([
62
+ { detail: "Use the shared repo for team memory." },
63
+ ]);
64
+
65
+ assert(context.includes("Relevant ClawMem memories for this request:"), "expected a legacy-safe heading");
66
+ assert(context.includes("- Use the shared repo for team memory."), "expected memories to stay readable");
67
+ assert(!context.includes("<relevant-memories>"), "expected legacy context to avoid XML wrappers too");
68
+ }
69
+
70
+ function testResolveHostVersionFromRuntime(): void {
71
+ const version = resolveOpenClawHostVersion({ runtime: { version: "2026.3.28" } } as never);
72
+ assert(version === "2026.3.28", "expected runtime.version to take precedence");
73
+ }
74
+
75
+ function testResolveHostVersionFromEnvFallback(): void {
76
+ const previous = {
77
+ OPENCLAW_VERSION: process.env.OPENCLAW_VERSION,
78
+ OPENCLAW_SERVICE_VERSION: process.env.OPENCLAW_SERVICE_VERSION,
79
+ npm_package_version: process.env.npm_package_version,
80
+ };
81
+ try {
82
+ delete process.env.OPENCLAW_VERSION;
83
+ process.env.OPENCLAW_SERVICE_VERSION = "2026.3.6";
84
+ delete process.env.npm_package_version;
85
+ const version = resolveOpenClawHostVersion({ runtime: {} } as never);
86
+ assert(version === "2026.3.6", "expected OPENCLAW_SERVICE_VERSION fallback");
87
+ } finally {
88
+ process.env.OPENCLAW_VERSION = previous.OPENCLAW_VERSION;
89
+ process.env.OPENCLAW_SERVICE_VERSION = previous.OPENCLAW_SERVICE_VERSION;
90
+ process.env.npm_package_version = previous.npm_package_version;
91
+ }
92
+ }
93
+
94
+ function testIgnoresNpmPackageVersionFallback(): void {
95
+ const previous = {
96
+ OPENCLAW_VERSION: process.env.OPENCLAW_VERSION,
97
+ OPENCLAW_SERVICE_VERSION: process.env.OPENCLAW_SERVICE_VERSION,
98
+ npm_package_version: process.env.npm_package_version,
99
+ };
100
+ try {
101
+ delete process.env.OPENCLAW_VERSION;
102
+ delete process.env.OPENCLAW_SERVICE_VERSION;
103
+ process.env.npm_package_version = "2026.3.99";
104
+ const version = resolveOpenClawHostVersion({ runtime: {} } as never);
105
+ assert(version === undefined, "expected npm_package_version to be ignored for host detection");
106
+ } finally {
107
+ process.env.OPENCLAW_VERSION = previous.OPENCLAW_VERSION;
108
+ process.env.OPENCLAW_SERVICE_VERSION = previous.OPENCLAW_SERVICE_VERSION;
109
+ process.env.npm_package_version = previous.npm_package_version;
110
+ }
111
+ }
112
+
113
+ function testResolvePromptHookModeModern(): void {
114
+ const mode = resolvePromptHookMode({ runtime: { version: "2026.3.28" } } as never);
115
+ assert(mode === "modern", "expected modern hook mode for OpenClaw 2026.3.28");
116
+ }
117
+
118
+ function testResolvePromptHookModeLegacy(): void {
119
+ const mode = resolvePromptHookMode({ runtime: { version: "2026.3.6" } } as never);
120
+ assert(mode === "legacy", "expected legacy hook mode before 2026.3.7");
121
+ }
122
+
123
+ function testResolvePromptHookModeLegacyForUnknownVersion(): void {
124
+ const mode = resolvePromptHookMode({ runtime: {} } as never);
125
+ assert(mode === "legacy", "expected unknown host versions to fall back to legacy mode");
126
+ }
127
+
128
+ testExtractPromptFromString();
129
+ testExtractPromptFromPromptField();
130
+ testExtractPromptFromLatestUserMessage();
131
+ testExtractPromptFromStructuredContent();
132
+ testBuildRelevantMemoriesSystemContext();
133
+ testBuildLegacyRelevantMemoriesContext();
134
+ testResolveHostVersionFromRuntime();
135
+ testResolveHostVersionFromEnvFallback();
136
+ testIgnoresNpmPackageVersionFallback();
137
+ testResolvePromptHookModeModern();
138
+ testResolvePromptHookModeLegacy();
139
+ testResolvePromptHookModeLegacyForUnknownVersion();
140
+
141
+ console.log("service tests passed");