memory-neo4j: configurable extraction model + sleep cycle optimizations

- 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.)
This commit is contained in:
Tarun Sukhani
2026-02-05 11:19:41 +00:00
parent 66f9f972b2
commit c1371b639e
6 changed files with 345 additions and 345 deletions

View File

@@ -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<string, unknown>;
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<string, unknown> | 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: {

View File

@@ -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<string, unknown>): ExtractionResul
const tags = Array.isArray(raw.tags) ? raw.tags : [];
const validEntityTypes = new Set<string>(ENTITY_TYPES);
const validCategories = new Set<string>(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<string, unknown> =>
@@ -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<ReturnType<typeof db.calculateAllEffectiveScores>> = [];
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<CaptureItem[]> {
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<string, unknown>;
return validateCaptureDecision(parsed);
} catch {
// Silently fail — auto-capture is best-effort
return [];
}
}
/**
* Validate and sanitize the auto-capture LLM output.
*/
function validateCaptureDecision(raw: Record<string, unknown>): CaptureItem[] {
const memories = Array.isArray(raw.memories) ? raw.memories : [];
const validCategories = new Set<string>(["preference", "fact", "decision", "entity", "other"]);
return memories
.filter(
(m: unknown): m is Record<string, unknown> =>
m !== null &&
typeof m === "object" &&
typeof (m as Record<string, unknown>).text === "string" &&
(m as Record<string, unknown>).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
// ============================================================================

View File

@@ -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("<relevant-memories>")) {
/** 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("</")) {
// Injected context from the memory system itself
if (trimmed.includes("<relevant-memories>") || trimmed.includes("<core-memory-refresh>")) {
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;
}
// ============================================================================

View File

@@ -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<void> {
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<string, string>();
const memoryData = new Map<string, { text: string; importance: number }>();
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<string, string>();
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<string, string[]>();
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<Array<{ id: string; category: string; effectiveScore: number }>> {
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 {

View File

@@ -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"]

View File

@@ -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
// ============================================================================