@clawmem-ai/clawmem 0.1.7 → 0.1.9

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 CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  **What it does:**
6
6
  - Creates one `type:conversation` issue per session, mirrors the full transcript as comments.
7
- - On session end: auto-extracts durable memories and stores each as a `type:memory` issue.
7
+ - During request-scoped hooks: best-effort extracts durable memories and stores each as a `type:memory` issue.
8
8
  - On session start: searches active memories by relevance and injects them into context.
9
+ - Lets agents inspect memory indexes and schema, fetch exact memories, update canonical facts in place, and write structured memories with `kind:*` and `topic:*` labels through plugin tools.
9
10
 
10
11
  ---
11
12
 
@@ -33,10 +34,13 @@ Finally: your `IDENTITY.md` is not something you fill out on day one. It fills i
33
34
 
34
35
  ```bash
35
36
  openclaw plugins install @clawmem-ai/clawmem
37
+ openclaw plugins enable clawmem
38
+ openclaw config set plugins.slots.memory clawmem
39
+ openclaw config validate
36
40
  openclaw gateway restart
37
41
  ```
38
42
 
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.
43
+ After restart, confirm OpenClaw shows ClawMem as the active memory plugin. clawmem then 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
44
 
41
45
  ---
42
46
 
@@ -75,7 +79,7 @@ Without it, every session starts from zero. With it, what you learn persists. On
75
79
  After installing clawmem, you have two memory systems running in parallel:
76
80
 
77
81
  1. **Workspace files** (`MEMORY.md`, `SOUL.md`, etc.) — always injected into every session via system prompt
78
- 2. **clawmem** — auto-extracts memories at session end; selectively injects by relevance at session start
82
+ 2. **clawmem** — auto-extracts memories during request-scoped maintenance; selectively injects by relevance at session start
79
83
 
80
84
  These two systems do not sync. Without explicit rules, the agent defaults to the familiar path (workspace files), and the two systems start to contradict each other.
81
85
 
@@ -113,7 +117,7 @@ clawmem is my brain. One system per job — no exceptions:
113
117
  | What | Where |
114
118
  |---|---|
115
119
  | Credentials / API keys | `knowledge/MEMORY.md` (always injected) |
116
- | Everything I learn, conclude, or figure out | clawmem only — auto-captured at session end |
120
+ | Everything I learn, conclude, or figure out | clawmem only — auto-captured during request-scoped maintenance |
117
121
  | Files | Only when a tool or another human needs to read them |
118
122
  | Workspace versioning | clawmem git service (not github.com) |
119
123
 
@@ -144,7 +148,7 @@ Strip everything except credentials. Add a header comment:
144
148
 
145
149
  ## Locking Key Insights Manually
146
150
 
147
- clawmem auto-extracts memories at session end — but important insights deserve to be locked immediately, not left to chance extraction.
151
+ clawmem auto-extracts memories during later request-scoped maintenance — but important insights deserve to be locked immediately, not left to chance extraction.
148
152
 
149
153
  After any significant realization, create a memory issue directly:
150
154
 
@@ -158,7 +162,7 @@ curl -X POST "https://git.clawmem.ai/api/v3/repos/$CLAWMEM_REPO/issues" \
158
162
  -d '{
159
163
  "title": "Memory: <concise title>",
160
164
  "body": "<the insight, in plain language>",
161
- "labels": ["type:memory", "memory-status:active"]
165
+ "labels": ["type:memory"]
162
166
  }'
163
167
  ```
164
168
 
@@ -189,7 +193,7 @@ GH_HOST=git.clawmem.ai GH_TOKEN=$CLAWMEM_TOKEN \
189
193
  gh issue create --repo <owner/team-memory> \
190
194
  --title "Memory: ..." \
191
195
  --body "..." \
192
- --label "type:memory,memory-status:active,source:team"
196
+ --label "type:memory,source:team"
193
197
  ```
194
198
 
195
199
  **Read team memories:**
