@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/reranker.ts CHANGED
@@ -4,18 +4,46 @@
4
4
  * Replaces the naive `textScore` word-overlap scorer with a proper ranking
5
5
  * pipeline:
6
6
  * 1. Okapi BM25 — term frequency / inverse document frequency
7
- * 2. Cosine similarity — between query and fact embeddings
7
+ * 2. Cosine similarity — between query and fact embeddings (WASM-backed)
8
8
  * 3. Importance — normalized importance score (0-1)
9
9
  * 4. Recency — time-decay with 1-week half-life
10
10
  * 5. Weighted RRF (Reciprocal Rank Fusion) — combines all ranking lists
11
11
  * 6. MMR (Maximal Marginal Relevance) — promotes diversity in results
12
12
  *
13
- * All functions are pure TypeScript with zero external dependencies (except
14
- * porter-stemmer for morphological normalization). This module runs
15
- * CLIENT-SIDE after decrypting candidates from the server.
13
+ * Cosine similarity delegates to the Rust WASM core for performance.
14
+ * All other functions are pure TypeScript. This module runs CLIENT-SIDE
15
+ * after decrypting candidates from the server.
16
16
  */
17
17
 
18
- import { stemmer } from 'porter-stemmer';
18
+ // ---------------------------------------------------------------------------
19
+ // Cosine Similarity
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Compute cosine similarity between two vectors.
24
+ *
25
+ * Returns dot(a, b) / (||a|| * ||b||).
26
+ * Returns 0 if either vector has zero magnitude (avoids division by zero).
27
+ */
28
+ export function cosineSimilarity(a: number[], b: number[]): number {
29
+ if (a.length === 0 || b.length === 0) return 0;
30
+
31
+ const len = Math.min(a.length, b.length);
32
+ let dot = 0;
33
+ let normA = 0;
34
+ let normB = 0;
35
+
36
+ for (let i = 0; i < len; i++) {
37
+ dot += a[i] * b[i];
38
+ normA += a[i] * a[i];
39
+ normB += b[i] * b[i];
40
+ }
41
+
42
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
43
+ if (denom === 0) return 0;
44
+
45
+ return dot / denom;
46
+ }
19
47
 
20
48
  // ---------------------------------------------------------------------------
21
49
  // Tokenization
@@ -30,8 +58,8 @@ import { stemmer } from 'porter-stemmer';
30
58
  * 3. Split on whitespace
31
59
  * 4. Filter tokens shorter than 2 characters
32
60
  *
33
- * Optionally removes common English stop words (enabled by default) to
34
- * improve BM25 signal — stop words have low IDF and add noise.
61
+ * Removes common English stop words to improve BM25 signal — stop words
62
+ * have low IDF and add noise.
35
63
  */
