@clawmem-ai/clawmem 0.1.7 → 0.1.9
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 +21 -15
- package/openclaw.plugin.json +9 -5
- package/package.json +1 -1
- package/src/config.ts +3 -2
- package/src/conversation.test.ts +70 -0
- package/src/conversation.ts +215 -23
- package/src/github-client.ts +36 -2
- package/src/memory.test.ts +373 -0
- package/src/memory.ts +370 -45
- package/src/service.ts +373 -10
- package/src/state.ts +16 -0
- package/src/types.ts +13 -3
package/src/service.ts
CHANGED
|
@@ -30,16 +30,18 @@ class ClawMemService {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
register(): void {
|
|
33
|
-
this.api.on("before_agent_start", async (ev, ctx) => this.
|
|
33
|
+
this.api.on("before_agent_start", async (ev, ctx) => this.handleBeforeAgentStart(ev.prompt, ctx.agentId));
|
|
34
34
|
this.api.on("agent_end", (ev, ctx) => this.scheduleTurn({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, agentId: ctx.agentId, messages: ev.messages }));
|
|
35
35
|
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 }));
|
|
36
36
|
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" }));
|
|
37
|
+
this.registerTools();
|
|
37
38
|
|
|
38
39
|
this.api.registerService({
|
|
39
40
|
id: "clawmem",
|
|
40
|
-
start: async (ctx) => {
|
|
41
|
+
start: async (ctx: { stateDir: string }) => {
|
|
41
42
|
this.statePath = resolveStatePath(ctx.stateDir);
|
|
42
43
|
await this.ensureLoaded();
|
|
44
|
+
this.warnIfInactiveMemorySlot();
|
|
43
45
|
this.unsubTranscript = this.api.runtime.events.onSessionTranscriptUpdate((u) => {
|
|
44
46
|
void this.track(this.handleTranscript(u.sessionFile)).catch((e) => this.warn("transcript update", e));
|
|
45
47
|
});
|
|
@@ -61,10 +63,254 @@ class ClawMemService {
|
|
|
61
63
|
});
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
+
private registerTools(): void {
|
|
67
|
+
this.api.registerTool({
|
|
68
|
+
name: "memory_list",
|
|
69
|
+
description: "List ClawMem memories by status or schema so the agent can inspect the current memory index before deduping or saving.",
|
|
70
|
+
required: true,
|
|
71
|
+
parameters: {
|
|
72
|
+
type: "object",
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
properties: {
|
|
75
|
+
status: { type: "string", enum: ["active", "stale", "all"], description: "Which memories to list. Defaults to active." },
|
|
76
|
+
kind: { type: "string", minLength: 1, description: "Optional kind filter, for example core-fact, lesson, or task." },
|
|
77
|
+
topic: { type: "string", minLength: 1, description: "Optional topic filter." },
|
|
78
|
+
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum number of memories to return." },
|
|
79
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
execute: async (_id: string, params: unknown) => {
|
|
83
|
+
const p = asRecord(params);
|
|
84
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
85
|
+
if (!(await this.ensureConfigured(agentId))) return toolText(`ClawMem route for agent "${agentId}" is not configured.`);
|
|
86
|
+
const { mem } = this.getServices(agentId);
|
|
87
|
+
const status = p.status === "stale" || p.status === "all" ? p.status : "active";
|
|
88
|
+
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.floor(p.limit) : 20;
|
|
89
|
+
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
90
|
+
const topic = typeof p.topic === "string" && p.topic.trim() ? p.topic.trim() : undefined;
|
|
91
|
+
const memories = await mem.listMemories({ status, kind, topic, limit });
|
|
92
|
+
if (memories.length === 0) {
|
|
93
|
+
const filters = [status !== "active" ? `status=${status}` : "", kind ? `kind=${kind}` : "", topic ? `topic=${topic}` : ""].filter(Boolean).join(", ");
|
|
94
|
+
return toolText(`No memories matched${filters ? ` (${filters})` : ""}.`);
|
|
95
|
+
}
|
|
96
|
+
const lines = [
|
|
97
|
+
`Found ${memories.length} ${status === "all" ? "" : `${status} `}memor${memories.length === 1 ? "y" : "ies"}:`,
|
|
98
|
+
...memories.map((memory) => `- ${renderMemoryLine(memory)}`),
|
|
99
|
+
];
|
|
100
|
+
return toolText(lines.join("\n"));
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.api.registerTool({
|
|
105
|
+
name: "memory_labels",
|
|
106
|
+
description: "List existing ClawMem schema labels so the agent can reuse current kinds and topics before adding new ones.",
|
|
107
|
+
required: true,
|
|
108
|
+
parameters: {
|
|
109
|
+
type: "object",
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
properties: {
|
|
112
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
113
|
+
limitTopics: { type: "integer", minimum: 1, maximum: 200, description: "Maximum number of topic labels to display." },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
execute: async (_id: string, params: unknown) => {
|
|
117
|
+
const p = asRecord(params);
|
|
118
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
119
|
+
if (!(await this.ensureConfigured(agentId))) return toolText(`ClawMem route for agent "${agentId}" is not configured.`);
|
|
120
|
+
const { mem } = this.getServices(agentId);
|
|
121
|
+
const schema = await mem.listSchema();
|
|
122
|
+
const rawLimit = typeof p.limitTopics === "number" && Number.isFinite(p.limitTopics) ? Math.floor(p.limitTopics) : 50;
|
|
123
|
+
const limitTopics = Math.min(200, Math.max(1, rawLimit));
|
|
124
|
+
const kinds = schema.kinds.length > 0 ? schema.kinds.map((kind) => `- kind:${kind}`).join("\n") : "- None";
|
|
125
|
+
const topics = schema.topics.length > 0 ? schema.topics.slice(0, limitTopics).map((topic) => `- topic:${topic}`).join("\n") : "- None";
|
|
126
|
+
const extra = schema.topics.length > limitTopics ? `\n- ...and ${schema.topics.length - limitTopics} more topics` : "";
|
|
127
|
+
return toolText([
|
|
128
|
+
"Current ClawMem schema labels:",
|
|
129
|
+
"",
|
|
130
|
+
"Kinds:",
|
|
131
|
+
kinds,
|
|
132
|
+
"",
|
|
133
|
+
"Topics:",
|
|
134
|
+
`${topics}${extra}`,
|
|
135
|
+
].join("\n"));
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.api.registerTool({
|
|
140
|
+
name: "memory_recall",
|
|
141
|
+
description: "Search ClawMem active memories for relevant prior facts, decisions, conventions, and lessons.",
|
|
142
|
+
required: true,
|
|
143
|
+
parameters: {
|
|
144
|
+
type: "object",
|
|
145
|
+
additionalProperties: false,
|
|
146
|
+
properties: {
|
|
147
|
+
query: { type: "string", minLength: 1, description: "What to recall from memory." },
|
|
148
|
+
limit: { type: "integer", minimum: 1, maximum: 20, description: "Maximum number of memories to return." },
|
|
149
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
150
|
+
},
|
|
151
|
+
required: ["query"],
|
|
152
|
+
},
|
|
153
|
+
execute: async (_id: string, params: unknown) => {
|
|
154
|
+
const p = asRecord(params);
|
|
155
|
+
const query = typeof p.query === "string" ? p.query.trim() : "";
|
|
156
|
+
if (!query) return toolText("Query is empty.");
|
|
157
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
158
|
+
if (!(await this.ensureConfigured(agentId))) return toolText(`ClawMem route for agent "${agentId}" is not configured.`);
|
|
159
|
+
const { mem } = this.getServices(agentId);
|
|
160
|
+
const rawLimit = typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.floor(p.limit) : this.config.memoryRecallLimit;
|
|
161
|
+
const limit = Math.min(20, Math.max(1, rawLimit));
|
|
162
|
+
const memories = await mem.search(query, limit);
|
|
163
|
+
if (memories.length === 0) return toolText(`No active memories matched "${query}".`);
|
|
164
|
+
const text = [
|
|
165
|
+
`Found ${memories.length} active memor${memories.length === 1 ? "y" : "ies"} for "${query}":`,
|
|
166
|
+
...memories.map((memory) => `- ${renderMemoryLine(memory)}`),
|
|
167
|
+
].join("\n");
|
|
168
|
+
return toolText(text);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
this.api.registerTool({
|
|
173
|
+
name: "memory_get",
|
|
174
|
+
description: "Fetch one ClawMem memory by memory id or issue number so the agent can verify an exact record.",
|
|
175
|
+
required: true,
|
|
176
|
+
parameters: {
|
|
177
|
+
type: "object",
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
properties: {
|
|
180
|
+
memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to retrieve." },
|
|
181
|
+
status: { type: "string", enum: ["active", "stale", "all"], description: "Which status bucket to search. Defaults to all." },
|
|
182
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
183
|
+
},
|
|
184
|
+
required: ["memoryId"],
|
|
185
|
+
},
|
|
186
|
+
execute: async (_id: string, params: unknown) => {
|
|
187
|
+
const p = asRecord(params);
|
|
188
|
+
const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
|
|
189
|
+
if (!memoryId) return toolText("memoryId is empty.");
|
|
190
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
191
|
+
if (!(await this.ensureConfigured(agentId))) return toolText(`ClawMem route for agent "${agentId}" is not configured.`);
|
|
192
|
+
const { mem } = this.getServices(agentId);
|
|
193
|
+
const status = p.status === "active" || p.status === "stale" ? p.status : "all";
|
|
194
|
+
const memory = await mem.get(memoryId, status);
|
|
195
|
+
if (!memory) return toolText(`No ${status === "all" ? "" : `${status} `}memory matched id "${memoryId}".`);
|
|
196
|
+
return toolText(renderMemoryBlock(memory));
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
this.api.registerTool({
|
|
201
|
+
name: "memory_store",
|
|
202
|
+
description: "Store a durable ClawMem memory immediately instead of waiting for session finalization.",
|
|
203
|
+
required: true,
|
|
204
|
+
parameters: {
|
|
205
|
+
type: "object",
|
|
206
|
+
additionalProperties: false,
|
|
207
|
+
properties: {
|
|
208
|
+
detail: { type: "string", minLength: 1, description: "The durable fact, lesson, decision, or preference to remember." },
|
|
209
|
+
kind: { type: "string", minLength: 1, description: "Optional schema kind, for example lesson, convention, skill, or task." },
|
|
210
|
+
topics: {
|
|
211
|
+
type: "array",
|
|
212
|
+
description: "Optional topic labels to improve future retrieval.",
|
|
213
|
+
items: { type: "string", minLength: 1 },
|
|
214
|
+
minItems: 1,
|
|
215
|
+
maxItems: 10,
|
|
216
|
+
},
|
|
217
|
+
sessionId: { type: "string", minLength: 1, description: "Optional source session id label. Defaults to manual." },
|
|
218
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
219
|
+
},
|
|
220
|
+
required: ["detail"],
|
|
221
|
+
},
|
|
222
|
+
execute: async (_id: string, params: unknown) => {
|
|
223
|
+
const p = asRecord(params);
|
|
224
|
+
const detail = typeof p.detail === "string" ? p.detail.trim() : "";
|
|
225
|
+
if (!detail) return toolText("Detail is empty.");
|
|
226
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
227
|
+
if (!(await this.ensureConfigured(agentId))) return toolText(`ClawMem route for agent "${agentId}" is not configured.`);
|
|
228
|
+
const { mem } = this.getServices(agentId);
|
|
229
|
+
const sessionId = typeof p.sessionId === "string" && p.sessionId.trim() ? p.sessionId.trim() : "manual";
|
|
230
|
+
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
231
|
+
const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
|
|
232
|
+
const result = await mem.store({ detail, ...(kind ? { kind } : {}), ...(topics && topics.length > 0 ? { topics } : {}) }, sessionId);
|
|
233
|
+
if (!result.created) return toolText(`Memory already exists.\n${renderMemoryBlock(result.memory)}`);
|
|
234
|
+
return toolText(`Stored memory.\n${renderMemoryBlock(result.memory)}`);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
this.api.registerTool({
|
|
239
|
+
name: "memory_update",
|
|
240
|
+
description: "Update an existing ClawMem memory in place when the same canonical fact or task has evolved.",
|
|
241
|
+
required: true,
|
|
242
|
+
parameters: {
|
|
243
|
+
type: "object",
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
properties: {
|
|
246
|
+
memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to update." },
|
|
247
|
+
detail: { type: "string", minLength: 1, description: "Optional replacement detail text for the same memory record." },
|
|
248
|
+
kind: { type: "string", minLength: 1, description: "Optional replacement kind label." },
|
|
249
|
+
topics: {
|
|
250
|
+
type: "array",
|
|
251
|
+
description: "Optional replacement topic labels.",
|
|
252
|
+
items: { type: "string", minLength: 1 },
|
|
253
|
+
minItems: 1,
|
|
254
|
+
maxItems: 10,
|
|
255
|
+
},
|
|
256
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
257
|
+
},
|
|
258
|
+
required: ["memoryId"],
|
|
259
|
+
},
|
|
260
|
+
execute: async (_id: string, params: unknown) => {
|
|
261
|
+
const p = asRecord(params);
|
|
262
|
+
const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
|
|
263
|
+
if (!memoryId) return toolText("memoryId is empty.");
|
|
264
|
+
const detail = typeof p.detail === "string" && p.detail.trim() ? p.detail.trim() : undefined;
|
|
265
|
+
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
266
|
+
const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
|
|
267
|
+
if (!detail && kind === undefined && topics === undefined) return toolText("Provide at least one of detail, kind, or topics.");
|
|
268
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
269
|
+
if (!(await this.ensureConfigured(agentId))) return toolText(`ClawMem route for agent "${agentId}" is not configured.`);
|
|
270
|
+
const { mem } = this.getServices(agentId);
|
|
271
|
+
let updated;
|
|
272
|
+
try {
|
|
273
|
+
updated = await mem.update(memoryId, { ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return toolText(`Unable to update memory "${memoryId}": ${String(error)}`);
|
|
276
|
+
}
|
|
277
|
+
if (!updated) return toolText(`No memory matched id "${memoryId}".`);
|
|
278
|
+
return toolText(`Updated memory.\n${renderMemoryBlock(updated)}`);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
this.api.registerTool({
|
|
283
|
+
name: "memory_forget",
|
|
284
|
+
description: "Mark an active ClawMem memory as stale when it is superseded or no longer true.",
|
|
285
|
+
required: true,
|
|
286
|
+
parameters: {
|
|
287
|
+
type: "object",
|
|
288
|
+
additionalProperties: false,
|
|
289
|
+
properties: {
|
|
290
|
+
memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to mark stale." },
|
|
291
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
292
|
+
},
|
|
293
|
+
required: ["memoryId"],
|
|
294
|
+
},
|
|
295
|
+
execute: async (_id: string, params: unknown) => {
|
|
296
|
+
const p = asRecord(params);
|
|
297
|
+
const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
|
|
298
|
+
if (!memoryId) return toolText("memoryId is empty.");
|
|
299
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
300
|
+
if (!(await this.ensureConfigured(agentId))) return toolText(`ClawMem route for agent "${agentId}" is not configured.`);
|
|
301
|
+
const { mem } = this.getServices(agentId);
|
|
302
|
+
const forgotten = await mem.forget(memoryId);
|
|
303
|
+
if (!forgotten) return toolText(`No active memory matched id "${memoryId}".`);
|
|
304
|
+
return toolText(`Marked memory [${forgotten.memoryId}] stale: ${forgotten.detail}`);
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async handleBeforeAgentStart(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
|
|
66
310
|
const routeAgentId = normalizeAgentId(agentId);
|
|
67
311
|
if (!(await this.ensureConfigured(routeAgentId))) return;
|
|
312
|
+
await this.runRequestMaintenance(routeAgentId);
|
|
313
|
+
if (typeof prompt !== "string" || prompt.trim().length < 5) return;
|
|
68
314
|
try {
|
|
69
315
|
const { mem } = this.getServices(routeAgentId);
|
|
70
316
|
const memories = await mem.search(prompt, this.config.memoryRecallLimit);
|
|
@@ -138,7 +384,7 @@ class ClawMemService {
|
|
|
138
384
|
if (!p.sessionId) return;
|
|
139
385
|
const agentId = normalizeAgentId(p.agentId);
|
|
140
386
|
if (!(await this.ensureConfigured(agentId))) return;
|
|
141
|
-
const { conv
|
|
387
|
+
const { conv } = this.getServices(agentId);
|
|
142
388
|
const s = this.getOrCreate(p.sessionId, agentId);
|
|
143
389
|
if (s.finalizedAt) return;
|
|
144
390
|
s.sessionKey = p.sessionKey ?? s.sessionKey; s.sessionFile = p.sessionFile ?? s.sessionFile;
|
|
@@ -150,11 +396,9 @@ class ClawMemService {
|
|
|
150
396
|
const next = snap.messages.slice(s.lastMirroredCount);
|
|
151
397
|
let allOk = true;
|
|
152
398
|
if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; allOk = n === next.length; }
|
|
153
|
-
let summary = "pending";
|
|
154
|
-
try { summary = await conv.generateSummary(s, snap); } catch (e) { summary = `failed: ${String(e)}`; }
|
|
155
399
|
await conv.syncLabels(s, snap, true);
|
|
156
|
-
await conv.syncBody(s, snap,
|
|
157
|
-
|
|
400
|
+
await conv.syncBody(s, snap, "pending", true);
|
|
401
|
+
s.summaryStatus = "pending";
|
|
158
402
|
if (allOk) s.finalizedAt = new Date().toISOString();
|
|
159
403
|
await this.persistState();
|
|
160
404
|
}
|
|
@@ -226,13 +470,35 @@ class ClawMemService {
|
|
|
226
470
|
if (!route.baseUrl) { this.api.logger.warn(`clawmem: cannot provision Git credentials for ${agentId} without a baseUrl`); return false; }
|
|
227
471
|
try {
|
|
228
472
|
const client = new GitHubIssueClient(route, this.api.logger);
|
|
229
|
-
const
|
|
473
|
+
const locale = Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.locale ?? "";
|
|
474
|
+
const sess = await client.createAnonymousSession(locale);
|
|
230
475
|
await this.persistAgentConfig(agentId, { baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
|
|
231
476
|
this.config.agents[agentId] = { ...(this.config.agents[agentId] ?? {}), baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name };
|
|
232
477
|
this.api.logger.info?.(`clawmem: provisioned Git credentials for agent ${agentId} -> ${sess.repo_full_name} via ${route.baseUrl}`);
|
|
233
478
|
return true;
|
|
234
479
|
} catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials for agent ${agentId} via ${route.baseUrl}: ${String(error)}`); return false; }
|
|
235
480
|
}
|
|
481
|
+
private warnIfInactiveMemorySlot(): void {
|
|
482
|
+
try {
|
|
483
|
+
const root = this.api.runtime.config.loadConfig();
|
|
484
|
+
const plugins = asRecord(root.plugins);
|
|
485
|
+
const slots = asRecord(plugins.slots);
|
|
486
|
+
const slot = typeof slots.memory === "string" ? String(slots.memory).trim() : "";
|
|
487
|
+
if (!slot) {
|
|
488
|
+
this.api.logger.warn(
|
|
489
|
+
`clawmem: plugins.slots.memory is not set, so OpenClaw may keep the default memory plugin active. Set plugins.slots.memory to "${this.api.id}" and restart the gateway.`,
|
|
490
|
+
);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (slot !== this.api.id) {
|
|
494
|
+
this.api.logger.warn(
|
|
495
|
+
`clawmem: plugins.slots.memory is "${slot}", so ClawMem is not the selected memory plugin. Set plugins.slots.memory to "${this.api.id}" and restart the gateway.`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
} catch (error) {
|
|
499
|
+
this.api.logger.warn(`clawmem: memory slot check failed: ${String(error)}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
236
502
|
private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; repo: string }): Promise<void> {
|
|
237
503
|
const root = this.api.runtime.config.loadConfig();
|
|
238
504
|
const plugins = root.plugins;
|
|
@@ -260,6 +526,57 @@ class ClawMemService {
|
|
|
260
526
|
},
|
|
261
527
|
});
|
|
262
528
|
}
|
|
529
|
+
private async runRequestMaintenance(agentId: string): Promise<void> {
|
|
530
|
+
const sessions = Object.values(this.state.sessions)
|
|
531
|
+
.filter((session) => normalizeAgentId(session.agentId) === agentId)
|
|
532
|
+
.sort((a, b) => Date.parse(b.updatedAt ?? b.createdAt ?? "") - Date.parse(a.updatedAt ?? a.createdAt ?? ""))
|
|
533
|
+
.slice(0, 8);
|
|
534
|
+
if (sessions.length === 0) return;
|
|
535
|
+
const { conv, mem } = this.getServices(agentId);
|
|
536
|
+
let changed = false;
|
|
537
|
+
let workDone = 0;
|
|
538
|
+
for (const session of sessions) {
|
|
539
|
+
if (workDone >= 3) break;
|
|
540
|
+
const snap = await conv.loadSnapshot(session, []);
|
|
541
|
+
if (!conv.shouldMirror(session.sessionId, snap.messages) || snap.messages.length === 0) continue;
|
|
542
|
+
if (!session.issueNumber) {
|
|
543
|
+
await conv.ensureIssue(session, snap);
|
|
544
|
+
changed = true;
|
|
545
|
+
}
|
|
546
|
+
if (session.summaryStatus === "pending") {
|
|
547
|
+
try {
|
|
548
|
+
const result = await conv.generateSummaryAndTitle(session, snap);
|
|
549
|
+
await conv.syncLabels(session, snap, true);
|
|
550
|
+
await conv.syncBody(session, snap, result.summary, true, result.title);
|
|
551
|
+
session.summaryStatus = "complete";
|
|
552
|
+
if (result.title?.trim()) {
|
|
553
|
+
session.issueTitle = result.title.trim();
|
|
554
|
+
session.titleSource = "llm";
|
|
555
|
+
}
|
|
556
|
+
this.maybeAutoNameRepo(agentId, result.summary, result.title);
|
|
557
|
+
changed = true;
|
|
558
|
+
workDone++;
|
|
559
|
+
} catch (error) {
|
|
560
|
+
this.warn(`request-scoped summary sync for ${session.sessionId}`, error);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (session.titleSource !== "llm" && snap.messages.length >= 2) {
|
|
564
|
+
await conv.syncTitle(session, snap);
|
|
565
|
+
changed = true;
|
|
566
|
+
workDone++;
|
|
567
|
+
}
|
|
568
|
+
if (snap.messages.length >= 2 && snap.messages.length > (session.lastMemorySyncCount ?? 0)) {
|
|
569
|
+
const ok = await mem.syncFromConversation(session, snap);
|
|
570
|
+
if (ok) {
|
|
571
|
+
session.lastMemorySyncCount = snap.messages.length;
|
|
572
|
+
changed = true;
|
|
573
|
+
}
|
|
574
|
+
workDone++;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (changed) await this.persistState();
|
|
578
|
+
}
|
|
579
|
+
|
|
263
580
|
private getServices(agentId?: string): { conv: ConversationMirror; mem: MemoryStore } {
|
|
264
581
|
const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
|
|
265
582
|
return {
|
|
@@ -267,9 +584,55 @@ class ClawMemService {
|
|
|
267
584
|
mem: new MemoryStore(client, this.api, this.config),
|
|
268
585
|
};
|
|
269
586
|
}
|
|
587
|
+
private resolveToolAgentId(agentId: unknown): string {
|
|
588
|
+
return normalizeAgentId(typeof agentId === "string" && agentId.trim() ? agentId : process.env.OPENCLAW_AGENT_ID);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* After finalization, check if the repo still has an empty/default description.
|
|
592
|
+
* If so, use the conversation summary to suggest a meaningful name and update
|
|
593
|
+
* the repo description automatically. Best-effort, fire-and-forget.
|
|
594
|
+
*/
|
|
595
|
+
private maybeAutoNameRepo(agentId: string, summary: string, title?: string): void {
|
|
596
|
+
if (!summary || summary.startsWith("failed:") || summary === "pending") return;
|
|
597
|
+
const snippet = title || summary.slice(0, 100);
|
|
598
|
+
void (async () => {
|
|
599
|
+
try {
|
|
600
|
+
const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
|
|
601
|
+
const repo = await client.getRepoInfo();
|
|
602
|
+
// Only auto-name if description is still empty or a default placeholder.
|
|
603
|
+
if (repo.description && repo.description !== "My Memory Space" && repo.description !== "我的记忆空间" && repo.description !== "マイメモリースペース") return;
|
|
604
|
+
// Use the conversation title or summary as a lightweight description.
|
|
605
|
+
await client.updateRepoDescription(snippet);
|
|
606
|
+
this.api.logger.info?.(`clawmem: auto-named repo to "${snippet}"`);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
this.api.logger.warn(`clawmem: auto-name repo failed: ${String(e)}`);
|
|
609
|
+
}
|
|
610
|
+
})();
|
|
611
|
+
}
|
|
270
612
|
private warn(scope: string, error: unknown): void { this.api.logger.warn(`clawmem: ${scope} failed: ${String(error)}`); }
|
|
271
613
|
}
|
|
272
614
|
|
|
273
615
|
function asRecord(v: unknown): Record<string, unknown> { return v && typeof v === "object" ? (v as Record<string, unknown>) : {}; }
|
|
616
|
+
function toolText(text: string): { content: Array<{ type: "text"; text: string }> } {
|
|
617
|
+
return { content: [{ type: "text", text }] };
|
|
618
|
+
}
|
|
619
|
+
function renderMemoryLine(memory: { memoryId: string; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale" }): string {
|
|
620
|
+
const schema = [memory.kind ? `kind:${memory.kind}` : "", ...(memory.topics ?? []).map((topic) => `topic:${topic}`)].filter(Boolean).join(", ");
|
|
621
|
+
return `[${memory.memoryId}] ${memory.title || "Memory"}${schema ? ` (${schema})` : ""}${memory.status === "stale" ? " [stale]" : ""}: ${memory.detail}`;
|
|
622
|
+
}
|
|
623
|
+
function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale"; sessionId?: string; date?: string }): string {
|
|
624
|
+
const lines = [
|
|
625
|
+
`Memory ID: ${memory.memoryId}`,
|
|
626
|
+
...(typeof memory.issueNumber === "number" ? [`Issue Number: ${memory.issueNumber}`] : []),
|
|
627
|
+
`Status: ${memory.status}`,
|
|
628
|
+
`Title: ${memory.title || "Memory"}`,
|
|
629
|
+
...(memory.kind ? [`Kind: ${memory.kind}`] : []),
|
|
630
|
+
...(memory.topics && memory.topics.length > 0 ? [`Topics: ${memory.topics.join(", ")}`] : []),
|
|
631
|
+
...(memory.sessionId ? [`Session: ${memory.sessionId}`] : []),
|
|
632
|
+
...(memory.date ? [`Date: ${memory.date}`] : []),
|
|
633
|
+
`Detail: ${memory.detail}`,
|
|
634
|
+
];
|
|
635
|
+
return lines.join("\n");
|
|
636
|
+
}
|
|
274
637
|
|
|
275
638
|
export function createClawMemPlugin(api: OpenClawPluginApi): void { new ClawMemService(api).register(); }
|
package/src/state.ts
CHANGED
|
@@ -42,9 +42,17 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
42
42
|
raw.sessions && typeof raw.sessions === "object"
|
|
43
43
|
? (raw.sessions as Record<string, unknown>)
|
|
44
44
|
: {};
|
|
45
|
+
const migrations: Record<string, string> = {};
|
|
46
|
+
if (raw.migrations && typeof raw.migrations === "object") {
|
|
47
|
+
for (const [k, v] of Object.entries(raw.migrations as Record<string, unknown>)) {
|
|
48
|
+
const s = readString(v);
|
|
49
|
+
if (s) migrations[k] = s;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
45
52
|
const out: PluginState = {
|
|
46
53
|
version: 2,
|
|
47
54
|
sessions: {},
|
|
55
|
+
...(Object.keys(migrations).length > 0 ? { migrations } : {}),
|
|
48
56
|
};
|
|
49
57
|
for (const [storedKey, sessionValue] of Object.entries(sessions)) {
|
|
50
58
|
if (!sessionValue || typeof sessionValue !== "object" || !storedKey.trim()) {
|
|
@@ -63,8 +71,11 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
63
71
|
agentId,
|
|
64
72
|
issueNumber: readNumber(rawSession.issueNumber),
|
|
65
73
|
issueTitle: readString(rawSession.issueTitle),
|
|
74
|
+
titleSource: readEnum(rawSession.titleSource, ["placeholder", "llm"]),
|
|
66
75
|
lastMirroredCount: readNumber(rawSession.lastMirroredCount) ?? 0,
|
|
67
76
|
turnCount: readNumber(rawSession.turnCount) ?? 0,
|
|
77
|
+
lastMemorySyncCount: readNumber(rawSession.lastMemorySyncCount),
|
|
78
|
+
summaryStatus: readEnum(rawSession.summaryStatus, ["pending", "complete"]),
|
|
68
79
|
finalizedAt: readString(rawSession.finalizedAt),
|
|
69
80
|
lastSummaryHash: readString(rawSession.lastSummaryHash),
|
|
70
81
|
lastTurnHash: readString(rawSession.lastTurnHash),
|
|
@@ -83,6 +94,11 @@ function readString(value: unknown): string | undefined {
|
|
|
83
94
|
return trimmed ? trimmed : undefined;
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
function readEnum<T extends string>(value: unknown, allowed: T[]): T | undefined {
|
|
98
|
+
const s = readString(value);
|
|
99
|
+
return s && (allowed as string[]).includes(s) ? (s as T) : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
86
102
|
function readNumber(value: unknown): number | undefined {
|
|
87
103
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
88
104
|
return undefined;
|
package/src/types.ts
CHANGED
|
@@ -26,16 +26,26 @@ export type ClawMemResolvedRoute = {
|
|
|
26
26
|
export type AnonymousSessionResponse = { token: string; owner_login: string; repo_name: string; repo_full_name: string };
|
|
27
27
|
export type SessionMirrorState = {
|
|
28
28
|
sessionId: string; sessionKey?: string; sessionFile?: string; agentId?: string;
|
|
29
|
-
issueNumber?: number; issueTitle?: string;
|
|
29
|
+
issueNumber?: number; issueTitle?: string; titleSource?: "placeholder" | "llm";
|
|
30
30
|
lastMirroredCount: number; turnCount: number; lastAssistantText?: string;
|
|
31
|
+
lastMemorySyncCount?: number;
|
|
32
|
+
summaryStatus?: "pending" | "complete";
|
|
31
33
|
finalizedAt?: string; lastSummaryHash?: string; lastTurnHash?: string;
|
|
32
34
|
createdAt?: string; updatedAt?: string;
|
|
33
35
|
};
|
|
34
|
-
export type PluginState = { version: 2; sessions: Record<string, SessionMirrorState> };
|
|
36
|
+
export type PluginState = { version: 2; sessions: Record<string, SessionMirrorState>; migrations?: Record<string, string> };
|
|
35
37
|
export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
|
|
36
38
|
export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
|
|
39
|
+
export type MemoryDraft = { detail: string; kind?: string; topics?: string[] };
|
|
40
|
+
export type MemorySchema = { kinds: string[]; topics: string[] };
|
|
41
|
+
export type MemoryListOptions = {
|
|
42
|
+
status?: "active" | "stale" | "all";
|
|
43
|
+
kind?: string;
|
|
44
|
+
topic?: string;
|
|
45
|
+
limit?: number;
|
|
46
|
+
};
|
|
37
47
|
export type ParsedMemoryIssue = {
|
|
38
48
|
issueNumber: number; title: string; memoryId: string; memoryHash?: string;
|
|
39
49
|
sessionId: string; date: string; detail: string;
|
|
40
|
-
topics?: string[]; status: "active" | "stale";
|
|
50
|
+
kind?: string; topics?: string[]; status: "active" | "stale";
|
|
41
51
|
};
|