@clawmem-ai/clawmem 0.1.13 → 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/README.md CHANGED
@@ -162,4 +162,4 @@ Full config with all options:
162
162
  - `memory_update` updates one existing memory issue in place; use it for evolving canonical facts or active tasks instead of creating a duplicate node.
163
163
  - Conversation lifecycle is stored in native issue state (`open` while live, `closed` after finalize); memory lifecycle uses native issue state too (`open` active, `closed` stale).
164
164
  - Memory extraction now prefers one atomic fact per memory item instead of bundling whole sessions into a single node.
165
- - Memory issue bodies store the durable detail plus flat metadata such as `memory_hash` and logical `date`; labels are reserved for schema and routing.
165
+ - Memory issue bodies store the durable detail in a YAML `detail` field plus flat metadata such as `memory_hash` and logical `date`; this matches the current Console parser in `agent-git-service/web`.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "clawmem",
3
3
  "name": "ClawMem",
4
- "version": "0.1.13",
4
+ "version": "0.1.14",
5
5
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
6
6
  "kind": "memory",
7
7
  "skills": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawmem-ai/clawmem",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,8 @@ On every user turn, run this loop:
50
50
  - Use `memory_update` when the same canonical fact or ongoing task should keep evolving as one node.
51
51
  - When updating an existing memory, preserve that node's current language unless the user explicitly asks for a rewrite.
52
52
  - Use `memory_store` when this is a genuinely new memory.
53
+ - When using `memory_store`, pass both `title` and `detail` when you can. Keep the title concise and human-readable, and keep `detail` as the full durable fact.
54
+ - When using `memory_update`, pass `title` as well if the existing title is too short, outdated, or less precise than the current canonical fact.
53
55
  - For new memories, write the memory title and body in the user's current language by default.
54
56
  - Use `memory_forget` when a memory is stale, superseded, or harmful if reused.
55
57
  3. Keep the user posted.
@@ -68,6 +70,8 @@ Bias toward retrieving and saving. A missed search or missed memory is worse tha
68
70
  - Project memory belongs in the relevant project repo.
69
71
  - Shared or team knowledge belongs in the shared repo for that group.
70
72
  - Memory titles and bodies default to the user's current language for new memories.
73
+ - Prefer a short standalone title plus a fuller `detail` body instead of stuffing the whole memory into the title.
74
+ - If you omit `title`, the plugin may derive it from `detail`, but providing an explicit title is preferred for readability in the Console.
71
75
  - When updating an existing memory, keep that node in its current language unless the user explicitly asks to rewrite it.
72
76
  - Keep schema labels and machine-oriented fields stable. Do not translate `type:*`, `kind:*`, `topic:*`, or other structural identifiers.
73
77
  - If the user is asking about collaboration, organizations, teams, invitations, collaborators, shared repo access, or why someone can or cannot access a memory repo, switch from normal memory reasoning to the collaboration workflow in `references/collaboration.md`.
@@ -76,6 +76,7 @@ Do not export `GH_HOST` or `GH_ENTERPRISE_TOKEN` globally for unrelated github.c
76
76
  Use the tool path first. If raw issue control is required:
77
77
 
78
78
  - For new memories, write the issue title and body in the user's current language.
79
+ - Prefer a concise standalone title and keep the full durable fact in the body.
79
80
  - When manually updating an existing memory, preserve that memory's current language unless the user explicitly asks for a rewrite.
80
81
  - Keep labels and schema markers such as `type:*`, `kind:*`, and `topic:*` in their fixed machine-readable form.
81
82
 
@@ -61,6 +61,8 @@ If you create a curated memory manually, include:
61
61
  ## Storage language
62
62
 
63
63
  - For new memory nodes, write the human-readable title and body in the user's current language by default.
64
+ - When using plugin tools, prefer passing an explicit short `title` plus a fuller `detail` body.
65
+ - Do not treat the title as the only durable content. The body detail should still contain the full reusable fact.
64
66
  - When updating an existing memory node, preserve that node's current language unless the user explicitly asks for a rewrite.
