@totalreclaw/totalreclaw 1.5.0 → 3.0.6

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,356 +0,0 @@
1
- /**
2
- * Unit tests for memory consolidation & near-duplicate detection.
3
- *
4
- * Run with: npx tsx consolidation.test.ts
5
- *
6
- * Uses TAP-style output (no test framework dependency).
7
- */
8
-
9
- import {
10
- findNearDuplicate,
11
- shouldSupersede,
12
- clusterFacts,
13
- getStoreDedupThreshold,
14
- getConsolidationThreshold,
15
- STORE_DEDUP_MAX_CANDIDATES,
16
- } from './consolidation.js';
17
- import type { DecryptedCandidate } from './consolidation.js';
18
-
19
- let passed = 0;
20
- let failed = 0;
21
- let testNum = 0;
22
-
23
- function assert(condition: boolean, message: string): void {
24
- testNum++;
25
- if (condition) {
26
- passed++;
27
- console.log(`ok ${testNum} - ${message}`);
28
- } else {
29
- failed++;
30
- console.log(`not ok ${testNum} - ${message}`);
31
- }
32
- }
33
-
34
- function assertClose(actual: number, expected: number, epsilon: number, message: string): void {
35
- const diff = Math.abs(actual - expected);
36
- assert(diff < epsilon, `${message} (expected ~${expected}, got ${actual}, diff=${diff})`);
37
- }
38
-
39
- // Helper: create a DecryptedCandidate
40
- function makeCandidate(
41
- overrides: Partial<DecryptedCandidate> & { id: string },
42
- ): DecryptedCandidate {
43
- return {
44
- text: `fact ${overrides.id}`,
45
- embedding: null,
46
- importance: 5,
47
- decayScore: 1.0,
48
- createdAt: 1000,
49
- version: 1,
50
- ...overrides,
51
- };
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // getStoreDedupThreshold tests
56
- // ---------------------------------------------------------------------------
57
-
58
- console.log('# getStoreDedupThreshold');
59
-
60
- {
61
- // Default threshold should be 0.85 (no env var set)
62
- const orig = process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
63
- delete process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
64
- assertClose(getStoreDedupThreshold(), 0.85, 1e-10, 'default threshold is 0.85');
65
- if (orig !== undefined) process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD = orig;
66
- }
67
-
68
- {
69
- // Custom threshold via env var
70
- const orig = process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
71
- process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD = '0.75';
72
- assertClose(getStoreDedupThreshold(), 0.75, 1e-10, 'custom threshold 0.75 from env');
73
- if (orig !== undefined) {
74
- process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD = orig;
75
- } else {
76
- delete process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
77
- }
78
- }
79
-
80
- {
81
- // Invalid env var falls back to default
82
- const orig = process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
83
- process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD = 'not-a-number';
84
- assertClose(getStoreDedupThreshold(), 0.85, 1e-10, 'invalid env var falls back to 0.85');
85
- if (orig !== undefined) {
86
- process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD = orig;
87
- } else {
88
- delete process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
89
- }
90
- }
91
-
92
- // ---------------------------------------------------------------------------
93
- // getConsolidationThreshold tests
94
- // ---------------------------------------------------------------------------
95
-
96
- console.log('# getConsolidationThreshold');
97
-
98
- {
99
- // Default threshold should be 0.88 (no env var set)
100
- const orig = process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
101
- delete process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
102
- assertClose(getConsolidationThreshold(), 0.88, 1e-10, 'default threshold is 0.88');
103
- if (orig !== undefined) process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD = orig;
104
- }
105
-
106
- {
107
- // Custom threshold via env var
108
- const orig = process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
109
- process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD = '0.95';
110
- assertClose(getConsolidationThreshold(), 0.95, 1e-10, 'custom threshold 0.95 from env');
111
- if (orig !== undefined) {
112
- process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD = orig;
113
- } else {
114
- delete process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
115
- }
116
- }
117
-
118
- {
119
- // Invalid env var falls back to default
120
- const orig = process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
121
- process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD = 'garbage';
122
- assertClose(getConsolidationThreshold(), 0.88, 1e-10, 'invalid env var falls back to 0.88');
123
- if (orig !== undefined) {
124
- process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD = orig;
125
- } else {
126
- delete process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
127
- }
128
- }
129
-
130
- // ---------------------------------------------------------------------------
131
- // STORE_DEDUP_MAX_CANDIDATES constant
132
- // ---------------------------------------------------------------------------
133
-
134
- console.log('# STORE_DEDUP_MAX_CANDIDATES');
135
-
136
- assert(STORE_DEDUP_MAX_CANDIDATES === 200, 'STORE_DEDUP_MAX_CANDIDATES is 200');
137
-
138
- // ---------------------------------------------------------------------------
139
- // findNearDuplicate tests
140
- // ---------------------------------------------------------------------------
141
-
142
- console.log('# findNearDuplicate');
143
-
144
- {
145
- // Empty candidates -> null
146
- const result = findNearDuplicate([1, 0, 0], [], 0.85);
147
- assert(result === null, 'empty candidates returns null');
148
- }
149
-
150
- {
151
- // No embeddings on candidates -> null
152
- const candidates = [
153
- makeCandidate({ id: 'a', embedding: null }),
154
- makeCandidate({ id: 'b', embedding: null }),
155
- ];
156
- const result = findNearDuplicate([1, 0, 0], candidates, 0.85);
157
- assert(result === null, 'candidates without embeddings returns null');
158
- }
159
-
160
- {
161
- // Below threshold -> null
162
- const candidates = [
163
- makeCandidate({ id: 'a', embedding: [0, 1, 0] }), // orthogonal, cosine = 0
164
- ];
165
- const result = findNearDuplicate([1, 0, 0], candidates, 0.85);
166
- assert(result === null, 'below threshold returns null');
167
- }
168
-
169
- {
170
- // Above threshold -> returns match
171
- const candidates = [
172
- makeCandidate({ id: 'a', embedding: [1, 0, 0] }), // cosine = 1.0
173
- ];
174
- const result = findNearDuplicate([1, 0, 0], candidates, 0.85);
175
- assert(result !== null, 'above threshold returns match');
176
- assert(result!.existingFact.id === 'a', 'match is the correct candidate');
177
- assertClose(result!.similarity, 1.0, 1e-6, 'similarity is ~1.0');
178
- }
179
-
180
- {
181
- // Multiple matches -> returns highest similarity
182
- const candidates = [
183
- makeCandidate({ id: 'low', embedding: [0.86, Math.sqrt(1 - 0.86 * 0.86), 0] }), // cosine ~ 0.86
184
- makeCandidate({ id: 'high', embedding: [0.99, Math.sqrt(1 - 0.99 * 0.99), 0] }), // cosine ~ 0.99
185
- makeCandidate({ id: 'mid', embedding: [0.90, Math.sqrt(1 - 0.90 * 0.90), 0] }), // cosine ~ 0.90
186
- ];
187
- const result = findNearDuplicate([1, 0, 0], candidates, 0.85);
188
- assert(result !== null, 'multiple matches: returns a match');
189
- assert(result!.existingFact.id === 'high', 'multiple matches: returns highest similarity');
190
- }
191
-
192
- {
193
- // Parallel vectors (cosine = 1.0) -> match
194
- const candidates = [
195
- makeCandidate({ id: 'parallel', embedding: [3, 6, 9] }), // parallel to [1, 2, 3]
196
- ];
197
- const result = findNearDuplicate([1, 2, 3], candidates, 0.85);
198
- assert(result !== null, 'parallel vectors: returns match');
199
- assertClose(result!.similarity, 1.0, 1e-6, 'parallel vectors: cosine is ~1.0');
200
- }
201
-
202
- {
203
- // Orthogonal vectors (cosine = 0) -> null
204
- const candidates = [
205
- makeCandidate({ id: 'ortho', embedding: [0, 1, 0] }),
206
- ];
207
- const result = findNearDuplicate([1, 0, 0], candidates, 0.85);
208
- assert(result === null, 'orthogonal vectors: returns null');
209
- }
210
-
211
- // ---------------------------------------------------------------------------
212
- // shouldSupersede tests
213
- // ---------------------------------------------------------------------------
214
-
215
- console.log('# shouldSupersede');
216
-
217
- {
218
- // Higher new importance -> supersede
219
- const existing = makeCandidate({ id: 'old', importance: 5 });
220
- const result = shouldSupersede(8, existing);
221
- assert(result === 'supersede', 'higher new importance -> supersede');
222
- }
223
-
224
- {
225
- // Lower new importance -> skip
226
- const existing = makeCandidate({ id: 'old', importance: 8 });
227
- const result = shouldSupersede(3, existing);
228
- assert(result === 'skip', 'lower new importance -> skip');
229
- }
230
-
231
- {
232
- // Equal importance -> supersede (newer wins)
233
- const existing = makeCandidate({ id: 'old', importance: 5 });
234
- const result = shouldSupersede(5, existing);
235
- assert(result === 'supersede', 'equal importance -> supersede (newer wins)');
236
- }
237
-
238
- // ---------------------------------------------------------------------------
239
- // clusterFacts tests
240
- // ---------------------------------------------------------------------------
241
-
242
- console.log('# clusterFacts');
243
-
244
- {
245
- // Empty facts -> empty clusters
246
- const clusters = clusterFacts([], 0.88);
247
- assert(clusters.length === 0, 'empty facts -> no clusters');
248
- }
249
-
250
- {
251
- // Single fact -> no clusters (needs at least 2 to form a cluster)
252
- const facts = [
253
- makeCandidate({ id: 'a', embedding: [1, 0, 0] }),
254
- ];
255
- const clusters = clusterFacts(facts, 0.88);
256
- assert(clusters.length === 0, 'single fact -> no clusters');
257
- }
258
-
259
- {
260
- // Two identical embeddings -> one cluster
261
- const facts = [
262
- makeCandidate({ id: 'a', embedding: [1, 0, 0] }),
263
- makeCandidate({ id: 'b', embedding: [1, 0, 0] }),
264
- ];
265
- const clusters = clusterFacts(facts, 0.88);
266
- assert(clusters.length === 1, 'two identical -> one cluster');
267
- assert(clusters[0].duplicates.length === 1, 'two identical -> one duplicate');
268
- }
269
-
270
- {
271
- // Two dissimilar embeddings -> no clusters
272
- const facts = [
273
- makeCandidate({ id: 'a', embedding: [1, 0, 0] }),
274
- makeCandidate({ id: 'b', embedding: [0, 1, 0] }), // orthogonal
275
- ];
276
- const clusters = clusterFacts(facts, 0.88);
277
- assert(clusters.length === 0, 'two dissimilar -> no clusters');
278
- }
279
-
280
- {
281
- // Multiple clusters: two groups of duplicates + one unique
282
- const facts = [
283
- makeCandidate({ id: 'a1', embedding: [1, 0, 0] }),
284
- makeCandidate({ id: 'a2', embedding: [1, 0, 0] }), // dup of a1
285
- makeCandidate({ id: 'b1', embedding: [0, 1, 0] }),
286
- makeCandidate({ id: 'b2', embedding: [0, 1, 0] }), // dup of b1
287
- makeCandidate({ id: 'c1', embedding: [0, 0, 1] }), // unique
288
- ];
289
- const clusters = clusterFacts(facts, 0.88);
290
- assert(clusters.length === 2, 'multiple clusters: two groups found');
291
- }
292
-
293
- {
294
- // Facts without embeddings are not clustered
295
- const facts = [
296
- makeCandidate({ id: 'a', embedding: [1, 0, 0] }),
297
- makeCandidate({ id: 'b', embedding: null }), // no embedding
298
- makeCandidate({ id: 'c', embedding: [1, 0, 0] }), // dup of a
299
- ];
300
- const clusters = clusterFacts(facts, 0.88);
301
- assert(clusters.length === 1, 'no-embedding facts skipped, one cluster of a+c');
302
- // b should not appear in any cluster
303
- const allIds = clusters.flatMap(c => [c.representative.id, ...c.duplicates.map(d => d.id)]);
304
- assert(!allIds.includes('b'), 'no-embedding fact not in any cluster');
305
- }
306
-
307
- {
308
- // Representative = highest importance (via decayScore tiebreak)
309
- const facts = [
310
- makeCandidate({ id: 'low', embedding: [1, 0, 0], decayScore: 0.5, importance: 3 }),
311
- makeCandidate({ id: 'high', embedding: [1, 0, 0], decayScore: 0.9, importance: 8 }),
312
- makeCandidate({ id: 'mid', embedding: [1, 0, 0], decayScore: 0.7, importance: 5 }),
313
- ];
314
- const clusters = clusterFacts(facts, 0.88);
315
- assert(clusters.length === 1, 'representative test: one cluster');
316
- assert(clusters[0].representative.id === 'high', 'representative = highest decayScore');
317
- assert(clusters[0].duplicates.length === 2, 'two duplicates');
318
- }
319
-
320
- {
321
- // Tiebreak: same decayScore -> most recent (highest createdAt)
322
- const facts = [
323
- makeCandidate({ id: 'old', embedding: [1, 0, 0], decayScore: 1.0, createdAt: 1000 }),
324
- makeCandidate({ id: 'new', embedding: [1, 0, 0], decayScore: 1.0, createdAt: 2000 }),
325
- ];
326
- const clusters = clusterFacts(facts, 0.88);
327
- assert(clusters.length === 1, 'tiebreak test: one cluster');
328
- assert(clusters[0].representative.id === 'new', 'tiebreak: most recent is representative');
329
- }
330
-
331
- {
332
- // Tiebreak: same decayScore + createdAt -> longest text
333
- const facts = [
334
- makeCandidate({ id: 'short', text: 'abc', embedding: [1, 0, 0], decayScore: 1.0, createdAt: 1000 }),
335
- makeCandidate({ id: 'long', text: 'abcdefghij', embedding: [1, 0, 0], decayScore: 1.0, createdAt: 1000 }),
336
- ];
337
- const clusters = clusterFacts(facts, 0.88);
338
- assert(clusters.length === 1, 'tiebreak longest text: one cluster');
339
- assert(clusters[0].representative.id === 'long', 'tiebreak: longest text is representative');
340
- }
341
-
342
- // ---------------------------------------------------------------------------
343
- // Summary
344
- // ---------------------------------------------------------------------------
345
-
346
- console.log(`\n1..${testNum}`);
347
- console.log(`# pass: ${passed}`);
348
- console.log(`# fail: ${failed}`);
349
-
350
- if (failed > 0) {
351
- console.log('\nFAILED');
352
- process.exit(1);
353
- } else {
354
- console.log('\nALL TESTS PASSED');
355
- process.exit(0);
356
- }
@@ -1,168 +0,0 @@
1
- // skill/plugin/extractor-dedup.test.ts
2
- /**
3
- * TAP-style tests for LLM-guided dedup in the OpenClaw extractor.
4
- * Tests parseFactsResponse() handling of action/existingFactId fields.
5
- */
6
-
7
- // We need to test parseFactsResponse which is not exported.
8
- // Instead we test via extractFacts indirectly, or we test the type + parse logic.
9
- // Since parseFactsResponse is internal, we test the exported extractFacts behavior
10
- // by mocking chatCompletion.
11
-
12
- // Actually, the simplest approach: test the ExtractedFact type and ensure
13
- // the parsing handles all action types correctly. Since parseFactsResponse
14
- // is not exported, we replicate its logic for testing.
15
-
16
- import type { ExtractedFact, ExtractionAction } from './extractor';
17
-
18
- let passed = 0;
19
- let failed = 0;
20
- const total = 14;
21
-
22
- function assert(condition: boolean, name: string): void {
23
- if (condition) {
24
- console.log(`ok ${passed + failed + 1} - ${name}`);
25
- passed++;
26
- } else {
27
- console.log(`not ok ${passed + failed + 1} - ${name}`);
28
- failed++;
29
- }
30
- }
31
-
32
- // Replicate parseFactsResponse logic for testing
33
- function parseFactsResponse(response: string): ExtractedFact[] {
34
- let cleaned = response.trim();
35
- if (cleaned.startsWith('```')) {
36
- cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
37
- }
38
-
39
- try {
40
- const parsed = JSON.parse(cleaned);
41
- if (!Array.isArray(parsed)) return [];
42
-
43
- const validActions: ExtractionAction[] = ['ADD', 'UPDATE', 'DELETE', 'NOOP'];
44
-
45
- return parsed
46
- .filter(
47
- (f: unknown) =>
48
- f &&
49
- typeof f === 'object' &&
50
- typeof (f as ExtractedFact).text === 'string' &&
51
- (f as ExtractedFact).text.length >= 5,
52
- )
53
- .map((f: unknown) => {
54
- const fact = f as Record<string, unknown>;
55
- const action = validActions.includes(String(fact.action) as ExtractionAction)
56
- ? (String(fact.action) as ExtractionAction)
57
- : 'ADD';
58
- return {
59
- text: String(fact.text).slice(0, 512),
60
- type: (['fact', 'preference', 'decision', 'episodic', 'goal', 'context', 'summary'].includes(String(fact.type))
61
- ? String(fact.type)
62
- : 'fact') as ExtractedFact['type'],
63
- importance: Math.max(1, Math.min(10, Number(fact.importance) || 5)),
64
- action,
65
- existingFactId: typeof fact.existingFactId === 'string' ? fact.existingFactId : undefined,
66
- };
67
- })
68
- .filter((f) => f.importance >= 6 || f.action === 'DELETE');
69
- } catch {
70
- return [];
71
- }
72
- }
73
-
74
- console.log('TAP version 14');
75
- console.log(`1..${total}`);
76
-
77
- // -- Backward compatibility: no action field -> defaults to ADD ----------------
78
-
79
- {
80
- const result = parseFactsResponse(JSON.stringify([
81
- { text: 'User prefers TypeScript', type: 'preference', importance: 8 },
82
- ]));
83
- assert(result.length === 1, 'backward-compat: parses fact without action field');
84
- assert(result[0].action === 'ADD', 'backward-compat: defaults to ADD when action missing');
85
- assert(result[0].existingFactId === undefined, 'backward-compat: no existingFactId');
86
- }
87
-
88
- // -- ADD action ----------------------------------------------------------------
89
-
90
- {
91
- const result = parseFactsResponse(JSON.stringify([
92
- { text: 'User started using Rust', type: 'fact', importance: 7, action: 'ADD' },
93
- ]));
94
- assert(result[0].action === 'ADD', 'ADD: parsed correctly');
95
- }
96
-
97
- // -- UPDATE action with existingFactId -----------------------------------------
98
-
99
- {
100
- const result = parseFactsResponse(JSON.stringify([
101
- {
102
- text: 'User now prefers light mode',
103
- type: 'preference',
104
- importance: 8,
105
- action: 'UPDATE',
106
- existingFactId: 'fact-123-dark-mode',
107
- },
108
- ]));
109
- assert(result[0].action === 'UPDATE', 'UPDATE: parsed correctly');
110
- assert(result[0].existingFactId === 'fact-123-dark-mode', 'UPDATE: existingFactId preserved');
111
- }
112
-
113
- // -- DELETE action --------------------------------------------------------------
114
-
115
- {
116
- const result = parseFactsResponse(JSON.stringify([
117
- {
118
- text: 'User no longer uses Vim',
119
- type: 'preference',
120
- importance: 3,
121
- action: 'DELETE',
122
- existingFactId: 'fact-456-vim',
123
- },
124
- ]));
125
- assert(result.length === 1, 'DELETE: passes importance filter even with importance < 6');
126
- assert(result[0].action === 'DELETE', 'DELETE: parsed correctly');
127
- assert(result[0].existingFactId === 'fact-456-vim', 'DELETE: existingFactId preserved');
128
- }
129
-
130
- // -- NOOP action ---------------------------------------------------------------
131
-
132
- {
133
- const result = parseFactsResponse(JSON.stringify([
134
- { text: 'Already known fact about TypeScript', type: 'fact', importance: 7, action: 'NOOP' },
135
- ]));
136
- assert(result[0].action === 'NOOP', 'NOOP: parsed correctly');
137
- }
138
-
139
- // -- Invalid action defaults to ADD --------------------------------------------
140
-
141
- {
142
- const result = parseFactsResponse(JSON.stringify([
143
- { text: 'Some fact with bad action', type: 'fact', importance: 7, action: 'INVALID' },
144
- ]));
145
- assert(result[0].action === 'ADD', 'invalid-action: defaults to ADD');
146
- }
147
-
148
- // -- Mixed batch with all action types -----------------------------------------
149
-
150
- {
151
- const result = parseFactsResponse(JSON.stringify([
152
- { text: 'New fact about Rust', type: 'fact', importance: 8, action: 'ADD' },
153
- { text: 'Updated preference', type: 'preference', importance: 7, action: 'UPDATE', existingFactId: 'old-1' },
154
- { text: 'Deleted old info', type: 'fact', importance: 2, action: 'DELETE', existingFactId: 'old-2' },
155
- { text: 'Already known', type: 'fact', importance: 7, action: 'NOOP' },
156
- ]));
157
- assert(result.length === 4, 'mixed-batch: all 4 actions parsed (DELETE passes despite low importance)');
158
- assert(result[0].action === 'ADD', 'mixed-batch: first is ADD');
159
- assert(result[2].action === 'DELETE', 'mixed-batch: DELETE with importance 2 still passes');
160
- }
161
-
162
- // -- Summary -------------------------------------------------------------------
163
-
164
- console.log(`\n# ${passed}/${total} passed`);
165
- if (failed > 0) {
166
- console.log(`# ${failed} FAILED`);
167
- process.exit(1);
168
- }