@clawmem-ai/clawmem 0.1.16 → 0.1.18

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.
@@ -6,10 +6,13 @@ import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, SESSION_TITLE_PREFIX, extractLabelN
6
6
  import type { GitHubIssueClient } from "./github-client.js";
7
7
  import { parseCandidates } from "./memory.js";
8
8
  import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
9
- import type { ClawMemPluginConfig, MemoryCandidate, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
10
- import { fmtTranscript, fmtTranscriptFrom, localDate, localDateTime, sha256, sliceTranscriptDelta, subKey } from "./utils.js";
9
+ import type { ClawMemPluginConfig, MemoryCandidate, MemorySchema, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
10
+ import { fmtTranscript, localDate, localDateTime, sha256, subKey } from "./utils.js";
11
11
  import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
12
12
 
13
+ const FINALIZE_SCHEMA_KIND_LIMIT = 24;
14
+ const FINALIZE_SCHEMA_TOPIC_LIMIT = 80;
15
+
13
16
  export class ConversationMirror {
14
17
  constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
15
18
 
@@ -17,16 +20,15 @@ export class ConversationMirror {
17
20
  if (sessionId.startsWith("slug-generator-")) return false;
18
21
  const first = messages.find((m) => m.role === "user")?.text ?? "";
19
22
  if (first.includes("generate a short 1-2 word filename slug") && first.includes("Reply with ONLY the slug")) return false;
20
- if (first.includes("Summarize the following conversation.") && first.includes('Return valid JSON only in the form {"summary":"..."}')) return false;
21
- if (first.includes("Extract durable memories from the conversation below.") && first.includes('Return JSON only in the form {"save":')) return false;
22
- if (first.includes("Maintain a rolling digest of the conversation below.") && first.includes('Return valid JSON only in the form {"digest":"...","title":"..."}')) return false;
23
- if (first.includes("Write the final issue summary for the conversation below.") && first.includes('Return valid JSON only in the form {"summary":"...","title":"..."}')) return false;
24
- if (first.includes("Extract atomic durable memory candidates from the conversation delta below.")) return false;
25
- if (first.includes("Reconcile extracted durable memory candidates against existing memories.")) return false;
23
+ if (first.includes("Write the final issue summary and extract durable memory candidates from the conversation below.")) return false;
26
24
  return true;
27
25
  }
28
26
 
29
27
  async loadSnapshot(session: SessionMirrorState, fallback: unknown[]): Promise<TranscriptSnapshot> {
28
+ const normalizedFallback = normalizeMessages(fallback);
29
+ if (normalizedFallback.length > 0) {
30
+ return { sessionId: session.sessionId, messages: normalizedFallback };
31
+ }
30
32
  const filePath = await this.resolveTranscriptPath(session.sessionFile);
31
33
  if (filePath) {
32
34
  session.sessionFile = filePath;
@@ -37,7 +39,7 @@ export class ConversationMirror {
37
39
  this.api.logger.warn(`clawmem: transcript read failed for ${filePath}: ${String(error)}`);
38
40
  }
39
41
  }
40
- return { sessionId: session.sessionId, messages: normalizeMessages(fallback) };
42
+ return { sessionId: session.sessionId, messages: normalizedFallback };
41
43
  }
42
44
 
43
45
  async ensureIssue(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
@@ -95,341 +97,34 @@ export class ConversationMirror {
95
97
  return count;
96
98
  }
97
99
 
98
- async generateRollingDigest(
100
+ async generateFinalArtifacts(
99
101
  session: SessionMirrorState,
100
102
  snapshot: TranscriptSnapshot,
101
- fromCursor: number,
102
- previousDigest?: string,
103
- ): Promise<{ digest: string; title?: string }> {
104
- const { anchorStart, deltaStart, anchorMessages, deltaMessages } = sliceTranscriptDelta(snapshot.messages, fromCursor, 2);
105
- if (deltaMessages.length === 0) {
106
- return { digest: previousDigest?.trim() || "" };
107
- }
103
+ schema?: MemorySchema,
104
+ ): Promise<{ summary: string; title?: string; candidates: MemoryCandidate[] }> {
105
+ if (snapshot.messages.length === 0) throw new Error("no conversation messages to finalize");
108
106
  const subagent = this.api.runtime.subagent;
109
- const sessionKey = subKey(session, "digest");
110
- const message = [
111
- "Maintain a rolling digest of the conversation below.",
112
- 'Return valid JSON only in the form {"digest":"...","title":"..."}',
113
- "Update the digest so it accurately represents the conversation so far in 4-8 concise factual sentences.",
114
- "Focus on decisions, constraints, preferences, open workstreams, and concrete outcomes worth carrying forward.",
115
- "Use the anchor messages only for context resolution. The new messages are the only part that must be incorporated now.",
116
- "Title is optional. If provided, keep it under 50 characters and accurately describe the overall conversation.",
117
- "",
118
- "<previous-digest>",
119
- previousDigest?.trim() || "None.",
120
- "</previous-digest>",
121
- "",
122
- "<anchor-messages>",
123
- anchorMessages.length > 0 ? fmtTranscriptFrom(anchorMessages, anchorStart) : "None.",
124
- "</anchor-messages>",
125
- "",
126
- "<new-messages>",
127
- fmtTranscriptFrom(deltaMessages, deltaStart),
128
- "</new-messages>",
129
- ].join("\n");
107
+ const sessionKey = subKey(session, "finalize");
108
+ const message = buildFinalizeArtifactsPrompt(snapshot, schema);
130
109
  try {
131
110
  const run = await subagent.run({
132
111
  sessionKey,
133
112
  message,
134
113
  deliver: false,
135
- lane: "clawmem-digest",
136
- idempotencyKey: sha256(`${session.sessionId}:${fromCursor}:${snapshot.messages.length}:digest-v1`),
137
- extraSystemPrompt: "You maintain rolling conversation digests for ClawMem. Output JSON only with string fields digest and optional title.",
138
- });
139
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.digestWaitTimeoutMs });
140
- if (wait.status === "timeout") throw new Error("digest subagent timed out");
141
- if (wait.status === "error") throw new Error(wait.error || "digest subagent failed");
142
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
143
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
144
- if (!text) throw new Error("digest subagent returned no assistant text");
145
- return parseDigestAndTitle(text);
146
- } finally {
147
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
148
- }
149
- }
150
-
151
- async deriveDelta(
152
- session: SessionMirrorState,
153
- snapshot: TranscriptSnapshot,
154
- fromCursor: number,
155
- previousDigest?: string,
156
- ): Promise<{ digest: string; title?: string; candidates: MemoryCandidate[] }> {
157
- const { anchorStart, deltaStart, anchorMessages, deltaMessages } = sliceTranscriptDelta(snapshot.messages, fromCursor, 2);
158
- if (deltaMessages.length === 0) {
159
- return { digest: previousDigest?.trim() || "", candidates: [] };
160
- }
161
- const subagent = this.api.runtime.subagent;
162
- const sessionKey = subKey(session, "derive-delta");
163
- const message = [
164
- "Maintain a rolling digest and extract atomic durable memory candidates from the conversation delta below.",
165
- 'Return valid JSON only in the form {"digest":"...","title":"...","candidates":[{"title":"...","detail":"...","kind":"...","topics":["..."],"evidence":"..."}]}.',
166
- "Update the digest so it accurately represents the conversation so far in 4-8 concise factual sentences.",
167
- "Focus the digest on decisions, constraints, preferences, open workstreams, and concrete outcomes worth carrying forward.",
168
- "Extract only durable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
169
- "Use the anchor messages only for context resolution. The new messages are the only source that may add new candidates now.",
170
- "Each candidate must represent one durable fact. Split independent facts into separate candidates.",
171
- "Do not extract temporary requests, tool chatter, startup boilerplate, or summaries about internal helper sessions.",
172
- "Kind and topics are optional. Keep them short, reusable, and low-cardinality.",
173
- "Evidence is optional. If present, keep it short and quote-free.",
174
- "Title is optional. If provided, keep it under 50 characters and accurately describe the overall conversation.",
175
- "Prefer an empty candidates array when nothing durable was added.",
176
- "",
177
- "<previous-digest>",
178
- previousDigest?.trim() || "None.",
179
- "</previous-digest>",
180
- "",
181
- "<anchor-messages>",
182
- anchorMessages.length > 0 ? fmtTranscriptFrom(anchorMessages, anchorStart) : "None.",
183
- "</anchor-messages>",
184
- "",
185
- "<new-messages>",
186
- fmtTranscriptFrom(deltaMessages, deltaStart),
187
- "</new-messages>",
188
- ].join("\n");
189
- try {
190
- const run = await subagent.run({
191
- sessionKey,
192
- message,
193
- deliver: false,
194
- lane: "clawmem-derive-delta",
195
- idempotencyKey: sha256(`${session.sessionId}:${fromCursor}:${snapshot.messages.length}:derive-delta-v1`),
196
- extraSystemPrompt: "You maintain rolling conversation digests and extract atomic durable memory candidates for ClawMem. Output JSON only with digest, optional title, and candidates.",
114
+ lane: "clawmem-finalize",
115
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:finalize-v2`),
116
+ extraSystemPrompt: "You finalize ClawMem conversations. Output JSON only with summary, title, and durable memory candidates. Reuse existing schema when it fits and keep human-readable memory text in the conversation language.",
197
117
  });
198
118
  const wait = await subagent.waitForRun({
199
119
  runId: run.runId,
200
- timeoutMs: Math.max(this.config.digestWaitTimeoutMs, this.config.memoryExtractWaitTimeoutMs),
120
+ timeoutMs: Math.max(this.config.summaryWaitTimeoutMs, this.config.memoryExtractWaitTimeoutMs),
201
121
  });
202
- if (wait.status === "timeout") throw new Error("derive delta subagent timed out");
203
- if (wait.status === "error") throw new Error(wait.error || "derive delta subagent failed");
122
+ if (wait.status === "timeout") throw new Error("finalize subagent timed out");
123
+ if (wait.status === "error") throw new Error(wait.error || "finalize subagent failed");
204
124
  const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
205
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
206
- if (!text) throw new Error("derive delta subagent returned no assistant text");
207
- return parseDerivedDelta(text);
208
- } finally {
209
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
210
- }
211
- }
212
-
213
- async generateFinalSummaryFromDigest(
214
- session: SessionMirrorState,
215
- snapshot: TranscriptSnapshot,
216
- digestText: string,
217
- ): Promise<{ summary: string; title?: string }> {
218
- if (!digestText.trim() && snapshot.messages.length === 0) throw new Error("no conversation content to summarize");
219
- const tailStart = Math.max(0, snapshot.messages.length - 6);
220
- const tailMessages = snapshot.messages.slice(tailStart);
221
- const subagent = this.api.runtime.subagent;
222
- const sessionKey = subKey(session, "summary-final");
223
- const message = [
224
- "Write the final issue summary for the conversation below.",
225
- 'Return valid JSON only in the form {"summary":"...","title":"..."}',
226
- "The summary should be concise, factual, and written in 2-4 sentences.",
227
- "Use the rolling digest as the primary source of truth, and use the recent tail only to preserve freshness and wording accuracy.",
228
- "Do not include markdown, bullet points, or analysis.",
229
- "",
230
- "Title rules:",
231
- "- Under 50 characters, accurately describe the main topic or task.",
232
- "- Should let someone immediately know what the conversation is about.",
233
- "- Must be in the same language as the majority of the conversation content.",
234
- "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
235
- "",
236
- "<rolling-digest>",
237
- digestText.trim() || "None.",
238
- "</rolling-digest>",
239
- "",
240
- "<recent-tail>",
241
- tailMessages.length > 0 ? fmtTranscriptFrom(tailMessages, tailStart) : "None.",
242
- "</recent-tail>",
243
- ].join("\n");
244
- try {
245
- const run = await subagent.run({
246
- sessionKey,
247
- message,
248
- deliver: false,
249
- lane: "clawmem-summary",
250
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary-final-v1`),
251
- extraSystemPrompt: "You write final conversation issue summaries for ClawMem. Output JSON only with string fields summary and title.",
252
- });
253
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
254
- if (wait.status === "timeout") throw new Error("summary subagent timed out");
255
- if (wait.status === "error") throw new Error(wait.error || "summary subagent failed");
256
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
257
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
258
- if (!text) throw new Error("summary subagent returned no assistant text");
259
- return parseSummaryAndTitle(text);
260
- } finally {
261
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
262
- }
263
- }
264
-
265
- async generateSummaryAndTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<{ summary: string; title?: string }> {
266
- if (snapshot.messages.length === 0) throw new Error("no conversation messages to summarize");
267
- const subagent = this.api.runtime.subagent;
268
- const sessionKey = subKey(session, "summary");
269
- const message = [
270
- "Summarize the following conversation and generate a short title.",
271
- 'Return valid JSON only in the form {"summary":"...","title":"..."}',
272
- "The summary should be concise, factual, and written in 2-4 sentences.",
273
- "Do not include markdown, bullet points, or analysis.",
274
- "",
275
- "Title rules:",
276
- "- Under 50 characters, accurately describe the main topic or task.",
277
- "- Should let someone immediately know what the conversation is about.",
278
- "- Must be in the same language as the majority of the conversation content.",
279
- "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
280
- "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
281
- ].join("\n");
282
- try {
283
- const run = await subagent.run({
284
- sessionKey, message, deliver: false, lane: "clawmem-summary",
285
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary-v2`),
286
- extraSystemPrompt: "You summarize conversations and generate accurate, descriptive titles. Output JSON only with string fields summary and title.",
287
- });
288
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
289
- if (wait.status === "timeout") throw new Error("summary subagent timed out");
290
- if (wait.status === "error") throw new Error(wait.error || "summary subagent failed");
291
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
292
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
293
- if (!text) throw new Error("summary subagent returned no assistant text");
294
- return parseSummaryAndTitle(text);
295
- } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
296
- }
297
-
298
- /** If the title has not yet been generated by LLM, generate an accurate title from the full conversation and update the issue. */
299
- async syncTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
300
- if (!session.issueNumber) return;
301
- if (session.titleSource === "llm") return;
302
- if (snapshot.messages.length < 2) return;
303
- try {
304
- const title = await this.generateTitle(session, snapshot);
305
- if (title) {
306
- await this.client.updateIssue(session.issueNumber, { title });
307
- session.issueTitle = title;
308
- session.titleSource = "llm";
309
- }
310
- } catch (e) {
311
- this.api.logger.warn(`clawmem: title sync failed: ${String(e)}`);
312
- }
313
- }
314
-
315
- /** Generate an accurate, descriptive title from the full conversation content via LLM. */
316
- async generateTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<string | undefined> {
317
- if (snapshot.messages.length === 0) return undefined;
318
- const subagent = this.api.runtime.subagent;
319
- const sessionKey = subKey(session, "title");
320
- const message = [
321
- "Generate a short, accurate title for the following conversation.",
322
- 'Return valid JSON only in the form {"title":"..."}',
323
- "",
324
- "Title rules:",
325
- "- Under 50 characters.",
326
- "- Accurately describe the main topic or task of the conversation.",
327
- "- Should let someone immediately know what the conversation is about.",
328
- "- Must be in the same language as the majority of the conversation content.",
329
- "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
330
- "",
331
- "<conversation>",
332
- fmtTranscript(snapshot.messages),
333
- "</conversation>",
334
- ].join("\n");
335
- try {
336
- const run = await subagent.run({
337
- sessionKey, message, deliver: false, lane: "clawmem-title",
338
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:title-v1`),
339
- extraSystemPrompt: "You generate accurate, descriptive titles for conversations. Output JSON only with a string field title.",
340
- });
341
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: 30000 });
342
- if (wait.status === "timeout" || wait.status === "error") return undefined;
343
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 10 })).messages);
344
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
345
- if (!text) return undefined;
346
- return parseTitle(text);
347
- } catch (e) {
348
- this.api.logger.warn(`clawmem: title generation failed: ${String(e)}`);
349
- return undefined;
350
- } finally {
351
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
352
- }
353
- }
354
-
355
- /** Re-title all existing conversation issues. Uses summary when available, falls back to reading comments. */
356
- async retitleConversations(): Promise<{ updated: number; skipped: number; failed: number; retitledIssues: number[] }> {
357
- let updated = 0, skipped = 0, failed = 0;
358
- const retitledIssues: number[] = [];
359
- let page = 1;
360
- while (true) {
361
- const issues = await this.client.listIssues({ labels: ["type:conversation"], state: "all", page, perPage: 50 });
362
- if (issues.length === 0) break;
363
- for (const issue of issues) {
364
- try {
365
- const yaml = parseFlatYaml(issue.body || "");
366
- const summary = yaml.summary;
367
- let titleInput: string | undefined;
368
- if (summary && summary !== "pending" && !summary.startsWith("failed:")) {
369
- titleInput = summary;
370
- } else {
371
- // No usable summary — reconstruct conversation from issue comments.
372
- const comments = await this.client.listComments(issue.number, { perPage: 50 });
373
- const conversationText = comments
374
- .map((c) => c.body?.trim())
375
- .filter((b): b is string => Boolean(b))
376
- .join("\n\n");
377
- if (conversationText.length >= 20) {
378
- // Cap to avoid excessive token usage in LLM call.
379
- titleInput = conversationText.length > 4000 ? conversationText.slice(0, 4000) + "\n..." : conversationText;
380
- }
381
- }
382
- if (!titleInput) { skipped++; continue; }
383
- const title = await this.generateTitleFromText(titleInput, `retitle-${issue.number}`);
384
- if (!title) { skipped++; continue; }
385
- await this.client.updateIssue(issue.number, { title });
386
- this.api.logger.info?.(`clawmem: retitled issue #${issue.number} -> "${title}"`);
387
- retitledIssues.push(issue.number);
388
- updated++;
389
- } catch (e) {
390
- this.api.logger.warn(`clawmem: retitle failed for issue #${issue.number}: ${String(e)}`);
391
- failed++;
392
- }
393
- }
394
- if (issues.length < 50) break;
395
- page++;
396
- }
397
- return { updated, skipped, failed, retitledIssues };
398
- }
399
-
400
- private async generateTitleFromText(text: string, uniqueKey: string): Promise<string | undefined> {
401
- const subagent = this.api.runtime.subagent;
402
- const sessionKey = `clawmem-${uniqueKey}`;
403
- const message = [
404
- "Generate a short, accurate title based on the following conversation content.",
405
- 'Return valid JSON only in the form {"title":"..."}',
406
- "",
407
- "Title rules:",
408
- "- Under 50 characters.",
409
- "- Accurately describe the main topic or task.",
410
- "- Should let someone immediately know what the conversation was about.",
411
- "- Must be in the same language as the content.",
412
- "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
413
- "",
414
- "<content>",
415
- text,
416
- "</content>",
417
- ].join("\n");
418
- try {
419
- const run = await subagent.run({
420
- sessionKey, message, deliver: false, lane: "clawmem-retitle",
421
- idempotencyKey: sha256(`retitle:${uniqueKey}:${text.slice(0, 200)}`),
422
- extraSystemPrompt: "You generate accurate, descriptive titles. Output JSON only with a string field title.",
423
- });
424
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: 30000 });
425
- if (wait.status === "timeout" || wait.status === "error") return undefined;
426
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 10 })).messages);
427
- const raw = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
428
- if (!raw) return undefined;
429
- return parseTitle(raw);
430
- } catch (e) {
431
- this.api.logger.warn(`clawmem: title generation from text failed (${uniqueKey}): ${String(e)}`);
432
- return undefined;
125
+ const text = [...msgs].reverse().find((entry) => entry.role === "assistant" && entry.text.trim())?.text;
126
+ if (!text) throw new Error("finalize subagent returned no assistant text");
127
+ return parseFinalArtifacts(text);
433
128
  } finally {
434
129
  subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
435
130
  }
