@clawmem-ai/clawmem 0.1.7 → 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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/conversation.test.ts +76 -0
- package/src/conversation.ts +48 -15
- package/src/github-client.ts +9 -2
- package/src/service.ts +34 -3
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
@@ -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
|
@@ -151,12 +151,20 @@ class ClawMemService {
|
|
|
151
151
|
let allOk = true;
|
|
152
152
|
if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; allOk = n === next.length; }
|
|
153
153
|
let summary = "pending";
|
|
154
|
-
|
|
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)}`; }
|
|
155
160
|
await conv.syncLabels(s, snap, true);
|
|
156
|
-
await conv.syncBody(s, snap, summary, true);
|
|
161
|
+
await conv.syncBody(s, snap, summary, true, generatedTitle);
|
|
157
162
|
await mem.syncFromConversation(s, snap);
|
|
158
163
|
if (allOk) s.finalizedAt = new Date().toISOString();
|
|
159
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);
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
// --- Infrastructure ---
|
|
@@ -226,7 +234,8 @@ class ClawMemService {
|
|
|
226
234
|
if (!route.baseUrl) { this.api.logger.warn(`clawmem: cannot provision Git credentials for ${agentId} without a baseUrl`); return false; }
|
|
227
235
|
try {
|
|
228
236
|
const client = new GitHubIssueClient(route, this.api.logger);
|
|
229
|
-
const
|
|
237
|
+
const locale = Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.locale ?? "";
|
|
238
|
+
const sess = await client.createAnonymousSession(locale);
|
|
230
239
|
await this.persistAgentConfig(agentId, { baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
|
|
231
240
|
this.config.agents[agentId] = { ...(this.config.agents[agentId] ?? {}), baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name };
|
|
232
241
|
this.api.logger.info?.(`clawmem: provisioned Git credentials for agent ${agentId} -> ${sess.repo_full_name} via ${route.baseUrl}`);
|
|
@@ -267,6 +276,28 @@ class ClawMemService {
|
|
|
267
276
|
mem: new MemoryStore(client, this.api, this.config),
|
|
268
277
|
};
|
|
269
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
|
+
})();
|
|
300
|
+
}
|
|
270
301
|
private warn(scope: string, error: unknown): void { this.api.logger.warn(`clawmem: ${scope} failed: ${String(error)}`); }
|
|
271
302
|
}
|
|
272
303
|
|