@@ -197,7 +201,8 @@ GH_HOST=git.clawmem.ai GH_TOKEN=$CLAWMEM_TOKEN \
197
201
  ```bash
198
202
  GH_HOST=git.clawmem.ai GH_TOKEN=$CLAWMEM_TOKEN \
199
203
  gh issue list --repo <owner/team-memory> \
200
- --label "memory-status:active" \
204
+ --state open \
205
+ --label "type:memory" \
201
206
  --json number,title,body
202
207
  ```
203
208
 
@@ -282,10 +287,6 @@ Full config with all options:
282
287
  memoryTitlePrefix: "Memory: ",
283
288
  defaultLabels: ["source:openclaw"],
284
289
  agentLabelPrefix: "agent:",
285
- activeStatusLabel: "status:active",
286
- closedStatusLabel: "status:closed",
287
- memoryActiveStatusLabel: "memory-status:active",
288
- memoryStaleStatusLabel: "memory-status:stale",
289
290
  autoCreateLabels: true,
290
291
  closeIssueOnReset: true,
291
292
  turnCommentDelayMs: 1000,
@@ -304,6 +305,11 @@ Full config with all options:
304
305
 
305
306
  - Conversation comments exclude tool calls, tool results, system messages, and heartbeat noise.
306
307
  - Summary failures do not block finalization; the `summary` field is written as `failed: ...`.
307
- - Memory search and auto-injection only return `memory-status:active` issues.
308
- - Durable memories are auto-captured on session finalize no memory tools are injected into the agent tool list.
309
- - Memory issue bodies store only the detail text; metadata comes from labels and issue number.
308
+ - Memory search and auto-injection only return open `type:memory` issues. Closed memory issues are treated as stale.
309
+ - `memory_recall` now prefers the backend `/api/v3/search/issues` endpoint scoped to the current repo plus `label:"type:memory"`; if backend search fails, clawmem falls back to local lexical ranking.
310
+ - Durable memories are extracted best-effort during later request-scoped maintenance, not by background subagent work after a request has already ended.
311
+ - The plugin exposes `memory_list`, `memory_get`, `memory_labels`, `memory_recall`, `memory_store`, `memory_update`, and `memory_forget` for mid-session use.
312
+ - `memory_store` accepts optional schema hints such as kind and topics; the plugin normalizes them into managed `kind:*` and `topic:*` labels.
313
+ - `memory_update` updates one existing memory issue in place; use it for evolving canonical facts or active tasks instead of creating a duplicate node.
314
+ - Conversation lifecycle is stored in native issue state (`open` while live, `closed` after finalize); memory lifecycle uses native issue state too (`open` active, `closed` stale).
315
+ - Memory issue bodies store the durable detail plus flat metadata such as `memory_hash` and logical `date`; labels are reserved for schema and routing.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "clawmem",
3
3
  "name": "ClawMem",
4
- "version": "0.1.7",
4
+ "version": "0.1.9",
5
5
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
6
6
  "kind": "memory",
7
7
  "configSchema": {
@@ -148,16 +148,20 @@
148
148
  "help": "Dynamic agent label prefix, for example agent:coder."
149
149
  },
150
150
  "activeStatusLabel": {
151
- "label": "Active Status Label"
151
+ "label": "Active Status Label",
152
+ "help": "Deprecated compatibility setting. Conversation lifecycle now uses native issue state."
152
153
  },
153
154
  "closedStatusLabel": {
154
- "label": "Closed Status Label"
155
+ "label": "Closed Status Label",
156
+ "help": "Deprecated compatibility setting. Conversation lifecycle now uses native issue state."
155
157
  },
156
158
  "memoryActiveStatusLabel": {
157
- "label": "Memory Active Status Label"
159
+ "label": "Memory Active Status Label",
160
+ "help": "Deprecated compatibility setting. Memory lifecycle now uses native issue state."
158
161
  },
159
162
  "memoryStaleStatusLabel": {
160
- "label": "Memory Stale Status Label"
163
+ "label": "Memory Stale Status Label",
164
+ "help": "Deprecated compatibility setting. Memory lifecycle now uses native issue state."
161
165
  },
