@clawmem-ai/clawmem 0.1.6 → 0.1.8

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
@@ -36,7 +36,7 @@ openclaw plugins install @clawmem-ai/clawmem
36
36
  openclaw gateway restart
37
37
  ```
38
38
 
39
- After restart, clawmem auto-provisions a private repo on `git.clawmem.ai` and writes `token` + `repo` back into your config. Memories start accumulating from the next session.
39
+ After restart, clawmem 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.
40
40
 
41
41
  ---
42
42
 
@@ -239,9 +239,15 @@ Minimal config (after auto-provisioning):
239
239
  enabled: true,
240
240
  config: {
241
241
  baseUrl: "https://git.clawmem.ai/api/v3",
242
- repo: "owner/repo",
243
- token: "<token>",
244
- authScheme: "token"
242
+ authScheme: "token",
243
+ agents: {
244
+ main: {
245
+ baseUrl: "https://git.clawmem.ai/api/v3",
246
+ repo: "owner/main-memory",
247
+ token: "<token>",
248
+ authScheme: "token"
249
+ }
250
+ }
245
251
  }
246
252
  }
247
253
  }
@@ -260,6 +266,18 @@ Full config with all options:
260
266
  config: {
261
267
  baseUrl: "https://git.clawmem.ai/api/v3",
262
268
  authScheme: "token",
269
+ agents: {
270
+ main: {
271
+ baseUrl: "https://git.clawmem.ai/api/v3",
272
+ repo: "owner/main-memory",
273
+ token: "<token>",
274
+ authScheme: "token"
275
+ },
276
+ coder: {
277
+ repo: "owner/coder-memory",
278
+ token: "<token>"
279
+ }
280
+ },
263
281
  issueTitlePrefix: "Session: ",
264
282
  memoryTitlePrefix: "Memory: ",
265
283
  defaultLabels: ["source:openclaw"],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "clawmem",
3
3
  "name": "ClawMem",
4
- "version": "0.1.6",
4
+ "version": "0.1.8",
5
5
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
6
6
  "kind": "memory",
7
7
  "configSchema": {
@@ -25,6 +25,32 @@
25
25
  "type": "string",
26
26
  "enum": ["token", "bearer"]
27
27
  },
28
+ "agents": {
29
+ "type": "object",
30
+ "default": {},
31
+ "additionalProperties": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "properties": {
35
+ "baseUrl": {
36
+ "type": "string",
37
+ "minLength": 1
38
+ },
39
+ "repo": {
40
+ "type": "string",
41
+ "pattern": "^[^/]+/[^/]+$"
42
+ },
43
+ "token": {
44
+ "type": "string",
45
+ "minLength": 1
46
+ },
47
+ "authScheme": {
48
+ "type": "string",
49
+ "enum": ["token", "bearer"]
50
+ }
51
+ }
52
+ }
53
+ },
28
54
  "issueTitlePrefix": {
29
55
  "type": "string"
30
56
  },
@@ -94,16 +120,20 @@
94
120
  "repo": {
95
121
  "label": "Repository",
96
122
  "placeholder": "owner/repo",
97
- "help": "Target repository that stores session issues. If left blank, clawmem will provision one automatically on first start."
123
+ "help": "Legacy single-route setting. New installs should use per-agent routes under agents.<agentId>."
98
124
  },
99
125
  "token": {
100
126
  "label": "API Token",
101
127
  "sensitive": true,
102
- "help": "Token used for issue, comment, and label API calls. If left blank, clawmem will provision one automatically on first start."
128
+ "help": "Legacy single-route setting. New installs should use per-agent routes under agents.<agentId>."
103
129
  },
104
130
  "authScheme": {
105
131
  "label": "Auth Scheme",
106
- "help": "Use 'token' for GitHub-style auth or 'bearer' for bearer tokens. Automatically provisioned credentials use 'token'."
132
+ "help": "Default auth scheme for per-agent routes. Automatically provisioned credentials use 'token'."
133
+ },
134
+ "agents": {
135
+ "label": "Agent Routes",
136
+ "help": "Per-agent ClawMem credentials keyed by agent id. Missing repo/token are provisioned automatically on first use."
107
137
  },
108
138
  "defaultLabels": {
109
139
  "label": "Default Labels",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawmem-ai/clawmem",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Hardcoded label/prefix constants and plugin config resolution.
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
- import type { ClawMemPluginConfig } from "./types.js";
3
+ import type { ClawMemAgentConfig, ClawMemPluginConfig, ClawMemResolvedRoute } from "./types.js";
4
+ import { normalizeAgentId } from "./utils.js";
4
5
 
5
6
  export const SESSION_TITLE_PREFIX = "Session: ";
6
7
  export const MEMORY_TITLE_PREFIX = "Memory: ";
@@ -20,18 +21,46 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
20
21
  const num = (v: unknown, d: number) => typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : d;
21
22
  const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
22
23
  const baseUrl = (str(raw.baseUrl) ?? "https://git.clawmem.ai").replace(/\/+$/, "");
24
+ const rawAgents = raw.agents && typeof raw.agents === "object" && !Array.isArray(raw.agents)
25
+ ? (raw.agents as Record<string, unknown>)
26
+ : {};
27
+ const agents: Record<string, ClawMemAgentConfig> = {};
28
+ for (const [rawAgentId, rawAgentConfig] of Object.entries(rawAgents)) {
29
+ if (!rawAgentConfig || typeof rawAgentConfig !== "object" || Array.isArray(rawAgentConfig)) continue;
30
+ const agentId = normalizeAgentId(rawAgentId);
31
+ const agent = rawAgentConfig as Record<string, unknown>;
32
+ agents[agentId] = {
33
+ baseUrl: str(agent.baseUrl)?.replace(/\/+$/, ""),
34
+ repo: str(agent.repo),
35
+ token: str(agent.token),
36
+ authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : undefined,
37
+ };
38
+ }
23
39
  return {
24
40
  baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
25
- repo: str(raw.repo), token: str(raw.token),
26
41
  authScheme: raw.authScheme === "bearer" ? "bearer" : "token",
42
+ agents,
27
43
  memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
28
44
  turnCommentDelayMs: num(raw.turnCommentDelayMs, 1000),
29
45
  summaryWaitTimeoutMs: clamp(num(raw.summaryWaitTimeoutMs, 120000), 1000, 600000),
30
46
  };
31
47
  }
32
48
 
33
- export function isPluginConfigured(config: ClawMemPluginConfig): boolean {
34
- return Boolean(config.baseUrl && config.repo && config.token);
49
+ export function resolveAgentRoute(config: ClawMemPluginConfig, agentId?: string): ClawMemResolvedRoute {
50
+ const id = normalizeAgentId(agentId);
51
+ const agent = config.agents[id] ?? {};
52
+ const baseUrl = (agent.baseUrl ?? config.baseUrl).replace(/\/+$/, "");
53
+ return {
54
+ agentId: id,
55
+ 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,
59
+ };
60
+ }
61
+
62
+ export function isAgentConfigured(route: ClawMemResolvedRoute): boolean {
63
+ return Boolean(route.baseUrl && route.repo && route.token);
35
64
  }
36
65
 
37
66
  export function resolveLabelColor(label: string): string {
@@ -0,0 +1,76 @@
1
+ // Tests for conversation title derivation logic.
2
+ import { deriveInitialTitle } from "./conversation.js";
3
+ import type { NormalizedMessage } from "./types.js";
4
+
5
+ function msg(role: string, text: string): NormalizedMessage {
6
+ return { role, text };
7
+ }
8
+
9
+ const tests: Array<{ name: string; messages: NormalizedMessage[]; sessionId: string; expected: string | RegExp }> = [
10
+ {
11
+ name: "uses first user message",
12
+ messages: [msg("user", "How do I configure Redis rate limiting?")],
13
+ sessionId: "abc123",
14
+ expected: "How do I configure Redis rate limiting?",
15
+ },
16
+ {
17
+ name: "truncates long messages to 50 chars",
18
+ messages: [msg("user", "I need help with configuring the distributed rate limiting system for our production Redis cluster")],
19
+ sessionId: "abc123",
20
+ expected: /^I need help with configuring the distributed rate…$/,
21
+ },
22
+ {
23
+ name: "strips markdown formatting",
24
+ messages: [msg("user", "## How do I **configure** `Redis` rate limiting?")],
25
+ sessionId: "abc123",
26
+ expected: "How do I configure Redis rate limiting?",
27
+ },
28
+ {
29
+ name: "falls back to session ID for short messages",
30
+ messages: [msg("user", "hi")],
31
+ sessionId: "abc-def-123",
32
+ expected: "Session: abc-def-123",
33
+ },
34
+ {
35
+ name: "falls back to session ID when no user messages",
36
+ messages: [msg("assistant", "Hello!")],
37
+ sessionId: "xyz-789",
38
+ expected: "Session: xyz-789",
39
+ },
40
+ {
41
+ name: "falls back to session ID for empty messages",
42
+ messages: [],
43
+ sessionId: "empty-sess",
44
+ expected: "Session: empty-sess",
45
+ },
46
+ {
47
+ name: "collapses whitespace",
48
+ messages: [msg("user", "How do I configure\n\nRedis?")],
49
+ sessionId: "abc",
50
+ expected: "How do I configure Redis?",
51
+ },
52
+ {
53
+ name: "skips assistant messages, uses first user message",
54
+ messages: [msg("assistant", "Welcome!"), msg("user", "Fix the login bug please")],
55
+ sessionId: "abc",
56
+ expected: "Fix the login bug please",
57
+ },
58
+ ];
59
+
60
+ let passed = 0;
61
+ let failed = 0;
62
+
63
+ for (const t of tests) {
64
+ const got = deriveInitialTitle(t.messages, t.sessionId);
65
+ const ok = t.expected instanceof RegExp ? t.expected.test(got) : got === t.expected;
66
+ if (!ok) {
67
+ console.error(`FAIL: ${t.name}\n got: ${JSON.stringify(got)}\n expected: ${t.expected instanceof RegExp ? t.expected.toString() : JSON.stringify(t.expected)}`);
68
+ failed++;
69
+ } else {
70
+ console.log(`PASS: ${t.name}`);
71
+ passed++;
72
+ }
73
+ }
74
+
75
+ console.log(`\n${passed} passed, ${failed} failed`);
76
+ if (failed > 0) process.exit(1);
@@ -47,7 +47,8 @@ export class ConversationMirror {
47
47
  );
48
48
  this.resetIssueBinding(session);
49
49
  }
50
- const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
50
+ // Use first user message as title (truncated), falling back to session ID.
51
+ const title = deriveInitialTitle(snapshot.messages, session.sessionId);
51
52
  const labels = this.buildLabels(session, snapshot, false);
52
53
  const body = this.renderBody(session, snapshot, "pending", false);
53
54
  await this.client.ensureLabels(labels);
@@ -59,9 +60,10 @@ export class ConversationMirror {
59
60
  session.updatedAt = session.createdAt;
60
61
  }
61
62
 
62
- async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean): Promise<void> {
63
+ async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean, titleOverride?: string): Promise<void> {
63
64
  if (!session.issueNumber) return;
64
- const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
65
+ // Prefer explicit override (LLM-generated title), then keep existing title, then fall back to session ID.
66
+ const title = titleOverride?.trim() || session.issueTitle || `${SESSION_TITLE_PREFIX}${session.sessionId}`;
65
67
  const body = this.renderBody(session, snapshot, summary, closed);
66
68
  const hash = sha256(`${title}\n${body}\n${closed ? "closed" : "open"}`);
67
69
  if (hash === session.lastSummaryHash) return;
@@ -86,22 +88,28 @@ export class ConversationMirror {
86
88
  return count;
87
89
  }
88
90
 
89
- async generateSummary(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<string> {
91
+ async generateSummaryAndTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<{ summary: string; title?: string }> {
90
92
  if (snapshot.messages.length === 0) throw new Error("no conversation messages to summarize");
91
93
  const subagent = this.api.runtime.subagent;
92
94
  const sessionKey = subKey(session, "summary");
93
95
  const message = [
94
- "Summarize the following conversation.",
95
- 'Return valid JSON only in the form {"summary":"..."}',
96
+ "Summarize the following conversation and generate a short title.",
97
+ 'Return valid JSON only in the form {"summary":"...","title":"..."}',
96
98
  "The summary should be concise, factual, and written in 2-4 sentences.",
97
99
  "Do not include markdown, bullet points, or analysis.",
100
+ "",
101
+ "Title rules:",
102
+ "- Under 50 characters, evocative like a good article headline.",
103
+ "- Should make someone curious to read the conversation.",
104
+ "- Must be in the same language as the majority of the conversation content.",
105
+ "- Good: creative, captures the spirit. Bad: dry meeting-minutes style.",
98
106
  "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
99
107
  ].join("\n");
100
108
  try {
101
109
  const run = await subagent.run({
102
110
  sessionKey, message, deliver: false, lane: "clawmem-summary",
103
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary`),
104
- extraSystemPrompt: "You summarize OpenClaw conversations. Output JSON only with one string field named summary.",
111
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary-v2`),
112
+ extraSystemPrompt: "You summarize OpenClaw conversations and generate evocative titles. Output JSON only with string fields summary and title.",
105
113
  });
106
114
  const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
107
115
  if (wait.status === "timeout") throw new Error("summary subagent timed out");
@@ -109,7 +117,7 @@ export class ConversationMirror {
109
117
  const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
110
118
  const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
111
119
  if (!text) throw new Error("summary subagent returned no assistant text");
112
- return parseSummary(text);
120
+ return parseSummaryAndTitle(text);
113
121
  } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
114
122
  }
115
123
 
@@ -184,11 +192,36 @@ function isNotFoundError(error: unknown): boolean {
184
192
  const text = String(error);
185
193
  return text.includes("HTTP 404");
186
194
  }
187
- function parseSummary(raw: string): string {
188
- const tryParse = (s: string): string | null => {
189
- try { const p = JSON.parse(s) as { summary?: unknown }; return typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null; }
190
- catch { const i = s.indexOf("{"), j = s.lastIndexOf("}");
191
- if (i >= 0 && j > i) { try { const p = JSON.parse(s.slice(i, j + 1)) as { summary?: unknown }; return typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null; } catch { return null; } }
195
+ /** Derive a conversation title from the first user message, truncated to 50 chars. */
196
+ export function deriveInitialTitle(messages: NormalizedMessage[], sessionId: string): string {
197
+ const firstUserMsg = messages.find((m) => m.role === "user")?.text?.trim() ?? "";
198
+ // Strip markdown, collapse whitespace.
199
+ const clean = firstUserMsg.replace(/[#*`~>|]/g, "").replace(/\s+/g, " ").trim();
200
+ if (clean.length >= 5) {
201
+ return clean.length <= 50 ? clean : clean.slice(0, 49).trimEnd() + "…";
202
+ }
203
+ return `${SESSION_TITLE_PREFIX}${sessionId}`;
204
+ }
205
+
206
+ function parseSummaryAndTitle(raw: string): { summary: string; title?: string } {
207
+ const tryParse = (s: string): { summary: string; title?: string } | null => {
208
+ try {
209
+ const p = JSON.parse(s) as { summary?: unknown; title?: unknown };
210
+ const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
211
+ if (!summary) return null;
212
+ const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
213
+ return { summary, title };
214
+ } catch {
215
+ const i = s.indexOf("{"), j = s.lastIndexOf("}");
216
+ if (i >= 0 && j > i) {
217
+ try {
218
+ const p = JSON.parse(s.slice(i, j + 1)) as { summary?: unknown; title?: unknown };
219
+ const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
220
+ if (!summary) return null;
221
+ const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
222
+ return { summary, title };
223
+ } catch { return null; }
224
+ }
192
225
  return null;
193
226
  }
194
227
  };
@@ -196,5 +229,5 @@ function parseSummary(raw: string): string {
196
229
  const direct = tryParse(t); if (direct) return direct;
197
230
  const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
198
231
  if (f?.[1]) { const nested = tryParse(f[1].trim()); if (nested) return nested; }
199
- return t;
232
+ return { summary: t };
200
233
  }
@@ -1,12 +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, ClawMemPluginConfig } from "./types.js";
3
+ import type { 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 ReqOpts = { allowNotFound?: boolean; allowValidationError?: boolean; omitAuth?: boolean };
7
7
 
8
8
  export class GitHubIssueClient {
9
- constructor(private readonly config: ClawMemPluginConfig, private readonly log: { warn?: (msg: string) => void }) {}
9
+ constructor(private readonly config: ClawMemResolvedRoute, private readonly log: { warn?: (msg: string) => void }) {}
10
10
 
11
11
  async createIssue(params: { title: string; body: string; labels: string[] }): Promise<IssueResponse> {
12
12
  return this.req<IssueResponse>(this.repoPath("issues"), { method: "POST", body: JSON.stringify(params) });
@@ -38,8 +38,15 @@ export class GitHubIssueClient {
38
38
  const unmanaged = extractLabelNames(issue.labels).filter((l) => !isManagedLabel(l));
39
39
  await this.updateIssue(issueNumber, { labels: [...new Set([...unmanaged, ...desired])] });
40
40
  }
41
- async createAnonymousSession(): Promise<AnonymousSessionResponse> {
42
- return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST" }, { omitAuth: true });
41
+ async getRepoInfo(): Promise<{ description?: string; name?: string }> {
42
+ return this.req<{ description?: string; name?: string }>(this.repoPath("").replace(/\/$/, ""), { method: "GET" });
43
+ }
44
+ async updateRepoDescription(description: string): Promise<void> {
45
+ await this.req(this.repoPath("").replace(/\/$/, ""), { method: "PATCH", body: JSON.stringify({ description }) });
46
+ }
47
+ async createAnonymousSession(locale?: string): Promise<AnonymousSessionResponse> {
48
+ const body = locale ? JSON.stringify({ locale }) : undefined;
49
+ return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST", ...(body ? { body } : {}) }, { omitAuth: true });
43
50
  }
44
51
 
45
52
  private repoPath(suffix: string): string {
package/src/service.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Thin orchestrator: wires conversation mirroring, memory store, and plugin lifecycle.
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
- import { isPluginConfigured, resolvePluginConfig } from "./config.js";
3
+ import { 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";
@@ -8,34 +8,29 @@ import { MemoryStore } from "./memory.js";
8
8
  import { loadState, resolveStatePath, saveState } from "./state.js";
9
9
  import { readTranscriptSnapshot } from "./transcript.js";
10
10
  import type { ClawMemPluginConfig, PluginState, SessionMirrorState, TranscriptSnapshot } from "./types.js";
11
+ import { inferAgentIdFromTranscriptPath, normalizeAgentId, sessionScopeKey } from "./utils.js";
11
12
 
12
13
  type TurnPayload = { sessionId?: string; sessionKey?: string; agentId?: string; messages: unknown[] };
13
14
  type FinalizePayload = { sessionId?: string; sessionKey?: string; sessionFile?: string; agentId?: string; reason?: string; messages?: unknown[] };
14
15
 
15
16
  class ClawMemService {
16
17
  private readonly config: ClawMemPluginConfig;
17
- private readonly client: GitHubIssueClient;
18
- private readonly conv: ConversationMirror;
19
- private readonly mem: MemoryStore;
20
18
  private readonly queue = new KeyedAsyncQueue();
21
19
  private readonly stateQueue = new KeyedAsyncQueue();
22
20
  private readonly pending = new Set<Promise<unknown>>();
23
21
  private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
24
22
  private statePath = "";
25
- private state: PluginState = { version: 1, sessions: {} };
23
+ private state: PluginState = { version: 2, sessions: {} };
26
24
  private unsubTranscript?: () => void;
27
25
  private loadPromise: Promise<void> | null = null;
28
- private configPromise: Promise<boolean> | null = null;
26
+ private readonly configPromises = new Map<string, Promise<boolean>>();
29
27
 
30
28
  constructor(private readonly api: OpenClawPluginApi) {
31
29
  this.config = resolvePluginConfig(api);
32
- this.client = new GitHubIssueClient(this.config, api.logger);
33
- this.conv = new ConversationMirror(this.client, api, this.config);
34
- this.mem = new MemoryStore(this.client, api, this.config);
35
30
  }
36
31
 
37
32
  register(): void {
38
- this.api.on("before_agent_start", async (ev) => this.handleRecall(ev.prompt));
33
+ this.api.on("before_agent_start", async (ev, ctx) => this.handleRecall(ev.prompt, ctx.agentId));
39
34
  this.api.on("agent_end", (ev, ctx) => this.scheduleTurn({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, agentId: ctx.agentId, messages: ev.messages }));
40
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 }));
41
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" }));
@@ -45,12 +40,17 @@ class ClawMemService {
45
40
  start: async (ctx) => {
46
41
  this.statePath = resolveStatePath(ctx.stateDir);
47
42
  await this.ensureLoaded();
48
- const ok = await this.ensureConfigured();
49
43
  this.unsubTranscript = this.api.runtime.events.onSessionTranscriptUpdate((u) => {
50
44
  void this.track(this.handleTranscript(u.sessionFile)).catch((e) => this.warn("transcript update", e));
51
45
  });
52
- if (ok) this.api.logger.info?.(`clawmem: mirroring sessions to ${this.config.repo} via ${this.config.baseUrl}`);
53
- else this.api.logger.warn(`clawmem: missing repo/token and automatic provisioning failed via ${this.config.baseUrl}; sync will retry on the next use`);
46
+ const configuredCount = Object.keys(this.config.agents).filter((agentId) => {
47
+ return isAgentConfigured(resolveAgentRoute(this.config, agentId));
48
+ }).length;
49
+ this.api.logger.info?.(
50
+ configuredCount > 0
51
+ ? `clawmem: ready with ${configuredCount} configured agent route(s); missing routes will provision on first use via ${this.config.baseUrl}`
52
+ : `clawmem: ready; agent routes will provision on first use via ${this.config.baseUrl}`,
53
+ );
54
54
  },
55
55
  stop: async () => {
56
56
  this.unsubTranscript?.();
@@ -61,11 +61,13 @@ class ClawMemService {
61
61
  });
62
62
  }
63
63
 
64
- private async handleRecall(prompt: unknown): Promise<{ prependContext: string } | void> {
64
+ private async handleRecall(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
65
65
  if (typeof prompt !== "string" || prompt.trim().length < 5) return;
66
- if (!(await this.ensureConfigured())) return;
66
+ const routeAgentId = normalizeAgentId(agentId);
67
+ if (!(await this.ensureConfigured(routeAgentId))) return;
67
68
  try {
68
- const memories = await this.mem.search(prompt, this.config.memoryRecallLimit);
69
+ const { mem } = this.getServices(routeAgentId);
70
+ const memories = await mem.search(prompt, this.config.memoryRecallLimit);
69
71
  if (memories.length === 0) return;
70
72
  const text = memories.map((m) => `- ${m.detail}`).join("\n");
71
73
  return { prependContext: `<relevant-memories>\nThe following active memories may be relevant to this conversation:\n${text}\n</relevant-memories>` };
@@ -75,69 +77,94 @@ class ClawMemService {
75
77
  private async handleTranscript(sessionFile: string): Promise<void> {
76
78
  let snap: TranscriptSnapshot;
77
79
  try { snap = await readTranscriptSnapshot(sessionFile); } catch (e) { this.warn("transcript read", e); return; }
78
- if (!snap.sessionId || !this.conv.shouldMirror(snap.sessionId, snap.messages)) return;
79
- if (!(await this.ensureConfigured())) return;
80
- await this.enqueueSession(snap.sessionId, async () => {
81
- const s = this.getOrCreate(snap.sessionId!);
80
+ if (!snap.sessionId) return;
81
+ const agentId = this.resolveTranscriptAgentId(snap.sessionId, sessionFile);
82
+ if (!agentId) {
83
+ this.api.logger.info?.(
84
+ `clawmem: skipping transcript sync for ${snap.sessionId} because agent ownership could not be inferred from ${sessionFile}`,
85
+ );
86
+ return;
87
+ }
88
+ const { conv } = this.getServices(agentId);
89
+ if (!conv.shouldMirror(snap.sessionId, snap.messages)) return;
90
+ if (!(await this.ensureConfigured(agentId))) return;
91
+ await this.enqueueSession(sessionScopeKey(snap.sessionId, agentId), async () => {
92
+ const s = this.getOrCreate(snap.sessionId!, agentId);
82
93
  s.sessionFile = sessionFile;
83
94
  s.updatedAt = new Date().toISOString();
84
- await this.conv.ensureIssue(s, snap);
95
+ await conv.ensureIssue(s, snap);
85
96
  await this.persistState();
86
97
  });
87
98
  }
88
99
 
89
100
  private scheduleTurn(p: TurnPayload): void {
90
101
  if (!p.sessionId) return;
91
- const prev = this.syncTimers.get(p.sessionId);
102
+ const scopeKey = sessionScopeKey(p.sessionId, p.agentId);
103
+ const prev = this.syncTimers.get(scopeKey);
92
104
  if (prev) clearTimeout(prev);
93
105
  const timer = setTimeout(() => {
94
- this.syncTimers.delete(p.sessionId!);
95
- void this.track(this.enqueueSession(p.sessionId!, () => this.syncTurn(p))).catch((e) => this.warn("turn sync", e));
106
+ this.syncTimers.delete(scopeKey);
107
+ void this.track(this.enqueueSession(scopeKey, () => this.syncTurn(p))).catch((e) => this.warn("turn sync", e));
96
108
  }, this.config.turnCommentDelayMs);
97
109
  timer.unref?.();
98
- this.syncTimers.set(p.sessionId, timer);
110
+ this.syncTimers.set(scopeKey, timer);
99
111
  }
100
112
 
101
113
  private async syncTurn(p: TurnPayload): Promise<void> {
102
- if (!p.sessionId || !(await this.ensureConfigured())) return;
103
- const s = this.getOrCreate(p.sessionId);
104
- s.sessionKey = p.sessionKey ?? s.sessionKey; s.agentId = p.agentId ?? s.agentId; s.updatedAt = new Date().toISOString();
105
- const snap = await this.conv.loadSnapshot(s, p.messages);
106
- if (!this.conv.shouldMirror(s.sessionId, snap.messages) || snap.messages.length === 0) { await this.persistState(); return; }
107
- await this.conv.ensureIssue(s, snap);
108
- await this.conv.syncLabels(s, snap, false);
114
+ if (!p.sessionId) return;
115
+ const agentId = normalizeAgentId(p.agentId);
116
+ if (!(await this.ensureConfigured(agentId))) return;
117
+ const { conv } = this.getServices(agentId);
118
+ const s = this.getOrCreate(p.sessionId, agentId);
119
+ s.sessionKey = p.sessionKey ?? s.sessionKey; s.agentId = agentId; s.updatedAt = new Date().toISOString();
120
+ const snap = await conv.loadSnapshot(s, p.messages);
121
+ if (!conv.shouldMirror(s.sessionId, snap.messages) || snap.messages.length === 0) { await this.persistState(); return; }
122
+ await conv.ensureIssue(s, snap);
123
+ await conv.syncLabels(s, snap, false);
109
124
  const next = snap.messages.slice(s.lastMirroredCount);
110
- if (next.length > 0) { const n = await this.conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; }
125
+ if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; }
111
126
  await this.persistState();
112
127
  }
113
128
 
114
129
  private enqueueFinalize(p: FinalizePayload): void {
115
130
  if (!p.sessionId) return;
116
- const prev = this.syncTimers.get(p.sessionId);
117
- if (prev) { clearTimeout(prev); this.syncTimers.delete(p.sessionId); }
118
- void this.track(this.enqueueSession(p.sessionId, () => this.finalize(p))).catch((e) => this.warn("finalize", e));
131
+ const scopeKey = sessionScopeKey(p.sessionId, p.agentId);
132
+ const prev = this.syncTimers.get(scopeKey);
133
+ if (prev) { clearTimeout(prev); this.syncTimers.delete(scopeKey); }
134
+ void this.track(this.enqueueSession(scopeKey, () => this.finalize(p))).catch((e) => this.warn("finalize", e));
119
135
  }
120
136
 
121
137
  private async finalize(p: FinalizePayload): Promise<void> {
122
- if (!p.sessionId || !(await this.ensureConfigured())) return;
123
- const s = this.getOrCreate(p.sessionId);
138
+ if (!p.sessionId) return;
139
+ const agentId = normalizeAgentId(p.agentId);
140
+ if (!(await this.ensureConfigured(agentId))) return;
141
+ const { conv, mem } = this.getServices(agentId);
142
+ const s = this.getOrCreate(p.sessionId, agentId);
124
143
  if (s.finalizedAt) return;
125
144
  s.sessionKey = p.sessionKey ?? s.sessionKey; s.sessionFile = p.sessionFile ?? s.sessionFile;
126
- s.agentId = p.agentId ?? s.agentId; s.updatedAt = new Date().toISOString();
127
- const snap = await this.conv.loadSnapshot(s, p.messages ?? []);
128
- if (!this.conv.shouldMirror(s.sessionId, snap.messages)) { await this.persistState(); return; }
145
+ s.agentId = agentId; s.updatedAt = new Date().toISOString();
146
+ const snap = await conv.loadSnapshot(s, p.messages ?? []);
147
+ if (!conv.shouldMirror(s.sessionId, snap.messages)) { await this.persistState(); return; }
129
148
  if (snap.messages.length === 0 && !s.issueNumber) { await this.persistState(); return; }
130
- await this.conv.ensureIssue(s, snap);
149
+ await conv.ensureIssue(s, snap);
131
150
  const next = snap.messages.slice(s.lastMirroredCount);
132
151
  let allOk = true;
133
- if (next.length > 0) { const n = await this.conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; allOk = n === next.length; }
152
+ if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; allOk = n === next.length; }
134
153
  let summary = "pending";
135
- try { summary = await this.conv.generateSummary(s, snap); } catch (e) { summary = `failed: ${String(e)}`; }
136
- await this.conv.syncLabels(s, snap, true);
137
- await this.conv.syncBody(s, snap, summary, true);
138
- await this.mem.syncFromConversation(s, snap);
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
+ await conv.syncLabels(s, snap, true);
161
+ await conv.syncBody(s, snap, summary, true, generatedTitle);
162
+ await mem.syncFromConversation(s, snap);
139
163
  if (allOk) s.finalizedAt = new Date().toISOString();
140
164
  await this.persistState();
165
+
166
+ // Auto-name the repo if it still has no description (first few conversations).
167
+ this.maybeAutoNameRepo(agentId, summary, generatedTitle);
141
168
  }
142
169
 
143
170
  // --- Infrastructure ---
@@ -155,13 +182,32 @@ class ClawMemService {
155
182
  );
156
183
  return promise;
157
184
  }
158
- private getOrCreate(sessionId: string): SessionMirrorState {
159
- if (this.state.sessions[sessionId]) return this.state.sessions[sessionId];
185
+ private getOrCreate(sessionId: string, agentId?: string): SessionMirrorState {
186
+ const scopeKey = sessionScopeKey(sessionId, agentId);
187
+ if (this.state.sessions[scopeKey]) return this.state.sessions[scopeKey];
160
188
  const now = new Date().toISOString();
161
- const s: SessionMirrorState = { sessionId, lastMirroredCount: 0, turnCount: 0, createdAt: now, updatedAt: now };
162
- this.state.sessions[sessionId] = s;
189
+ const s: SessionMirrorState = {
190
+ sessionId,
191
+ agentId: normalizeAgentId(agentId),
192
+ lastMirroredCount: 0,
193
+ turnCount: 0,
194
+ createdAt: now,
195
+ updatedAt: now,
196
+ };
197
+ this.state.sessions[scopeKey] = s;
163
198
  return s;
164
199
  }
200
+ private resolveTranscriptAgentId(sessionId: string, sessionFile: string): string | null {
201
+ const fromPath = inferAgentIdFromTranscriptPath(sessionFile);
202
+ if (fromPath) return fromPath;
203
+ const knownAgents = new Set(
204
+ Object.values(this.state.sessions)
205
+ .filter((session) => session.sessionId === sessionId)
206
+ .map((session) => normalizeAgentId(session.agentId)),
207
+ );
208
+ if (knownAgents.size === 1) return [...knownAgents][0] ?? null;
209
+ return null;
210
+ }
165
211
  private async persistState(): Promise<void> {
166
212
  if (!this.statePath) this.statePath = resolveStatePath(this.api.runtime.state.resolveStateDir());
167
213
  await this.stateQueue.enqueue("state", () => saveState(this.statePath, this.state));
@@ -174,29 +220,83 @@ class ClawMemService {
174
220
  })();
175
221
  return this.loadPromise;
176
222
  }
177
- private async ensureConfigured(): Promise<boolean> {
178
- if (isPluginConfigured(this.config)) return true;
179
- if (this.configPromise) return this.configPromise;
180
- const p = this.bootstrap();
181
- this.configPromise = p;
182
- try { return await p; } finally { if (this.configPromise === p) this.configPromise = null; }
223
+ private async ensureConfigured(agentId?: string): Promise<boolean> {
224
+ const id = normalizeAgentId(agentId);
225
+ if (isAgentConfigured(resolveAgentRoute(this.config, id))) return true;
226
+ const pending = this.configPromises.get(id);
227
+ if (pending) return pending;
228
+ const p = this.bootstrap(id);
229
+ this.configPromises.set(id, p);
230
+ try { return await p; } finally { if (this.configPromises.get(id) === p) this.configPromises.delete(id); }
183
231
  }
184
- private async bootstrap(): Promise<boolean> {
185
- if (!this.config.baseUrl) { this.api.logger.warn("clawmem: cannot provision Git credentials without a baseUrl"); return false; }
232
+ private async bootstrap(agentId: string): Promise<boolean> {
233
+ const route = resolveAgentRoute(this.config, agentId);
234
+ if (!route.baseUrl) { this.api.logger.warn(`clawmem: cannot provision Git credentials for ${agentId} without a baseUrl`); return false; }
186
235
  try {
187
- const sess = await this.client.createAnonymousSession();
188
- await this.persistPluginConfig({ baseUrl: this.config.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
189
- this.config.authScheme = "token"; this.config.token = sess.token; this.config.repo = sess.repo_full_name;
190
- this.api.logger.info?.(`clawmem: provisioned Git credentials for ${sess.repo_full_name} via ${this.config.baseUrl}`);
236
+ 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}`);
191
242
  return true;
192
- } catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials via ${this.config.baseUrl}: ${String(error)}`); return false; }
243
+ } catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials for agent ${agentId} via ${route.baseUrl}: ${String(error)}`); return false; }
193
244
  }
194
- private async persistPluginConfig(values: Partial<ClawMemPluginConfig>): Promise<void> {
245
+ private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; repo: string }): Promise<void> {
195
246
  const root = this.api.runtime.config.loadConfig();
196
247
  const plugins = root.plugins;
197
248
  const entries = plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) ? (plugins.entries as Record<string, unknown>) : {};
198
249
  const ex = asRecord(entries[this.api.id]), exCfg = asRecord(ex.config);
199
- await this.api.runtime.config.writeConfigFile({ ...root, plugins: { ...(plugins ?? {}), entries: { ...entries, [this.api.id]: { ...ex, config: { ...exCfg, ...values } } } } });
250
+ const agents = exCfg.agents && typeof exCfg.agents === "object" && !Array.isArray(exCfg.agents) ? (exCfg.agents as Record<string, unknown>) : {};
251
+ const existingAgent = asRecord(agents[agentId]);
252
+ await this.api.runtime.config.writeConfigFile({
253
+ ...root,
254
+ plugins: {
255
+ ...(plugins ?? {}),
256
+ entries: {
257
+ ...entries,
258
+ [this.api.id]: {
259
+ ...ex,
260
+ config: {
261
+ ...exCfg,
262
+ agents: {
263
+ ...agents,
264
+ [agentId]: { ...existingAgent, ...values },
265
+ },
266
+ },
267
+ },
268
+ },
269
+ },
270
+ });
271
+ }
272
+ private getServices(agentId?: string): { conv: ConversationMirror; mem: MemoryStore } {
273
+ const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
274
+ return {
275
+ conv: new ConversationMirror(client, this.api, this.config),
276
+ mem: new MemoryStore(client, this.api, this.config),
277
+ };
278
+ }
279
+ /**
280
+ * After finalization, check if the repo still has an empty/default description.
281
+ * If so, use the conversation summary to suggest a meaningful name and update
282
+ * the repo description automatically. Best-effort, fire-and-forget.
283
+ */
284
+ private maybeAutoNameRepo(agentId: string, summary: string, title?: string): void {
285
+ if (!summary || summary.startsWith("failed:") || summary === "pending") return;
286
+ const snippet = title || summary.slice(0, 100);
287
+ void (async () => {
288
+ try {
289
+ const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
290
+ const repo = await client.getRepoInfo();
291
+ // Only auto-name if description is still empty or a default placeholder.
292
+ if (repo.description && repo.description !== "My Memory Space" && repo.description !== "我的记忆空间" && repo.description !== "マイメモリースペース") return;
293
+ // Use the conversation title or summary as a lightweight description.
294
+ await client.updateRepoDescription(snippet);
295
+ this.api.logger.info?.(`clawmem: auto-named repo to "${snippet}"`);
296
+ } catch (e) {
297
+ this.api.logger.warn(`clawmem: auto-name repo failed: ${String(e)}`);
298
+ }
299
+ })();
200
300
  }
201
301
  private warn(scope: string, error: unknown): void { this.api.logger.warn(`clawmem: ${scope} failed: ${String(error)}`); }
202
302
  }
package/src/state.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { PluginState } from "./types.js";
4
+ import { normalizeAgentId, sessionScopeKey } from "./utils.js";
4
5
 
5
6
  const EMPTY_STATE: PluginState = {
6
- version: 1,
7
+ version: 2,
7
8
  sessions: {},
8
9
  };
9
10
 
@@ -42,19 +43,24 @@ function sanitizeState(value: unknown): PluginState {
42
43
  ? (raw.sessions as Record<string, unknown>)
43
44
  : {};
44
45
  const out: PluginState = {
45
- version: 1,
46
+ version: 2,
46
47
  sessions: {},
47
48
  };
48
- for (const [sessionId, sessionValue] of Object.entries(sessions)) {
49
- if (!sessionValue || typeof sessionValue !== "object" || !sessionId.trim()) {
49
+ for (const [storedKey, sessionValue] of Object.entries(sessions)) {
50
+ if (!sessionValue || typeof sessionValue !== "object" || !storedKey.trim()) {
50
51
  continue;
51
52
  }
52
53
  const rawSession = sessionValue as Record<string, unknown>;
53
- out.sessions[sessionId] = {
54
+ const sessionId = readString(rawSession.sessionId) ?? storedKey.trim();
55
+ if (!sessionId) {
56
+ continue;
57
+ }
58
+ const agentId = normalizeAgentId(readString(rawSession.agentId));
59
+ out.sessions[sessionScopeKey(sessionId, agentId)] = {
54
60
  sessionId,
55
61
  sessionKey: readString(rawSession.sessionKey),
56
62
  sessionFile: readString(rawSession.sessionFile),
57
- agentId: readString(rawSession.agentId),
63
+ agentId,
58
64
  issueNumber: readNumber(rawSession.issueNumber),
59
65
  issueTitle: readString(rawSession.issueTitle),
60
66
  lastMirroredCount: readNumber(rawSession.lastMirroredCount) ?? 0,
package/src/types.ts CHANGED
@@ -1,10 +1,28 @@
1
1
  // Shared types for the clawmem plugin.
2
+ export type ClawMemAgentConfig = {
3
+ baseUrl?: string;
4
+ repo?: string;
5
+ token?: string;
6
+ authScheme?: "token" | "bearer";
7
+ };
8
+
2
9
  export type ClawMemPluginConfig = {
3
- baseUrl?: string; repo?: string; token?: string;
10
+ baseUrl: string;
4
11
  authScheme: "token" | "bearer";
5
- memoryRecallLimit: number; turnCommentDelayMs: number;
12
+ agents: Record<string, ClawMemAgentConfig>;
13
+ memoryRecallLimit: number;
14
+ turnCommentDelayMs: number;
6
15
  summaryWaitTimeoutMs: number;
7
16
  };
17
+
18
+ export type ClawMemResolvedRoute = {
19
+ agentId: string;
20
+ baseUrl: string;
21
+ repo?: string;
22
+ token?: string;
23
+ authScheme: "token" | "bearer";
24
+ };
25
+
8
26
  export type AnonymousSessionResponse = { token: string; owner_login: string; repo_name: string; repo_full_name: string };
9
27
  export type SessionMirrorState = {
10
28
  sessionId: string; sessionKey?: string; sessionFile?: string; agentId?: string;
@@ -13,7 +31,7 @@ export type SessionMirrorState = {
13
31
  finalizedAt?: string; lastSummaryHash?: string; lastTurnHash?: string;
14
32
  createdAt?: string; updatedAt?: string;
15
33
  };
16
- export type PluginState = { version: 1; sessions: Record<string, SessionMirrorState> };
34
+ export type PluginState = { version: 2; sessions: Record<string, SessionMirrorState> };
17
35
  export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
18
36
  export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
19
37
  export type ParsedMemoryIssue = {
package/src/utils.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  // Shared utility helpers used by memory.ts and conversation.ts.
2
2
  import crypto from "node:crypto";
3
+ import path from "node:path";
3
4
  import type { NormalizedMessage } from "./types.js";
4
5
 
6
+ export const DEFAULT_AGENT_ID = "main";
7
+
5
8
  export function sha256(v: string): string { return crypto.createHash("sha256").update(v).digest("hex"); }
6
9
 
10
+ export function normalizeAgentId(value: string | undefined | null): string {
11
+ const trimmed = (value ?? "").trim().toLowerCase();
12
+ if (!trimmed) return DEFAULT_AGENT_ID;
13
+ return trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || DEFAULT_AGENT_ID;
14
+ }
15
+
16
+ export function sessionScopeKey(sessionId: string, agentId?: string): string {
17
+ return `${normalizeAgentId(agentId)}:${sessionId.trim()}`;
18
+ }
19
+
20
+ export function inferAgentIdFromTranscriptPath(filePath: string): string | undefined {
21
+ const parts = path.resolve(filePath).split(path.sep);
22
+ const idx = parts.lastIndexOf("agents");
23
+ if (idx < 0 || !parts[idx + 1] || parts[idx + 2] !== "sessions") return undefined;
24
+ return normalizeAgentId(parts[idx + 1]);
25
+ }
26
+
7
27
  export function subKey(s: { sessionId: string; agentId?: string }, suffix: string): string {
8
28
  const san = (v: string) => v.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "main";
9
29
  return `agent:${san(s.agentId || "main")}:subagent:clawmem-${suffix}-${san(s.sessionId)}`;