@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.
@@ -1,5 +1,6 @@
1
- import { MemoryStore, scoreMemoryMatch } from "./memory.js";
1
+ import { MemoryStore, mergeMemoryCandidates } from "./memory.js";
2
2
  import type { ParsedMemoryIssue } from "./types.js";
3
+ import { sha256 } from "./utils.js";
3
4
  import { stringifyFlatYaml } from "./yaml.js";
4
5
 
5
6
  function memory(overrides: Partial<ParsedMemoryIssue> = {}): ParsedMemoryIssue {
@@ -41,15 +42,6 @@ function assert(condition: unknown, message: string): void {
41
42
  if (!condition) throw new Error(message);
42
43
  }
43
44
 
44
- function testConfig(): never {
45
- return {
46
- memoryRecallLimit: 5,
47
- memoryAutoRecallLimit: 3,
48
- turnCommentDelayMs: 1000,
49
- summaryWaitTimeoutMs: 120000,
50
- } as never;
51
- }
52
-
53
45
  async function testBackendSearchBuildsSingleCleanedQuery(): Promise<void> {
54
46
  const queries: string[] = [];
55
47
  const client = {
@@ -59,7 +51,7 @@ async function testBackendSearchBuildsSingleCleanedQuery(): Promise<void> {
59
51
  return [] as IssueRecord[];
60
52
  },
61
53
  };
62
- const store = new MemoryStore(client as never, {} as never, testConfig());
54
+ const store = new MemoryStore(client as never);
63
55
  await store.search([
64
56
  "<clawmem-context>",
65
57
  "- [11] Previous memory that should be stripped",
@@ -108,7 +100,7 @@ async function testBackendSearchPreferredForRecall(): Promise<void> {
108
100
  return searched;
109
101
  },
110
102
  };
111
- const store = new MemoryStore(client as never, {} as never, testConfig());
103
+ const store = new MemoryStore(client as never);
112
104
  const found = await store.search("redis rate limiting", 1);
113
105
 
114
106
  assert(queries.length === 1, "expected backend search to be called once");
@@ -132,7 +124,7 @@ async function testBackendSearchReturnsEmptyWithoutLexicalFallback(): Promise<vo
132
124
  listIssues: async () => issues,
133
125
  searchIssues: async () => [] as IssueRecord[],
134
126
  };
135
- const store = new MemoryStore(client as never, {} as never, testConfig());
127
+ const store = new MemoryStore(client as never);
136
128
  const found = await store.search("redis rate limiting", 5);
137
129
 
138
130
  assert(found.length === 0, "expected backend-only recall to return no results when the backend finds nothing");
@@ -143,7 +135,7 @@ async function testBackendSearchPropagatesErrors(): Promise<void> {
143
135
  repo: () => "owner/main-memory",
144
136
  searchIssues: async () => { throw new Error("search unavailable"); },
145
137
  };
146
- const store = new MemoryStore(client as never, {} as never, testConfig());
138
+ const store = new MemoryStore(client as never);
147
139
  let message = "";
148
140
  try {
149
141
  await store.search("redis rate limiting", 5);
@@ -154,23 +146,30 @@ async function testBackendSearchPropagatesErrors(): Promise<void> {
154
146
  assert(message.includes("search unavailable"), "expected backend failures to propagate instead of falling back locally");
155
147
  }
156
148
 
157
- function testCjkScoring(): void {
158
- const billing = memory({
159
- issueNumber: 3,
160
- title: "Memory: 账单修复流程",
161
- detail: "遇到账单不一致时,先核对 invoice_id,再补发 webhook。",
162
- topics: ["账单", "支付"],
163
- });
164
- const unrelated = memory({
165
- issueNumber: 4,
166
- title: "Memory: 部署备注",
167
- detail: "发布前需要确认灰度流量比例。",
168
- topics: ["部署"],
169
- });
170
- const billingScore = scoreMemoryMatch(billing, "账单 webhook");
171
- const unrelatedScore = scoreMemoryMatch(unrelated, "账单 webhook");
172
- assert(billingScore > unrelatedScore, "expected Chinese query scoring to prefer the billing memory");
173
- assert(billingScore > 0, "expected Chinese query to produce a positive match score");
149
+ function testMergeMemoryCandidates(): void {
150
+ const merged = mergeMemoryCandidates(
151
+ [
152
+ {
153
+ candidateId: "abc",
154
+ detail: "Redis Lua scripts keep rate limiting atomic.",
155
+ topics: ["redis"],
156
+ },
157
+ ],
158
+ [
159
+ {
160
+ candidateId: "abc",
161
+ detail: "Redis Lua scripts keep rate limiting atomic.",
162
+ kind: "lesson",
163
+ topics: ["rate-limit"],
164
+ evidence: "User confirmed the production path uses Lua.",
165
+ },
166
+ ],
167
+ );
168
+
169
+ assert(merged.length === 1, "expected duplicate candidates to merge by candidateId");
170
+ assert(merged[0]?.kind === "lesson", "expected merged candidates to preserve new schema hints");
171
+ assert(JSON.stringify(merged[0]?.topics) === JSON.stringify(["rate-limit", "redis"]), "expected merged candidates to union topics");
172
+ assert(merged[0]?.evidence === "User confirmed the production path uses Lua.", "expected merged candidates to preserve evidence");
174
173
  }
175
174
 
176
175
  async function testStructuredStoreAndSchema(): Promise<void> {
@@ -178,6 +177,8 @@ async function testStructuredStoreAndSchema(): Promise<void> {
178
177
  const ensured: string[][] = [];
179
178
  const labels: LabelRecord[] = [{ name: "kind:lesson" }, { name: "topic:redis" }];
180
179
  const client = {
180
+ repo: () => "owner/main-memory",
181
+ searchIssues: async () => [] as IssueRecord[],
181
182
  listIssues: async () => [] as IssueRecord[],
182
183
  listLabels: async () => labels,
183
184
  ensureLabels: async (next: string[]) => { ensured.push(next); },
@@ -186,7 +187,7 @@ async function testStructuredStoreAndSchema(): Promise<void> {
186
187
  return { number: 99, title: payload.title };
187
188
  },
188
189
  };
189
- const store = new MemoryStore(client as never, {} as never, testConfig());
190
+ const store = new MemoryStore(client as never);
190
191
  const result = await store.store({ detail: "Redis Lua scripts are required for atomic rate limiting.", kind: "Lesson", topics: ["Redis Ops", "rate_limit"] });
191
192
  const schema = await store.listSchema();
192
193
 
@@ -207,6 +208,47 @@ async function testStructuredStoreAndSchema(): Promise<void> {
207
208
  assert(schema.topics.includes("redis"), "expected schema to expose existing topic labels");
208
209
  }
209
210
 
211
+ async function testListSchemaPrefersLabelsWithoutIssueScan(): Promise<void> {
212
+ const client = {
213
+ listLabels: async () => [{ name: "kind:lesson" }, { name: "topic:redis" }, { name: "topic:rate-limit" }],
214
+ listIssues: async () => { throw new Error("listSchema should not scan issues when label schema is available"); },
215
+ };
216
+ const store = new MemoryStore(client as never);
217
+ const schema = await store.listSchema();
218
+
219
+ assert(JSON.stringify(schema.kinds) === JSON.stringify(["lesson"]), "expected schema kinds to come from labels");
220
+ assert(JSON.stringify(schema.topics) === JSON.stringify(["rate-limit", "redis"]), "expected schema topics to come from labels");
221
+ }
222
+
223
+ async function testStoreDeduplicatesViaHashSearch(): Promise<void> {
224
+ const detail = "Redis Lua scripts are required for atomic rate limiting.";
225
+ const hash = sha256(detail);
226
+ const existing = memory({
227
+ issueNumber: 77,
228
+ detail,
229
+ memoryHash: hash,
230
+ kind: "lesson",
231
+ topics: ["redis-ops"],
232
+ });
233
+ const queries: string[] = [];
234
+ const client = {
235
+ repo: () => "owner/main-memory",
236
+ searchIssues: async (query: string) => {
237
+ queries.push(query);
238
+ return [issueFromMemory(existing)];
239
+ },
240
+ listIssues: async () => { throw new Error("store should not scan all active memories"); },
241
+ ensureLabels: async () => {},
242
+ createIssue: async () => { throw new Error("store should not create a duplicate issue"); },
243
+ };
244
+ const store = new MemoryStore(client as never);
245
+ const result = await store.store({ detail, kind: "lesson", topics: ["redis_ops"] });
246
+
247
+ assert(result.created === false, "expected hash search to reuse an existing exact duplicate");
248
+ assert(result.memory.issueNumber === 77, "expected hash search to return the existing memory");
249
+ assert(queries.length === 1 && queries[0]?.includes(hash), "expected store to query by memory hash");
250
+ }
251
+
210
252
  async function testStoreKeepsFullAutoTitleAndSupportsExplicitTitle(): Promise<void> {
211
253
  const created: Array<{ title: string; body: string; labels: string[] }> = [];
212
254
  const client = {
@@ -218,7 +260,7 @@ async function testStoreKeepsFullAutoTitleAndSupportsExplicitTitle(): Promise<vo
218
260
  return { number: created.length + 100, title: payload.title };
219
261
  },
220
262
  };
221
- const store = new MemoryStore(client as never, {} as never, testConfig());
263
+ const store = new MemoryStore(client as never);
222
264
  const longDetail = "Tech Decision #001: Frontend = React Native, Backend = FastAPI, Database = PostgreSQL, and analytics events must stay append-only for auditability.";
223
265
  const auto = await store.store({ detail: longDetail });
224
266
  const explicit = await store.store({ title: "Architecture Decision #001", detail: "Use React Native + FastAPI for the first mobile stack." });
@@ -255,6 +297,11 @@ async function testGetAndListMemories(): Promise<void> {
255
297
  })),
256
298
  ];
257
299
  const client = {
300
+ getIssue: async (number: number) => {
301
+ const issue = issues.find((entry) => entry.number === number);
302
+ if (!issue) throw new Error("issue missing");
303
+ return issue;
304
+ },
258
305
  listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
259
306
  const labels = params?.labels ?? [];
260
307
  const state = params?.state ?? "open";
@@ -266,7 +313,7 @@ async function testGetAndListMemories(): Promise<void> {
266
313
  });
267
314
  },
268
315
  };
269
- const store = new MemoryStore(client as never, {} as never, testConfig());
316
+ const store = new MemoryStore(client as never);
270
317
  const exact = await store.get("4");
271
318
  const activeFacts = await store.listMemories({ status: "active", kind: "core-fact", limit: 10 });
272
319
  const sports = await store.listMemories({ status: "all", topic: "sports", limit: 10 });
@@ -288,6 +335,11 @@ async function testLegacyMemoriesWithoutSessionOrDate(): Promise<void> {
288
335
  ];
289
336
  const client = {
290
337
  repo: () => "owner/main-memory",
338
+ getIssue: async (number: number) => {
339
+ const issue = issues.find((entry) => entry.number === number);
340
+ if (!issue) throw new Error("issue missing");
341
+ return issue;
342
+ },
291
343
  listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
292
344
  const labels = params?.labels ?? [];
293
345
  const state = params?.state ?? "open";
@@ -300,7 +352,7 @@ async function testLegacyMemoriesWithoutSessionOrDate(): Promise<void> {
300
352
  },
301
353
  searchIssues: async () => issues,
302
354
  };
303
- const store = new MemoryStore(client as never, {} as never, testConfig());
355
+ const store = new MemoryStore(client as never);
304
356
  const exact = await store.get("4");
305
357
  const recalled = await store.search("F1 Dota 2", 5);
306
358
 
@@ -323,6 +375,11 @@ async function testUpdateMemoryInPlace(): Promise<void> {
323
375
  const updatedIssues: Array<{ number: number; title?: string; body?: string }> = [];
324
376
  const syncedLabels: Array<{ number: number; labels: string[] }> = [];
325
377
  const client = {
378
+ getIssue: async (number: number) => {
379
+ const issue = issues.find((entry) => entry.number === number);
380
+ if (!issue) throw new Error("issue missing");
381
+ return issue;
382
+ },
326
383
  listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
327
384
  const labels = params?.labels ?? [];
328
385
  const state = params?.state ?? "open";
@@ -349,7 +406,7 @@ async function testUpdateMemoryInPlace(): Promise<void> {
349
406
  issue.labels = labels;
350
407
  },
351
408
  };
352
- const store = new MemoryStore(client as never, {} as never, testConfig());
409
+ const store = new MemoryStore(client as never);
353
410
  const updated = await store.update("4", {
354
411
  detail: "xiangz likes F1, watches Dota 2 as a viewer, and recently follows tennis.",
355
412
  topics: ["preferences", "sports"],
@@ -378,6 +435,11 @@ async function testUpdateSupportsExplicitRetitle(): Promise<void> {
378
435
  ];
379
436
  const updatedIssues: Array<{ number: number; title?: string; body?: string }> = [];
380
437
  const client = {
438
+ getIssue: async (number: number) => {
439
+ const issue = issues.find((entry) => entry.number === number);
440
+ if (!issue) throw new Error("issue missing");
441
+ return issue;
442
+ },
381
443
  listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
382
444
  const labels = params?.labels ?? [];
383
445
  const state = params?.state ?? "open";
@@ -399,13 +461,58 @@ async function testUpdateSupportsExplicitRetitle(): Promise<void> {
399
461
  },
400
462
  syncManagedLabels: async () => {},
401
463
  };
402
- const store = new MemoryStore(client as never, {} as never, testConfig());
464
+ const store = new MemoryStore(client as never);
403
465
  const updated = await store.update("20", { title: "Billing Audit Convention" });
404
466
 
405
467
  assert(updated?.title === "Memory: Billing Audit Convention", "expected memory_update to support explicit retitle");
406
468
  assert(updatedIssues[0]?.title === "Memory: Billing Audit Convention", "expected issue title patch to use the explicit retitle");
407
469
  }
408
470
 
471
+ async function testUpdateUsesHashSearchForDuplicateCheck(): Promise<void> {
472
+ const currentDetail = "We use append-only audit events for billing changes.";
473
+ const conflictingDetail = "Billing events must stay append-only for auditability.";
474
+ const current = issueFromMemory(memory({
475
+ issueNumber: 20,
476
+ title: "Memory: billing convention",
477
+ detail: currentDetail,
478
+ memoryHash: sha256(currentDetail),
479
+ kind: "convention",
480
+ }));
481
+ const conflicting = issueFromMemory(memory({
482
+ issueNumber: 21,
483
+ title: "Memory: audit rule",
484
+ detail: conflictingDetail,
485
+ memoryHash: sha256(conflictingDetail),
486
+ kind: "convention",
487
+ }));
488
+ const queries: string[] = [];
489
+ const client = {
490
+ repo: () => "owner/main-memory",
491
+ getIssue: async (number: number) => {
492
+ if (number === 20) return current;
493
+ throw new Error("issue missing");
494
+ },
495
+ searchIssues: async (query: string) => {
496
+ queries.push(query);
497
+ return [conflicting];
498
+ },
499
+ listIssues: async () => { throw new Error("update should not scan all active memories when direct lookup/search are available"); },
500
+ ensureLabels: async () => {},
501
+ updateIssue: async () => { throw new Error("duplicate update should fail before mutating"); },
502
+ syncManagedLabels: async () => {},
503
+ };
504
+ const store = new MemoryStore(client as never);
505
+ let message = "";
506
+ try {
507
+ await store.update("20", { detail: conflictingDetail });
508
+ } catch (error) {
509
+ message = String(error);
510
+ }
511
+
512
+ assert(message.includes("[21]"), "expected duplicate detection to reference the conflicting memory");
513
+ assert(queries.length === 1 && queries[0]?.includes(sha256(conflictingDetail)), "expected update duplicate checks to search by memory hash");
514
+ }
515
+
409
516
  async function testForgetClosesMemoryIssue(): Promise<void> {
410
517
  const issues: IssueRecord[] = [
411
518
  issueFromMemory(memory({
@@ -419,6 +526,11 @@ async function testForgetClosesMemoryIssue(): Promise<void> {
419
526
  const syncedLabels: Array<{ number: number; labels: string[] }> = [];
420
527
  const updatedIssues: Array<{ number: number; state?: "open" | "closed" }> = [];
421
528
  const client = {
529
+ getIssue: async (number: number) => {
530
+ const issue = issues.find((entry) => entry.number === number);
531
+ if (!issue) throw new Error("issue missing");
532
+ return issue;
533
+ },
422
534
  listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
423
535
  const labels = params?.labels ?? [];
424
536
  const state = params?.state ?? "open";
@@ -443,7 +555,7 @@ async function testForgetClosesMemoryIssue(): Promise<void> {
443
555
  return issue;
444
556
  },
445
557
  };
446
- const store = new MemoryStore(client as never, {} as never, testConfig());
558
+ const store = new MemoryStore(client as never);
447
559
  const forgotten = await store.forget("12");
448
560
 
449
561
  assert(forgotten?.status === "stale", "expected forgotten memory to be returned as stale");
@@ -456,14 +568,17 @@ async function main(): Promise<void> {
456
568
  await testBackendSearchPreferredForRecall();
457
569
  await testBackendSearchReturnsEmptyWithoutLexicalFallback();
458
570
  await testBackendSearchPropagatesErrors();
459
- testCjkScoring();
571
+ testMergeMemoryCandidates();
460
572
  await testStructuredStoreAndSchema();
573
+ await testListSchemaPrefersLabelsWithoutIssueScan();
574
+ await testStoreDeduplicatesViaHashSearch();
461
575
  await testStoreKeepsFullAutoTitleAndSupportsExplicitTitle();
462
576
  await testGetAndListMemories();
463
577
  await testLegacyMemoriesWithoutSessionOrDate();
464
578
  await testUpdateMemoryInPlace();
465
579
  await testUpdateSupportsExplicitRetitle();
466
580
  await testForgetClosesMemoryIssue();
581
+ await testUpdateUsesHashSearchForDuplicateCheck();
467
582
  console.log("memory tests passed");
468
583
  }
469
584