mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
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:
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user