@@ -500,6 +195,65 @@ export class ConversationMirror {
500
195
  }
501
196
  }
502
197
 
198
+ export function buildFinalizeArtifactsPrompt(snapshot: TranscriptSnapshot, schema?: MemorySchema): string {
199
+ return [
200
+ "Write the final issue summary and extract durable memory candidates from the conversation below.",
201
+ 'Return valid JSON only in the form {"summary":"...","title":"...","candidates":[{"title":"...","detail":"...","kind":"...","topics":["..."],"evidence":"..."}]}.',
202
+ "The summary should be concise, factual, and written in 2-4 sentences.",
203
+ "Do not include markdown, bullet points, or analysis.",
204
+ "",
205
+ "Title rules:",
206
+ "- Under 50 characters, accurately describe the main topic or task.",
207
+ "- Should let someone immediately know what the conversation is about.",
208
+ "- Must be in the same language as the majority of the conversation content.",
209
+ "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
210
+ "",
211
+ "Candidate rules:",
212
+ "- Extract only durable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
213
+ "- Each candidate must represent one durable fact. Split independent facts into separate candidates.",
214
+ "- Prefer a concise explicit title for each candidate whenever the fact can be named clearly.",
215
+ "- Candidate titles and details must be in the same language as the majority of the conversation content.",
216
+ "- Do not extract temporary requests, tool chatter, startup boilerplate, or summaries about internal helper sessions.",
217
+ "- Reuse existing schema labels when one already fits.",
218
+ "- If no existing kind or topic fits, create one short stable machine-readable label instead of a translated or near-duplicate variant.",
219
+ "- Keep kind and topic labels short, reusable, low-cardinality, and machine-readable.",
220
+ "- Evidence is optional. If present, keep it short and quote-free.",
221
+ "- Prefer an empty candidates array when nothing durable was learned.",
222
+ "",
223
+ ...buildFinalizeSchemaSection(schema),
224
+ "<conversation>",
225
+ fmtTranscript(snapshot.messages),
226
+ "</conversation>",
227
+ ].join("\n");
228
+ }
229
+
230
+ function buildFinalizeSchemaSection(schema?: MemorySchema): string[] {
231
+ if (!schema) return [];
232
+
233
+ const kinds = schema.kinds.map((kind) => kind.trim()).filter(Boolean);
234
+ const topics = schema.topics.map((topic) => topic.trim()).filter(Boolean);
235
+ if (kinds.length === 0 && topics.length === 0) return [];
236
+
237
+ const kindLines = kinds.slice(0, FINALIZE_SCHEMA_KIND_LIMIT).map((kind) => `- kind:${kind}`);
238
+ const topicLines = topics.slice(0, FINALIZE_SCHEMA_TOPIC_LIMIT).map((topic) => `- topic:${topic}`);
239
+ const kindOverflow = kinds.length > kindLines.length ? [`- ...and ${kinds.length - kindLines.length} more kinds`] : [];
240
+ const topicOverflow = topics.length > topicLines.length ? [`- ...and ${topics.length - topicLines.length} more topics`] : [];
241
+
242
+ return [
243
+ "Current schema to reuse first:",
244
+ "<current-schema>",
245
+ "Kinds:",
246
+ ...(kindLines.length > 0 ? kindLines : ["- None"]),
247
+ ...kindOverflow,
248
+ "Topics:",
249
+ ...(topicLines.length > 0 ? topicLines : ["- None"]),
250
+ ...topicOverflow,
251
+ "</current-schema>",
252
+ "Prefer these existing labels whenever they fit. Only create a new label when none of the current labels matches the fact you are storing.",
253
+ "",
254
+ ];
255
+ }
256
+
503
257
  async function fexists(p: string): Promise<boolean> { try { return (await fs.promises.stat(p)).isFile(); } catch { return false; } }