65
67
  - Do not translate schema or routing markers such as `type:*`, `kind:*`, `topic:*`, or other machine-oriented field names.
66
68
 
@@ -177,12 +177,36 @@ async function testStructuredStoreAndSchema(): Promise<void> {
177
177
  assert(created[0]?.labels.includes("topic:rate-limit"), "expected created labels to include normalized topic");
178
178
  assert(!created[0]?.labels.some((label) => label.startsWith("session:")), "expected manual memory_store writes to omit synthetic session labels");
179
179
  assert(!created[0]?.labels.some((label) => label.startsWith("date:")), "expected new memory labels to omit date labels");
180
+ assert(created[0]?.body.includes("memory_hash:"), "expected new memory body to retain metadata fields");
181
+ assert(created[0]?.body.includes("detail: Redis Lua scripts are required for atomic rate limiting."), "expected new memory body to store detail in YAML");
180
182
  assert(created[0]?.body.includes(`date: ${result.memory.date}`), "expected new memory body to retain logical date metadata");
181
183
  assert(ensured[0]?.includes("kind:lesson"), "expected ensureLabels to include kind label");
182
184
  assert(schema.kinds.includes("lesson"), "expected schema to expose existing kind labels");
183
185
  assert(schema.topics.includes("redis"), "expected schema to expose existing topic labels");
184
186
  }
185
187
 
