@clawmem-ai/clawmem 0.1.9 → 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 CHANGED
@@ -40,7 +40,7 @@ openclaw config validate
40
40
  openclaw gateway restart
41
41
  ```
42
42
 
43
- After restart, confirm OpenClaw shows ClawMem as the active memory plugin. clawmem then provisions per-agent memory repos on `git.clawmem.ai` as each agent is first used, then writes that agent's `token` + `repo` back into your config under `plugins.entries.clawmem.config.agents.<agentId>`. Memories start accumulating from that agent's next session.
43
+ After restart, confirm OpenClaw shows ClawMem as the active memory plugin. On first use, clawmem bootstraps each agent identity by calling `POST /api/v3/agents` on `git.clawmem.ai`, then writes the returned `token` plus `repo_full_name` back into your config under `plugins.entries.clawmem.config.agents.<agentId>` as that agent's `defaultRepo`. Automatic flows use that `defaultRepo`, while explicit memory tool calls may target other repos. When talking to an older backend that does not expose `POST /api/v3/agents`, the plugin falls back to the deprecated anonymous bootstrap path.
44
44
 
45
45
  ---
46
46
 
@@ -274,21 +274,15 @@ Full config with all options:
274
274
  agents: {
275
275
  main: {
276
276
  baseUrl: "https://git.clawmem.ai/api/v3",
277
- repo: "owner/main-memory",
277
+ defaultRepo: "owner/main-memory",
278
278
  token: "<token>",
279
279
  authScheme: "token"
280
280
  },
281
281
  coder: {
282
- repo: "owner/coder-memory",
282
+ defaultRepo: "owner/coder-memory",
283
283
  token: "<token>"
284
284
  }
285
285
  },
286
- issueTitlePrefix: "Session: ",
287
- memoryTitlePrefix: "Memory: ",
288
- defaultLabels: ["source:openclaw"],
289
- agentLabelPrefix: "agent:",
290
- autoCreateLabels: true,
291
- closeIssueOnReset: true,
292
286
  turnCommentDelayMs: 1000,
293
287
  summaryWaitTimeoutMs: 120000,
294
288
  memoryRecallLimit: 5
@@ -308,8 +302,10 @@ Full config with all options:
308
302
  - Memory search and auto-injection only return open `type:memory` issues. Closed memory issues are treated as stale.
309
303
  - `memory_recall` now prefers the backend `/api/v3/search/issues` endpoint scoped to the current repo plus `label:"type:memory"`; if backend search fails, clawmem falls back to local lexical ranking.
310
304
  - Durable memories are extracted best-effort during later request-scoped maintenance, not by background subagent work after a request has already ended.
311
- - The plugin exposes `memory_list`, `memory_get`, `memory_labels`, `memory_recall`, `memory_store`, `memory_update`, and `memory_forget` for mid-session use.
305
+ - The plugin exposes `memory_repos`, `memory_repo_create`, `memory_list`, `memory_get`, `memory_labels`, `memory_recall`, `memory_store`, `memory_update`, and `memory_forget` for mid-session use.
306
+ - Route resolution is now: agent identity supplies credentials, `defaultRepo` is the fallback memory space, and explicit tool calls may override repo per operation.
312
307
  - `memory_store` accepts optional schema hints such as kind and topics; the plugin normalizes them into managed `kind:*` and `topic:*` labels.
308
+ - Memory issues no longer use `session:*` labels. Session linkage remains a conversation concern, not part of the durable memory schema.
313
309
  - `memory_update` updates one existing memory issue in place; use it for evolving canonical facts or active tasks instead of creating a duplicate node.
314
310
  - Conversation lifecycle is stored in native issue state (`open` while live, `closed` after finalize); memory lifecycle uses native issue state too (`open` active, `closed` stale).
315
311
  - Memory issue bodies store the durable detail plus flat metadata such as `memory_hash` and logical `date`; labels are reserved for schema and routing.
@@ -1,18 +1,22 @@
1
1
  {
2
2
  "id": "clawmem",
3
3
  "name": "ClawMem",
4
- "version": "0.1.9",
4
+ "version": "0.1.10",
5
5
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
6
6
  "kind": "memory",
7
7
  "configSchema": {
8
8
  "type": "object",
9
- "additionalProperties": false,
9
+ "additionalProperties": true,
10
10
  "properties": {
11
11
  "baseUrl": {
12
12
  "type": "string",
13
13
  "minLength": 1,
14
14
  "default": "https://git.clawmem.ai"
15
15
  },
16
+ "defaultRepo": {
17
+ "type": "string",
18
+ "pattern": "^[^/]+/[^/]+$"
19
+ },
16
20
  "repo": {
17
21
  "type": "string",
18
22
  "pattern": "^[^/]+/[^/]+$"
@@ -36,6 +40,10 @@
36
40
  "type": "string",
37
41
  "minLength": 1
38
42
  },
43
+ "defaultRepo": {
44
+ "type": "string",
45
+ "pattern": "^[^/]+/[^/]+$"
46
+ },
39
47
  "repo": {
40
48
  "type": "string",
41
49
  "pattern": "^[^/]+/[^/]+$"
@@ -51,40 +59,6 @@
51
59
  }
52
60
  }
53
61
  },
54
- "issueTitlePrefix": {
55
- "type": "string"
56
- },
57
- "memoryTitlePrefix": {
58
- "type": "string"
59
- },
60
- "defaultLabels": {
61
- "type": "array",
62
- "items": {
63
- "type": "string"
64
- },
65
- "default": ["source:openclaw"]
66
- },
67
- "agentLabelPrefix": {
68
- "type": "string"
69
- },
70
- "activeStatusLabel": {
71
- "type": "string"
72
- },
73
- "closedStatusLabel": {
74
- "type": "string"
75
- },
76
- "memoryActiveStatusLabel": {
77
- "type": "string"
78
- },
79
- "memoryStaleStatusLabel": {
80
- "type": "string"
81
- },
82
- "autoCreateLabels": {
83
- "type": "boolean"
84
- },
85
- "closeIssueOnReset": {
86
- "type": "boolean"
87
- },
88
62
  "turnCommentDelayMs": {
89
63
  "type": "integer",
90
64
  "minimum": 0,
@@ -99,15 +73,6 @@
99
73
  "type": "integer",
100
74
  "minimum": 1,
101
75
  "maximum": 20
102
- },
103
- "labelColor": {
104
- "type": "string",
105
- "pattern": "^#?[0-9a-fA-F]{6}$"
106
- },
107
- "maxExcerptChars": {
108
- "type": "integer",
109
- "minimum": 120,
110
- "maximum": 4000
111
76
  }
112
77
  }
113
78
  },
@@ -117,10 +82,15 @@
117
82
  "placeholder": "https://git.clawmem.ai",
118
83
  "help": "GitHub-compatible API base URL. Root URLs are normalized to /api/v3 automatically."
119
84
  },
85
+ "defaultRepo": {
86
+ "label": "Default Repo",
87
+ "placeholder": "owner/repo",
88
+ "help": "Default memory repo for automatic flows and for tool calls that do not specify repo explicitly."
89
+ },
120
90
  "repo": {
121
91
  "label": "Repository",
122
92
  "placeholder": "owner/repo",
123
- "help": "Legacy single-route setting. New installs should use per-agent routes under agents.<agentId>."
93
+ "help": "Legacy alias for defaultRepo. New installs should use defaultRepo or per-agent defaultRepo under agents.<agentId>."
124
94
  },
125
95
  "token": {
126
96
  "label": "API Token",
@@ -133,41 +103,7 @@
133
103
  },
134
104
  "agents": {
135
105
  "label": "Agent Routes",
136
- "help": "Per-agent ClawMem credentials keyed by agent id. Missing repo/token are provisioned automatically on first use."
137
- },
138
- "defaultLabels": {
139
- "label": "Default Labels",
140
- "help": "Labels added to every new session issue. Good place for topic:* tags."
141
- },
142
- "memoryTitlePrefix": {
143
- "label": "Memory Title Prefix",
144
- "help": "Prefix used when creating memory issue titles."
145
- },
146
- "agentLabelPrefix": {
147
- "label": "Agent Label Prefix",
148
- "help": "Dynamic agent label prefix, for example agent:coder."
149
- },
150
- "activeStatusLabel": {
151
- "label": "Active Status Label",
152
- "help": "Deprecated compatibility setting. Conversation lifecycle now uses native issue state."
153
- },
154
- "closedStatusLabel": {
155
- "label": "Closed Status Label",
156
- "help": "Deprecated compatibility setting. Conversation lifecycle now uses native issue state."
157
- },
158
- "memoryActiveStatusLabel": {
159
- "label": "Memory Active Status Label",
160
- "help": "Deprecated compatibility setting. Memory lifecycle now uses native issue state."
161
- },
162
- "memoryStaleStatusLabel": {
163
- "label": "Memory Stale Status Label",
164
- "help": "Deprecated compatibility setting. Memory lifecycle now uses native issue state."
165
- },
166
- "autoCreateLabels": {
167
- "label": "Auto Create Labels"
168
- },
169
- "closeIssueOnReset": {
170
- "label": "Close On Reset"
106
+ "help": "Per-agent ClawMem identities keyed by agent id. Each identity has credentials plus an optional defaultRepo."
171
107
  },
172
108
  "turnCommentDelayMs": {
173
109
  "label": "Turn Sync Delay (ms)",
@@ -180,14 +116,6 @@
180
116
  "memoryRecallLimit": {
181
117
  "label": "Memory Recall Limit",
182
118
  "help": "Maximum number of active memories injected into context before an agent starts."
183
- },
184
- "labelColor": {
185
- "label": "Fallback Label Color",
186
- "placeholder": "0e8a16"
187
- },
188
- "maxExcerptChars": {
189
- "label": "Max Excerpt Chars",
190
- "help": "Soft cap used when rendering summaries and comment sections."
191
119
  }
192
120
  }
193
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawmem-ai/clawmem",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,82 @@
1
+ import { hasDefaultRepo, isAgentConfigured, resolveAgentRoute } from "./config.js";
2
+ import type { ClawMemPluginConfig } from "./types.js";
3
+ import { buildAgentBootstrapRegistration, DEFAULT_BOOTSTRAP_REPO_NAME } from "./utils.js";
4
+
5
+ function assert(condition: unknown, message: string): void {
6
+ if (!condition) throw new Error(message);
7
+ }
8
+
9
+ function baseConfig(): ClawMemPluginConfig {
10
+ return {
11
+ baseUrl: "https://git.clawmem.ai/api/v3",
12
+ authScheme: "token",
13
+ token: "top-token",
14
+ defaultRepo: "global/default-memory",
15
+ repo: "global/legacy-memory",
16
+ agents: {
17
+ main: {
18
+ token: "agent-token",
19
+ defaultRepo: "main/private-memory",
20
+ },
21
+ legacy: {
22
+ token: "legacy-token",
23
+ repo: "legacy/old-memory",
24
+ },
25
+ identityOnly: {
26
+ token: "identity-token",
27
+ },
28
+ },
29
+ memoryRecallLimit: 5,
30
+ turnCommentDelayMs: 1000,
31
+ summaryWaitTimeoutMs: 120000,
32
+ };
33
+ }
34
+
35
+ function testDefaultRepoResolution(): void {
36
+ const route = resolveAgentRoute(baseConfig(), "main");
37
+ assert(route.defaultRepo === "main/private-memory", "expected per-agent defaultRepo to be preferred");
38
+ assert(route.repo === "main/private-memory", "expected selected repo to default to defaultRepo");
39
+ assert(route.token === "agent-token", "expected per-agent token to be preferred");
40
+ }
41
+
42
+ function testRepoOverride(): void {
43
+ const route = resolveAgentRoute(baseConfig(), "main", "org/shared-memory");
44
+ assert(route.defaultRepo === "main/private-memory", "expected defaultRepo to remain unchanged");
45
+ assert(route.repo === "org/shared-memory", "expected explicit repo override to win");
46
+ }
47
+
48
+ function testLegacyRepoFallback(): void {
49
+ const route = resolveAgentRoute(baseConfig(), "legacy");
50
+ assert(route.defaultRepo === "legacy/old-memory", "expected legacy repo to act as defaultRepo fallback");
51
+ assert(route.repo === "legacy/old-memory", "expected selected repo to use the legacy repo fallback");
52
+ }
53
+
54
+ function testIdentityOnlyStillConfigured(): void {
55
+ const config = baseConfig();
56
+ delete config.defaultRepo;
57
+ delete config.repo;
58
+ const route = resolveAgentRoute(config, "identityOnly");
59
+ assert(isAgentConfigured(route) === true, "expected an identity with baseUrl and token to count as configured");
60
+ assert(hasDefaultRepo(route) === false, "expected no default repo when only credentials are present");
61
+ }
62
+
63
+ function testBootstrapRegistrationUsesStableDefaults(): void {
64
+ const registration = buildAgentBootstrapRegistration("Main_Coder");
65
+ assert(registration.prefixLogin === "main-coder", "expected agent bootstrap login prefix to match backend format");
66
+ assert(registration.defaultRepoName === DEFAULT_BOOTSTRAP_REPO_NAME, "expected bootstrap repo name to use the stable default");
67
+ }
68
+
69
+ function testBootstrapRegistrationTrimsLongPrefixes(): void {
70
+ const registration = buildAgentBootstrapRegistration("___THIS_IS_A_SUPER_LONG_AGENT_ID_THAT_SHOULD_BE_TRIMMED___");
71
+ assert(/^[a-z0-9][a-z0-9-]*$/.test(registration.prefixLogin), "expected bootstrap login prefix to satisfy backend validation");
72
+ assert(registration.prefixLogin.length <= 32, "expected bootstrap login prefix to fit backend max length");
73
+ }
74
+
75
+ testDefaultRepoResolution();
76
+ testRepoOverride();
77
+ testLegacyRepoFallback();
78
+ testIdentityOnlyStillConfigured();
79
+ testBootstrapRegistrationUsesStableDefaults();
80
+ testBootstrapRegistrationTrimsLongPrefixes();
81
+
82
+ console.log("config tests passed");
package/src/config.ts CHANGED
@@ -5,7 +5,7 @@ import { normalizeAgentId } from "./utils.js";
5
5
 
6
6
  export const SESSION_TITLE_PREFIX = "Session: ";
7
7
  export const MEMORY_TITLE_PREFIX = "Memory: ";
8
- export const DEFAULT_LABELS: readonly string[] = ["source:openclaw"];
8
+ export const DEFAULT_LABELS: readonly string[] = [];
9
9
  export const AGENT_LABEL_PREFIX = "agent:";
10
10
  export const LABEL_ACTIVE = "status:active";
11
11
  export const LABEL_CLOSED = "status:closed";
@@ -31,6 +31,7 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
31
31
  const agent = rawAgentConfig as Record<string, unknown>;
32
32
  agents[agentId] = {
33
33
  baseUrl: str(agent.baseUrl)?.replace(/\/+$/, ""),
34
+ defaultRepo: normalizeRepoName(str(agent.defaultRepo) ?? str(agent.repo)),
34
35
  repo: str(agent.repo),
35
36
  token: str(agent.token),
36
37
  authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : undefined,
@@ -38,6 +39,9 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
38
39
  }
39
40
  return {
40
41
  baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
42
+ defaultRepo: normalizeRepoName(str(raw.defaultRepo) ?? str(raw.repo)),
43
+ repo: normalizeRepoName(str(raw.repo)),
44
+ token: str(raw.token),
41
45
  authScheme: raw.authScheme === "bearer" ? "bearer" : "token",
42
46
  agents,
43
47
  memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
@@ -46,21 +50,28 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
46
50
  };
47
51
  }
48
52
 
49
- export function resolveAgentRoute(config: ClawMemPluginConfig, agentId?: string): ClawMemResolvedRoute {
53
+ export function resolveAgentRoute(config: ClawMemPluginConfig, agentId?: string, repoOverride?: string): ClawMemResolvedRoute {
50
54
  const id = normalizeAgentId(agentId);
51
55
  const agent = config.agents[id] ?? {};
52
56
  const baseUrl = (agent.baseUrl ?? config.baseUrl).replace(/\/+$/, "");
57
+ const defaultRepo = normalizeRepoName(agent.defaultRepo ?? agent.repo) ?? config.defaultRepo ?? normalizeRepoName(config.repo);
58
+ const repo = normalizeRepoName(repoOverride) ?? defaultRepo;
53
59
  return {
54
60
  agentId: id,
55
61
  baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
56
- repo: agent.repo?.trim() || undefined,
57
- token: agent.token?.trim() || undefined,
58
- authScheme: agent.authScheme === "bearer" ? "bearer" : config.authScheme,
62
+ ...(defaultRepo ? { defaultRepo } : {}),
63
+ ...(repo ? { repo } : {}),
64
+ token: agent.token?.trim() || config.token?.trim() || undefined,
65
+ authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : config.authScheme,
59
66
  };
60
67
  }
61
68
 
62
69
  export function isAgentConfigured(route: ClawMemResolvedRoute): boolean {
63
- return Boolean(route.baseUrl && route.repo && route.token);
70
+ return Boolean(route.baseUrl && route.token);
71
+ }
72
+
73
+ export function hasDefaultRepo(route: ClawMemResolvedRoute): boolean {
74
+ return Boolean(route.defaultRepo);
64
75
  }
65
76
 
66
77
  export function resolveLabelColor(label: string): string {
@@ -97,3 +108,9 @@ export function labelVal(labels: string[], prefix: string): string | undefined {
97
108
  const m = labels.find((l) => l.startsWith(prefix));
98
109
  return m ? m.slice(prefix.length).trim() || undefined : undefined;
99
110
  }
111
+
112
+ function normalizeRepoName(value: string | undefined): string | undefined {
113
+ if (!value) return undefined;
114
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, "");
115
+ return /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : undefined;
116
+ }
@@ -1,11 +1,12 @@
1
1
  // GitHub Issues API client for clawmem. No label caching — idempotent create-if-absent.
2
2
  import { resolveLabelColor, labelDescription, extractLabelNames, isManagedLabel } from "./config.js";
3
- import type { AnonymousSessionResponse, ClawMemResolvedRoute } from "./types.js";
3
+ import type { AgentRegistrationResponse, AnonymousSessionResponse, ClawMemResolvedRoute } from "./types.js";
4
4
 
5
5
  type IssueResponse = { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> };
6
6
  type SearchIssuesResponse = { items?: IssueResponse[]; total_count?: number; incomplete_results?: boolean };
7
7
  type CommentResponse = { id?: number; body?: string; created_at?: string };
8
8
  type LabelResponse = { name?: string; color?: string; description?: string };
9
+ type RepoResponse = { name?: string; full_name?: string; description?: string; private?: boolean; owner?: { login?: string } };
9
10
  type ReqOpts = { allowNotFound?: boolean; allowValidationError?: boolean; omitAuth?: boolean };
10
11
 
11
12
  export class GitHubIssueClient {
@@ -14,6 +15,9 @@ export class GitHubIssueClient {
14
15
  repo(): string | undefined {
15
16
  return this.config.repo?.trim() || undefined;
16
17
  }
18
+ defaultRepo(): string | undefined {
19
+ return this.config.defaultRepo?.trim() || undefined;
20
+ }
17
21
 
18
22
  async createIssue(params: { title: string; body: string; labels: string[] }): Promise<IssueResponse> {
19
23
  return this.req<IssueResponse>(this.repoPath("issues"), { method: "POST", body: JSON.stringify(params) });
@@ -53,6 +57,20 @@ export class GitHubIssueClient {
53
57
  q.set("per_page", String(params?.perPage ?? 100));
54
58
  return this.req<LabelResponse[]>(`${this.repoPath("labels")}?${q}`, { method: "GET" });
55
59
  }
60
+ async listUserRepos(): Promise<RepoResponse[]> {
61
+ return this.req<RepoResponse[]>("user/repos", { method: "GET" });
62
+ }
63
+ async createUserRepo(params: { name: string; description?: string; private?: boolean; autoInit?: boolean }): Promise<RepoResponse> {
64
+ return this.req<RepoResponse>("user/repos", {
65
+ method: "POST",
66
+ body: JSON.stringify({
67
+ name: params.name,
68
+ ...(params.description ? { description: params.description } : {}),
69
+ private: params.private ?? true,
70
+ auto_init: params.autoInit ?? false,
71
+ }),
72
+ });
73
+ }
56
74
  async ensureLabels(labels: string[]): Promise<void> {
57
75
  for (const label of labels) {
58
76
  if (!label.trim()) continue;
@@ -71,6 +89,15 @@ export class GitHubIssueClient {
71
89
  async updateRepoDescription(description: string): Promise<void> {
72
90
  await this.req(this.repoPath("").replace(/\/$/, ""), { method: "PATCH", body: JSON.stringify({ description }) });
73
91
  }
92
+ async registerAgent(prefixLogin: string, defaultRepoName: string): Promise<AgentRegistrationResponse> {
93
+ return this.req<AgentRegistrationResponse>("agents", {
94
+ method: "POST",
95
+ body: JSON.stringify({
96
+ prefix_login: prefixLogin,
97
+ default_repo_name: defaultRepoName,
98
+ }),
99
+ }, { omitAuth: true });
100
+ }
74
101
  async createAnonymousSession(locale?: string): Promise<AnonymousSessionResponse> {
75
102
  const body = locale ? JSON.stringify({ locale }) : undefined;
76
103
  return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST", ...(body ? { body } : {}) }, { omitAuth: true });
@@ -7,7 +7,6 @@ function memory(overrides: Partial<ParsedMemoryIssue> = {}): ParsedMemoryIssue {
7
7
  issueNumber: overrides.issueNumber ?? 1,
8
8
  title: overrides.title ?? "Memory: Example",
9
9
  memoryId: overrides.memoryId ?? String(overrides.issueNumber ?? 1),
10
- sessionId: overrides.sessionId ?? "sess-1",
11
10
  date: overrides.date ?? "2026-03-23",
12
11
  detail: overrides.detail ?? "Example durable detail",
13
12
  status: overrides.status ?? "active",
@@ -32,7 +31,6 @@ function issueFromMemory(m: ParsedMemoryIssue): IssueRecord {
32
31
  state: m.status === "stale" ? "closed" : "open",
33
32
  labels: [
34
33
  "type:memory",
35
- `session:${m.sessionId}`,
36
34
  ...(m.kind ? [`kind:${m.kind}`] : []),
37
35
  ...(m.topics ?? []).map((topic) => `topic:${topic}`),
38
36
  ],
@@ -158,7 +156,7 @@ async function testStructuredStoreAndSchema(): Promise<void> {
158
156
  },
159
157
  };
160
158
  const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
161
- const result = await store.store({ detail: "Redis Lua scripts are required for atomic rate limiting.", kind: "Lesson", topics: ["Redis Ops", "rate_limit"] }, "manual");
159
+ const result = await store.store({ detail: "Redis Lua scripts are required for atomic rate limiting.", kind: "Lesson", topics: ["Redis Ops", "rate_limit"] });
162
160
  const schema = await store.listSchema();
163
161
 
164
162
  assert(result.created === true, "expected a new structured memory to be created");
@@ -168,6 +166,7 @@ async function testStructuredStoreAndSchema(): Promise<void> {
168
166
  assert(created[0]?.labels.includes("kind:lesson"), "expected created labels to include normalized kind");
169
167
  assert(created[0]?.labels.includes("topic:redis-ops"), "expected created labels to include normalized topic");
170
168
  assert(created[0]?.labels.includes("topic:rate-limit"), "expected created labels to include normalized topic");
169
+ assert(!created[0]?.labels.some((label) => label.startsWith("session:")), "expected manual memory_store writes to omit synthetic session labels");
171
170
  assert(!created[0]?.labels.some((label) => label.startsWith("date:")), "expected new memory labels to omit date labels");
172
171
  assert(created[0]?.body.includes(`date: ${result.memory.date}`), "expected new memory body to retain logical date metadata");
173
172
  assert(ensured[0]?.includes("kind:lesson"), "expected ensureLabels to include kind label");
@@ -249,7 +248,6 @@ async function testLegacyMemoriesWithoutSessionOrDate(): Promise<void> {
249
248
  const recalled = await store.search("F1 Dota 2", 5);
250
249
 
251
250
  assert(exact?.issueNumber === 4, "expected legacy memory without session/date to be readable");
252
- assert(exact?.sessionId === "legacy", "expected missing session label to fall back to legacy");
253
251
  assert(exact?.date === "1970-01-01", "expected missing date label to fall back to a placeholder");
254
252
  assert(recalled.some((memory) => memory.issueNumber === 4), "expected legacy memory to participate in recall");
255
253
  }
package/src/memory.ts CHANGED
@@ -71,7 +71,7 @@ export class MemoryStore {
71
71
  .slice(0, limit);
72
72
  }
73
73
 
74
- async store(draft: MemoryDraft, sessionId = "manual"): Promise<{ created: boolean; memory: ParsedMemoryIssue }> {
74
+ async store(draft: MemoryDraft): Promise<{ created: boolean; memory: ParsedMemoryIssue }> {
75
75
  const normalized = normalizeDraft(draft);
76
76
  const detail = norm(normalized.detail);
77
77
  const allActive = await this.listByStatus("active");
@@ -83,7 +83,7 @@ export class MemoryStore {
83
83
  }
84
84
 
85
85
  const date = localDate();
86
- const labels = memLabels(sessionId, normalized.kind, normalized.topics);
86
+ const labels = memLabels(normalized.kind, normalized.topics);
87
87
  const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
88
88
  const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
89
89
  await this.client.ensureLabels(labels);
@@ -95,7 +95,6 @@ export class MemoryStore {
95
95
  title,
96
96
  memoryId: String(issue.number),
97
97
  memoryHash: hash,
98
- sessionId,
99
98
  date,
100
99
  detail,
101
100
  ...(normalized.kind ? { kind: normalized.kind } : {}),
@@ -121,7 +120,7 @@ export class MemoryStore {
121
120
  if (duplicate) throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
122
121
  const nextTitle = `${MEMORY_TITLE_PREFIX}${trunc(nextDetail, 72)}`;
123
122
  const nextBody = stringifyFlatYaml([["memory_hash", nextHash], ["date", current.date], ["detail", nextDetail]]);
124
- const nextLabels = memLabels(current.sessionId, nextKind, nextTopics);
123
+ const nextLabels = memLabels(nextKind, nextTopics);
125
124
  await this.client.ensureLabels(nextLabels);
126
125
  await this.client.updateIssue(current.issueNumber, { title: nextTitle, body: nextBody });
127
126
  await this.client.syncManagedLabels(current.issueNumber, nextLabels);
@@ -140,7 +139,7 @@ export class MemoryStore {
140
139
  if (!id) throw new Error("memoryId is empty");
141
140
  const mem = await this.get(id, "active");
142
141
  if (!mem) return null;
143
- await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.sessionId, mem.kind, mem.topics));
142
+ await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.kind, mem.topics));
144
143
  await this.client.updateIssue(mem.issueNumber, { state: "closed" });
145
144
  return { ...mem, status: "stale" };
146
145
  }
@@ -148,7 +147,7 @@ export class MemoryStore {
148
147
  async syncFromConversation(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<boolean> {
149
148
  try {
150
149
  const decision = await this.generateDecision(session, snapshot);
151
- const { savedCount, staledCount } = await this.applyDecision(session.sessionId, decision);
150
+ const { savedCount, staledCount } = await this.applyDecision(decision);
152
151
  if (savedCount > 0 || staledCount > 0)
153
152
  this.api.logger.info?.(`clawmem: synced memories for ${session.sessionId} (saved=${savedCount}, stale=${staledCount})`);
154
153
  return true;
@@ -199,7 +198,7 @@ export class MemoryStore {
199
198
  private parseIssue(issue: { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> }): ParsedMemoryIssue | null {
200
199
  const labels = extractLabelNames(issue.labels);
201
200
  if (!labels.includes("type:memory")) return null;
202
- const sessionId = labelVal(labels, "session:"), kind = labelVal(labels, "kind:");
201
+ const kind = labelVal(labels, "kind:");
203
202
  const topics = labels.filter((l) => l.startsWith("topic:")).map((l) => l.slice(6).trim()).filter(Boolean);
204
203
  const rawBody = (issue.body ?? "").trim();
205
204
  const body = rawBody ? parseFlatYaml(rawBody) : {};
@@ -210,7 +209,6 @@ export class MemoryStore {
210
209
  issueNumber: issue.number, title: issue.title?.trim() || "",
211
210
  memoryId: body.memory_id?.trim() || String(issue.number),
212
211
  memoryHash: body.memory_hash?.trim() || undefined,
213
- sessionId: sessionId || "legacy",
214
212
  date: body.date?.trim() || "1970-01-01",
215
213
  detail,
216
214
  ...(kind ? { kind } : {}),
@@ -219,7 +217,7 @@ export class MemoryStore {
219
217
  };
220
218
  }
221
219
 
222
- private async applyDecision(sessionId: string, decision: MemoryDecision): Promise<{ savedCount: number; staledCount: number }> {
220
+ private async applyDecision(decision: MemoryDecision): Promise<{ savedCount: number; staledCount: number }> {
223
221
  const allActive = await this.listByStatus("active");
224
222
  const activeById = new Map(allActive.map((m) => [m.memoryId, m]));
225
223
  const activeByHash = new Map(allActive.map((m) => [m.memoryHash || sha256(norm(m.detail)), m]));
@@ -235,7 +233,7 @@ export class MemoryStore {
235
233
  activeByHash.set(hash, merged);
236
234
  continue;
237
235
  }
238
- const labels = memLabels(sessionId, draft.kind, draft.topics);
236
+ const labels = memLabels(draft.kind, draft.topics);
239
237
  const date = localDate();
240
238
  const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
241
239
  const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
@@ -246,7 +244,6 @@ export class MemoryStore {
246
244
  title,
247
245
  memoryId: String(issue.number),
248
246
  memoryHash: hash,
249
- sessionId,
250
247
  date,
251
248
  detail,
252
249
  ...(draft.kind ? { kind: draft.kind } : {}),
@@ -259,7 +256,7 @@ export class MemoryStore {
259
256
  for (const id of [...new Set(decision.stale.map((s) => s.trim()).filter(Boolean))]) {
260
257
  const mem = activeById.get(id);
261
258
  if (!mem) continue;
262
- await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.sessionId, mem.kind, mem.topics));
259
+ await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.kind, mem.topics));
263
260
  await this.client.updateIssue(mem.issueNumber, { state: "closed" });
264
261
  staledCount++;
265
262
  }
@@ -318,7 +315,7 @@ export class MemoryStore {
318
315
  const sameKind = (memory.kind ?? "") === (nextKind ?? "");
319
316
  const sameTopics = JSON.stringify(currentTopics) === JSON.stringify(nextTopics);
320
317
  if (sameKind && sameTopics) return memory;
321
- const labels = memLabels(memory.sessionId, nextKind, nextTopics);
318
+ const labels = memLabels(nextKind, nextTopics);
322
319
  await this.client.ensureLabels(labels);
323
320
  await this.client.syncManagedLabels(memory.issueNumber, labels);
324
321
  return {
@@ -329,10 +326,9 @@ export class MemoryStore {
329
326
  }
330
327
  }
331
328
 
332
- function memLabels(sessionId: string, kind?: string, topics?: string[]): string[] {
329
+ function memLabels(kind?: string, topics?: string[]): string[] {
333
330
  return [
334
331
  "type:memory",
335
- `session:${sessionId}`,
336
332
  ...(kind ? [`kind:${kind}`] : []),
337
333
  ...((topics ?? []).map((topic) => `topic:${topic}`)),
338
334
  ];
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[] };
@@ -46,7 +46,8 @@ class ClawMemService {
46
46
  void this.track(this.handleTranscript(u.sessionFile)).catch((e) => this.warn("transcript update", e));
47
47
  });
48
48
  const configuredCount = Object.keys(this.config.agents).filter((agentId) => {
49
- return isAgentConfigured(resolveAgentRoute(this.config, agentId));
49
+ const route = resolveAgentRoute(this.config, agentId);
50
+ return isAgentConfigured(route) && hasDefaultRepo(route);
50
51
  }).length;
51
52
  this.api.logger.info?.(
52
53
  configuredCount > 0
@@ -64,6 +65,86 @@ class ClawMemService {
64
65
  }
65
66
 
66
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
+
67
148
  this.api.registerTool({
68
149
  name: "memory_list",
69
150
  description: "List ClawMem memories by status or schema so the agent can inspect the current memory index before deduping or saving.",
@@ -76,25 +157,26 @@ class ClawMemService {
76
157
  kind: { type: "string", minLength: 1, description: "Optional kind filter, for example core-fact, lesson, or task." },
77
158
  topic: { type: "string", minLength: 1, description: "Optional topic filter." },
78
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." },
79
161
  agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
80
162
  },
81
163
  },
82
164
  execute: async (_id: string, params: unknown) => {
83
165
  const p = asRecord(params);
84
166
  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);
167
+ const resolved = await this.requireToolRoute(agentId, p.repo);
168
+ if ("error" in resolved) return toolText(resolved.error);
87
169
  const status = p.status === "stale" || p.status === "all" ? p.status : "active";
88
170
  const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.floor(p.limit) : 20;
89
171
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
90
172
  const topic = typeof p.topic === "string" && p.topic.trim() ? p.topic.trim() : undefined;
91
- const memories = await mem.listMemories({ status, kind, topic, limit });
173
+ const memories = await resolved.mem.listMemories({ status, kind, topic, limit });
92
174
  if (memories.length === 0) {
93
175
  const filters = [status !== "active" ? `status=${status}` : "", kind ? `kind=${kind}` : "", topic ? `topic=${topic}` : ""].filter(Boolean).join(", ");
94
- return toolText(`No memories matched${filters ? ` (${filters})` : ""}.`);
176
+ return toolText(`No memories matched in ${resolved.route.repo}${filters ? ` (${filters})` : ""}.`);
95
177
  }
96
178
  const lines = [
97
- `Found ${memories.length} ${status === "all" ? "" : `${status} `}memor${memories.length === 1 ? "y" : "ies"}:`,
179
+ `Found ${memories.length} ${status === "all" ? "" : `${status} `}memor${memories.length === 1 ? "y" : "ies"} in ${resolved.route.repo}:`,
98
180
  ...memories.map((memory) => `- ${renderMemoryLine(memory)}`),
99
181
  ];
100
182
  return toolText(lines.join("\n"));
@@ -109,6 +191,7 @@ class ClawMemService {
109
191
  type: "object",
110
192
  additionalProperties: false,
111
193
  properties: {
194
+ repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
112
195
  agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
113
196
  limitTopics: { type: "integer", minimum: 1, maximum: 200, description: "Maximum number of topic labels to display." },
114
197
  },
@@ -116,16 +199,16 @@ class ClawMemService {
116
199
  execute: async (_id: string, params: unknown) => {
117
200
  const p = asRecord(params);
118
201
  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();
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();
122
205
  const rawLimit = typeof p.limitTopics === "number" && Number.isFinite(p.limitTopics) ? Math.floor(p.limitTopics) : 50;
123
206
  const limitTopics = Math.min(200, Math.max(1, rawLimit));
124
207
  const kinds = schema.kinds.length > 0 ? schema.kinds.map((kind) => `- kind:${kind}`).join("\n") : "- None";
125
208
  const topics = schema.topics.length > 0 ? schema.topics.slice(0, limitTopics).map((topic) => `- topic:${topic}`).join("\n") : "- None";
126
209
  const extra = schema.topics.length > limitTopics ? `\n- ...and ${schema.topics.length - limitTopics} more topics` : "";
127
210
  return toolText([
128
- "Current ClawMem schema labels:",
211
+ `Current ClawMem schema labels in ${resolved.route.repo}:`,
129
212
  "",
130
213
  "Kinds:",
131
214
  kinds,
@@ -146,6 +229,7 @@ class ClawMemService {
146
229
  properties: {
147
230
  query: { type: "string", minLength: 1, description: "What to recall from memory." },
148
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." },
149
233
  agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
150
234
  },
151
235
  required: ["query"],
@@ -155,14 +239,14 @@ class ClawMemService {
155
239
  const query = typeof p.query === "string" ? p.query.trim() : "";
156
240
  if (!query) return toolText("Query is empty.");
157
241
  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);
242
+ const resolved = await this.requireToolRoute(agentId, p.repo);
243
+ if ("error" in resolved) return toolText(resolved.error);
160
244
  const rawLimit = typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.floor(p.limit) : this.config.memoryRecallLimit;
161
245
  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}".`);
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}.`);
164
248
  const text = [
165
- `Found ${memories.length} active memor${memories.length === 1 ? "y" : "ies"} for "${query}":`,
249
+ `Found ${memories.length} active memor${memories.length === 1 ? "y" : "ies"} for "${query}" in ${resolved.route.repo}:`,
166
250
  ...memories.map((memory) => `- ${renderMemoryLine(memory)}`),
167
251
  ].join("\n");
168
252
  return toolText(text);
@@ -179,6 +263,7 @@ class ClawMemService {
179
263
  properties: {
180
264
  memoryId: { type: "string", minLength: 1, description: "The memory id or issue number to retrieve." },
181
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." },
182
267
  agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
183
268
  },
184
269
  required: ["memoryId"],
@@ -188,12 +273,12 @@ class ClawMemService {
188
273
  const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
189
274
  if (!memoryId) return toolText("memoryId is empty.");
190
275
  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);
276
+ const resolved = await this.requireToolRoute(agentId, p.repo);
277
+ if ("error" in resolved) return toolText(resolved.error);
193
278
  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));
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)}`);
197
282
  },