504
258
  function isNotFoundError(error: unknown): boolean {
505
259
  const text = String(error);
@@ -539,69 +293,12 @@ function parseSummaryAndTitle(raw: string): { summary: string; title?: string }
539
293
  return { summary: t };
540
294
  }
541
295
 
542
- function parseDigestAndTitle(raw: string): { digest: string; title?: string } {
543
- const tryParse = (s: string): { digest: string; title?: string } | null => {
544
- try {
545
- const p = JSON.parse(s) as { digest?: unknown; title?: unknown };
546
- const digest = typeof p?.digest === "string" && p.digest.trim() ? p.digest.trim() : null;
547
- if (!digest) return null;
548
- const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
549
- return { digest, title };
550
- } catch {
551
- const i = s.indexOf("{"), j = s.lastIndexOf("}");
552
- if (i >= 0 && j > i) {
553
- try {
554
- const p = JSON.parse(s.slice(i, j + 1)) as { digest?: unknown; title?: unknown };
555
- const digest = typeof p?.digest === "string" && p.digest.trim() ? p.digest.trim() : null;
556
- if (!digest) return null;
557
- const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
558
- return { digest, title };
559
- } catch { return null; }
560
- }
561
- return null;
562
- }
563
- };
564
- const t = raw.trim();
565
- const direct = tryParse(t);
566
- if (direct) return direct;
567
- const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
568
- if (f?.[1]) {
569
- const nested = tryParse(f[1].trim());
570
- if (nested) return nested;
571
- }
572
- return { digest: t };
573
- }
574
-
575
- function parseDerivedDelta(raw: string): { digest: string; title?: string; candidates: MemoryCandidate[] } {
576
- const parsedDigest = parseDigestAndTitle(raw);
296
+ function parseFinalArtifacts(raw: string): { summary: string; title?: string; candidates: MemoryCandidate[] } {
297
+ const parsedSummary = parseSummaryAndTitle(raw);
577
298
  const candidates = parseCandidates(raw);
578
299
  return {
579
- digest: parsedDigest.digest,
580
- ...(parsedDigest.title ? { title: parsedDigest.title } : {}),
300
+ summary: parsedSummary.summary,
301
+ ...(parsedSummary.title ? { title: parsedSummary.title } : {}),
581
302
  candidates,
582
303
  };
583
304
  }
584
-
585
- function parseTitle(raw: string): string | undefined {
586
- const tryParse = (s: string): string | undefined => {
587
- try {
588
- const p = JSON.parse(s) as { title?: unknown };
589
- return typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
590
- } catch {
591
- const i = s.indexOf("{"), j = s.lastIndexOf("}");
592
- if (i >= 0 && j > i) {
593
- try {
594
- const p = JSON.parse(s.slice(i, j + 1)) as { title?: unknown };
595
- return typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
596
- } catch { return undefined; }
597
- }
598
- return undefined;
599
- }
600
- };
601
- const t = raw.trim();
602
- const direct = tryParse(t);
603
- if (direct) return direct;
604
- const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
605
- if (f?.[1]) return tryParse(f[1].trim());
606
- return undefined;
607
- }
@@ -0,0 +1,101 @@
1
+ import { GitHubIssueClient } from "./github-client.js";
2
+ import type { ClawMemResolvedRoute } from "./types.js";
3
+
4
+ function assert(condition: unknown, message: string): void {
5
+ if (!condition) throw new Error(message);
6
+ }
7
+
8
+ type FetchCall = { url: string; init: RequestInit };
9
+
10
+ function createClientRecorder(): {
11
+ client: GitHubIssueClient;
12
+ calls: FetchCall[];
13
+ restore(): void;
14
+ } {
15
+ const calls: FetchCall[] = [];
16
+ const originalFetch = globalThis.fetch;
17
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
18
+ calls.push({ url: String(input), init: init ?? {} });
19
+ const method = init?.method ?? "GET";
20
+ if (method === "DELETE" || method === "PATCH") return new Response(null, { status: 204 });
21
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
22
+ }) as typeof fetch;
23
+
24
+ const route: ClawMemResolvedRoute = {
25
+ agentId: "main",
26
+ baseUrl: "https://git.clawmem.ai/api/v3",
27
+ defaultRepo: "alice/memory",
28
+ repo: "alice/memory",
29
+ token: "token-123",
30
+ authScheme: "token",
31
+ };
32
+
33
+ return {
34
+ client: new GitHubIssueClient(route, {}),
35
+ calls,
36
+ restore() {
37
+ globalThis.fetch = originalFetch;
38
+ },
39
+ };
40
+ }
41
+
42
+ async function testOrgGovernanceRoutes(): Promise<void> {
43
+ const { client, calls, restore } = createClientRecorder();
44
+ try {
45
+ await client.listOrgMembers("acme", "admin");
46
+ await client.getOrgMembership("acme", "alice");
47
+ await client.removeOrgMember("acme", "alice");
48
+ await client.removeOrgMembership("acme", "alice");
49
+ await client.revokeOrgInvitation("acme", 12);
50
+
51
+ assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/members?role=admin", "expected org member list route");
52
+ assert(calls[1]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/memberships/alice", "expected org membership route");
53
+ assert(calls[2]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/members/alice", "expected org member delete route");
54
+ assert(calls[2]?.init.method === "DELETE", "expected DELETE for org member removal");
55
+ assert(calls[3]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/memberships/alice", "expected org membership delete route");
56
+ assert(calls[3]?.init.method === "DELETE", "expected DELETE for org membership removal");
57
+ assert(calls[4]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/invitations/12", "expected org invitation revoke route");
58
+ assert(calls[4]?.init.method === "DELETE", "expected DELETE for org invitation revoke");
59
+ } finally {
60
+ restore();
61
+ }
62
+ }
63
+
64
+ async function testTeamGovernanceRoutes(): Promise<void> {
65
+ const { client, calls, restore } = createClientRecorder();
66
+ try {
67
+ await client.getTeam("acme", "platform");
68
+ await client.updateTeam("acme", "platform", { name: "Platform Eng", description: "Core platform", privacy: "closed" });
69
+ await client.deleteTeam("acme", "platform");
70
+ await client.listTeamMembers("acme", "platform");
71
+
72
+ assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team get route");
73
+ assert(calls[1]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team update route");
74
+ assert(calls[1]?.init.method === "PATCH", "expected PATCH for team update");
75
+ assert(String(calls[1]?.init.body).includes("\"name\":\"Platform Eng\""), "expected team update payload to include name");
76
+ assert(calls[2]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team delete route");
77
+ assert(calls[2]?.init.method === "DELETE", "expected DELETE for team delete");
78
+ assert(calls[3]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform/members", "expected team members route");
79
+ } finally {
80
+ restore();
81
+ }
82
+ }
83
+
84
+ async function testRepoTransferRoute(): Promise<void> {
85
+ const { client, calls, restore } = createClientRecorder();
86
+ try {
87
+ await client.transferRepo("alice", "memory", "acme");
88
+ assert(calls.length === 1, "expected one repo transfer request");
89
+ assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/repos/alice/memory/transfer", "expected repo transfer route");
90
+ assert(calls[0]?.init.method === "POST", "expected POST for repo transfer");
91
+ assert(String(calls[0]?.init.body) === "{\"new_owner\":\"acme\"}", "expected repo transfer payload");
92
+ } finally {
93
+ restore();
94
+ }
95
+ }
96
+
97
+ await testOrgGovernanceRoutes();
98
+ await testTeamGovernanceRoutes();
99
+ await testRepoTransferRoute();
100
+
101
+ console.log("github client tests passed");