@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/memory.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { MemoryStore,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|