@clawmem-ai/clawmem 0.1.8 → 0.1.10

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.
@@ -2,12 +2,12 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
5
- import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, LABEL_ACTIVE, LABEL_CLOSED, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
5
+ import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
6
6
  import type { GitHubIssueClient } from "./github-client.js";
7
7
  import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
8
8
  import type { ClawMemPluginConfig, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
9
9
  import { fmtTranscript, localDate, localDateTime, sha256, subKey } from "./utils.js";
10
- import { stringifyFlatYaml } from "./yaml.js";
10
+ import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
11
11
 
12
12
  export class ConversationMirror {
13
13
  constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
@@ -47,7 +47,7 @@ export class ConversationMirror {
47
47
  );
48
48
  this.resetIssueBinding(session);
49
49
  }
50
- // Use first user message as title (truncated), falling back to session ID.
50
+ // Use placeholder title; real title is generated by LLM once enough messages are available.
51
51
  const title = deriveInitialTitle(snapshot.messages, session.sessionId);
52
52
  const labels = this.buildLabels(session, snapshot, false);
53
53
  const body = this.renderBody(session, snapshot, "pending", false);
@@ -55,6 +55,7 @@ export class ConversationMirror {
55
55
  const issue = await this.client.createIssue({ title, body, labels });
56
56
  session.issueNumber = issue.number;
57
57
  session.issueTitle = issue.title ?? title;
58
+ session.titleSource = "placeholder";
58
59
  session.lastSummaryHash = sha256(`${title}\n${body}\nopen`);
59
60
  session.createdAt = new Date().toISOString();
60
61
  session.updatedAt = session.createdAt;
@@ -69,6 +70,7 @@ export class ConversationMirror {
69
70
  if (hash === session.lastSummaryHash) return;
70
71
  await this.client.updateIssue(session.issueNumber, { title, body, ...(closed ? { state: "closed" as const } : {}) });
71
72
  session.issueTitle = title;
73
+ if (titleOverride?.trim()) session.titleSource = "llm";
72
74
  session.lastSummaryHash = hash;
73
75
  }
74
76
 
@@ -99,17 +101,17 @@ export class ConversationMirror {
99
101
  "Do not include markdown, bullet points, or analysis.",
100
102
  "",
101
103
  "Title rules:",
102
- "- Under 50 characters, evocative like a good article headline.",
103
- "- Should make someone curious to read the conversation.",
104
+ "- Under 50 characters, accurately describe the main topic or task.",
105
+ "- Should let someone immediately know what the conversation is about.",
104
106
  "- Must be in the same language as the majority of the conversation content.",
105
- "- Good: creative, captures the spirit. Bad: dry meeting-minutes style.",
107
+ "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
106
108
  "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
107
109
  ].join("\n");
108
110
  try {
109
111
  const run = await subagent.run({
110
112
  sessionKey, message, deliver: false, lane: "clawmem-summary",
111
113
  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.",
114
+ extraSystemPrompt: "You summarize conversations and generate accurate, descriptive titles. Output JSON only with string fields summary and title.",
113
115
  });
114
116
  const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
115
117
  if (wait.status === "timeout") throw new Error("summary subagent timed out");
@@ -121,20 +123,158 @@ export class ConversationMirror {
121
123
  } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
122
124
  }
123
125
 
124
- private buildLabels(session: SessionMirrorState, snapshot: TranscriptSnapshot, closed: boolean): string[] {
125
- const dates = this.resolveDates(session, snapshot.messages);
126
- const labels = new Set([...DEFAULT_LABELS, "type:conversation", `session:${session.sessionId}`, `date:${dates.date}`]);
126
+ /** If the title has not yet been generated by LLM, generate an accurate title from the full conversation and update the issue. */
127
+ async syncTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
128
+ if (!session.issueNumber) return;
129
+ if (session.titleSource === "llm") return;
130
+ if (snapshot.messages.length < 2) return;
131
+ try {
132
+ const title = await this.generateTitle(session, snapshot);
133
+ if (title) {
134
+ await this.client.updateIssue(session.issueNumber, { title });
135
+ session.issueTitle = title;
136
+ session.titleSource = "llm";
137
+ }
138
+ } catch (e) {
139
+ this.api.logger.warn(`clawmem: title sync failed: ${String(e)}`);
140
+ }
141
+ }
142
+
143
+ /** Generate an accurate, descriptive title from the full conversation content via LLM. */
144
+ async generateTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<string | undefined> {
145
+ if (snapshot.messages.length === 0) return undefined;
146
+ const subagent = this.api.runtime.subagent;
147
+ const sessionKey = subKey(session, "title");
148
+ const message = [
149
+ "Generate a short, accurate title for the following conversation.",
150
+ 'Return valid JSON only in the form {"title":"..."}',
151
+ "",
152
+ "Title rules:",
153
+ "- Under 50 characters.",
154
+ "- Accurately describe the main topic or task of the conversation.",
155
+ "- Should let someone immediately know what the conversation is about.",
156
+ "- Must be in the same language as the majority of the conversation content.",
157
+ "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
158
+ "",
159
+ "<conversation>",
160
+ fmtTranscript(snapshot.messages),
161
+ "</conversation>",
162
+ ].join("\n");
163
+ try {
164
+ const run = await subagent.run({
165
+ sessionKey, message, deliver: false, lane: "clawmem-title",
166
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:title-v1`),
167
+ extraSystemPrompt: "You generate accurate, descriptive titles for conversations. Output JSON only with a string field title.",
168
+ });
169
+ const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: 30000 });
170
+ if (wait.status === "timeout" || wait.status === "error") return undefined;
171
+ const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 10 })).messages);
172
+ const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
173
+ if (!text) return undefined;
174
+ return parseTitle(text);
175
+ } catch (e) {
176
+ this.api.logger.warn(`clawmem: title generation failed: ${String(e)}`);
177
+ return undefined;
178
+ } finally {
179
+ subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
180
+ }
181
+ }
182
+
183
+ /** Re-title all existing conversation issues. Uses summary when available, falls back to reading comments. */
184
+ async retitleConversations(): Promise<{ updated: number; skipped: number; failed: number; retitledIssues: number[] }> {
185
+ let updated = 0, skipped = 0, failed = 0;
186
+ const retitledIssues: number[] = [];
187
+ let page = 1;
188
+ while (true) {
189
+ const issues = await this.client.listIssues({ labels: ["type:conversation"], state: "all", page, perPage: 50 });
190
+ if (issues.length === 0) break;
191
+ for (const issue of issues) {
192
+ try {
193
+ const yaml = parseFlatYaml(issue.body || "");
194
+ const summary = yaml.summary;
195
+ let titleInput: string | undefined;
196
+ if (summary && summary !== "pending" && !summary.startsWith("failed:")) {
197
+ titleInput = summary;
198
+ } else {
199
+ // No usable summary — reconstruct conversation from issue comments.
200
+ const comments = await this.client.listComments(issue.number, { perPage: 50 });
201
+ const conversationText = comments
202
+ .map((c) => c.body?.trim())
203
+ .filter((b): b is string => Boolean(b))
204
+ .join("\n\n");
205
+ if (conversationText.length >= 20) {
206
+ // Cap to avoid excessive token usage in LLM call.
207
+ titleInput = conversationText.length > 4000 ? conversationText.slice(0, 4000) + "\n..." : conversationText;
208
+ }
209
+ }
210
+ if (!titleInput) { skipped++; continue; }
211
+ const title = await this.generateTitleFromText(titleInput, `retitle-${issue.number}`);
212
+ if (!title) { skipped++; continue; }
213
+ await this.client.updateIssue(issue.number, { title });
214
+ this.api.logger.info?.(`clawmem: retitled issue #${issue.number} -> "${title}"`);
215
+ retitledIssues.push(issue.number);
216
+ updated++;
217
+ } catch (e) {
218
+ this.api.logger.warn(`clawmem: retitle failed for issue #${issue.number}: ${String(e)}`);
219
+ failed++;
220
+ }
221
+ }
222
+ if (issues.length < 50) break;
223
+ page++;
224
+ }
225
+ return { updated, skipped, failed, retitledIssues };
226
+ }
227
+
228
+ private async generateTitleFromText(text: string, uniqueKey: string): Promise<string | undefined> {
229
+ const subagent = this.api.runtime.subagent;
230
+ const sessionKey = `clawmem-${uniqueKey}`;
231
+ const message = [
232
+ "Generate a short, accurate title based on the following conversation content.",
233
+ 'Return valid JSON only in the form {"title":"..."}',
234
+ "",
235
+ "Title rules:",
236
+ "- Under 50 characters.",
237
+ "- Accurately describe the main topic or task.",
238
+ "- Should let someone immediately know what the conversation was about.",
239
+ "- Must be in the same language as the content.",
240
+ "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
241
+ "",
242
+ "<content>",
243
+ text,
244
+ "</content>",
245
+ ].join("\n");
246
+ try {
247
+ const run = await subagent.run({
248
+ sessionKey, message, deliver: false, lane: "clawmem-retitle",
249
+ idempotencyKey: sha256(`retitle:${uniqueKey}:${text.slice(0, 200)}`),
250
+ extraSystemPrompt: "You generate accurate, descriptive titles. Output JSON only with a string field title.",
251
+ });
252
+ const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: 30000 });
253
+ if (wait.status === "timeout" || wait.status === "error") return undefined;
254
+ const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 10 })).messages);
255
+ const raw = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
256
+ if (!raw) return undefined;
257
+ return parseTitle(raw);
258
+ } catch (e) {
259
+ this.api.logger.warn(`clawmem: title generation from text failed (${uniqueKey}): ${String(e)}`);
260
+ return undefined;
261
+ } finally {
262
+ subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
263
+ }
264
+ }
265
+
266
+ private buildLabels(session: SessionMirrorState, _snapshot: TranscriptSnapshot, _closed: boolean): string[] {
267
+ const labels = new Set([...DEFAULT_LABELS, "type:conversation", `session:${session.sessionId}`]);
127
268
  if (session.agentId) labels.add(`${AGENT_LABEL_PREFIX}${session.agentId}`);
128
- labels.add(closed ? LABEL_CLOSED : LABEL_ACTIVE);
129
269
  return [...labels].filter((l) => l.trim().length > 0);
130
270
  }
