@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/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.handleRecall(ev.prompt, ctx.agentId));
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
- return isAgentConfigured(resolveAgentRoute(this.config, agentId));
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 async handleRecall(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
65
- if (typeof prompt !== "string" || prompt.trim().length < 5) return;
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.ensureConfigured(routeAgentId))) return;
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.ensureConfigured(agentId))) return;
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.ensureConfigured(agentId))) return;
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.ensureConfigured(agentId))) return;
141
- const { conv, mem } = this.getServices(agentId);
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, summary, true, generatedTitle);
162
- await mem.syncFromConversation(s, snap);
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 ensureConfigured(agentId?: string): Promise<boolean> {
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 locale = Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.locale ?? "";
238
- const sess = await client.createAnonymousSession(locale);
239
- await this.persistAgentConfig(agentId, { baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
240
- this.config.agents[agentId] = { ...(this.config.agents[agentId] ?? {}), baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name };
241
- this.api.logger.info?.(`clawmem: provisioned Git credentials for agent ${agentId} -> ${sess.repo_full_name} via ${route.baseUrl}`);
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 persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; repo: string }): Promise<void> {
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 getServices(agentId?: string): { conv: ConversationMirror; mem: MemoryStore } {
273
- const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
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(); }