@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "clawmem",
3
3
  "name": "ClawMem",
4
- "version": "0.1.7",
4
+ "version": "0.1.8",
5
5
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
6
6
  "kind": "memory",
7
7
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawmem-ai/clawmem",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,76 @@
1
+ // Tests for conversation title derivation logic.
2
+ import { deriveInitialTitle } from "./conversation.js";
3
+ import type { NormalizedMessage } from "./types.js";
4
+
5
+ function msg(role: string, text: string): NormalizedMessage {
6
+ return { role, text };
7
+ }
8
+
9
+ const tests: Array<{ name: string; messages: NormalizedMessage[]; sessionId: string; expected: string | RegExp }> = [
10
+ {
11
+ name: "uses first user message",
12
+ messages: [msg("user", "How do I configure Redis rate limiting?")],
13
+ sessionId: "abc123",
14
+ expected: "How do I configure Redis rate limiting?",
15
+ },
16
+ {
17
+ name: "truncates long messages to 50 chars",
18
+ messages: [msg("user", "I need help with configuring the distributed rate limiting system for our production Redis cluster")],
19
+ sessionId: "abc123",
20
+ expected: /^I need help with configuring the distributed rate…$/,
21
+ },
22
+ {
23
+ name: "strips markdown formatting",
24
+ messages: [msg("user", "## How do I **configure** `Redis` rate limiting?")],
25
+ sessionId: "abc123",
26
+ expected: "How do I configure Redis rate limiting?",
27
+ },
28
+ {
29
+ name: "falls back to session ID for short messages",
30
+ messages: [msg("user", "hi")],
31
+ sessionId: "abc-def-123",
32
+ expected: "Session: abc-def-123",
33
+ },
34
+ {
35
+ name: "falls back to session ID when no user messages",
36
+ messages: [msg("assistant", "Hello!")],
37
+ sessionId: "xyz-789",
38
+ expected: "Session: xyz-789",
39
+ },
40
+ {
41
+ name: "falls back to session ID for empty messages",
42
+ messages: [],
43
+ sessionId: "empty-sess",
44
+ expected: "Session: empty-sess",
45
+ },
46
+ {
47
+ name: "collapses whitespace",
48
+ messages: [msg("user", "How do I configure\n\nRedis?")],
49
+ sessionId: "abc",
50
+ expected: "How do I configure Redis?",
51
+ },
52
+ {
53
+ name: "skips assistant messages, uses first user message",
54
+ messages: [msg("assistant", "Welcome!"), msg("user", "Fix the login bug please")],
55
+ sessionId: "abc",
56
+ expected: "Fix the login bug please",
57
+ },
58
+ ];
59
+
60
+ let passed = 0;
61
+ let failed = 0;
62
+
63
+ for (const t of tests) {
64
+ const got = deriveInitialTitle(t.messages, t.sessionId);
65
+ const ok = t.expected instanceof RegExp ? t.expected.test(got) : got === t.expected;
66
+ if (!ok) {
67
+ console.error(`FAIL: ${t.name}\n got: ${JSON.stringify(got)}\n expected: ${t.expected instanceof RegExp ? t.expected.toString() : JSON.stringify(t.expected)}`);
68
+ failed++;
69
+ } else {
70
+ console.log(`PASS: ${t.name}`);
71
+ passed++;
72
+ }
73
+ }
74
+
75
+ console.log(`\n${passed} passed, ${failed} failed`);
76
+ if (failed > 0) process.exit(1);
@@ -47,7 +47,8 @@ export class ConversationMirror {
47
47
  );
48
48
  this.resetIssueBinding(session);
49
49
  }
50
- const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
50
+ // Use first user message as title (truncated), falling back to session ID.
51
+ const title = deriveInitialTitle(snapshot.messages, session.sessionId);
51
52
  const labels = this.buildLabels(session, snapshot, false);
52
53
  const body = this.renderBody(session, snapshot, "pending", false);
53
54
  await this.client.ensureLabels(labels);
@@ -59,9 +60,10 @@ export class ConversationMirror {
59
60
  session.updatedAt = session.createdAt;
60
61
  }
61
62
 