131
271
 
132
- private renderBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean): string {
272
+ private renderBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, _closed: boolean): string {
133
273
  const dates = this.resolveDates(session, snapshot.messages);
134
274
  return stringifyFlatYaml([
135
275
  ["type", "conversation"], ["session_id", session.sessionId], ["date", dates.date],
136
276
  ["start_at", dates.startAt], ["end_at", dates.endAt],
137
- ["status", closed ? "closed" : "active"], ["summary", summary],
277
+ ["summary", summary],
138
278
  ]);
139
279
  }
140
280
 
@@ -180,6 +320,7 @@ export class ConversationMirror {
180
320
  private resetIssueBinding(session: SessionMirrorState): void {
181
321
  session.issueNumber = undefined;
182
322
  session.issueTitle = undefined;
323
+ session.titleSource = undefined;
183
324
  session.lastSummaryHash = undefined;
184
325
  session.lastMirroredCount = 0;
185
326
  session.turnCount = 0;
@@ -192,14 +333,8 @@ function isNotFoundError(error: unknown): boolean {
192
333
  const text = String(error);
193
334
  return text.includes("HTTP 404");
194
335
  }
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
- }
336
+ /** Derive an initial placeholder title for a new conversation. The real title is generated by LLM once enough messages are available. */
337
+ export function deriveInitialTitle(_messages: NormalizedMessage[], sessionId: string): string {
203
338
  return `${SESSION_TITLE_PREFIX}${sessionId}`;
204
339
  }
205
340
 
@@ -231,3 +366,27 @@ function parseSummaryAndTitle(raw: string): { summary: string; title?: string }
231
366
  if (f?.[1]) { const nested = tryParse(f[1].trim()); if (nested) return nested; }
232
367
  return { summary: t };
233
368
  }
