@totalreclaw/totalreclaw 1.3.0 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/index.ts +451 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -79,9 +79,9 @@ Most of the time you won't use these directly -- the automatic hooks handle memo
79
79
  | Tier | Memories | Reads | Storage | Price |
80
80
  |------|----------|-------|---------|-------|
81
81
  | **Free** | 500/month | Unlimited | Testnet (trial) | $0 |
82
- | **Pro** | Unlimited | Unlimited | Permanent on-chain (Gnosis) | $5/month |
82
+ | **Pro** | Unlimited | Unlimited | Permanent on-chain (Gnosis) | See `totalreclaw_status` |
83
83
 
84
- Pay with card via Stripe. Counter resets monthly.
84
+ Pay with card via Stripe. Use `totalreclaw_status` to check current pricing. Counter resets monthly.
85
85
 
86
86
  ## Using with Other Agents
87
87
 
package/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * - totalreclaw_status -- check billing/subscription status
10
10
  * - totalreclaw_consolidate -- scan and merge near-duplicate memories
11
11
  * - totalreclaw_import_from -- import memories from other tools (Mem0, MCP Memory, etc.)
12
+ * - totalreclaw_upgrade -- create Stripe checkout for Pro upgrade
13
+ * - totalreclaw_migrate -- migrate testnet memories to mainnet after Pro upgrade
12
14
  *
13
15
  * Also registers a `before_agent_start` hook that automatically injects
14
16
  * relevant memories into the agent's context.
@@ -134,6 +136,9 @@ const MAX_FACTS_PER_EXTRACTION = 15;
134
136
  // Store-time near-duplicate detection (consolidation module)
135
137
  const STORE_DEDUP_ENABLED = process.env.TOTALRECLAW_STORE_DEDUP !== 'false';
136
138
 
139
+ // One-time welcome-back message for returning Pro users (set during init, consumed by first before_agent_start)
140
+ let welcomeBackMessage: string | null = null;
141
+
137
142
  // B2: Minimum relevance threshold — cosine below this means no memory injection
138
143
  const RELEVANCE_THRESHOLD = parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3');
139
144
 
@@ -421,6 +426,45 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
421
426
  subgraphOwner = userId;
422
427
  }
423
428
  }
