@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 +21 -15
- package/openclaw.plugin.json +9 -5
- package/package.json +1 -1
- package/src/config.ts +3 -2
- package/src/conversation.test.ts +14 -20
- package/src/conversation.ts +180 -21
- package/src/github-client.ts +27 -0
- package/src/memory.test.ts +373 -0
- package/src/memory.ts +370 -45
- package/src/service.ts +349 -17
- package/src/state.ts +16 -0
- package/src/types.ts +13 -3
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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,
|
|
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
|
-
--
|
|
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
|
|
308
|
-
-
|
|
309
|
-
-
|
|
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.
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "clawmem",
|
|
3
3
|
"name": "ClawMem",
|
|
4
|
-
"version": "0.1.
|
|
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
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.`;
|
package/src/conversation.test.ts
CHANGED
|
@@ -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
|
|
9
|
+
const tests: Array<{ name: string; messages: NormalizedMessage[]; sessionId: string; expected: string }> = [
|
|
10
10
|
{
|
|
11
|
-
name: "
|
|
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: "
|
|
14
|
+
expected: "Session: abc123",
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
|
-
name: "
|
|
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:
|
|
20
|
+
expected: "Session: abc123",
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
name: "
|
|
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: "
|
|
26
|
+
expected: "Session: abc123",
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
|
-
name: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 =
|
|
59
|
+
const ok = got === t.expected;
|
|
66
60
|
if (!ok) {
|
|
67
|
-
console.error(`FAIL: ${t.name}\n got: ${JSON.stringify(got)}\n 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}`);
|
package/src/conversation.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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,
|
|
103
|
-
"- Should
|
|
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:
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
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
|
-
["
|
|
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
|
|
196
|
-
export function deriveInitialTitle(
|
|
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
|
+
}
|
package/src/github-client.ts
CHANGED
|
@@ -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;
|