From c1371b639e3b8bb47b75050eec1b6ffa9694505b Mon Sep 17 00:00:00 2001 From: Tarun Sukhani Date: Thu, 5 Feb 2026 11:19:41 +0000 Subject: [PATCH] memory-neo4j: configurable extraction model + sleep cycle optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add extraction config section (apiKey, model, baseUrl) to plugin schema with env-var fallback and Ollama/local LLM support (no API key required) - Add category classification to extraction prompt; update memories from 'other' to LLM-assigned category - Reorder sleep phases: extraction before decay - Parallelize extraction (3 concurrent via Promise.allSettled) - Pre-compute effective scores once and reuse for promotion/demotion - Replace O(n²) Cartesian dedup with per-memory HNSW vector index queries - Use mentionCount for orphan entity detection instead of subquery - Remove dead auto-capture code (evaluateAutoCapture, CaptureItem, etc.) --- extensions/memory-neo4j/config.ts | 52 +++- extensions/memory-neo4j/extractor.ts | 274 +++++++------------ extensions/memory-neo4j/index.ts | 184 ++++++------- extensions/memory-neo4j/neo4j-client.ts | 134 ++++++--- extensions/memory-neo4j/openclaw.plugin.json | 31 +++ extensions/memory-neo4j/schema.ts | 15 +- 6 files changed, 345 insertions(+), 345 deletions(-) diff --git a/extensions/memory-neo4j/config.ts b/extensions/memory-neo4j/config.ts index 51e1486727c..4530539b7b0 100644 --- a/extensions/memory-neo4j/config.ts +++ b/extensions/memory-neo4j/config.ts @@ -19,6 +19,11 @@ export type MemoryNeo4jConfig = { model: string; baseUrl?: string; }; + extraction?: { + apiKey?: string; + model: string; + baseUrl: string; + }; autoCapture: boolean; autoRecall: boolean; coreMemory: { @@ -101,16 +106,26 @@ function resolveEnvVars(value: string): string { } /** - * Resolve extraction config from environment variables. - * Returns enabled: false if OPENROUTER_API_KEY is not set. + * Resolve extraction config from plugin config with env var fallback. + * Enabled when an API key is available (cloud) or a baseUrl is explicitly + * configured (Ollama / local LLMs that don't need a key). */ -export function resolveExtractionConfig(): ExtractionConfig { - const apiKey = process.env.OPENROUTER_API_KEY ?? ""; +export function resolveExtractionConfig( + cfgExtraction?: MemoryNeo4jConfig["extraction"], +): ExtractionConfig { + const apiKey = cfgExtraction?.apiKey ?? process.env.OPENROUTER_API_KEY ?? ""; + const model = + cfgExtraction?.model ?? process.env.EXTRACTION_MODEL ?? "google/gemini-2.0-flash-001"; + const baseUrl = + cfgExtraction?.baseUrl ?? process.env.EXTRACTION_BASE_URL ?? "https://openrouter.ai/api/v1"; + // Enabled when an API key is set (cloud provider) or baseUrl was explicitly + // configured in the plugin config (Ollama / local — no key needed). + const enabled = apiKey.length > 0 || cfgExtraction?.baseUrl != null; return { - enabled: apiKey.length > 0, + enabled, apiKey, - model: process.env.EXTRACTION_MODEL ?? "google/gemini-2.0-flash-001", - baseUrl: process.env.EXTRACTION_BASE_URL ?? "https://openrouter.ai/api/v1", + model, + baseUrl, temperature: 0.0, maxRetries: 2, }; @@ -136,7 +151,7 @@ export const memoryNeo4jConfigSchema = { const cfg = value as Record; assertAllowedKeys( cfg, - ["embedding", "neo4j", "autoCapture", "autoRecall", "coreMemory"], + ["embedding", "neo4j", "autoCapture", "autoRecall", "coreMemory", "extraction"], "memory-neo4j config", ); @@ -205,6 +220,26 @@ export const memoryNeo4jConfigSchema = { ? coreMemoryRaw.refreshAtContextPercent : undefined; + // Parse extraction section (optional — falls back to env vars in resolveExtractionConfig) + const extractionRaw = cfg.extraction as Record | undefined; + assertAllowedKeys(extractionRaw ?? {}, ["apiKey", "model", "baseUrl"], "extraction config"); + let extraction: MemoryNeo4jConfig["extraction"]; + if (extractionRaw) { + const exApiKey = + typeof extractionRaw.apiKey === "string" ? resolveEnvVars(extractionRaw.apiKey) : undefined; + const exModel = typeof extractionRaw.model === "string" ? extractionRaw.model : undefined; + const exBaseUrl = + typeof extractionRaw.baseUrl === "string" ? extractionRaw.baseUrl : undefined; + // Only include if at least one field was provided + if (exApiKey || exModel || exBaseUrl) { + extraction = { + apiKey: exApiKey, + model: exModel ?? (process.env.EXTRACTION_MODEL || "google/gemini-2.0-flash-001"), + baseUrl: exBaseUrl ?? (process.env.EXTRACTION_BASE_URL || "https://openrouter.ai/api/v1"), + }; + } + } + return { neo4j: { uri: neo4jRaw.uri, @@ -217,6 +252,7 @@ export const memoryNeo4jConfigSchema = { model: embeddingModel, baseUrl, }, + extraction, autoCapture: cfg.autoCapture !== false, autoRecall: cfg.autoRecall !== false, coreMemory: { diff --git a/extensions/memory-neo4j/extractor.ts b/extensions/memory-neo4j/extractor.ts index 01d3d26dfc0..6fdc9f5d99f 100644 --- a/extensions/memory-neo4j/extractor.ts +++ b/extensions/memory-neo4j/extractor.ts @@ -1,19 +1,19 @@ /** - * LLM-based entity extraction and auto-capture decision for memory-neo4j. + * LLM-based entity extraction and sleep cycle for memory-neo4j. * - * Uses Gemini Flash via OpenRouter for: - * 1. Entity extraction: Extract entities and relationships from stored memories - * 2. Auto-capture decision: Decide what's worth remembering from conversations + * Extraction uses a configurable OpenAI-compatible LLM (OpenRouter, Ollama, etc.) to: + * - Extract entities, relationships, and tags from stored memories + * - Classify memories into categories (preference, fact, decision, etc.) * - * Both run as background fire-and-forget operations with graceful degradation. + * Runs as background fire-and-forget operations with graceful degradation. */ import { randomUUID } from "node:crypto"; import type { ExtractionConfig } from "./config.js"; import type { Embeddings } from "./embeddings.js"; import type { Neo4jMemoryClient } from "./neo4j-client.js"; -import type { CaptureItem, EntityType, ExtractionResult, MemoryCategory } from "./schema.js"; -import { ALLOWED_RELATIONSHIP_TYPES, ENTITY_TYPES } from "./schema.js"; +import type { EntityType, ExtractionResult, MemoryCategory } from "./schema.js"; +import { ALLOWED_RELATIONSHIP_TYPES, ENTITY_TYPES, MEMORY_CATEGORIES } from "./schema.js"; // ============================================================================ // Types @@ -31,12 +31,13 @@ type Logger = { // ============================================================================ const ENTITY_EXTRACTION_PROMPT = `You are an entity extraction system for a personal memory store. -Extract entities and relationships from this memory text. +Extract entities and relationships from this memory text, and classify the memory. Memory: "{text}" Return JSON: { + "category": "preference|fact|decision|entity|other", "entities": [ {"name": "tarun", "type": "person", "aliases": ["boss"], "description": "brief description"} ], @@ -55,49 +56,8 @@ Rules: - Confidence: 0.0-1.0 - Only extract what's explicitly stated or strongly implied - Return empty arrays if nothing to extract -- Keep entity descriptions brief (1 sentence max)`; - -// ============================================================================ -// Auto-Capture Decision Prompt -// ============================================================================ - -const AUTO_CAPTURE_PROMPT = `You are an AI memory curator. Given these user messages from a conversation, identify information worth storing as long-term memories. - -Only extract: -- Personal preferences and opinions ("I prefer dark mode", "I like TypeScript") -- Important facts about people, places, organizations -- Decisions made ("We decided to use Neo4j", "Going with plan A") -- Contact information (emails, phone numbers, usernames) -- Important events or dates -- Technical decisions and configurations - -Do NOT extract: -- General questions or instructions to the AI -- Routine greetings or acknowledgments -- Information that is too vague or contextual -- Information already in system prompts or documentation - -Categories: -- "core": Foundational identity info that should ALWAYS be remembered (user's name, role, company, key relationships, critical preferences that define who they are). Use sparingly - only for truly foundational facts. -- "preference": User preferences and opinions -- "fact": Facts about people, places, things -- "decision": Decisions made -- "entity": Entity-focused memories -- "other": Miscellaneous - -Messages: -""" -{messages} -""" - -Return JSON: -{ - "memories": [ - {"text": "concise memory text", "category": "core|preference|fact|decision|entity|other", "importance": 0.7} - ] -} - -If nothing is worth remembering, return: {"memories": []}`; +- Keep entity descriptions brief (1 sentence max) +- Category: "preference" for opinions/preferences, "fact" for factual info, "decision" for choices made, "entity" for entity-focused, "other" for miscellaneous`; // ============================================================================ // OpenRouter API Client @@ -180,8 +140,13 @@ function validateExtractionResult(raw: Record): ExtractionResul const tags = Array.isArray(raw.tags) ? raw.tags : []; const validEntityTypes = new Set(ENTITY_TYPES); + const validCategories = new Set(MEMORY_CATEGORIES); + const rawCategory = typeof raw.category === "string" ? raw.category : undefined; + const category = + rawCategory && validCategories.has(rawCategory) ? (rawCategory as MemoryCategory) : undefined; return { + category, entities: entities .filter( (e: unknown): e is Record => @@ -332,10 +297,16 @@ export async function runBackgroundExtraction( } } + // Update category if the LLM classified it (only overwrites 'other') + if (result.category) { + await db.updateMemoryCategory(memoryId, result.category); + } + await db.updateExtractionStatus(memoryId, "complete"); logger.info( `memory-neo4j: extraction complete for ${memoryId.slice(0, 8)} — ` + - `${result.entities.length} entities, ${result.relationships.length} rels, ${result.tags.length} tags`, + `${result.entities.length} entities, ${result.relationships.length} rels, ${result.tags.length} tags` + + (result.category ? `, category=${result.category}` : ""), ); } catch (err) { logger.warn(`memory-neo4j: extraction failed for ${memoryId.slice(0, 8)}: ${String(err)}`); @@ -528,12 +499,13 @@ export async function runSleepCycle( // Phase 2: Pareto Scoring & Threshold Calculation // -------------------------------------------------------------------------- let paretoThreshold = 0; + let allScores: Awaited> = []; if (!abortSignal?.aborted) { onPhaseStart?.("pareto"); logger.info("memory-neo4j: [sleep] Phase 2: Pareto Scoring"); try { - const allScores = await db.calculateAllEffectiveScores(agentId); + 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; @@ -560,18 +532,19 @@ export async function runSleepCycle( } // -------------------------------------------------------------------------- - // Phase 3: Core Promotion (regular memories above threshold) + // Phase 3: Core Promotion (using pre-computed scores from Phase 2) // -------------------------------------------------------------------------- if (!abortSignal?.aborted && paretoThreshold > 0) { onPhaseStart?.("promotion"); logger.info("memory-neo4j: [sleep] Phase 3: Core Promotion"); try { - const candidates = await db.findPromotionCandidates({ - paretoThreshold, - minAgeDays: promotionMinAgeDays, - agentId, - }); + const candidates = allScores.filter( + (s) => + s.category !== "core" && + s.effectiveScore >= paretoThreshold && + s.ageDays >= promotionMinAgeDays, + ); result.promotion.candidatesFound = candidates.length; if (candidates.length > 0) { @@ -594,17 +567,16 @@ export async function runSleepCycle( } // -------------------------------------------------------------------------- - // Phase 4: Core Demotion (core memories fallen below threshold) + // Phase 4: Core Demotion (using pre-computed scores from Phase 2) // -------------------------------------------------------------------------- if (!abortSignal?.aborted && paretoThreshold > 0) { onPhaseStart?.("demotion"); logger.info("memory-neo4j: [sleep] Phase 4: Core Demotion"); try { - const candidates = await db.findDemotionCandidates({ - paretoThreshold, - agentId, - }); + const candidates = allScores.filter( + (s) => s.category === "core" && s.effectiveScore < paretoThreshold, + ); result.demotion.candidatesFound = candidates.length; if (candidates.length > 0) { @@ -627,40 +599,13 @@ export async function runSleepCycle( } // -------------------------------------------------------------------------- - // Phase 5: Decay & Pruning - // -------------------------------------------------------------------------- - if (!abortSignal?.aborted) { - onPhaseStart?.("decay"); - logger.info("memory-neo4j: [sleep] Phase 5: Decay & Pruning"); - - try { - const decayed = await db.findDecayedMemories({ - retentionThreshold: decayRetentionThreshold, - baseHalfLifeDays: decayBaseHalfLifeDays, - importanceMultiplier: decayImportanceMultiplier, - agentId, - }); - - if (decayed.length > 0) { - const ids = decayed.map((m) => m.id); - result.decay.memoriesPruned = await db.pruneMemories(ids); - onProgress?.("decay", `Pruned ${result.decay.memoriesPruned} decayed memories`); - } - - logger.info( - `memory-neo4j: [sleep] Phase 5 complete — ${result.decay.memoriesPruned} memories pruned`, - ); - } catch (err) { - logger.warn(`memory-neo4j: [sleep] Phase 5 error: ${String(err)}`); - } - } - - // -------------------------------------------------------------------------- - // Phase 6: Entity Extraction + // Phase 5: Entity Extraction (moved before decay so new memories get + // extracted before pruning can remove them) // -------------------------------------------------------------------------- + const EXTRACTION_CONCURRENCY = 3; if (!abortSignal?.aborted && config.enabled) { onPhaseStart?.("extraction"); - logger.info("memory-neo4j: [sleep] Phase 6: Entity Extraction"); + logger.info("memory-neo4j: [sleep] Phase 5: Entity Extraction"); try { // Get initial count @@ -677,24 +622,32 @@ export async function runSleepCycle( break; } - for (const memory of pending) { - if (abortSignal?.aborted) { - break; + // Process in parallel chunks of EXTRACTION_CONCURRENCY + for ( + let i = 0; + i < pending.length && !abortSignal?.aborted; + i += EXTRACTION_CONCURRENCY + ) { + const chunk = pending.slice(i, i + EXTRACTION_CONCURRENCY); + const outcomes = await Promise.allSettled( + chunk.map((memory) => + runBackgroundExtraction(memory.id, memory.text, db, embeddings, config, logger), + ), + ); + + for (const outcome of outcomes) { + result.extraction.processed++; + if (outcome.status === "fulfilled") { + result.extraction.succeeded++; + } else { + result.extraction.failed++; + } } - try { - await runBackgroundExtraction(memory.id, memory.text, db, embeddings, config, logger); - result.extraction.succeeded++; - } catch (err) { - logger.warn( - `memory-neo4j: extraction failed for ${memory.id.slice(0, 8)}: ${String(err)}`, - ); - result.extraction.failed++; - } - - result.extraction.processed++; - - if (result.extraction.processed % 10 === 0) { + if ( + result.extraction.processed % 10 === 0 || + i + EXTRACTION_CONCURRENCY >= pending.length + ) { onProgress?.( "extraction", `${result.extraction.processed}/${result.extraction.total} processed`, @@ -710,13 +663,43 @@ export async function runSleepCycle( } logger.info( - `memory-neo4j: [sleep] Phase 6 complete — ${result.extraction.succeeded} extracted, ${result.extraction.failed} failed`, + `memory-neo4j: [sleep] Phase 5 complete — ${result.extraction.succeeded} extracted, ${result.extraction.failed} failed`, + ); + } catch (err) { + logger.warn(`memory-neo4j: [sleep] Phase 5 error: ${String(err)}`); + } + } else if (!config.enabled) { + logger.info("memory-neo4j: [sleep] Phase 5 skipped — extraction not enabled"); + } + + // -------------------------------------------------------------------------- + // Phase 6: Decay & Pruning (after extraction so freshly extracted memories + // aren't pruned before they build entity connections) + // -------------------------------------------------------------------------- + if (!abortSignal?.aborted) { + onPhaseStart?.("decay"); + logger.info("memory-neo4j: [sleep] Phase 6: Decay & Pruning"); + + try { + const decayed = await db.findDecayedMemories({ + retentionThreshold: decayRetentionThreshold, + baseHalfLifeDays: decayBaseHalfLifeDays, + importanceMultiplier: decayImportanceMultiplier, + agentId, + }); + + if (decayed.length > 0) { + const ids = decayed.map((m) => m.id); + result.decay.memoriesPruned = await db.pruneMemories(ids); + onProgress?.("decay", `Pruned ${result.decay.memoriesPruned} decayed memories`); + } + + logger.info( + `memory-neo4j: [sleep] Phase 6 complete — ${result.decay.memoriesPruned} memories pruned`, ); } catch (err) { logger.warn(`memory-neo4j: [sleep] Phase 6 error: ${String(err)}`); } - } else if (!config.enabled) { - logger.info("memory-neo4j: [sleep] Phase 6 skipped — extraction not enabled"); } // -------------------------------------------------------------------------- @@ -762,69 +745,6 @@ export async function runSleepCycle( return result; } -// ============================================================================ -// Auto-Capture Decision -// ============================================================================ - -/** - * Evaluate user messages and decide what's worth storing as long-term memory. - * Returns a list of memory items to store, or empty if nothing worth keeping. - */ -export async function evaluateAutoCapture( - userMessages: string[], - config: ExtractionConfig, -): Promise { - if (!config.enabled || userMessages.length === 0) { - return []; - } - - const combined = userMessages.join("\n\n"); - if (combined.length < 10) { - return []; - } - - const prompt = AUTO_CAPTURE_PROMPT.replace("{messages}", combined); - - try { - const content = await callOpenRouter(config, prompt); - if (!content) { - return []; - } - - const parsed = JSON.parse(content) as Record; - return validateCaptureDecision(parsed); - } catch { - // Silently fail — auto-capture is best-effort - return []; - } -} - -/** - * Validate and sanitize the auto-capture LLM output. - */ -function validateCaptureDecision(raw: Record): CaptureItem[] { - const memories = Array.isArray(raw.memories) ? raw.memories : []; - - const validCategories = new Set(["preference", "fact", "decision", "entity", "other"]); - - return memories - .filter( - (m: unknown): m is Record => - m !== null && - typeof m === "object" && - typeof (m as Record).text === "string" && - (m as Record).text !== "", - ) - .map((m) => ({ - text: String(m.text).slice(0, 2000), // cap length - category: validCategories.has(String(m.category)) - ? (String(m.category) as MemoryCategory) - : "other", - importance: typeof m.importance === "number" ? Math.min(1, Math.max(0, m.importance)) : 0.7, - })) - .slice(0, 5); // Max 5 captures per conversation -} - // ============================================================================ // Message Extraction Helper // ============================================================================ diff --git a/extensions/memory-neo4j/index.ts b/extensions/memory-neo4j/index.ts index cd114274e18..ac40c1d93ac 100644 --- a/extensions/memory-neo4j/index.ts +++ b/extensions/memory-neo4j/index.ts @@ -24,7 +24,7 @@ import { vectorDimsForModel, } from "./config.js"; import { Embeddings } from "./embeddings.js"; -import { evaluateAutoCapture, extractUserMessages, runSleepCycle } from "./extractor.js"; +import { extractUserMessages, runSleepCycle } from "./extractor.js"; import { Neo4jMemoryClient } from "./neo4j-client.js"; import { hybridSearch } from "./search.js"; @@ -43,7 +43,7 @@ const memoryNeo4jPlugin = { register(api: OpenClawPluginApi) { // Parse configuration const cfg = memoryNeo4jConfigSchema.parse(api.pluginConfig); - const extractionConfig = resolveExtractionConfig(); + const extractionConfig = resolveExtractionConfig(cfg.extraction); const vectorDim = vectorDimsForModel(cfg.embedding.model); // Create shared resources @@ -494,8 +494,8 @@ const memoryNeo4jPlugin = { ); 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 5: Extraction — Extract entities and categorize"); + console.log(" Phase 6: Decay & Pruning — Remove stale low-importance memories"); console.log(" Phase 7: Orphan Cleanup — Remove disconnected nodes\n"); try { @@ -522,8 +522,8 @@ const memoryNeo4jPlugin = { pareto: "Phase 2: Pareto Scoring", promotion: "Phase 3: Core Promotion", demotion: "Phase 4: Core Demotion", - decay: "Phase 5: Decay & Pruning", - extraction: "Phase 6: Extraction", + extraction: "Phase 5: Extraction", + decay: "Phase 6: Decay & Pruning", cleanup: "Phase 7: Orphan Cleanup", }; console.log(`\n▶ ${phaseNames[phase]}`); @@ -818,7 +818,19 @@ const memoryNeo4jPlugin = { }); } - // Auto-capture: LLM-based decision on what to store from conversations + // Auto-capture: attention-gated memory pipeline modeled on human memory. + // + // Phase 1 — Attention gating (real-time): + // Lightweight heuristic filter rejects obvious noise (greetings, short + // acks, system markup, code dumps) without any LLM call. + // + // Phase 2 — Short-term retention: + // Everything that passes the gate is embedded, deduped, and stored as + // regular memory with extractionStatus "pending". + // + // Phase 3 — Sleep consolidation (deferred to `openclaw memory neo4j sleep`): + // The sleep cycle handles entity extraction, categorization, Pareto + // scoring, promotion/demotion, and decay — mirroring hippocampal replay. api.logger.debug?.( `memory-neo4j: autoCapture=${cfg.autoCapture}, extraction.enabled=${extractionConfig.enabled}`, ); @@ -837,71 +849,24 @@ const memoryNeo4jPlugin = { const sessionKey = ctx.sessionKey; try { - if (extractionConfig.enabled) { - // LLM-based auto-capture (Decision Q8) - const userMessages = extractUserMessages(event.messages); - if (userMessages.length === 0) { - return; - } + const userMessages = extractUserMessages(event.messages); + if (userMessages.length === 0) { + return; + } - const items = await evaluateAutoCapture(userMessages, extractionConfig); - if (items.length === 0) { - return; - } + // Phase 1: Attention gating — fast heuristic filter + const retained = userMessages.filter((text) => passesAttentionGate(text)); + if (retained.length === 0) { + return; + } - let stored = 0; - for (const item of items) { - try { - const vector = await embeddings.embed(item.text); - - // Check for duplicates - const existing = await db.findSimilar(vector, 0.95, 1); - if (existing.length > 0) { - continue; - } - - const memoryId = randomUUID(); - await db.storeMemory({ - id: memoryId, - text: item.text, - embedding: vector, - importance: item.importance, - category: item.category, - source: "auto-capture", - extractionStatus: "pending", - agentId, - sessionKey, - }); - - // Extraction deferred to sleep cycle (like human memory consolidation) - stored++; - } catch (err) { - api.logger.debug?.(`memory-neo4j: auto-capture item failed: ${String(err)}`); - } - } - - if (stored > 0) { - api.logger.info(`memory-neo4j: auto-captured ${stored} memories (LLM-based)`); - } - } else { - // Fallback: rule-based capture (no extraction API key) - const userMessages = extractUserMessages(event.messages); - if (userMessages.length === 0) { - return; - } - - const toCapture = userMessages.filter( - (text) => text.length >= 10 && text.length <= 500 && shouldCaptureRuleBased(text), - ); - if (toCapture.length === 0) { - return; - } - - let stored = 0; - for (const text of toCapture.slice(0, 3)) { - const category = detectCategory(text); + // Phase 2: Short-term retention — embed, dedup, store + let stored = 0; + for (const text of retained) { + try { const vector = await embeddings.embed(text); + // Quick dedup (same content already stored) const existing = await db.findSimilar(vector, 0.95, 1); if (existing.length > 0) { continue; @@ -911,19 +876,21 @@ const memoryNeo4jPlugin = { id: randomUUID(), text, embedding: vector, - importance: 0.7, - category, + importance: 0.5, // neutral — sleep cycle scores via Pareto + category: "other", // sleep cycle will categorize source: "auto-capture", - extractionStatus: "skipped", + extractionStatus: extractionConfig.enabled ? "pending" : "skipped", agentId, sessionKey, }); stored++; + } catch (err) { + api.logger.debug?.(`memory-neo4j: auto-capture item failed: ${String(err)}`); } + } - if (stored > 0) { - api.logger.info(`memory-neo4j: auto-captured ${stored} memories (rule-based)`); - } + if (stored > 0) { + api.logger.info(`memory-neo4j: auto-captured ${stored} memories (attention-gated)`); } } catch (err) { api.logger.warn(`memory-neo4j: auto-capture failed: ${String(err)}`); @@ -960,52 +927,57 @@ const memoryNeo4jPlugin = { }; // ============================================================================ -// Rule-based capture filter (fallback when no extraction API key) +// Attention gate — lightweight heuristic filter (phase 1 of memory pipeline) +// +// Rejects obvious noise without any LLM call, analogous to how the brain's +// sensory gating filters out irrelevant stimuli before they enter working +// memory. Everything that passes gets stored; the sleep cycle decides what +// matters. // ============================================================================ -const MEMORY_TRIGGERS = [ - /remember|zapamatuj|pamatuj/i, - /prefer|radši|nechci|preferuji/i, - /decided|rozhodli|budeme používat/i, - /\+\d{10,}/, - /[\w.-]+@[\w.-]+\.\w+/, - /my\s+\w+\s+is|is\s+my/i, - /i (like|prefer|hate|love|want|need)/i, - /always|never|important/i, +const NOISE_PATTERNS = [ + // Greetings / acknowledgments + /^(hi|hey|hello|yo|sup|ok|okay|sure|thanks|thank you|thx|ty|yep|yup|nope|no|yes|yeah|cool|nice|great|got it|sounds good|perfect|alright|fine|noted|ack|kk|k)\s*[.!?]*$/i, + // Single-word or near-empty + /^\S{0,3}$/, + // Pure emoji + /^[\p{Emoji}\s]+$/u, + // System/XML markup + /^<[a-z-]+>[\s\S]*<\/[a-z-]+>$/i, ]; -function shouldCaptureRuleBased(text: string): boolean { - if (text.includes("")) { +/** Maximum message length — code dumps, logs, etc. are not memories. */ +const MAX_CAPTURE_CHARS = 2000; + +/** Minimum message length — too short to be meaningful. */ +const MIN_CAPTURE_CHARS = 10; + +function passesAttentionGate(text: string): boolean { + const trimmed = text.trim(); + + // Length bounds + if (trimmed.length < MIN_CAPTURE_CHARS || trimmed.length > MAX_CAPTURE_CHARS) { return false; } - if (text.startsWith("<") && text.includes("") || trimmed.includes("")) { return false; } - if (text.includes("**") && text.includes("\n-")) { + + // Noise patterns + if (NOISE_PATTERNS.some((r) => r.test(trimmed))) { return false; } - const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length; + + // Excessive emoji (likely reaction, not substance) + const emojiCount = (trimmed.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length; if (emojiCount > 3) { return false; } - return MEMORY_TRIGGERS.some((r) => r.test(text)); -} -function detectCategory(text: string): MemoryCategory { - const lower = text.toLowerCase(); - if (/prefer|radši|like|love|hate|want/i.test(lower)) { - return "preference"; - } - if (/decided|rozhodli|will use|budeme/i.test(lower)) { - return "decision"; - } - if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) { - return "entity"; - } - if (/is|are|has|have|je|má|jsou/i.test(lower)) { - return "fact"; - } - return "other"; + // Passes gate — retain for short-term storage + return true; } // ============================================================================ diff --git a/extensions/memory-neo4j/neo4j-client.ts b/extensions/memory-neo4j/neo4j-client.ts index 2b8daf0e2fa..fe67e7696f7 100644 --- a/extensions/memory-neo4j/neo4j-client.ts +++ b/extensions/memory-neo4j/neo4j-client.ts @@ -811,6 +811,25 @@ export class Neo4jMemoryClient { } } + /** + * Update a memory's category. Only updates if current category is 'other' + * (auto-assigned) to avoid overriding user-explicit categorization. + */ + async updateMemoryCategory(id: string, category: string): Promise { + await this.ensureInitialized(); + const session = this.driver!.session(); + try { + await session.run( + `MATCH (m:Memory {id: $id}) + WHERE m.category = 'other' + SET m.category = $category, m.updatedAt = $now`, + { id, category, now: new Date().toISOString() }, + ); + } finally { + await session.close(); + } + } + /** * Update the extraction status of a Memory node. */ @@ -900,10 +919,11 @@ export class Neo4jMemoryClient { * Find clusters of near-duplicate memories by vector similarity. * Returns groups where each group contains memories that are duplicates of each other. * - * Algorithm: - * 1. For each memory, find others with similarity >= threshold - * 2. Group into clusters (transitive closure) - * 3. Return clusters with 2+ members + * Algorithm (O(N log N) via HNSW index, replaces O(N²) Cartesian product): + * 1. Fetch all memory IDs and metadata + * 2. For each memory, query the vector index for nearest neighbors above threshold + * 3. Build clusters via union-find (transitive closure) + * 4. Return clusters with 2+ members */ async findDuplicateClusters( threshold: number = 0.95, @@ -912,24 +932,29 @@ export class Neo4jMemoryClient { await this.ensureInitialized(); const session = this.driver!.session(); try { - // Find all pairs of similar memories - const agentFilter = agentId ? "WHERE m1.agentId = $agentId AND m2.agentId = $agentId" : ""; - const result = await session.run( - `MATCH (m1:Memory), (m2:Memory) - ${agentFilter} - WHERE m1.id < m2.id - AND vector.similarity.cosine(m1.embedding, m2.embedding) >= $threshold - RETURN m1.id AS id1, m1.text AS text1, m1.importance AS imp1, - m2.id AS id2, m2.text AS text2, m2.importance AS imp2, - vector.similarity.cosine(m1.embedding, m2.embedding) AS similarity - ORDER BY similarity DESC - LIMIT 500`, - { threshold, agentId }, + // Step 1: Fetch all memory metadata (no embeddings — lightweight) + const agentFilter = agentId ? "WHERE m.agentId = $agentId" : ""; + const allResult = await session.run( + `MATCH (m:Memory) ${agentFilter} + RETURN m.id AS id, m.text AS text, m.importance AS importance`, + agentId ? { agentId } : {}, ); - // Build clusters using union-find - const parent = new Map(); const memoryData = new Map(); + for (const r of allResult.records) { + memoryData.set(r.get("id") as string, { + text: r.get("text") as string, + importance: r.get("importance") as number, + }); + } + + if (memoryData.size < 2) { + return []; + } + + // Step 2: For each memory, find near-duplicates via HNSW vector index + // Each query is O(log N) vs O(N) for brute-force, total O(N log N) + const parent = new Map(); const find = (x: string): string => { if (!parent.has(x)) { @@ -949,23 +974,37 @@ export class Neo4jMemoryClient { } }; - for (const record of result.records) { - const id1 = record.get("id1") as string; - const id2 = record.get("id2") as string; - union(id1, id2); - memoryData.set(id1, { - text: record.get("text1") as string, - importance: record.get("imp1") as number, - }); - memoryData.set(id2, { - text: record.get("text2") as string, - importance: record.get("imp2") as number, - }); + let pairsFound = 0; + for (const id of memoryData.keys()) { + const similar = await session.run( + `MATCH (src:Memory {id: $id}) + CALL db.index.vector.queryNodes('memory_embedding_index', $k, src.embedding) + YIELD node, score + WHERE node.id <> $id AND score >= $threshold + RETURN node.id AS matchId`, + { id, k: neo4j.int(10), threshold }, + ); + + for (const r of similar.records) { + const matchId = r.get("matchId") as string; + if (memoryData.has(matchId)) { + union(id, matchId); + pairsFound++; + } + } + + // Early exit if we've found many pairs (safety bound) + if (pairsFound > 500) { + break; + } } - // Group by root + // Step 3: Group by root const clusters = new Map(); for (const id of memoryData.keys()) { + if (!parent.has(id)) { + continue; + } const root = find(id); if (!clusters.has(root)) { clusters.set(root, []); @@ -1159,8 +1198,7 @@ export class Neo4jMemoryClient { try { const result = await session.run( `MATCH (e:Entity) - WHERE NOT EXISTS { MATCH (:Memory)-[:MENTIONS]->(e) } - OR e.mentionCount <= 0 + WHERE coalesce(e.mentionCount, 0) <= 0 RETURN e.id AS id, e.name AS name, e.type AS type LIMIT $limit`, { limit: neo4j.int(limit) }, @@ -1261,23 +1299,34 @@ export class Neo4jMemoryClient { * This gives core memories a slight disadvantage (they need strong retrieval * patterns to stay in top 20%), creating healthy churn. */ - async calculateAllEffectiveScores( - agentId?: string, - ): Promise> { + async calculateAllEffectiveScores(agentId?: string): Promise< + Array<{ + id: string; + text: string; + category: string; + importance: number; + retrievalCount: number; + ageDays: number; + effectiveScore: number; + }> + > { await this.ensureInitialized(); const session = this.driver!.session(); try { - const agentFilter = agentId ? "WHERE m.agentId = $agentId" : ""; + const agentFilter = agentId + ? "WHERE m.agentId = $agentId AND m.createdAt IS NOT NULL" + : "WHERE m.createdAt IS NOT NULL"; const result = await session.run( `MATCH (m:Memory) ${agentFilter} WITH m, coalesce(m.retrievalCount, 0) AS retrievalCount, + duration.between(datetime(m.createdAt), datetime()).days AS ageDays, CASE WHEN m.lastRetrievedAt IS NULL THEN null ELSE duration.between(datetime(m.lastRetrievedAt), datetime()).days END AS daysSinceRetrieval - WITH m, retrievalCount, daysSinceRetrieval, + WITH m, retrievalCount, ageDays, daysSinceRetrieval, // Effective score: importance × freq_boost × recency // This is used for global ranking (promotion/demotion threshold) m.importance * (1 + log(1 + retrievalCount) * 0.3) * @@ -1285,14 +1334,19 @@ export class Neo4jMemoryClient { 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 + RETURN m.id AS id, m.text AS text, m.category AS category, + m.importance AS importance, retrievalCount, ageDays, effectiveScore ORDER BY effectiveScore DESC`, agentId ? { agentId } : {}, ); return result.records.map((r) => ({ id: r.get("id") as string, + text: r.get("text") as string, category: r.get("category") as string, + importance: r.get("importance") as number, + retrievalCount: r.get("retrievalCount") as number, + ageDays: r.get("ageDays") as number, effectiveScore: r.get("effectiveScore") as number, })); } finally { diff --git a/extensions/memory-neo4j/openclaw.plugin.json b/extensions/memory-neo4j/openclaw.plugin.json index 09aa4234743..8ee7179d78b 100644 --- a/extensions/memory-neo4j/openclaw.plugin.json +++ b/extensions/memory-neo4j/openclaw.plugin.json @@ -43,6 +43,22 @@ "autoRecall": { "label": "Auto-Recall", "help": "Automatically inject relevant memories into context" + }, + "extraction.apiKey": { + "label": "Extraction API Key", + "sensitive": true, + "placeholder": "sk-or-v1-...", + "help": "API key for extraction LLM (not needed for Ollama/local models)" + }, + "extraction.model": { + "label": "Extraction Model", + "placeholder": "google/gemini-2.0-flash-001", + "help": "Model for entity extraction (e.g., google/gemini-2.0-flash-001 for OpenRouter, llama3.1:8b for Ollama)" + }, + "extraction.baseUrl": { + "label": "Extraction Base URL", + "placeholder": "https://openrouter.ai/api/v1", + "help": "Base URL for extraction API (e.g., https://openrouter.ai/api/v1 or http://localhost:11434/v1 for Ollama)" } }, "configSchema": { @@ -92,6 +108,21 @@ }, "autoRecall": { "type": "boolean" + }, + "extraction": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "model": { + "type": "string" + }, + "baseUrl": { + "type": "string" + } + } } }, "required": ["neo4j"] diff --git a/extensions/memory-neo4j/schema.ts b/extensions/memory-neo4j/schema.ts index bfecaba89d8..76cfb44381b 100644 --- a/extensions/memory-neo4j/schema.ts +++ b/extensions/memory-neo4j/schema.ts @@ -68,25 +68,12 @@ export type ExtractedTag = { }; export type ExtractionResult = { + category?: MemoryCategory; entities: ExtractedEntity[]; relationships: ExtractedRelationship[]; tags: ExtractedTag[]; }; -// ============================================================================ -// Auto-Capture Types -// ============================================================================ - -export type CaptureItem = { - text: string; - category: MemoryCategory; - importance: number; -}; - -export type CaptureDecision = { - memories: CaptureItem[]; -}; - // ============================================================================ // Search Types // ============================================================================