429
+
430
+ // One-time billing check for returning users (imported recovery phrase).
431
+ // If they already have an active Pro subscription, inform them on next conversation start.
432
+ if (existingUserId && authKeyHex) {
433
+ try {
434
+ const walletAddr = subgraphOwner || userId || '';
435
+ if (walletAddr) {
436
+ const billingUrl = (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, '');
437
+ const resp = await fetch(`${billingUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
438
+ method: 'GET',
439
+ headers: {
440
+ 'Authorization': `Bearer ${authKeyHex}`,
441
+ 'Accept': 'application/json',
442
+ 'X-TotalReclaw-Client': 'openclaw-plugin',
443
+ },
444
+ });
445
+ if (resp.ok) {
446
+ const billingData = await resp.json() as Record<string, unknown>;
447
+ const tier = billingData.tier as string;
448
+ const expiresAt = billingData.expires_at as string | undefined;
449
+ // Populate billing cache for future use.
450
+ writeBillingCache({
451
+ tier: tier || 'free',
452
+ free_writes_used: (billingData.free_writes_used as number) ?? 0,
453
+ free_writes_limit: (billingData.free_writes_limit as number) ?? 0,
454
+ features: billingData.features as BillingCache['features'] | undefined,
455
+ checked_at: Date.now(),
456
+ });
457
+ if (tier === 'pro' && expiresAt) {
458
+ const expiryDate = new Date(expiresAt).toLocaleDateString();
459
+ welcomeBackMessage = `Welcome back! Your Pro subscription is active (expires: ${expiryDate}).`;
460
+ logger.info(`Returning Pro user detected — expires ${expiryDate}`);
461
+ }
462
+ }
463
+ }
464
+ } catch {
465
+ // Best-effort — don't block initialization on billing check failure.
466
+ }
467
+ }
424
468
  }
425
469
 
426
470
  function isDocker(): boolean {
@@ -682,6 +726,138 @@ function decryptFromHex(hexBlob: string, key: Buffer): string {
682
726
  return decrypt(b64, key);
683
727
  }
684
728
 
729
+ // ---------------------------------------------------------------------------
730
+ // Migration GraphQL helpers
731
+ // ---------------------------------------------------------------------------
732
+
733
+ interface MigrationFact {
734
+ id: string;
735
+ owner: string;
736
+ encryptedBlob: string;
737
+ encryptedEmbedding: string | null;
738
+ decayScore: string;
739
+ isActive: boolean;
740
+ contentFp: string;
741
+ source: string;
742
+ agentId: string;
743
+ version: number;
744
+ timestamp: string;
745
+ }
746
+
747
+ const MIGRATION_PAGE_SIZE = 1000;
748
+
749
+ /** Execute a GraphQL query against a subgraph endpoint. Returns null on error. */
750
+ async function migrationGqlQuery<T>(
751
+ endpoint: string,
752
+ query: string,
753
+ variables: Record<string, unknown>,
754
+ authKey?: string,
755
+ ): Promise<T | null> {
756
+ try {
757
+ const headers: Record<string, string> = {
758
+ 'Content-Type': 'application/json',
759
+ 'X-TotalReclaw-Client': 'openclaw-plugin',
760
+ };
761
+ if (authKey) headers['Authorization'] = `Bearer ${authKey}`;
762
+ const response = await fetch(endpoint, {
763
+ method: 'POST',
764
+ headers,
765
+ body: JSON.stringify({ query, variables }),
766
+ });
767
+ if (!response.ok) return null;
768
+ const json = await response.json() as { data?: T; errors?: unknown[] };
769
+ return json.data ?? null;
770
+ } catch {
771
+ return null;
772
+ }
773
+ }
774
+
775
+ /** Fetch all active facts by owner from a subgraph, paginated. */
776
+ async function fetchAllFactsByOwner(
777
+ subgraphUrl: string,
778
+ owner: string,
779
+ authKey: string,
780
+ ): Promise<MigrationFact[]> {
781
+ const allFacts: MigrationFact[] = [];
782
+ let lastId = '';
783
+
784
+ while (true) {
785
+ const hasLastId = lastId !== '';
786
+ const query = hasLastId
787
+ ? `query($owner:Bytes!,$first:Int!,$lastId:String!){facts(where:{owner:$owner,isActive:true,id_gt:$lastId},first:$first,orderBy:id,orderDirection:asc){id owner encryptedBlob encryptedEmbedding decayScore isActive contentFp source agentId version timestamp}}`
788
+ : `query($owner:Bytes!,$first:Int!){facts(where:{owner:$owner,isActive:true},first:$first,orderBy:id,orderDirection:asc){id owner encryptedBlob encryptedEmbedding decayScore isActive contentFp source agentId version timestamp}}`;
789
+ const vars: Record<string, unknown> = hasLastId
790
+ ? { owner, first: MIGRATION_PAGE_SIZE, lastId }
791
+ : { owner, first: MIGRATION_PAGE_SIZE };
792
+
793
+ const data = await migrationGqlQuery<{ facts?: MigrationFact[] }>(subgraphUrl, query, vars, authKey);
794
+ const facts = data?.facts ?? [];
795
+ if (facts.length === 0) break;
796
+ allFacts.push(...facts);
797
+ if (facts.length < MIGRATION_PAGE_SIZE) break;
798
+ lastId = facts[facts.length - 1].id;
799
+ }
800
+
801
+ return allFacts;
802
+ }
803
+
804
+ /** Fetch content fingerprints from a subgraph for idempotency. */
805
+ async function fetchContentFingerprintsByOwner(
806
+ subgraphUrl: string,
807
+ owner: string,
808
+ authKey: string,
809
+ ): Promise<Set<string>> {
810
+ const fps = new Set<string>();
811
+ let lastId = '';
812
+
813
+ while (true) {
814
+ const hasLastId = lastId !== '';
815
+ const query = hasLastId
816
+ ? `query($owner:Bytes!,$first:Int!,$lastId:String!){facts(where:{owner:$owner,isActive:true,id_gt:$lastId},first:$first,orderBy:id,orderDirection:asc){id contentFp}}`
817
+ : `query($owner:Bytes!,$first:Int!){facts(where:{owner:$owner,isActive:true},first:$first,orderBy:id,orderDirection:asc){id contentFp}}`;
818
+ const vars: Record<string, unknown> = hasLastId
819
+ ? { owner, first: MIGRATION_PAGE_SIZE, lastId }
820
+ : { owner, first: MIGRATION_PAGE_SIZE };
821
+
822
+ const data = await migrationGqlQuery<{ facts?: Array<{ id: string; contentFp: string }> }>(subgraphUrl, query, vars, authKey);
823
+ const facts = data?.facts ?? [];
824
+ if (facts.length === 0) break;
825
+ for (const f of facts) {
826
+ if (f.contentFp) fps.add(f.contentFp);
827
+ }
828
+ if (facts.length < MIGRATION_PAGE_SIZE) break;
829
+ lastId = facts[facts.length - 1].id;
830
+ }
831
+
832
+ return fps;
833
+ }
834
+
835
+ /** Fetch blind index hashes for given fact IDs. */
836
+ async function fetchBlindIndicesByFactIds(
837
+ subgraphUrl: string,
838
+ factIds: string[],
839
+ authKey: string,
840
+ ): Promise<Map<string, string[]>> {
841
+ const result = new Map<string, string[]>();
842
+ const CHUNK = 50;
843
+
844
+ for (let i = 0; i < factIds.length; i += CHUNK) {
845
+ const chunk = factIds.slice(i, i + CHUNK);
846
+ const query = `query($factIds:[String!]!,$first:Int!){blindIndexes(where:{fact_in:$factIds},first:$first){hash fact{id}}}`;
847
+ const data = await migrationGqlQuery<{
848
+ blindIndexes?: Array<{ hash: string; fact: { id: string } }>;
849
+ }>(subgraphUrl, query, { factIds: chunk, first: 1000 }, authKey);
850
+
851
+ for (const entry of data?.blindIndexes ?? []) {
852
+ const existing = result.get(entry.fact.id) || [];
853
+ existing.push(entry.hash);
854
+ result.set(entry.fact.id, existing);
855
+ }
856
+ }
857
+
858
+ return result;
859
+ }
860
+
685
861
  /**
686
862
  * Fetch existing memories from the vault to provide dedup context for extraction.
687
863
  * Returns a lightweight list of {id, text} pairs for the LLM prompt.
@@ -2183,6 +2359,268 @@ const plugin = {
2183
2359
  { name: 'totalreclaw_import_from' },
2184
2360
  );
2185
2361
 
2362
+ // ---------------------------------------------------------------
2363
+ // Tool: totalreclaw_upgrade
2364
+ // ---------------------------------------------------------------
2365
+
2366
+ api.registerTool(
2367
+ {
2368
+ name: 'totalreclaw_upgrade',
2369
+ label: 'Upgrade to Pro',
2370
+ description:
2371
+ 'Upgrade to TotalReclaw Pro for unlimited encrypted memories. ' +
2372
+ 'Returns a Stripe checkout URL for the user to complete payment via credit/debit card.',
2373
+ parameters: {
2374
+ type: 'object',
2375
+ properties: {},
2376
+ additionalProperties: false,
2377
+ },
2378
+ async execute() {
2379
+ try {
2380
+ await requireFullSetup(api.logger);
2381
+
2382
+ if (!authKeyHex) {
2383
+ return {
2384
+ content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
2385
+ };
2386
+ }
2387
+
2388
+ const serverUrl = (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, '');
2389
+ const walletAddr = subgraphOwner || userId || '';
2390
+
2391
+ if (!walletAddr) {
2392
+ return {
2393
+ content: [{ type: 'text', text: 'Wallet address not available. Please ensure the plugin is fully initialized.' }],
2394
+ };
2395
+ }
2396
+
2397
+ const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
2398
+ method: 'POST',
2399
+ headers: {
2400
+ 'Authorization': `Bearer ${authKeyHex}`,
2401
+ 'Content-Type': 'application/json',
2402
+ 'X-TotalReclaw-Client': 'openclaw-plugin',
2403
+ },
2404
+ body: JSON.stringify({
2405
+ wallet_address: walletAddr,
2406
+ tier: 'pro',
2407
+ }),
2408
+ });
2409
+
2410
+ if (!response.ok) {
2411
+ const body = await response.text().catch(() => '');
2412
+ return {
2413
+ content: [{ type: 'text', text: `Failed to create checkout session (HTTP ${response.status}): ${body || response.statusText}` }],
2414
+ };
2415
+ }
2416
+
2417
+ const data = await response.json() as { checkout_url?: string };
2418
+
2419
+ if (!data.checkout_url) {
2420
+ return {
2421
+ content: [{ type: 'text', text: 'Failed to create checkout session: no checkout URL returned.' }],
2422
+ };
2423
+ }
2424
+
2425
+ return {
2426
+ content: [{ type: 'text', text: `Open this URL to upgrade to Pro: ${data.checkout_url}` }],
2427
+ details: { checkout_url: data.checkout_url },
2428
+ };
2429
+ } catch (err: unknown) {
2430
+ const message = err instanceof Error ? err.message : String(err);
2431
+ api.logger.error(`totalreclaw_upgrade failed: ${message}`);
2432
+ return {
2433
+ content: [{ type: 'text', text: `Failed to create checkout session: ${message}` }],
2434
+ };
2435
+ }
2436
+ },
2437
+ },
2438
+ { name: 'totalreclaw_upgrade' },
2439
+ );
2440
+
2441
+ // ---------------------------------------------------------------
2442
+ // Tool: totalreclaw_migrate
2443
+ // ---------------------------------------------------------------
2444
+
2445
+ api.registerTool(
2446
+ {
2447
+ name: 'totalreclaw_migrate',
2448
+ label: 'Migrate Testnet to Mainnet',
2449
+ description:
2450
+ 'Migrate memories from testnet (Base Sepolia) to mainnet (Gnosis) after upgrading to Pro. ' +
2451
+ 'Dry-run by default — set confirm=true to execute. Idempotent: re-running skips already-migrated facts.',
2452
+ parameters: {
2453
+ type: 'object',
2454
+ properties: {
2455
+ confirm: {
2456
+ type: 'boolean',
2457
+ description: 'Set to true to execute the migration. Without it, returns a dry-run preview.',
2458
+ default: false,
2459
+ },
2460
+ },
2461
+ additionalProperties: false,
2462
+ },
2463
+ async execute(_params: { confirm?: boolean }) {
2464
+ try {
2465
+ await requireFullSetup(api.logger);
2466
+
2467
+ if (!authKeyHex || !subgraphOwner) {
2468
+ return {
2469
+ content: [{ type: 'text', text: 'Plugin not fully initialized. Ensure TOTALRECLAW_RECOVERY_PHRASE is set.' }],
2470
+ };
2471
+ }
2472
+
2473
+ if (!isSubgraphMode()) {
2474
+ return {
2475
+ content: [{ type: 'text', text: 'Migration is only available with the managed service (subgraph mode).' }],
2476
+ };
2477
+ }
2478
+
2479
+ const confirm = _params?.confirm === true;
2480
+ const serverUrl = (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, '');
2481
+
2482
+ // 1. Check billing tier
2483
+ const billingResp = await fetch(
2484
+ `${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`,
2485
+ {
2486
+ method: 'GET',
2487
+ headers: {
2488
+ 'Authorization': `Bearer ${authKeyHex}`,
2489
+ 'Content-Type': 'application/json',
2490
+ 'X-TotalReclaw-Client': 'openclaw-plugin',
2491
+ },
2492
+ },
2493
+ );
2494
+ if (!billingResp.ok) {
2495
+ return { content: [{ type: 'text', text: `Failed to check billing tier (HTTP ${billingResp.status}).` }] };
2496
+ }
2497
+ const billingData = await billingResp.json() as { tier: string };
2498
+ if (billingData.tier !== 'pro') {
2499
+ return {
2500
+ content: [{ type: 'text', text: 'Migration requires Pro tier. Use totalreclaw_upgrade to upgrade first.' }],
2501
+ };
2502
+ }
2503
+
2504
+ // 2. Fetch testnet facts via relay (chain=testnet query param)
2505
+ const testnetSubgraphUrl = `${serverUrl}/v1/subgraph?chain=testnet`;
2506
+ const mainnetSubgraphUrl = `${serverUrl}/v1/subgraph`;
2507
+
2508
+ api.logger.info('Fetching testnet facts...');
2509
+ const testnetFacts = await fetchAllFactsByOwner(testnetSubgraphUrl, subgraphOwner, authKeyHex);
2510
+
2511
+ if (testnetFacts.length === 0) {
2512
+ return {
2513
+ content: [{ type: 'text', text: 'No facts found on testnet. Nothing to migrate.' }],
2514
+ };
2515
+ }
2516
+
2517
+ // 3. Check mainnet for existing facts (idempotency)
2518
+ api.logger.info('Checking mainnet for existing facts...');
2519
+ const mainnetFps = await fetchContentFingerprintsByOwner(mainnetSubgraphUrl, subgraphOwner, authKeyHex);
2520
+ const factsToMigrate = testnetFacts.filter(f => !f.contentFp || !mainnetFps.has(f.contentFp));
2521
+ const alreadyOnMainnet = testnetFacts.length - factsToMigrate.length;
2522
+
2523
+ // 4. Dry-run
2524
+ if (!confirm) {
2525
+ const msg = factsToMigrate.length === 0
2526
+ ? `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.`
2527
+ : `Found ${factsToMigrate.length} facts to migrate from testnet to Gnosis mainnet (${alreadyOnMainnet} already on mainnet). Call with confirm=true to proceed.`;
2528
+ return {
2529
+ content: [{ type: 'text', text: msg }],
2530
+ details: {
2531
+ mode: 'dry_run',
2532
+ testnet_facts: testnetFacts.length,
2533
+ already_on_mainnet: alreadyOnMainnet,
2534
+ to_migrate: factsToMigrate.length,
2535
+ },
2536
+ };
2537
+ }
2538
+
2539
+ // 5. Execute migration
2540
+ if (factsToMigrate.length === 0) {
2541
+ return {
2542
+ content: [{ type: 'text', text: `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.` }],
2543
+ };
2544
+ }
2545
+
2546
+ // Fetch blind indices
2547
+ api.logger.info(`Fetching blind indices for ${factsToMigrate.length} facts...`);
2548
+ const factIds = factsToMigrate.map(f => f.id);
2549
+ const blindIndicesMap = await fetchBlindIndicesByFactIds(testnetSubgraphUrl, factIds, authKeyHex);
2550
+
2551
+ // Build protobuf payloads
2552
+ const payloads: Buffer[] = [];
2553
+ for (const fact of factsToMigrate) {
2554
+ const blobHex = fact.encryptedBlob.startsWith('0x') ? fact.encryptedBlob.slice(2) : fact.encryptedBlob;
2555
+ const indices = blindIndicesMap.get(fact.id) || [];
2556
+ const factPayload: FactPayload = {
2557
+ id: fact.id,
2558
+ timestamp: new Date().toISOString(),
2559
+ owner: subgraphOwner,
2560
+ encryptedBlob: blobHex,
2561
+ blindIndices: indices,
2562
+ decayScore: parseFloat(fact.decayScore) || 0.5,
2563
+ source: fact.source || 'migration',
2564
+ contentFp: fact.contentFp || '',
2565
+ agentId: fact.agentId || 'openclaw-plugin',
2566
+ encryptedEmbedding: fact.encryptedEmbedding || undefined,
2567
+ };
2568
+ payloads.push(encodeFactProtobuf(factPayload));
2569
+ }
2570
+
2571
+ // Batch submit (15 per UserOp)
2572
+ const BATCH_SIZE = 15;
2573
+ const batchConfig = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner ?? undefined };
2574
+ let migrated = 0;
2575
+ let failedBatches = 0;
2576
+
2577
+ for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
2578
+ const batch = payloads.slice(i, i + BATCH_SIZE);
2579
+ const batchNum = Math.floor(i / BATCH_SIZE) + 1;
2580
+ const totalBatches = Math.ceil(payloads.length / BATCH_SIZE);
2581
+ api.logger.info(`Migrating batch ${batchNum}/${totalBatches} (${batch.length} facts)...`);
2582
+
2583
+ try {
2584
+ const result = await submitFactBatchOnChain(batch, batchConfig);
2585
+ if (result.success) {
2586
+ migrated += batch.length;
2587
+ } else {
2588
+ failedBatches++;
2589
+ }
2590
+ } catch (err: unknown) {
2591
+ const msg = err instanceof Error ? err.message : String(err);
2592
+ api.logger.error(`Migration batch ${batchNum} failed: ${msg}`);
2593
+ failedBatches++;
2594
+ }
2595
+ }
2596
+
2597
+ const resultMsg = failedBatches === 0
2598
+ ? `Successfully migrated ${migrated} memories from testnet to Gnosis mainnet.`
2599
+ : `Migrated ${migrated}/${factsToMigrate.length} memories. ${failedBatches} batch(es) failed — re-run to retry (idempotent).`;
2600
+
2601
+ return {
2602
+ content: [{ type: 'text', text: resultMsg }],
2603
+ details: {
2604
+ mode: 'executed',
2605
+ testnet_facts: testnetFacts.length,
2606
+ already_on_mainnet: alreadyOnMainnet,
2607
+ to_migrate: factsToMigrate.length,
2608
+ migrated,
2609
+ failed_batches: failedBatches,
2610
+ },
2611
+ };
2612
+ } catch (err: unknown) {
2613
+ const message = err instanceof Error ? err.message : String(err);
2614
+ api.logger.error(`totalreclaw_migrate failed: ${message}`);
2615
+ return {
2616
+ content: [{ type: 'text', text: `Migration failed: ${message}` }],
2617
+ };
2618
+ }
2619
+ },
2620
+ },
2621
+ { name: 'totalreclaw_migrate' },
2622
+ );
2623
+
2186
2624
  // ---------------------------------------------------------------
2187
2625
  // Hook: before_agent_start
2188
2626
  // ---------------------------------------------------------------
@@ -2213,6 +2651,13 @@ const plugin = {
2213
2651
  };
2214
2652
  }
2215
2653
 
2654
+ // One-time welcome-back message for returning Pro users.
2655
+ let welcomeBack = '';
2656
+ if (welcomeBackMessage) {
2657
+ welcomeBack = `\n\n${welcomeBackMessage}`;
2658
+ welcomeBackMessage = null; // Consume — only show once
2659
+ }
2660
+
2216
2661
  // Billing cache check — warn if quota is approaching limit.
2217
2662
  let billingWarning = '';
2218
2663
  try {
@@ -2286,7 +2731,7 @@ const plugin = {
2286
2731
  const lines = cachedFacts.slice(0, 8).map((f, i) =>
2287
2732
  `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
2288
2733
  );
2289
- return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
2734
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
2290
2735
  }
2291
2736
  }
2292
2737
 
@@ -2299,7 +2744,7 @@ const plugin = {
2299
2744
  const lines = cachedFacts.slice(0, 8).map((f, i) =>
2300
2745
  `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
2301
2746
  );
2302
- return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
2747
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
2303
2748
  }
2304
2749
 
2305
2750
  if (allTrapdoors.length === 0) return undefined;
@@ -2316,7 +2761,7 @@ const plugin = {
2316
2761
  const lines = cachedFacts.slice(0, 8).map((f, i) =>
2317
2762
  `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
2318
2763
  );
2319
- return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
2764
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
2320
2765
  }
2321
2766
  return undefined;
2322
2767
  }
@@ -2328,7 +2773,7 @@ const plugin = {
2328
2773
  const lines = cachedFacts.slice(0, 8).map((f, i) =>
2329
2774
  `${i + 1}. ${f.text} (importance: ${f.importance}/10, cached)`,
2330
2775
  );
2331
- return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + billingWarning };
2776
+ return { prependContext: `## Relevant Memories\n\n${lines.join('\n')}` + welcomeBack + billingWarning };
2332
2777
  }
2333
2778
 
2334
2779
  // 5. Decrypt subgraph results and build reranker input.
@@ -2434,7 +2879,7 @@ const plugin = {
2434
2879
  });
2435
2880
  const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
2436
2881
 
2437
- return { prependContext: contextString + billingWarning };
2882
+ return { prependContext: contextString + welcomeBack + billingWarning };
2438
2883
  }
2439
2884
 
2440
2885
  // --- Server mode (existing behavior) ---
@@ -2546,7 +2991,7 @@ const plugin = {
2546
2991
  });
2547
2992
  const contextString = `## Relevant Memories\n\n${lines.join('\n')}`;
2548
2993
 
2549
- return { prependContext: contextString + billingWarning };
2994
+ return { prependContext: contextString + welcomeBack + billingWarning };
2550
2995
  } catch (err: unknown) {
2551
2996
  // The hook must NEVER throw -- log and return undefined.
2552
2997
  const message = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "End-to-end encrypted memory for AI agents — portable, yours forever. Automatic extraction, semantic search, and on-chain storage",
5
5
  "type": "module",
6
6
  "keywords": [