@totalreclaw/totalreclaw 1.0.0

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/embedding.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * TotalReclaw Plugin - Local Embedding via @huggingface/transformers
3
+ *
4
+ * Uses the Xenova/bge-small-en-v1.5 ONNX model to generate 384-dimensional
5
+ * text embeddings locally. No API key needed, no data leaves the machine.
6
+ *
7
+ * This preserves the zero-knowledge guarantee: embeddings are generated
8
+ * CLIENT-SIDE before encryption, so no plaintext ever reaches an external API.
9
+ *
10
+ * Model details:
11
+ * - Quantized (int8) ONNX model: ~33.8MB download on first use
12
+ * - Cached in ~/.cache/huggingface/ after first download
13
+ * - Lazy initialization: first call ~2-3s (model load), subsequent ~15ms
14
+ * - Output: 384-dimensional normalized embedding vector
15
+ * - For retrieval, queries should be prefixed with an instruction string
16
+ * (documents/passages should NOT be prefixed)
17
+ *
18
+ * Dependencies: @huggingface/transformers (handles model download, WordPiece
19
+ * tokenization, ONNX inference, mean pooling, and normalization).
20
+ */
21
+
22
+ // @ts-ignore - @huggingface/transformers types may not be perfect
23
+ import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
24
+
25
+ /** ONNX-optimized bge-small-en-v1.5 from HuggingFace Hub. */
26
+ const MODEL_ID = 'Xenova/bge-small-en-v1.5';
27
+
28
+ /** Fixed output dimensionality for bge-small-en-v1.5. */
29
+ const EMBEDDING_DIM = 384;
30
+
31
+ /**
32
+ * Query instruction prefix for bge-small-en-v1.5 retrieval tasks.
33
+ *
34
+ * Per the BAAI model card: prepend this to short queries when searching
35
+ * for relevant passages. Do NOT prepend for documents/passages being stored.
36
+ */
37
+ const QUERY_PREFIX = 'Represent this sentence for searching relevant passages: ';
38
+
39
+ /** Lazily initialized feature extraction pipeline. */
40
+ let extractor: FeatureExtractionPipeline | null = null;
41
+
42
+ /**
43
+ * Generate a 384-dimensional embedding vector for the given text.
44
+ *
45
+ * On first call, downloads and loads the ONNX model (~33.8MB, cached).
46
+ * Subsequent calls reuse the loaded model and run in ~15ms.
47
+ *
48
+ * For bge-small-en-v1.5, queries should set `isQuery: true` to prepend the
49
+ * retrieval instruction prefix. Documents being stored should use the default
50
+ * (`isQuery: false`) so no prefix is added.
51
+ *
52
+ * @param text - The text to embed.
53
+ * @param options - Optional settings.
54
+ * @param options.isQuery - If true, prepend the BGE query instruction prefix
55
+ * for improved retrieval accuracy (default: false).
56
+ * @returns 384-dimensional normalized embedding as a number array.
57
+ */
58
+ export async function generateEmbedding(
59
+ text: string,
60
+ options?: { isQuery?: boolean },
61
+ ): Promise<number[]> {
62
+ if (!extractor) {
63
+ extractor = await pipeline('feature-extraction', MODEL_ID, {
64
+ // Use quantized (int8) model for smaller download (~33.8MB vs ~67MB)
65
+ quantized: true,
66
+ });
67
+ }
68
+
69
+ const input = options?.isQuery ? QUERY_PREFIX + text : text;
70
+ const output = await extractor(input, { pooling: 'mean', normalize: true });
71
+ // output.data is a Float32Array; convert to plain number[]
72
+ return Array.from(output.data as Float32Array);
73
+ }
74
+
75
+ /**
76
+ * Get the embedding vector dimensionality.
77
+ *
78
+ * Always returns 384 (fixed for bge-small-en-v1.5).
79
+ * This is needed by downstream code (e.g. LSH hasher) to know the vector
80
+ * size without calling the embedding model.
81
+ */
82
+ export function getEmbeddingDims(): number {
83
+ return EMBEDDING_DIM;
84
+ }
package/extractor.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * TotalReclaw Plugin - Fact Extractor
3
+ *
4
+ * Uses LLM calls to extract atomic facts from conversation messages.
5
+ * Matches the extraction prompts described in SKILL.md.
6
+ */
7
+
8
+ import { chatCompletion, resolveLLMConfig } from './llm-client.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface ExtractedFact {
15
+ text: string;
16
+ type: 'fact' | 'preference' | 'decision' | 'episodic' | 'goal';
17
+ importance: number; // 1-10
18
+ }
19
+
20
+ interface ContentBlock {
21
+ type?: string;
22
+ text?: string;
23
+ thinking?: string;
24
+ }
25
+
26
+ interface ConversationMessage {
27
+ role?: string;
28
+ content?: string | ContentBlock[];
29
+ text?: string;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Extraction Prompt
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction engine. Analyze the conversation and extract atomic facts worth remembering long-term.
37
+
38
+ Rules:
39
+ 1. Each fact must be a single, atomic piece of information
40
+ 2. Focus on user-specific information: preferences, decisions, facts about them, their goals
41
+ 3. Skip generic knowledge, greetings, and small talk
42
+ 4. Skip information that is only relevant to the current conversation
43
+ 5. Score importance 1-10 (7+ = worth storing, below 7 = skip)
44
+ 6. Only extract facts with importance >= 6
45
+
46
+ Types:
47
+ - fact: Objective information about the user
48
+ - preference: Likes, dislikes, or preferences
49
+ - decision: Choices the user has made
50
+ - episodic: Events or experiences
51
+ - goal: Objectives or targets
52
+
53
+ Return a JSON array (no markdown, no code fences):
54
+ [{"text": "...", "type": "...", "importance": N}, ...]
55
+
56
+ If nothing is worth extracting, return: []`;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Extract text content from a conversation message (handles various formats).
64
+ *
65
+ * OpenClaw AgentMessage objects use content arrays:
66
+ * { role: "user", content: [{ type: "text", text: "..." }] }
67
+ * { role: "assistant", content: [{ type: "text", text: "..." }, { type: "toolCall", ... }] }
68
+ *
69
+ * We also handle the simpler { role, content: "string" } format.
70
+ */
71
+ function messageToText(msg: unknown): { role: string; content: string } | null {
72
+ if (!msg || typeof msg !== 'object') return null;
73
+
74
+ const m = msg as ConversationMessage;
75
+ const role = m.role ?? 'unknown';
76
+
77
+ // Only keep user and assistant messages
78
+ if (role !== 'user' && role !== 'assistant') return null;
79
+
80
+ let textContent: string;
81
+
82
+ if (typeof m.content === 'string') {
83
+ // Simple string content
84
+ textContent = m.content;
85
+ } else if (Array.isArray(m.content)) {
86
+ // OpenClaw AgentMessage format: array of content blocks
87
+ // Extract text from { type: "text", text: "..." } blocks
88
+ const textParts = (m.content as ContentBlock[])
89
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
90
+ .map((block) => block.text as string);
91
+ textContent = textParts.join('\n');
92
+ } else if (typeof m.text === 'string') {
93
+ // Fallback: { text: "..." } field
94
+ textContent = m.text;
95
+ } else {
96
+ return null;
97
+ }
98
+
99
+ if (textContent.length < 3) return null;
100
+
101
+ return { role, content: textContent };
102
+ }
103
+
104
+ /**
105
+ * Truncate messages to fit within a token budget (rough estimate: 4 chars per token).
106
+ */
107
+ function truncateMessages(messages: Array<{ role: string; content: string }>, maxChars: number): string {
108
+ const lines: string[] = [];
109
+ let totalChars = 0;
110
+
111
+ for (const msg of messages) {
112
+ const line = `[${msg.role}]: ${msg.content}`;
113
+ if (totalChars + line.length > maxChars) break;
114
+ lines.push(line);
115
+ totalChars += line.length;
116
+ }
117
+
118
+ return lines.join('\n\n');
119
+ }
120
+
121
+ /**
122
+ * Parse the LLM response into structured facts.
123
+ */
124
+ function parseFactsResponse(response: string): ExtractedFact[] {
125
+ // Strip markdown code fences if present
126
+ let cleaned = response.trim();
127
+ if (cleaned.startsWith('```')) {
128
+ cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
129
+ }
130
+
131
+ try {
132
+ const parsed = JSON.parse(cleaned);
133
+ if (!Array.isArray(parsed)) return [];
134
+
135
+ return parsed
136
+ .filter(
137
+ (f: unknown) =>
138
+ f &&
139
+ typeof f === 'object' &&
140
+ typeof (f as ExtractedFact).text === 'string' &&
141
+ (f as ExtractedFact).text.length >= 5,
142
+ )
143
+ .map((f: unknown) => {
144
+ const fact = f as Record<string, unknown>;
145
+ return {
146
+ text: String(fact.text).slice(0, 512),
147
+ type: (['fact', 'preference', 'decision', 'episodic', 'goal'].includes(String(fact.type))
148
+ ? String(fact.type)
149
+ : 'fact') as ExtractedFact['type'],
150
+ importance: Math.max(1, Math.min(10, Number(fact.importance) || 5)),
151
+ };
152
+ })
153
+ .filter((f) => f.importance >= 6); // Only keep important facts
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Main extraction function
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Extract facts from a list of conversation messages using LLM.
165
+ *
166
+ * @param rawMessages - The messages array from the hook event (unknown[])
167
+ * @param mode - 'turn' for agent_end (recent only), 'full' for compaction/reset
168
+ * @returns Array of extracted facts, or empty array on failure.
169
+ */
170
+ export async function extractFacts(
171
+ rawMessages: unknown[],
172
+ mode: 'turn' | 'full',
173
+ ): Promise<ExtractedFact[]> {
174
+ const config = resolveLLMConfig();
175
+ if (!config) return []; // No LLM available
176
+
177
+ // Parse messages
178
+ const parsed = rawMessages
179
+ .map(messageToText)
180
+ .filter((m): m is { role: string; content: string } => m !== null);
181
+
182
+ if (parsed.length === 0) return [];
183
+
184
+ // For 'turn' mode, only look at last 6 messages (3 turns)
185
+ // For 'full' mode, use all messages but truncate to fit token budget
186
+ const relevantMessages = mode === 'turn' ? parsed.slice(-6) : parsed;
187
+
188
+ // Truncate to ~3000 tokens worth of text
189
+ const conversationText = truncateMessages(relevantMessages, 12_000);
190
+
191
+ if (conversationText.length < 20) return [];
192
+
193
+ const userPrompt =
194
+ mode === 'turn'
195
+ ? `Extract important facts from these recent conversation turns:\n\n${conversationText}`
196
+ : `Extract ALL valuable long-term memories from this conversation before it is lost:\n\n${conversationText}`;
197
+
198
+ try {
199
+ const response = await chatCompletion(config, [
200
+ { role: 'system', content: EXTRACTION_SYSTEM_PROMPT },
201
+ { role: 'user', content: userPrompt },
202
+ ]);
203
+
204
+ if (!response) return [];
205
+
206
+ return parseFactsResponse(response);
207
+ } catch {
208
+ return []; // Fail silently -- hooks must never break the agent
209
+ }
210
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Generate a BIP-39 12-word mnemonic for use as TOTALRECLAW_MASTER_PASSWORD.
4
+ *
5
+ * Usage: npx tsx generate-mnemonic.ts
6
+ */
7
+ import { generateMnemonic } from '@scure/bip39';
8
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
9
+
10
+ const mnemonic = generateMnemonic(wordlist, 128);
11
+ console.log('\n Your TotalReclaw master mnemonic (12 words):\n');
12
+ console.log(` ${mnemonic}\n`);
13
+ console.log(' WRITE THIS DOWN. If you lose it, your memories are unrecoverable.');
14
+ console.log(' Set it as TOTALRECLAW_MASTER_PASSWORD in your .env file.\n');
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Hot cache wrapper for the plugin.
3
+ *
4
+ * Self-contained AES-256-GCM encrypted cache (same implementation as
5
+ * client/src/cache/hot-cache.ts but without cross-package import).
6
+ */
7
+
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export interface HotFact {
13
+ id: string;
14
+ text: string;
15
+ importance: number;
16
+ }
17
+
18
+ interface CachePayload {
19
+ hotFacts: HotFact[];
20
+ factCount: number;
21
+ lastSyncedBlock: number;
22
+ smartAccountAddress: string;
23
+ lastUpdatedAt?: number; // Unix timestamp (ms) of last cache update
24
+ lastQueryEmbedding?: number[]; // Embedding of last search query
25
+ }
26
+
27
+ const MAX_HOT_FACTS = 30;
28
+ const IV_LENGTH = 12;
29
+ const TAG_LENGTH = 16;
30
+
31
+ export class PluginHotCache {
32
+ private hotFacts: HotFact[] = [];
33
+ private factCount = 0;
34
+ private lastSyncedBlock = 0;
35
+ private smartAccountAddress = '';
36
+ private lastUpdatedAt = 0;
37
+ private lastQueryEmbedding: number[] | null = null;
38
+ private key: Buffer;
39
+
40
+ constructor(private cachePath: string, hexKey: string) {
41
+ this.key = Buffer.from(hexKey, 'hex');
42
+ }
43
+
44
+ getHotFacts(): HotFact[] { return [...this.hotFacts]; }
45
+ getFactCount(): number { return this.factCount; }
46
+ getLastSyncedBlock(): number { return this.lastSyncedBlock; }
47
+ getSmartAccountAddress(): string { return this.smartAccountAddress; }
48
+ getLastUpdatedAt(): number { return this.lastUpdatedAt; }
49
+ getLastQueryEmbedding(): number[] | null { return this.lastQueryEmbedding ? [...this.lastQueryEmbedding] : null; }
50
+
51
+ setHotFacts(facts: HotFact[]): void {
52
+ const sorted = [...facts].sort((a, b) => b.importance - a.importance);
53
+ this.hotFacts = sorted.slice(0, MAX_HOT_FACTS);
54
+ this.lastUpdatedAt = Date.now();
55
+ }
56
+
57
+ setFactCount(count: number): void { this.factCount = count; }
58
+ setLastSyncedBlock(block: number): void { this.lastSyncedBlock = block; }
59
+ setSmartAccountAddress(addr: string): void { this.smartAccountAddress = addr; }
60
+ setLastUpdatedAt(ts: number): void { this.lastUpdatedAt = ts; }
61
+ setLastQueryEmbedding(embedding: number[] | null): void { this.lastQueryEmbedding = embedding ? [...embedding] : null; }
62
+
63
+ /**
64
+ * Check if the cache is fresh (within TTL).
65
+ * @param ttlMs TTL in milliseconds (default: 5 minutes)
66
+ */
67
+ isFresh(ttlMs: number = 300_000): boolean {
68
+ if (this.lastUpdatedAt === 0) return false;
69
+ return (Date.now() - this.lastUpdatedAt) < ttlMs;
70
+ }
71
+
72
+ flush(): void {
73
+ const payload: CachePayload = {
74
+ hotFacts: this.hotFacts,
75
+ factCount: this.factCount,
76
+ lastSyncedBlock: this.lastSyncedBlock,
77
+ smartAccountAddress: this.smartAccountAddress,
78
+ lastUpdatedAt: this.lastUpdatedAt,
79
+ lastQueryEmbedding: this.lastQueryEmbedding,
80
+ };
81
+
82
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf-8');
83
+ const iv = crypto.randomBytes(IV_LENGTH);
84
+ const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv);
85
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
86
+ const tag = cipher.getAuthTag();
87
+
88
+ const output = Buffer.concat([iv, tag, encrypted]);
89
+
90
+ const dir = path.dirname(this.cachePath);
91
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
92
+ fs.writeFileSync(this.cachePath, output);
93
+ }
94
+
95
+ load(): void {
96
+ if (!fs.existsSync(this.cachePath)) return;
97
+
98
+ try {
99
+ const data = fs.readFileSync(this.cachePath);
100
+ if (data.length < IV_LENGTH + TAG_LENGTH) return;
101
+
102
+ const iv = data.subarray(0, IV_LENGTH);
103
+ const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
104
+ const ciphertext = data.subarray(IV_LENGTH + TAG_LENGTH);
105
+
106
+ const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv);
107
+ decipher.setAuthTag(tag);
108
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
109
+
110
+ const payload: CachePayload = JSON.parse(decrypted.toString('utf-8'));
111
+ this.hotFacts = payload.hotFacts || [];
112
+ this.factCount = payload.factCount || 0;
113
+ this.lastSyncedBlock = payload.lastSyncedBlock || 0;
114
+ this.smartAccountAddress = payload.smartAccountAddress || '';
115
+ this.lastUpdatedAt = payload.lastUpdatedAt || 0;
116
+ this.lastQueryEmbedding = payload.lastQueryEmbedding || null;
117
+ } catch {
118
+ this.hotFacts = [];
119
+ this.factCount = 0;
120
+ this.lastSyncedBlock = 0;
121
+ this.smartAccountAddress = '';
122
+ this.lastUpdatedAt = 0;
123
+ this.lastQueryEmbedding = null;
124
+ }
125
+ }
126
+ }