62
- async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean): Promise<void> {
63
+ async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean, titleOverride?: string): Promise<void> {
63
64
  if (!session.issueNumber) return;
64
- const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
65
+ // Prefer explicit override (LLM-generated title), then keep existing title, then fall back to session ID.
66
+ const title = titleOverride?.trim() || session.issueTitle || `${SESSION_TITLE_PREFIX}${session.sessionId}`;
65
67
  const body = this.renderBody(session, snapshot, summary, closed);
66
68
  const hash = sha256(`${title}\n${body}\n${closed ? "closed" : "open"}`);
67
69
  if (hash === session.lastSummaryHash) return;
@@ -86,22 +88,28 @@ export class ConversationMirror {
86
88
  return count;
87
89
  }
88
90
 
89
- async generateSummary(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<string> {
91
+ async generateSummaryAndTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<{ summary: string; title?: string }> {
90
92
  if (snapshot.messages.length === 0) throw new Error("no conversation messages to summarize");
91
93
  const subagent = this.api.runtime.subagent;
92
94
  const sessionKey = subKey(session, "summary");
93
95
  const message = [
94
- "Summarize the following conversation.",
95
- 'Return valid JSON only in the form {"summary":"..."}',
96
+ "Summarize the following conversation and generate a short title.",
97
+ 'Return valid JSON only in the form {"summary":"...","title":"..."}',
96
98
  "The summary should be concise, factual, and written in 2-4 sentences.",
97
99
  "Do not include markdown, bullet points, or analysis.",
100
+ "",
101
+ "Title rules:",
102
+ "- Under 50 characters, evocative like a good article headline.",
103
+ "- Should make someone curious to read the conversation.",
104
+ "- Must be in the same language as the majority of the conversation content.",
105
+ "- Good: creative, captures the spirit. Bad: dry meeting-minutes style.",
98
106
  "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
99
107
  ].join("\n");
100
108
  try {
101
109
  const run = await subagent.run({
102
110
  sessionKey, message, deliver: false, lane: "clawmem-summary",
103
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary`),
104
- extraSystemPrompt: "You summarize OpenClaw conversations. Output JSON only with one string field named summary.",
111
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary-v2`),
112
+ extraSystemPrompt: "You summarize OpenClaw conversations and generate evocative titles. Output JSON only with string fields summary and title.",
105
113
  });
106
114
  const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
107
115
  if (wait.status === "timeout") throw new Error("summary subagent timed out");
@@ -109,7 +117,7 @@ export class ConversationMirror {
109
117
  const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
110
118
  const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
111
119
  if (!text) throw new Error("summary subagent returned no assistant text");
112
- return parseSummary(text);
120
+ return parseSummaryAndTitle(text);
113
121
  } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
114
122
  }
115
123
 
@@ -184,11 +192,36 @@ function isNotFoundError(error: unknown): boolean {
184
192
  const text = String(error);
185
193
  return text.includes("HTTP 404");
186
194
  }
187
- function parseSummary(raw: string): string {
188
- const tryParse = (s: string): string | null => {
189
- try { const p = JSON.parse(s) as { summary?: unknown }; return typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null; }
190
- catch { const i = s.indexOf("{"), j = s.lastIndexOf("}");
191
- if (i >= 0 && j > i) { try { const p = JSON.parse(s.slice(i, j + 1)) as { summary?: unknown }; return typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null; } catch { return null; } }
195
+ /** Derive a conversation title from the first user message, truncated to 50 chars. */
196
+ export function deriveInitialTitle(messages: NormalizedMessage[], sessionId: string): string {
197
+ const firstUserMsg = messages.find((m) => m.role === "user")?.text?.trim() ?? "";
198
+ // Strip markdown, collapse whitespace.
199
+ const clean = firstUserMsg.replace(/[#*`~>|]/g, "").replace(/\s+/g, " ").trim();
200
+ if (clean.length >= 5) {
201
+ return clean.length <= 50 ? clean : clean.slice(0, 49).trimEnd() + "…";
202
+ }
203
+ return `${SESSION_TITLE_PREFIX}${sessionId}`;
204
+ }
205
+
206
+ function parseSummaryAndTitle(raw: string): { summary: string; title?: string } {
207
+ const tryParse = (s: string): { summary: string; title?: string } | null => {
208
+ try {
209
+ const p = JSON.parse(s) as { summary?: unknown; title?: unknown };
210
+ const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
211
+ if (!summary) return null;
212
+ const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
213
+ return { summary, title };
214
+ } catch {
215
+ const i = s.indexOf("{"), j = s.lastIndexOf("}");
216
+ if (i >= 0 && j > i) {
217
+ try {
218
+ const p = JSON.parse(s.slice(i, j + 1)) as { summary?: unknown; title?: unknown };
219
+ const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
220
+ if (!summary) return null;
221
+ const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
222
+ return { summary, title };
223
+ } catch { return null; }
224
+ }
192
225
  return null;
193
226
  }
194
227
  };
@@ -196,5 +229,5 @@ function parseSummary(raw: string): string {
196
229
  const direct = tryParse(t); if (direct) return direct;
197
230
  const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
198
231
  if (f?.[1]) { const nested = tryParse(f[1].trim()); if (nested) return nested; }
199
- return t;
232
+ return { summary: t };
200
233
  }
@@ -38,8 +38,15 @@ export class GitHubIssueClient {
38
38
  const unmanaged = extractLabelNames(issue.labels).filter((l) => !isManagedLabel(l));
39
39
  await this.updateIssue(issueNumber, { labels: [...new Set([...unmanaged, ...desired])] });
40
40
  }
41
- async createAnonymousSession(): Promise<AnonymousSessionResponse> {
42
- return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST" }, { omitAuth: true });
41
+ async getRepoInfo(): Promise<{ description?: string; name?: string }> {
42
+ return this.req<{ description?: string; name?: string }>(this.repoPath("").replace(/\/$/, ""), { method: "GET" });
43
+ }
44
+ async updateRepoDescription(description: string): Promise<void> {
45
+ await this.req(this.repoPath("").replace(/\/$/, ""), { method: "PATCH", body: JSON.stringify({ description }) });
46
+ }
47
+ async createAnonymousSession(locale?: string): Promise<AnonymousSessionResponse> {
48
+ const body = locale ? JSON.stringify({ locale }) : undefined;
49
+ return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST", ...(body ? { body } : {}) }, { omitAuth: true });
43
50
  }
44
51
 
45
52
  private repoPath(suffix: string): string {
package/src/service.ts CHANGED
@@ -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
- try { summary = await conv.generateSummary(s, snap); } catch (e) { summary = `failed: ${String(e)}`; }
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 sess = await client.createAnonymousSession();
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