369
+
370
+ function parseTitle(raw: string): string | undefined {
371
+ const tryParse = (s: string): string | undefined => {
372
+ try {
373
+ const p = JSON.parse(s) as { title?: unknown };
374
+ return typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
375
+ } catch {
376
+ const i = s.indexOf("{"), j = s.lastIndexOf("}");
377
+ if (i >= 0 && j > i) {
378
+ try {
379
+ const p = JSON.parse(s.slice(i, j + 1)) as { title?: unknown };
380
+ return typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
381
+ } catch { return undefined; }
382
+ }
383
+ return undefined;
384
+ }
385
+ };
386
+ const t = raw.trim();
387
+ const direct = tryParse(t);
388
+ if (direct) return direct;
389
+ const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
390
+ if (f?.[1]) return tryParse(f[1].trim());
391
+ return undefined;
392
+ }
@@ -1,13 +1,24 @@
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, ClawMemResolvedRoute } from "./types.js";
3
+ import type { AgentRegistrationResponse, 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
+ type SearchIssuesResponse = { items?: IssueResponse[]; total_count?: number; incomplete_results?: boolean };
7
+ type CommentResponse = { id?: number; body?: string; created_at?: string };
8
+ type LabelResponse = { name?: string; color?: string; description?: string };
9
+ type RepoResponse = { name?: string; full_name?: string; description?: string; private?: boolean; owner?: { login?: string } };
6
10
  type ReqOpts = { allowNotFound?: boolean; allowValidationError?: boolean; omitAuth?: boolean };
7
11
 
8
12
  export class GitHubIssueClient {
9
13
  constructor(private readonly config: ClawMemResolvedRoute, private readonly log: { warn?: (msg: string) => void }) {}
10
14
 
15
+ repo(): string | undefined {
16
+ return this.config.repo?.trim() || undefined;
17
+ }
18
+ defaultRepo(): string | undefined {
19
+ return this.config.defaultRepo?.trim() || undefined;
20
+ }
21
+
11
22
  async createIssue(params: { title: string; body: string; labels: string[] }): Promise<IssueResponse> {
12
23
  return this.req<IssueResponse>(this.repoPath("issues"), { method: "POST", body: JSON.stringify(params) });
13
24
  }
@@ -20,12 +31,46 @@ export class GitHubIssueClient {
20
31
  async createComment(issueNumber: number, body: string): Promise<void> {
21
32
  await this.req(this.repoPath(`issues/${issueNumber}/comments`), { method: "POST", body: JSON.stringify({ body }) });
22
33
  }
34
+ async listComments(issueNumber: number, params?: { page?: number; perPage?: number }): Promise<CommentResponse[]> {
35
+ const q = new URLSearchParams();
36
+ q.set("page", String(params?.page ?? 1));
37
+ q.set("per_page", String(params?.perPage ?? 100));
38
+ return this.req<CommentResponse[]>(`${this.repoPath(`issues/${issueNumber}/comments`)}?${q}`, { method: "GET" });
39
+ }
23
40
  async listIssues(params: { labels?: string[]; state?: "open" | "closed" | "all"; page?: number; perPage?: number }): Promise<IssueResponse[]> {
24
41
  const q = new URLSearchParams();
25
42
  q.set("state", params.state ?? "open"); q.set("page", String(params.page ?? 1)); q.set("per_page", String(params.perPage ?? 100));
26
43
  if (params.labels?.length) q.set("labels", params.labels.join(","));
27
44
  return this.req<IssueResponse[]>(`${this.repoPath("issues")}?${q}`, { method: "GET" });
28
45
  }
46
+ async searchIssues(query: string, params?: { page?: number; perPage?: number }): Promise<IssueResponse[]> {
47
+ const q = new URLSearchParams();
48
+ q.set("q", query);
49
+ q.set("page", String(params?.page ?? 1));
50
+ q.set("per_page", String(params?.perPage ?? 100));
51
+ const res = await this.req<SearchIssuesResponse>(`search/issues?${q}`, { method: "GET" });
52
+ return Array.isArray(res?.items) ? res.items : [];
53
+ }
54
+ async listLabels(params?: { page?: number; perPage?: number }): Promise<LabelResponse[]> {
55
+ const q = new URLSearchParams();
56
+ q.set("page", String(params?.page ?? 1));
57
+ q.set("per_page", String(params?.perPage ?? 100));
58
+ return this.req<LabelResponse[]>(`${this.repoPath("labels")}?${q}`, { method: "GET" });
59
+ }
60
+ async listUserRepos(): Promise<RepoResponse[]> {
61
+ return this.req<RepoResponse[]>("user/repos", { method: "GET" });
62
+ }
63
+ async createUserRepo(params: { name: string; description?: string; private?: boolean; autoInit?: boolean }): Promise<RepoResponse> {
64
+ return this.req<RepoResponse>("user/repos", {
65
+ method: "POST",
66
+ body: JSON.stringify({
67
+ name: params.name,
68
+ ...(params.description ? { description: params.description } : {}),
69
+ private: params.private ?? true,
70
+ auto_init: params.autoInit ?? false,
71
+ }),
72
+ });
73
+ }
29
74
  async ensureLabels(labels: string[]): Promise<void> {
30
75
  for (const label of labels) {
31
76
  if (!label.trim()) continue;
@@ -44,6 +89,15 @@ export class GitHubIssueClient {
44
89
  async updateRepoDescription(description: string): Promise<void> {
45
90
  await this.req(this.repoPath("").replace(/\/$/, ""), { method: "PATCH", body: JSON.stringify({ description }) });
46
91
  }
92
+ async registerAgent(prefixLogin: string, defaultRepoName: string): Promise<AgentRegistrationResponse> {
93
+ return this.req<AgentRegistrationResponse>("agents", {
94
+ method: "POST",
95
+ body: JSON.stringify({
96
+ prefix_login: prefixLogin,
97
+ default_repo_name: defaultRepoName,
98
+ }),
99
+ }, { omitAuth: true });
100
+ }
47
101
  async createAnonymousSession(locale?: string): Promise<AnonymousSessionResponse> {
48
102
  const body = locale ? JSON.stringify({ locale }) : undefined;
49
103
  return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST", ...(body ? { body } : {}) }, { omitAuth: true });