From e7ac300b7e560d9ff5495a37b560acbb3cbb1bc9 Mon Sep 17 00:00:00 2001 From: Tarun Sukhani Date: Wed, 4 Feb 2026 17:05:56 +0000 Subject: [PATCH] memory-neo4j: add Pareto-based memory ecosystem with retrieval tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement retrieval tracking and Pareto-based memory consolidation: - Track retrievalCount and lastRetrievedAt on every search - Effective importance formula: importance × freq_boost × recency_factor - Seven-phase sleep cycle: dedup, pareto scoring, promotion, demotion, decay/pruning, extraction, cleanup - Bidirectional mobility between core (≤20%) and regular memory tiers - Core memories ranked by pure usage (no importance multiplier) Based on ACT-R memory model and Ebbinghaus forgetting curve research. Co-Authored-By: Claude Opus 4.5 --- extensions/memory-neo4j/extractor.ts | 184 ++++++++++---- extensions/memory-neo4j/index.ts | 46 ++-- extensions/memory-neo4j/neo4j-client.ts | 314 ++++++++++++++++++++++-- extensions/memory-neo4j/search.ts | 14 +- 4 files changed, 478 insertions(+), 80 deletions(-) diff --git a/extensions/memory-neo4j/extractor.ts b/extensions/memory-neo4j/extractor.ts index 6fbe81d832e..01d3d26dfc0 100644 --- a/extensions/memory-neo4j/extractor.ts +++ b/extensions/memory-neo4j/extractor.ts @@ -348,7 +348,7 @@ export async function runBackgroundExtraction( // ============================================================================ /** - * Sleep Cycle Result - aggregated stats from all five phases. + * Sleep Cycle Result - aggregated stats from all phases. */ export type SleepCycleResult = { // Phase 1: Deduplication @@ -356,23 +356,35 @@ export type SleepCycleResult = { clustersFound: number; memoriesMerged: number; }; - // Phase 2: Core Promotion + // Phase 2: Pareto Scoring & Threshold + pareto: { + totalMemories: number; + coreMemories: number; + regularMemories: number; + threshold: number; // The 80th percentile effective score + }; + // Phase 3: Core Promotion promotion: { candidatesFound: number; promoted: number; }; - // Phase 3: Decay & Pruning + // Phase 4: Core Demotion + demotion: { + candidatesFound: number; + demoted: number; + }; + // Phase 5: Decay & Pruning decay: { memoriesPruned: number; }; - // Phase 4: Entity Extraction + // Phase 6: Entity Extraction extraction: { total: number; processed: number; succeeded: number; failed: number; }; - // Phase 5: Orphan Cleanup + // Phase 7: Orphan Cleanup cleanup: { entitiesRemoved: number; tagsRemoved: number; @@ -390,43 +402,60 @@ export type SleepCycleOptions = { // Phase 1: Deduplication dedupThreshold?: number; // Vector similarity threshold (default: 0.95) - // Phase 2: Core Promotion - promotionImportanceThreshold?: number; // Min importance to auto-promote (default: 0.9) + // Phase 2-4: Pareto-based Promotion/Demotion + paretoPercentile?: number; // Top N% for core (default: 0.2 = top 20%) promotionMinAgeDays?: number; // Min age before promotion (default: 7) - // Phase 3: Decay + // Phase 5: Decay decayRetentionThreshold?: number; // Below this, memory is pruned (default: 0.1) decayBaseHalfLifeDays?: number; // Base half-life in days (default: 30) decayImportanceMultiplier?: number; // How much importance extends half-life (default: 2) - // Phase 4: Extraction + // Phase 6: Extraction extractionBatchSize?: number; // Memories per batch (default: 50) extractionDelayMs?: number; // Delay between batches (default: 1000) // Progress callback - onPhaseStart?: (phase: "dedup" | "promotion" | "decay" | "extraction" | "cleanup") => void; + onPhaseStart?: ( + phase: "dedup" | "pareto" | "promotion" | "demotion" | "decay" | "extraction" | "cleanup", + ) => void; onProgress?: (phase: string, message: string) => void; }; /** - * Run the full sleep cycle - five phases of memory consolidation. + * Run the full sleep cycle - seven phases of memory consolidation. * - * This mimics how human memory consolidation works during sleep: + * This implements a Pareto-based memory ecosystem where core memory + * is bounded to the top 20% of memories by effective score. + * + * Phases: * 1. DEDUPLICATION - Merge near-duplicate memories (reduce redundancy) - * 2. CORE PROMOTION - Promote high-importance memories to core status - * 3. DECAY/PRUNING - Remove old, low-importance memories (forgetting curve) - * 4. EXTRACTION - Form entity relationships (strengthen connections) - * 5. CLEANUP - Remove orphaned entities/tags (garbage collection) + * 2. PARETO SCORING - Calculate effective scores for all memories + * 3. CORE PROMOTION - Regular memories above threshold → core + * 4. CORE DEMOTION - Core memories below threshold → regular + * 5. DECAY/PRUNING - Remove old, low-importance memories (forgetting curve) + * 6. EXTRACTION - Form entity relationships (strengthen connections) + * 7. CLEANUP - Remove orphaned entities/tags (garbage collection) + * + * Effective Score Formulas: + * - Regular memories: importance × freq_boost × recency + * - Core memories: importance × freq_boost × recency (same for threshold comparison) + * - Core memory retrieval ranking: freq_boost × recency (pure usage-based) + * + * Where: + * - freq_boost = 1 + log(1 + retrievalCount) × 0.3 + * - recency = 2^(-days_since_last / 14) * * Benefits: - * - Reduces latency during active conversations - * - Prevents memory bloat and "self-degradation" - * - Cleaner separation between capture and consolidation + * - Self-regulating core memory size (Pareto distribution) + * - Memories can be promoted AND demoted based on usage + * - Simulates human memory consolidation during sleep * * Research basis: + * - Pareto principle (20/80 rule) for memory tiering + * - ACT-R memory model for retrieval-based importance * - Ebbinghaus forgetting curve for decay - * - FadeMem importance-weighted retention - * - Graphiti/Zep edge deduplication patterns + * - MemGPT/Letta for tiered memory architecture */ export async function runSleepCycle( db: Neo4jMemoryClient, @@ -440,7 +469,7 @@ export async function runSleepCycle( agentId, abortSignal, dedupThreshold = 0.95, - promotionImportanceThreshold = 0.9, + paretoPercentile = 0.2, promotionMinAgeDays = 7, decayRetentionThreshold = 0.1, decayBaseHalfLifeDays = 30, @@ -453,7 +482,9 @@ export async function runSleepCycle( const result: SleepCycleResult = { dedup: { clustersFound: 0, memoriesMerged: 0 }, + pareto: { totalMemories: 0, coreMemories: 0, regularMemories: 0, threshold: 0 }, promotion: { candidatesFound: 0, promoted: 0 }, + demotion: { candidatesFound: 0, demoted: 0 }, decay: { memoriesPruned: 0 }, extraction: { total: 0, processed: 0, succeeded: 0, failed: 0 }, cleanup: { entitiesRemoved: 0, tagsRemoved: 0 }, @@ -494,15 +525,50 @@ export async function runSleepCycle( } // -------------------------------------------------------------------------- - // Phase 2: Core Promotion + // Phase 2: Pareto Scoring & Threshold Calculation // -------------------------------------------------------------------------- + let paretoThreshold = 0; if (!abortSignal?.aborted) { + onPhaseStart?.("pareto"); + logger.info("memory-neo4j: [sleep] Phase 2: Pareto Scoring"); + + try { + const allScores = await db.calculateAllEffectiveScores(agentId); + result.pareto.totalMemories = allScores.length; + result.pareto.coreMemories = allScores.filter((s) => s.category === "core").length; + result.pareto.regularMemories = allScores.filter((s) => s.category !== "core").length; + + // Calculate the threshold for top N% (default: top 20%) + paretoThreshold = db.calculateParetoThreshold(allScores, 1 - paretoPercentile); + result.pareto.threshold = paretoThreshold; + + onProgress?.( + "pareto", + `Scored ${allScores.length} memories (${result.pareto.coreMemories} core, ${result.pareto.regularMemories} regular)`, + ); + onProgress?.( + "pareto", + `Pareto threshold (top ${paretoPercentile * 100}%): ${paretoThreshold.toFixed(4)}`, + ); + + logger.info( + `memory-neo4j: [sleep] Phase 2 complete — threshold=${paretoThreshold.toFixed(4)} for top ${paretoPercentile * 100}%`, + ); + } catch (err) { + logger.warn(`memory-neo4j: [sleep] Phase 2 error: ${String(err)}`); + } + } + + // -------------------------------------------------------------------------- + // Phase 3: Core Promotion (regular memories above threshold) + // -------------------------------------------------------------------------- + if (!abortSignal?.aborted && paretoThreshold > 0) { onPhaseStart?.("promotion"); - logger.info("memory-neo4j: [sleep] Phase 2: Core Promotion"); + logger.info("memory-neo4j: [sleep] Phase 3: Core Promotion"); try { const candidates = await db.findPromotionCandidates({ - importanceThreshold: promotionImportanceThreshold, + paretoThreshold, minAgeDays: promotionMinAgeDays, agentId, }); @@ -512,24 +578,60 @@ export async function runSleepCycle( const ids = candidates.map((m) => m.id); result.promotion.promoted = await db.promoteToCore(ids); for (const c of candidates) { - onProgress?.("promotion", `Promoted "${c.text.slice(0, 50)}..." to core`); + onProgress?.( + "promotion", + `Promoted "${c.text.slice(0, 40)}..." (score=${c.effectiveScore.toFixed(3)}, ${c.retrievalCount} retrievals)`, + ); } } logger.info( - `memory-neo4j: [sleep] Phase 2 complete — ${result.promotion.promoted} memories promoted to core`, + `memory-neo4j: [sleep] Phase 3 complete — ${result.promotion.promoted} memories promoted to core`, ); } catch (err) { - logger.warn(`memory-neo4j: [sleep] Phase 2 error: ${String(err)}`); + logger.warn(`memory-neo4j: [sleep] Phase 3 error: ${String(err)}`); } } // -------------------------------------------------------------------------- - // Phase 3: Decay & Pruning + // Phase 4: Core Demotion (core memories fallen below threshold) + // -------------------------------------------------------------------------- + if (!abortSignal?.aborted && paretoThreshold > 0) { + onPhaseStart?.("demotion"); + logger.info("memory-neo4j: [sleep] Phase 4: Core Demotion"); + + try { + const candidates = await db.findDemotionCandidates({ + paretoThreshold, + agentId, + }); + result.demotion.candidatesFound = candidates.length; + + if (candidates.length > 0) { + const ids = candidates.map((m) => m.id); + result.demotion.demoted = await db.demoteFromCore(ids); + for (const c of candidates) { + onProgress?.( + "demotion", + `Demoted "${c.text.slice(0, 40)}..." (score=${c.effectiveScore.toFixed(3)}, ${c.retrievalCount} retrievals)`, + ); + } + } + + logger.info( + `memory-neo4j: [sleep] Phase 4 complete — ${result.demotion.demoted} memories demoted from core`, + ); + } catch (err) { + logger.warn(`memory-neo4j: [sleep] Phase 4 error: ${String(err)}`); + } + } + + // -------------------------------------------------------------------------- + // Phase 5: Decay & Pruning // -------------------------------------------------------------------------- if (!abortSignal?.aborted) { onPhaseStart?.("decay"); - logger.info("memory-neo4j: [sleep] Phase 3: Decay & Pruning"); + logger.info("memory-neo4j: [sleep] Phase 5: Decay & Pruning"); try { const decayed = await db.findDecayedMemories({ @@ -546,19 +648,19 @@ export async function runSleepCycle( } logger.info( - `memory-neo4j: [sleep] Phase 3 complete — ${result.decay.memoriesPruned} memories pruned`, + `memory-neo4j: [sleep] Phase 5 complete — ${result.decay.memoriesPruned} memories pruned`, ); } catch (err) { - logger.warn(`memory-neo4j: [sleep] Phase 3 error: ${String(err)}`); + logger.warn(`memory-neo4j: [sleep] Phase 5 error: ${String(err)}`); } } // -------------------------------------------------------------------------- - // Phase 4: Entity Extraction + // Phase 6: Entity Extraction // -------------------------------------------------------------------------- if (!abortSignal?.aborted && config.enabled) { onPhaseStart?.("extraction"); - logger.info("memory-neo4j: [sleep] Phase 4: Entity Extraction"); + logger.info("memory-neo4j: [sleep] Phase 6: Entity Extraction"); try { // Get initial count @@ -608,21 +710,21 @@ export async function runSleepCycle( } logger.info( - `memory-neo4j: [sleep] Phase 4 complete — ${result.extraction.succeeded} extracted, ${result.extraction.failed} failed`, + `memory-neo4j: [sleep] Phase 6 complete — ${result.extraction.succeeded} extracted, ${result.extraction.failed} failed`, ); } catch (err) { - logger.warn(`memory-neo4j: [sleep] Phase 4 error: ${String(err)}`); + logger.warn(`memory-neo4j: [sleep] Phase 6 error: ${String(err)}`); } } else if (!config.enabled) { - logger.info("memory-neo4j: [sleep] Phase 4 skipped — extraction not enabled"); + logger.info("memory-neo4j: [sleep] Phase 6 skipped — extraction not enabled"); } // -------------------------------------------------------------------------- - // Phase 5: Orphan Cleanup + // Phase 7: Orphan Cleanup // -------------------------------------------------------------------------- if (!abortSignal?.aborted) { onPhaseStart?.("cleanup"); - logger.info("memory-neo4j: [sleep] Phase 5: Orphan Cleanup"); + logger.info("memory-neo4j: [sleep] Phase 7: Orphan Cleanup"); try { // Clean up orphan entities @@ -642,10 +744,10 @@ export async function runSleepCycle( } logger.info( - `memory-neo4j: [sleep] Phase 5 complete — ${result.cleanup.entitiesRemoved} entities, ${result.cleanup.tagsRemoved} tags removed`, + `memory-neo4j: [sleep] Phase 7 complete — ${result.cleanup.entitiesRemoved} entities, ${result.cleanup.tagsRemoved} tags removed`, ); } catch (err) { - logger.warn(`memory-neo4j: [sleep] Phase 5 error: ${String(err)}`); + logger.warn(`memory-neo4j: [sleep] Phase 7 error: ${String(err)}`); } } diff --git a/extensions/memory-neo4j/index.ts b/extensions/memory-neo4j/index.ts index 121a837421b..a6671b47063 100644 --- a/extensions/memory-neo4j/index.ts +++ b/extensions/memory-neo4j/index.ts @@ -464,14 +464,11 @@ const memoryNeo4jPlugin = { memory .command("sleep") .description( - "Run sleep cycle — consolidate memories (dedup → promote → decay → extract → cleanup)", + "Run sleep cycle — consolidate memories with Pareto-based promotion/demotion", ) .option("--agent ", "Agent id (default: all agents)") .option("--dedup-threshold ", "Vector similarity threshold for dedup (default: 0.95)") - .option( - "--promotion-threshold ", - "Min importance for auto-promotion to core (default: 0.9)", - ) + .option("--pareto ", "Top N% for core memory (default: 0.2 = top 20%)") .option("--promotion-min-age ", "Min age in days before promotion (default: 7)") .option("--decay-threshold ", "Decay score threshold for pruning (default: 0.1)") .option("--decay-half-life ", "Base half-life in days (default: 30)") @@ -481,7 +478,7 @@ const memoryNeo4jPlugin = { async (opts: { agent?: string; dedupThreshold?: string; - promotionThreshold?: string; + pareto?: string; promotionMinAge?: string; decayThreshold?: string; decayHalfLife?: string; @@ -490,12 +487,16 @@ const memoryNeo4jPlugin = { }) => { console.log("\n🌙 Memory Sleep Cycle"); console.log("═════════════════════════════════════════════════════════════"); - console.log("Five-phase memory consolidation (like human sleep):\n"); + console.log("Seven-phase memory consolidation (Pareto-based):\n"); console.log(" Phase 1: Deduplication — Merge near-duplicate memories"); - console.log(" Phase 2: Core Promotion — Promote high-importance to core"); - console.log(" Phase 3: Decay & Pruning — Remove stale low-importance memories"); - console.log(" Phase 4: Extraction — Form entity relationships"); - console.log(" Phase 5: Orphan Cleanup — Remove disconnected nodes\n"); + console.log( + " Phase 2: Pareto Scoring — Calculate effective scores for all memories", + ); + console.log(" Phase 3: Core Promotion — Regular memories above threshold → core"); + console.log(" Phase 4: Core Demotion — Core memories below threshold → regular"); + console.log(" Phase 5: Decay & Pruning — Remove stale low-importance memories"); + console.log(" Phase 6: Extraction — Form entity relationships"); + console.log(" Phase 7: Orphan Cleanup — Remove disconnected nodes\n"); try { await db.ensureInitialized(); @@ -503,9 +504,7 @@ const memoryNeo4jPlugin = { const result = await runSleepCycle(db, embeddings, extractionConfig, api.logger, { agentId: opts.agent, dedupThreshold: opts.dedupThreshold ? parseFloat(opts.dedupThreshold) : undefined, - promotionImportanceThreshold: opts.promotionThreshold - ? parseFloat(opts.promotionThreshold) - : undefined, + paretoPercentile: opts.pareto ? parseFloat(opts.pareto) : undefined, promotionMinAgeDays: opts.promotionMinAge ? parseInt(opts.promotionMinAge, 10) : undefined, @@ -520,10 +519,12 @@ const memoryNeo4jPlugin = { onPhaseStart: (phase) => { const phaseNames = { dedup: "Phase 1: Deduplication", - promotion: "Phase 2: Core Promotion", - decay: "Phase 3: Decay & Pruning", - extraction: "Phase 4: Extraction", - cleanup: "Phase 5: Orphan Cleanup", + pareto: "Phase 2: Pareto Scoring", + promotion: "Phase 3: Core Promotion", + demotion: "Phase 4: Core Demotion", + decay: "Phase 5: Decay & Pruning", + extraction: "Phase 6: Extraction", + cleanup: "Phase 7: Orphan Cleanup", }; console.log(`\n▶ ${phaseNames[phase]}`); console.log("─────────────────────────────────────────────────────────────"); @@ -539,9 +540,18 @@ const memoryNeo4jPlugin = { console.log( ` Deduplication: ${result.dedup.clustersFound} clusters → ${result.dedup.memoriesMerged} merged`, ); + console.log( + ` Pareto: ${result.pareto.totalMemories} total (${result.pareto.coreMemories} core, ${result.pareto.regularMemories} regular)`, + ); + console.log( + ` Threshold: ${result.pareto.threshold.toFixed(4)} (top 20%)`, + ); console.log( ` Promotion: ${result.promotion.promoted}/${result.promotion.candidatesFound} promoted to core`, ); + console.log( + ` Demotion: ${result.demotion.demoted}/${result.demotion.candidatesFound} demoted from core`, + ); console.log(` Decay/Pruning: ${result.decay.memoriesPruned} memories pruned`); console.log( ` Extraction: ${result.extraction.succeeded}/${result.extraction.total} extracted` + diff --git a/extensions/memory-neo4j/neo4j-client.ts b/extensions/memory-neo4j/neo4j-client.ts index 613d2fcc6d8..2b8daf0e2fa 100644 --- a/extensions/memory-neo4j/neo4j-client.ts +++ b/extensions/memory-neo4j/neo4j-client.ts @@ -148,6 +148,10 @@ export class Neo4jMemoryClient { session, "CREATE INDEX memory_created_index IF NOT EXISTS FOR (m:Memory) ON (m.createdAt)", ); + await this.runSafe( + session, + "CREATE INDEX memory_retrieved_index IF NOT EXISTS FOR (m:Memory) ON (m.lastRetrievedAt)", + ); await this.runSafe( session, "CREATE INDEX entity_type_index IF NOT EXISTS FOR (e:Entity) ON (e.type)", @@ -216,7 +220,8 @@ export class Neo4jMemoryClient { importance: $importance, category: $category, source: $source, extractionStatus: $extractionStatus, agentId: $agentId, sessionKey: $sessionKey, - createdAt: $createdAt, updatedAt: $updatedAt + createdAt: $createdAt, updatedAt: $updatedAt, + retrievalCount: $retrievalCount, lastRetrievedAt: $lastRetrievedAt }) RETURN m.id AS id`, { @@ -224,6 +229,8 @@ export class Neo4jMemoryClient { sessionKey: input.sessionKey ?? null, createdAt: now, updatedAt: now, + retrievalCount: 0, + lastRetrievedAt: null, }, ); return result.records[0].get("id") as string; @@ -588,6 +595,82 @@ export class Neo4jMemoryClient { } } + // -------------------------------------------------------------------------- + // Retrieval Tracking + // -------------------------------------------------------------------------- + + /** + * Record retrieval events for memories. Called after search/recall. + * Increments retrievalCount and updates lastRetrievedAt timestamp. + */ + async recordRetrievals(memoryIds: string[]): Promise { + if (memoryIds.length === 0) { + return; + } + + await this.ensureInitialized(); + const session = this.driver!.session(); + try { + await session.run( + `UNWIND $ids AS memId + MATCH (m:Memory {id: memId}) + SET m.retrievalCount = coalesce(m.retrievalCount, 0) + 1, + m.lastRetrievedAt = $now`, + { ids: memoryIds, now: new Date().toISOString() }, + ); + } finally { + await session.close(); + } + } + + /** + * Calculate effective importance using retrieval-based reinforcement. + * + * Two modes: + * 1. With importance (regular memories): importance × freq_boost × recency + * 2. Without importance (core memories): freq_boost × recency + * + * Research basis: + * - ACT-R memory model (frequency with power-law decay) + * - FSRS spaced repetition (stability/retrievability) + * - Ebbinghaus forgetting curve (exponential decay) + */ + calculateEffectiveImportance( + retrievalCount: number, + daysSinceLastRetrieval: number | null, + options: { + baseImportance?: number; // Include importance multiplier (for regular memories) + frequencyScale?: number; // How much retrievals boost importance (default: 0.3) + recencyHalfLifeDays?: number; // Half-life for recency decay (default: 14) + } = {}, + ): number { + const { baseImportance, frequencyScale = 0.3, recencyHalfLifeDays = 14 } = options; + + // Frequency boost: log(1 + n) provides diminishing returns + // log(1+0)=0, log(1+1)≈0.69, log(1+10)≈2.4, log(1+100)≈4.6 + const frequencyBoost = 1 + Math.log1p(retrievalCount) * frequencyScale; + + // Recency factor: exponential decay with configurable half-life + // If never retrieved (null), use a baseline factor + let recencyFactor: number; + if (daysSinceLastRetrieval === null) { + recencyFactor = 0.1; // Never retrieved - low baseline + } else { + recencyFactor = Math.pow(2, -daysSinceLastRetrieval / recencyHalfLifeDays); + } + + // Combined effective importance + const usageScore = frequencyBoost * recencyFactor; + + // Include importance multiplier if provided (for regular memories) + if (baseImportance !== undefined) { + return baseImportance * usageScore; + } + + // Pure usage-based (for core memories) + return usageScore; + } + // -------------------------------------------------------------------------- // Entity & Relationship Operations // -------------------------------------------------------------------------- @@ -1172,20 +1255,101 @@ export class Neo4jMemoryClient { // -------------------------------------------------------------------------- /** - * Find memories that should be promoted to core status. - * Candidates: high importance (≥ threshold), not already core, aged at least minAgeDays. + * Calculate effective scores for all memories to determine Pareto threshold. + * + * Uses: importance × freq_boost × recency for ALL memories (including core). + * This gives core memories a slight disadvantage (they need strong retrieval + * patterns to stay in top 20%), creating healthy churn. */ - async findPromotionCandidates( - options: { - importanceThreshold?: number; // Minimum importance to promote (default: 0.9) - minAgeDays?: number; // Minimum age in days (default: 7) - agentId?: string; - limit?: number; - } = {}, - ): Promise< - Array<{ id: string; text: string; category: string; importance: number; ageDays: number }> + async calculateAllEffectiveScores( + agentId?: string, + ): Promise> { + await this.ensureInitialized(); + const session = this.driver!.session(); + try { + const agentFilter = agentId ? "WHERE m.agentId = $agentId" : ""; + const result = await session.run( + `MATCH (m:Memory) + ${agentFilter} + WITH m, + coalesce(m.retrievalCount, 0) AS retrievalCount, + CASE + WHEN m.lastRetrievedAt IS NULL THEN null + ELSE duration.between(datetime(m.lastRetrievedAt), datetime()).days + END AS daysSinceRetrieval + WITH m, retrievalCount, daysSinceRetrieval, + // Effective score: importance × freq_boost × recency + // This is used for global ranking (promotion/demotion threshold) + m.importance * (1 + log(1 + retrievalCount) * 0.3) * + CASE + WHEN daysSinceRetrieval IS NULL THEN 0.1 + ELSE 2.0 ^ (-1.0 * daysSinceRetrieval / 14.0) + END AS effectiveScore + RETURN m.id AS id, m.category AS category, effectiveScore + ORDER BY effectiveScore DESC`, + agentId ? { agentId } : {}, + ); + + return result.records.map((r) => ({ + id: r.get("id") as string, + category: r.get("category") as string, + effectiveScore: r.get("effectiveScore") as number, + })); + } finally { + await session.close(); + } + } + + /** + * Calculate the Pareto threshold (80th percentile) for promotion/demotion. + * Returns the effective score that separates top 20% from bottom 80%. + */ + calculateParetoThreshold( + scores: Array<{ effectiveScore: number }>, + percentile: number = 0.8, + ): number { + if (scores.length === 0) { + return 0; + } + + // Scores should already be sorted descending, but ensure it + const sorted = scores.toSorted((a, b) => b.effectiveScore - a.effectiveScore); + + // Find the index at the percentile boundary + // For top 20%, we want the score at index = 20% of total + const topPercent = 1 - percentile; // 0.2 for top 20% + const boundaryIndex = Math.floor(sorted.length * topPercent); + + // Return the score at that boundary (or 0 if empty) + return sorted[boundaryIndex]?.effectiveScore ?? 0; + } + + /** + * Find regular memories that should be promoted to core (above Pareto threshold). + * + * Pareto-based promotion: + * - Calculate effective score for all memories: importance × freq × recency + * - Find the 80th percentile threshold (top 20%) + * - Regular memories above threshold get promoted to core + * - Also requires minimum age (default: 7 days) to ensure stability + */ + async findPromotionCandidates(options: { + paretoThreshold: number; // The calculated Pareto threshold + minAgeDays?: number; // Minimum age in days (default: 7) + agentId?: string; + limit?: number; + }): Promise< + Array<{ + id: string; + text: string; + category: string; + importance: number; + ageDays: number; + retrievalCount: number; + effectiveScore: number; + }> > { - const { importanceThreshold = 0.9, minAgeDays = 7, agentId, limit = 50 } = options; + const { paretoThreshold, minAgeDays = 7, agentId, limit = 100 } = options; await this.ensureInitialized(); const session = this.driver!.session(); @@ -1193,18 +1357,31 @@ export class Neo4jMemoryClient { const agentFilter = agentId ? "AND m.agentId = $agentId" : ""; const result = await session.run( `MATCH (m:Memory) - WHERE m.importance >= $threshold - AND m.category <> 'core' + WHERE m.category <> 'core' AND m.createdAt IS NOT NULL ${agentFilter} - WITH m, duration.between(datetime(m.createdAt), datetime()).days AS ageDays + WITH m, + duration.between(datetime(m.createdAt), datetime()).days AS ageDays, + coalesce(m.retrievalCount, 0) AS retrievalCount, + CASE + WHEN m.lastRetrievedAt IS NULL THEN null + ELSE duration.between(datetime(m.lastRetrievedAt), datetime()).days + END AS daysSinceRetrieval WHERE ageDays >= $minAgeDays + WITH m, ageDays, retrievalCount, daysSinceRetrieval, + // Effective score: importance × freq_boost × recency + m.importance * (1 + log(1 + retrievalCount) * 0.3) * + CASE + WHEN daysSinceRetrieval IS NULL THEN 0.1 + ELSE 2.0 ^ (-1.0 * daysSinceRetrieval / 14.0) + END AS effectiveScore + WHERE effectiveScore >= $threshold RETURN m.id AS id, m.text AS text, m.category AS category, - m.importance AS importance, ageDays - ORDER BY m.importance DESC + m.importance AS importance, ageDays, retrievalCount, effectiveScore + ORDER BY effectiveScore DESC LIMIT $limit`, { - threshold: importanceThreshold, + threshold: paretoThreshold, minAgeDays, agentId, limit: neo4j.int(limit), @@ -1217,6 +1394,76 @@ export class Neo4jMemoryClient { category: r.get("category") as string, importance: r.get("importance") as number, ageDays: r.get("ageDays") as number, + retrievalCount: r.get("retrievalCount") as number, + effectiveScore: r.get("effectiveScore") as number, + })); + } finally { + await session.close(); + } + } + + /** + * Find core memories that should be demoted (fallen below Pareto threshold). + * + * Core memories use the same formula for threshold comparison: + * importance × freq × recency + * + * If they fall below the top 20% threshold, they get demoted back to regular. + */ + async findDemotionCandidates(options: { + paretoThreshold: number; // The calculated Pareto threshold + agentId?: string; + limit?: number; + }): Promise< + Array<{ + id: string; + text: string; + importance: number; + retrievalCount: number; + effectiveScore: number; + }> + > { + const { paretoThreshold, agentId, limit = 100 } = options; + + await this.ensureInitialized(); + const session = this.driver!.session(); + try { + const agentFilter = agentId ? "AND m.agentId = $agentId" : ""; + const result = await session.run( + `MATCH (m:Memory) + WHERE m.category = 'core' + ${agentFilter} + WITH m, + coalesce(m.retrievalCount, 0) AS retrievalCount, + CASE + WHEN m.lastRetrievedAt IS NULL THEN null + ELSE duration.between(datetime(m.lastRetrievedAt), datetime()).days + END AS daysSinceRetrieval + WITH m, retrievalCount, daysSinceRetrieval, + // Effective score: importance × freq_boost × recency + m.importance * (1 + log(1 + retrievalCount) * 0.3) * + CASE + WHEN daysSinceRetrieval IS NULL THEN 0.1 + ELSE 2.0 ^ (-1.0 * daysSinceRetrieval / 14.0) + END AS effectiveScore + WHERE effectiveScore < $threshold + RETURN m.id AS id, m.text AS text, m.importance AS importance, + retrievalCount, effectiveScore + ORDER BY effectiveScore ASC + LIMIT $limit`, + { + threshold: paretoThreshold, + agentId, + limit: neo4j.int(limit), + }, + ); + + return result.records.map((r) => ({ + id: r.get("id") as string, + text: r.get("text") as string, + importance: r.get("importance") as number, + retrievalCount: r.get("retrievalCount") as number, + effectiveScore: r.get("effectiveScore") as number, })); } finally { await session.close(); @@ -1237,7 +1484,7 @@ export class Neo4jMemoryClient { const result = await session.run( `UNWIND $ids AS memId MATCH (m:Memory {id: memId}) - SET m.category = 'core', m.updatedAt = $now + SET m.category = 'core', m.promotedAt = $now, m.updatedAt = $now RETURN count(*) AS promoted`, { ids: memoryIds, now: new Date().toISOString() }, ); @@ -1248,6 +1495,33 @@ export class Neo4jMemoryClient { } } + /** + * Demote memories from core back to their original category. + * Uses 'fact' as default since we don't track original category. + */ + async demoteFromCore(memoryIds: string[]): Promise { + if (memoryIds.length === 0) { + return 0; + } + + await this.ensureInitialized(); + const session = this.driver!.session(); + try { + const result = await session.run( + `UNWIND $ids AS memId + MATCH (m:Memory {id: memId}) + WHERE m.category = 'core' + SET m.category = 'fact', m.demotedAt = $now, m.updatedAt = $now + RETURN count(*) AS demoted`, + { ids: memoryIds, now: new Date().toISOString() }, + ); + + return (result.records[0]?.get("demoted") as number) ?? 0; + } finally { + await session.close(); + } + } + // -------------------------------------------------------------------------- // Retry Logic // -------------------------------------------------------------------------- diff --git a/extensions/memory-neo4j/search.ts b/extensions/memory-neo4j/search.ts index 22f34874f03..db0a6b22acd 100644 --- a/extensions/memory-neo4j/search.ts +++ b/extensions/memory-neo4j/search.ts @@ -246,7 +246,7 @@ export async function hybridSearch( const maxRrf = fused.length > 0 ? fused[0].rrfScore : 1; const normalizer = maxRrf > 0 ? 1 / maxRrf : 1; - return fused.slice(0, limit).map((r) => ({ + const results = fused.slice(0, limit).map((r) => ({ id: r.id, text: r.text, category: r.category, @@ -254,4 +254,16 @@ export async function hybridSearch( createdAt: r.createdAt, score: Math.min(1, r.rrfScore * normalizer), // Normalize to 0-1 })); + + // 6. Record retrieval events (fire-and-forget for latency) + // This tracks which memories are actually being used, enabling + // retrieval-based importance adjustment and promotion criteria. + if (results.length > 0) { + const memoryIds = results.map((r) => r.id); + db.recordRetrievals(memoryIds).catch(() => { + // Silently ignore - retrieval tracking is non-critical + }); + } + + return results; }