@clawmem-ai/clawmem 0.1.15 → 0.1.17
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 +6 -4
- package/openclaw.plugin.json +11 -11
- package/package.json +12 -2
- package/skills/clawmem/SKILL.md +5 -3
- package/skills/clawmem/references/collaboration.md +43 -1
- package/skills/clawmem/references/schema.md +2 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +1 -1
- package/src/conversation.test.ts +63 -13
- package/src/conversation.ts +100 -188
- package/src/github-client.test.ts +101 -0
- package/src/github-client.ts +59 -0
- package/src/memory.test.ts +154 -39
- package/src/memory.ts +139 -246
- package/src/runtime-env.ts +12 -0
- package/src/service.test.ts +118 -0
- package/src/service.ts +765 -200
- package/src/state.test.ts +119 -0
- package/src/state.ts +124 -25
- package/src/types.ts +33 -6
- package/src/utils.ts +19 -0
package/src/conversation.ts
CHANGED
|
@@ -4,11 +4,15 @@ import path from "node:path";
|
|
|
4
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
5
5
|
import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
|
|
6
6
|
import type { GitHubIssueClient } from "./github-client.js";
|
|
7
|
+
import { parseCandidates } from "./memory.js";
|
|
7
8
|
import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
|
|
8
|
-
import type { ClawMemPluginConfig, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
9
|
+
import type { ClawMemPluginConfig, MemoryCandidate, MemorySchema, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
9
10
|
import { fmtTranscript, localDate, localDateTime, sha256, subKey } from "./utils.js";
|
|
10
11
|
import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
|
|
11
12
|
|
|
13
|
+
const FINALIZE_SCHEMA_KIND_LIMIT = 24;
|
|
14
|
+
const FINALIZE_SCHEMA_TOPIC_LIMIT = 80;
|
|
15
|
+
|
|
12
16
|
export class ConversationMirror {
|
|
13
17
|
constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
|
|
14
18
|
|
|
@@ -16,12 +20,15 @@ export class ConversationMirror {
|
|
|
16
20
|
if (sessionId.startsWith("slug-generator-")) return false;
|
|
17
21
|
const first = messages.find((m) => m.role === "user")?.text ?? "";
|
|
18
22
|
if (first.includes("generate a short 1-2 word filename slug") && first.includes("Reply with ONLY the slug")) return false;
|
|
19
|
-
if (first.includes("
|
|
20
|
-
if (first.includes("Extract durable memories from the conversation below.") && first.includes('Return JSON only in the form {"save":')) return false;
|
|
23
|
+
if (first.includes("Write the final issue summary and extract durable memory candidates from the conversation below.")) return false;
|
|
21
24
|
return true;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
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
|
+
}
|
|
25
32
|
const filePath = await this.resolveTranscriptPath(session.sessionFile);
|
|
26
33
|
if (filePath) {
|
|
27
34
|
session.sessionFile = filePath;
|
|
@@ -32,7 +39,7 @@ export class ConversationMirror {
|
|
|
32
39
|
this.api.logger.warn(`clawmem: transcript read failed for ${filePath}: ${String(error)}`);
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
|
-
return { sessionId: session.sessionId, messages:
|
|
42
|
+
return { sessionId: session.sessionId, messages: normalizedFallback };
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
async ensureIssue(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
|
|
@@ -90,174 +97,34 @@ export class ConversationMirror {
|
|
|
90
97
|
return count;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
async
|
|
94
|
-
|
|
100
|
+
async generateFinalArtifacts(
|
|
101
|
+
session: SessionMirrorState,
|
|
102
|
+
snapshot: TranscriptSnapshot,
|
|
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");
|
|
95
106
|
const subagent = this.api.runtime.subagent;
|
|
96
|
-
const sessionKey = subKey(session, "
|
|
97
|
-
const message =
|
|
98
|
-
"Summarize the following conversation and generate a short title.",
|
|
99
|
-
'Return valid JSON only in the form {"summary":"...","title":"..."}',
|
|
100
|
-
"The summary should be concise, factual, and written in 2-4 sentences.",
|
|
101
|
-
"Do not include markdown, bullet points, or analysis.",
|
|
102
|
-
"",
|
|
103
|
-
"Title rules:",
|
|
104
|
-
"- Under 50 characters, accurately describe the main topic or task.",
|
|
105
|
-
"- Should let someone immediately know what the conversation is about.",
|
|
106
|
-
"- Must be in the same language as the majority of the conversation content.",
|
|
107
|
-
"- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
|
|
108
|
-
"", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
|
|
109
|
-
].join("\n");
|
|
107
|
+
const sessionKey = subKey(session, "finalize");
|
|
108
|
+
const message = buildFinalizeArtifactsPrompt(snapshot, schema);
|
|
110
109
|
try {
|
|
111
110
|
const run = await subagent.run({
|
|
112
|
-
sessionKey,
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
sessionKey,
|
|
112
|
+
message,
|
|
113
|
+
deliver: false,
|
|
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.",
|
|
115
117
|
});
|
|
116
|
-
const wait = await subagent.waitForRun({
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
|
|
120
|
-
const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
|
|
121
|
-
if (!text) throw new Error("summary subagent returned no assistant text");
|
|
122
|
-
return parseSummaryAndTitle(text);
|
|
123
|
-
} finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
|
|
124
|
-
}
|
|
125
|
-
|
|
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.",
|
|
118
|
+
const wait = await subagent.waitForRun({
|
|
119
|
+
runId: run.runId,
|
|
120
|
+
timeoutMs: Math.max(this.config.summaryWaitTimeoutMs, this.config.memoryExtractWaitTimeoutMs),
|
|
168
121
|
});
|
|
169
|
-
|
|
170
|
-
if (wait.status === "
|
|
171
|
-
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit:
|
|
172
|
-
const text = [...msgs].reverse().find((
|
|
173
|
-
if (!text)
|
|
174
|
-
return
|
|
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;
|
|
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");
|
|
124
|
+
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
|
|
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);
|
|
261
128
|
} finally {
|
|
262
129
|
subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
|
|
263
130
|
}
|
|
@@ -328,6 +195,65 @@ export class ConversationMirror {
|
|
|
328
195
|
}
|
|
329
196
|
}
|
|
330
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
|
+
|
|
331
257
|
async function fexists(p: string): Promise<boolean> { try { return (await fs.promises.stat(p)).isFile(); } catch { return false; } }
|
|
332
258
|
function isNotFoundError(error: unknown): boolean {
|
|
333
259
|
const text = String(error);
|
|
@@ -367,26 +293,12 @@ function parseSummaryAndTitle(raw: string): { summary: string; title?: string }
|
|
|
367
293
|
return { summary: t };
|
|
368
294
|
}
|
|
369
295
|
|
|
370
|
-
function
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
|
|
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
|
-
}
|
|
296
|
+
function parseFinalArtifacts(raw: string): { summary: string; title?: string; candidates: MemoryCandidate[] } {
|
|
297
|
+
const parsedSummary = parseSummaryAndTitle(raw);
|
|
298
|
+
const candidates = parseCandidates(raw);
|
|
299
|
+
return {
|
|
300
|
+
summary: parsedSummary.summary,
|
|
301
|
+
...(parsedSummary.title ? { title: parsedSummary.title } : {}),
|
|
302
|
+
candidates,
|
|
385
303
|
};
|
|
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
304
|
}
|
|
@@ -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");
|
package/src/github-client.ts
CHANGED
|
@@ -52,6 +52,12 @@ type RepositoryInvitationResponse = {
|
|
|
52
52
|
inviter?: { login?: string; name?: string };
|
|
53
53
|
};
|
|
54
54
|
type TeamMembershipResponse = { state?: string; role?: string };
|
|
55
|
+
type OrganizationMembershipResponse = {
|
|
56
|
+
state?: string;
|
|
57
|
+
role?: string;
|
|
58
|
+
organization?: OrgResponse;
|
|
59
|
+
user?: CollaboratorResponse;
|
|
60
|
+
};
|
|
55
61
|
type InvitationResponse = {
|
|
56
62
|
id?: number;
|
|
57
63
|
role?: string;
|
|
@@ -145,9 +151,30 @@ export class GitHubIssueClient {
|
|
|
145
151
|
async getOrg(org: string): Promise<OrgResponse> {
|
|
146
152
|
return this.req<OrgResponse>(`orgs/${encodeURIComponent(org)}`, { method: "GET" });
|
|
147
153
|
}
|
|
154
|
+
async listOrgMembers(org: string, role?: "admin"): Promise<CollaboratorResponse[]> {
|
|
155
|
+
const q = new URLSearchParams();
|
|
156
|
+
if (role) q.set("role", role);
|
|
157
|
+
const suffix = q.toString();
|
|
158
|
+
return this.req<CollaboratorResponse[]>(`orgs/${encodeURIComponent(org)}/members${suffix ? `?${suffix}` : ""}`, { method: "GET" });
|
|
159
|
+
}
|
|
160
|
+
async getOrgMembership(org: string, username: string): Promise<OrganizationMembershipResponse> {
|
|
161
|
+
return this.req<OrganizationMembershipResponse>(
|
|
162
|
+
`orgs/${encodeURIComponent(org)}/memberships/${encodeURIComponent(username)}`,
|
|
163
|
+
{ method: "GET" },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
async removeOrgMember(org: string, username: string): Promise<void> {
|
|
167
|
+
await this.req(`orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
|
|
168
|
+
}
|
|
169
|
+
async removeOrgMembership(org: string, username: string): Promise<void> {
|
|
170
|
+
await this.req(`orgs/${encodeURIComponent(org)}/memberships/${encodeURIComponent(username)}`, { method: "DELETE" });
|
|
171
|
+
}
|
|
148
172
|
async listOrgTeams(org: string): Promise<TeamResponse[]> {
|
|
149
173
|
return this.req<TeamResponse[]>(`orgs/${encodeURIComponent(org)}/teams`, { method: "GET" });
|
|
150
174
|
}
|
|
175
|
+
async getTeam(org: string, teamSlug: string): Promise<TeamResponse> {
|
|
176
|
+
return this.req<TeamResponse>(`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}`, { method: "GET" });
|
|
177
|
+
}
|
|
151
178
|
async createOrgTeam(org: string, params: { name: string; description?: string; privacy?: "closed" | "secret" }): Promise<TeamResponse> {
|
|
152
179
|
return this.req<TeamResponse>(`orgs/${encodeURIComponent(org)}/teams`, {
|
|
153
180
|
method: "POST",
|
|
@@ -158,6 +185,29 @@ export class GitHubIssueClient {
|
|
|
158
185
|
}),
|
|
159
186
|
});
|
|
160
187
|
}
|
|
188
|
+
async updateTeam(
|
|
189
|
+
org: string,
|
|
190
|
+
teamSlug: string,
|
|
191
|
+
params: { name?: string; description?: string; privacy?: "closed" | "secret" },
|
|
192
|
+
): Promise<TeamResponse> {
|
|
193
|
+
return this.req<TeamResponse>(`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}`, {
|
|
194
|
+
method: "PATCH",
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
...(params.name ? { name: params.name } : {}),
|
|
197
|
+
...(params.description ? { description: params.description } : {}),
|
|
198
|
+
...(params.privacy ? { privacy: params.privacy } : {}),
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async deleteTeam(org: string, teamSlug: string): Promise<void> {
|
|
203
|
+
await this.req(`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}`, { method: "DELETE" });
|
|
204
|
+
}
|
|
205
|
+
async listTeamMembers(org: string, teamSlug: string): Promise<CollaboratorResponse[]> {
|
|
206
|
+
return this.req<CollaboratorResponse[]>(
|
|
207
|
+
`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/members`,
|
|
208
|
+
{ method: "GET" },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
161
211
|
async setTeamMembership(org: string, teamSlug: string, username: string, role: "member" | "maintainer"): Promise<TeamMembershipResponse> {
|
|
162
212
|
return this.req<TeamMembershipResponse>(
|
|
163
213
|
`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(username)}`,
|
|
@@ -238,6 +288,9 @@ export class GitHubIssueClient {
|
|
|
238
288
|
}),
|
|
239
289
|
});
|
|
240
290
|
}
|
|
291
|
+
async revokeOrgInvitation(org: string, invitationId: number): Promise<void> {
|
|
292
|
+
await this.req(`orgs/${encodeURIComponent(org)}/invitations/${invitationId}`, { method: "DELETE" });
|
|
293
|
+
}
|
|
241
294
|
async listOrgOutsideCollaborators(org: string): Promise<CollaboratorResponse[]> {
|
|
242
295
|
return this.req<CollaboratorResponse[]>(`orgs/${encodeURIComponent(org)}/outside_collaborators`, { method: "GET" });
|
|
243
296
|
}
|
|
@@ -250,6 +303,12 @@ export class GitHubIssueClient {
|
|
|
250
303
|
async declineUserOrgInvitation(invitationId: number): Promise<void> {
|
|
251
304
|
await this.req(`user/organization_invitations/${invitationId}`, { method: "DELETE" });
|
|
252
305
|
}
|
|
306
|
+
async transferRepo(owner: string, repo: string, newOwner: string): Promise<RepoResponse> {
|
|
307
|
+
return this.req<RepoResponse>(`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/transfer`, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
body: JSON.stringify({ new_owner: newOwner }),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
253
312
|
async ensureLabels(labels: string[]): Promise<void> {
|
|
254
313
|
for (const label of labels) {
|
|
255
314
|
if (!label.trim()) continue;
|