@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.
- package/CLAWHUB.md +134 -0
- package/README.md +407 -64
- package/SKILL.md +1032 -0
- package/api-client.ts +5 -5
- package/claims-helper.ts +686 -0
- package/config.ts +211 -0
- package/consolidation.ts +141 -33
- package/contradiction-sync.ts +1389 -0
- package/crypto.ts +63 -261
- package/digest-sync.ts +516 -0
- package/embedding.ts +69 -46
- package/extractor.ts +1307 -84
- package/hot-cache-wrapper.ts +1 -1
- package/import-adapters/base-adapter.ts +4 -5
- package/import-adapters/chatgpt-adapter.ts +323 -0
- package/import-adapters/claude-adapter.ts +146 -0
- package/import-adapters/gemini-adapter.ts +243 -0
- package/import-adapters/index.ts +9 -0
- package/import-adapters/mcp-memory-adapter.ts +4 -2
- package/import-adapters/mem0-adapter.ts +2 -2
- package/import-adapters/types.ts +25 -2
- package/index.ts +2002 -319
- package/llm-client.ts +106 -53
- package/lsh.ts +21 -210
- package/package.json +20 -7
- package/pin.ts +502 -0
- package/reranker.ts +96 -124
- package/skill.json +213 -0
- package/subgraph-search.ts +112 -5
- package/subgraph-store.ts +559 -275
- package/consolidation.test.ts +0 -356
- package/extractor-dedup.test.ts +0 -168
- package/import-adapters/import-adapters.test.ts +0 -595
- package/lsh.test.ts +0 -463
- package/pocv2-e2e-test.ts +0 -917
- package/porter-stemmer.d.ts +0 -4
- package/reranker.test.ts +0 -594
- package/semantic-dedup.test.ts +0 -392
- package/setup.sh +0 -19
- package/store-dedup-wiring.test.ts +0 -186
package/consolidation.test.ts
DELETED
|
@@ -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
|
-
}
|
package/extractor-dedup.test.ts
DELETED
|
@@ -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
|
-
}
|