@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/index.ts ADDED
@@ -0,0 +1,1885 @@
1
+ /**
2
+ * TotalReclaw Plugin for OpenClaw
3
+ *
4
+ * Registers runtime tools so OpenClaw can execute TotalReclaw operations:
5
+ * - totalreclaw_remember -- store an encrypted memory
6
+ * - totalreclaw_recall -- search and decrypt memories
7
+ * - totalreclaw_forget -- soft-delete a memory
8
+ * - totalreclaw_export -- export all memories (JSON or Markdown)
9
+ * - totalreclaw_status -- check billing/subscription status
10
+ *
11
+ * Also registers a `before_agent_start` hook that automatically injects
12
+ * relevant memories into the agent's context.
13
+ *
14
+ * All data is encrypted client-side with AES-256-GCM. The server never
15
+ * sees plaintext.
16
+ */
17
+
18
+ import {
19
+ deriveKeys,
20
+ deriveLshSeed,
21
+ computeAuthKeyHash,
22
+ encrypt,
23
+ decrypt,
24
+ generateBlindIndices,
25
+ generateContentFingerprint,
26
+ } from './crypto.js';
27
+ import { createApiClient, type StoreFactPayload } from './api-client.js';
28
+ import { extractFacts, type ExtractedFact } from './extractor.js';
29
+ import { initLLMClient, generateEmbedding, getEmbeddingDims } from './llm-client.js';
30
+ import { LSHHasher } from './lsh.js';
31
+ import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS, type RerankerCandidate } from './reranker.js';
32
+ import { deduplicateBatch } from './semantic-dedup.js';
33
+ import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactOnChain, deriveSmartAccountAddress, type FactPayload } from './subgraph-store.js';
34
+ import { searchSubgraph, getSubgraphFactCount } from './subgraph-search.js';
35
+ import { PluginHotCache, type HotFact } from './hot-cache-wrapper.js';
36
+ import crypto from 'node:crypto';
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // OpenClaw Plugin API type (defined locally to avoid SDK dependency)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ interface OpenClawPluginApi {
45
+ logger: {
46
+ info(...args: unknown[]): void;
47
+ warn(...args: unknown[]): void;
48
+ error(...args: unknown[]): void;
49
+ };
50
+ config?: {
51
+ agents?: {
52
+ defaults?: {
53
+ model?: {
54
+ primary?: string;
55
+ };
56
+ };
57
+ };
58
+ [key: string]: unknown;
59
+ };
60
+ pluginConfig?: Record<string, unknown>;
61
+ registerTool(tool: unknown, opts?: { name?: string; names?: string[] }): void;
62
+ registerService(service: { id: string; start(): void; stop?(): void }): void;
63
+ on(hookName: string, handler: (...args: unknown[]) => unknown, opts?: { priority?: number }): void;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Persistent credential storage
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Path where we persist userId + salt across restarts. */
71
+ const CREDENTIALS_PATH = process.env.TOTALRECLAW_CREDENTIALS_PATH || '/home/node/.totalreclaw/credentials.json';
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Cosine similarity threshold — skip injection when top result is below this
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Minimum cosine similarity of the top reranked result required to inject
79
+ * memories into context. Below this threshold, the query is considered
80
+ * irrelevant to any stored memories and results are suppressed.
81
+ *
82
+ * Default 0.15 is tuned for bge-small-en-v1.5 which produces lower
83
+ * similarity scores than OpenAI models. Configurable via env var.
84
+ */
85
+ const COSINE_THRESHOLD = parseFloat(
86
+ process.env.TOTALRECLAW_COSINE_THRESHOLD ?? '0.15',
87
+ );
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Module-level state (persists across tool calls within a session)
91
+ // ---------------------------------------------------------------------------
92
+
93
+ let authKeyHex: string | null = null;
94
+ let encryptionKey: Buffer | null = null;
95
+ let dedupKey: Buffer | null = null;
96
+ let userId: string | null = null;
97
+ let subgraphOwner: string | null = null; // Smart Account address for subgraph queries
98
+ let apiClient: ReturnType<typeof createApiClient> | null = null;
99
+ let initPromise: Promise<void> | null = null;
100
+
101
+ // LSH hasher — lazily initialized on first use (needs credentials + embedding dims)
102
+ let lshHasher: LSHHasher | null = null;
103
+ let lshInitFailed = false; // If true, skip LSH on future calls (provider doesn't support embeddings)
104
+
105
+ // Hot cache for subgraph mode — lazily initialized
106
+ let pluginHotCache: PluginHotCache | null = null;
107
+
108
+ // Two-tier search state (C1): skip redundant searches when query is semantically similar
109
+ let lastSearchTimestamp = 0;
110
+ let lastQueryEmbedding: number[] | null = null;
111
+
112
+ // Feature flags — configurable for A/B testing
113
+ const CACHE_TTL_MS = parseInt(process.env.TOTALRECLAW_CACHE_TTL_MS ?? String(5 * 60 * 1000), 10);
114
+ const SEMANTIC_SKIP_THRESHOLD = parseFloat(process.env.TOTALRECLAW_SEMANTIC_SKIP_THRESHOLD ?? '0.85');
115
+
116
+ // Auto-extract throttle (C3): only extract every N turns in agent_end hook
117
+ let turnsSinceLastExtraction = 0;
118
+ const AUTO_EXTRACT_EVERY_TURNS = parseInt(process.env.TOTALRECLAW_EXTRACT_EVERY_TURNS ?? '5', 10);
119
+
120
+ // B2: Minimum relevance threshold — cosine below this means no memory injection
121
+ const RELEVANCE_THRESHOLD = parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3');
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Billing cache infrastructure
125
+ // ---------------------------------------------------------------------------
126
+
127
+ const BILLING_CACHE_PATH = path.join(process.env.HOME ?? '/home/node', '.totalreclaw', 'billing-cache.json');
128
+ const BILLING_CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
129
+ const QUOTA_WARNING_THRESHOLD = 0.8; // 80%
130
+
131
+ interface BillingCache {
132
+ tier: string;
133
+ free_writes_used: number;
134
+ free_writes_limit: number;
135
+ checked_at: number;
136
+ }
137
+
138
+ function readBillingCache(): BillingCache | null {
139
+ try {
140
+ if (!fs.existsSync(BILLING_CACHE_PATH)) return null;
141
+ const raw = JSON.parse(fs.readFileSync(BILLING_CACHE_PATH, 'utf-8')) as BillingCache;
142
+ if (!raw.checked_at || Date.now() - raw.checked_at > BILLING_CACHE_TTL) return null;
143
+ return raw;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function writeBillingCache(cache: BillingCache): void {
150
+ try {
151
+ const dir = path.dirname(BILLING_CACHE_PATH);
152
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
153
+ fs.writeFileSync(BILLING_CACHE_PATH, JSON.stringify(cache));
154
+ } catch {
155
+ // Best-effort — don't block on cache write failure.
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Ensure MEMORY.md has a TotalReclaw header so the agent knows encrypted
161
+ * memories are injected automatically via the before_agent_start hook.
162
+ *
163
+ * Option 3 approach: don't delete or stub MEMORY.md — let the agent use it
164
+ * for workspace-level notes, but make clear that user facts/preferences are
165
+ * handled by TotalReclaw's E2EE pipeline. This avoids confusing the agent
166
+ * (OpenClaw's system prompt still tells it to search MEMORY.md) while
167
+ * guiding it away from writing sensitive data in cleartext.
168
+ */
169
+ const MEMORY_HEADER = `# Memory
170
+
171
+ > **TotalReclaw is active.** Your encrypted memories are loaded automatically
172
+ > at the start of each conversation — no need to search this file for them.
173
+ > Use \`totalreclaw_remember\` to store new memories and \`totalreclaw_recall\`
174
+ > to search. Do NOT write user facts, preferences, or decisions to this file.
175
+ > This file is for workspace-level notes only (non-sensitive).
176
+
177
+ `;
178
+
179
+ function ensureMemoryHeader(logger: OpenClawPluginApi['logger']): void {
180
+ try {
181
+ const workspace = path.join(process.env.HOME ?? '/home/node', '.openclaw', 'workspace');
182
+ const memoryMd = path.join(workspace, 'MEMORY.md');
183
+
184
+ if (fs.existsSync(memoryMd)) {
185
+ const content = fs.readFileSync(memoryMd, 'utf-8');
186
+ if (!content.includes('TotalReclaw is active')) {
187
+ fs.writeFileSync(memoryMd, MEMORY_HEADER + content);
188
+ logger.info('Added TotalReclaw header to MEMORY.md');
189
+ }
190
+ } else {
191
+ // Create MEMORY.md with the header so the agent doesn't get ENOENT
192
+ const dir = path.dirname(memoryMd);
193
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
194
+ fs.writeFileSync(memoryMd, MEMORY_HEADER);
195
+ logger.info('Created MEMORY.md with TotalReclaw header');
196
+ }
197
+ } catch {
198
+ // Best-effort — don't block the hook
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Dynamic candidate pool sizing
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** Cached fact count for dynamic candidate pool sizing. */
207
+ let cachedFactCount: number | null = null;
208
+ /** Timestamp of last fact count fetch (ms). */
209
+ let lastFactCountFetch: number = 0;
210
+ /** Cache TTL for fact count: 5 minutes. */
211
+ const FACT_COUNT_CACHE_TTL = 5 * 60 * 1000;
212
+
213
+ /**
214
+ * Compute the candidate pool size from a fact count.
215
+ *
216
+ * Formula: pool = min(max(factCount * 3, 400), 5000)
217
+ * - At least 400 candidates (even for tiny vaults)
218
+ * - At most 5000 candidates (to bound decryption + reranking cost)
219
+ * - 3x fact count in between
220
+ */
221
+ function computeCandidatePool(factCount: number): number {
222
+ return Math.min(Math.max(factCount * 3, 400), 5000);
223
+ }
224
+
225
+ /**
226
+ * Fetch the user's fact count from the server, with caching.
227
+ *
228
+ * Uses the /v1/export endpoint with limit=1 to get `total_count` without
229
+ * downloading all facts. Falls back to 400 (which gives pool=1200) if
230
+ * the server is unreachable or returns no count.
231
+ */
232
+ async function getFactCount(logger: OpenClawPluginApi['logger']): Promise<number> {
233
+ const now = Date.now();
234
+
235
+ // Return cached value if fresh.
236
+ if (cachedFactCount !== null && (now - lastFactCountFetch) < FACT_COUNT_CACHE_TTL) {
237
+ return cachedFactCount;
238
+ }
239
+
240
+ try {
241
+ if (!apiClient || !authKeyHex) {
242
+ return cachedFactCount ?? 400; // Not initialized yet, use default
243
+ }
244
+
245
+ const page = await apiClient.exportFacts(authKeyHex, 1);
246
+ const count = page.total_count ?? page.facts.length;
247
+
248
+ cachedFactCount = count;
249
+ lastFactCountFetch = now;
250
+ logger.info(`Fact count updated: ${count} (candidate pool: ${computeCandidatePool(count)})`);
251
+ return count;
252
+ } catch (err) {
253
+ const msg = err instanceof Error ? err.message : String(err);
254
+ logger.warn(`Failed to fetch fact count (using ${cachedFactCount ?? 400}): ${msg}`);
255
+ return cachedFactCount ?? 400; // Fall back to cached or default
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Initialisation
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /** True when master password is missing — tools return setup instructions. */
264
+ let needsSetup = false;
265
+
266
+ /**
267
+ * Derive keys from the master password, load or create credentials, and
268
+ * register with the server if this is the first run.
269
+ */
270
+ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
271
+ const serverUrl =
272
+ process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz';
273
+ const masterPassword = process.env.TOTALRECLAW_MASTER_PASSWORD;
274
+
275
+ if (!masterPassword) {
276
+ needsSetup = true;
277
+ logger.info('TOTALRECLAW_MASTER_PASSWORD not set — setup required (see SKILL.md Post-Install Setup)');
278
+ return;
279
+ }
280
+
281
+ apiClient = createApiClient(serverUrl);
282
+
283
+ // --- Attempt to load existing credentials ---
284
+ let existingSalt: Buffer | undefined;
285
+ let existingUserId: string | undefined;
286
+
287
+ try {
288
+ if (fs.existsSync(CREDENTIALS_PATH)) {
289
+ const creds = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
290
+ existingSalt = Buffer.from(creds.salt, 'base64');
291
+ existingUserId = creds.userId;
292
+ logger.info(`Loaded existing credentials for user ${existingUserId}`);
293
+ }
294
+ } catch (e) {
295
+ logger.warn('Failed to load credentials, will register new account');
296
+ }
297
+
298
+ // --- Derive keys ---
299
+ const keys = deriveKeys(masterPassword, existingSalt);
300
+ authKeyHex = keys.authKey.toString('hex');
301
+ encryptionKey = keys.encryptionKey;
302
+ dedupKey = keys.dedupKey;
303
+
304
+ // Cache credentials for lazy LSH seed derivation
305
+ masterPasswordCache = masterPassword;
306
+ saltCache = keys.salt;
307
+
308
+ if (existingUserId) {
309
+ userId = existingUserId;
310
+ logger.info(`Authenticated as user ${userId}`);
311
+ } else {
312
+ // First run -- register with the server.
313
+ const authHash = computeAuthKeyHash(keys.authKey);
314
+ const saltHex = keys.salt.toString('hex');
315
+
316
+ let registeredUserId: string | undefined;
317
+ try {
318
+ const result = await apiClient.register(authHash, saltHex);
319
+ registeredUserId = result.user_id;
320
+ } catch (err: unknown) {
321
+ const msg = err instanceof Error ? err.message : String(err);
322
+ if (msg.includes('USER_EXISTS') && isSubgraphMode()) {
323
+ // In subgraph mode, derive a deterministic userId from the auth key
324
+ // hash. The server is only a relay proxy — userId is used as the
325
+ // subgraph owner field and must be consistent between store/search.
326
+ registeredUserId = authHash.slice(0, 32);
327
+ logger.info(`Using derived userId for subgraph mode (server returned USER_EXISTS)`);
328
+ } else {
329
+ throw err;
330
+ }
331
+ }
332
+
333
+ userId = registeredUserId!;
334
+
335
+ // Persist credentials so we can resume later.
336
+ const dir = path.dirname(CREDENTIALS_PATH);
337
+ if (!fs.existsSync(dir)) {
338
+ fs.mkdirSync(dir, { recursive: true });
339
+ }
340
+ fs.writeFileSync(
341
+ CREDENTIALS_PATH,
342
+ JSON.stringify({ userId, salt: keys.salt.toString('base64') }),
343
+ );
344
+
345
+ logger.info(`Registered new user: ${userId}`);
346
+ }
347
+
348
+ // Derive Smart Account address for subgraph queries (on-chain owner identity).
349
+ if (isSubgraphMode()) {
350
+ try {
351
+ const config = getSubgraphConfig();
352
+ subgraphOwner = await deriveSmartAccountAddress(config.mnemonic, config.chainId);
353
+ logger.info(`Subgraph owner (Smart Account): ${subgraphOwner}`);
354
+ } catch (err) {
355
+ logger.warn(`Failed to derive Smart Account address: ${err instanceof Error ? err.message : String(err)}`);
356
+ // Fall back to userId — won't match subgraph Bytes format, but better than null
357
+ subgraphOwner = userId;
358
+ }
359
+ }
360
+ }
361
+
362
+ const SETUP_ERROR_MSG =
363
+ 'TotalReclaw setup required. Follow the "Post-Install Setup" steps in SKILL.md:\n' +
364
+ '1. Set TOTALRECLAW_MASTER_PASSWORD — ask the user if they have an existing recovery phrase or generate a new 12-word BIP-39 mnemonic.\n' +
365
+ '2. Set TOTALRECLAW_SUBGRAPH_MODE=true\n' +
366
+ '3. Restart the gateway to apply changes.';
367
+
368
+ /**
369
+ * Ensure `initialize()` has completed (runs at most once).
370
+ */
371
+ async function ensureInitialized(logger: OpenClawPluginApi['logger']): Promise<void> {
372
+ if (!initPromise) {
373
+ initPromise = initialize(logger);
374
+ }
375
+ await initPromise;
376
+ }
377
+
378
+ /**
379
+ * Like ensureInitialized, but throws if setup is still needed.
380
+ * Use in tool handlers where we need a fully configured plugin.
381
+ */
382
+ async function requireFullSetup(logger: OpenClawPluginApi['logger']): Promise<void> {
383
+ await ensureInitialized(logger);
384
+ if (needsSetup) {
385
+ throw new Error(SETUP_ERROR_MSG);
386
+ }
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // LSH + Embedding helpers
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /** Master password cached for LSH seed derivation (set during initialize()). */
394
+ let masterPasswordCache: string | null = null;
395
+ /** Salt cached for LSH seed derivation (set during initialize()). */
396
+ let saltCache: Buffer | null = null;
397
+
398
+ /**
399
+ * Get or initialize the LSH hasher.
400
+ *
401
+ * The hasher is created lazily because it needs:
402
+ * 1. The master password + salt (available after initialize())
403
+ * 2. The embedding dimensions (available after initLLMClient())
404
+ *
405
+ * If the provider doesn't support embeddings, this returns null and
406
+ * sets `lshInitFailed` to avoid retrying.
407
+ */
408
+ function getLSHHasher(logger: OpenClawPluginApi['logger']): LSHHasher | null {
409
+ if (lshHasher) return lshHasher;
410
+ if (lshInitFailed) return null;
411
+
412
+ try {
413
+ if (!masterPasswordCache || !saltCache) {
414
+ logger.warn('LSH hasher: credentials not available yet');
415
+ return null;
416
+ }
417
+
418
+ const dims = getEmbeddingDims();
419
+ const lshSeed = deriveLshSeed(masterPasswordCache, saltCache);
420
+ lshHasher = new LSHHasher(lshSeed, dims);
421
+ logger.info(`LSH hasher initialized (dims=${dims}, tables=${lshHasher.tables}, bits=${lshHasher.bits})`);
422
+ return lshHasher;
423
+ } catch (err) {
424
+ const msg = err instanceof Error ? err.message : String(err);
425
+ logger.warn(`LSH hasher initialization failed (will use word-only indices): ${msg}`);
426
+ lshInitFailed = true;
427
+ return null;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Generate an embedding for the given text and compute LSH bucket hashes.
433
+ *
434
+ * Returns null if embedding generation fails (provider doesn't support it,
435
+ * network error, etc.). In that case, the caller should fall back to
436
+ * word-only blind indices.
437
+ */
438
+ async function generateEmbeddingAndLSH(
439
+ text: string,
440
+ logger: OpenClawPluginApi['logger'],
441
+ ): Promise<{ embedding: number[]; lshBuckets: string[]; encryptedEmbedding: string } | null> {
442
+ try {
443
+ const embedding = await generateEmbedding(text);
444
+
445
+ const hasher = getLSHHasher(logger);
446
+ const lshBuckets = hasher ? hasher.hash(embedding) : [];
447
+
448
+ // Encrypt the embedding (JSON array of numbers) for zero-knowledge storage
449
+ const encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey!);
450
+
451
+ return { embedding, lshBuckets, encryptedEmbedding };
452
+ } catch (err) {
453
+ const msg = err instanceof Error ? err.message : String(err);
454
+ logger.warn(`Embedding/LSH generation failed (falling back to word-only indices): ${msg}`);
455
+ return null;
456
+ }
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Utility helpers
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Encrypt a plaintext document string and return its hex-encoded ciphertext.
465
+ *
466
+ * The server stores blobs as hex (not base64), so we convert the base64
467
+ * output of `encrypt()` into hex.
468
+ */
469
+ function encryptToHex(plaintext: string, key: Buffer): string {
470
+ const b64 = encrypt(plaintext, key);
471
+ return Buffer.from(b64, 'base64').toString('hex');
472
+ }
473
+
474
+ /**
475
+ * Decrypt a hex-encoded ciphertext blob into a UTF-8 string.
476
+ */
477
+ function decryptFromHex(hexBlob: string, key: Buffer): string {
478
+ const hex = hexBlob.startsWith('0x') ? hexBlob.slice(2) : hexBlob;
479
+ const b64 = Buffer.from(hex, 'hex').toString('base64');
480
+ return decrypt(b64, key);
481
+ }
482
+
483
+ /**
484
+ * Simple text-overlap scoring between a query and a candidate document.
485
+ * Returns the number of overlapping lowercase words.
486
+ */
487
+ function textScore(query: string, docText: string): number {
488
+ const queryWords = new Set(
489
+ query.toLowerCase().split(/\s+/).filter((w) => w.length >= 2),
490
+ );
491
+ const docWords = docText.toLowerCase().split(/\s+/);
492
+ let score = 0;
493
+ for (const word of docWords) {
494
+ if (queryWords.has(word)) score++;
495
+ }
496
+ return score;
497
+ }
498
+
499
+ /**
500
+ * Format a relative time string (e.g. "2 hours ago").
501
+ */
502
+ function relativeTime(isoOrMs: string | number): string {
503
+ const ms = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime();
504
+ const diffMs = Date.now() - ms;
505
+ const seconds = Math.floor(diffMs / 1000);
506
+ if (seconds < 60) return 'just now';
507
+ const minutes = Math.floor(seconds / 60);
508
+ if (minutes < 60) return `${minutes}m ago`;
509
+ const hours = Math.floor(minutes / 60);
510
+ if (hours < 24) return `${hours}h ago`;
511
+ const days = Math.floor(hours / 24);
512
+ return `${days}d ago`;
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Importance filter for auto-extraction
517
+ // ---------------------------------------------------------------------------
518
+
519
+ /**
520
+ * Minimum importance score (1-10) for auto-extracted facts to be stored.
521
+ * Facts below this threshold are silently dropped to save storage and gas.
522
+ * Configurable via TOTALRECLAW_MIN_IMPORTANCE env var (default: 3).
523
+ *
524
+ * NOTE: This filter is ONLY applied to auto-extraction (hooks).
525
+ * The explicit `totalreclaw_remember` tool always stores regardless of importance.
526
+ */
527
+ const MIN_IMPORTANCE_THRESHOLD = Math.max(
528
+ 1,
529
+ Math.min(10, Number(process.env.TOTALRECLAW_MIN_IMPORTANCE) || 3),
530
+ );
531
+
532
+ /**
533
+ * Filter extracted facts by importance threshold.
534
+ * Facts with importance < MIN_IMPORTANCE_THRESHOLD are dropped.
535
+ * Facts with missing/undefined importance are treated as importance=5 (kept).
536
+ */
537
+ function filterByImportance(
538
+ facts: ExtractedFact[],
539
+ logger: OpenClawPluginApi['logger'],
540
+ ): { kept: ExtractedFact[]; dropped: number } {
541
+ const kept: ExtractedFact[] = [];
542
+ let dropped = 0;
543
+
544
+ for (const fact of facts) {
545
+ const importance = fact.importance ?? 5;
546
+ if (importance >= MIN_IMPORTANCE_THRESHOLD) {
547
+ kept.push(fact);
548
+ } else {
549
+ dropped++;
550
+ }
551
+ }
552
+
553
+ if (dropped > 0) {
554
+ logger.info(
555
+ `Importance filter: dropped ${dropped}/${facts.length} facts below threshold ${MIN_IMPORTANCE_THRESHOLD}`,
556
+ );
557
+ }
558
+
559
+ return { kept, dropped };
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Auto-extraction helper
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /**
567
+ * Store extracted facts in the TotalReclaw server.
568
+ * Encrypts each fact, generates blind indices and fingerprint, stores via API.
569
+ * Silently skips duplicates.
570
+ *
571
+ * Before storing, performs semantic near-duplicate detection within the batch:
572
+ * facts whose embeddings have cosine similarity >= threshold (default 0.9)
573
+ * against an already-accepted fact in the same batch are skipped.
574
+ */
575
+ async function storeExtractedFacts(
576
+ facts: ExtractedFact[],
577
+ logger: OpenClawPluginApi['logger'],
578
+ ): Promise<number> {
579
+ if (!encryptionKey || !dedupKey || !authKeyHex || !userId || !apiClient) return 0;
580
+
581
+ // Phase 1: Generate embeddings for all facts (needed for dedup + storage).
582
+ const embeddingMap = new Map<string, number[]>();
583
+ const embeddingResultMap = new Map<
584
+ string,
585
+ { embedding: number[]; lshBuckets: string[]; encryptedEmbedding: string }
586
+ >();
587
+
588
+ for (const fact of facts) {
589
+ try {
590
+ const result = await generateEmbeddingAndLSH(fact.text, logger);
591
+ if (result) {
592
+ embeddingMap.set(fact.text, result.embedding);
593
+ embeddingResultMap.set(fact.text, result);
594
+ }
595
+ } catch {
596
+ // Embedding generation failed for this fact -- proceed without it.
597
+ }
598
+ }
599
+
600
+ // Phase 2: Semantic batch dedup.
601
+ const dedupedFacts = deduplicateBatch(facts, embeddingMap, logger);
602
+
603
+ if (dedupedFacts.length < facts.length) {
604
+ logger.info(
605
+ `Semantic dedup: ${facts.length - dedupedFacts.length} near-duplicate(s) removed from batch of ${facts.length}`,
606
+ );
607
+ }
608
+
609
+ // Phase 3: Store the deduplicated facts.
610
+ let stored = 0;
611
+
612
+ for (const fact of dedupedFacts) {
613
+ try {
614
+ const doc = {
615
+ text: fact.text,
616
+ metadata: {
617
+ type: fact.type,
618
+ importance: fact.importance / 10,
619
+ source: 'auto-extraction',
620
+ created_at: new Date().toISOString(),
621
+ },
622
+ };
623
+
624
+ const encryptedBlob = encryptToHex(JSON.stringify(doc), encryptionKey);
625
+ const blindIndices = generateBlindIndices(fact.text);
626
+
627
+ // Use pre-computed embedding result if available.
628
+ const embeddingResult = embeddingResultMap.get(fact.text) ?? null;
629
+ const allIndices = embeddingResult
630
+ ? [...blindIndices, ...embeddingResult.lshBuckets]
631
+ : blindIndices;
632
+
633
+ const contentFp = generateContentFingerprint(fact.text, dedupKey);
634
+ const factId = crypto.randomUUID();
635
+
636
+ const payload: StoreFactPayload = {
637
+ id: factId,
638
+ timestamp: new Date().toISOString(),
639
+ encrypted_blob: encryptedBlob,
640
+ blind_indices: allIndices,
641
+ decay_score: fact.importance,
642
+ source: 'auto-extraction',
643
+ content_fp: contentFp,
644
+ agent_id: 'openclaw-plugin-auto',
645
+ encrypted_embedding: embeddingResult?.encryptedEmbedding,
646
+ };
647
+
648
+ if (isSubgraphMode()) {
649
+ const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner };
650
+ const protobuf = encodeFactProtobuf({
651
+ id: factId,
652
+ timestamp: new Date().toISOString(),
653
+ owner: subgraphOwner || userId!,
654
+ encryptedBlob: encryptedBlob,
655
+ blindIndices: allIndices,
656
+ decayScore: fact.importance,
657
+ source: 'auto-extraction',
658
+ contentFp: contentFp,
659
+ agentId: 'openclaw-plugin-auto',
660
+ encryptedEmbedding: embeddingResult?.encryptedEmbedding,
661
+ });
662
+ await submitFactOnChain(protobuf, config);
663
+ } else {
664
+ await apiClient.store(userId, [payload], authKeyHex);
665
+ }
666
+ stored++;
667
+ } catch (err: unknown) {
668
+ // Check for 403 / quota exceeded — invalidate billing cache so next
669
+ // before_agent_start re-fetches and warns the user.
670
+ const errMsg = err instanceof Error ? err.message : String(err);
671
+ if (errMsg.includes('403') || errMsg.toLowerCase().includes('quota')) {
672
+ try { fs.unlinkSync(BILLING_CACHE_PATH); } catch { /* ignore */ }
673
+ logger.warn(`Quota exceeded — billing cache invalidated. ${errMsg}`);
674
+ break; // Stop trying to store remaining facts — they'll all fail too
675
+ }
676
+ // Otherwise skip failed facts (e.g., duplicates return success with duplicate_ids)
677
+ }
678
+ }
679
+
680
+ if (stored > 0) {
681
+ logger.info(`Auto-extracted and stored ${stored} memories`);
682
+ }
683
+
684
+ return stored;
685
+ }
686
+
687
+ // ---------------------------------------------------------------------------
688
+ // Plugin definition
689
+ // ---------------------------------------------------------------------------
690
+
691
+ const plugin = {
692
+ id: 'totalreclaw',
693
+ name: 'TotalReclaw',
694
+ description: 'Zero-knowledge encrypted memory vault for AI agents',
695
+ kind: 'memory' as const,
696
+ configSchema: {
697
+ type: 'object',
698
+ additionalProperties: false,
699
+ properties: {
700
+ extraction: {
701
+ type: 'object',
702
+ properties: {
703
+ model: { type: 'string', description: "Override the extraction model (e.g., 'glm-4.5-flash', 'gpt-4.1-mini')" },
704
+ enabled: { type: 'boolean', description: 'Enable/disable auto-extraction (default: true)' },
705
+ },
706
+ additionalProperties: false,
707
+ },
708
+ },
709
+ },
710
+
711
+ register(api: OpenClawPluginApi) {
712
+ // ---------------------------------------------------------------
713
+ // LLM client initialization (auto-detect provider from OpenClaw config)
714
+ // ---------------------------------------------------------------
715
+
716
+ initLLMClient({
717
+ primaryModel: api.config?.agents?.defaults?.model?.primary as string | undefined,
718
+ pluginConfig: api.pluginConfig,
719
+ logger: api.logger,
720
+ });
721
+
722
+ // ---------------------------------------------------------------
723
+ // Service registration (lifecycle logging)
724
+ // ---------------------------------------------------------------
725
+
726
+ api.registerService({
727
+ id: 'totalreclaw',
728
+ start: () => {
729
+ api.logger.info('TotalReclaw plugin loaded');
730
+ },
731
+ stop: () => {
732
+ api.logger.info('TotalReclaw plugin stopped');
733
+ },
734
+ });
735
+
736
+ // ---------------------------------------------------------------
737
+ // Tool: totalreclaw_remember
738
+ // ---------------------------------------------------------------
739
+
740
+ api.registerTool(
741
+ {
742
+ name: 'totalreclaw_remember',
743
+ label: 'Remember',
744
+ description:
745
+ 'Store a memory in the encrypted vault. Use this when the user shares important information worth remembering.',
746
+ parameters: {
747
+ type: 'object',
748
+ properties: {
749
+ text: {
750
+ type: 'string',
751
+ description: 'The memory text to store',
752
+ },
753
+ type: {
754
+ type: 'string',
755
+ enum: ['fact', 'preference', 'decision', 'episodic', 'goal'],
756
+ description: 'The kind of memory (default: fact)',
757
+ },
758
+ importance: {
759
+ type: 'number',
760
+ minimum: 1,
761
+ maximum: 10,
762
+ description: 'Importance score 1-10 (default: 5)',
763
+ },
764
+ },
765
+ required: ['text'],
766
+ additionalProperties: false,
767
+ },
768
+ async execute(_toolCallId: string, params: { text: string; type?: string; importance?: number }) {
769
+ try {
770
+ await requireFullSetup(api.logger);
771
+
772
+ const memoryType = params.type ?? 'fact';
773
+ const importance = params.importance ?? 5;
774
+
775
+ // Build the document JSON that will be encrypted.
776
+ const doc = {
777
+ text: params.text,
778
+ metadata: {
779
+ type: memoryType,
780
+ importance: importance / 10, // normalise to 0-1 range
781
+ source: 'explicit',
782
+ created_at: new Date().toISOString(),
783
+ },
784
+ };
785
+
786
+ // Encrypt the document.
787
+ const encryptedBlob = encryptToHex(JSON.stringify(doc), encryptionKey!);
788
+
789
+ // Generate blind indices for server-side search.
790
+ const blindIndices = generateBlindIndices(params.text);
791
+
792
+ // Generate embedding + LSH bucket hashes (PoC v2).
793
+ // Falls back to word-only indices if embedding generation fails.
794
+ const embeddingResult = await generateEmbeddingAndLSH(params.text, api.logger);
795
+
796
+ // Merge LSH bucket hashes into blind indices.
797
+ const allIndices = embeddingResult
798
+ ? [...blindIndices, ...embeddingResult.lshBuckets]
799
+ : blindIndices;
800
+
801
+ // Generate content fingerprint for dedup.
802
+ const contentFp = generateContentFingerprint(params.text, dedupKey!);
803
+
804
+ // Generate a unique fact ID.
805
+ const factId = crypto.randomUUID();
806
+
807
+ // Build the payload matching the server's FactJSON schema.
808
+ const factPayload: StoreFactPayload = {
809
+ id: factId,
810
+ timestamp: new Date().toISOString(),
811
+ encrypted_blob: encryptedBlob,
812
+ blind_indices: allIndices,
813
+ decay_score: importance,
814
+ source: 'explicit',
815
+ content_fp: contentFp,
816
+ agent_id: 'openclaw-plugin',
817
+ encrypted_embedding: embeddingResult?.encryptedEmbedding,
818
+ };
819
+
820
+ if (isSubgraphMode()) {
821
+ // Subgraph mode: encode as Protobuf and submit on-chain via relay UserOp
822
+ const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner };
823
+ const protobuf = encodeFactProtobuf({
824
+ id: factId,
825
+ timestamp: new Date().toISOString(),
826
+ owner: subgraphOwner || userId!,
827
+ encryptedBlob: encryptedBlob,
828
+ blindIndices: allIndices,
829
+ decayScore: importance,
830
+ source: 'explicit',
831
+ contentFp: contentFp,
832
+ agentId: 'openclaw-plugin',
833
+ encryptedEmbedding: embeddingResult?.encryptedEmbedding,
834
+ });
835
+ await submitFactOnChain(protobuf, config);
836
+ } else {
837
+ await apiClient!.store(userId!, [factPayload], authKeyHex!);
838
+ }
839
+
840
+ return {
841
+ content: [{ type: 'text', text: `Memory stored (ID: ${factId})` }],
842
+ details: { factId },
843
+ };
844
+ } catch (err: unknown) {
845
+ const message = err instanceof Error ? err.message : String(err);
846
+ api.logger.error(`totalreclaw_remember failed: ${message}`);
847
+ return {
848
+ content: [{ type: 'text', text: `Failed to store memory: ${message}` }],
849
+ };
850
+ }
851
+ },
852
+ },
853
+ { name: 'totalreclaw_remember' },
854
+ );
855
+
856
+ // ---------------------------------------------------------------
857
+ // Tool: totalreclaw_recall
858
+ // ---------------------------------------------------------------
859
+
860
+ api.registerTool(
861
+ {
862
+ name: 'totalreclaw_recall',
863
+ label: 'Recall',
864
+ description:
865
+ 'Search the encrypted memory vault. Returns the most relevant memories matching the query.',
866
+ parameters: {
867
+ type: 'object',
868
+ properties: {
869
+ query: {
870
+ type: 'string',
871
+ description: 'Search query text',
872
+ },
873
+ k: {
874
+ type: 'number',
875
+ minimum: 1,
876
+ maximum: 20,
877
+ description: 'Number of results to return (default: 8)',
878
+ },
879
+ },
880
+ required: ['query'],
881
+ additionalProperties: false,
882
+ },
883
+ async execute(_toolCallId: string, params: { query: string; k?: number }) {
884
+ try {
885
+ await requireFullSetup(api.logger);
886
+
887
+ const k = Math.min(params.k ?? 8, 20);
888
+
889
+ // 1. Generate word trapdoors (blind indices for the query).
890
+ const wordTrapdoors = generateBlindIndices(params.query);
891
+
892
+ // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
893
+ let queryEmbedding: number[] | null = null;
894
+ let lshTrapdoors: string[] = [];
895
+ try {
896
+ queryEmbedding = await generateEmbedding(params.query, { isQuery: true });
897
+ const hasher = getLSHHasher(api.logger);
898
+ if (hasher && queryEmbedding) {
899
+ lshTrapdoors = hasher.hash(queryEmbedding);
900
+ }
901
+ } catch (err) {
902
+ const msg = err instanceof Error ? err.message : String(err);
903
+ api.logger.warn(`Recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
904
+ }
905
+
906
+ // 3. Merge word trapdoors + LSH trapdoors.
907
+ const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
908
+
909
+ if (allTrapdoors.length === 0) {
910
+ return {
911
+ content: [{ type: 'text', text: 'No searchable terms in query.' }],
912
+ details: { count: 0, memories: [] },
913
+ };
914
+ }
915
+
916
+ // 4. Request more candidates than needed so we can re-rank client-side.
917
+ // 5. Decrypt candidates (text + embeddings) and build reranker input.
918
+ const rerankerCandidates: RerankerCandidate[] = [];
919
+ const metaMap = new Map<string, { metadata: Record<string, unknown>; timestamp: number }>();
920
+
921
+ if (isSubgraphMode()) {
922
+ // --- Subgraph search path ---
923
+ const factCount = await getSubgraphFactCount(subgraphOwner || userId!, authKeyHex!);
924
+ const pool = computeCandidatePool(factCount);
925
+ const subgraphResults = await searchSubgraph(subgraphOwner || userId!, allTrapdoors, pool, authKeyHex!);
926
+
927
+ for (const result of subgraphResults) {
928
+ try {
929
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey!);
930
+ const doc = JSON.parse(docJson) as { text: string; metadata?: Record<string, unknown> };
931
+
932
+ let decryptedEmbedding: number[] | undefined;
933
+ if (result.encryptedEmbedding) {
934
+ try {
935
+ decryptedEmbedding = JSON.parse(
936
+ decryptFromHex(result.encryptedEmbedding, encryptionKey!),
937
+ );
938
+ } catch {
939
+ // Embedding decryption failed -- proceed without it.
940
+ }
941
+ }
942
+
943
+ rerankerCandidates.push({
944
+ id: result.id,
945
+ text: doc.text,
946
+ embedding: decryptedEmbedding,
947
+ importance: (doc.metadata?.importance as number) ?? 0.5,
948
+ createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
949
+ });
950
+
951
+ metaMap.set(result.id, {
952
+ metadata: doc.metadata ?? {},
953
+ timestamp: Date.now(), // Subgraph doesn't return ms timestamp; use current
954
+ });
955
+ } catch {
956
+ // Skip candidates we cannot decrypt.
957
+ }
958
+ }
959
+
960
+ // Update hot cache with top results for instant auto-recall.
961
+ try {
962
+ if (!pluginHotCache && encryptionKey) {
963
+ const config = getSubgraphConfig();
964
+ pluginHotCache = new PluginHotCache(config.cachePath, encryptionKey.toString('hex'));
965
+ pluginHotCache.load();
966
+ }
967
+ if (pluginHotCache) {
968
+ const hotFacts: HotFact[] = rerankerCandidates.map((c) => {
969
+ const meta = metaMap.get(c.id);
970
+ const importance = meta?.metadata.importance
971
+ ? Math.round((meta.metadata.importance as number) * 10)
972
+ : 5;
973
+ return { id: c.id, text: c.text, importance };
974
+ });
975
+ pluginHotCache.setHotFacts(hotFacts);
976
+ pluginHotCache.setFactCount(rerankerCandidates.length);
977
+ pluginHotCache.flush();
978
+ }
979
+ } catch {
980
+ // Hot cache update is best-effort -- don't fail the recall.
981
+ }
982
+ } else {
983
+ // --- Server search path (existing behavior) ---
984
+ const factCount = await getFactCount(api.logger);
985
+ const pool = computeCandidatePool(factCount);
986
+ const candidates = await apiClient!.search(
987
+ userId!,
988
+ allTrapdoors,
989
+ pool,
990
+ authKeyHex!,
991
+ );
992
+
993
+ for (const candidate of candidates) {
994
+ try {
995
+ const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey!);
996
+ const doc = JSON.parse(docJson) as { text: string; metadata?: Record<string, unknown> };
997
+
998
+ let decryptedEmbedding: number[] | undefined;
999
+ if (candidate.encrypted_embedding) {
1000
+ try {
1001
+ decryptedEmbedding = JSON.parse(
1002
+ decryptFromHex(candidate.encrypted_embedding, encryptionKey!),
1003
+ );
1004
+ } catch {
1005
+ // Embedding decryption failed -- proceed without it.
1006
+ }
1007
+ }
1008
+
1009
+ rerankerCandidates.push({
1010
+ id: candidate.fact_id,
1011
+ text: doc.text,
1012
+ embedding: decryptedEmbedding,
1013
+ importance: (doc.metadata?.importance as number) ?? 0.5,
1014
+ createdAt: typeof candidate.timestamp === 'number'
1015
+ ? candidate.timestamp / 1000
1016
+ : new Date(candidate.timestamp).getTime() / 1000,
1017
+ });
1018
+
1019
+ metaMap.set(candidate.fact_id, {
1020
+ metadata: doc.metadata ?? {},
1021
+ timestamp: candidate.timestamp,
1022
+ });
1023
+ } catch {
1024
+ // Skip candidates we cannot decrypt (e.g. corrupted data).
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ // 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
1030
+ const queryIntent = detectQueryIntent(params.query);
1031
+ const reranked = rerank(
1032
+ params.query,
1033
+ queryEmbedding ?? [],
1034
+ rerankerCandidates,
1035
+ k,
1036
+ INTENT_WEIGHTS[queryIntent],
1037
+ );
1038
+
1039
+ if (reranked.length === 0) {
1040
+ return {
1041
+ content: [{ type: 'text', text: 'No memories found matching your query.' }],
1042
+ details: { count: 0, memories: [] },
1043
+ };
1044
+ }
1045
+
1046
+ // 6b. Cosine similarity threshold gate — skip results when the
1047
+ // best match is below the minimum relevance threshold.
1048
+ const maxCosine = Math.max(
1049
+ ...reranked.map((r) => r.cosineSimilarity ?? 0),
1050
+ );
1051
+ if (maxCosine < COSINE_THRESHOLD) {
1052
+ api.logger.info(
1053
+ `Recall: cosine threshold gate filtered results (max=${maxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
1054
+ );
1055
+ return {
1056
+ content: [{ type: 'text', text: 'No relevant memories found for this query.' }],
1057
+ details: { count: 0, memories: [] },
1058
+ };
1059
+ }
1060
+
1061
+ // 7. Format results.
1062
+ const lines = reranked.map((m, i) => {
1063
+ const meta = metaMap.get(m.id);
1064
+ const imp = meta?.metadata.importance
1065
+ ? ` (importance: ${Math.round((meta.metadata.importance as number) * 10)}/10)`
1066
+ : '';
1067
+ const age = meta ? relativeTime(meta.timestamp) : '';
1068
+ return `${i + 1}. ${m.text}${imp} -- ${age} [ID: ${m.id}]`;
1069
+ });
1070
+
1071
+ const formatted = lines.join('\n');
1072
+
1073
+ return {
1074
+ content: [{ type: 'text', text: formatted }],
1075
+ details: {
1076
+ count: reranked.length,
1077
+ memories: reranked.map((m) => ({
1078
+ factId: m.id,
1079
+ text: m.text,
1080
+ })),
1081
+ },
1082
+ };
1083
+ } catch (err: unknown) {
1084
+ const message = err instanceof Error ? err.message : String(err);
1085
+ api.logger.error(`totalreclaw_recall failed: ${message}`);
1086
+ return {
1087
+ content: [{ type: 'text', text: `Failed to search memories: ${message}` }],
1088
+ };
1089
+ }
1090
+ },
1091
+ },
1092
+ { name: 'totalreclaw_recall' },
1093
+ );
1094
+
1095
+ // ---------------------------------------------------------------
1096
+ // Tool: totalreclaw_forget
1097
+ // ---------------------------------------------------------------
1098
+
1099
+ api.registerTool(
1100
+ {
1101
+ name: 'totalreclaw_forget',
1102
+ label: 'Forget',
1103
+ description: 'Delete a specific memory by its ID.',
1104
+ parameters: {
1105
+ type: 'object',
1106
+ properties: {
1107
+ factId: {
1108
+ type: 'string',
1109
+ description: 'The UUID of the memory to delete',
1110
+ },
1111
+ },
1112
+ required: ['factId'],
1113
+ additionalProperties: false,
1114
+ },
1115
+ async execute(_toolCallId: string, params: { factId: string }) {
1116
+ try {
1117
+ await requireFullSetup(api.logger);
1118
+
1119
+ if (isSubgraphMode()) {
1120
+ // On-chain tombstone: write a minimal protobuf with decayScore=0
1121
+ // The subgraph will overwrite the fact and set isActive=false
1122
+ const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner };
1123
+ const tombstone: FactPayload = {
1124
+ id: params.factId,
1125
+ timestamp: new Date().toISOString(),
1126
+ owner: subgraphOwner || userId!,
1127
+ encryptedBlob: '00', // minimal 1-byte placeholder
1128
+ blindIndices: [],
1129
+ decayScore: 0,
1130
+ source: 'tombstone',
1131
+ contentFp: '',
1132
+ agentId: 'openclaw-plugin',
1133
+ };
1134
+ const protobuf = encodeFactProtobuf(tombstone);
1135
+ const result = await submitFactOnChain(protobuf, config);
1136
+ api.logger.info(`Tombstone written for ${params.factId}: tx=${result.txHash}`);
1137
+ return {
1138
+ content: [{ type: 'text', text: `Memory ${params.factId} deleted (on-chain tombstone, tx: ${result.txHash})` }],
1139
+ details: { deleted: true, txHash: result.txHash },
1140
+ };
1141
+ } else {
1142
+ await apiClient!.deleteFact(params.factId, authKeyHex!);
1143
+ return {
1144
+ content: [{ type: 'text', text: `Memory ${params.factId} deleted` }],
1145
+ details: { deleted: true },
1146
+ };
1147
+ }
1148
+ } catch (err: unknown) {
1149
+ const message = err instanceof Error ? err.message : String(err);
1150
+ api.logger.error(`totalreclaw_forget failed: ${message}`);
1151
+ return {
1152
+ content: [{ type: 'text', text: `Failed to delete memory: ${message}` }],
1153
+ };
1154
+ }
1155
+ },
1156
+ },
1157
+ { name: 'totalreclaw_forget' },
1158
+ );
1159
+
1160
+ // ---------------------------------------------------------------
1161
+ // Tool: totalreclaw_export
1162
+ // ---------------------------------------------------------------
1163
+
1164
+ api.registerTool(
1165
+ {
1166
+ name: 'totalreclaw_export',
1167
+ label: 'Export',
1168
+ description:
1169
+ 'Export all stored memories. Decrypts every memory and returns them as JSON or Markdown.',
1170
+ parameters: {
1171
+ type: 'object',
1172
+ properties: {
1173
+ format: {
1174
+ type: 'string',
1175
+ enum: ['json', 'markdown'],
1176
+ description: 'Output format (default: json)',
1177
+ },
1178
+ },
1179
+ additionalProperties: false,
1180
+ },
1181
+ async execute(_toolCallId: string, params: { format?: string }) {
1182
+ try {
1183
+ await requireFullSetup(api.logger);
1184
+
1185
+ const format = params.format ?? 'json';
1186
+
1187
+ // Paginate through all facts.
1188
+ const allFacts: Array<{
1189
+ id: string;
1190
+ text: string;
1191
+ metadata: Record<string, unknown>;
1192
+ created_at: string;
1193
+ }> = [];
1194
+
1195
+ if (isSubgraphMode()) {
1196
+ // Query subgraph for all active facts
1197
+ const config = getSubgraphConfig();
1198
+ const relayUrl = config.relayUrl;
1199
+ const PAGE_SIZE = 1000;
1200
+ let skip = 0;
1201
+ let hasMore = true;
1202
+ const owner = subgraphOwner || userId || '';
1203
+
1204
+ while (hasMore) {
1205
+ const query = `{ facts(where: { owner: "${owner}", isActive: true }, first: ${PAGE_SIZE}, skip: ${skip}, orderBy: sequenceId, orderDirection: asc) { id encryptedBlob source agentId timestamp sequenceId } }`;
1206
+
1207
+ const res = await fetch(`${relayUrl}/v1/subgraph`, {
1208
+ method: 'POST',
1209
+ headers: {
1210
+ 'Content-Type': 'application/json',
1211
+ ...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
1212
+ },
1213
+ body: JSON.stringify({ query }),
1214
+ });
1215
+
1216
+ const json = (await res.json()) as {
1217
+ data?: { facts?: Array<{ id: string; encryptedBlob: string; source: string; agentId: string; timestamp: string; sequenceId: string }> };
1218
+ };
1219
+ const facts = json?.data?.facts || [];
1220
+
1221
+ for (const fact of facts) {
1222
+ try {
1223
+ let hexBlob = fact.encryptedBlob;
1224
+ if (hexBlob.startsWith('0x')) hexBlob = hexBlob.slice(2);
1225
+ const docJson = decryptFromHex(hexBlob, encryptionKey!);
1226
+ const doc = JSON.parse(docJson) as { text: string; metadata?: Record<string, unknown> };
1227
+ allFacts.push({
1228
+ id: fact.id,
1229
+ text: doc.text,
1230
+ metadata: doc.metadata ?? {},
1231
+ created_at: new Date(parseInt(fact.timestamp) * 1000).toISOString(),
1232
+ });
1233
+ } catch {
1234
+ // Skip facts we cannot decrypt
1235
+ }
1236
+ }
1237
+
1238
+ skip += PAGE_SIZE;
1239
+ hasMore = facts.length === PAGE_SIZE;
1240
+ }
1241
+ } else {
1242
+ // HTTP server mode — paginate through PostgreSQL facts
1243
+ let cursor: string | undefined;
1244
+ let hasMore = true;
1245
+
1246
+ while (hasMore) {
1247
+ const page = await apiClient!.exportFacts(authKeyHex!, 1000, cursor);
1248
+
1249
+ for (const fact of page.facts) {
1250
+ try {
1251
+ const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey!);
1252
+ const doc = JSON.parse(docJson) as { text: string; metadata?: Record<string, unknown> };
1253
+ allFacts.push({
1254
+ id: fact.id,
1255
+ text: doc.text,
1256
+ metadata: doc.metadata ?? {},
1257
+ created_at: fact.created_at,
1258
+ });
1259
+ } catch {
1260
+ // Skip facts we cannot decrypt.
1261
+ }
1262
+ }
1263
+
1264
+ cursor = page.cursor ?? undefined;
1265
+ hasMore = page.has_more;
1266
+ }
1267
+ }
1268
+
1269
+ // Format output.
1270
+ let formatted: string;
1271
+
1272
+ if (format === 'markdown') {
1273
+ if (allFacts.length === 0) {
1274
+ formatted = '*No memories stored.*';
1275
+ } else {
1276
+ const lines = allFacts.map((f, i) => {
1277
+ const meta = f.metadata;
1278
+ const type = (meta.type as string) ?? 'fact';
1279
+ const imp = meta.importance
1280
+ ? ` (importance: ${Math.round((meta.importance as number) * 10)}/10)`
1281
+ : '';
1282
+ return `${i + 1}. **[${type}]** ${f.text}${imp} \n _ID: ${f.id} | Created: ${f.created_at}_`;
1283
+ });
1284
+ formatted = `# Exported Memories (${allFacts.length})\n\n${lines.join('\n')}`;
1285
+ }
1286
+ } else {
1287
+ formatted = JSON.stringify(allFacts, null, 2);
1288
+ }
1289
+
1290
+ return {
1291
+ content: [{ type: 'text', text: formatted }],
1292
+ details: { count: allFacts.length },
1293
+ };
1294
+ } catch (err: unknown) {
1295
+ const message = err instanceof Error ? err.message : String(err);
1296
+ api.logger.error(`totalreclaw_export failed: ${message}`);
1297
+ return {
1298
+ content: [{ type: 'text', text: `Failed to export memories: ${message}` }],
1299
+ };
1300
+ }
1301
+ },
1302
+ },
1303
+ { name: 'totalreclaw_export' },
1304
+ );
1305
+
1306
+ // ---------------------------------------------------------------
1307
+ // Tool: totalreclaw_status
1308
+ // ---------------------------------------------------------------
1309
+
1310
+ api.registerTool(
1311
+ {
1312
+ name: 'totalreclaw_status',
1313
+ label: 'Status',
1314
+ description:
1315
+ 'Check TotalReclaw billing and subscription status — tier, writes used, reset date.',
1316
+ parameters: {
1317
+ type: 'object',
1318
+ properties: {},
1319
+ additionalProperties: false,
1320
+ },
1321
+ async execute() {
1322
+ try {
1323
+ await requireFullSetup(api.logger);
1324
+
1325
+ if (!authKeyHex) {
1326
+ return {
1327
+ content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
1328
+ };
1329
+ }
1330
+
1331
+ const serverUrl = (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, '');
1332
+ const walletAddr = subgraphOwner || userId || '';
1333
+ const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
1334
+ method: 'GET',
1335
+ headers: {
1336
+ 'Authorization': `Bearer ${authKeyHex}`,
1337
+ 'Accept': 'application/json',
1338
+ },
1339
+ });
1340
+
1341
+ if (!response.ok) {
1342
+ const body = await response.text().catch(() => '');
1343
+ return {
1344
+ content: [{ type: 'text', text: `Failed to fetch billing status (HTTP ${response.status}): ${body || response.statusText}` }],
1345
+ };
1346
+ }
1347
+
1348
+ const data = await response.json() as Record<string, unknown>;
1349
+ const tier = (data.tier as string) || 'free';
1350
+ const freeWritesUsed = (data.free_writes_used as number) ?? 0;
1351
+ const freeWritesLimit = (data.free_writes_limit as number) ?? 0;
1352
+ const freeWritesResetAt = data.free_writes_reset_at as string | undefined;
1353
+
1354
+ // Update billing cache on success.
1355
+ writeBillingCache({
1356
+ tier,
1357
+ free_writes_used: freeWritesUsed,
1358
+ free_writes_limit: freeWritesLimit,
1359
+ checked_at: Date.now(),
1360
+ });
1361
+
1362
+ const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
1363
+ const lines: string[] = [
1364
+ `Tier: ${tierLabel}`,
1365
+ `Writes: ${freeWritesUsed}/${freeWritesLimit} used this month`,
1366
+ ];
1367
+ if (freeWritesResetAt) {
1368
+ lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
1369
+ }
1370
+ if (tier !== 'pro') {
1371
+ lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
1372
+ }
1373
+
1374
+ return {
1375
+ content: [{ type: 'text', text: lines.join('\n') }],
1376
+ details: { tier, free_writes_used: freeWritesUsed, free_writes_limit: freeWritesLimit },
1377
+ };
1378
+ } catch (err: unknown) {
1379
+ const message = err instanceof Error ? err.message : String(err);
1380
+ api.logger.error(`totalreclaw_status failed: ${message}`);
1381
+ return {
1382
+ content: [{ type: 'text', text: `Failed to check status: ${message}` }],
1383
+ };
1384
+ }
1385
+ },
1386
+ },
1387
+ { name: 'totalreclaw_status' },
1388
+ );
1389
+
1390
+ // ---------------------------------------------------------------
1391
+ // Hook: before_agent_start
1392
+ // ---------------------------------------------------------------
1393
+
1394
+ api.on(
1395
+ 'before_agent_start',
1396
+ async (event: unknown) => {
1397
+ try {
1398
+ // Prevent cleartext leakage from OpenClaw's native memory system.
1399
+ ensureMemoryHeader(api.logger);
1400
+
1401
+ const evt = event as { prompt?: string } | undefined;
1402
+
1403
+ // Skip trivial or missing prompts.
1404
+ if (!evt?.prompt || evt.prompt.length < 5) {
1405
+ return undefined;
1406
+ }
1407
+
1408
+ await ensureInitialized(api.logger);
1409
+
1410
+ // If setup is still needed after init, inject setup instructions
1411
+ if (needsSetup) {
1412
+ return {
1413
+ prependContext:
1414
+ '## TotalReclaw Setup Required\n\n' +
1415
+ 'TotalReclaw is installed but needs configuration. Follow the "Post-Install Setup" steps in SKILL.md to complete setup.\n' +
1416
+ 'Ask the user: "Do you have an existing TotalReclaw recovery phrase, or should I generate a new one?"',
1417
+ };
1418
+ }
1419
+
1420
+ // Billing cache check — warn if quota is approaching limit.
1421
+ let billingWarning = '';
1422
+ try {
1423
+ let cache = readBillingCache();
1424
+ if (!cache && authKeyHex) {
1425
+ // Cache is stale or missing — fetch fresh billing status.
1426
+ const billingUrl = (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, '');
1427
+ const walletParam = encodeURIComponent(subgraphOwner || userId || '');
1428
+ const billingResp = await fetch(`${billingUrl}/v1/billing/status?wallet_address=${walletParam}`, {
1429
+ method: 'GET',
1430
+ headers: { 'Authorization': `Bearer ${authKeyHex}`, 'Accept': 'application/json' },
1431
+ });
1432
+ if (billingResp.ok) {
1433
+ const billingData = await billingResp.json() as Record<string, unknown>;
1434
+ cache = {
1435
+ tier: (billingData.tier as string) || 'free',
1436
+ free_writes_used: (billingData.free_writes_used as number) ?? 0,
1437
+ free_writes_limit: (billingData.free_writes_limit as number) ?? 0,
1438
+ checked_at: Date.now(),
1439
+ };
1440
+ writeBillingCache(cache);
1441
+ }
1442
+ }
1443
+ if (cache && cache.free_writes_limit > 0) {
1444
+ const usageRatio = cache.free_writes_used / cache.free_writes_limit;
1445
+ if (usageRatio >= QUOTA_WARNING_THRESHOLD) {
1446
+ billingWarning = `\n\nTotalReclaw quota warning: ${cache.free_writes_used}/${cache.free_writes_limit} writes used this month (${Math.round(usageRatio * 100)}%). Visit https://totalreclaw.xyz/pricing to upgrade.`;
1447
+ }
1448
+ }
1449
+ } catch {
1450
+ // Best-effort — don't block on billing check failure.
1451
+ }
1452
+
1453
+ if (isSubgraphMode()) {
1454
+ // --- Subgraph mode: hot cache first, then background refresh ---
1455
+
1456
+ // Initialize hot cache if needed.
1457
+ if (!pluginHotCache && encryptionKey) {
1458
+ const config = getSubgraphConfig();
1459
+ pluginHotCache = new PluginHotCache(config.cachePath, encryptionKey.toString('hex'));
1460
+ pluginHotCache.load();
1461
+ }
1462
+
1463
+ // Try to return cached facts instantly.
1464
+ const cachedFacts = pluginHotCache?.getHotFacts() ?? [];
1465
+
1466
+ // Query subgraph in parallel for fresh results.
1467
+ // 1. Generate word trapdoors from the user prompt.
1468
+ const wordTrapdoors = generateBlindIndices(evt.prompt);
1469
+
1470
+ // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
1471
+ let queryEmbedding: number[] | null = null;
1472
+ let lshTrapdoors: string[] = [];
1473
+ try {
1474
+ queryEmbedding = await generateEmbedding(evt.prompt, { isQuery: true });
1475
+ const hasher = getLSHHasher(api.logger);
1476
+ if (hasher && queryEmbedding) {
1477
+ lshTrapdoors = hasher.hash(queryEmbedding);
1478
+ }
1479
+ } catch {
1480
+ // Embedding/LSH failed -- proceed with word-only trapdoors.
1481
+ }
1482
+
1483
+ // Two-tier search (C1): if cache is fresh AND query is semantically similar, return cached
1484
+ const now = Date.now();
1485
+ const cacheAge = now - lastSearchTimestamp;
1486
+ if (cacheAge < CACHE_TTL_MS && cachedFacts.length > 0 && queryEmbedding && lastQueryEmbedding) {
1487
+ const querySimilarity = cosineSimilarity(queryEmbedding, lastQueryEmbedding);
1488
+ if (querySimilarity > SEMANTIC_SKIP_THRESHOLD) {
1489
+ const lines = cachedFacts.slice(0, 8).map((f, i) =>
1490
+ `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
1491
+ );
1492
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
1493
+ }
1494
+ }
1495
+
1496
+ // 3. Merge trapdoors — always include word trapdoors for small-dataset coverage.
1497
+ // LSH alone has low collision probability on <100 facts, causing 0 matches.
1498
+ const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
1499
+
1500
+ // If we have cached facts and no trapdoors, return cached facts.
1501
+ if (allTrapdoors.length === 0 && cachedFacts.length > 0) {
1502
+ const lines = cachedFacts.slice(0, 8).map((f, i) =>
1503
+ `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
1504
+ );
1505
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
1506
+ }
1507
+
1508
+ if (allTrapdoors.length === 0) return undefined;
1509
+
1510
+ // 4. Query subgraph for fresh results.
1511
+ let subgraphResults: Awaited<ReturnType<typeof searchSubgraph>> = [];
1512
+ try {
1513
+ const factCount = await getSubgraphFactCount(subgraphOwner || userId!, authKeyHex!);
1514
+ const pool = computeCandidatePool(factCount);
1515
+ subgraphResults = await searchSubgraph(subgraphOwner || userId!, allTrapdoors, pool, authKeyHex!);
1516
+ } catch {
1517
+ // Subgraph query failed -- fall back to cached facts if available.
1518
+ if (cachedFacts.length > 0) {
1519
+ const lines = cachedFacts.slice(0, 8).map((f, i) =>
1520
+ `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
1521
+ );
1522
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
1523
+ }
1524
+ return undefined;
1525
+ }
1526
+
1527
+ if (subgraphResults.length === 0 && cachedFacts.length === 0) return undefined;
1528
+
1529
+ // If subgraph returned no results but we have cache, use cache.
1530
+ if (subgraphResults.length === 0) {
1531
+ const lines = cachedFacts.slice(0, 8).map((f, i) =>
1532
+ `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
1533
+ );
1534
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
1535
+ }
1536
+
1537
+ // 5. Decrypt subgraph results and build reranker input.
1538
+ const rerankerCandidates: RerankerCandidate[] = [];
1539
+ const hookMetaMap = new Map<string, { importance: number; age: string }>();
1540
+
1541
+ for (const result of subgraphResults) {
1542
+ try {
1543
+ const docJson = decryptFromHex(result.encryptedBlob, encryptionKey!);
1544
+ const doc = JSON.parse(docJson) as { text: string; metadata?: Record<string, unknown> };
1545
+
1546
+ let decryptedEmbedding: number[] | undefined;
1547
+ if (result.encryptedEmbedding) {
1548
+ try {
1549
+ decryptedEmbedding = JSON.parse(
1550
+ decryptFromHex(result.encryptedEmbedding, encryptionKey!),
1551
+ );
1552
+ } catch {
1553
+ // Embedding decryption failed -- proceed without it.
1554
+ }
1555
+ }
1556
+
1557
+ const importanceRaw = (doc.metadata?.importance as number) ?? 0.5;
1558
+ const createdAtSec = result.timestamp ? parseInt(result.timestamp, 10) : undefined;
1559
+ rerankerCandidates.push({
1560
+ id: result.id,
1561
+ text: doc.text,
1562
+ embedding: decryptedEmbedding,
1563
+ importance: importanceRaw,
1564
+ createdAt: createdAtSec,
1565
+ });
1566
+
1567
+ const importance = doc.metadata?.importance
1568
+ ? Math.round((doc.metadata.importance as number) * 10)
1569
+ : 5;
1570
+ hookMetaMap.set(result.id, {
1571
+ importance,
1572
+ age: 'subgraph',
1573
+ });
1574
+ } catch {
1575
+ // Skip un-decryptable candidates.
1576
+ }
1577
+ }
1578
+
1579
+ // 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
1580
+ const hookQueryIntent = detectQueryIntent(evt.prompt);
1581
+ const reranked = rerank(
1582
+ evt.prompt,
1583
+ queryEmbedding ?? [],
1584
+ rerankerCandidates,
1585
+ 8,
1586
+ INTENT_WEIGHTS[hookQueryIntent],
1587
+ );
1588
+
1589
+ // B2: Minimum relevance threshold — skip noise injection for irrelevant turns.
1590
+ const candidatesWithEmb = rerankerCandidates.filter(c => c.embedding && c.embedding.length > 0);
1591
+ if (candidatesWithEmb.length > 0 && queryEmbedding && queryEmbedding.length > 0) {
1592
+ const topCosine = Math.max(
1593
+ ...candidatesWithEmb.map(c => cosineSimilarity(queryEmbedding!, c.embedding!))
1594
+ );
1595
+ if (topCosine < RELEVANCE_THRESHOLD) return undefined;
1596
+ }
1597
+
1598
+ // Update hot cache with reranked results.
1599
+ try {
1600
+ if (pluginHotCache) {
1601
+ const hotFacts: HotFact[] = rerankerCandidates.map((c) => {
1602
+ const meta = hookMetaMap.get(c.id);
1603
+ return { id: c.id, text: c.text, importance: meta?.importance ?? 5 };
1604
+ });
1605
+ pluginHotCache.setHotFacts(hotFacts);
1606
+ pluginHotCache.setLastQueryEmbedding(queryEmbedding);
1607
+ pluginHotCache.flush();
1608
+ }
1609
+ } catch {
1610
+ // Hot cache update is best-effort.
1611
+ }
1612
+
1613
+ // Record search state for two-tier cache (C1).
1614
+ lastSearchTimestamp = Date.now();
1615
+ lastQueryEmbedding = queryEmbedding;
1616
+
1617
+ if (reranked.length === 0) return undefined;
1618
+
1619
+ // 6b. Cosine similarity threshold gate — skip injection when the
1620
+ // best match is below the minimum relevance threshold.
1621
+ const hookMaxCosine = Math.max(
1622
+ ...reranked.map((r) => r.cosineSimilarity ?? 0),
1623
+ );
1624
+ if (hookMaxCosine < COSINE_THRESHOLD) {
1625
+ api.logger.info(
1626
+ `Hook: cosine threshold gate filtered results (max=${hookMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD})`,
1627
+ );
1628
+ return undefined;
1629
+ }
1630
+
1631
+ // 7. Build context string.
1632
+ const lines = reranked.map((m, i) => {
1633
+ const meta = hookMetaMap.get(m.id);
1634
+ const importance = meta?.importance ?? 5;
1635
+ const age = meta?.age ?? '';
1636
+ return `${i + 1}. ${m.text} (importance: ${importance}/10, ${age})`;
1637
+ });
1638
+ const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
1639
+
1640
+ return { prependContext: contextString + billingWarning };
1641
+ }
1642
+
1643
+ // --- Server mode (existing behavior) ---
1644
+
1645
+ // 1. Generate word trapdoors from the user prompt.
1646
+ const wordTrapdoors = generateBlindIndices(evt.prompt);
1647
+
1648
+ // 2. Generate query embedding + LSH trapdoors (may fail gracefully).
1649
+ let queryEmbedding: number[] | null = null;
1650
+ let lshTrapdoors: string[] = [];
1651
+ try {
1652
+ queryEmbedding = await generateEmbedding(evt.prompt, { isQuery: true });
1653
+ const hasher = getLSHHasher(api.logger);
1654
+ if (hasher && queryEmbedding) {
1655
+ lshTrapdoors = hasher.hash(queryEmbedding);
1656
+ }
1657
+ } catch {
1658
+ // Embedding/LSH failed -- proceed with word-only trapdoors.
1659
+ }
1660
+
1661
+ // 3. Merge word + LSH trapdoors.
1662
+ const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
1663
+ if (allTrapdoors.length === 0) return undefined;
1664
+
1665
+ // 4. Fetch candidates from the server (dynamic pool sizing).
1666
+ const factCount = await getFactCount(api.logger);
1667
+ const pool = computeCandidatePool(factCount);
1668
+ const candidates = await apiClient!.search(
1669
+ userId!,
1670
+ allTrapdoors,
1671
+ pool,
1672
+ authKeyHex!,
1673
+ );
1674
+
1675
+ if (candidates.length === 0) return undefined;
1676
+
1677
+ // 5. Decrypt candidates (text + embeddings) and build reranker input.
1678
+ const rerankerCandidates: RerankerCandidate[] = [];
1679
+ const hookMetaMap = new Map<string, { importance: number; age: string }>();
1680
+
1681
+ for (const candidate of candidates) {
1682
+ try {
1683
+ const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey!);
1684
+ const doc = JSON.parse(docJson) as { text: string; metadata?: Record<string, unknown> };
1685
+
1686
+ // Decrypt embedding if present.
1687
+ let decryptedEmbedding: number[] | undefined;
1688
+ if (candidate.encrypted_embedding) {
1689
+ try {
1690
+ decryptedEmbedding = JSON.parse(
1691
+ decryptFromHex(candidate.encrypted_embedding, encryptionKey!),
1692
+ );
1693
+ } catch {
1694
+ // Embedding decryption failed -- proceed without it.
1695
+ }
1696
+ }
1697
+
1698
+ const importanceRaw = (doc.metadata?.importance as number) ?? 0.5;
1699
+ const createdAtSec = typeof candidate.timestamp === 'number'
1700
+ ? candidate.timestamp / 1000
1701
+ : new Date(candidate.timestamp).getTime() / 1000;
1702
+ rerankerCandidates.push({
1703
+ id: candidate.fact_id,
1704
+ text: doc.text,
1705
+ embedding: decryptedEmbedding,
1706
+ importance: importanceRaw,
1707
+ createdAt: createdAtSec,
1708
+ });
1709
+
1710
+ const importance = doc.metadata?.importance
1711
+ ? Math.round((doc.metadata.importance as number) * 10)
1712
+ : 5;
1713
+ hookMetaMap.set(candidate.fact_id, {
1714
+ importance,
1715
+ age: relativeTime(candidate.timestamp),
1716
+ });
1717
+ } catch {
1718
+ // Skip un-decryptable candidates.
1719
+ }
1720
+ }
1721
+
1722
+ // 6. Re-rank with BM25 + cosine + RRF fusion (intent-weighted).
1723
+ const srvHookIntent = detectQueryIntent(evt.prompt);
1724
+ const reranked = rerank(
1725
+ evt.prompt,
1726
+ queryEmbedding ?? [],
1727
+ rerankerCandidates,
1728
+ 8,
1729
+ INTENT_WEIGHTS[srvHookIntent],
1730
+ );
1731
+
1732
+ // B2: Minimum relevance threshold — skip noise injection for irrelevant turns.
1733
+ const candidatesWithEmbSrv = rerankerCandidates.filter(c => c.embedding && c.embedding.length > 0);
1734
+ if (candidatesWithEmbSrv.length > 0 && queryEmbedding && queryEmbedding.length > 0) {
1735
+ const topCosine = Math.max(
1736
+ ...candidatesWithEmbSrv.map(c => cosineSimilarity(queryEmbedding!, c.embedding!))
1737
+ );
1738
+ if (topCosine < RELEVANCE_THRESHOLD) return undefined;
1739
+ }
1740
+
1741
+ if (reranked.length === 0) return undefined;
1742
+
1743
+ // 7. Build context string.
1744
+ const lines = reranked.map((m, i) => {
1745
+ const meta = hookMetaMap.get(m.id);
1746
+ const importance = meta?.importance ?? 5;
1747
+ const age = meta?.age ?? '';
1748
+ return `${i + 1}. ${m.text} (importance: ${importance}/10, ${age})`;
1749
+ });
1750
+ const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
1751
+
1752
+ return { prependContext: contextString + billingWarning };
1753
+ } catch (err: unknown) {
1754
+ // The hook must NEVER throw -- log and return undefined.
1755
+ const message = err instanceof Error ? err.message : String(err);
1756
+ api.logger.warn(`before_agent_start hook failed: ${message}`);
1757
+ return undefined;
1758
+ }
1759
+ },
1760
+ { priority: 10 },
1761
+ );
1762
+
1763
+ // ---------------------------------------------------------------
1764
+ // Hook: agent_end — auto-extract facts after each conversation turn
1765
+ // ---------------------------------------------------------------
1766
+
1767
+ api.on(
1768
+ 'agent_end',
1769
+ async (event: unknown) => {
1770
+ try {
1771
+ const evt = event as { messages?: unknown[]; success?: boolean } | undefined;
1772
+ if (!evt?.success || !evt?.messages || evt.messages.length < 2) return;
1773
+
1774
+ await ensureInitialized(api.logger);
1775
+ if (needsSetup) return;
1776
+
1777
+ // C3: Throttle auto-extraction to every N turns (configurable via env).
1778
+ turnsSinceLastExtraction++;
1779
+ if (turnsSinceLastExtraction >= AUTO_EXTRACT_EVERY_TURNS) {
1780
+ const rawFacts = await extractFacts(evt.messages, 'turn');
1781
+ const { kept: facts } = filterByImportance(rawFacts, api.logger);
1782
+ if (facts.length > 0) {
1783
+ await storeExtractedFacts(facts, api.logger);
1784
+ }
1785
+ turnsSinceLastExtraction = 0;
1786
+ }
1787
+ } catch (err: unknown) {
1788
+ const message = err instanceof Error ? err.message : String(err);
1789
+ api.logger.warn(`agent_end extraction failed: ${message}`);
1790
+ }
1791
+ },
1792
+ { priority: 90 },
1793
+ );
1794
+
1795
+ // ---------------------------------------------------------------
1796
+ // Hook: before_compaction — extract ALL facts before context is lost
1797
+ // ---------------------------------------------------------------
1798
+
1799
+ api.on(
1800
+ 'before_compaction',
1801
+ async (event: unknown) => {
1802
+ try {
1803
+ const evt = event as { messages?: unknown[]; messageCount?: number } | undefined;
1804
+ if (!evt?.messages || evt.messages.length < 2) return;
1805
+
1806
+ await ensureInitialized(api.logger);
1807
+ if (needsSetup) return;
1808
+
1809
+ api.logger.info(
1810
+ `Pre-compaction extraction: processing ${evt.messages.length} messages`,
1811
+ );
1812
+
1813
+ const rawCompactFacts = await extractFacts(evt.messages, 'full');
1814
+ const { kept: facts } = filterByImportance(rawCompactFacts, api.logger);
1815
+ if (facts.length > 0) {
1816
+ await storeExtractedFacts(facts, api.logger);
1817
+ }
1818
+ turnsSinceLastExtraction = 0; // Reset C3 counter on compaction.
1819
+ } catch (err: unknown) {
1820
+ const message = err instanceof Error ? err.message : String(err);
1821
+ api.logger.warn(`before_compaction extraction failed: ${message}`);
1822
+ }
1823
+ },
1824
+ { priority: 5 },
1825
+ );
1826
+
1827
+ // ---------------------------------------------------------------
1828
+ // Hook: before_reset — final extraction before session is cleared
1829
+ // ---------------------------------------------------------------
1830
+
1831
+ api.on(
1832
+ 'before_reset',
1833
+ async (event: unknown) => {
1834
+ try {
1835
+ const evt = event as { messages?: unknown[]; reason?: string } | undefined;
1836
+ if (!evt?.messages || evt.messages.length < 2) return;
1837
+
1838
+ await ensureInitialized(api.logger);
1839
+ if (needsSetup) return;
1840
+
1841
+ api.logger.info(
1842
+ `Pre-reset extraction (${evt.reason ?? 'unknown'}): processing ${evt.messages.length} messages`,
1843
+ );
1844
+
1845
+ const rawResetFacts = await extractFacts(evt.messages, 'full');
1846
+ const { kept: facts } = filterByImportance(rawResetFacts, api.logger);
1847
+ if (facts.length > 0) {
1848
+ await storeExtractedFacts(facts, api.logger);
1849
+ }
1850
+ turnsSinceLastExtraction = 0; // Reset C3 counter on reset.
1851
+ } catch (err: unknown) {
1852
+ const message = err instanceof Error ? err.message : String(err);
1853
+ api.logger.warn(`before_reset extraction failed: ${message}`);
1854
+ }
1855
+ },
1856
+ { priority: 5 },
1857
+ );
1858
+ },
1859
+ };
1860
+
1861
+ export default plugin;
1862
+
1863
+ /**
1864
+ * Reset all module-level state for test isolation.
1865
+ * ONLY call this from test code — never in production.
1866
+ */
1867
+ export function __resetForTesting(): void {
1868
+ authKeyHex = null;
1869
+ encryptionKey = null;
1870
+ dedupKey = null;
1871
+ userId = null;
1872
+ subgraphOwner = null;
1873
+ apiClient = null;
1874
+ initPromise = null;
1875
+ lshHasher = null;
1876
+ lshInitFailed = false;
1877
+ masterPasswordCache = null;
1878
+ saltCache = null;
1879
+ cachedFactCount = null;
1880
+ lastFactCountFetch = 0;
1881
+ pluginHotCache = null;
1882
+ lastSearchTimestamp = 0;
1883
+ lastQueryEmbedding = null;
1884
+ turnsSinceLastExtraction = 0;
1885
+ }