@clawmem-ai/clawmem 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,373 @@
1
+ import { MemoryStore, scoreMemoryMatch } from "./memory.js";
2
+ import type { ParsedMemoryIssue } from "./types.js";
3
+ import { stringifyFlatYaml } from "./yaml.js";
4
+
5
+ function memory(overrides: Partial<ParsedMemoryIssue> = {}): ParsedMemoryIssue {
6
+ return {
7
+ issueNumber: overrides.issueNumber ?? 1,
8
+ title: overrides.title ?? "Memory: Example",
9
+ memoryId: overrides.memoryId ?? String(overrides.issueNumber ?? 1),
10
+ sessionId: overrides.sessionId ?? "sess-1",
11
+ date: overrides.date ?? "2026-03-23",
12
+ detail: overrides.detail ?? "Example durable detail",
13
+ status: overrides.status ?? "active",
14
+ ...(overrides.kind ? { kind: overrides.kind } : {}),
15
+ ...(overrides.memoryHash ? { memoryHash: overrides.memoryHash } : {}),
16
+ ...(overrides.topics ? { topics: overrides.topics } : {}),
17
+ };
18
+ }
19
+
20
+ type IssueRecord = { number: number; title?: string; body?: string; state?: "open" | "closed"; labels?: string[] };
21
+ type LabelRecord = { name?: string };
22
+
23
+ function issueFromMemory(m: ParsedMemoryIssue): IssueRecord {
24
+ return {
25
+ number: m.issueNumber,
26
+ title: m.title,
27
+ body: stringifyFlatYaml([
28
+ ["memory_hash", m.memoryHash ?? ""],
29
+ ["date", m.date],
30
+ ["detail", m.detail],
31
+ ]),
32
+ state: m.status === "stale" ? "closed" : "open",
33
+ labels: [
34
+ "type:memory",
35
+ `session:${m.sessionId}`,
36
+ ...(m.kind ? [`kind:${m.kind}`] : []),
37
+ ...(m.topics ?? []).map((topic) => `topic:${topic}`),
38
+ ],
39
+ };
40
+ }
41
+
42
+ function assert(condition: unknown, message: string): void {
43
+ if (!condition) throw new Error(message);
44
+ }
45
+
46
+ async function testSearchRanking(): Promise<void> {
47
+ const issues = [
48
+ issueFromMemory(memory({
49
+ issueNumber: 1,
50
+ title: "Memory: Redis rate limit tuning",
51
+ detail: "Distributed Redis rate limiting must use Lua scripts to stay atomic.",
52
+ kind: "lesson",
53
+ topics: ["redis", "rate-limiting"],
54
+ })),
55
+ issueFromMemory(memory({
56
+ issueNumber: 2,
57
+ title: "Memory: Generic backend notes",
58
+ detail: "We use Redis in several services, but this one is not about rate limiting.",
59
+ topics: ["backend"],
60
+ })),
61
+ ];
62
+ const client = {
63
+ listIssues: async () => issues,
64
+ };
65
+ const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
66
+ const found = await store.search("redis rate limiting", 5);
67
+ assert(found.length === 2, "expected both memories to match");
68
+ assert(found[0]?.issueNumber === 1, "expected the more specific Redis rate limiting memory to rank first");
69
+ }
70
+
71
+ async function testBackendSearchPreferredForRecall(): Promise<void> {
72
+ const listed = [
73
+ issueFromMemory(memory({
74
+ issueNumber: 1,
75
+ title: "Memory: lexical decoy",
76
+ detail: "redis rate limiting checklist",
77
+ kind: "lesson",
78
+ })),
79
+ ];
80
+ const searched = [
81
+ issueFromMemory(memory({
82
+ issueNumber: 2,
83
+ title: "Memory: semantic winner",
84
+ detail: "Use Lua scripts to keep Redis rate limiting atomic.",
85
+ kind: "lesson",
86
+ topics: ["redis"],
87
+ })),
88
+ ];
89
+ const queries: string[] = [];
90
+ const client = {
91
+ repo: () => "owner/main-memory",
92
+ listIssues: async () => listed,
93
+ searchIssues: async (query: string) => {
94
+ queries.push(query);
95
+ return searched;
96
+ },
97
+ };
98
+ const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
99
+ const found = await store.search("redis rate limiting", 5);
100
+
101
+ assert(queries.length === 1, "expected backend search to be called once");
102
+ assert(queries[0]?.includes('repo:owner/main-memory'), "expected backend query to scope to the current repo");
103
+ assert(queries[0]?.includes('label:\"type:memory\"') || queries[0]?.includes('label:"type:memory"'), "expected backend query to filter memory issues");
104
+ assert(found.length === 1 && found[0]?.issueNumber === 2, "expected backend search results to be preferred");
105
+ }
106
+
107
+ async function testBackendSearchFallsBackToLocalLexical(): Promise<void> {
108
+ const issues = [
109
+ issueFromMemory(memory({
110
+ issueNumber: 3,
111
+ title: "Memory: Redis rate limit tuning",
112
+ detail: "Distributed Redis rate limiting must use Lua scripts to stay atomic.",
113
+ kind: "lesson",
114
+ topics: ["redis"],
115
+ })),
116
+ ];
117
+ const client = {
118
+ repo: () => "owner/main-memory",
119
+ listIssues: async () => issues,
120
+ searchIssues: async () => { throw new Error("search unavailable"); },
121
+ };
122
+ const store = new MemoryStore(client as never, { logger: { warn: () => {} } } as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
123
+ const found = await store.search("redis rate limiting", 5);
124
+
125
+ assert(found.length === 1 && found[0]?.issueNumber === 3, "expected lexical fallback when backend search fails");
126
+ }
127
+
128
+ function testCjkScoring(): void {
129
+ const billing = memory({
130
+ issueNumber: 3,
131
+ title: "Memory: 账单修复流程",
132
+ detail: "遇到账单不一致时,先核对 invoice_id,再补发 webhook。",
133
+ topics: ["账单", "支付"],
134
+ });
135
+ const unrelated = memory({
136
+ issueNumber: 4,
137
+ title: "Memory: 部署备注",
138
+ detail: "发布前需要确认灰度流量比例。",
139
+ topics: ["部署"],
140
+ });
141
+ const billingScore = scoreMemoryMatch(billing, "账单 webhook");
142
+ const unrelatedScore = scoreMemoryMatch(unrelated, "账单 webhook");
143
+ assert(billingScore > unrelatedScore, "expected Chinese query scoring to prefer the billing memory");
144
+ assert(billingScore > 0, "expected Chinese query to produce a positive match score");
145
+ }
146
+
147
+ async function testStructuredStoreAndSchema(): Promise<void> {
148
+ const created: Array<{ title: string; body: string; labels: string[] }> = [];
149
+ const ensured: string[][] = [];
150
+ const labels: LabelRecord[] = [{ name: "kind:lesson" }, { name: "topic:redis" }];
151
+ const client = {
152
+ listIssues: async () => [] as IssueRecord[],
153
+ listLabels: async () => labels,
154
+ ensureLabels: async (next: string[]) => { ensured.push(next); },
155
+ createIssue: async (payload: { title: string; body: string; labels: string[] }) => {
156
+ created.push(payload);
157
+ return { number: 99, title: payload.title };
158
+ },
159
+ };
160
+ const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
161
+ const result = await store.store({ detail: "Redis Lua scripts are required for atomic rate limiting.", kind: "Lesson", topics: ["Redis Ops", "rate_limit"] }, "manual");
162
+ const schema = await store.listSchema();
163
+
164
+ assert(result.created === true, "expected a new structured memory to be created");
165
+ assert(result.memory.kind === "lesson", "expected kind to be normalized");
166
+ assert(JSON.stringify(result.memory.topics) === JSON.stringify(["rate-limit", "redis-ops"]), "expected topics to be normalized and sorted");
167
+ assert(created.length === 1, "expected a single issue creation");
168
+ assert(created[0]?.labels.includes("kind:lesson"), "expected created labels to include normalized kind");
169
+ assert(created[0]?.labels.includes("topic:redis-ops"), "expected created labels to include normalized topic");
170
+ assert(created[0]?.labels.includes("topic:rate-limit"), "expected created labels to include normalized topic");
171
+ assert(!created[0]?.labels.some((label) => label.startsWith("date:")), "expected new memory labels to omit date labels");
172
+ assert(created[0]?.body.includes(`date: ${result.memory.date}`), "expected new memory body to retain logical date metadata");
173
+ assert(ensured[0]?.includes("kind:lesson"), "expected ensureLabels to include kind label");
174
+ assert(schema.kinds.includes("lesson"), "expected schema to expose existing kind labels");
175
+ assert(schema.topics.includes("redis"), "expected schema to expose existing topic labels");
176
+ }
177
+
178
+ async function testGetAndListMemories(): Promise<void> {
179
+ const issues = [
180
+ issueFromMemory(memory({
181
+ issueNumber: 4,
182
+ title: "Memory: xiangz preferences",
183
+ detail: "xiangz likes F1 and watches Dota 2 as a viewer.",
184
+ kind: "core-fact",
185
+ topics: ["preferences", "hobbies"],
186
+ })),
187
+ issueFromMemory(memory({
188
+ issueNumber: 10,
189
+ title: "Memory: fruit preference",
190
+ detail: "xiangz likes mango.",
191
+ kind: "core-fact",
192
+ topics: ["food"],
193
+ })),
194
+ issueFromMemory(memory({
195
+ issueNumber: 11,
196
+ title: "Memory: old sports note",
197
+ detail: "xiangz follows F1.",
198
+ kind: "lesson",
199
+ status: "stale",
200
+ topics: ["sports"],
201
+ })),
202
+ ];
203
+ const client = {
204
+ listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
205
+ const labels = params?.labels ?? [];
206
+ const state = params?.state ?? "open";
207
+ return issues.filter((issue) => {
208
+ const issueLabels = issue.labels ?? [];
209
+ if (!labels.every((label) => issueLabels.includes(label))) return false;
210
+ if (state === "all") return true;
211
+ return (issue.state ?? "open") === state;
212
+ });
213
+ },
214
+ };
215
+ const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
216
+ const exact = await store.get("4");
217
+ const activeFacts = await store.listMemories({ status: "active", kind: "core-fact", limit: 10 });
218
+ const sports = await store.listMemories({ status: "all", topic: "sports", limit: 10 });
219
+
220
+ assert(exact?.issueNumber === 4, "expected direct memory lookup to find issue #4");
221
+ assert(activeFacts.length === 2, "expected listMemories to filter active core facts");
222
+ assert(activeFacts[0]?.issueNumber === 10, "expected listMemories to sort newest-first");
223
+ assert(sports.length === 1 && sports[0]?.issueNumber === 11, "expected listMemories to filter by topic across statuses");
224
+ }
225
+
226
+ async function testLegacyMemoriesWithoutSessionOrDate(): Promise<void> {
227
+ const issues: IssueRecord[] = [
228
+ {
229
+ number: 4,
230
+ title: "Memory: xiangz preferences",
231
+ body: "xiangz likes F1 and watches Dota 2 as a viewer.",
232
+ labels: ["type:memory", "kind:core-fact", "topic:preferences"],
233
+ },
234
+ ];
235
+ const client = {
236
+ listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
237
+ const labels = params?.labels ?? [];
238
+ const state = params?.state ?? "open";
239
+ return issues.filter((issue) => {
240
+ const issueLabels = issue.labels ?? [];
241
+ if (!labels.every((label) => issueLabels.includes(label))) return false;
242
+ if (state === "all") return true;
243
+ return (issue.state ?? "open") === state;
244
+ });
245
+ },
246
+ };
247
+ const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
248
+ const exact = await store.get("4");
249
+ const recalled = await store.search("F1 Dota 2", 5);
250
+
251
+ assert(exact?.issueNumber === 4, "expected legacy memory without session/date to be readable");
252
+ assert(exact?.sessionId === "legacy", "expected missing session label to fall back to legacy");
253
+ assert(exact?.date === "1970-01-01", "expected missing date label to fall back to a placeholder");
254
+ assert(recalled.some((memory) => memory.issueNumber === 4), "expected legacy memory to participate in recall");
255
+ }
256
+
257
+ async function testUpdateMemoryInPlace(): Promise<void> {
258
+ const issues: IssueRecord[] = [
259
+ issueFromMemory(memory({
260
+ issueNumber: 4,
261
+ title: "Memory: xiangz preferences",
262
+ detail: "xiangz likes F1 and watches Dota 2 as a viewer.",
263
+ kind: "core-fact",
264
+ topics: ["preferences"],
265
+ })),
266
+ ];
267
+ const ensured: string[][] = [];
268
+ const updatedIssues: Array<{ number: number; title?: string; body?: string }> = [];
269
+ const syncedLabels: Array<{ number: number; labels: string[] }> = [];
270
+ const client = {
271
+ listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
272
+ const labels = params?.labels ?? [];
273
+ const state = params?.state ?? "open";
274
+ return issues.filter((issue) => {
275
+ const issueLabels = issue.labels ?? [];
276
+ if (!labels.every((label) => issueLabels.includes(label))) return false;
277
+ if (state === "all") return true;
278
+ return (issue.state ?? "open") === state;
279
+ });
280
+ },
281
+ ensureLabels: async (labels: string[]) => { ensured.push(labels); },
282
+ updateIssue: async (number: number, patch: { title?: string; body?: string }) => {
283
+ updatedIssues.push({ number, ...patch });
284
+ const issue = issues.find((entry) => entry.number === number);
285
+ if (!issue) throw new Error("issue missing");
286
+ if (patch.title) issue.title = patch.title;
287
+ if (patch.body) issue.body = patch.body;
288
+ return issue;
289
+ },
290
+ syncManagedLabels: async (number: number, labels: string[]) => {
291
+ syncedLabels.push({ number, labels });
292
+ const issue = issues.find((entry) => entry.number === number);
293
+ if (!issue) throw new Error("issue missing");
294
+ issue.labels = labels;
295
+ },
296
+ };
297
+ const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
298
+ const updated = await store.update("4", {
299
+ detail: "xiangz likes F1, watches Dota 2 as a viewer, and recently follows tennis.",
300
+ topics: ["preferences", "sports"],
301
+ });
302
+
303
+ assert(updated?.issueNumber === 4, "expected memory_update to modify the same issue");
304
+ assert(updated?.detail.includes("tennis"), "expected updated detail to be returned");
305
+ assert(JSON.stringify(updated?.topics) === JSON.stringify(["preferences", "sports"]), "expected topics to be replaced");
306
+ assert(updatedIssues.length === 1, "expected a single issue update");
307
+ assert(updatedIssues[0]?.title !== "Memory: xiangz preferences", "expected title to refresh from updated detail");
308
+ assert(ensured[0]?.includes("topic:sports"), "expected new topic label to be ensured");
309
+ assert(syncedLabels[0]?.labels.includes("kind:core-fact"), "expected existing kind label to be preserved");
310
+ }
311
+
312
+ async function testForgetClosesMemoryIssue(): Promise<void> {
313
+ const issues: IssueRecord[] = [
314
+ issueFromMemory(memory({
315
+ issueNumber: 12,
316
+ title: "Memory: outdated deployment rule",
317
+ detail: "Always restart the full cluster after deploy.",
318
+ kind: "convention",
319
+ topics: ["deploy"],
320
+ })),
321
+ ];
322
+ const syncedLabels: Array<{ number: number; labels: string[] }> = [];
323
+ const updatedIssues: Array<{ number: number; state?: "open" | "closed" }> = [];
324
+ const client = {
325
+ listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
326
+ const labels = params?.labels ?? [];
327
+ const state = params?.state ?? "open";
328
+ return issues.filter((issue) => {
329
+ const issueLabels = issue.labels ?? [];
330
+ if (!labels.every((label) => issueLabels.includes(label))) return false;
331
+ if (state === "all") return true;
332
+ return (issue.state ?? "open") === state;
333
+ });
334
+ },
335
+ syncManagedLabels: async (number: number, labels: string[]) => {
336
+ syncedLabels.push({ number, labels });
337
+ const issue = issues.find((entry) => entry.number === number);
338
+ if (!issue) throw new Error("issue missing");
339
+ issue.labels = labels;
340
+ },
341
+ updateIssue: async (number: number, patch: { state?: "open" | "closed" }) => {
342
+ updatedIssues.push({ number, state: patch.state });
343
+ const issue = issues.find((entry) => entry.number === number);
344
+ if (!issue) throw new Error("issue missing");
345
+ if (patch.state) issue.state = patch.state;
346
+ return issue;
347
+ },
348
+ };
349
+ const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
350
+ const forgotten = await store.forget("12");
351
+
352
+ assert(forgotten?.status === "stale", "expected forgotten memory to be returned as stale");
353
+ assert(updatedIssues[0]?.state === "closed", "expected memory_forget to close the issue");
354
+ assert(syncedLabels[0]?.labels.every((label) => !label.startsWith("memory-status:")), "expected memory_forget to stop writing lifecycle labels");
355
+ }
356
+
357
+ async function main(): Promise<void> {
358
+ await testSearchRanking();
359
+ await testBackendSearchPreferredForRecall();
360
+ await testBackendSearchFallsBackToLocalLexical();
361
+ testCjkScoring();
362
+ await testStructuredStoreAndSchema();
363
+ await testGetAndListMemories();
364
+ await testLegacyMemoriesWithoutSessionOrDate();
365
+ await testUpdateMemoryInPlace();
366
+ await testForgetClosesMemoryIssue();
367
+ console.log("memory tests passed");
368
+ }
369
+
370
+ main().catch((error) => {
371
+ console.error(error instanceof Error ? error.message : String(error));
372
+ process.exit(1);
373
+ });