@clawmem-ai/clawmem 0.1.5 → 0.1.7

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
 
@@ -53,7 +53,7 @@ Before the workflow can publish successfully, configure the package on npmjs.com
53
53
  Release flow:
54
54
 
55
55
  1. Bump `package.json` to the version you want to ship.
56
- 2. Create and push a matching tag such as `0.1.5`.
56
+ 2. Create and push a matching tag such as `0.1.6`.
57
57
  3. GitHub Actions runs `.github/workflows/release.yml` and publishes with OIDC. No long-lived `NPM_TOKEN` secret is required.
58
58
 
59
59
  The workflow intentionally publishes from a tag push instead of `workflow_dispatch`, because npm validates the workflow filename exactly when using trusted publishing.
@@ -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.5",
4
+ "version": "0.1.7",
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.5",
3
+ "version": "0.1.7",
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 {
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
5
- import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, LABEL_ACTIVE, LABEL_CLOSED, SESSION_TITLE_PREFIX } from "./config.js";
5
+ import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, LABEL_ACTIVE, LABEL_CLOSED, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
6
6
  import type { GitHubIssueClient } from "./github-client.js";
7
7
  import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
8
8
  import type { ClawMemPluginConfig, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
@@ -36,7 +36,17 @@ export class ConversationMirror {
36
36
  }
37
37
 
38
38
  async ensureIssue(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
39
- if (session.issueNumber) return;
39
+ if (session.issueNumber) {
40
+ const existing = await this.lookupBoundIssue(session);
41
+ if (existing && this.isBoundIssue(session, existing)) {
42
+ session.issueTitle = existing.title?.trim() || session.issueTitle;
43
+ return;
44
+ }
45
+ this.api.logger.warn(
46
+ `clawmem: issue binding for ${session.sessionId} is stale or mismatched (${session.issueNumber}); recreating`,
47
+ );
48
+ this.resetIssueBinding(session);
49
+ }
40
50
  const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
41
51
  const labels = this.buildLabels(session, snapshot, false);
42
52
  const body = this.renderBody(session, snapshot, "pending", false);
@@ -143,9 +153,37 @@ export class ConversationMirror {
143
153
  } catch { /* directory unreadable */ }
144
154
  return null;
145
155
  }
156
+
157
+ private async lookupBoundIssue(session: SessionMirrorState): Promise<{ number: number; title?: string; labels?: Array<{ name?: string } | string> } | null> {
158
+ if (!session.issueNumber) return null;
159
+ try {
160
+ return await this.client.getIssue(session.issueNumber);
161
+ } catch (error) {
162
+ if (isNotFoundError(error)) return null;
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ private isBoundIssue(session: SessionMirrorState, issue: { title?: string; labels?: Array<{ name?: string } | string> }): boolean {
168
+ const labels = extractLabelNames(issue.labels);
169
+ return labels.includes("type:conversation") && labels.includes(`session:${session.sessionId}`);
170
+ }
171
+
172
+ private resetIssueBinding(session: SessionMirrorState): void {
173
+ session.issueNumber = undefined;
174
+ session.issueTitle = undefined;
175
+ session.lastSummaryHash = undefined;
176
+ session.lastMirroredCount = 0;
177
+ session.turnCount = 0;
178
+ session.finalizedAt = undefined;
179
+ }
146
180
  }
147
181
 
148
182
  async function fexists(p: string): Promise<boolean> { try { return (await fs.promises.stat(p)).isFile(); } catch { return false; } }
183
+ function isNotFoundError(error: unknown): boolean {
184
+ const text = String(error);
185
+ return text.includes("HTTP 404");
186
+ }
149
187
  function parseSummary(raw: string): string {
150
188
  const tryParse = (s: string): string | null => {
151
189
  try { const p = JSON.parse(s) as { summary?: unknown }; return typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null; }
@@ -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) });
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,67 +77,84 @@ 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
+ try { summary = await conv.generateSummary(s, snap); } catch (e) { summary = `failed: ${String(e)}`; }
155
+ await conv.syncLabels(s, snap, true);
156
+ await conv.syncBody(s, snap, summary, true);
157
+ await mem.syncFromConversation(s, snap);
139
158
  if (allOk) s.finalizedAt = new Date().toISOString();
140
159
  await this.persistState();
141
160
  }
@@ -146,15 +165,41 @@ class ClawMemService {
146
165
  return this.queue.enqueue(sessionId, async () => { await this.ensureLoaded(); return task(); });
147
166
  }
148
167
  private track<T>(promise: Promise<T>): Promise<T> {
149
- this.pending.add(promise); void promise.finally(() => this.pending.delete(promise)); return promise;
168
+ this.pending.add(promise);
169
+ // Avoid creating a second rejecting promise via finally(); OpenClaw treats
170
+ // unhandled rejections as fatal and exits the gateway process.
171
+ void promise.then(
172
+ () => this.pending.delete(promise),
173
+ () => this.pending.delete(promise),
174
+ );
175
+ return promise;
150
176
  }