198
283
  });
199
284
 
@@ -214,7 +299,7 @@ class ClawMemService {
214
299
  minItems: 1,
215
300
  maxItems: 10,
216
301
  },
217
- sessionId: { type: "string", minLength: 1, description: "Optional source session id label. Defaults to manual." },
302
+ repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
218
303
  agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
219
304
  },
220
305
  required: ["detail"],
@@ -224,14 +309,13 @@ class ClawMemService {
224
309
  const detail = typeof p.detail === "string" ? p.detail.trim() : "";
225
310
  if (!detail) return toolText("Detail is empty.");
226
311
  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";
312
+ const resolved = await this.requireToolRoute(agentId, p.repo);
313
+ if ("error" in resolved) return toolText(resolved.error);
230
314
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
231
315
  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)}`);
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)}`);
235
319
  },
236
320
  });
237
321
 
@@ -253,6 +337,7 @@ class ClawMemService {
253
337
  minItems: 1,
254
338
  maxItems: 10,
255
339
  },
340
+ repo: { type: "string", minLength: 3, description: "Optional memory repo override in owner/repo form. Defaults to the agent's defaultRepo." },
256
341
  agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
257
342
  },
258
343
  required: ["memoryId"],
@@ -266,16 +351,16 @@ class ClawMemService {
266
351
  const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
267
352
  if (!detail && kind === undefined && topics === undefined) return toolText("Provide at least one of detail, kind, or topics.");
268
353
  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);
