@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/README.md +5 -3
- package/openclaw.plugin.json +10 -1
- package/package.json +1 -1
- package/skills/clawmem/SKILL.md +10 -0
- package/skills/clawmem/references/collaboration.md +2 -0
- package/skills/clawmem/references/communication.md +5 -0
- package/skills/clawmem/references/manual-ops.md +9 -4
- package/skills/clawmem/references/repair.md +2 -1
- package/skills/clawmem/references/schema.md +8 -0
- package/src/collaboration.test.ts +71 -0
- package/src/collaboration.ts +109 -0
- package/src/config.test.ts +1 -0
- package/src/config.ts +4 -3
- package/src/github-client.ts +4 -4
- package/src/memory.test.ts +85 -8
- package/src/memory.ts +83 -22
- package/src/service.test.ts +141 -0
- package/src/service.ts +344 -65
- package/src/types.ts +2 -1
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
|
-
|
|
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
|
|
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({
|
|
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", "
|
|
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
|
|
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("
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
1331
|
+
const memories = await mem.search(prompt, this.config.memoryAutoRecallLimit);
|
|
1291
1332
|
if (memories.length === 0) return;
|
|
1292
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
1639
|
+
}
|
|
1640
|
+
if (session.titleSource !== "llm" && snap.messages.length >= 2) {
|
|
1641
|
+
try {
|
|
1569
1642
|
await conv.syncTitle(session, snap);
|
|
1570
1643
|
changed = true;
|
|
1571
|
-
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
retryNeeded = true;
|
|
1646
|
+
this.warn(`background title sync for ${session.sessionId}`, error);
|
|
1572
1647
|
}
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
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: {
|
|
1710
|
-
|
|
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: {
|
|
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";
|