@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/service.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Thin orchestrator: wires conversation mirroring, memory store, and plugin lifecycle.
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
3
  import { hasDefaultRepo, isAgentConfigured, resolveAgentRoute, resolvePluginConfig } from "./config.js";
4
+ import { filterDirectCollaborators, listRepoAccessTeams, resolveOrgInvitationRole } from "./collaboration.js";
4
5
  import { ConversationMirror } from "./conversation.js";
5
6
  import { GitHubIssueClient } from "./github-client.js";
6
7
  import { KeyedAsyncQueue } from "./keyed-async-queue.js";
@@ -13,15 +14,19 @@ import { buildAgentBootstrapRegistration, inferAgentIdFromTranscriptPath, normal
13
14
  type TurnPayload = { sessionId?: string; sessionKey?: string; agentId?: string; messages: unknown[] };
14
15
  type FinalizePayload = { sessionId?: string; sessionKey?: string; sessionFile?: string; agentId?: string; reason?: string; messages?: unknown[] };
15
16
  type CollaborationPermission = "read" | "write" | "admin";
16
- type CollaborationOrgRole = "member" | "admin";
17
17
  type CollaborationTeamRole = "member" | "maintainer";
18
18
 
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";
22
+
19
23
  class ClawMemService {
20
24
  private readonly config: ClawMemPluginConfig;
21
25
  private readonly queue = new KeyedAsyncQueue();
22
26
  private readonly stateQueue = new KeyedAsyncQueue();
23
27
  private readonly pending = new Set<Promise<unknown>>();
24
28
  private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
29
+ private readonly maintenanceTimers = new Map<string, ReturnType<typeof setTimeout>>();
25
30
  private statePath = "";
26
31
  private state: PluginState = { version: 2, sessions: {} };
27
32
  private unsubTranscript?: () => void;
@@ -33,7 +38,12 @@ class ClawMemService {
33
38
  }
34
39
 
35
40
  register(): void {
36
- 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
+ }
37
47
  this.api.on("agent_end", (ev, ctx) => this.scheduleTurn({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, agentId: ctx.agentId, messages: ev.messages }));
38
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 }));
39
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" }));
@@ -52,16 +62,19 @@ class ClawMemService {
52
62
  const route = resolveAgentRoute(this.config, agentId);
53
63
  return isAgentConfigured(route) && hasDefaultRepo(route);
54
64
  }).length;
65
+ const hostVersion = resolveOpenClawHostVersion(this.api);
55
66
  this.api.logger.info?.(
56
67
  configuredCount > 0
57
- ? `clawmem: ready with ${configuredCount} configured agent route(s); missing routes will provision on first use via ${this.config.baseUrl}`
58
- : `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}`,
59
70
  );
60
71
  },
61
72
  stop: async () => {
62
73
  this.unsubTranscript?.();
63
74
  for (const t of this.syncTimers.values()) clearTimeout(t);
64
75
  this.syncTimers.clear();
76
+ for (const t of this.maintenanceTimers.values()) clearTimeout(t);
77
+ this.maintenanceTimers.clear();
65
78
  await Promise.allSettled([...this.pending]);
66
79
  },
67
80
  });
