diff --git a/index.ts b/index.ts index f6e202dc..95402c47 100644 --- a/index.ts +++ b/index.ts @@ -58,6 +58,7 @@ import { SmartExtractor, createExtractionRateLimiter } from "./src/smart-extract import { compressTexts, estimateConversationValue } from "./src/session-compressor.js"; import { NoisePrototypeBank } from "./src/noise-prototypes.js"; import { createLlmClient } from "./src/llm-client.js"; +import { createDreamingEngine, type DreamingEngine, type DreamingConfig } from "./src/dreaming-engine.js"; import { createDecayEngine, DEFAULT_DECAY_CONFIG } from "./src/decay-engine.js"; import { createTierManager, DEFAULT_TIER_CONFIG } from "./src/tier-manager.js"; import { createMemoryUpgrader } from "./src/memory-upgrader.js"; @@ -225,6 +226,7 @@ interface PluginConfig { skipLowValue?: boolean; maxExtractionsPerHour?: number; }; + dreaming?: DreamingConfig; } type ReflectionThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; @@ -254,7 +256,9 @@ function resolveEnvVars(value: string): string { return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { const envValue = process.env[envVar]; if (!envValue) { - throw new Error(`Environment variable ${envVar} is not set`); + // Return empty string instead of throwing — the feature using this value + // will simply be unavailable (e.g., reranking disabled). + return ''; } return envValue; }); @@ -3606,6 +3610,7 @@ const memoryLanceDBProPlugin = { // ======================================================================== let backupTimer: ReturnType | null = null; + let dreamingTimer: ReturnType | null = null; const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours async function runBackup() { @@ -3749,12 +3754,70 @@ const memoryLanceDBProPlugin = { // Run initial backup after a short delay, then schedule daily setTimeout(() => void runBackup(), 60_000); // 1 min after start backupTimer = setInterval(() => void runBackup(), BACKUP_INTERVAL_MS); + + // ======================================================================== + // Dreaming Engine + // ======================================================================== + let dreamingEngine: DreamingEngine | null = null; + dreamingTimer = null; + + const dreamingConfig = config.dreaming as DreamingConfig | undefined; + if (dreamingConfig?.enabled) { + try { + dreamingEngine = createDreamingEngine({ + store, + decayEngine, + tierManager, + config: dreamingConfig, + log: (msg: string) => api.logger.info(msg), + debugLog: (msg: string) => api.logger.debug(msg), + }); + + // Run first dreaming cycle after 5 minutes, then every 6 hours + const DREAMING_INTERVAL_MS = 6 * 60 * 60 * 1000; + setTimeout(async () => { + try { + const report = await dreamingEngine!.run(); + api.logger.info( + `memory-lancedb-pro: dreaming cycle complete — ` + + `light: ${report.phases.light.scanned} scanned, ${report.phases.light.transitions.length} transitions; ` + + `deep: ${report.phases.deep.promoted}/${report.phases.deep.candidates} promoted; ` + + `rem: ${report.phases.rem.patterns.length} patterns, ${report.phases.rem.reflectionsCreated} reflections`, + ); + } catch (err) { + api.logger.warn(`memory-lancedb-pro: dreaming cycle failed: ${String(err)}`); + } + }, 5 * 60 * 1000); + + dreamingTimer = setInterval(async () => { + try { + const report = await dreamingEngine!.run(); + api.logger.info( + `memory-lancedb-pro: dreaming cycle complete — ` + + `light: ${report.phases.light.scanned} scanned, ${report.phases.light.transitions.length} transitions; ` + + `deep: ${report.phases.deep.promoted}/${report.phases.deep.candidates} promoted; ` + + `rem: ${report.phases.rem.patterns.length} patterns, ${report.phases.rem.reflectionsCreated} reflections`, + ); + } catch (err) { + api.logger.warn(`memory-lancedb-pro: dreaming cycle failed: ${String(err)}`); + } + }, DREAMING_INTERVAL_MS); + + api.logger.info("memory-lancedb-pro: dreaming engine initialized (interval: 6h)"); + } catch (err) { + api.logger.warn(`memory-lancedb-pro: dreaming init failed: ${String(err)}`); + } + } }, stop: async () => { if (backupTimer) { clearInterval(backupTimer); backupTimer = null; } + if (dreamingTimer) { + clearInterval(dreamingTimer); + dreamingTimer = null; + } api.logger.info("memory-lancedb-pro: stopped"); }, }); @@ -4040,6 +4103,7 @@ export function parsePluginConfig(value: unknown): PluginConfig { : 30, } : { skipLowValue: false, maxExtractionsPerHour: 30 }, + dreaming: cfg.dreaming as DreamingConfig | undefined, }; } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index bf274e03..73859298 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -853,6 +853,135 @@ "description": "Maximum number of auto-capture extractions allowed per hour" } } + }, + "dreaming": { + "type": "object", + "additionalProperties": false, + "description": "Dreaming: periodic memory consolidation and promotion from short-term to long-term storage. Bridges LanceDB Pro's tier-manager, decay-engine, and smart-extraction into OpenClaw's dreaming lifecycle.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dreaming memory consolidation cycles" + }, + "cron": { + "type": "string", + "default": "", + "description": "Custom cron expression for dreaming schedule. Leave empty for managed scheduling." + }, + "timezone": { + "type": "string", + "default": "UTC", + "description": "Timezone for cron scheduling (IANA format)" + }, + "storageMode": { + "type": "string", + "enum": ["inline", "separate", "both"], + "default": "inline", + "description": "How dream insights are stored: inline (metadata), separate (dedicated collection), or both" + }, + "separateReports": { + "type": "boolean", + "default": false, + "description": "Generate separate dream reports per phase" + }, + "verboseLogging": { + "type": "boolean", + "default": false, + "description": "Enable verbose logging for dreaming cycles" + }, + "phases": { + "type": "object", + "additionalProperties": false, + "description": "Dreaming phase configuration", + "properties": { + "light": { + "type": "object", + "additionalProperties": false, + "description": "Light phase: collect candidate memories for consolidation", + "properties": { + "lookbackDays": { + "type": "integer", + "minimum": 1, + "maximum": 365, + "default": 7, + "description": "How many days of recent memories to scan" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50, + "description": "Maximum candidate memories per light phase" + } + } + }, + "deep": { + "type": "object", + "additionalProperties": false, + "description": "Deep phase: evaluate and score memories for promotion using tier-manager and decay-engine", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Maximum memories to process per deep phase" + }, + "minScore": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.5, + "description": "Minimum composite score for promotion consideration" + }, + "minRecallCount": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "default": 2, + "description": "Minimum recall count for promotion" + }, + "recencyHalfLifeDays": { + "type": "integer", + "minimum": 1, + "maximum": 365, + "default": 14, + "description": "Recency weighting half-life for scoring" + } + } + }, + "rem": { + "type": "object", + "additionalProperties": false, + "description": "REM phase: theme reflection and pattern detection across promoted memories", + "properties": { + "lookbackDays": { + "type": "integer", + "minimum": 1, + "maximum": 365, + "default": 30, + "description": "Lookback window for pattern analysis" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10, + "description": "Maximum patterns to detect per REM phase" + }, + "minPatternStrength": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.6, + "description": "Minimum strength for detected patterns" + } + } + } + } + } + } } } }, @@ -1376,6 +1505,55 @@ "label": "Max Extractions Per Hour", "help": "Rate limit for auto-capture extractions. Prevents excessive LLM calls during rapid-fire sessions.", "advanced": true + }, + "dreaming.enabled": { + "label": "Enable Dreaming", + "help": "Enable periodic memory consolidation and promotion cycles using tier-manager and decay-engine" + }, + "dreaming.cron": { + "label": "Dreaming Cron", + "help": "Custom cron expression for dreaming schedule. Leave empty for managed scheduling.", + "advanced": true + }, + "dreaming.timezone": { + "label": "Dreaming Timezone", + "help": "IANA timezone for cron scheduling", + "advanced": true + }, + "dreaming.storageMode": { + "label": "Dream Storage Mode", + "help": "inline (metadata), separate (dedicated collection), or both", + "advanced": true + }, + "dreaming.verboseLogging": { + "label": "Verbose Dreaming", + "help": "Enable verbose logging for dreaming cycles", + "advanced": true + }, + "dreaming.phases.light.lookbackDays": { + "label": "Light Phase Lookback", + "help": "Days of recent memories to scan in light phase", + "advanced": true + }, + "dreaming.phases.light.limit": { + "label": "Light Phase Limit", + "help": "Max candidate memories per light phase", + "advanced": true + }, + "dreaming.phases.deep.minScore": { + "label": "Deep Phase Min Score", + "help": "Minimum composite score for promotion consideration", + "advanced": true + }, + "dreaming.phases.deep.minRecallCount": { + "label": "Deep Phase Min Recalls", + "help": "Minimum recall count for promotion", + "advanced": true + }, + "dreaming.phases.rem.minPatternStrength": { + "label": "REM Min Pattern Strength", + "help": "Minimum strength for detected patterns", + "advanced": true } } } diff --git a/src/dreaming-engine.ts b/src/dreaming-engine.ts new file mode 100644 index 00000000..065cd315 --- /dev/null +++ b/src/dreaming-engine.ts @@ -0,0 +1,319 @@ +/** + * Dreaming Engine — Periodic memory consolidation + * + * Three-phase process that runs on a schedule: + * 1. Light Sleep: Decay scoring + tier re-evaluation for recent memories + * 2. Deep Sleep: Promote frequently-recalled Working memories to Core + * 3. REM: Detect patterns and create reflection memories + */ + +import type { MemoryStore, MemoryEntry } from "./store.js"; + +/** Config for the dreaming engine — mirrors the plugin's dreaming config section */ +export interface DreamingConfig { + enabled: boolean; + cron: string; + timezone: string; + storageMode: "inline" | "separate" | "both"; + separateReports: boolean; + verboseLogging: boolean; + phases: { + light: { lookbackDays: number; limit: number }; + deep: { limit: number; minScore: number; minRecallCount: number; recencyHalfLifeDays: number }; + rem: { lookbackDays: number; limit: number; minPatternStrength: number }; + }; +} +import type { TierTransition, TierableMemory } from "./tier-manager.js"; +import type { DecayScore, DecayableMemory } from "./decay-engine.js"; +import type { MemoryTier } from "./memory-categories.js"; + +import { parseSmartMetadata } from "./smart-metadata.js"; + +// ── Report types ────────────────────────────────────────────────── + +export interface DreamingReport { + timestamp: number; + phases: { + light: { scanned: number; transitions: TierTransition[] }; + deep: { candidates: number; promoted: number }; + rem: { patterns: string[]; reflectionsCreated: number }; + }; +} + +export interface DreamingEngine { + run(): Promise; +} + +// ── Factory ─────────────────────────────────────────────────────── + +interface DreamingEngineParams { + store: MemoryStore; + decayEngine: { scoreAll(memories: DecayableMemory[], now: number): DecayScore[] }; + tierManager: { evaluateAll(memories: TierableMemory[], decayScores: DecayScore[], now: number): TierTransition[] }; + config: DreamingConfig; + log: (msg: string) => void; + debugLog: (msg: string) => void; + workspaceDir?: string; +} + +export function createDreamingEngine(params: DreamingEngineParams): DreamingEngine { + const { store, decayEngine, tierManager, config, log, debugLog } = params; + + const verbose = config.verboseLogging; + const dbg = verbose ? debugLog : () => {}; + + return { + async run(): Promise { + const now = Date.now(); + log("💤 Dreaming cycle started"); + + const report: DreamingReport = { + timestamp: now, + phases: { + light: { scanned: 0, transitions: [] }, + deep: { candidates: 0, promoted: 0 }, + rem: { patterns: [], reflectionsCreated: 0 }, + }, + }; + + // Phase 1: Light Sleep + try { + report.phases.light = await runLightSleep(now); + } catch (err) { + log(`⚠️ Light sleep failed: ${err}`); + } + + // Phase 2: Deep Sleep + try { + report.phases.deep = await runDeepSleep(now); + } catch (err) { + log(`⚠️ Deep sleep failed: ${err}`); + } + + // Phase 3: REM + try { + report.phases.rem = await runREM(now); + } catch (err) { + log(`⚠️ REM failed: ${err}`); + } + + log("☀️ Dreaming cycle complete"); + return report; + }, + }; + + // ── Phase 1: Light Sleep ──────────────────────────────────────── + + async function runLightSleep(now: number): Promise { + const { lookbackDays, limit } = config.phases.light; + const cutoff = now - lookbackDays * 86_400_000; + + dbg(`Light sleep: fetching memories newer than ${new Date(cutoff).toISOString()}`); + + // Fetch recent memories (may get more than we need, filter in-memory) + const entries = await store.list(undefined, undefined, limit * 2, 0); + const recent = entries.filter((e) => e.timestamp > cutoff).slice(0, limit); + + dbg(`Light sleep: ${recent.length} recent memories to evaluate`); + + if (recent.length === 0) { + return { scanned: 0, transitions: [] }; + } + + // Convert to decay/tier inputs via smart metadata + const decayable: DecayableMemory[] = []; + const tierable: TierableMemory[] = []; + + for (const entry of recent) { + const parsed = parseSmartMetadata(entry.metadata, entry); + const decayMem: DecayableMemory = { + id: entry.id, + importance: entry.importance, + confidence: parsed.confidence ?? 0.5, + tier: (parsed.tier as MemoryTier) ?? "working", + accessCount: parsed.access_count ?? 0, + createdAt: entry.timestamp, + lastAccessedAt: parsed.last_accessed_at ?? entry.timestamp, + temporalType: parsed.type === "static" || parsed.type === "dynamic" ? parsed.type : undefined, + }; + decayable.push(decayMem); + + tierable.push({ + id: entry.id, + tier: decayMem.tier, + importance: entry.importance, + accessCount: decayMem.accessCount, + createdAt: entry.timestamp, + }); + } + + // Score decay, then evaluate tier transitions + const decayScores = decayEngine.scoreAll(decayable, now); + const transitions = tierManager.evaluateAll(tierable, decayScores, now); + + dbg(`Light sleep: ${transitions.length} tier transitions proposed`); + + // Apply transitions + for (const t of transitions) { + await store.patchMetadata(t.memoryId, { + tier: t.toTier, + tier_updated_at: now, + }); + dbg(` ↕ ${t.memoryId}: ${t.fromTier} → ${t.toTier} (${t.reason})`); + } + + return { scanned: recent.length, transitions }; + } + + // ── Phase 2: Deep Sleep ───────────────────────────────────────── + + async function runDeepSleep(now: number): Promise { + const { limit, minScore, minRecallCount } = config.phases.deep; + + dbg("Deep sleep: fetching Working-tier memories"); + + // Fetch all memories and filter to working tier + const entries = await store.list(undefined, undefined, limit * 5, 0); + const working = entries.filter((e) => { + const parsed = parseSmartMetadata(e.metadata, e); + return parsed.tier === "working"; + }).slice(0, limit); + + if (working.length === 0) { + return { candidates: 0, promoted: 0 }; + } + + // Convert and score for decay + const decayable: DecayableMemory[] = working.map((e) => { + const parsed = parseSmartMetadata(e.metadata, e); + return { + id: e.id, + importance: e.importance, + confidence: parsed.confidence ?? 0.5, + tier: "working" as MemoryTier, + accessCount: parsed.access_count ?? 0, + createdAt: e.timestamp, + lastAccessedAt: parsed.last_accessed_at ?? e.timestamp, + }; + }); + + const scores = decayEngine.scoreAll(decayable, now); + const scoreMap = new Map(scores.map((s) => [s.memoryId, s])); + + // Promote memories that meet both thresholds + let promoted = 0; + for (const entry of working) { + const parsed = parseSmartMetadata(entry.metadata, entry); + const score = scoreMap.get(entry.id); + const accessCount = parsed.access_count ?? 0; + const composite = score?.composite ?? 0; + + if (composite >= minScore && accessCount >= minRecallCount) { + // Boost importance by 20% (capped at 1.0) + const newImportance = Math.min(1.0, entry.importance * 1.2); + await store.patchMetadata(entry.id, { + tier: "core", + tier_updated_at: now, + importance: newImportance, + }); + dbg(` ⬆ Deep sleep promoted: ${entry.id} (score=${composite.toFixed(3)}, accesses=${accessCount})`); + promoted++; + } + } + + return { candidates: working.length, promoted }; + } + + // ── Phase 3: REM ──────────────────────────────────────────────── + + async function runREM(now: number): Promise { + const { lookbackDays, limit, minPatternStrength } = config.phases.rem; + const cutoff = now - lookbackDays * 86_400_000; + + dbg("REM: analyzing memory patterns"); + + const entries = await store.list(undefined, undefined, limit, 0); + const recent = entries.filter((e) => e.timestamp > cutoff); + + if (recent.length < 5) { + // Not enough data for pattern detection + return { patterns: [], reflectionsCreated: 0 }; + } + + const patterns: string[] = []; + + // Analyze category frequency per tier + const tierCategoryMap = new Map>(); + const categoryTotal = new Map(); + + for (const entry of recent) { + const parsed = parseSmartMetadata(entry.metadata, entry); + const tier = parsed.tier ?? "working"; + const cat = entry.category; + + if (!tierCategoryMap.has(tier)) tierCategoryMap.set(tier, new Map()); + const catMap = tierCategoryMap.get(tier)!; + catMap.set(cat, (catMap.get(cat) ?? 0) + 1); + categoryTotal.set(cat, (categoryTotal.get(cat) ?? 0) + 1); + } + + // Detect categories that cluster disproportionately in high tiers + const highTiers: MemoryTier[] = ["core", "working"]; + for (const tier of highTiers) { + const catMap = tierCategoryMap.get(tier); + if (!catMap) continue; + + for (const [cat, count] of catMap) { + const total = categoryTotal.get(cat) ?? 0; + if (total < 3) continue; // Skip sparse categories + + const ratio = count / total; + if (ratio >= minPatternStrength) { + const pattern = `"${cat}" memories cluster in ${tier} tier (${Math.round(ratio * 100)}%)`; + patterns.push(pattern); + } + } + } + + // Detect high-importance categories + const importanceByCategory = new Map(); + for (const entry of recent) { + const arr = importanceByCategory.get(entry.category) ?? []; + arr.push(entry.importance); + importanceByCategory.set(entry.category, arr); + } + + for (const [cat, scores] of importanceByCategory) { + if (scores.length < 3) continue; + const avg = scores.reduce((a, b) => a + b, 0) / scores.length; + if (avg >= 0.8) { + patterns.push(`Category "${cat}" has consistently high importance (avg ${avg.toFixed(2)})`); + } + } + + // Create reflection memories for discovered patterns + let reflectionsCreated = 0; + if (patterns.length > 0) { + const reflectionText = `Dreaming reflection: ${patterns.join(". ")}. Generated from ${recent.length} memories analyzed.`; + + await store.store({ + text: reflectionText, + vector: [], // Non-searchable reflection; could embed later + category: "reflection", + scope: "global", + importance: 0.4, + metadata: JSON.stringify({ + dream_timestamp: now, + patterns_count: patterns.length, + memories_analyzed: recent.length, + source: "dreaming-engine", + }), + }); + reflectionsCreated = 1; + + dbg(`REM: created reflection memory with ${patterns.length} pattern(s)`); + } + + return { patterns, reflectionsCreated }; + } +}