@clawmem-ai/clawmem 0.1.6 → 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 +22 -4
- package/openclaw.plugin.json +34 -4
- package/package.json +1 -1
- package/src/config.ts +33 -4
- package/src/github-client.ts +2 -2
- package/src/service.ts +135 -66
- package/src/state.ts +12 -6
- package/src/types.ts +21 -3
- package/src/utils.ts +20 -0
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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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"],
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "clawmem",
|
|
3
3
|
"name": "ClawMem",
|
|
4
|
-
"version": "0.1.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
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
|
|
34
|
-
|
|
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 {
|
package/src/github-client.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
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 {
|
|
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:
|
|
23
|
+
private state: PluginState = { version: 2, sessions: {} };
|
|
26
24
|
private unsubTranscript?: () => void;
|
|
27
25
|
private loadPromise: Promise<void> | null = null;
|
|
28
|
-
private
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
66
|
+
const routeAgentId = normalizeAgentId(agentId);
|
|
67
|
+
if (!(await this.ensureConfigured(routeAgentId))) return;
|
|
67
68
|
try {
|
|
68
|
-
const
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
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(
|
|
95
|
-
void this.track(this.enqueueSession(
|
|
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(
|
|
110
|
+
this.syncTimers.set(scopeKey, timer);
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
private async syncTurn(p: TurnPayload): Promise<void> {
|
|
102
|
-
if (!p.sessionId
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
await
|
|
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
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
123
|
-
const
|
|
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 =
|
|
127
|
-
const snap = await
|
|
128
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
136
|
-
await
|
|
137
|
-
await
|
|
138
|
-
await
|
|
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
|
}
|
|
@@ -155,13 +174,32 @@ class ClawMemService {
|
|
|
155
174
|
);
|
|
156
175
|
return promise;
|
|
157
176
|
}
|
|
158
|
-
private getOrCreate(sessionId: string): SessionMirrorState {
|
|
159
|
-
|
|
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];
|
|
160
180
|
const now = new Date().toISOString();
|
|
161
|
-
const s: SessionMirrorState = {
|
|
162
|
-
|
|
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;
|
|
163
190
|
return s;
|
|
164
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
|
+
}
|
|
165
203
|
private async persistState(): Promise<void> {
|
|
166
204
|
if (!this.statePath) this.statePath = resolveStatePath(this.api.runtime.state.resolveStateDir());
|
|
167
205
|
await this.stateQueue.enqueue("state", () => saveState(this.statePath, this.state));
|
|
@@ -174,29 +212,60 @@ class ClawMemService {
|
|
|
174
212
|
})();
|
|
175
213
|
return this.loadPromise;
|
|
176
214
|
}
|
|
177
|
-
private async ensureConfigured(): Promise<boolean> {
|
|
178
|
-
|
|
179
|
-
if (this.
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
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); }
|
|
183
223
|
}
|
|
184
|
-
private async bootstrap(): Promise<boolean> {
|
|
185
|
-
|
|
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; }
|
|
186
227
|
try {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
this.
|
|
190
|
-
this.
|
|
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}`);
|
|
191
233
|
return true;
|
|
192
|
-
} catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials via ${
|
|
234
|
+
} catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials for agent ${agentId} via ${route.baseUrl}: ${String(error)}`); return false; }
|
|
193
235
|
}
|
|
194
|
-
private async
|
|
236
|
+
private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; repo: string }): Promise<void> {
|
|
195
237
|
const root = this.api.runtime.config.loadConfig();
|
|
196
238
|
const plugins = root.plugins;
|
|
197
239
|
const entries = plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) ? (plugins.entries as Record<string, unknown>) : {};
|
|
198
240
|
const ex = asRecord(entries[this.api.id]), exCfg = asRecord(ex.config);
|
|
199
|
-
|
|
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
|
+
};
|
|
200
269
|
}
|
|
201
270
|
private warn(scope: string, error: unknown): void { this.api.logger.warn(`clawmem: ${scope} failed: ${String(error)}`); }
|
|
202
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:
|
|
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:
|
|
46
|
+
version: 2,
|
|
46
47
|
sessions: {},
|
|
47
48
|
};
|
|
48
|
-
for (const [
|
|
49
|
-
if (!sessionValue || typeof sessionValue !== "object" || !
|
|
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
|
-
|
|
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
|
|
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
|
|
10
|
+
baseUrl: string;
|
|
4
11
|
authScheme: "token" | "bearer";
|
|
5
|
-
|
|
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:
|
|
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)}`;
|