memory-neo4j: add Pareto-based memory ecosystem with retrieval tracking

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 <noreply@anthropic.com>
This commit is contained in:
Tarun Sukhani
2026-02-04 17:05:56 +00:00
parent e65d1deedd
commit e7ac300b7e
4 changed files with 478 additions and 80 deletions

View File

@@ -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)}`);
}
}

View File

@@ -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 <id>", "Agent id (default: all agents)")
.option("--dedup-threshold <n>", "Vector similarity threshold for dedup (default: 0.95)")
.option(
"--promotion-threshold <n>",
"Min importance for auto-promotion to core (default: 0.9)",
)
.option("--pareto <n>", "Top N% for core memory (default: 0.2 = top 20%)")
.option("--promotion-min-age <days>", "Min age in days before promotion (default: 7)")
.option("--decay-threshold <n>", "Decay score threshold for pruning (default: 0.1)")
.option("--decay-half-life <days>", "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` +

View File

@@ -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<void> {
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<Array<{ id: string; category: string; effectiveScore: number }>> {
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<number> {
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
// --------------------------------------------------------------------------

View File

@@ -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;
}