162
166
  "autoCreateLabels": {
163
167
  "label": "Auto Create Labels"
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.9",
4
4
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -12,7 +12,7 @@ export const LABEL_CLOSED = "status:closed";
12
12
  export const LABEL_MEMORY_ACTIVE = "memory-status:active";
13
13
  export const LABEL_MEMORY_STALE = "memory-status:stale";
14
14
 
15
- const MANAGED_PREFIXES = ["type:", "session:", "date:", "topic:", "agent:", "source:"];
15
+ const MANAGED_PREFIXES = ["type:", "kind:", "session:", "date:", "topic:", "agent:", "source:"];
16
16
  const MANAGED_EXACT = new Set([LABEL_ACTIVE, LABEL_CLOSED, LABEL_MEMORY_ACTIVE, LABEL_MEMORY_STALE]);
17
17
 
18
18
  export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig {
@@ -67,6 +67,7 @@ export function resolveLabelColor(label: string): string {
67
67
  if (label.startsWith("status:")) return "b60205";
68
68
  if (label.startsWith("memory-status:")) return label.endsWith(":stale") ? "d93f0b" : "0e8a16";
69
69
  if (label.startsWith("type:")) return label === "type:memory" ? "5319e7" : "1d76db";
70
+ if (label.startsWith("kind:")) return "5319e7";
70
71
  if (label.startsWith("date:")) return "c5def5";
71
72
  if (label.startsWith("topic:")) return "fbca04";
72
73
  if (label.startsWith("session:")) return "bfdadc";
@@ -76,7 +77,7 @@ export function resolveLabelColor(label: string): string {
76
77
  }
77
78
 
78
79
  export function labelDescription(label: string): string {
79
- for (const [pfx, d] of [["type:", "Issue type"], ["memory-status:", "Memory lifecycle status"],
80
+ for (const [pfx, d] of [["type:", "Issue type"], ["kind:", "Memory kind"], ["memory-status:", "Memory lifecycle status"],
80
81
  ["status:", "Conversation lifecycle status"], ["session:", "Session association"],
81
82
  ["date:", "Date"], ["topic:", "Topic"], ["agent:", "Agent"], ["source:", "Source"]] as const)
82
83
  if (label.startsWith(pfx)) return `${d} label managed by clawmem.`;
@@ -0,0 +1,70 @@
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 }> = [
10
+ {
11
+ name: "returns placeholder regardless of user message content",
12
+ messages: [msg("user", "How do I configure Redis rate limiting?")],
13
+ sessionId: "abc123",
14
+ expected: "Session: abc123",
15
+ },
16
+ {
17
+ name: "returns placeholder for long messages",
18
+ messages: [msg("user", "I need help with configuring the distributed rate limiting system for our production Redis cluster")],
19
+ sessionId: "abc123",
20
+ expected: "Session: abc123",
21
+ },
22
+ {
23
+ name: "returns placeholder for messages with markdown",
24
+ messages: [msg("user", "## How do I **configure** `Redis` rate limiting?")],
25
+ sessionId: "abc123",
26
+ expected: "Session: abc123",
27
+ },
28
+ {
29
+ name: "returns placeholder for short messages",
30
+ messages: [msg("user", "hi")],
31
+ sessionId: "abc-def-123",
32
+ expected: "Session: abc-def-123",
33
+ },
34
+ {
35
+ name: "returns placeholder when no user messages",
36
+ messages: [msg("assistant", "Hello!")],
37
+ sessionId: "xyz-789",
38
+ expected: "Session: xyz-789",
39
+ },
40
+ {
41
+ name: "returns placeholder for empty messages",
42
+ messages: [],
43
+ sessionId: "empty-sess",
44
+ expected: "Session: empty-sess",
45
+ },
46
+ {
47
+ name: "returns placeholder with session ID for any input",
48
+ messages: [msg("assistant", "Welcome!"), msg("user", "Fix the login bug please")],
49
+ sessionId: "abc",
50
+ expected: "Session: abc",
51
+ },
52
+ ];
53
+
54
+ let passed = 0;
55
+ let failed = 0;
56
+
57
+ for (const t of tests) {
58
+ const got = deriveInitialTitle(t.messages, t.sessionId);
59
+ const ok = got === t.expected;
60
+ if (!ok) {
61
+ console.error(`FAIL: ${t.name}\n got: ${JSON.stringify(got)}\n expected: ${JSON.stringify(t.expected)}`);
62
+ failed++;
63
+ } else {
64
+ console.log(`PASS: ${t.name}`);
65
+ passed++;
66
+ }
67
+ }
68
+
69
+ console.log(`\n${passed} passed, ${failed} failed`);
70
+ if (failed > 0) process.exit(1);
@@ -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,26 +47,30 @@ export class ConversationMirror {
47
47
  );
48
48
  this.resetIssueBinding(session);
49
49
  }
50
- const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
50
+ // Use placeholder title; real title is generated by LLM once enough messages are available.
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);
54
55
  const issue = await this.client.createIssue({ title, body, labels });
55
56
  session.issueNumber = issue.number;
56
57
  session.issueTitle = issue.title ?? title;
58
+ session.titleSource = "placeholder";
57
59
  session.lastSummaryHash = sha256(`${title}\n${body}\nopen`);
58
60
  session.createdAt = new Date().toISOString();
59
61
  session.updatedAt = session.createdAt;
60
62
  }
61
63
 
62
- async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean): Promise<void> {
64
+ async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean, titleOverride?: string): Promise<void> {
63
65
  if (!session.issueNumber) return;
64
- const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
66
+ // Prefer explicit override (LLM-generated title), then keep existing title, then fall back to session ID.
67
+ const title = titleOverride?.trim() || session.issueTitle || `${SESSION_TITLE_PREFIX}${session.sessionId}`;
65
68
  const body = this.renderBody(session, snapshot, summary, closed);
66
69
  const hash = sha256(`${title}\n${body}\n${closed ? "closed" : "open"}`);
67
70
  if (hash === session.lastSummaryHash) return;
68
71
  await this.client.updateIssue(session.issueNumber, { title, body, ...(closed ? { state: "closed" as const } : {}) });
69
72
  session.issueTitle = title;
73
+ if (titleOverride?.trim()) session.titleSource = "llm";
70
74
  session.lastSummaryHash = hash;
71
75
  }
72
76
 
@@ -86,22 +90,28 @@ export class ConversationMirror {
86
90
  return count;
87
91
  }
88
92
 
89
- async generateSummary(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<string> {
93
+ async generateSummaryAndTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<{ summary: string; title?: string }> {
90
94
  if (snapshot.messages.length === 0) throw new Error("no conversation messages to summarize");
91
95
  const subagent = this.api.runtime.subagent;
92
96
  const sessionKey = subKey(session, "summary");
93
97
  const message = [
94
- "Summarize the following conversation.",
95
- 'Return valid JSON only in the form {"summary":"..."}',
98
+ "Summarize the following conversation and generate a short title.",
99
+ 'Return valid JSON only in the form {"summary":"...","title":"..."}',
96
100
  "The summary should be concise, factual, and written in 2-4 sentences.",
97
101
  "Do not include markdown, bullet points, or analysis.",
102
+ "",
103
+ "Title rules:",
104
+ "- Under 50 characters, accurately describe the main topic or task.",
105
+ "- Should let someone immediately know what the conversation is about.",
106
+ "- Must be in the same language as the majority of the conversation content.",
107
+ "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
98
108
  "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
99
109
  ].join("\n");
100
110
  try {
101
111
  const run = await subagent.run({
102
112
  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.",
113
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary-v2`),
114
+ extraSystemPrompt: "You summarize conversations and generate accurate, descriptive titles. Output JSON only with string fields summary and title.",
105
115
  });
106
116
  const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
107
117
  if (wait.status === "timeout") throw new Error("summary subagent timed out");
@@ -109,24 +119,162 @@ export class ConversationMirror {
109
119
  const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
110
120
  const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
111
121
  if (!text) throw new Error("summary subagent returned no assistant text");
112
- return parseSummary(text);
122
+ return parseSummaryAndTitle(text);
113
123
  } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
114
124
  }
115
125
 
116
- private buildLabels(session: SessionMirrorState, snapshot: TranscriptSnapshot, closed: boolean): string[] {
117
- const dates = this.resolveDates(session, snapshot.messages);
118
- 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}`]);
119
268
  if (session.agentId) labels.add(`${AGENT_LABEL_PREFIX}${session.agentId}`);
120
- labels.add(closed ? LABEL_CLOSED : LABEL_ACTIVE);
121
269
  return [...labels].filter((l) => l.trim().length > 0);
122
270
  }
123
271
 
124
- private renderBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean): string {
272
+ private renderBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, _closed: boolean): string {
125
273
  const dates = this.resolveDates(session, snapshot.messages);
126
274
  return stringifyFlatYaml([
127
275
  ["type", "conversation"], ["session_id", session.sessionId], ["date", dates.date],
128
276
  ["start_at", dates.startAt], ["end_at", dates.endAt],
129
- ["status", closed ? "closed" : "active"], ["summary", summary],
277
+ ["summary", summary],
130
278
  ]);
131
279
  }
132
280
 
@@ -172,6 +320,7 @@ export class ConversationMirror {
172
320
  private resetIssueBinding(session: SessionMirrorState): void {
173
321
  session.issueNumber = undefined;
174
322
  session.issueTitle = undefined;
323
+ session.titleSource = undefined;
175
324
  session.lastSummaryHash = undefined;
176
325
  session.lastMirroredCount = 0;
177
326
  session.turnCount = 0;
@@ -184,11 +333,30 @@ function isNotFoundError(error: unknown): boolean {
184
333
  const text = String(error);
185
334
  return text.includes("HTTP 404");
186
335
  }
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; } }
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 {
338
+ return `${SESSION_TITLE_PREFIX}${sessionId}`;
339
+ }
340
+
341
+ function parseSummaryAndTitle(raw: string): { summary: string; title?: string } {
342
+ const tryParse = (s: string): { summary: string; title?: string } | null => {
343
+ try {
344
+ const p = JSON.parse(s) as { summary?: unknown; title?: unknown };
345
+ const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
346
+ if (!summary) return null;
347
+ const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
348
+ return { summary, title };
349
+ } catch {
350
+ const i = s.indexOf("{"), j = s.lastIndexOf("}");
351
+ if (i >= 0 && j > i) {
352
+ try {
353
+ const p = JSON.parse(s.slice(i, j + 1)) as { summary?: unknown; title?: unknown };
354
+ const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
355
+ if (!summary) return null;
356
+ const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
357
+ return { summary, title };
358
+ } catch { return null; }
359
+ }
192
360
  return null;
193
361
  }
194
362
  };
@@ -196,5 +364,29 @@ function parseSummary(raw: string): string {
196
364
  const direct = tryParse(t); if (direct) return direct;
197
365
  const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
198
366
  if (f?.[1]) { const nested = tryParse(f[1].trim()); if (nested) return nested; }
199
- return t;
367
+ return { summary: t };
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;
200
392
  }
@@ -3,11 +3,18 @@ import { resolveLabelColor, labelDescription, extractLabelNames, isManagedLabel
3
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
+ 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 };
6
9
  type ReqOpts = { allowNotFound?: boolean; allowValidationError?: boolean; omitAuth?: boolean };
7
10
 
8
11
  export class GitHubIssueClient {
9
12
  constructor(private readonly config: ClawMemResolvedRoute, private readonly log: { warn?: (msg: string) => void }) {}
10
13
 
14
+ repo(): string | undefined {
15
+ return this.config.repo?.trim() || undefined;
16
+ }
17
+
11
18
  async createIssue(params: { title: string; body: string; labels: string[] }): Promise<IssueResponse> {
12
19
  return this.req<IssueResponse>(this.repoPath("issues"), { method: "POST", body: JSON.stringify(params) });
13
20
  }
@@ -20,12 +27,32 @@ export class GitHubIssueClient {
20
27
  async createComment(issueNumber: number, body: string): Promise<void> {
21
28
  await this.req(this.repoPath(`issues/${issueNumber}/comments`), { method: "POST", body: JSON.stringify({ body }) });
22
29
  }
30
+ async listComments(issueNumber: number, params?: { page?: number; perPage?: number }): Promise<CommentResponse[]> {
31
+ const q = new URLSearchParams();
32
+ q.set("page", String(params?.page ?? 1));
33
+ q.set("per_page", String(params?.perPage ?? 100));
34
+ return this.req<CommentResponse[]>(`${this.repoPath(`issues/${issueNumber}/comments`)}?${q}`, { method: "GET" });
35
+ }
23
36
  async listIssues(params: { labels?: string[]; state?: "open" | "closed" | "all"; page?: number; perPage?: number }): Promise<IssueResponse[]> {
24
37
  const q = new URLSearchParams();
25
38
  q.set("state", params.state ?? "open"); q.set("page", String(params.page ?? 1)); q.set("per_page", String(params.perPage ?? 100));
26
39
  if (params.labels?.length) q.set("labels", params.labels.join(","));
27
40
  return this.req<IssueResponse[]>(`${this.repoPath("issues")}?${q}`, { method: "GET" });
28
41
  }
42
+ async searchIssues(query: string, params?: { page?: number; perPage?: number }): Promise<IssueResponse[]> {
43
+ const q = new URLSearchParams();
44
+ q.set("q", query);
45
+ q.set("page", String(params?.page ?? 1));
46
+ q.set("per_page", String(params?.perPage ?? 100));
47
+ const res = await this.req<SearchIssuesResponse>(`search/issues?${q}`, { method: "GET" });
48
+ return Array.isArray(res?.items) ? res.items : [];
49
+ }
50
+ async listLabels(params?: { page?: number; perPage?: number }): Promise<LabelResponse[]> {
51
+ const q = new URLSearchParams();
52
+ q.set("page", String(params?.page ?? 1));
53
+ q.set("per_page", String(params?.perPage ?? 100));
54
+ return this.req<LabelResponse[]>(`${this.repoPath("labels")}?${q}`, { method: "GET" });
55
+ }
29
56
  async ensureLabels(labels: string[]): Promise<void> {
30
57
  for (const label of labels) {
31
58
  if (!label.trim()) continue;
@@ -38,8 +65,15 @@ export class GitHubIssueClient {
38
65
  const unmanaged = extractLabelNames(issue.labels).filter((l) => !isManagedLabel(l));
39
66
  await this.updateIssue(issueNumber, { labels: [...new Set([...unmanaged, ...desired])] });
40
67
  }
41
- async createAnonymousSession(): Promise<AnonymousSessionResponse> {
42
- return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST" }, { omitAuth: true });
68
+ async getRepoInfo(): Promise<{ description?: string; name?: string }> {
69
+ return this.req<{ description?: string; name?: string }>(this.repoPath("").replace(/\/$/, ""), { method: "GET" });
70
+ }
71
+ async updateRepoDescription(description: string): Promise<void> {
72
+ await this.req(this.repoPath("").replace(/\/$/, ""), { method: "PATCH", body: JSON.stringify({ description }) });
73
+ }
74
+ async createAnonymousSession(locale?: string): Promise<AnonymousSessionResponse> {
75
+ const body = locale ? JSON.stringify({ locale }) : undefined;
76
+ return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST", ...(body ? { body } : {}) }, { omitAuth: true });
43
77
  }
44
78
 
45
79
  private repoPath(suffix: string): string {