@@ -224,7 +237,7 @@ class ClawMemService {
224
237
 
225
238
  this.api.registerTool({
226
239
  name: "memory_recall",
227
- description: "Search ClawMem active memories for relevant prior facts, decisions, conventions, and lessons.",
240
+ description: "Search ClawMem active memories for relevant prior facts, decisions, conventions, and lessons. Use this before answering questions about prior conversations, earlier assistant responses, user preferences, or historical project context.",
228
241
  required: true,
229
242
  parameters: {
230
243
  type: "object",
@@ -287,12 +300,13 @@ class ClawMemService {
287
300
 
288
301
  this.api.registerTool({
289
302
  name: "memory_store",
290
- description: "Store a durable ClawMem memory immediately instead of waiting for session finalization.",
303
+ description: "Store one atomic durable ClawMem memory immediately instead of waiting for session finalization. Keep each write to a single fact, preference, decision, or timeline update.",
291
304
  required: true,
292
305
  parameters: {
293
306
  type: "object",
294
307
  additionalProperties: false,
295
308
  properties: {
309
+ title: { type: "string", minLength: 1, description: "Optional human-readable memory title. Defaults to the full detail text when omitted." },
296
310
  detail: { type: "string", minLength: 1, description: "The durable fact, lesson, decision, or preference to remember." },
297
311
  kind: { type: "string", minLength: 1, description: "Optional schema kind, for example lesson, convention, skill, or task." },
298
312
  topics: {
@@ -309,6 +323,7 @@ class ClawMemService {
309
323
  },
310
324
  execute: async (_id: string, params: unknown) => {
311
325
  const p = asRecord(params);
326
+ const title = typeof p.title === "string" ? p.title.trim() : "";
312
327
  const detail = typeof p.detail === "string" ? p.detail.trim() : "";
313
328
  if (!detail) return toolText("Detail is empty.");
314
329
  const agentId = this.resolveToolAgentId(p.agentId);
@@ -316,7 +331,12 @@ class ClawMemService {
316
331
  if ("error" in resolved) return toolText(resolved.error);
317
332
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
318
333
  const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
319
- const result = await resolved.mem.store({ detail, ...(kind ? { kind } : {}), ...(topics && topics.length > 0 ? { topics } : {}) });
334
+ const result = await resolved.mem.store({
335
+ ...(title ? { title } : {}),
336
+ detail,
337
+ ...(kind ? { kind } : {}),
338
+ ...(topics && topics.length > 0 ? { topics } : {}),
339
+ });
320
340
  if (!result.created) return toolText(`Memory already exists in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
321
341
  return toolText(`Stored memory in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
322
342
  },
@@ -331,6 +351,7 @@ class ClawMemService {
331
351
  additionalProperties: false,
332
352
  properties: {
333
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." },
334
355
  detail: { type: "string", minLength: 1, description: "Optional replacement detail text for the same memory record." },
335
356
  kind: { type: "string", minLength: 1, description: "Optional replacement kind label." },
336
357
  topics: {
@@ -349,16 +370,17 @@ class ClawMemService {
349
370
  const p = asRecord(params);
350
371
  const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
351
372
  if (!memoryId) return toolText("memoryId is empty.");
373
+ const title = typeof p.title === "string" && p.title.trim() ? p.title.trim() : undefined;
352
374
  const detail = typeof p.detail === "string" && p.detail.trim() ? p.detail.trim() : undefined;
353
375
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
354
376
  const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
355
- 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.");
356
378
  const agentId = this.resolveToolAgentId(p.agentId);
357
379
  const resolved = await this.requireToolRoute(agentId, p.repo);
358
380
  if ("error" in resolved) return toolText(resolved.error);
359
381
  let updated;
360
382
  try {
361
- 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 } : {}) });
362
384
  } catch (error) {
363
385
  return toolText(`Unable to update memory "${memoryId}": ${String(error)}`);
364
386
  }
@@ -1006,7 +1028,7 @@ class ClawMemService {
1006
1028
  properties: {
1007
1029
  org: { type: "string", minLength: 1, description: "Organization login." },
1008
1030
  inviteeLogin: { type: "string", minLength: 1, description: "Username to invite." },
1009
- role: { type: "string", enum: ["member", "admin"], description: "Org role for the invitation. Defaults to member." },
1031
+ role: { type: "string", enum: ["member", "owner"], description: "Org role for the invitation. Defaults to member." },
1010
1032
  teamIds: {
1011
1033
  type: "array",
1012
1034
  description: "Optional numeric team ids to pre-assign on acceptance.",
@@ -1027,7 +1049,8 @@ class ClawMemService {
1027
1049
  const org = typeof p.org === "string" ? p.org.trim() : "";
1028
1050
  const inviteeLogin = typeof p.inviteeLogin === "string" ? p.inviteeLogin.trim() : "";
1029
1051
  if (!org || !inviteeLogin) return toolText("org and inviteeLogin are required.");
1030
- const role: CollaborationOrgRole = p.role === "admin" ? "admin" : "member";
1052
+ const role = resolveOrgInvitationRole(p.role, "member");
1053
+ if ("error" in role) return toolText(role.error);
1031
1054
  const teamIds = Array.isArray(p.teamIds)
1032
1055
  ? p.teamIds.filter((value): value is number => typeof value === "number" && Number.isInteger(value) && value > 0)
1033
1056
  : undefined;
@@ -1039,7 +1062,7 @@ class ClawMemService {
1039
1062
  try {
1040
1063
  const invitation = await resolved.client.createOrgInvitation(org, {
1041
1064
  inviteeLogin,
1042
- role,
1065
+ role: role.role,
1043
1066
  ...(teamIds && teamIds.length > 0 ? { teamIds } : {}),
1044
1067
  ...(expiresInDays ? { expiresInDays } : {}),
1045
1068
  });
@@ -1228,9 +1251,9 @@ class ClawMemService {
1228
1251
  }
1229
1252
 
1230
1253
  try {
1231
- const collaborators = await target.client.listRepoCollaborators(target.owner, target.repo);
1254
+ const collaborators = filterDirectCollaborators(await target.client.listRepoCollaborators(target.owner, target.repo), target.owner);
1232
1255
  lines.push("");
1233
- lines.push("Direct collaborators:");
1256
+ lines.push("Explicit collaborators (excluding owner):");
1234
1257
  if (collaborators.length === 0) lines.push("- None visible");
1235
1258
  else lines.push(...collaborators.map((collaborator) => `- ${renderCollaboratorLine(collaborator)}`));
1236
1259
  } catch (error) {
@@ -1247,14 +1270,17 @@ class ClawMemService {
1247
1270
  notes.push(`Repo invitation lookup failed: ${String(error)}`);
1248
1271
  }
1249
1272
 
1250
- try {
1251
- const teams = await target.client.listRepoTeams(target.owner, target.repo);
1252
- lines.push("");
1253
- lines.push("Teams with repo access:");
1254
- if (teams.length === 0) lines.push("- None visible");
1255
- else lines.push(...teams.map((team) => `- ${renderTeamLine(team)}`));
1256
- } catch (error) {
1257
- notes.push(`Repo team grant lookup failed: ${String(error)}`);
1273
+ if (orgName) {
1274
+ try {
1275
+ const teamAccess = await listRepoAccessTeams(target.client, orgName, target.fullName);
1276
+ lines.push("");
1277
+ lines.push("Teams with repo access:");
1278
+ if (teamAccess.teams.length === 0) lines.push("- None visible");
1279
+ else lines.push(...teamAccess.teams.map((team) => `- ${renderTeamLine(team)}`));
1280
+ notes.push(...teamAccess.notes);
1281
+ } catch (error) {
1282
+ notes.push(`Repo team grant lookup failed: ${String(error)}`);
1283
+ }
1258
1284
  }
1259
1285
 
1260
1286
  try {
@@ -1280,17 +1306,31 @@ class ClawMemService {
1280
1306
  });
1281
1307
  }
1282
1308
 
1283
- 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> {
1284
1324
  const routeAgentId = normalizeAgentId(agentId);
1285
1325
  if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
1286
- await this.runRequestMaintenance(routeAgentId);
1326
+ this.scheduleRecentSessionMaintenance(routeAgentId);
1327
+ const prompt = extractPromptTextForRecall(event);
1287
1328
  if (typeof prompt !== "string" || prompt.trim().length < 5) return;
1288
1329
  try {
1289
1330
  const { mem } = this.getServices(routeAgentId);
1290
- const memories = await mem.search(prompt, this.config.memoryRecallLimit);
1331
+ const memories = await mem.search(prompt, this.config.memoryAutoRecallLimit);
1291
1332
  if (memories.length === 0) return;
1292
- const text = memories.map((m) => `- ${m.detail}`).join("\n");
1293
- return { prependContext: `<relevant-memories>\nThe following active memories may be relevant to this conversation:\n${text}\n</relevant-memories>` };
1333
+ return { prependContext: buildLegacyRelevantMemoriesContext(memories) };
1294
1334
  } catch (error) { this.api.logger.warn(`clawmem: memory recall failed: ${String(error)}`); }
1295
1335
  }
1296
1336
 
@@ -1357,6 +1397,7 @@ class ClawMemService {
1357
1397
  private async finalize(p: FinalizePayload): Promise<void> {
1358
1398
  if (!p.sessionId) return;
1359
1399
  const agentId = normalizeAgentId(p.agentId);
1400
+ const scopeKey = sessionScopeKey(p.sessionId, agentId);
1360
1401
  if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
1361
1402
  const { conv } = this.getServices(agentId);
1362
1403
  const s = this.getOrCreate(p.sessionId, agentId);
@@ -1375,6 +1416,7 @@ class ClawMemService {
1375
1416
  s.summaryStatus = "pending";
1376
1417
  if (allOk) s.finalizedAt = new Date().toISOString();
1377
1418
  await this.persistState();
1419
+ this.scheduleSessionMaintenance(scopeKey, agentId, { reason: p.reason ?? "finalize" });
1378
1420
  }
1379
1421
 
1380
1422
  // --- Infrastructure ---
@@ -1531,55 +1573,109 @@ class ClawMemService {
1531
1573
  },
1532
1574
  });
1533
1575
  }
1534
- private async runRequestMaintenance(agentId: string): Promise<void> {
1576
+ private scheduleRecentSessionMaintenance(agentId: string): void {
1535
1577
  const sessions = Object.values(this.state.sessions)
1536
1578
  .filter((session) => normalizeAgentId(session.agentId) === agentId)
1537
1579
  .sort((a, b) => Date.parse(b.updatedAt ?? b.createdAt ?? "") - Date.parse(a.updatedAt ?? a.createdAt ?? ""))
1538
1580
  .slice(0, 8);
1539
- if (sessions.length === 0) return;
1581
+ for (const session of sessions) {
1582
+ if (!this.sessionNeedsMaintenance(session)) continue;
1583
+ this.scheduleSessionMaintenance(sessionScopeKey(session.sessionId, session.agentId), agentId, {
1584
+ reason: "request-start-fallback",
1585
+ delayMs: 0,
1586
+ });
1587
+ break;
1588
+ }
1589
+ }
1590
+
1591
+ private scheduleSessionMaintenance(
1592
+ scopeKey: string,
1593
+ agentId: string,
1594
+ options: { delayMs?: number; attempt?: number; reason?: string } = {},
1595
+ ): void {
1596
+ const prev = this.maintenanceTimers.get(scopeKey);
1597
+ if (prev) clearTimeout(prev);
1598
+ const delayMs = Math.max(0, options.delayMs ?? 0);
1599
+ const attempt = Math.max(0, options.attempt ?? 0);
1600
+ const reason = options.reason ?? "scheduled";
1601
+ const timer = setTimeout(() => {
1602
+ this.maintenanceTimers.delete(scopeKey);
1603
+ void this.track(this.enqueueSession(scopeKey, () => this.runSessionMaintenance(scopeKey, agentId, attempt, reason)))
1604
+ .catch((error) => this.warn(`background maintenance for ${scopeKey}`, error));
1605
+ }, delayMs);
1606
+ timer.unref?.();
1607
+ this.maintenanceTimers.set(scopeKey, timer);
1608
+ }
1609
+
1610
+ private async runSessionMaintenance(scopeKey: string, agentId: string, attempt: number, reason: string): Promise<void> {
1611
+ const session = this.state.sessions[scopeKey];
1612
+ if (!session || !this.sessionNeedsMaintenance(session)) return;
1613
+ if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
1540
1614
  const { conv, mem } = this.getServices(agentId);
1615
+ const snap = await conv.loadSnapshot(session, []);
1616
+ if (!conv.shouldMirror(session.sessionId, snap.messages) || snap.messages.length === 0) return;
1541
1617
  let changed = false;
1542
- let workDone = 0;
1543
- for (const session of sessions) {
1544
- if (workDone >= 3) break;
1545
- const snap = await conv.loadSnapshot(session, []);
1546
- if (!conv.shouldMirror(session.sessionId, snap.messages) || snap.messages.length === 0) continue;
1547
- if (!session.issueNumber) {
1548
- await conv.ensureIssue(session, snap);
1549
- changed = true;
1550
- }
1551
- if (session.summaryStatus === "pending") {
1552
- try {
1553
- const result = await conv.generateSummaryAndTitle(session, snap);
1554
- await conv.syncLabels(session, snap, true);
1555
- await conv.syncBody(session, snap, result.summary, true, result.title);
1556
- session.summaryStatus = "complete";
1557
- if (result.title?.trim()) {
1558
- session.issueTitle = result.title.trim();
1559
- session.titleSource = "llm";
1560
- }
1561
- this.maybeAutoNameRepo(agentId, result.summary, result.title);
1562
- changed = true;
1563
- workDone++;
1564
- } catch (error) {
1565
- this.warn(`request-scoped summary sync for ${session.sessionId}`, error);
1618
+ let retryNeeded = false;
1619
+ if (!session.issueNumber) {
1620
+ await conv.ensureIssue(session, snap);
1621
+ changed = true;
1622
+ }
1623
+ if (session.summaryStatus === "pending") {
1624
+ try {
1625
+ const result = await conv.generateSummaryAndTitle(session, snap);
1626
+ await conv.syncLabels(session, snap, true);
1627
+ await conv.syncBody(session, snap, result.summary, true, result.title);
1628
+ session.summaryStatus = "complete";
1629
+ if (result.title?.trim()) {
1630
+ session.issueTitle = result.title.trim();
1631
+ session.titleSource = "llm";
1566
1632
  }
1633
+ this.maybeAutoNameRepo(agentId, result.summary, result.title);
1634
+ changed = true;
1635
+ } catch (error) {
1636
+ retryNeeded = true;
1637
+ this.warn(`background summary sync for ${session.sessionId}`, error);
1567
1638
  }
1568
- if (session.titleSource !== "llm" && snap.messages.length >= 2) {
1639
+ }
1640
+ if (session.titleSource !== "llm" && snap.messages.length >= 2) {
1641
+ try {
1569
1642
  await conv.syncTitle(session, snap);
1570
1643
  changed = true;
1571
- workDone++;
1644
+ } catch (error) {
1645
+ retryNeeded = true;
1646
+ this.warn(`background title sync for ${session.sessionId}`, error);
1572
1647
  }
1573
- if (snap.messages.length >= 2 && snap.messages.length > (session.lastMemorySyncCount ?? 0)) {
1574
- const ok = await mem.syncFromConversation(session, snap);
1575
- if (ok) {
1576
- session.lastMemorySyncCount = snap.messages.length;
1577
- changed = true;
1578
- }
1579
- workDone++;
1648
+ }
1649
+ if (snap.messages.length >= 2 && snap.messages.length > (session.lastMemorySyncCount ?? 0)) {
1650
+ const ok = await mem.syncFromConversation(session, snap);
1651
+ if (ok) {
1652
+ session.lastMemorySyncCount = snap.messages.length;
1653
+ changed = true;
1654
+ } else {
1655
+ retryNeeded = true;
1580
1656
  }
1581
1657
  }
1582
1658
  if (changed) await this.persistState();
1659
+ if (!retryNeeded || !this.sessionNeedsMaintenance(session)) return;
1660
+ if (attempt < SESSION_MAINTENANCE_RETRY_DELAYS_MS.length) {
1661
+ const delayMs = SESSION_MAINTENANCE_RETRY_DELAYS_MS[attempt];
1662
+ this.api.logger.warn?.(
1663
+ `clawmem: background maintenance incomplete for ${session.sessionId}; retrying in ${Math.round(delayMs / 1000)}s (${reason})`,
1664
+ );
1665
+ this.scheduleSessionMaintenance(scopeKey, agentId, { delayMs, attempt: attempt + 1, reason: "retry" });
1666
+ return;
1667
+ }
1668
+ this.api.logger.warn?.(
1669
+ `clawmem: background maintenance remains pending for ${session.sessionId}; it will be retried opportunistically on future requests`,
1670
+ );
1671
+ }
1672
+
1673
+ private sessionNeedsMaintenance(session: SessionMirrorState): boolean {
1674
+ if (session.summaryStatus === "pending") return true;
1675
+ const hasMeaningfulTranscript = Math.max(session.lastMirroredCount, session.turnCount) >= 2;
1676
+ if (!hasMeaningfulTranscript) return false;
1677
+ if (session.titleSource !== "llm") return true;
1678
+ return (session.lastMemorySyncCount ?? 0) < session.lastMirroredCount;
1583
1679
  }
1584
1680
 
1585
1681
  private getServices(agentId?: string, repo?: string): { route: ClawMemResolvedRoute; conv: ConversationMirror; mem: MemoryStore; client: GitHubIssueClient } {
@@ -1706,11 +1802,30 @@ function shouldFallbackToAnonymousBootstrap(error: unknown): boolean {
1706
1802
  function toolText(text: string): { content: Array<{ type: "text"; text: string }> } {
1707
1803
  return { content: [{ type: "text", text }] };
1708
1804
  }
1709
- function renderMemoryLine(memory: { memoryId: string; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale" }): string {
1710
- const schema = [memory.kind ? `kind:${memory.kind}` : "", ...(memory.topics ?? []).map((topic) => `topic:${topic}`)].filter(Boolean).join(", ");
1805
+ function renderMemoryLine(memory: {
1806
+ memoryId: string;
1807
+ title?: string;
1808
+ detail: string;
1809
+ kind?: string;
1810
+ topics?: string[];
1811
+ status: "active" | "stale";
1812
+ }): string {
1813
+ const schema = [
1814
+ memory.kind ? `kind:${memory.kind}` : "",
1815
+ ...(memory.topics ?? []).map((topic) => `topic:${topic}`),
1816
+ ].filter(Boolean).join(", ");
1711
1817
  return `[${memory.memoryId}] ${memory.title || "Memory"}${schema ? ` (${schema})` : ""}${memory.status === "stale" ? " [stale]" : ""}: ${memory.detail}`;
1712
1818
  }
1713
- function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale"; date?: string }): string {
1819
+ function renderMemoryBlock(memory: {
1820
+ memoryId: string;
1821
+ issueNumber?: number;
1822
+ title?: string;
1823
+ detail: string;
1824
+ kind?: string;
1825
+ topics?: string[];
1826
+ status: "active" | "stale";
1827
+ date?: string;
1828
+ }): string {
1714
1829
  const lines = [
1715
1830
  `Memory ID: ${memory.memoryId}`,
1716
1831
  ...(typeof memory.issueNumber === "number" ? [`Issue Number: ${memory.issueNumber}`] : []),
@@ -1724,6 +1839,170 @@ function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; tit
1724
1839
  return lines.join("\n");
1725
1840
  }
1726
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
+
1857
+ function formatInjectedMemory(memory: {
1858
+ detail: string;
1859
+ }): string {
1860
+ return memory.detail;
1861
+ }
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
+
1727
2006
  function renderOrgLine(org: { login?: string; name?: string; default_repository_permission?: string; description?: string }): string {
1728
2007
  const login = org.login?.trim() || "unknown-org";
1729
2008
  const name = org.name?.trim() ? ` (${org.name.trim()})` : "";
package/src/types.ts CHANGED
@@ -15,6 +15,7 @@ export type ClawMemPluginConfig = {
15
15
  authScheme: "token" | "bearer";
16
16
  agents: Record<string, ClawMemAgentConfig>;
17
17
  memoryRecallLimit: number;
18
+ memoryAutoRecallLimit: number;
18
19
  turnCommentDelayMs: number;
19
20
  summaryWaitTimeoutMs: number;
20
21
  };
@@ -43,7 +44,7 @@ export type SessionMirrorState = {
43
44
  export type PluginState = { version: 2; sessions: Record<string, SessionMirrorState>; migrations?: Record<string, string> };
44
45
  export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
45
46
  export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
46
- export type MemoryDraft = { detail: string; kind?: string; topics?: string[] };
47
+ export type MemoryDraft = { title?: string; detail: string; kind?: string; topics?: string[] };
47
48
  export type MemorySchema = { kinds: string[]; topics: string[] };
48
49
  export type MemoryListOptions = {
49
50
  status?: "active" | "stale" | "all";