@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 +22 -4
- package/openclaw.plugin.json +34 -4
- package/package.json +1 -1
- package/src/config.ts +33 -4
- package/src/conversation.test.ts +76 -0
- package/src/conversation.ts +48 -15
- package/src/github-client.ts +11 -4
- package/src/service.ts +166 -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.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": "
|
|
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 {
|
|
@@ -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);
|
package/src/conversation.ts
CHANGED
|
@@ -47,7 +47,8 @@ export class ConversationMirror {
|
|
|
47
47
|
);
|
|
48
48
|
this.resetIssueBinding(session);
|
|
49
49
|
}
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
}
|
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) });
|
|
@@ -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
|
|
42
|
-
return this.req<
|
|
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 {
|
|
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,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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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 = {
|
|
162
|
-
|
|
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
|
-
|
|
179
|
-
if (this.
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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:
|
|
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)}`;
|