188
+ async function testStoreKeepsFullAutoTitleAndSupportsExplicitTitle(): Promise<void> {
189
+ const created: Array<{ title: string; body: string; labels: string[] }> = [];
190
+ const client = {
191
+ listIssues: async () => [] as IssueRecord[],
192
+ listLabels: async () => [] as LabelRecord[],
193
+ ensureLabels: async () => {},
194
+ createIssue: async (payload: { title: string; body: string; labels: string[] }) => {
195
+ created.push(payload);
196
+ return { number: created.length + 100, title: payload.title };
197
+ },
198
+ };
199
+ const store = new MemoryStore(client as never, {} as never, testConfig());
200
+ const longDetail = "Tech Decision #001: Frontend = React Native, Backend = FastAPI, Database = PostgreSQL, and analytics events must stay append-only for auditability.";
201
+ const auto = await store.store({ detail: longDetail });
202
+ const explicit = await store.store({ title: "Architecture Decision #001", detail: "Use React Native + FastAPI for the first mobile stack." });
203
+
204
+ assert(auto.memory.title === `Memory: ${longDetail}`, "expected auto-generated memory title to keep the full detail without truncation");
205
+ assert(explicit.memory.title === "Memory: Architecture Decision #001", "expected explicit memory title to be preserved");
206
+ assert(created[0]?.title === `Memory: ${longDetail}`, "expected created issue title to keep the full auto title");
207
+ assert(created[1]?.title === "Memory: Architecture Decision #001", "expected created issue title to use the explicit title");
208
+ }
209
+
186
210
  async function testGetAndListMemories(): Promise<void> {
187
211
  const issues = [
188
212
  issueFromMemory(memory({
@@ -312,10 +336,52 @@ async function testUpdateMemoryInPlace(): Promise<void> {
312
336
  assert(JSON.stringify(updated?.topics) === JSON.stringify(["preferences", "sports"]), "expected topics to be replaced");
313
337
  assert(updatedIssues.length === 1, "expected a single issue update");
314
338
  assert(updatedIssues[0]?.title !== "Memory: xiangz preferences", "expected title to refresh from updated detail");
339
+ assert(updatedIssues[0]?.body?.includes("memory_hash:"), "expected updated body to retain metadata");
340
+ assert(updatedIssues[0]?.body?.includes("detail:"), "expected updated body to store a detail field in YAML");
341
+ assert(updatedIssues[0]?.body?.includes("recently follows tennis"), "expected updated body to contain the updated detail text");
315
342
  assert(ensured[0]?.includes("topic:sports"), "expected new topic label to be ensured");
316
343
  assert(syncedLabels[0]?.labels.includes("kind:core-fact"), "expected existing kind label to be preserved");
317
344
  }
318
345
 
346
+ async function testUpdateSupportsExplicitRetitle(): Promise<void> {
347
+ const issues: IssueRecord[] = [
348
+ issueFromMemory(memory({
349
+ issueNumber: 20,
350
+ title: "Memory: old short title",
351
+ detail: "We use append-only audit events for billing changes.",
352
+ kind: "convention",
353
+ })),
354
+ ];
355
+ const updatedIssues: Array<{ number: number; title?: string; body?: string }> = [];
356
+ const client = {
357
+ listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
358
+ const labels = params?.labels ?? [];
359
+ const state = params?.state ?? "open";
360
+ return issues.filter((issue) => {
361
+ const issueLabels = issue.labels ?? [];
362
+ if (!labels.every((label) => issueLabels.includes(label))) return false;
363
+ if (state === "all") return true;
364
+ return (issue.state ?? "open") === state;
365
+ });
366
+ },
367
+ ensureLabels: async () => {},
368
+ updateIssue: async (number: number, patch: { title?: string; body?: string }) => {
369
+ updatedIssues.push({ number, ...patch });
370
+ const issue = issues.find((entry) => entry.number === number);
371
+ if (!issue) throw new Error("issue missing");
372
+ if (patch.title) issue.title = patch.title;
373
+ if (patch.body) issue.body = patch.body;
374
+ return issue;
375
+ },
376
+ syncManagedLabels: async () => {},
377
+ };
378
+ const store = new MemoryStore(client as never, {} as never, testConfig());
379
+ const updated = await store.update("20", { title: "Billing Audit Convention" });
380
+
381
+ assert(updated?.title === "Memory: Billing Audit Convention", "expected memory_update to support explicit retitle");
382
+ assert(updatedIssues[0]?.title === "Memory: Billing Audit Convention", "expected issue title patch to use the explicit retitle");
383
+ }
384
+
319
385
  async function testForgetClosesMemoryIssue(): Promise<void> {
320
386
  const issues: IssueRecord[] = [
321
387
  issueFromMemory(memory({
@@ -367,9 +433,11 @@ async function main(): Promise<void> {
367
433
  await testBackendSearchFallsBackToLocalLexical();
368
434
  testCjkScoring();
369
435
  await testStructuredStoreAndSchema();
436
+ await testStoreKeepsFullAutoTitleAndSupportsExplicitTitle();
370
437
  await testGetAndListMemories();
371
438
  await testLegacyMemoriesWithoutSessionOrDate();
372
439
  await testUpdateMemoryInPlace();
440
+ await testUpdateSupportsExplicitRetitle();
373
441
  await testForgetClosesMemoryIssue();
374
442
  console.log("memory tests passed");
375
443
  }
package/src/memory.ts CHANGED
@@ -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,16 +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
213
  issueNumber: issue.number,
210
214
  title: issue.title?.trim() || "",
211
- memoryId: body.memory_id?.trim() || String(issue.number),
212
- memoryHash: body.memory_hash?.trim() || undefined,
213
- date: body.date?.trim() || "1970-01-01",
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",
214
218
  detail,
215
219
  ...(kind ? { kind } : {}),
216
220
  ...(topics.length > 0 ? { topics } : {}),
@@ -236,8 +240,8 @@ export class MemoryStore {
236
240
  }
237
241
  const labels = memLabels(draft.kind, draft.topics);
238
242
  const date = localDate();
239
- const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
240
- const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
243
+ const title = renderMemoryTitle(draft);
244
+ const body = renderMemoryBody(detail, hash, date);
241
245
  await this.client.ensureLabels(labels);
242
246
  const issue = await this.client.createIssue({ title, body, labels });
243
247
  activeByHash.set(hash, {
@@ -280,9 +284,10 @@ export class MemoryStore {
280
284
  const sessionKey = subKey(session, "memory");
281
285
  const message = [
282
286
  "Extract durable memories from the conversation below.",
283
- '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"]}.',
284
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.",
285
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.",
286
291
  "Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
287
292
  "Infer kind and topics when they would help future retrieval. Reuse existing kinds and topics when possible.",
288
293
  "If no existing kind fits, you may propose a new short kind label. Keep kinds concise and reusable.",
@@ -300,7 +305,7 @@ export class MemoryStore {
300
305
  deliver: false,
301
306
  lane: "clawmem-memory",
302
307
  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 kind, and optional topics, plus stale string ids. Keep each save item to one durable fact.",
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.",
304
309
  });
305
310
  const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
306
311
  if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
@@ -341,6 +346,35 @@ function memLabels(kind?: string, topics?: string[]): string[] {
341
346
  ];
342
347
  }
343
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
+
344
378
  function norm(v: string): string { return v.replace(/\s+/g, " ").trim(); }
345
379
  function trunc(v: string, max: number): string { const s = norm(v); return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`; }
346
380
  function normalizeSearch(v: string): string {
@@ -429,9 +463,11 @@ export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): n
429
463
  function normalizeDraft(input: MemoryDraft): MemoryDraft {
430
464
  const detail = norm(input.detail);
431
465
  if (!detail) throw new Error("memory detail is empty");
466
+ const title = typeof input.title === "string" && input.title.trim() ? norm(input.title) : undefined;
432
467
  const kind = normalizeLabelValue(input.kind, "kind:");
433
468
  const topics = uniqueNormalized((input.topics ?? []).map((topic) => normalizeLabelValue(topic, "topic:")).filter(Boolean) as string[]);
434
469
  return {
470
+ ...(title ? { title } : {}),
435
471
  detail,
436
472
  ...(kind ? { kind } : {}),
437
473
  ...(topics.length > 0 ? { topics } : {}),
@@ -490,12 +526,13 @@ function parseSaveItem(value: unknown): MemoryDraft | null {
490
526
  }
491
527
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
492
528
  const record = value as Record<string, unknown>;
529
+ const title = typeof record.title === "string" ? record.title : undefined;
493
530
  const detail = typeof record.detail === "string" ? norm(record.detail) : "";
494
531
  if (!detail) return null;
495
532
  const kind = typeof record.kind === "string" ? record.kind : undefined;
496
533
  const topics = Array.isArray(record.topics) ? record.topics.filter((v): v is string => typeof v === "string") : undefined;
497
534
  try {
498
- return normalizeDraft({ detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
535
+ return normalizeDraft({ ...(title ? { title } : {}), detail, ...(kind ? { kind } : {}), ...(topics ? { topics } : {}) });
499
536
  } catch {
500
537
  return null;
501
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");
package/src/service.ts CHANGED
@@ -17,6 +17,8 @@ type CollaborationPermission = "read" | "write" | "admin";
17
17
  type CollaborationTeamRole = "member" | "maintainer";
18
18
 
19
19
  const SESSION_MAINTENANCE_RETRY_DELAYS_MS = [5000, 30000, 120000] as const;
20
+ const MODERN_PROMPT_HOOK_MIN_HOST_VERSION = "2026.3.7";
21
+ type PromptHookMode = "modern" | "legacy";
20
22
 
21
23
  class ClawMemService {
22
24
  private readonly config: ClawMemPluginConfig;
@@ -36,7 +38,12 @@ class ClawMemService {
36
38
  }
37
39
 
38
40
  register(): void {
39
- this.api.on("before_agent_start", async (ev, ctx) => this.handleBeforeAgentStart(ev.prompt, ctx.agentId));
41
+ const promptHookMode = resolvePromptHookMode(this.api);
42
+ if (promptHookMode === "modern") {
43
+ this.api.on("before_prompt_build", async (ev, ctx) => this.handleBeforePromptBuild(ev, ctx.agentId));
44
+ } else {
45
+ this.api.on("before_agent_start", async (ev, ctx) => this.handleBeforeAgentStart(ev, ctx.agentId));
46
+ }
40
47
  this.api.on("agent_end", (ev, ctx) => this.scheduleTurn({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, agentId: ctx.agentId, messages: ev.messages }));
41
48
  this.api.on("before_reset", (ev, ctx) => this.enqueueFinalize({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, sessionFile: ev.sessionFile, agentId: ctx.agentId, reason: ev.reason, messages: ev.messages }));
42
49
  this.api.on("session_end", (ev, ctx) => this.enqueueFinalize({ sessionId: ev.sessionId ?? ctx.sessionId, sessionKey: ev.sessionKey ?? ctx.sessionKey, agentId: ctx.agentId, reason: "session_end" }));
@@ -55,10 +62,11 @@ class ClawMemService {
55
62
  const route = resolveAgentRoute(this.config, agentId);
56
63
  return isAgentConfigured(route) && hasDefaultRepo(route);
57
64
  }).length;
65
+ const hostVersion = resolveOpenClawHostVersion(this.api);
58
66
  this.api.logger.info?.(
59
67
  configuredCount > 0
60
- ? `clawmem: ready with ${configuredCount} configured agent route(s); missing routes will provision on first use via ${this.config.baseUrl}`
61
- : `clawmem: ready; agent routes will provision on first use via ${this.config.baseUrl}`,
68
+ ? `clawmem: ready with ${configuredCount} configured agent route(s); prompt hook mode=${promptHookMode}${hostVersion ? ` for OpenClaw ${hostVersion}` : ""}; missing routes will provision on first use via ${this.config.baseUrl}`
69
+ : `clawmem: ready; prompt hook mode=${promptHookMode}${hostVersion ? ` for OpenClaw ${hostVersion}` : ""}; agent routes will provision on first use via ${this.config.baseUrl}`,
62
70
  );
63
71
  },
64
72
  stop: async () => {
@@ -298,6 +306,7 @@ class ClawMemService {
298
306
  type: "object",
299
307
  additionalProperties: false,
300
308
  properties: {
309
+ title: { type: "string", minLength: 1, description: "Optional human-readable memory title. Defaults to the full detail text when omitted." },
301
310
  detail: { type: "string", minLength: 1, description: "The durable fact, lesson, decision, or preference to remember." },
302
311
  kind: { type: "string", minLength: 1, description: "Optional schema kind, for example lesson, convention, skill, or task." },
303
312
  topics: {
@@ -314,6 +323,7 @@ class ClawMemService {
314
323
  },
315
324
  execute: async (_id: string, params: unknown) => {
316
325
  const p = asRecord(params);
326
+ const title = typeof p.title === "string" ? p.title.trim() : "";
317
327
  const detail = typeof p.detail === "string" ? p.detail.trim() : "";
318
328
  if (!detail) return toolText("Detail is empty.");
319
329
  const agentId = this.resolveToolAgentId(p.agentId);
@@ -322,6 +332,7 @@ class ClawMemService {
322
332
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
323
333
  const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
324
334
  const result = await resolved.mem.store({
335
+ ...(title ? { title } : {}),
325
336
  detail,
326
337
  ...(kind ? { kind } : {}),
327
338
  ...(topics && topics.length > 0 ? { topics } : {}),
@@ -340,6 +351,7 @@ class ClawMemService {
340
351
  additionalProperties: false,
341
352
  properties: {
342
353
  memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to update." },
354
+ title: { type: "string", minLength: 1, description: "Optional replacement title for the same memory record." },
343
355
  detail: { type: "string", minLength: 1, description: "Optional replacement detail text for the same memory record." },
344
356
  kind: { type: "string", minLength: 1, description: "Optional replacement kind label." },
345
357
  topics: {
@@ -358,16 +370,17 @@ class ClawMemService {
358
370
  const p = asRecord(params);
359
371
  const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
360
372
  if (!memoryId) return toolText("memoryId is empty.");
373
+ const title = typeof p.title === "string" && p.title.trim() ? p.title.trim() : undefined;
361
374
  const detail = typeof p.detail === "string" && p.detail.trim() ? p.detail.trim() : undefined;
362
375
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
363
376
  const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
364
- if (!detail && kind === undefined && topics === undefined) return toolText("Provide at least one of detail, kind, or topics.");
377
+ if (title === undefined && !detail && kind === undefined && topics === undefined) return toolText("Provide at least one of title, detail, kind, or topics.");
365
378
  const agentId = this.resolveToolAgentId(p.agentId);
366
379
  const resolved = await this.requireToolRoute(agentId, p.repo);
367
380
  if ("error" in resolved) return toolText(resolved.error);
368
381
  let updated;
369
382
  try {
370
- updated = await resolved.mem.update(memoryId, { ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
383
+ updated = await resolved.mem.update(memoryId, { ...(title ? { title } : {}), ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
371
384
  } catch (error) {
372
385
  return toolText(`Unable to update memory "${memoryId}": ${String(error)}`);
373
386
  }
@@ -1293,17 +1306,31 @@ class ClawMemService {
1293
1306
  });
1294
1307
  }
1295
1308
 
1296
- private async handleBeforeAgentStart(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
1309
+ private async handleBeforePromptBuild(event: unknown, agentId?: string): Promise<{ prependSystemContext: string } | void> {
1310
+ const routeAgentId = normalizeAgentId(agentId);
1311
+ if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
1312
+ this.scheduleRecentSessionMaintenance(routeAgentId);
1313
+ const prompt = extractPromptTextForRecall(event);
1314
+ if (typeof prompt !== "string" || prompt.trim().length < 5) return;
1315
+ try {
1316
+ const { mem } = this.getServices(routeAgentId);
1317
+ const memories = await mem.search(prompt, this.config.memoryAutoRecallLimit);
1318
+ if (memories.length === 0) return;
1319
+ return { prependSystemContext: buildRelevantMemoriesSystemContext(memories) };
1320
+ } catch (error) { this.api.logger.warn(`clawmem: memory recall failed: ${String(error)}`); }
1321
+ }
1322
+
1323
+ private async handleBeforeAgentStart(event: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
1297
1324
  const routeAgentId = normalizeAgentId(agentId);
1298
1325
  if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
1299
1326
  this.scheduleRecentSessionMaintenance(routeAgentId);
1327
+ const prompt = extractPromptTextForRecall(event);
1300
1328
  if (typeof prompt !== "string" || prompt.trim().length < 5) return;
1301
1329
  try {
1302
1330
  const { mem } = this.getServices(routeAgentId);
1303
1331
  const memories = await mem.search(prompt, this.config.memoryAutoRecallLimit);
1304
1332
  if (memories.length === 0) return;
1305
- const text = memories.map((m) => `- ${formatInjectedMemory(m)}`).join("\n");
1306
- return { prependContext: `<relevant-memories>\nThe following active memories may be relevant to this conversation:\n${text}\n</relevant-memories>` };
1333
+ return { prependContext: buildLegacyRelevantMemoriesContext(memories) };
1307
1334
  } catch (error) { this.api.logger.warn(`clawmem: memory recall failed: ${String(error)}`); }
1308
1335
  }
1309
1336
 
@@ -1812,12 +1839,170 @@ function renderMemoryBlock(memory: {
1812
1839
  return lines.join("\n");
1813
1840
  }
1814
1841
 
1842
+ export function buildRelevantMemoriesSystemContext(memories: Array<{ detail: string }>): string {
1843
+ return [
1844
+ "ClawMem relevant memories:",
1845
+ "Use these as background context only when they help with the current request.",
1846
+ ...memories.map((memory) => `- ${formatInjectedMemory(memory)}`),
1847
+ ].join("\n");
1848
+ }
1849
+
1850
+ export function buildLegacyRelevantMemoriesContext(memories: Array<{ detail: string }>): string {
1851
+ return [
1852
+ "Relevant ClawMem memories for this request:",
1853
+ ...memories.map((memory) => `- ${formatInjectedMemory(memory)}`),
1854
+ ].join("\n");
1855
+ }
1856
+
1815
1857
  function formatInjectedMemory(memory: {
1816
1858
  detail: string;
1817
1859
  }): string {
1818
1860
  return memory.detail;
1819
1861
  }
1820
1862
 
1863
+ export function extractPromptTextForRecall(event: unknown): string | undefined {
1864
+ const direct = normalizePromptText(event);
1865
+ if (direct) return direct;
1866
+
1867
+ const record = asRecord(event);
1868
+ for (const candidate of [record.prompt, record.userPrompt, record.input, record.query, record.text]) {
1869
+ const text = normalizePromptText(candidate);
1870
+ if (text) return text;
1871
+ }
1872
+
1873
+ return extractPromptTextFromMessages(record.messages) ?? extractPromptTextFromMessages(record.conversation);
1874
+ }
1875
+
1876
+ function extractPromptTextFromMessages(value: unknown): string | undefined {
1877
+ if (!Array.isArray(value)) return undefined;
1878
+ let fallback: string | undefined;
1879
+ for (let index = value.length - 1; index >= 0; index--) {
1880
+ const message = value[index];
1881
+ const record = asRecord(message);
1882
+ const role = typeof record.role === "string" ? record.role.trim().toLowerCase() : "";
1883
+ const text = normalizePromptText(record.text)
1884
+ ?? normalizePromptText(record.prompt)
1885
+ ?? normalizePromptText(record.content)
1886
+ ?? normalizePromptText(record.message);
1887
+ if (!text) continue;
1888
+ if (!fallback) fallback = text;
1889
+ if (!role || role === "user") return text;
1890
+ }
1891
+ return fallback;
1892
+ }
1893
+
1894
+ function normalizePromptText(value: unknown): string | undefined {
1895
+ if (typeof value === "string") {
1896
+ const trimmed = value.trim();
1897
+ return trimmed || undefined;
1898
+ }
1899
+ if (Array.isArray(value)) {
1900
+ const parts = value
1901
+ .map((entry) => {
1902
+ if (typeof entry === "string") return entry.trim();
1903
+ const record = asRecord(entry);
1904
+ if (record.type === "text" && typeof record.text === "string") return record.text.trim();
1905
+ if (typeof record.text === "string") return record.text.trim();
1906
+ return "";
1907
+ })
1908
+ .filter(Boolean);
1909
+ return parts.length > 0 ? parts.join("\n") : undefined;
1910
+ }
1911
+ return undefined;
1912
+ }
1913
+
1914
+ export function resolvePromptHookMode(api: Pick<OpenClawPluginApi, "runtime">): PromptHookMode {
1915
+ const hostVersion = resolveOpenClawHostVersion(api);
1916
+ if (!hostVersion) return "legacy";
1917
+ const comparison = compareOpenClawVersions(hostVersion, MODERN_PROMPT_HOOK_MIN_HOST_VERSION);
1918
+ if (comparison === null) return "legacy";
1919
+ return comparison >= 0 ? "modern" : "legacy";
1920
+ }
1921
+
1922
+ export function resolveOpenClawHostVersion(api: Pick<OpenClawPluginApi, "runtime">): string | undefined {
1923
+ const runtimeVersion = typeof api.runtime?.version === "string" ? api.runtime.version.trim() : "";
1924
+ if (isUsableOpenClawVersion(runtimeVersion)) return runtimeVersion;
1925
+ for (const candidate of [
1926
+ process.env.OPENCLAW_VERSION,
1927
+ process.env.OPENCLAW_SERVICE_VERSION,
1928
+ ]) {
1929
+ const trimmed = candidate?.trim();
1930
+ if (isUsableOpenClawVersion(trimmed)) return trimmed;
1931
+ }
1932
+ return undefined;
1933
+ }
1934
+
1935
+ function isUsableOpenClawVersion(version: string | undefined): version is string {
1936
+ return Boolean(version && version !== "0.0.0" && version !== "unknown");
1937
+ }
1938
+
1939
+ function compareOpenClawVersions(left: string, right: string): number | null {
1940
+ const leftSemver = parseComparableSemver(left);
1941
+ const rightSemver = parseComparableSemver(right);
1942
+ if (!leftSemver || !rightSemver) return null;
1943
+ if (leftSemver.major !== rightSemver.major) return leftSemver.major < rightSemver.major ? -1 : 1;
1944
+ if (leftSemver.minor !== rightSemver.minor) return leftSemver.minor < rightSemver.minor ? -1 : 1;
1945
+ if (leftSemver.patch !== rightSemver.patch) return leftSemver.patch < rightSemver.patch ? -1 : 1;
1946
+ return comparePrereleaseIdentifiers(leftSemver.prerelease, rightSemver.prerelease);
1947
+ }
1948
+
1949
+ type ComparableSemver = {
1950
+ major: number;
1951
+ minor: number;
1952
+ patch: number;
1953
+ prerelease: string[] | null;
1954
+ };
1955
+
1956
+ function parseComparableSemver(version: string | undefined): ComparableSemver | null {
1957
+ if (!version) return null;
1958
+ const normalized = normalizeLegacyDotBetaVersion(version);
1959
+ const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
1960
+ if (!match) return null;
1961
+ const [, major, minor, patch, prereleaseRaw] = match;
1962
+ if (!major || !minor || !patch) return null;
1963
+ return {
1964
+ major: Number.parseInt(major, 10),
1965
+ minor: Number.parseInt(minor, 10),
1966
+ patch: Number.parseInt(patch, 10),
1967
+ prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null,
1968
+ };
1969
+ }
1970
+
1971
+ function normalizeLegacyDotBetaVersion(version: string): string {
1972
+ const trimmed = version.trim();
1973
+ const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed);
1974
+ if (!dotBetaMatch) return trimmed;
1975
+ const base = dotBetaMatch[1];
1976
+ const suffix = dotBetaMatch[2];
1977
+ return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
1978
+ }
1979
+
1980
+ function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | null): number {
1981
+ if (!a?.length && !b?.length) return 0;
1982
+ if (!a?.length) return 1;
1983
+ if (!b?.length) return -1;
1984
+ const max = Math.max(a.length, b.length);
1985
+ for (let index = 0; index < max; index += 1) {
1986
+ const left = a[index];
1987
+ const right = b[index];
1988
+ if (left == null && right == null) return 0;
1989
+ if (left == null) return -1;
1990
+ if (right == null) return 1;
1991
+ if (left === right) continue;
1992
+ const leftNumeric = /^[0-9]+$/.test(left);
1993
+ const rightNumeric = /^[0-9]+$/.test(right);
1994
+ if (leftNumeric && rightNumeric) {
1995
+ const leftNumber = Number.parseInt(left, 10);
1996
+ const rightNumber = Number.parseInt(right, 10);
1997
+ return leftNumber < rightNumber ? -1 : 1;
1998
+ }
1999
+ if (leftNumeric && !rightNumeric) return -1;
2000
+ if (!leftNumeric && rightNumeric) return 1;
2001
+ return left < right ? -1 : 1;
2002
+ }
2003
+ return 0;
2004
+ }
2005
+
1821
2006
  function renderOrgLine(org: { login?: string; name?: string; default_repository_permission?: string; description?: string }): string {
1822
2007
  const login = org.login?.trim() || "unknown-org";
1823
2008
  const name = org.name?.trim() ? ` (${org.name.trim()})` : "";
package/src/types.ts CHANGED
@@ -44,7 +44,7 @@ export type SessionMirrorState = {
44
44
  export type PluginState = { version: 2; sessions: Record<string, SessionMirrorState>; migrations?: Record<string, string> };
45
45
  export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
46
46
  export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
47
- export type MemoryDraft = { detail: string; kind?: string; topics?: string[] };
47
+ export type MemoryDraft = { title?: string; detail: string; kind?: string; topics?: string[] };
48
48
  export type MemorySchema = { kinds: string[]; topics: string[] };
49
49
  export type MemoryListOptions = {
50
50
  status?: "active" | "stale" | "all";