354
+ const resolved = await this.requireToolRoute(agentId, p.repo);
355
+ if ("error" in resolved) return toolText(resolved.error);
271
356
  let updated;
272
357
  try {
273
- updated = await mem.update(memoryId, { ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
358
+ updated = await resolved.mem.update(memoryId, { ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
274
359
  } catch (error) {
275
360
  return toolText(`Unable to update memory "${memoryId}": ${String(error)}`);
276
361
  }
277
- if (!updated) return toolText(`No memory matched id "${memoryId}".`);
278
- return toolText(`Updated memory.\n${renderMemoryBlock(updated)}`);
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)}`);
279
364
  },
280
365
  });
281
366
 
@@ -288,6 +373,7 @@ class ClawMemService {
288
373
  additionalProperties: false,
289
374
  properties: {
290
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." },
291
377
  agentId: { type: "string", minLength: 1, description: "Optional agent route override. Defaults to the current agent when available." },
292
378
  },
293
379
  required: ["memoryId"],
@@ -297,18 +383,18 @@ class ClawMemService {
297
383
  const memoryId = typeof p.memoryId === "string" ? p.memoryId.trim() : "";
298
384
  if (!memoryId) return toolText("memoryId is empty.");
299
385
  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}`);
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}`);
305
391
  },
306
392
  });
307
393
  }
308
394
 
309
395
  private async handleBeforeAgentStart(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
310
396
  const routeAgentId = normalizeAgentId(agentId);
311
- if (!(await this.ensureConfigured(routeAgentId))) return;
397
+ if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
312
398
  await this.runRequestMaintenance(routeAgentId);
313
399
  if (typeof prompt !== "string" || prompt.trim().length < 5) return;
314
400
  try {
@@ -333,7 +419,7 @@ class ClawMemService {
333
419
  }
334
420
  const { conv } = this.getServices(agentId);
335
421
  if (!conv.shouldMirror(snap.sessionId, snap.messages)) return;
336
- if (!(await this.ensureConfigured(agentId))) return;
422
+ if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
337
423
  await this.enqueueSession(sessionScopeKey(snap.sessionId, agentId), async () => {
338
424
  const s = this.getOrCreate(snap.sessionId!, agentId);
339
425
  s.sessionFile = sessionFile;
@@ -359,7 +445,7 @@ class ClawMemService {
359
445
  private async syncTurn(p: TurnPayload): Promise<void> {
360
446
  if (!p.sessionId) return;
361
447
  const agentId = normalizeAgentId(p.agentId);
362
- if (!(await this.ensureConfigured(agentId))) return;
448
+ if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
363
449
  const { conv } = this.getServices(agentId);
364
450
  const s = this.getOrCreate(p.sessionId, agentId);
365
451
  s.sessionKey = p.sessionKey ?? s.sessionKey; s.agentId = agentId; s.updatedAt = new Date().toISOString();
@@ -383,7 +469,7 @@ class ClawMemService {
383
469
  private async finalize(p: FinalizePayload): Promise<void> {
384
470
  if (!p.sessionId) return;
385
471
  const agentId = normalizeAgentId(p.agentId);
386
- if (!(await this.ensureConfigured(agentId))) return;
472
+ if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
387
473
  const { conv } = this.getServices(agentId);
388
474
  const s = this.getOrCreate(p.sessionId, agentId);
389
475
  if (s.finalizedAt) return;
@@ -456,7 +542,7 @@ class ClawMemService {
456
542
  })();
457
543
  return this.loadPromise;
458
544
  }
459
- private async ensureConfigured(agentId?: string): Promise<boolean> {
545
+ private async ensureIdentityConfigured(agentId?: string): Promise<boolean> {
460
546
  const id = normalizeAgentId(agentId);
461
547
  if (isAgentConfigured(resolveAgentRoute(this.config, id))) return true;
462
548
  const pending = this.configPromises.get(id);
@@ -465,19 +551,50 @@ class ClawMemService {
465
551
  this.configPromises.set(id, p);
466
552
  try { return await p; } finally { if (this.configPromises.get(id) === p) this.configPromises.delete(id); }
467
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
+ }
468
559
  private async bootstrap(agentId: string): Promise<boolean> {
469
560
  const route = resolveAgentRoute(this.config, agentId);
470
561
  if (!route.baseUrl) { this.api.logger.warn(`clawmem: cannot provision Git credentials for ${agentId} without a baseUrl`); return false; }
471
562
  try {
472
563
  const client = new GitHubIssueClient(route, this.api.logger);
473
- const locale = Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.locale ?? "";
474
- const sess = await client.createAnonymousSession(locale);
475
- await this.persistAgentConfig(agentId, { baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
476
- this.config.agents[agentId] = { ...(this.config.agents[agentId] ?? {}), baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name };
477
- 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
+ );
478
581
  return true;
479
582
  } catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials for agent ${agentId} via ${route.baseUrl}: ${String(error)}`); return false; }
