@clawmem-ai/clawmem 0.1.8 → 0.1.10
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 +25 -23
- package/openclaw.plugin.json +17 -85
- package/package.json +1 -1
- package/src/config.test.ts +82 -0
- package/src/config.ts +26 -8
- package/src/conversation.test.ts +14 -20
- package/src/conversation.ts +180 -21
- package/src/github-client.ts +55 -1
- package/src/memory.test.ts +371 -0
- package/src/memory.ts +368 -47
- package/src/service.ts +517 -34
- package/src/state.ts +16 -0
- package/src/types.ts +22 -5
- package/src/utils.ts +13 -0
package/src/service.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Thin orchestrator: wires conversation mirroring, memory store, and plugin lifecycle.
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import { isAgentConfigured, resolveAgentRoute, resolvePluginConfig } from "./config.js";
|
|
3
|
+
import { hasDefaultRepo, isAgentConfigured, resolveAgentRoute, resolvePluginConfig } from "./config.js";
|
|
4
4
|
import { ConversationMirror } from "./conversation.js";
|
|
5
5
|
import { GitHubIssueClient } from "./github-client.js";
|
|
6
6
|
import { KeyedAsyncQueue } from "./keyed-async-queue.js";
|
|
7
7
|
import { MemoryStore } from "./memory.js";
|
|
8
8
|
import { loadState, resolveStatePath, saveState } from "./state.js";
|
|
9
9
|
import { readTranscriptSnapshot } from "./transcript.js";
|
|
10
|
-
import type { ClawMemPluginConfig, PluginState, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
11
|
-
import { inferAgentIdFromTranscriptPath, normalizeAgentId, sessionScopeKey } from "./utils.js";
|
|
10
|
+
import type { BootstrapIdentityResponse, ClawMemPluginConfig, ClawMemResolvedRoute, PluginState, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
11
|
+
import { buildAgentBootstrapRegistration, inferAgentIdFromTranscriptPath, normalizeAgentId, sessionScopeKey } from "./utils.js";
|
|
12
12
|
|
|
13
13
|
type TurnPayload = { sessionId?: string; sessionKey?: string; agentId?: string; messages: unknown[] };
|
|
14
14
|
type FinalizePayload = { sessionId?: string; sessionKey?: string; sessionFile?: string; agentId?: string; reason?: string; messages?: unknown[] };
|
|
@@ -30,21 +30,24 @@ 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
|
});
|
|
46
48
|
const configuredCount = Object.keys(this.config.agents).filter((agentId) => {
|
|
47
|
-
|
|
49
|
+
const route = resolveAgentRoute(this.config, agentId);
|
|
50
|
+
return isAgentConfigured(route) && hasDefaultRepo(route);
|
|
48
51
|
}).length;
|
|
49
52
|
this.api.logger.info?.(
|
|
50
53
|
configuredCount > 0
|
|
@@ -61,10 +64,339 @@ class ClawMemService {
|
|
|
61
64
|
});
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
private
|
|
65
|
-
|
|
67
|
+
private registerTools(): void {
|
|
68
|
+
this.api.registerTool({
|
|
69
|
+
name: "memory_repos",
|
|
70
|
+
description: "List the memory repos the current ClawMem agent identity can access so the agent can choose the right space before retrieving or storing memory.",
|
|
71
|
+
required: true,
|
|
72
|
+
parameters: {
|
|
73
|
+
type: "object",
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
properties: {
|
|
76
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent identity override. Defaults to the current agent when available." },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
execute: async (_id: string, params: unknown) => {
|
|
80
|
+
const p = asRecord(params);
|
|
81
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
82
|
+
const resolved = await this.requireToolIdentity(agentId);
|
|
83
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
84
|
+
const repos = await resolved.client.listUserRepos();
|
|
85
|
+
if (repos.length === 0) return toolText(`Agent "${agentId}" has no accessible ClawMem repos yet.`);
|
|
86
|
+
const lines = [
|
|
87
|
+
`Accessible ClawMem repos for agent "${agentId}":`,
|
|
88
|
+
...repos
|
|
89
|
+
.map((repo) => {
|
|
90
|
+
const fullName = repo.full_name?.trim() || repo.name?.trim() || "unknown";
|
|
91
|
+
const flags = [
|
|
92
|
+
resolved.route.defaultRepo === fullName ? "default" : "",
|
|
93
|
+
repo.private ? "private" : "shared",
|
|
94
|
+
].filter(Boolean).join(", ");
|
|
95
|
+
const description = repo.description?.trim() ? ` - ${repo.description.trim()}` : "";
|
|
96
|
+
return `- ${fullName}${flags ? ` [${flags}]` : ""}${description}`;
|
|
97
|
+
}),
|
|
98
|
+
];
|
|
99
|
+
return toolText(lines.join("\n"));
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.api.registerTool({
|
|
104
|
+
name: "memory_repo_create",
|
|
105
|
+
description: "Create a new ClawMem repo under the current agent identity when the agent decides a new memory space is needed.",
|
|
106
|
+
required: true,
|
|
107
|
+
parameters: {
|
|
108
|
+
type: "object",
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
properties: {
|
|
111
|
+
name: { type: "string", minLength: 1, description: "Repository name only, without owner prefix." },
|
|
112
|
+
description: { type: "string", minLength: 1, description: "Optional repo description." },
|
|
113
|
+
private: { type: "boolean", description: "Whether the new repo should be private. Defaults to true." },
|
|
114
|
+
setDefault: { type: "boolean", description: "Whether to make the new repo this agent's default memory repo." },
|
|
115
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent identity override. Defaults to the current agent when available." },
|
|
116
|
+
},
|
|
117
|
+
required: ["name"],
|
|
118
|
+
},
|
|
119
|
+
execute: async (_id: string, params: unknown) => {
|
|
120
|
+
const p = asRecord(params);
|
|
121
|
+
const name = typeof p.name === "string" ? p.name.trim() : "";
|
|
122
|
+
if (!name) return toolText("name is empty.");
|
|
123
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
124
|
+
const resolved = await this.requireToolIdentity(agentId);
|
|
125
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
126
|
+
const created = await resolved.client.createUserRepo({
|
|
127
|
+
name,
|
|
128
|
+
...(typeof p.description === "string" && p.description.trim() ? { description: p.description.trim() } : {}),
|
|
129
|
+
...(typeof p.private === "boolean" ? { private: p.private } : {}),
|
|
130
|
+
});
|
|
131
|
+
const fullName = created.full_name?.trim() || created.name?.trim() || name;
|
|
132
|
+
let defaultNote = "";
|
|
133
|
+
const shouldSetDefault = p.setDefault === true || !resolved.route.defaultRepo;
|
|
134
|
+
if (shouldSetDefault && fullName.includes("/")) {
|
|
135
|
+
await this.persistAgentConfig(agentId, {
|
|
136
|
+
baseUrl: resolved.route.baseUrl,
|
|
137
|
+
authScheme: resolved.route.authScheme,
|
|
138
|
+
token: resolved.route.token!,
|
|
139
|
+
defaultRepo: fullName,
|
|
140
|
+
});
|
|
141
|
+
this.config.agents[agentId] = { ...(this.config.agents[agentId] ?? {}), defaultRepo: fullName };
|
|
142
|
+
defaultNote = resolved.route.defaultRepo ? "\nSet as default repo for this agent." : "\nSet as the first default repo for this agent.";
|
|
143
|
+
}
|
|
144
|
+
return toolText(`Created memory repo ${fullName}.${defaultNote}`);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
this.api.registerTool({
|
|
149
|
+
name: "memory_list",
|
|
150
|
+
description: "List ClawMem memories by status or schema so the agent can inspect the current memory index before deduping or saving.",
|
|
151
|
+
required: true,
|
|
152
|
+
parameters: {
|
|
153
|
+
type: "object",
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
properties: {
|
|
156
|
+
status: { type: "string", enum: ["active", "stale", "all"], description: "Which memories to list. Defaults to active." },
|
|
157
|
+
kind: { type: "string", minLength: 1, description: "Optional kind filter, for example core-fact, lesson, or task." },
|
|
158
|
+
topic: { type: "string", minLength: 1, description: "Optional topic filter." },
|
|
159
|
+
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum number of memories to return." },
|
|
160
|
+
repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
|
|
161
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
execute: async (_id: string, params: unknown) => {
|
|
165
|
+
const p = asRecord(params);
|
|
166
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
167
|
+
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
168
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
169
|
+
const status = p.status === "stale" || p.status === "all" ? p.status : "active";
|
|
170
|
+
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.floor(p.limit) : 20;
|
|
171
|
+
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
172
|
+
const topic = typeof p.topic === "string" && p.topic.trim() ? p.topic.trim() : undefined;
|
|
173
|
+
const memories = await resolved.mem.listMemories({ status, kind, topic, limit });
|
|
174
|
+
if (memories.length === 0) {
|
|
175
|
+
const filters = [status !== "active" ? `status=${status}` : "", kind ? `kind=${kind}` : "", topic ? `topic=${topic}` : ""].filter(Boolean).join(", ");
|
|
176
|
+
return toolText(`No memories matched in ${resolved.route.repo}${filters ? ` (${filters})` : ""}.`);
|
|
177
|
+
}
|
|
178
|
+
const lines = [
|
|
179
|
+
`Found ${memories.length} ${status === "all" ? "" : `${status} `}memor${memories.length === 1 ? "y" : "ies"} in ${resolved.route.repo}:`,
|
|
180
|
+
...memories.map((memory) => `- ${renderMemoryLine(memory)}`),
|
|
181
|
+
];
|
|
182
|
+
return toolText(lines.join("\n"));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
this.api.registerTool({
|
|
187
|
+
name: "memory_labels",
|
|
188
|
+
description: "List existing ClawMem schema labels so the agent can reuse current kinds and topics before adding new ones.",
|
|
189
|
+
required: true,
|
|
190
|
+
parameters: {
|
|
191
|
+
type: "object",
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
properties: {
|
|
194
|
+
repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
|
|
195
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
196
|
+
limitTopics: { type: "integer", minimum: 1, maximum: 200, description: "Maximum number of topic labels to display." },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
execute: async (_id: string, params: unknown) => {
|
|
200
|
+
const p = asRecord(params);
|
|
201
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
202
|
+
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
203
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
204
|
+
const schema = await resolved.mem.listSchema();
|
|
205
|
+
const rawLimit = typeof p.limitTopics === "number" && Number.isFinite(p.limitTopics) ? Math.floor(p.limitTopics) : 50;
|
|
206
|
+
const limitTopics = Math.min(200, Math.max(1, rawLimit));
|
|
207
|
+
const kinds = schema.kinds.length > 0 ? schema.kinds.map((kind) => `- kind:${kind}`).join("\n") : "- None";
|
|
208
|
+
const topics = schema.topics.length > 0 ? schema.topics.slice(0, limitTopics).map((topic) => `- topic:${topic}`).join("\n") : "- None";
|
|
209
|
+
const extra = schema.topics.length > limitTopics ? `\n- ...and ${schema.topics.length - limitTopics} more topics` : "";
|
|
210
|
+
return toolText([
|
|
211
|
+
`Current ClawMem schema labels in ${resolved.route.repo}:`,
|
|
212
|
+
"",
|
|
213
|
+
"Kinds:",
|
|
214
|
+
kinds,
|
|
215
|
+
"",
|
|
216
|
+
"Topics:",
|
|
217
|
+
`${topics}${extra}`,
|
|
218
|
+
].join("\n"));
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
this.api.registerTool({
|
|
223
|
+
name: "memory_recall",
|
|
224
|
+
description: "Search ClawMem active memories for relevant prior facts, decisions, conventions, and lessons.",
|
|
225
|
+
required: true,
|
|
226
|
+
parameters: {
|
|
227
|
+
type: "object",
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
properties: {
|
|
230
|
+
query: { type: "string", minLength: 1, description: "What to recall from memory." },
|
|
231
|
+
limit: { type: "integer", minimum: 1, maximum: 20, description: "Maximum number of memories to return." },
|
|
232
|
+
repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
|
|
233
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
234
|
+
},
|
|
235
|
+
required: ["query"],
|
|
236
|
+
},
|
|
237
|
+
execute: async (_id: string, params: unknown) => {
|
|
238
|
+
const p = asRecord(params);
|
|
239
|
+
const query = typeof p.query === "string" ? p.query.trim() : "";
|
|
240
|
+
if (!query) return toolText("Query is empty.");
|
|
241
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
242
|
+
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
243
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
244
|
+
const rawLimit = typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.floor(p.limit) : this.config.memoryRecallLimit;
|
|
245
|
+
const limit = Math.min(20, Math.max(1, rawLimit));
|
|
246
|
+
const memories = await resolved.mem.search(query, limit);
|
|
247
|
+
if (memories.length === 0) return toolText(`No active memories matched "${query}" in ${resolved.route.repo}.`);
|
|
248
|
+
const text = [
|
|
249
|
+
`Found ${memories.length} active memor${memories.length === 1 ? "y" : "ies"} for "${query}" in ${resolved.route.repo}:`,
|
|
250
|
+
...memories.map((memory) => `- ${renderMemoryLine(memory)}`),
|
|
251
|
+
].join("\n");
|
|
252
|
+
return toolText(text);
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
this.api.registerTool({
|
|
257
|
+
name: "memory_get",
|
|
258
|
+
description: "Fetch one ClawMem memory by memory id or issue number so the agent can verify an exact record.",
|
|
259
|
+
required: true,
|
|
260
|
+
parameters: {
|
|
261
|
+
type: "object",
|
|
262
|
+
additionalProperties: false,
|
|
263
|
+
properties: {
|
|
264
|
+
memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to retrieve." },
|
|
265
|
+
status: { type: "string", enum: ["active", "stale", "all"], description: "Which status bucket to search. Defaults to all." },
|
|
266
|
+
repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
|
|
267
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
268
|
+
},
|
|
269
|
+
required: ["memoryId"],
|
|
270
|
+
},
|
|
271
|
+
execute: async (_id: string, params: unknown) => {
|
|
272
|
+
const p = asRecord(params);
|
|
273
|
+
const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
|
|
274
|
+
if (!memoryId) return toolText("memoryId is empty.");
|
|
275
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
276
|
+
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
277
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
278
|
+
const status = p.status === "active" || p.status === "stale" ? p.status : "all";
|
|
279
|
+
const memory = await resolved.mem.get(memoryId, status);
|
|
280
|
+
if (!memory) return toolText(`No ${status === "all" ? "" : `${status} `}memory matched id "${memoryId}" in ${resolved.route.repo}.`);
|
|
281
|
+
return toolText(`Repo: ${resolved.route.repo}\n${renderMemoryBlock(memory)}`);
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
this.api.registerTool({
|
|
286
|
+
name: "memory_store",
|
|
287
|
+
description: "Store a durable ClawMem memory immediately instead of waiting for session finalization.",
|
|
288
|
+
required: true,
|
|
289
|
+
parameters: {
|
|
290
|
+
type: "object",
|
|
291
|
+
additionalProperties: false,
|
|
292
|
+
properties: {
|
|
293
|
+
detail: { type: "string", minLength: 1, description: "The durable fact, lesson, decision, or preference to remember." },
|
|
294
|
+
kind: { type: "string", minLength: 1, description: "Optional schema kind, for example lesson, convention, skill, or task." },
|
|
295
|
+
topics: {
|
|
296
|
+
type: "array",
|
|
297
|
+
description: "Optional topic labels to improve future retrieval.",
|
|
298
|
+
items: { type: "string", minLength: 1 },
|
|
299
|
+
minItems: 1,
|
|
300
|
+
maxItems: 10,
|
|
301
|
+
},
|
|
302
|
+
repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
|
|
303
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
304
|
+
},
|
|
305
|
+
required: ["detail"],
|
|
306
|
+
},
|
|
307
|
+
execute: async (_id: string, params: unknown) => {
|
|
308
|
+
const p = asRecord(params);
|
|
309
|
+
const detail = typeof p.detail === "string" ? p.detail.trim() : "";
|
|
310
|
+
if (!detail) return toolText("Detail is empty.");
|
|
311
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
312
|
+
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
313
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
314
|
+
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
315
|
+
const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
|
|
316
|
+
const result = await resolved.mem.store({ detail, ...(kind ? { kind } : {}), ...(topics && topics.length > 0 ? { topics } : {}) });
|
|
317
|
+
if (!result.created) return toolText(`Memory already exists in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
|
|
318
|
+
return toolText(`Stored memory in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
this.api.registerTool({
|
|
323
|
+
name: "memory_update",
|
|
324
|
+
description: "Update an existing ClawMem memory in place when the same canonical fact or task has evolved.",
|
|
325
|
+
required: true,
|
|
326
|
+
parameters: {
|
|
327
|
+
type: "object",
|
|
328
|
+
additionalProperties: false,
|
|
329
|
+
properties: {
|
|
330
|
+
memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to update." },
|
|
331
|
+
detail: { type: "string", minLength: 1, description: "Optional replacement detail text for the same memory record." },
|
|
332
|
+
kind: { type: "string", minLength: 1, description: "Optional replacement kind label." },
|
|
333
|
+
topics: {
|
|
334
|
+
type: "array",
|
|
335
|
+
description: "Optional replacement topic labels.",
|
|
336
|
+
items: { type: "string", minLength: 1 },
|
|
337
|
+
minItems: 1,
|
|
338
|
+
maxItems: 10,
|
|
339
|
+
},
|
|
340
|
+
repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
|
|
341
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
342
|
+
},
|
|
343
|
+
required: ["memoryId"],
|
|
344
|
+
},
|
|
345
|
+
execute: async (_id: string, params: unknown) => {
|
|
346
|
+
const p = asRecord(params);
|
|
347
|
+
const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
|
|
348
|
+
if (!memoryId) return toolText("memoryId is empty.");
|
|
349
|
+
const detail = typeof p.detail === "string" && p.detail.trim() ? p.detail.trim() : undefined;
|
|
350
|
+
const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
|
|
351
|
+
const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
|
|
352
|
+
if (!detail && kind === undefined && topics === undefined) return toolText("Provide at least one of detail, kind, or topics.");
|
|
353
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
354
|
+
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
355
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
356
|
+
let updated;
|
|
357
|
+
try {
|
|
358
|
+
updated = await resolved.mem.update(memoryId, { ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return toolText(`Unable to update memory "${memoryId}": ${String(error)}`);
|
|
361
|
+
}
|
|
362
|
+
if (!updated) return toolText(`No memory matched id "${memoryId}" in ${resolved.route.repo}.`);
|
|
363
|
+
return toolText(`Updated memory in ${resolved.route.repo}.\n${renderMemoryBlock(updated)}`);
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
this.api.registerTool({
|
|
368
|
+
name: "memory_forget",
|
|
369
|
+
description: "Mark an active ClawMem memory as stale when it is superseded or no longer true.",
|
|
370
|
+
required: true,
|
|
371
|
+
parameters: {
|
|
372
|
+
type: "object",
|
|
373
|
+
additionalProperties: false,
|
|
374
|
+
properties: {
|
|
375
|
+
memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to mark stale." },
|
|
376
|
+
repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
|
|
377
|
+
agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
|
|
378
|
+
},
|
|
379
|
+
required: ["memoryId"],
|
|
380
|
+
},
|
|
381
|
+
execute: async (_id: string, params: unknown) => {
|
|
382
|
+
const p = asRecord(params);
|
|
383
|
+
const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
|
|
384
|
+
if (!memoryId) return toolText("memoryId is empty.");
|
|
385
|
+
const agentId = this.resolveToolAgentId(p.agentId);
|
|
386
|
+
const resolved = await this.requireToolRoute(agentId, p.repo);
|
|
387
|
+
if ("error" in resolved) return toolText(resolved.error);
|
|
388
|
+
const forgotten = await resolved.mem.forget(memoryId);
|
|
389
|
+
if (!forgotten) return toolText(`No active memory matched id "${memoryId}" in ${resolved.route.repo}.`);
|
|
390
|
+
return toolText(`Marked memory [${forgotten.memoryId}] stale in ${resolved.route.repo}: ${forgotten.detail}`);
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async handleBeforeAgentStart(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
|
|
66
396
|
const routeAgentId = normalizeAgentId(agentId);
|
|
67
|
-
if (!(await this.
|
|
397
|
+
if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
|
|
398
|
+
await this.runRequestMaintenance(routeAgentId);
|
|
399
|
+
if (typeof prompt !== "string" || prompt.trim().length < 5) return;
|
|
68
400
|
try {
|
|
69
401
|
const { mem } = this.getServices(routeAgentId);
|
|
70
402
|
const memories = await mem.search(prompt, this.config.memoryRecallLimit);
|
|
@@ -87,7 +419,7 @@ class ClawMemService {
|
|
|
87
419
|
}
|
|
88
420
|
const { conv } = this.getServices(agentId);
|
|
89
421
|
if (!conv.shouldMirror(snap.sessionId, snap.messages)) return;
|
|
90
|
-
if (!(await this.
|
|
422
|
+
if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
|
|
91
423
|
await this.enqueueSession(sessionScopeKey(snap.sessionId, agentId), async () => {
|
|
92
424
|
const s = this.getOrCreate(snap.sessionId!, agentId);
|
|
93
425
|
s.sessionFile = sessionFile;
|
|
@@ -113,7 +445,7 @@ class ClawMemService {
|
|
|
113
445
|
private async syncTurn(p: TurnPayload): Promise<void> {
|
|
114
446
|
if (!p.sessionId) return;
|
|
115
447
|
const agentId = normalizeAgentId(p.agentId);
|
|
116
|
-
if (!(await this.
|
|
448
|
+
if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
|
|
117
449
|
const { conv } = this.getServices(agentId);
|
|
118
450
|
const s = this.getOrCreate(p.sessionId, agentId);
|
|
119
451
|
s.sessionKey = p.sessionKey ?? s.sessionKey; s.agentId = agentId; s.updatedAt = new Date().toISOString();
|
|
@@ -137,8 +469,8 @@ class ClawMemService {
|
|
|
137
469
|
private async finalize(p: FinalizePayload): Promise<void> {
|
|
138
470
|
if (!p.sessionId) return;
|
|
139
471
|
const agentId = normalizeAgentId(p.agentId);
|
|
140
|
-
if (!(await this.
|
|
141
|
-
const { conv
|
|
472
|
+
if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
|
|
473
|
+
const { conv } = this.getServices(agentId);
|
|
142
474
|
const s = this.getOrCreate(p.sessionId, agentId);
|
|
143
475
|
if (s.finalizedAt) return;
|
|
144
476
|
s.sessionKey = p.sessionKey ?? s.sessionKey; s.sessionFile = p.sessionFile ?? s.sessionFile;
|
|
@@ -150,21 +482,11 @@ class ClawMemService {
|
|
|
150
482
|
const next = snap.messages.slice(s.lastMirroredCount);
|
|
151
483
|
let allOk = true;
|
|
152
484
|
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
|
-
let generatedTitle: string | undefined;
|
|
155
|
-
try {
|
|
156
|
-
const result = await conv.generateSummaryAndTitle(s, snap);
|
|
157
|
-
summary = result.summary;
|
|
158
|
-
generatedTitle = result.title;
|
|
159
|
-
} catch (e) { summary = `failed: ${String(e)}`; }
|
|
160
485
|
await conv.syncLabels(s, snap, true);
|
|
161
|
-
await conv.syncBody(s, snap,
|
|
162
|
-
|
|
486
|
+
await conv.syncBody(s, snap, "pending", true);
|
|
487
|
+
s.summaryStatus = "pending";
|
|
163
488
|
if (allOk) s.finalizedAt = new Date().toISOString();
|
|
164
489
|
await this.persistState();
|
|
165
|
-
|
|
166
|
-
// Auto-name the repo if it still has no description (first few conversations).
|
|
167
|
-
this.maybeAutoNameRepo(agentId, summary, generatedTitle);
|
|
168
490
|
}
|
|
169
491
|
|
|
170
492
|
// --- Infrastructure ---
|
|
@@ -220,7 +542,7 @@ class ClawMemService {
|
|
|
220
542
|
})();
|
|
221
543
|
return this.loadPromise;
|
|
222
544
|
}
|
|
223
|
-
private async
|
|
545
|
+
private async ensureIdentityConfigured(agentId?: string): Promise<boolean> {
|
|
224
546
|
const id = normalizeAgentId(agentId);
|
|
225
547
|
if (isAgentConfigured(resolveAgentRoute(this.config, id))) return true;
|
|
226
548
|
const pending = this.configPromises.get(id);
|
|
@@ -229,20 +551,72 @@ class ClawMemService {
|
|
|
229
551
|
this.configPromises.set(id, p);
|
|
230
552
|
try { return await p; } finally { if (this.configPromises.get(id) === p) this.configPromises.delete(id); }
|
|
231
553
|
}
|
|
554
|
+
private async ensureDefaultRepoConfigured(agentId?: string): Promise<boolean> {
|
|
555
|
+
const id = normalizeAgentId(agentId);
|
|
556
|
+
if (!(await this.ensureIdentityConfigured(id))) return false;
|
|
557
|
+
return hasDefaultRepo(resolveAgentRoute(this.config, id));
|
|
558
|
+
}
|
|
232
559
|
private async bootstrap(agentId: string): Promise<boolean> {
|
|
233
560
|
const route = resolveAgentRoute(this.config, agentId);
|
|
234
561
|
if (!route.baseUrl) { this.api.logger.warn(`clawmem: cannot provision Git credentials for ${agentId} without a baseUrl`); return false; }
|
|
235
562
|
try {
|
|
236
563
|
const client = new GitHubIssueClient(route, this.api.logger);
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
564
|
+
const bootstrap = await this.provisionAgentIdentity(client, agentId);
|
|
565
|
+
await this.persistAgentConfig(agentId, {
|
|
566
|
+
baseUrl: route.baseUrl,
|
|
567
|
+
authScheme: "token",
|
|
568
|
+
token: bootstrap.identity.token,
|
|
569
|
+
defaultRepo: bootstrap.identity.repo_full_name,
|
|
570
|
+
});
|
|
571
|
+
this.config.agents[agentId] = {
|
|
572
|
+
...(this.config.agents[agentId] ?? {}),
|
|
573
|
+
baseUrl: route.baseUrl,
|
|
574
|
+
authScheme: "token",
|
|
575
|
+
token: bootstrap.identity.token,
|
|
576
|
+
defaultRepo: bootstrap.identity.repo_full_name,
|
|
577
|
+
};
|
|
578
|
+
this.api.logger.info?.(
|
|
579
|
+
`clawmem: provisioned Git credentials for agent ${agentId} with default repo ${bootstrap.identity.repo_full_name} via ${route.baseUrl} (${bootstrap.method})`,
|
|
580
|
+
);
|
|
242
581
|
return true;
|
|
243
582
|
} catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials for agent ${agentId} via ${route.baseUrl}: ${String(error)}`); return false; }
|
|
244
583
|
}
|
|
245
|
-
private async
|
|
584
|
+
private async provisionAgentIdentity(client: GitHubIssueClient, agentId: string): Promise<{ identity: BootstrapIdentityResponse; method: string }> {
|
|
585
|
+
const registration = buildAgentBootstrapRegistration(agentId);
|
|
586
|
+
try {
|
|
587
|
+
const identity = await client.registerAgent(registration.prefixLogin, registration.defaultRepoName);
|
|
588
|
+
return { identity, method: "/api/v3/agents" };
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (!shouldFallbackToAnonymousBootstrap(error)) throw error;
|
|
591
|
+
this.api.logger.warn?.(`clawmem: /api/v3/agents is unavailable for agent ${agentId}; falling back to deprecated anonymous bootstrap`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const locale = Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.locale ?? "";
|
|
595
|
+
const identity = await client.createAnonymousSession(locale);
|
|
596
|
+
return { identity, method: "/api/v3/anonymous/session" };
|
|
597
|
+
}
|
|
598
|
+
private warnIfInactiveMemorySlot(): void {
|
|
599
|
+
try {
|
|
600
|
+
const root = this.api.runtime.config.loadConfig();
|
|
601
|
+
const plugins = asRecord(root.plugins);
|
|
602
|
+
const slots = asRecord(plugins.slots);
|
|
603
|
+
const slot = typeof slots.memory === "string" ? String(slots.memory).trim() : "";
|
|
604
|
+
if (!slot) {
|
|
605
|
+
this.api.logger.warn(
|
|
606
|
+
`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.`,
|
|
607
|
+
);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (slot !== this.api.id) {
|
|
611
|
+
this.api.logger.warn(
|
|
612
|
+
`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.`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
} catch (error) {
|
|
616
|
+
this.api.logger.warn(`clawmem: memory slot check failed: ${String(error)}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; defaultRepo: string }): Promise<void> {
|
|
246
620
|
const root = this.api.runtime.config.loadConfig();
|
|
247
621
|
const plugins = root.plugins;
|
|
248
622
|
const entries = plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) ? (plugins.entries as Record<string, unknown>) : {};
|
|
@@ -269,13 +643,98 @@ class ClawMemService {
|
|
|
269
643
|
},
|
|
270
644
|
});
|
|
271
645
|
}
|
|
272
|
-
private
|
|
273
|
-
const
|
|
646
|
+
private async runRequestMaintenance(agentId: string): Promise<void> {
|
|
647
|
+
const sessions = Object.values(this.state.sessions)
|
|
648
|
+
.filter((session) => normalizeAgentId(session.agentId) === agentId)
|
|
649
|
+
.sort((a, b) => Date.parse(b.updatedAt ?? b.createdAt ?? "") - Date.parse(a.updatedAt ?? a.createdAt ?? ""))
|
|
650
|
+
.slice(0, 8);
|
|
651
|
+
if (sessions.length === 0) return;
|
|
652
|
+
const { conv, mem } = this.getServices(agentId);
|
|
653
|
+
let changed = false;
|
|
654
|
+
let workDone = 0;
|
|
655
|
+
for (const session of sessions) {
|
|
656
|
+
if (workDone >= 3) break;
|
|
657
|
+
const snap = await conv.loadSnapshot(session, []);
|
|
658
|
+
if (!conv.shouldMirror(session.sessionId, snap.messages) || snap.messages.length === 0) continue;
|
|
659
|
+
if (!session.issueNumber) {
|
|
660
|
+
await conv.ensureIssue(session, snap);
|
|
661
|
+
changed = true;
|
|
662
|
+
}
|
|
663
|
+
if (session.summaryStatus === "pending") {
|
|
664
|
+
try {
|
|
665
|
+
const result = await conv.generateSummaryAndTitle(session, snap);
|
|
666
|
+
await conv.syncLabels(session, snap, true);
|
|
667
|
+
await conv.syncBody(session, snap, result.summary, true, result.title);
|
|
668
|
+
session.summaryStatus = "complete";
|
|
669
|
+
if (result.title?.trim()) {
|
|
670
|
+
session.issueTitle = result.title.trim();
|
|
671
|
+
session.titleSource = "llm";
|
|
672
|
+
}
|
|
673
|
+
this.maybeAutoNameRepo(agentId, result.summary, result.title);
|
|
674
|
+
changed = true;
|
|
675
|
+
workDone++;
|
|
676
|
+
} catch (error) {
|
|
677
|
+
this.warn(`request-scoped summary sync for ${session.sessionId}`, error);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (session.titleSource !== "llm" && snap.messages.length >= 2) {
|
|
681
|
+
await conv.syncTitle(session, snap);
|
|
682
|
+
changed = true;
|
|
683
|
+
workDone++;
|
|
684
|
+
}
|
|
685
|
+
if (snap.messages.length >= 2 && snap.messages.length > (session.lastMemorySyncCount ?? 0)) {
|
|
686
|
+
const ok = await mem.syncFromConversation(session, snap);
|
|
687
|
+
if (ok) {
|
|
688
|
+
session.lastMemorySyncCount = snap.messages.length;
|
|
689
|
+
changed = true;
|
|
690
|
+
}
|
|
691
|
+
workDone++;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (changed) await this.persistState();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private getServices(agentId?: string, repo?: string): { route: ClawMemResolvedRoute; conv: ConversationMirror; mem: MemoryStore; client: GitHubIssueClient } {
|
|
698
|
+
const route = resolveAgentRoute(this.config, agentId, repo);
|
|
699
|
+
const client = new GitHubIssueClient(route, this.api.logger);
|
|
274
700
|
return {
|
|
701
|
+
route,
|
|
702
|
+
client,
|
|
275
703
|
conv: new ConversationMirror(client, this.api, this.config),
|
|
276
704
|
mem: new MemoryStore(client, this.api, this.config),
|
|
277
705
|
};
|
|
278
706
|
}
|
|
707
|
+
private resolveToolAgentId(agentId: unknown): string {
|
|
708
|
+
return normalizeAgentId(typeof agentId === "string" && agentId.trim() ? agentId : process.env.OPENCLAW_AGENT_ID);
|
|
709
|
+
}
|
|
710
|
+
private resolveToolRepo(repo: unknown): { repo?: string; error?: string } {
|
|
711
|
+
if (repo === undefined || repo === null || repo === "") return {};
|
|
712
|
+
if (typeof repo !== "string") return { error: "repo must be a string like owner/repo." };
|
|
713
|
+
const trimmed = repo.trim().replace(/^\/+|\/+$/g, "");
|
|
714
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) return { error: `Invalid repo "${repo}". Expected owner/repo.` };
|
|
715
|
+
return { repo: trimmed };
|
|
716
|
+
}
|
|
717
|
+
private async requireToolIdentity(agentId: string): Promise<{ route: ClawMemResolvedRoute; client: GitHubIssueClient } | { error: string }> {
|
|
718
|
+
if (!(await this.ensureIdentityConfigured(agentId))) {
|
|
719
|
+
return { error: `ClawMem identity for agent "${agentId}" is not configured.` };
|
|
720
|
+
}
|
|
721
|
+
const { route, client } = this.getServices(agentId);
|
|
722
|
+
return { route, client };
|
|
723
|
+
}
|
|
724
|
+
private async requireToolRoute(agentId: string, repo: unknown): Promise<{ route: ClawMemResolvedRoute; conv: ConversationMirror; mem: MemoryStore; client: GitHubIssueClient } | { error: string }> {
|
|
725
|
+
const parsed = this.resolveToolRepo(repo);
|
|
726
|
+
if (parsed.error) return { error: parsed.error };
|
|
727
|
+
if (!(await this.ensureIdentityConfigured(agentId))) {
|
|
728
|
+
return { error: `ClawMem identity for agent "${agentId}" is not configured.` };
|
|
729
|
+
}
|
|
730
|
+
const services = this.getServices(agentId, parsed.repo);
|
|
731
|
+
if (!services.route.repo) {
|
|
732
|
+
return {
|
|
733
|
+
error: `No memory repo selected for agent "${agentId}". Provide repo explicitly or configure agents.${agentId}.defaultRepo.`,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return services;
|
|
737
|
+
}
|
|
279
738
|
/**
|
|
280
739
|
* After finalization, check if the repo still has an empty/default description.
|
|
281
740
|
* If so, use the conversation summary to suggest a meaningful name and update
|
|
@@ -302,5 +761,29 @@ class ClawMemService {
|
|
|
302
761
|
}
|
|
303
762
|
|
|
304
763
|
function asRecord(v: unknown): Record<string, unknown> { return v && typeof v === "object" ? (v as Record<string, unknown>) : {}; }
|
|
764
|
+
function shouldFallbackToAnonymousBootstrap(error: unknown): boolean {
|
|
765
|
+
const msg = String(error);
|
|
766
|
+
return /^Error:\s*HTTP (404|405|501):/i.test(msg) || /^HTTP (404|405|501):/i.test(msg);
|
|
767
|
+
}
|
|
768
|
+
function toolText(text: string): { content: Array<{ type: "text"; text: string }> } {
|
|
769
|
+
return { content: [{ type: "text", text }] };
|
|
770
|
+
}
|
|
771
|
+
function renderMemoryLine(memory: { memoryId: string; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale" }): string {
|
|
772
|
+
const schema = [memory.kind ? `kind:${memory.kind}` : "", ...(memory.topics ?? []).map((topic) => `topic:${topic}`)].filter(Boolean).join(", ");
|
|
773
|
+
return `[${memory.memoryId}] ${memory.title || "Memory"}${schema ? ` (${schema})` : ""}${memory.status === "stale" ? " [stale]" : ""}: ${memory.detail}`;
|
|
774
|
+
}
|
|
775
|
+
function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale"; date?: string }): string {
|
|
776
|
+
const lines = [
|
|
777
|
+
`Memory ID: ${memory.memoryId}`,
|
|
778
|
+
...(typeof memory.issueNumber === "number" ? [`Issue Number: ${memory.issueNumber}`] : []),
|
|
779
|
+
`Status: ${memory.status}`,
|
|
780
|
+
`Title: ${memory.title || "Memory"}`,
|
|
781
|
+
...(memory.kind ? [`Kind: ${memory.kind}`] : []),
|
|
782
|
+
...(memory.topics && memory.topics.length > 0 ? [`Topics: ${memory.topics.join(", ")}`] : []),
|
|
783
|
+
...(memory.date ? [`Date: ${memory.date}`] : []),
|
|
784
|
+
`Detail: ${memory.detail}`,
|
|
785
|
+
];
|
|
786
|
+
return lines.join("\n");
|
|
787
|
+
}
|
|
305
788
|
|
|
306
789
|
export function createClawMemPlugin(api: OpenClawPluginApi): void { new ClawMemService(api).register(); }
|