@clawmem-ai/clawmem 0.1.8 → 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.8",
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.8",
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.`;
@@ -6,54 +6,48 @@ function msg(role: string, text: string): NormalizedMessage {
6
6
  return { role, text };
7
7
  }
8
8
 
9
- const tests: Array<{ name: string; messages: NormalizedMessage[]; sessionId: string; expected: string | RegExp }> = [
9
+ const tests: Array<{ name: string; messages: NormalizedMessage[]; sessionId: string; expected: string }> = [
10
10
  {
11
- name: "uses first user message",
11
+ name: "returns placeholder regardless of user message content",
12
12
  messages: [msg("user", "How do I configure Redis rate limiting?")],
13
13
  sessionId: "abc123",
14
- expected: "How do I configure Redis rate limiting?",
14
+ expected: "Session: abc123",
15
15
  },
16
16
  {
17
- name: "truncates long messages to 50 chars",
17
+ name: "returns placeholder for long messages",
18
18
  messages: [msg("user", "I need help with configuring the distributed rate limiting system for our production Redis cluster")],
19
19
  sessionId: "abc123",
20
- expected: /^I need help with configuring the distributed rate…$/,
20
+ expected: "Session: abc123",
21
21
  },
22
22
  {
23
- name: "strips markdown formatting",
23
+ name: "returns placeholder for messages with markdown",
24
24
  messages: [msg("user", "## How do I **configure** `Redis` rate limiting?")],
25
25
  sessionId: "abc123",
26
- expected: "How do I configure Redis rate limiting?",
26
+ expected: "Session: abc123",
27
27
  },
28
28
  {
29
- name: "falls back to session ID for short messages",
29
+ name: "returns placeholder for short messages",
30
30
  messages: [msg("user", "hi")],
31
31
  sessionId: "abc-def-123",
32
32
  expected: "Session: abc-def-123",
33
33
  },
34
34
  {
35
- name: "falls back to session ID when no user messages",
35
+ name: "returns placeholder when no user messages",
36
36
  messages: [msg("assistant", "Hello!")],
37
37
  sessionId: "xyz-789",
38
38
  expected: "Session: xyz-789",
39
39
  },
40
40
  {
41
- name: "falls back to session ID for empty messages",
41
+ name: "returns placeholder for empty messages",
42
42
  messages: [],
43
43
  sessionId: "empty-sess",
44
44
  expected: "Session: empty-sess",
45
45
  },
46
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",
47
+ name: "returns placeholder with session ID for any input",
54
48
  messages: [msg("assistant", "Welcome!"), msg("user", "Fix the login bug please")],
55
49
  sessionId: "abc",
56
- expected: "Fix the login bug please",
50
+ expected: "Session: abc",
57
51
  },
58
52
  ];
59
53
 
@@ -62,9 +56,9 @@ let failed = 0;
62
56
 
63
57
  for (const t of tests) {
64
58
  const got = deriveInitialTitle(t.messages, t.sessionId);
65
- const ok = t.expected instanceof RegExp ? t.expected.test(got) : got === t.expected;
59
+ const ok = got === t.expected;
66
60
  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)}`);
61
+ console.error(`FAIL: ${t.name}\n got: ${JSON.stringify(got)}\n expected: ${JSON.stringify(t.expected)}`);
68
62
  failed++;
69
63
  } else {
70
64
  console.log(`PASS: ${t.name}`);
@@ -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
+ }
@@ -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;