@clawmem-ai/clawmem 0.1.3

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.
@@ -0,0 +1,162 @@
1
+ // Session mirroring: creates/updates GitHub issues and comments for each conversation.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
5
+ import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, LABEL_ACTIVE, LABEL_CLOSED, SESSION_TITLE_PREFIX } from "./config.js";
6
+ import type { GitHubIssueClient } from "./github-client.js";
7
+ import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
8
+ import type { ClawMemPluginConfig, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
9
+ import { fmtTranscript, localDate, localDateTime, sha256, subKey } from "./utils.js";
10
+ import { stringifyFlatYaml } from "./yaml.js";
11
+
12
+ export class ConversationMirror {
13
+ constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
14
+
15
+ shouldMirror(sessionId: string, messages: NormalizedMessage[]): boolean {
16
+ if (sessionId.startsWith("slug-generator-")) return false;
17
+ const first = messages.find((m) => m.role === "user")?.text ?? "";
18
+ if (first.includes("generate a short 1-2 word filename slug") && first.includes("Reply with ONLY the slug")) return false;
19
+ if (first.includes("Summarize the following conversation.") && first.includes('Return valid JSON only in the form {"summary":"..."}')) return false;
20
+ if (first.includes("Extract durable memories from the conversation below.") && first.includes('Return JSON only in the form {"save":')) return false;
21
+ return true;
22
+ }
23
+
24
+ async loadSnapshot(session: SessionMirrorState, fallback: unknown[]): Promise<TranscriptSnapshot> {
25
+ const filePath = await this.resolveTranscriptPath(session.sessionFile);
26
+ if (filePath) {
27
+ session.sessionFile = filePath;
28
+ try {
29
+ const t = await readTranscriptSnapshot(filePath);
30
+ return { sessionId: t.sessionId ?? session.sessionId, messages: t.messages };
31
+ } catch (error) {
32
+ this.api.logger.warn(`clawmem: transcript read failed for ${filePath}: ${String(error)}`);
33
+ }
34
+ }
35
+ return { sessionId: session.sessionId, messages: normalizeMessages(fallback) };
36
+ }
37
+
38
+ async ensureIssue(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
39
+ if (session.issueNumber) return;
40
+ const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
41
+ const labels = this.buildLabels(session, snapshot, false);
42
+ const body = this.renderBody(session, snapshot, "pending", false);
43
+ await this.client.ensureLabels(labels);
44
+ const issue = await this.client.createIssue({ title, body, labels });
45
+ session.issueNumber = issue.number;
46
+ session.issueTitle = issue.title ?? title;
47
+ session.lastSummaryHash = sha256(`${title}\n${body}\nopen`);
48
+ session.createdAt = new Date().toISOString();
49
+ session.updatedAt = session.createdAt;
50
+ }
51
+
52
+ async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean): Promise<void> {
53
+ if (!session.issueNumber) return;
54
+ const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
55
+ const body = this.renderBody(session, snapshot, summary, closed);
56
+ const hash = sha256(`${title}\n${body}\n${closed ? "closed" : "open"}`);
57
+ if (hash === session.lastSummaryHash) return;
58
+ await this.client.updateIssue(session.issueNumber, { title, body, ...(closed ? { state: "closed" as const } : {}) });
59
+ session.issueTitle = title;
60
+ session.lastSummaryHash = hash;
61
+ }
62
+
63
+ async syncLabels(session: SessionMirrorState, snapshot: TranscriptSnapshot, closed: boolean): Promise<void> {
64
+ if (!session.issueNumber) return;
65
+ const labels = this.buildLabels(session, snapshot, closed);
66
+ await this.client.ensureLabels(labels);
67
+ await this.client.syncManagedLabels(session.issueNumber, labels);
68
+ }
69
+
70
+ async appendComments(issueNumber: number, messages: NormalizedMessage[]): Promise<number> {
71
+ let count = 0;
72
+ for (const msg of messages) {
73
+ try { await this.client.createComment(issueNumber, `role: ${msg.role}\n\n${msg.text.trim()}`); count++; }
74
+ catch (error) { this.api.logger.warn(`clawmem: conversation comment failed: ${String(error)}`); break; }
75
+ }
76
+ return count;
77
+ }
78
+
79
+ async generateSummary(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<string> {
80
+ if (snapshot.messages.length === 0) throw new Error("no conversation messages to summarize");
81
+ const subagent = this.api.runtime.subagent;
82
+ const sessionKey = subKey(session, "summary");
83
+ const message = [
84
+ "Summarize the following conversation.",
85
+ 'Return valid JSON only in the form {"summary":"..."}',
86
+ "The summary should be concise, factual, and written in 2-4 sentences.",
87
+ "Do not include markdown, bullet points, or analysis.",
88
+ "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
89
+ ].join("\n");
90
+ try {
91
+ const run = await subagent.run({
92
+ sessionKey, message, deliver: false, lane: "clawmem-summary",
93
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary`),
94
+ extraSystemPrompt: "You summarize OpenClaw conversations. Output JSON only with one string field named summary.",
95
+ });
96
+ const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
97
+ if (wait.status === "timeout") throw new Error("summary subagent timed out");
98
+ if (wait.status === "error") throw new Error(wait.error || "summary subagent failed");
99
+ const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
100
+ const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
101
+ if (!text) throw new Error("summary subagent returned no assistant text");
102
+ return parseSummary(text);
103
+ } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
104
+ }
105
+
106
+ private buildLabels(session: SessionMirrorState, snapshot: TranscriptSnapshot, closed: boolean): string[] {
107
+ const dates = this.resolveDates(session, snapshot.messages);
108
+ const labels = new Set([...DEFAULT_LABELS, "type:conversation", `session:${session.sessionId}`, `date:${dates.date}`]);
109
+ if (session.agentId) labels.add(`${AGENT_LABEL_PREFIX}${session.agentId}`);
110
+ labels.add(closed ? LABEL_CLOSED : LABEL_ACTIVE);
111
+ return [...labels].filter((l) => l.trim().length > 0);
112
+ }
113
+
114
+ private renderBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean): string {
115
+ const dates = this.resolveDates(session, snapshot.messages);
116
+ return stringifyFlatYaml([
117
+ ["type", "conversation"], ["session_id", session.sessionId], ["date", dates.date],
118
+ ["start_at", dates.startAt], ["end_at", dates.endAt],
119
+ ["status", closed ? "closed" : "active"], ["summary", summary],
120
+ ]);
121
+ }
122
+
123
+ private resolveDates(session: SessionMirrorState, messages: NormalizedMessage[]): { date: string; startAt: string; endAt: string } {
124
+ const ts = messages.map((m) => m.timestamp).filter((v): v is string => Boolean(v?.trim()))
125
+ .map((v) => new Date(v)).filter((d) => Number.isFinite(d.getTime()));
126
+ const fallbackStart = session.createdAt ? new Date(session.createdAt) : new Date();
127
+ const fallbackEnd = session.updatedAt ? new Date(session.updatedAt) : fallbackStart;
128
+ const start = ts[0] ?? fallbackStart, end = ts.at(-1) ?? fallbackEnd;
129
+ return { date: localDate(start), startAt: localDateTime(start), endAt: localDateTime(end) };
130
+ }
131
+
132
+ private async resolveTranscriptPath(filePath: string | undefined): Promise<string | null> {
133
+ if (!filePath) return null;
134
+ if (await fexists(filePath)) return filePath;
135
+ try {
136
+ const dir = path.dirname(filePath), prefix = `${path.basename(filePath)}.reset.`;
137
+ const latest = (await fs.promises.readdir(dir, { withFileTypes: true }))
138
+ .filter((e) => e.isFile() && e.name.startsWith(prefix)).map((e) => e.name).sort().at(-1);
139
+ if (latest) {
140
+ this.api.logger.info?.(`clawmem: using reset transcript ${path.join(dir, latest)} because ${filePath} is missing`);
141
+ return path.join(dir, latest);
142
+ }
143
+ } catch { /* directory unreadable */ }
144
+ return null;
145
+ }
146
+ }
147
+
148
+ async function fexists(p: string): Promise<boolean> { try { return (await fs.promises.stat(p)).isFile(); } catch { return false; } }
149
+ function parseSummary(raw: string): string {
150
+ const tryParse = (s: string): string | null => {
151
+ try { const p = JSON.parse(s) as { summary?: unknown }; return typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null; }
152
+ catch { const i = s.indexOf("{"), j = s.lastIndexOf("}");
153
+ 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; } }
154
+ return null;
155
+ }
156
+ };
157
+ const t = raw.trim();
158
+ const direct = tryParse(t); if (direct) return direct;
159
+ const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
160
+ if (f?.[1]) { const nested = tryParse(f[1].trim()); if (nested) return nested; }
161
+ return t;
162
+ }
@@ -0,0 +1,64 @@
1
+ // GitHub Issues API client for clawmem. No label caching — idempotent create-if-absent.
2
+ import { resolveLabelColor, labelDescription, extractLabelNames, isManagedLabel } from "./config.js";
3
+ import type { AnonymousSessionResponse, ClawMemPluginConfig } from "./types.js";
4
+
5
+ type IssueResponse = { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> };
6
+ type ReqOpts = { allowNotFound?: boolean; allowValidationError?: boolean; omitAuth?: boolean };
7
+
8
+ export class GitHubIssueClient {
9
+ constructor(private readonly config: ClawMemPluginConfig, private readonly log: { warn?: (msg: string) => void }) {}
10
+
11
+ async createIssue(params: { title: string; body: string; labels: string[] }): Promise<IssueResponse> {
12
+ return this.req<IssueResponse>(this.repoPath("issues"), { method: "POST", body: JSON.stringify(params) });
13
+ }
14
+ async updateIssue(n: number, params: { title?: string; body?: string; state?: "open" | "closed"; labels?: string[] }): Promise<IssueResponse> {
15
+ return this.req<IssueResponse>(this.repoPath(`issues/${n}`), { method: "PATCH", body: JSON.stringify(params) });
16
+ }
17
+ async getIssue(n: number): Promise<IssueResponse> {
18
+ return this.req<IssueResponse>(this.repoPath(`issues/${n}`), { method: "GET" });
19
+ }
20
+ async createComment(issueNumber: number, body: string): Promise<void> {
21
+ await this.req(this.repoPath(`issues/${issueNumber}/comments`), { method: "POST", body: JSON.stringify({ body }) });
22
+ }
23
+ async listIssues(params: { labels?: string[]; state?: "open" | "closed" | "all"; page?: number; perPage?: number }): Promise<IssueResponse[]> {
24
+ const q = new URLSearchParams();
25
+ q.set("state", params.state ?? "open"); q.set("page", String(params.page ?? 1)); q.set("per_page", String(params.perPage ?? 100));
26
+ if (params.labels?.length) q.set("labels", params.labels.join(","));
27
+ return this.req<IssueResponse[]>(`${this.repoPath("issues")}?${q}`, { method: "GET" });
28
+ }
29
+ async ensureLabels(labels: string[]): Promise<void> {
30
+ for (const label of labels) {
31
+ if (!label.trim()) continue;
32
+ await this.req(this.repoPath("labels"), { method: "POST",
33
+ body: JSON.stringify({ name: label, color: resolveLabelColor(label), description: labelDescription(label) }) }, { allowValidationError: true });
34
+ }
35
+ }
36
+ async syncManagedLabels(issueNumber: number, desired: string[]): Promise<void> {
37
+ const issue = await this.getIssue(issueNumber);
38
+ const unmanaged = extractLabelNames(issue.labels).filter((l) => !isManagedLabel(l));
39
+ await this.updateIssue(issueNumber, { labels: [...new Set([...unmanaged, ...desired])] });
40
+ }
41
+ async createAnonymousSession(): Promise<AnonymousSessionResponse> {
42
+ return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST" }, { omitAuth: true });
43
+ }
44
+
45
+ private repoPath(suffix: string): string {
46
+ if (!this.config.repo) throw new Error("clawmem repository is not configured");
47
+ return `repos/${this.config.repo}/${suffix}`;
48
+ }
49
+ private async req<T = void>(pathname: string, init: RequestInit, opts: ReqOpts = {}): Promise<T> {
50
+ if (!this.config.baseUrl) throw new Error("clawmem baseUrl is not configured");
51
+ if (!opts.omitAuth && !this.config.token) throw new Error("clawmem token is not configured");
52
+ const base = this.config.baseUrl.replace(/\/+$/, "");
53
+ const headers: Record<string, string> = { Accept: "application/vnd.github+json", "Content-Type": "application/json" };
54
+ if (!opts.omitAuth) headers.Authorization = this.config.authScheme === "bearer" ? `Bearer ${this.config.token}` : `token ${this.config.token}`;
55
+ const res = await fetch(new URL(pathname, `${base}/`), { ...init, headers: { ...headers, ...(init.headers ?? {}) } });
56
+ if (res.status === 404 && opts.allowNotFound) return undefined as T;
57
+ if (res.status === 422 && opts.allowValidationError) return undefined as T;
58
+ if (!res.ok) { const d = await res.text(); throw new Error(`HTTP ${res.status}: ${d || res.statusText}`); }
59
+ if (res.status === 204) return undefined as T;
60
+ const text = await res.text();
61
+ if (!text.trim()) return undefined as T;
62
+ try { return JSON.parse(text) as T; } catch (e) { this.log.warn?.(`clawmem: failed to parse API response: ${String(e)}`); return undefined as T; }
63
+ }
64
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A keyed async queue that serializes async tasks per key.
3
+ * Inlined from openclaw/plugin-sdk/keyed-async-queue to avoid
4
+ * dependency on platform internals that may not exist in older versions.
5
+ *
6
+ * Matches the behaviour of the upstream implementation: a failed task
7
+ * does not block subsequent tasks on the same key.
8
+ */
9
+ export class KeyedAsyncQueue {
10
+ private readonly tails = new Map<string, Promise<void>>();
11
+
12
+ enqueue<T>(key: string, task: () => Promise<T>): Promise<T> {
13
+ const current = (this.tails.get(key) ?? Promise.resolve())
14
+ .catch(() => void 0)
15
+ .then(task);
16
+ const tail = current.then(
17
+ () => void 0,
18
+ () => void 0,
19
+ );
20
+ this.tails.set(key, tail);
21
+ tail.finally(() => {
22
+ if (this.tails.get(key) === tail) this.tails.delete(key);
23
+ });
24
+ return current;
25
+ }
26
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Memory CRUD, sha256 dedup, and AI-driven memory extraction.
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
+ import { LABEL_MEMORY_ACTIVE, LABEL_MEMORY_STALE, MEMORY_TITLE_PREFIX, extractLabelNames, labelVal } from "./config.js";
4
+ import type { GitHubIssueClient } from "./github-client.js";
5
+ import { normalizeMessages } from "./transcript.js";
6
+ import type { ClawMemPluginConfig, NormalizedMessage, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
7
+ import { fmtTranscript, localDate, sha256, subKey } from "./utils.js";
8
+ import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
9
+
10
+ type MemoryDecision = { save: string[]; stale: string[] };
11
+
12
+ export class MemoryStore {
13
+ constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
14
+
15
+ async search(query: string, limit: number): Promise<ParsedMemoryIssue[]> {
16
+ const memories = await this.list("active");
17
+ const q = query.trim().toLowerCase();
18
+ const tokens = q.split(/[^a-z0-9]+/i).filter((t) => t.length > 1);
19
+ return memories
20
+ .map((m) => {
21
+ const hay = `${m.title}\n${m.detail}`.toLowerCase();
22
+ let score = hay.includes(q) ? 10 : 0;
23
+ for (const t of tokens) if (hay.includes(t)) score += 1;
24
+ return { m, score };
25
+ })
26
+ .filter((e) => e.score > 0)
27
+ .sort((a, b) => b.score - a.score || b.m.issueNumber - a.m.issueNumber)
28
+ .slice(0, limit)
29
+ .map((e) => e.m);
30
+ }
31
+
32
+ async syncFromConversation(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
33
+ try {
34
+ const decision = await this.generateDecision(session, snapshot);
35
+ const { savedCount, staledCount } = await this.applyDecision(session.sessionId, decision);
36
+ if (savedCount > 0 || staledCount > 0)
37
+ this.api.logger.info?.(`clawmem: synced memories for ${session.sessionId} (saved=${savedCount}, stale=${staledCount})`);
38
+ } catch (error) {
39
+ this.api.logger.warn(`clawmem: memory capture failed: ${String(error)}`);
40
+ }
41
+ }
42
+
43
+ private async list(status: "active" | "stale" | "all"): Promise<ParsedMemoryIssue[]> {
44
+ const labels = ["type:memory"];
45
+ if (status === "active") labels.push(LABEL_MEMORY_ACTIVE);
46
+ else if (status === "stale") labels.push(LABEL_MEMORY_STALE);
47
+ const out: ParsedMemoryIssue[] = [];
48
+ for (let page = 1; page <= 20; page++) {
49
+ const batch = await this.client.listIssues({ labels, state: "all", page, perPage: 100 });
50
+ for (const issue of batch) { const p = this.parseIssue(issue); if (p) out.push(p); }
51
+ if (batch.length < 100) break;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ private parseIssue(issue: { number: number; title?: string; body?: string; labels?: Array<{ name?: string } | string> }): ParsedMemoryIssue | null {
57
+ const labels = extractLabelNames(issue.labels);
58
+ if (!labels.includes("type:memory")) return null;
59
+ const sessionId = labelVal(labels, "session:"), date = labelVal(labels, "date:");
60
+ const topics = labels.filter((l) => l.startsWith("topic:")).map((l) => l.slice(6).trim()).filter(Boolean);
61
+ const rawBody = (issue.body ?? "").trim();
62
+ const body = rawBody ? parseFlatYaml(rawBody) : {};
63
+ const detail = body.detail?.trim() || rawBody;
64
+ if (!sessionId || !date || !detail) return null;
65
+ return {
66
+ issueNumber: issue.number, title: issue.title?.trim() || "",
67
+ memoryId: body.memory_id?.trim() || String(issue.number),
68
+ memoryHash: body.memory_hash?.trim() || undefined,
69
+ sessionId, date, detail,
70
+ ...(topics.length > 0 ? { topics } : {}),
71
+ status: labels.includes(LABEL_MEMORY_STALE) ? "stale" : "active",
72
+ };
73
+ }
74
+
75
+ private async applyDecision(sessionId: string, decision: MemoryDecision): Promise<{ savedCount: number; staledCount: number }> {
76
+ const allActive = await this.list("active");
77
+ const activeById = new Map(allActive.map((m) => [m.memoryId, m]));
78
+ const existingHashes = new Set(allActive.map((m) => m.memoryHash || sha256(norm(m.detail))));
79
+ let savedCount = 0;
80
+ for (const raw of decision.save) {
81
+ const detail = norm(raw);
82
+ if (!detail) continue;
83
+ const hash = sha256(detail);
84
+ if (existingHashes.has(hash)) continue;
85
+ const date = localDate(), labels = memLabels(sessionId, date, "active");
86
+ const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
87
+ const body = stringifyFlatYaml([["memory_hash", hash], ["detail", detail]]);
88
+ await this.client.ensureLabels(labels);
89
+ await this.client.createIssue({ title, body, labels });
90
+ existingHashes.add(hash);
91
+ savedCount++;
92
+ }
93
+ let staledCount = 0;
94
+ for (const id of [...new Set(decision.stale.map((s) => s.trim()).filter(Boolean))]) {
95
+ const mem = activeById.get(id);
96
+ if (!mem) continue;
97
+ await this.client.ensureLabels([LABEL_MEMORY_STALE]);
98
+ await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.sessionId, mem.date, "stale"));
99
+ staledCount++;
100
+ }
101
+ return { savedCount, staledCount };
102
+ }
103
+
104
+ private async generateDecision(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<MemoryDecision> {
105
+ if (snapshot.messages.length === 0) return { save: [], stale: [] };
106
+ const recent = (await this.list("active")).sort((a, b) => b.issueNumber - a.issueNumber).slice(0, 20);
107
+ const existingBlock = recent.length === 0 ? "None." : recent.map((m) => `[${m.memoryId}] ${m.detail}`).join("\n");
108
+ const subagent = this.api.runtime.subagent;
109
+ const sessionKey = subKey(session, "memory");
110
+ const message = [
111
+ "Extract durable memories from the conversation below.",
112
+ 'Return JSON only in the form {"save":["..."],"stale":["memory-id"]}.',
113
+ "Use save for stable, reusable facts, preferences, decisions, constraints, and ongoing context worth remembering later.",
114
+ "Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
115
+ "Do not save temporary requests, startup boilerplate, tool chatter, summaries about internal helper sessions, or one-off operational details.",
116
+ "Prefer empty arrays when nothing durable should be remembered.",
117
+ "", "<existing-active-memories>", existingBlock, "</existing-active-memories>",
118
+ "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
119
+ ].join("\n");
120
+ try {
121
+ const run = await subagent.run({
122
+ sessionKey, message, deliver: false, lane: "clawmem-memory",
123
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:memory-decision`),
124
+ extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with string arrays save and stale.",
125
+ });
126
+ const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
127
+ if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
128
+ if (wait.status === "error") throw new Error(wait.error || "memory decision subagent failed");
129
+ const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
130
+ const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
131
+ if (!text) throw new Error("memory decision subagent returned no assistant text");
132
+ return parseDecision(text);
133
+ } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
134
+ }
135
+ }
136
+
137
+ function memLabels(sessionId: string, date: string, status: "active" | "stale"): string[] {
138
+ return ["type:memory", `session:${sessionId}`, `date:${date}`, status === "active" ? LABEL_MEMORY_ACTIVE : LABEL_MEMORY_STALE];
139
+ }
140
+ function norm(v: string): string { return v.replace(/\s+/g, " ").trim(); }
141
+ function trunc(v: string, max: number): string { const s = norm(v); return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`; }
142
+ function parseDecision(raw: string): MemoryDecision {
143
+ const tryParse = (s: string): MemoryDecision | null => {
144
+ try {
145
+ const p = JSON.parse(s) as Record<string, unknown>;
146
+ return { save: Array.isArray(p.save) ? p.save.filter((v): v is string => typeof v === "string") : [],
147
+ stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [] };
148
+ } catch { return null; }
149
+ };
150
+ const t = raw.trim();
151
+ return tryParse(t) ?? (() => {
152
+ const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
153
+ const nested = f?.[1] ? tryParse(f[1].trim()) : null;
154
+ if (nested) return nested;
155
+ throw new Error("memory decision subagent returned invalid JSON");
156
+ })();
157
+ }
package/src/service.ts ADDED
@@ -0,0 +1,199 @@
1
+ // Thin orchestrator: wires conversation mirroring, memory store, and plugin lifecycle.
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
+ import { isPluginConfigured, resolvePluginConfig } from "./config.js";
4
+ import { ConversationMirror } from "./conversation.js";
5
+ import { GitHubIssueClient } from "./github-client.js";
6
+ import { KeyedAsyncQueue } from "./keyed-async-queue.js";
7
+ import { MemoryStore } from "./memory.js";
8
+ import { loadState, resolveStatePath, saveState } from "./state.js";
9
+ import { readTranscriptSnapshot } from "./transcript.js";
10
+ import type { ClawMemPluginConfig, PluginState, SessionMirrorState, TranscriptSnapshot } from "./types.js";
11
+
12
+ type TurnPayload = { sessionId?: string; sessionKey?: string; agentId?: string; messages: unknown[] };
13
+ type FinalizePayload = { sessionId?: string; sessionKey?: string; sessionFile?: string; agentId?: string; reason?: string; messages?: unknown[] };
14
+
15
+ class ClawMemService {
16
+ private readonly config: ClawMemPluginConfig;
17
+ private readonly client: GitHubIssueClient;
18
+ private readonly conv: ConversationMirror;
19
+ private readonly mem: MemoryStore;
20
+ private readonly queue = new KeyedAsyncQueue();
21
+ private readonly stateQueue = new KeyedAsyncQueue();
22
+ private readonly pending = new Set<Promise<unknown>>();
23
+ private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
24
+ private statePath = "";
25
+ private state: PluginState = { version: 1, sessions: {} };
26
+ private unsubTranscript?: () => void;
27
+ private loadPromise: Promise<void> | null = null;
28
+ private configPromise: Promise<boolean> | null = null;
29
+
30
+ constructor(private readonly api: OpenClawPluginApi) {
31
+ this.config = resolvePluginConfig(api);
32
+ this.client = new GitHubIssueClient(this.config, api.logger);
33
+ this.conv = new ConversationMirror(this.client, api, this.config);
34
+ this.mem = new MemoryStore(this.client, api, this.config);
35
+ }
36
+
37
+ register(): void {
38
+ this.api.on("before_agent_start", async (ev) => this.handleRecall(ev.prompt));
39
+ this.api.on("agent_end", (ev, ctx) => this.scheduleTurn({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, agentId: ctx.agentId, messages: ev.messages }));
40
+ this.api.on("before_reset", (ev, ctx) => this.enqueueFinalize({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, sessionFile: ev.sessionFile, agentId: ctx.agentId, reason: ev.reason, messages: ev.messages }));
41
+ this.api.on("session_end", (ev, ctx) => this.enqueueFinalize({ sessionId: ev.sessionId ?? ctx.sessionId, sessionKey: ev.sessionKey ?? ctx.sessionKey, agentId: ctx.agentId, reason: "session_end" }));
42
+
43
+ this.api.registerService({
44
+ id: "clawmem",
45
+ start: async (ctx) => {
46
+ this.statePath = resolveStatePath(ctx.stateDir);
47
+ await this.ensureLoaded();
48
+ const ok = await this.ensureConfigured();
49
+ this.unsubTranscript = this.api.runtime.events.onSessionTranscriptUpdate((u) => {
50
+ void this.track(this.handleTranscript(u.sessionFile)).catch((e) => this.warn("transcript update", e));
51
+ });
52
+ if (ok) this.api.logger.info?.(`clawmem: mirroring sessions to ${this.config.repo} via ${this.config.baseUrl}`);
53
+ else this.api.logger.warn(`clawmem: missing repo/token and automatic provisioning failed via ${this.config.baseUrl}; sync will retry on the next use`);
54
+ },
55
+ stop: async () => {
56
+ this.unsubTranscript?.();
57
+ for (const t of this.syncTimers.values()) clearTimeout(t);
58
+ this.syncTimers.clear();
59
+ await Promise.allSettled([...this.pending]);
60
+ },
61
+ });
62
+ }
63
+
64
+ private async handleRecall(prompt: unknown): Promise<{ prependContext: string } | void> {
65
+ if (typeof prompt !== "string" || prompt.trim().length < 5) return;
66
+ if (!(await this.ensureConfigured())) return;
67
+ try {
68
+ const memories = await this.mem.search(prompt, this.config.memoryRecallLimit);
69
+ if (memories.length === 0) return;
70
+ const text = memories.map((m) => `- ${m.detail}`).join("\n");
71
+ return { prependContext: `<relevant-memories>\nThe following active memories may be relevant to this conversation:\n${text}\n</relevant-memories>` };
72
+ } catch (error) { this.api.logger.warn(`clawmem: memory recall failed: ${String(error)}`); }
73
+ }
74
+
75
+ private async handleTranscript(sessionFile: string): Promise<void> {
76
+ let snap: TranscriptSnapshot;
77
+ try { snap = await readTranscriptSnapshot(sessionFile); } catch (e) { this.warn("transcript read", e); return; }
78
+ if (!snap.sessionId || !this.conv.shouldMirror(snap.sessionId, snap.messages)) return;
79
+ if (!(await this.ensureConfigured())) return;
80
+ await this.enqueueSession(snap.sessionId, async () => {
81
+ const s = this.getOrCreate(snap.sessionId!);
82
+ s.sessionFile = sessionFile;
83
+ s.updatedAt = new Date().toISOString();
84
+ await this.conv.ensureIssue(s, snap);
85
+ await this.persistState();
86
+ });
87
+ }
88
+
89
+ private scheduleTurn(p: TurnPayload): void {
90
+ if (!p.sessionId) return;
91
+ const prev = this.syncTimers.get(p.sessionId);
92
+ if (prev) clearTimeout(prev);
93
+ const timer = setTimeout(() => {
94
+ this.syncTimers.delete(p.sessionId!);
95
+ void this.track(this.enqueueSession(p.sessionId!, () => this.syncTurn(p))).catch((e) => this.warn("turn sync", e));
96
+ }, this.config.turnCommentDelayMs);
97
+ timer.unref?.();
98
+ this.syncTimers.set(p.sessionId, timer);
99
+ }
100
+
101
+ private async syncTurn(p: TurnPayload): Promise<void> {
102
+ if (!p.sessionId || !(await this.ensureConfigured())) return;
103
+ const s = this.getOrCreate(p.sessionId);
104
+ s.sessionKey = p.sessionKey ?? s.sessionKey; s.agentId = p.agentId ?? s.agentId; s.updatedAt = new Date().toISOString();
105
+ const snap = await this.conv.loadSnapshot(s, p.messages);
106
+ if (!this.conv.shouldMirror(s.sessionId, snap.messages) || snap.messages.length === 0) { await this.persistState(); return; }
107
+ await this.conv.ensureIssue(s, snap);
108
+ await this.conv.syncLabels(s, snap, false);
109
+ const next = snap.messages.slice(s.lastMirroredCount);
110
+ if (next.length > 0) { const n = await this.conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; }
111
+ await this.persistState();
112
+ }
113
+
114
+ private enqueueFinalize(p: FinalizePayload): void {
115
+ if (!p.sessionId) return;
116
+ const prev = this.syncTimers.get(p.sessionId);
117
+ if (prev) { clearTimeout(prev); this.syncTimers.delete(p.sessionId); }
118
+ void this.track(this.enqueueSession(p.sessionId, () => this.finalize(p))).catch((e) => this.warn("finalize", e));
119
+ }
120
+
121
+ private async finalize(p: FinalizePayload): Promise<void> {
122
+ if (!p.sessionId || !(await this.ensureConfigured())) return;
123
+ const s = this.getOrCreate(p.sessionId);
124
+ if (s.finalizedAt) return;
125
+ s.sessionKey = p.sessionKey ?? s.sessionKey; s.sessionFile = p.sessionFile ?? s.sessionFile;
126
+ s.agentId = p.agentId ?? s.agentId; s.updatedAt = new Date().toISOString();
127
+ const snap = await this.conv.loadSnapshot(s, p.messages ?? []);
128
+ if (!this.conv.shouldMirror(s.sessionId, snap.messages)) { await this.persistState(); return; }
129
+ if (snap.messages.length === 0 && !s.issueNumber) { await this.persistState(); return; }
130
+ await this.conv.ensureIssue(s, snap);
131
+ const next = snap.messages.slice(s.lastMirroredCount);
132
+ let allOk = true;
133
+ if (next.length > 0) { const n = await this.conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; allOk = n === next.length; }
134
+ let summary = "pending";
135
+ try { summary = await this.conv.generateSummary(s, snap); } catch (e) { summary = `failed: ${String(e)}`; }
136
+ await this.conv.syncLabels(s, snap, true);
137
+ await this.conv.syncBody(s, snap, summary, true);
138
+ await this.mem.syncFromConversation(s, snap);
139
+ if (allOk) s.finalizedAt = new Date().toISOString();
140
+ await this.persistState();
141
+ }
142
+
143
+ // --- Infrastructure ---
144
+
145
+ private enqueueSession<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
146
+ return this.queue.enqueue(sessionId, async () => { await this.ensureLoaded(); return task(); });
147
+ }
148
+ private track<T>(promise: Promise<T>): Promise<T> {
149
+ this.pending.add(promise); void promise.finally(() => this.pending.delete(promise)); return promise;
150
+ }
151
+ private getOrCreate(sessionId: string): SessionMirrorState {
152
+ if (this.state.sessions[sessionId]) return this.state.sessions[sessionId];
153
+ const now = new Date().toISOString();
154
+ const s: SessionMirrorState = { sessionId, lastMirroredCount: 0, turnCount: 0, createdAt: now, updatedAt: now };
155
+ this.state.sessions[sessionId] = s;
156
+ return s;
157
+ }
158
+ private async persistState(): Promise<void> {
159
+ if (!this.statePath) this.statePath = resolveStatePath(this.api.runtime.state.resolveStateDir());
160
+ await this.stateQueue.enqueue("state", () => saveState(this.statePath, this.state));
161
+ }
162
+ private async ensureLoaded(): Promise<void> {
163
+ if (this.loadPromise) return this.loadPromise;
164
+ this.loadPromise = (async () => {
165
+ if (!this.statePath) this.statePath = resolveStatePath(this.api.runtime.state.resolveStateDir());
166
+ this.state = await loadState(this.statePath);
167
+ })();
168
+ return this.loadPromise;
169
+ }
170
+ private async ensureConfigured(): Promise<boolean> {
171
+ if (isPluginConfigured(this.config)) return true;
172
+ if (this.configPromise) return this.configPromise;
173
+ const p = this.bootstrap();
174
+ this.configPromise = p;
175
+ try { return await p; } finally { if (this.configPromise === p) this.configPromise = null; }
176
+ }
177
+ private async bootstrap(): Promise<boolean> {
178
+ if (!this.config.baseUrl) { this.api.logger.warn("clawmem: cannot provision Git credentials without a baseUrl"); return false; }
179
+ try {
180
+ const sess = await this.client.createAnonymousSession();
181
+ await this.persistPluginConfig({ baseUrl: this.config.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
182
+ this.config.authScheme = "token"; this.config.token = sess.token; this.config.repo = sess.repo_full_name;
183
+ this.api.logger.info?.(`clawmem: provisioned Git credentials for ${sess.repo_full_name} via ${this.config.baseUrl}`);
184
+ return true;
185
+ } catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials via ${this.config.baseUrl}: ${String(error)}`); return false; }
186
+ }
187
+ private async persistPluginConfig(values: Partial<ClawMemPluginConfig>): Promise<void> {
188
+ const root = this.api.runtime.config.loadConfig();
189
+ const plugins = root.plugins;
190
+ const entries = plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) ? (plugins.entries as Record<string, unknown>) : {};
191
+ const ex = asRecord(entries[this.api.id]), exCfg = asRecord(ex.config);
192
+ await this.api.runtime.config.writeConfigFile({ ...root, plugins: { ...(plugins ?? {}), entries: { ...entries, [this.api.id]: { ...ex, config: { ...exCfg, ...values } } } } });
193
+ }
194
+ private warn(scope: string, error: unknown): void { this.api.logger.warn(`clawmem: ${scope} failed: ${String(error)}`); }
195
+ }
196
+
197
+ function asRecord(v: unknown): Record<string, unknown> { return v && typeof v === "object" ? (v as Record<string, unknown>) : {}; }
198
+
199
+ export function createClawMemPlugin(api: OpenClawPluginApi): void { new ClawMemService(api).register(); }