480
583
  }
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
+ }
481
598
  private warnIfInactiveMemorySlot(): void {
482
599
  try {
483
600
  const root = this.api.runtime.config.loadConfig();
@@ -499,7 +616,7 @@ class ClawMemService {
499
616
  this.api.logger.warn(`clawmem: memory slot check failed: ${String(error)}`);
500
617
  }
501
618
  }
502
- private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; repo: string }): Promise<void> {
619
+ private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; defaultRepo: string }): Promise<void> {
503
620
  const root = this.api.runtime.config.loadConfig();
504
621
  const plugins = root.plugins;
505
622
  const entries = plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) ? (plugins.entries as Record<string, unknown>) : {};
@@ -577,9 +694,12 @@ class ClawMemService {
577
694
  if (changed) await this.persistState();
578
695
  }
579
696
 
580
- private getServices(agentId?: string): { conv: ConversationMirror; mem: MemoryStore } {
581
- const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
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);
582
700
  return {
701
+ route,
702
+ client,
583
703
  conv: new ConversationMirror(client, this.api, this.config),
584
704
  mem: new MemoryStore(client, this.api, this.config),
585
705
  };
@@ -587,6 +707,34 @@ class ClawMemService {
587
707
  private resolveToolAgentId(agentId: unknown): string {
588
708
  return normalizeAgentId(typeof agentId === "string" && agentId.trim() ? agentId : process.env.OPENCLAW_AGENT_ID);
589
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
+ }
590
738
  /**
591
739
  * After finalization, check if the repo still has an empty/default description.
592
740
  * If so, use the conversation summary to suggest a meaningful name and update
@@ -613,6 +761,10 @@ class ClawMemService {
613
761
  }
614
762
 
615
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
+ }
616
768
  function toolText(text: string): { content: Array<{ type: "text"; text: string }> } {
617
769
  return { content: [{ type: "text", text }] };
618
770
  }
@@ -620,7 +772,7 @@ function renderMemoryLine(memory: { memoryId: string; title?: string; detail: st
620
772
  const schema = [memory.kind ? `kind:${memory.kind}` : "", ...(memory.topics ?? []).map((topic) => `topic:${topic}`)].filter(Boolean).join(", ");
621
773
  return `[${memory.memoryId}] ${memory.title || "Memory"}${schema ? ` (${schema})` : ""}${memory.status === "stale" ? " [stale]" : ""}: ${memory.detail}`;
622
774
  }
623
- function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale"; sessionId?: string; date?: string }): string {
775
+ function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale"; date?: string }): string {
624
776
  const lines = [
625
777
  `Memory ID: ${memory.memoryId}`,
626
778
  ...(typeof memory.issueNumber === "number" ? [`Issue Number: ${memory.issueNumber}`] : []),
@@ -628,7 +780,6 @@ function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; tit
628
780
  `Title: ${memory.title || "Memory"}`,
629
781
  ...(memory.kind ? [`Kind: ${memory.kind}`] : []),
630
782
  ...(memory.topics && memory.topics.length > 0 ? [`Topics: ${memory.topics.join(", ")}`] : []),
631
- ...(memory.sessionId ? [`Session: ${memory.sessionId}`] : []),
632
783
  ...(memory.date ? [`Date: ${memory.date}`] : []),
633
784
  `Detail: ${memory.detail}`,
634
785
  ];
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Shared types for the clawmem plugin.
2
2
  export type ClawMemAgentConfig = {
3
3
  baseUrl?: string;
4
+ defaultRepo?: string;
4
5
  repo?: string;
5
6
  token?: string;
6
7
  authScheme?: "token" | "bearer";
@@ -8,6 +9,9 @@ export type ClawMemAgentConfig = {
8
9
 
9
10
  export type ClawMemPluginConfig = {
10
11
  baseUrl: string;
12
+ defaultRepo?: string;
13
+ repo?: string;
14
+ token?: string;
11
15
  authScheme: "token" | "bearer";
12
16
  agents: Record<string, ClawMemAgentConfig>;
13
17
  memoryRecallLimit: number;
@@ -18,12 +22,15 @@ export type ClawMemPluginConfig = {
18
22
  export type ClawMemResolvedRoute = {
19
23
  agentId: string;
20
24
  baseUrl: string;
25
+ defaultRepo?: string;
21
26
  repo?: string;
22
27
  token?: string;
23
28
  authScheme: "token" | "bearer";
24
29
  };
25
30
 
26
- export type AnonymousSessionResponse = { token: string; owner_login: string; repo_name: string; repo_full_name: string };
31
+ export type BootstrapIdentityResponse = { token: string; repo_full_name: string };
32
+ export type AgentRegistrationResponse = BootstrapIdentityResponse & { login: string };
33
+ export type AnonymousSessionResponse = BootstrapIdentityResponse & { owner_login: string; repo_name: string };
27
34
  export type SessionMirrorState = {
28
35
  sessionId: string; sessionKey?: string; sessionFile?: string; agentId?: string;
29
36
  issueNumber?: number; issueTitle?: string; titleSource?: "placeholder" | "llm";
@@ -46,6 +53,6 @@ export type MemoryListOptions = {
46
53
  };
47
54
  export type ParsedMemoryIssue = {
48
55
  issueNumber: number; title: string; memoryId: string; memoryHash?: string;
49
- sessionId: string; date: string; detail: string;
56
+ date: string; detail: string;
50
57
  kind?: string; topics?: string[]; status: "active" | "stale";
51
58
  };
package/src/utils.ts CHANGED
@@ -4,6 +4,9 @@ import path from "node:path";
4
4
  import type { NormalizedMessage } from "./types.js";
5
5
 
6
6
  export const DEFAULT_AGENT_ID = "main";
7
+ export const DEFAULT_BOOTSTRAP_REPO_NAME = "memory";
8
+
9
+ const MAX_AGENT_LOGIN_PREFIX_LEN = 32;
7
10
 
8
11
  export function sha256(v: string): string { return crypto.createHash("sha256").update(v).digest("hex"); }
9
12
 
@@ -13,6 +16,16 @@ export function normalizeAgentId(value: string | undefined | null): string {
13
16
  return trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || DEFAULT_AGENT_ID;
14
17
  }
15
18
 
19
+ export function buildAgentBootstrapRegistration(agentId: string): { prefixLogin: string; defaultRepoName: string } {
20
+ const prefixLogin = normalizeAgentId(agentId)
21
+ .replace(/_/g, "-")
22
+ .replace(/-+/g, "-")
23
+ .replace(/^-+|-+$/g, "")
24
+ .slice(0, MAX_AGENT_LOGIN_PREFIX_LEN)
25
+ .replace(/-+$/g, "") || DEFAULT_AGENT_ID;
26
+ return { prefixLogin, defaultRepoName: DEFAULT_BOOTSTRAP_REPO_NAME };
27
+ }
28
+
16
29
  export function sessionScopeKey(sessionId: string, agentId?: string): string {
17
30
  return `${normalizeAgentId(agentId)}:${sessionId.trim()}`;
18
31
  }