151
- private getOrCreate(sessionId: string): SessionMirrorState {
152
- if (this.state.sessions[sessionId]) return this.state.sessions[sessionId];
177
+ private getOrCreate(sessionId: string, agentId?: string): SessionMirrorState {
178
+ const scopeKey = sessionScopeKey(sessionId, agentId);
179
+ if (this.state.sessions[scopeKey]) return this.state.sessions[scopeKey];
153
180
  const now = new Date().toISOString();
154
- const s: SessionMirrorState = { sessionId, lastMirroredCount: 0, turnCount: 0, createdAt: now, updatedAt: now };
155
- this.state.sessions[sessionId] = s;
181
+ const s: SessionMirrorState = {
182
+ sessionId,
183
+ agentId: normalizeAgentId(agentId),
184
+ lastMirroredCount: 0,
185
+ turnCount: 0,
186
+ createdAt: now,
187
+ updatedAt: now,
188
+ };
189
+ this.state.sessions[scopeKey] = s;
156
190
  return s;
157
191
  }
192
+ private resolveTranscriptAgentId(sessionId: string, sessionFile: string): string | null {
193
+ const fromPath = inferAgentIdFromTranscriptPath(sessionFile);
194
+ if (fromPath) return fromPath;
195
+ const knownAgents = new Set(
196
+ Object.values(this.state.sessions)
197
+ .filter((session) => session.sessionId === sessionId)
198
+ .map((session) => normalizeAgentId(session.agentId)),
199
+ );
200
+ if (knownAgents.size === 1) return [...knownAgents][0] ?? null;
201
+ return null;
202
+ }
158
203
  private async persistState(): Promise<void> {
159
204
  if (!this.statePath) this.statePath = resolveStatePath(this.api.runtime.state.resolveStateDir());
160
205
  await this.stateQueue.enqueue("state", () => saveState(this.statePath, this.state));
@@ -167,29 +212,60 @@ class ClawMemService {
167
212
  })();
168
213
  return this.loadPromise;
169
214
  }
170
- private async ensureConfigured(): Promise<boolean> {
171
- if (isPluginConfigured(this.config)) return true;
172
- if (this.configPromise) return this.configPromise;
173
- const p = this.bootstrap();
174
- this.configPromise = p;
175
- try { return await p; } finally { if (this.configPromise === p) this.configPromise = null; }
215
+ private async ensureConfigured(agentId?: string): Promise<boolean> {
216
+ const id = normalizeAgentId(agentId);
217
+ if (isAgentConfigured(resolveAgentRoute(this.config, id))) return true;
218
+ const pending = this.configPromises.get(id);
219
+ if (pending) return pending;
220
+ const p = this.bootstrap(id);
221
+ this.configPromises.set(id, p);
222
+ try { return await p; } finally { if (this.configPromises.get(id) === p) this.configPromises.delete(id); }
176
223
  }
177
- private async bootstrap(): Promise<boolean> {
178
- if (!this.config.baseUrl) { this.api.logger.warn("clawmem: cannot provision Git credentials without a baseUrl"); return false; }
224
+ private async bootstrap(agentId: string): Promise<boolean> {
225
+ const route = resolveAgentRoute(this.config, agentId);
226
+ if (!route.baseUrl) { this.api.logger.warn(`clawmem: cannot provision Git credentials for ${agentId} without a baseUrl`); return false; }
179
227
  try {
180
- const sess = await this.client.createAnonymousSession();
181
- await this.persistPluginConfig({ baseUrl: this.config.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
182
- this.config.authScheme = "token"; this.config.token = sess.token; this.config.repo = sess.repo_full_name;
183
- this.api.logger.info?.(`clawmem: provisioned Git credentials for ${sess.repo_full_name} via ${this.config.baseUrl}`);
228
+ const client = new GitHubIssueClient(route, this.api.logger);
229
+ const sess = await client.createAnonymousSession();
230
+ await this.persistAgentConfig(agentId, { baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
231
+ this.config.agents[agentId] = { ...(this.config.agents[agentId] ?? {}), baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name };
232
+ this.api.logger.info?.(`clawmem: provisioned Git credentials for agent ${agentId} -> ${sess.repo_full_name} via ${route.baseUrl}`);
184
233
  return true;
185
- } catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials via ${this.config.baseUrl}: ${String(error)}`); return false; }
234
+ } catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials for agent ${agentId} via ${route.baseUrl}: ${String(error)}`); return false; }
186
235
  }
187
- private async persistPluginConfig(values: Partial<ClawMemPluginConfig>): Promise<void> {
236
+ private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; repo: string }): Promise<void> {
188
237
  const root = this.api.runtime.config.loadConfig();
189
238
  const plugins = root.plugins;
190
239
  const entries = plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) ? (plugins.entries as Record<string, unknown>) : {};
191
240
  const ex = asRecord(entries[this.api.id]), exCfg = asRecord(ex.config);
192
- await this.api.runtime.config.writeConfigFile({ ...root, plugins: { ...(plugins ?? {}), entries: { ...entries, [this.api.id]: { ...ex, config: { ...exCfg, ...values } } } } });
241
+ const agents = exCfg.agents && typeof exCfg.agents === "object" && !Array.isArray(exCfg.agents) ? (exCfg.agents as Record<string, unknown>) : {};
242
+ const existingAgent = asRecord(agents[agentId]);
243
+ await this.api.runtime.config.writeConfigFile({
244
+ ...root,
245
+ plugins: {
246
+ ...(plugins ?? {}),
247
+ entries: {
248
+ ...entries,
249
+ [this.api.id]: {
250
+ ...ex,
251
+ config: {
252
+ ...exCfg,
253
+ agents: {
254
+ ...agents,
255
+ [agentId]: { ...existingAgent, ...values },
256
+ },
257
+ },
258
+ },
259
+ },
260
+ },
261
+ });
262
+ }
263
+ private getServices(agentId?: string): { conv: ConversationMirror; mem: MemoryStore } {
264
+ const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
265
+ return {
266
+ conv: new ConversationMirror(client, this.api, this.config),
267
+ mem: new MemoryStore(client, this.api, this.config),
268
+ };
193
269
  }
194
270
  private warn(scope: string, error: unknown): void { this.api.logger.warn(`clawmem: ${scope} failed: ${String(error)}`); }
195
271
  }
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)}`;