36
64
  const STOP_WORDS = new Set([
37
65
  'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'do', 'for',
@@ -54,9 +82,7 @@ export function tokenize(text: string, removeStopWords: boolean = true): string[
54
82
  tokens = tokens.filter((t) => !STOP_WORDS.has(t));
55
83
  }
56
84
 
57
- // Stem each token for morphological normalization.
58
- // This ensures BM25 matches "gaming" with "games" (both stem to "game").
59
- return tokens.map((t) => stemmer(t));
85
+ return tokens;
60
86
  }
61
87
 
62
88
  // ---------------------------------------------------------------------------
@@ -66,17 +92,6 @@ export function tokenize(text: string, removeStopWords: boolean = true): string[
66
92
  /**
67
93
  * Compute the Okapi BM25 score for a single document against a query.
68
94
  *
69
- * Formula:
70
- * score = SUM_i IDF(qi) * (f(qi, D) * (k1 + 1)) / (f(qi, D) + k1 * (1 - b + b * |D| / avgdl))
71
- *
72
- * where:
73
- * IDF(qi) = ln((N - n(qi) + 0.5) / (n(qi) + 0.5) + 1)
74
- * f(qi, D) = frequency of term qi in document D
75
- * |D| = length of document D (in tokens)
76
- * avgdl = average document length across the corpus
77
- * N = total number of documents
78
- * n(qi) = number of documents containing term qi
79
- *
80
95
  * @param queryTerms - Tokenized query terms
81
96
  * @param docTerms - Tokenized document terms
82
97
  * @param avgDocLen - Average document length (in tokens) across the candidate corpus
@@ -112,7 +127,6 @@ export function bm25Score(
112
127
  const nqi = termDocFreqs.get(qi) ?? 0;
113
128
 
114
129
  // IDF with Robertson-Walker floor: ln((N - n + 0.5) / (n + 0.5) + 1)
115
- // The +1 inside ln ensures IDF is always >= 0 even when n > N/2.
116
130
  const idf = Math.log((docCount - nqi + 0.5) / (nqi + 0.5) + 1);
117
131
 
118
132
  // TF saturation with length normalization.
@@ -124,36 +138,6 @@ export function bm25Score(
124
138
  return score;
125
139
  }
126
140
 
127
- // ---------------------------------------------------------------------------
128
- // Cosine Similarity
129
- // ---------------------------------------------------------------------------
130
-
131
- /**
132
- * Compute cosine similarity between two vectors.
133
- *
134
- * Returns dot(a, b) / (||a|| * ||b||).
135
- * Returns 0 if either vector has zero magnitude (avoids division by zero).
136
- */
137
- export function cosineSimilarity(a: number[], b: number[]): number {
138
- if (a.length === 0 || b.length === 0) return 0;
139
-
140
- const len = Math.min(a.length, b.length);
141
- let dot = 0;
142
- let normA = 0;
143
- let normB = 0;
144
-
145
- for (let i = 0; i < len; i++) {
146
- dot += a[i] * b[i];
147
- normA += a[i] * a[i];
148
- normB += b[i] * b[i];
149
- }
150
-
151
- const denom = Math.sqrt(normA) * Math.sqrt(normB);
152
- if (denom === 0) return 0;
153
-
154
- return dot / denom;
155
- }
156
-
157
141
  // ---------------------------------------------------------------------------
158
142
  // Reciprocal Rank Fusion (RRF)
159
143
  // ---------------------------------------------------------------------------
@@ -165,18 +149,6 @@ export interface RankedItem {
165
149
 
166
150
  /**
167
151
  * Fuse multiple ranking lists using Reciprocal Rank Fusion.
168
- *
169
- * For each document d appearing in any ranking list:
170
- * rrfScore(d) = SUM_i 1 / (k + rank_i(d))
171
- *
172
- * where rank_i(d) is the 1-based rank of document d in the i-th list.
173
- * Documents not present in a list are not penalized (they simply receive
174
- * no contribution from that list).
175
- *
176
- * @param rankings - Array of ranking lists, each sorted by score descending.
177
- * Each item has an `id` and a `score`.
178
- * @param k - RRF smoothing constant (default 60, per the original paper).
179
- * @returns - Fused ranking sorted by RRF score descending.
180
152
  */
181
153
  export function rrfFuse(
182
154
  rankings: RankedItem[][],
@@ -187,7 +159,7 @@ export function rrfFuse(
187
159
  for (const ranking of rankings) {
188
160
  for (let rank = 0; rank < ranking.length; rank++) {
189
161
  const item = ranking[rank];
190
- const contribution = 1 / (k + rank + 1); // rank is 0-based, formula uses 1-based
162
+ const contribution = 1 / (k + rank + 1);
191
163
  fusedScores.set(item.id, (fusedScores.get(item.id) ?? 0) + contribution);
192
164
  }
193
165
  }
@@ -207,14 +179,6 @@ export function rrfFuse(
207
179
 
208
180
  /**
209
181
  * Fuse multiple ranking lists using Weighted Reciprocal Rank Fusion.
210
- *
211
- * Like standard RRF, but each ranking list's contribution is multiplied by
212
- * its weight, allowing callers to emphasize or de-emphasize specific signals.
213
- *
214
- * @param rankings - Array of ranking lists, each sorted by score descending.
215
- * @param weights - Weight for each ranking list (same length as rankings).
216
- * @param k - RRF smoothing constant (default 60).
217
- * @returns - Fused ranking sorted by weighted RRF score descending.
218
182
  */
219
183
  export function weightedRrfFuse(
220
184
  rankings: RankedItem[][],
@@ -279,7 +243,7 @@ export const INTENT_WEIGHTS: Record<QueryIntent, RankingWeights> = {
279
243
 
280
244
  /**
281
245
  * Classify a query into one of three intent types using lightweight heuristics.
282
- * Temporal is checked first so "What did we discuss yesterday?" temporal.
246
+ * Temporal is checked first so "What did we discuss yesterday?" -> temporal.
283
247
  */
284
248
  export function detectQueryIntent(query: string): QueryIntent {
285
249
  if (TEMPORAL_KEYWORDS.test(query)) return 'temporal';
@@ -293,11 +257,49 @@ export interface RerankerCandidate {
293
257
  embedding?: number[];
294
258
  importance?: number; // 0-1 normalized importance score
295
259
  createdAt?: number; // Unix timestamp (seconds) when fact was created
260
+ /**
261
+ * Memory Taxonomy v1 provenance tag. Plugin v3.0.0+ surfaces this when a
262
+ * candidate was decrypted from a v1 blob. When present and
263
+ * `applySourceWeights: true` is passed to rerank(), the final RRF score
264
+ * is multiplied by the Retrieval v2 Tier 1 source weight from core.
265
+ */
266
+ source?: string;
296
267
  }
297
268
 
298
269
  export interface RerankerResult extends RerankerCandidate {
299
270
  rrfScore: number;
300
271
  cosineSimilarity?: number;
272
+ /** Source weight multiplier applied (1.0 = no weighting). */
273
+ sourceWeight?: number;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Source-weight lookup (Retrieval v2 Tier 1)
278
+ //
279
+ // Mirrors the table in `rust/totalreclaw-core/src/reranker.rs` exactly so
280
+ // the TypeScript reranker produces the same ordering as core rerankWithConfig
281
+ // when `applySourceWeights: true` is passed.
282
+ //
283
+ // NOTE: this is duplicated here (vs calling core via WASM) because the
284
+ // plugin's local reranker handles RRF + MMR on the client side with rich
285
+ // candidate metadata. The core `rerankWithConfig` is the canonical source
286
+ // of truth and will be used directly by MCP/Python adapters.
287
+ // ---------------------------------------------------------------------------
288
+
289
+ const SOURCE_WEIGHTS: Record<string, number> = {
290
+ 'user': 1.0,
291
+ 'user-inferred': 0.9,
292
+ 'derived': 0.7,
293
+ 'external': 0.7,
294
+ 'assistant': 0.55,
295
+ };
296
+
297
+ const LEGACY_FALLBACK_WEIGHT = 0.85;
298
+
299
+ export function getSourceWeight(source: string | undefined): number {
300
+ if (!source) return LEGACY_FALLBACK_WEIGHT;
301
+ const w = SOURCE_WEIGHTS[source.toLowerCase()];
302
+ return w ?? 0.85; // unknown source → moderate penalty
301
303
  }
302
304
 
303
305
  // ---------------------------------------------------------------------------
@@ -306,14 +308,6 @@ export interface RerankerResult extends RerankerCandidate {
306
308
 
307
309
  /**
308
310
  * Compute a recency score with a 1-week half-life.
309
- *
310
- * Score = 1 / (1 + hours_since_creation / 168)
311
- *
312
- * A fact created just now scores ~1.0, one week ago scores 0.5,
313
- * two weeks ago scores ~0.33, etc.
314
- *
315
- * @param createdAt - Unix timestamp in seconds
316
- * @returns - Recency score in (0, 1]
317
311
  */
318
312
  function recencyScore(createdAt: number): number {
319
313
  const nowSeconds = Date.now() / 1000;
@@ -327,21 +321,6 @@ function recencyScore(createdAt: number): number {
327
321
 
328
322
  /**
329
323
  * Apply Maximal Marginal Relevance to promote diversity in results.
330
- *
331
- * MMR re-orders a ranked list of candidates so that highly similar candidates
332
- * are spread out. The algorithm greedily selects the candidate that maximizes:
333
- *
334
- * MMR(d) = lambda * relevance(d) - (1 - lambda) * max_sim(d, selected)
335
- *
336
- * where:
337
- * - relevance(d) = position-based score (1.0 for first, linearly decreasing)
338
- * - max_sim(d, selected) = max cosine similarity between d and any already
339
- * selected candidate (0 if no embeddings available)
340
- *
341
- * @param candidates - Candidates in relevance order (best first)
342
- * @param lambda - Trade-off between relevance and diversity (default 0.7)
343
- * @param topK - Number of results to return (default 8)
344
- * @returns - Re-ordered candidates with diversity
345
324
  */
346
325
  export function applyMMR(
347
326
  candidates: RerankerCandidate[],
@@ -402,31 +381,12 @@ export function applyMMR(
402
381
  * Re-rank decrypted candidates using BM25 + Cosine + Importance + Recency
403
382
  * with Weighted RRF fusion and MMR diversity.
404
383
  *
405
- * Pipeline:
406
- * 1. Tokenize query and all candidate texts
407
- * 2. Build corpus statistics (term document frequencies, average doc length)
408
- * 3. Score each candidate with BM25
409
- * 4. Score each candidate with cosine similarity (if embedding available)
410
- * 5. Score each candidate by importance
411
- * 6. Score each candidate by recency
412
- * 7. Fuse all 4 rankings with weighted RRF
413
- * 8. Apply MMR for diversity
414
- * 9. Return top-k candidates sorted by fused score
415
- *
416
- * Backward compatibility:
417
- * - Candidates without embeddings get cosine score = 0 and are excluded
418
- * from the cosine ranking list. They can still rank well via other signals.
419
- * - If NO candidates have embeddings, cosine ranking is omitted.
420
- * - Candidates without importance get neutral score (0.5).
421
- * - Candidates without createdAt get neutral recency score (0.5).
422
- * - topK defaults to 8, weights default to equal (0.25 each).
423
- *
424
- * @param query - The user's search query (plaintext)
425
- * @param queryEmbedding - Embedding vector for the query
426
- * @param candidates - Decrypted candidates with text and optional embeddings
427
- * @param topK - Number of results to return (default 8)
428
- * @param weights - Optional partial ranking weights (merged with defaults)
429
- * @returns - Top-k candidates sorted by fused score, with scores attached
384
+ * When `applySourceWeights` is true, the final RRF score for each candidate
385
+ * is multiplied by a Retrieval v2 Tier 1 source weight based on the
386
+ * candidate's `source` field (user=1.0, user-inferred=0.9, derived/external=0.7,
387
+ * assistant=0.55). Candidates without a `source` field use the legacy
388
+ * fallback weight (0.85). This is the flag equivalent of core
389
+ * `rerankWithConfig(.., apply_source_weights=true)`.
430
390
  */
431
391
  export function rerank(
432
392
  query: string,
@@ -434,6 +394,7 @@ export function rerank(
434
394
  candidates: RerankerCandidate[],
435
395
  topK: number = 8,
436
396
  weights?: Partial<RankingWeights>,
397
+ applySourceWeights: boolean = false,
437
398
  ): RerankerResult[] {
438
399
  if (candidates.length === 0) return [];
439
400
 
@@ -448,7 +409,6 @@ export function rerank(
448
409
  const docCount = candidates.length;
449
410
  let totalDocLen = 0;
450
411
 
451
- // Count how many documents contain each term.
452
412
  const termDocFreqs = new Map<string, number>();
453
413
  for (const terms of candidateTerms) {
454
414
  totalDocLen += terms.length;
@@ -521,14 +481,26 @@ export function rerank(
521
481
  for (const item of fused) {
522
482
  const candidate = candidateMap.get(item.id);
523
483
  if (candidate) {
484
+ const sourceWeight = applySourceWeights
485
+ ? getSourceWeight(candidate.source)
486
+ : 1.0;
524
487
  rrfResults.push({
525
488
  ...candidate,
526
- rrfScore: item.score,
489
+ rrfScore: item.score * sourceWeight,
527
490
  cosineSimilarity: cosineScores.get(item.id),
491
+ sourceWeight: applySourceWeights ? sourceWeight : undefined,
528
492
  });
529
493
  }
530
494
  }
531
495
 
496
+ // When source weights are applied the RRF-scaled scores may no longer be in
497
+ // descending order (weighted=0.55 assistant could slip below a weighted=1.0
498
+ // user fact that was originally ranked lower). Re-sort so the top-K picked
499
+ // by MMR is meaningful.
500
+ if (applySourceWeights) {
501
+ rrfResults.sort((a, b) => b.rrfScore - a.rrfScore);
502
+ }
503
+
532
504
  // --- Step 9: Apply MMR for diversity, then return top-k ---
533
505
  const mmrResults = applyMMR(rrfResults, 0.7, topK);
534
506
 
package/skill.json ADDED
@@ -0,0 +1,213 @@
1
+ {
2
+ "name": "totalreclaw",
3
+ "version": "1.6.1",
4
+ "description": "End-to-end encrypted memory for AI agents — portable, yours forever. XChaCha20-Poly1305 E2EE: server never sees plaintext.",
5
+ "author": "TotalReclaw Team",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/p-diogo/totalreclaw",
8
+ "repository": "https://github.com/p-diogo/totalreclaw",
9
+ "keywords": [
10
+ "memory",
11
+ "e2ee",
12
+ "e2e-encryption",
13
+ "encryption",
14
+ "privacy",
15
+ "agent-memory",
16
+ "persistent-context"
17
+ ],
18
+ "openclaw": {
19
+ "minVersion": "0.1.0",
20
+ "maxVersion": "1.0.0",
21
+ "requires": {
22
+ "env": [],
23
+ "bins": []
24
+ },
25
+ "emoji": "🧠",
26
+ "os": ["macos", "linux", "windows"],
27
+ "hooks": {
28
+ "before_agent_start": {
29
+ "priority": 10,
30
+ "description": "Retrieve relevant memories before agent processes message"
31
+ },
32
+ "agent_end": {
33
+ "priority": 90,
34
+ "description": "Extract and store facts after agent completes turn"
35
+ },
36
+ "pre_compaction": {
37
+ "priority": 5,
38
+ "description": "Full memory flush before context compaction"
39
+ },
40
+ "before_reset": {
41
+ "priority": 5,
42
+ "description": "Full memory flush before conversation reset"
43
+ }
44
+ },
45
+ "tools": [
46
+ {
47
+ "name": "totalreclaw_remember",
48
+ "description": "Store a new fact or preference in long-term memory",
49
+ "parameters": {
50
+ "text": {
51
+ "type": "string",
52
+ "required": true,
53
+ "description": "The fact or information to remember"
54
+ },
55
+ "type": {
56
+ "type": "string",
57
+ "required": false,
58
+ "enum": ["fact", "preference", "decision", "episodic", "goal"],
59
+ "default": "fact",
60
+ "description": "Type of memory"
61
+ },
62
+ "importance": {
63
+ "type": "integer",
64
+ "required": false,
65
+ "min": 1,
66
+ "max": 10,
67
+ "description": "Importance score 1-10. Default: auto-detected by LLM"
68
+ }
69
+ }
70
+ },
71
+ {
72
+ "name": "totalreclaw_recall",
73
+ "description": "Search and retrieve relevant memories from long-term storage",
74
+ "parameters": {
75
+ "query": {
76
+ "type": "string",
77
+ "required": true,
78
+ "description": "Natural language query to search memories"
79
+ },
80
+ "k": {
81
+ "type": "integer",
82
+ "required": false,
83
+ "default": 8,
84
+ "max": 20,
85
+ "description": "Number of results to return"
86
+ }
87
+ }
88
+ },
89
+ {
90
+ "name": "totalreclaw_forget",
91
+ "description": "Delete a specific fact from memory",
92
+ "parameters": {
93
+ "factId": {
94
+ "type": "string",
95
+ "required": true,
96
+ "description": "UUID of the fact to delete"
97
+ }
98
+ }
99
+ },
100
+ {
101
+ "name": "totalreclaw_export",
102
+ "description": "Export all stored memories in plaintext format",
103
+ "parameters": {
104
+ "format": {
105
+ "type": "string",
106
+ "required": false,
107
+ "enum": ["json", "markdown"],
108
+ "default": "json",
109
+ "description": "Export format"
110
+ }
111
+ }
112
+ },
113
+ {
114
+ "name": "totalreclaw_status",
115
+ "description": "Check billing and subscription status, including quota usage and upgrade options",
116
+ "parameters": {}
117
+ },
118
+ {
119
+ "name": "totalreclaw_upgrade",
120
+ "description": "Get a checkout URL to upgrade to TotalReclaw Pro (unlimited memories on Gnosis mainnet)",
121
+ "parameters": {}
122
+ },
123
+ {
124
+ "name": "totalreclaw_import_from",
125
+ "description": "Import memories from other AI memory tools (Mem0, MCP Memory Server) into TotalReclaw",
126
+ "parameters": {
127
+ "source": {
128
+ "type": "string",
129
+ "required": true,
130
+ "enum": ["mem0", "mcp-memory"],
131
+ "description": "Source system to import from"
132
+ },
133
+ "api_key": {
134
+ "type": "string",
135
+ "required": false,
136
+ "description": "API key for the source (Mem0). Used once, never stored."
137
+ },
138
+ "source_user_id": {
139
+ "type": "string",
140
+ "required": false,
141
+ "description": "User or agent ID in the source system"
142
+ },
143
+ "content": {
144
+ "type": "string",
145
+ "required": false,
146
+ "description": "File content (JSON, JSONL, or CSV) for file-based sources"
147
+ },
148
+ "file_path": {
149
+ "type": "string",
150
+ "required": false,
151
+ "description": "Path to a file on disk for file-based sources"
152
+ },
153
+ "namespace": {
154
+ "type": "string",
155
+ "required": false,
156
+ "default": "imported",
157
+ "description": "Target namespace in TotalReclaw"
158
+ },
159
+ "dry_run": {
160
+ "type": "boolean",
161
+ "required": false,
162
+ "default": false,
163
+ "description": "Preview without importing"
164
+ }
165
+ }
166
+ },
167
+ {
168
+ "name": "totalreclaw_consolidate",
169
+ "description": "Scan all stored memories and merge near-duplicates, keeping the most important/recent version",
170
+ "parameters": {
171
+ "dry_run": {
172
+ "type": "boolean",
173
+ "required": false,
174
+ "default": false,
175
+ "description": "Preview consolidation without deleting"
176
+ }
177
+ }
178
+ }
179
+ ],
180
+ "config": {
181
+ "serverUrl": {
182
+ "type": "string",
183
+ "default": "https://api.totalreclaw.xyz",
184
+ "description": "TotalReclaw server URL (only change for self-hosted mode)"
185
+ },
186
+ "autoExtractEveryTurns": {
187
+ "type": "number",
188
+ "default": 3,
189
+ "description": "Number of turns between automatic extractions"
190
+ },
191
+ "minImportanceForAutoStore": {
192
+ "type": "number",
193
+ "default": 6,
194
+ "description": "Minimum importance (1-10) to auto-store memories"
195
+ },
196
+ "maxMemoriesInContext": {
197
+ "type": "number",
198
+ "default": 8,
199
+ "description": "Maximum memories to inject into context"
200
+ },
201
+ "forgetThreshold": {
202
+ "type": "number",
203
+ "default": 0.3,
204
+ "description": "Decay score threshold for eviction"
205
+ },
206
+ "rerankerModel": {
207
+ "type": "string",
208
+ "default": "BAAI/bge-reranker-base",
209
+ "description": "ONNX reranker model for result reranking"
210
+ }
211
+ }
212
+ }
213
+ }