Files
openclaw/extensions/memory-neo4j/index.ts
Tarun Sukhani b70cecc307 memory-neo4j: long-term fixes — streaming, abort signals, configurable depth/decay
- Semantic dedup vector pre-screen: skip LLM calls when cosine similarity < 0.8
- Propagate abort signal into sleep cycle phases and extraction pipeline
- Configurable graph search depth (1-3 hops) via graphSearchDepth config
- Streaming extraction: SSE-based callOpenRouterStream with abort responsiveness
- Configurable per-category decay curves for memory consolidation
- Updated tests with SSE streaming mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00

1340 lines
53 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenClaw Memory (Neo4j) Plugin
*
* Drop-in replacement for memory-lancedb with three-signal hybrid search,
* entity extraction, and knowledge graph capabilities.
*
* Provides:
* - memory_recall: Hybrid search (vector + BM25 + graph traversal)
* - memory_store: Store memories with background entity extraction
* - memory_forget: Delete memories with cascade cleanup
*
* Architecture decisions: see docs/memory-neo4j/ARCHITECTURE.md
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
import { randomUUID } from "node:crypto";
import { stringEnum } from "openclaw/plugin-sdk";
import type { MemoryCategory, MemorySource } from "./schema.js";
import { passesAttentionGate, passesAssistantAttentionGate } from "./attention-gate.js";
import {
DEFAULT_EMBEDDING_DIMS,
EMBEDDING_DIMENSIONS,
MEMORY_CATEGORIES,
memoryNeo4jConfigSchema,
resolveExtractionConfig,
vectorDimsForModel,
} from "./config.js";
import { Embeddings } from "./embeddings.js";
import {
extractUserMessages,
extractAssistantMessages,
stripMessageWrappers,
runSleepCycle,
isSemanticDuplicate,
rateImportance,
} from "./extractor.js";
import { Neo4jMemoryClient } from "./neo4j-client.js";
import { hybridSearch } from "./search.js";
// ============================================================================
// Plugin Definition
// ============================================================================
const memoryNeo4jPlugin = {
id: "memory-neo4j",
name: "Memory (Neo4j)",
description:
"Neo4j-backed long-term memory with three-signal hybrid search, entity extraction, and knowledge graph",
kind: "memory" as const,
configSchema: memoryNeo4jConfigSchema,
register(api: OpenClawPluginApi) {
// Parse configuration
const cfg = memoryNeo4jConfigSchema.parse(api.pluginConfig);
const extractionConfig = resolveExtractionConfig(cfg.extraction);
const vectorDim = vectorDimsForModel(cfg.embedding.model);
// Warn on empty neo4j password (may be valid for some setups, but usually a misconfiguration)
if (!cfg.neo4j.password) {
api.logger.warn(
"memory-neo4j: neo4j.password is empty — this may be intentional for passwordless setups, but verify your configuration",
);
}
// Warn when using default embedding dimensions for an unknown model
const isKnownModel =
cfg.embedding.model in EMBEDDING_DIMENSIONS ||
Object.keys(EMBEDDING_DIMENSIONS).some((known) => cfg.embedding.model.startsWith(known));
if (!isKnownModel) {
api.logger.warn(
`memory-neo4j: unknown embedding model "${cfg.embedding.model}" — using default ${DEFAULT_EMBEDDING_DIMS} dimensions. ` +
`If your model outputs a different dimension, vector operations will fail. ` +
`Known models: ${Object.keys(EMBEDDING_DIMENSIONS).join(", ")}`,
);
}
// Create shared resources
const db = new Neo4jMemoryClient(
cfg.neo4j.uri,
cfg.neo4j.username,
cfg.neo4j.password,
vectorDim,
api.logger,
);
const embeddings = new Embeddings(
cfg.embedding.apiKey,
cfg.embedding.model,
cfg.embedding.provider,
cfg.embedding.baseUrl,
api.logger,
);
api.logger.debug?.(
`memory-neo4j: registered (uri: ${cfg.neo4j.uri}, provider: ${cfg.embedding.provider}, model: ${cfg.embedding.model}, ` +
`extraction: ${extractionConfig.enabled ? extractionConfig.model : "disabled"})`,
);
// ========================================================================
// Tools (using factory pattern for agentId)
// ========================================================================
// memory_recall — Three-signal hybrid search
api.registerTool(
(ctx) => {
const agentId = ctx.agentId || "default";
return {
name: "memory_recall",
label: "Memory Recall",
description:
"Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
}),
async execute(_toolCallId: string, params: unknown) {
const { query, limit = 5 } = params as {
query: string;
limit?: number;
};
const results = await hybridSearch(
db,
embeddings,
query,
limit,
agentId,
extractionConfig.enabled,
{ graphSearchDepth: cfg.graphSearchDepth },
);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
details: { count: 0 },
};
}
const text = results
.map((r, i) => `${i + 1}. [${r.category}] ${r.text} (${(r.score * 100).toFixed(0)}%)`)
.join("\n");
const sanitizedResults = results.map((r) => ({
id: r.id,
text: r.text,
category: r.category,
importance: r.importance,
score: r.score,
}));
return {
content: [
{
type: "text",
text: `Found ${results.length} memories:\n\n${text}`,
},
],
details: { count: results.length, memories: sanitizedResults },
};
},
};
},
{ name: "memory_recall" },
);
// memory_store — Store with background entity extraction
api.registerTool(
(ctx) => {
const agentId = ctx.agentId || "default";
const sessionKey = ctx.sessionKey;
return {
name: "memory_store",
label: "Memory Store",
description:
"Save important information in long-term memory. Use for preferences, facts, decisions.",
parameters: Type.Object({
text: Type.String({ description: "Information to remember" }),
importance: Type.Optional(
Type.Number({
description: "Importance 0-1 (default: 0.7)",
}),
),
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
}),
async execute(_toolCallId: string, params: unknown) {
const {
text,
importance = 0.7,
category = "other",
} = params as {
text: string;
importance?: number;
category?: MemoryCategory;
};
// 1. Generate embedding
const vector = await embeddings.embed(text);
// 2. Check for duplicates (vector similarity > 0.95)
const existing = await db.findSimilar(vector, 0.95, 1);
if (existing.length > 0) {
return {
content: [
{
type: "text",
text: `Similar memory already exists: "${existing[0].text}"`,
},
],
details: {
action: "duplicate",
existingId: existing[0].id,
existingText: existing[0].text,
},
};
}
// 3. Store memory immediately (fast path)
const memoryId = randomUUID();
await db.storeMemory({
id: memoryId,
text,
embedding: vector,
importance: Math.min(1, Math.max(0, importance)),
category,
source: "user" as MemorySource,
extractionStatus: extractionConfig.enabled ? "pending" : "skipped",
agentId,
sessionKey,
});
// 4. Extraction is deferred to sleep cycle (like human memory consolidation)
// See: runSleepCycleExtraction() and `openclaw memory sleep` command
return {
content: [
{
type: "text",
text: `Stored: "${text.slice(0, 100)}${text.length > 100 ? "..." : ""}"`,
},
],
details: { action: "created", id: memoryId },
};
},
};
},
{ name: "memory_store" },
);
// memory_forget — Delete with cascade
api.registerTool(
(ctx) => {
const agentId = ctx.agentId || "default";
return {
name: "memory_forget",
label: "Memory Forget",
description: "Delete specific memories. GDPR-compliant.",
parameters: Type.Object({
query: Type.Optional(Type.String({ description: "Search to find memory" })),
memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })),
}),
async execute(_toolCallId: string, params: unknown) {
const { query, memoryId } = params as {
query?: string;
memoryId?: string;
};
// Direct delete by ID
if (memoryId) {
const deleted = await db.deleteMemory(memoryId, agentId);
if (!deleted) {
return {
content: [
{
type: "text",
text: `Memory ${memoryId} not found.`,
},
],
details: { action: "not_found", id: memoryId },
};
}
return {
content: [
{
type: "text",
text: `Memory ${memoryId} forgotten.`,
},
],
details: { action: "deleted", id: memoryId },
};
}
// Search-based delete
if (query) {
const vector = await embeddings.embed(query);
const results = await db.vectorSearch(vector, 5, 0.7, agentId);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No matching memories found." }],
details: { found: 0 },
};
}
// Auto-delete if single high-confidence match
if (results.length === 1 && results[0].score > 0.9) {
await db.deleteMemory(results[0].id, agentId);
return {
content: [
{
type: "text",
text: `Forgotten: "${results[0].text}"`,
},
],
details: { action: "deleted", id: results[0].id },
};
}
// Multiple candidates — ask user to specify
const list = results.map((r) => `- [${r.id}] ${r.text.slice(0, 60)}...`).join("\n");
const sanitizedCandidates = results.map((r) => ({
id: r.id,
text: r.text,
category: r.category,
score: r.score,
}));
return {
content: [
{
type: "text",
text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
},
],
details: {
action: "candidates",
candidates: sanitizedCandidates,
},
};
}
return {
content: [{ type: "text", text: "Provide query or memoryId." }],
details: { error: "missing_param" },
};
},
};
},
{ name: "memory_forget" },
);
// ========================================================================
// CLI Commands
// ========================================================================
api.registerCli(
({ program }) => {
// Find existing memory command or create fallback
let memoryCmd = program.commands.find((cmd) => cmd.name() === "memory");
if (!memoryCmd) {
// Fallback if core memory CLI not registered yet
memoryCmd = program.command("memory").description("Memory commands");
}
// Add neo4j memory subcommand group
const memory = memoryCmd.command("neo4j").description("Neo4j graph memory commands");
memory
.command("list")
.description("List memory counts by agent and category")
.option("--json", "Output as JSON")
.action(async (opts: { json?: boolean }) => {
try {
await db.ensureInitialized();
const stats = await db.getMemoryStats();
if (opts.json) {
console.log(JSON.stringify(stats, null, 2));
return;
}
if (stats.length === 0) {
console.log("No memories stored.");
return;
}
// Group by agentId
const byAgent = new Map<
string,
Array<{ category: string; count: number; avgImportance: number }>
>();
for (const row of stats) {
const list = byAgent.get(row.agentId) || [];
list.push({
category: row.category,
count: row.count,
avgImportance: row.avgImportance,
});
byAgent.set(row.agentId, list);
}
// Print table for each agent
for (const [agentId, categories] of byAgent) {
const total = categories.reduce((sum, c) => sum + c.count, 0);
console.log(`\n┌─ ${agentId} (${total} total)`);
console.log("│");
console.log("│ Category Count Avg Importance");
console.log("│ ─────────────────────────────────────");
for (const { category, count, avgImportance } of categories) {
const cat = category.padEnd(12);
const cnt = String(count).padStart(5);
const imp = (avgImportance * 100).toFixed(0).padStart(3) + "%";
console.log(`${cat} ${cnt} ${imp}`);
}
console.log("└");
}
console.log("");
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
memory
.command("search")
.description("Search memories")
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", "5")
.action(async (query: string, opts: { limit: string }) => {
try {
const results = await hybridSearch(
db,
embeddings,
query,
parseInt(opts.limit, 10),
"default",
extractionConfig.enabled,
{ graphSearchDepth: cfg.graphSearchDepth },
);
const output = results.map((r) => ({
id: r.id,
text: r.text,
category: r.category,
importance: r.importance,
score: r.score,
}));
console.log(JSON.stringify(output, null, 2));
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
memory
.command("stats")
.description("Show memory statistics and configuration")
.action(async () => {
try {
await db.ensureInitialized();
const stats = await db.getMemoryStats();
const total = stats.reduce((sum, s) => sum + s.count, 0);
console.log("\nMemory (Neo4j) Statistics");
console.log("─────────────────────────");
console.log(`Total memories: ${total}`);
console.log(`Neo4j URI: ${cfg.neo4j.uri}`);
console.log(`Embedding: ${cfg.embedding.provider}/${cfg.embedding.model}`);
console.log(
`Extraction: ${extractionConfig.enabled ? extractionConfig.model : "disabled"}`,
);
console.log(`Auto-capture: ${cfg.autoCapture ? "enabled" : "disabled"}`);
console.log(`Auto-recall: ${cfg.autoRecall ? "enabled" : "disabled"}`);
console.log(`Core memory: ${cfg.coreMemory.enabled ? "enabled" : "disabled"}`);
if (stats.length > 0) {
// Group by category across all agents
const byCategory = new Map<string, number>();
for (const row of stats) {
byCategory.set(row.category, (byCategory.get(row.category) ?? 0) + row.count);
}
console.log("\nBy Category:");
for (const [category, count] of byCategory) {
console.log(` ${category.padEnd(12)} ${count}`);
}
// Show agent count
const agents = new Set(stats.map((s) => s.agentId));
console.log(`\nAgents: ${agents.size} (${[...agents].join(", ")})`);
}
console.log("");
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
memory
.command("sleep")
.description(
"Run sleep cycle — consolidate memories with Pareto-based promotion/demotion",
)
.option("--agent <id>", "Agent id (default: all agents)")
.option("--dedup-threshold <n>", "Vector similarity threshold for dedup (default: 0.95)")
.option("--pareto <n>", "Top N% for core memory (default: 0.2 = top 20%)")
.option("--promotion-min-age <days>", "Min age in days before promotion (default: 7)")
.option("--decay-threshold <n>", "Decay score threshold for pruning (default: 0.1)")
.option("--decay-half-life <days>", "Base half-life in days (default: 30)")
.option("--batch-size <n>", "Extraction batch size (default: 50)")
.option("--delay <ms>", "Delay between extraction batches in ms (default: 1000)")
.action(
async (opts: {
agent?: string;
dedupThreshold?: string;
pareto?: string;
promotionMinAge?: string;
decayThreshold?: string;
decayHalfLife?: string;
batchSize?: string;
delay?: string;
}) => {
console.log("\n🌙 Memory Sleep Cycle");
console.log("═════════════════════════════════════════════════════════════");
console.log("Seven-phase memory consolidation (Pareto-based):\n");
console.log(" Phase 1: Deduplication — Merge near-duplicate memories");
console.log(
" Phase 1b: Semantic Dedup — LLM-based paraphrase detection (0.750.95 band)",
);
console.log(" Phase 1c: Conflict Detection — Resolve contradictory memories");
console.log(
" Phase 2: Pareto Scoring — Calculate effective scores for all memories",
);
console.log(" Phase 3: Core Promotion — Regular memories above threshold → core");
console.log(" Phase 4: Core Demotion — Core memories below threshold → regular");
console.log(" Phase 5: 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 {
// Validate sleep cycle CLI parameters before running
const batchSize = opts.batchSize ? parseInt(opts.batchSize, 10) : undefined;
const delay = opts.delay ? parseInt(opts.delay, 10) : undefined;
const decayHalfLife = opts.decayHalfLife
? parseInt(opts.decayHalfLife, 10)
: undefined;
const decayThreshold = opts.decayThreshold
? parseFloat(opts.decayThreshold)
: undefined;
const pareto = opts.pareto ? parseFloat(opts.pareto) : undefined;
const promotionMinAge = opts.promotionMinAge
? parseInt(opts.promotionMinAge, 10)
: undefined;
if (batchSize != null && (Number.isNaN(batchSize) || batchSize <= 0)) {
console.error("Error: --batch-size must be greater than 0");
process.exitCode = 1;
return;
}
if (delay != null && (Number.isNaN(delay) || delay < 0)) {
console.error("Error: --delay must be >= 0");
process.exitCode = 1;
return;
}
if (decayHalfLife != null && (Number.isNaN(decayHalfLife) || decayHalfLife <= 0)) {
console.error("Error: --decay-half-life must be greater than 0");
process.exitCode = 1;
return;
}
if (
decayThreshold != null &&
(Number.isNaN(decayThreshold) || decayThreshold < 0 || decayThreshold > 1)
) {
console.error("Error: --decay-threshold must be between 0 and 1");
process.exitCode = 1;
return;
}
if (pareto != null && (Number.isNaN(pareto) || pareto < 0 || pareto > 1)) {
console.error("Error: --pareto must be between 0 and 1");
process.exitCode = 1;
return;
}
if (
promotionMinAge != null &&
(Number.isNaN(promotionMinAge) || promotionMinAge < 0)
) {
console.error("Error: --promotion-min-age must be >= 0");
process.exitCode = 1;
return;
}
await db.ensureInitialized();
const result = await runSleepCycle(db, embeddings, extractionConfig, api.logger, {
agentId: opts.agent,
dedupThreshold: opts.dedupThreshold ? parseFloat(opts.dedupThreshold) : undefined,
paretoPercentile: pareto,
promotionMinAgeDays: promotionMinAge,
decayRetentionThreshold: decayThreshold,
decayBaseHalfLifeDays: decayHalfLife,
decayCurves:
Object.keys(cfg.decayCurves).length > 0 ? cfg.decayCurves : undefined,
extractionBatchSize: batchSize,
extractionDelayMs: delay,
onPhaseStart: (phase) => {
const phaseNames: Record<string, string> = {
dedup: "Phase 1: Deduplication",
semanticDedup: "Phase 1b: Semantic Deduplication",
conflict: "Phase 1c: Conflict Detection",
pareto: "Phase 2: Pareto Scoring",
promotion: "Phase 3: Core Promotion",
demotion: "Phase 4: Core Demotion",
extraction: "Phase 5: Extraction",
decay: "Phase 6: Decay & Pruning",
cleanup: "Phase 7: Orphan Cleanup",
};
console.log(`\n▶ ${phaseNames[phase]}`);
console.log("─────────────────────────────────────────────────────────────");
},
onProgress: (_phase, message) => {
console.log(` ${message}`);
},
});
console.log("\n═════════════════════════════════════════════════════════════");
console.log(`✅ Sleep cycle complete in ${(result.durationMs / 1000).toFixed(1)}s`);
console.log("─────────────────────────────────────────────────────────────");
console.log(
` Deduplication: ${result.dedup.clustersFound} clusters → ${result.dedup.memoriesMerged} merged`,
);
console.log(
` Conflicts: ${result.conflict.pairsFound} pairs, ${result.conflict.resolved} resolved, ${result.conflict.invalidated} invalidated`,
);
console.log(
` Semantic Dedup: ${result.semanticDedup.pairsChecked} pairs checked, ${result.semanticDedup.duplicatesMerged} merged`,
);
console.log(
` Pareto: ${result.pareto.totalMemories} total (${result.pareto.coreMemories} core, ${result.pareto.regularMemories} regular)`,
);
console.log(
` Threshold: ${result.pareto.threshold.toFixed(4)} (top 20%)`,
);
console.log(
` Promotion: ${result.promotion.promoted}/${result.promotion.candidatesFound} promoted to core`,
);
console.log(
` Demotion: ${result.demotion.demoted}/${result.demotion.candidatesFound} demoted from core`,
);
console.log(` Decay/Pruning: ${result.decay.memoriesPruned} memories pruned`);
console.log(
` Extraction: ${result.extraction.succeeded}/${result.extraction.total} extracted` +
(result.extraction.failed > 0 ? ` (${result.extraction.failed} failed)` : ""),
);
console.log(
` Cleanup: ${result.cleanup.entitiesRemoved} entities, ${result.cleanup.tagsRemoved} tags removed`,
);
if (result.aborted) {
console.log("\n⚠ Sleep cycle was aborted before completion.");
}
console.log("");
} catch (err) {
console.error(
`\n❌ Sleep cycle failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
},
);
memory
.command("promote")
.description("Manually promote a memory to core status")
.argument("<id>", "Memory ID to promote")
.action(async (id: string) => {
try {
await db.ensureInitialized();
const promoted = await db.promoteToCore([id]);
if (promoted > 0) {
console.log(`✅ Memory ${id} promoted to core.`);
} else {
console.log(`❌ Memory ${id} not found.`);
process.exitCode = 1;
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
memory
.command("index")
.description(
"Re-embed all memories and entities — use after changing embedding model/provider",
)
.option("--batch-size <n>", "Embedding batch size (default: 50)")
.action(async (opts: { batchSize?: string }) => {
const batchSize = opts.batchSize ? parseInt(opts.batchSize, 10) : 50;
if (Number.isNaN(batchSize) || batchSize <= 0) {
console.error("Error: --batch-size must be greater than 0");
process.exitCode = 1;
return;
}
console.log("\nMemory Neo4j — Reindex Embeddings");
console.log("═════════════════════════════════════════════════════════════");
console.log(`Model: ${cfg.embedding.provider}/${cfg.embedding.model}`);
console.log(`Dimensions: ${vectorDim}`);
console.log(`Batch size: ${batchSize}\n`);
try {
const startedAt = Date.now();
const result = await db.reindex((texts) => embeddings.embedBatch(texts), {
batchSize,
onProgress: (phase, done, total) => {
if (phase === "drop-indexes" && done === 0) {
console.log("▶ Dropping old vector index…");
} else if (phase === "memories") {
console.log(` Memories: ${done}/${total}`);
} else if (phase === "create-indexes" && done === 0) {
console.log("▶ Recreating vector index…");
}
},
});
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log("\n═════════════════════════════════════════════════════════════");
console.log(`✅ Reindex complete in ${elapsed}s — ${result.memories} memories`);
console.log("");
} catch (err) {
console.error(
`\n❌ Reindex failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
});
memory
.command("cleanup")
.description(
"Retroactively apply the attention gate — find and remove low-substance memories",
)
.option("--execute", "Actually delete (default: dry-run preview)")
.option("--all", "Include explicitly-stored memories (default: auto-capture only)")
.option("--agent <id>", "Only clean up memories for a specific agent")
.action(async (opts: { execute?: boolean; all?: boolean; agent?: string }) => {
try {
await db.ensureInitialized();
// Fetch memories — by default only auto-capture (explicit stores are trusted)
const conditions: string[] = [];
if (!opts.all) {
conditions.push("m.source = 'auto-capture'");
}
if (opts.agent) {
conditions.push("m.agentId = $agentId");
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const allMemories = await db.runQuery<{ id: string; text: string; source: string }>(
`MATCH (m:Memory) ${where}
RETURN m.id AS id, m.text AS text, COALESCE(m.source, 'unknown') AS source
ORDER BY m.createdAt ASC`,
opts.agent ? { agentId: opts.agent } : {},
);
// Strip channel metadata wrappers (same as the real pipeline) then gate
const noise: Array<{ id: string; text: string; source: string }> = [];
for (const mem of allMemories) {
const stripped = stripMessageWrappers(mem.text);
if (!passesAttentionGate(stripped)) {
noise.push(mem);
}
}
if (noise.length === 0) {
console.log("\nNo low-substance memories found. Everything passes the gate.");
return;
}
console.log(
`\nFound ${noise.length}/${allMemories.length} memories that fail the attention gate:\n`,
);
for (const mem of noise) {
const preview = mem.text.length > 80 ? `${mem.text.slice(0, 77)}...` : mem.text;
console.log(` [${mem.source}] "${preview}"`);
}
if (!opts.execute) {
console.log(
`\nDry run — ${noise.length} memories would be removed. Re-run with --execute to delete.\n`,
);
return;
}
// Delete in batch
const deleted = await db.pruneMemories(noise.map((m) => m.id));
console.log(`\nDeleted ${deleted} low-substance memories.\n`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
},
{ commands: [] }, // Adds subcommands to existing "memory" command, no conflict
);
// ========================================================================
// Lifecycle Hooks
// ========================================================================
// Track sessions where core memories have already been loaded (skip on subsequent turns).
// NOTE: This is in-memory and will be cleared on gateway restart. The agent_bootstrap
// hook below also checks for existing conversation history to avoid re-injecting core
// memories after restarts.
const bootstrappedSessions = new Set<string>();
const coreMemoryIdsBySession = new Map<string, Set<string>>();
// Track mid-session refresh: maps sessionKey → tokens at last refresh
// Used to avoid refreshing too frequently (only refresh after significant context growth)
const midSessionRefreshAt = new Map<string, number>();
const MIN_TOKENS_SINCE_REFRESH = 10_000; // Only refresh if context grew by 10k+ tokens
// Track session timestamps for TTL-based cleanup. Without this, bootstrappedSessions
// and midSessionRefreshAt leak entries for sessions that ended without an explicit
// after_compaction event (e.g., normal session end on long-running gateways).
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const sessionLastSeen = new Map<string, number>();
let lastTtlSweep = Date.now();
/** Evict stale entries from session tracking maps older than SESSION_TTL_MS. */
function pruneStaleSessionEntries(): void {
const now = Date.now();
// Only sweep at most once per 5 minutes to avoid overhead
if (now - lastTtlSweep < 5 * 60 * 1000) {
return;
}
lastTtlSweep = now;
const cutoff = now - SESSION_TTL_MS;
for (const [key, ts] of sessionLastSeen) {
if (ts < cutoff) {
bootstrappedSessions.delete(key);
midSessionRefreshAt.delete(key);
coreMemoryIdsBySession.delete(key);
sessionLastSeen.delete(key);
}
}
}
/** Mark a session as recently active for TTL tracking. */
function touchSession(sessionKey: string): void {
sessionLastSeen.set(sessionKey, Date.now());
pruneStaleSessionEntries();
}
// After compaction: clear bootstrap flag and mid-session refresh tracking
if (cfg.coreMemory.enabled) {
api.on("after_compaction", async (_event, ctx) => {
if (ctx.sessionKey) {
bootstrappedSessions.delete(ctx.sessionKey);
midSessionRefreshAt.delete(ctx.sessionKey);
coreMemoryIdsBySession.delete(ctx.sessionKey);
sessionLastSeen.delete(ctx.sessionKey);
api.logger.info?.(
`memory-neo4j: cleared bootstrap/refresh flags for session ${ctx.sessionKey} after compaction`,
);
}
});
}
// Session end: clear bootstrap flag so core memories are re-injected on the next turn.
// Fired by /new and /reset commands. Uses sessionKey (which is how bootstrappedSessions
// is keyed), with sessionId as fallback for implementations that only provide sessionId.
api.on("session_end", async (_event, ctx) => {
const key = ctx.sessionKey ?? ctx.sessionId;
if (key) {
bootstrappedSessions.delete(key);
midSessionRefreshAt.delete(key);
coreMemoryIdsBySession.delete(key);
sessionLastSeen.delete(key);
api.logger.info?.(
`memory-neo4j: cleared bootstrap/refresh flags for session=${key} (session_end)`,
);
}
});
// Mid-session core memory refresh: re-inject core memories when context grows past threshold
// This counters the "lost in the middle" phenomenon by placing core memories closer to end of context
const refreshThreshold = cfg.coreMemory.refreshAtContextPercent;
if (cfg.coreMemory.enabled && refreshThreshold) {
api.logger.debug?.(
`memory-neo4j: registering before_agent_start hook for mid-session core refresh at ${refreshThreshold}%`,
);
api.on("before_agent_start", async (event, ctx) => {
// Skip if context info not available
if (!event.contextWindowTokens || !event.estimatedUsedTokens) {
return;
}
const sessionKey = ctx.sessionKey ?? "";
const agentId = ctx.agentId || "default";
const usagePercent = (event.estimatedUsedTokens / event.contextWindowTokens) * 100;
// Only refresh if we've crossed the threshold
if (usagePercent < refreshThreshold) {
return;
}
// Check if we've already refreshed recently (prevent over-refreshing)
const lastRefreshTokens = midSessionRefreshAt.get(sessionKey) ?? 0;
const tokensSinceRefresh = event.estimatedUsedTokens - lastRefreshTokens;
if (tokensSinceRefresh < MIN_TOKENS_SINCE_REFRESH) {
api.logger.debug?.(
`memory-neo4j: skipping mid-session refresh (only ${tokensSinceRefresh} tokens since last refresh)`,
);
return;
}
try {
const maxEntries = cfg.coreMemory.maxEntries;
const coreMemories = await db.listByCategory("core", maxEntries, 0, agentId);
if (coreMemories.length === 0) {
return;
}
// Record this refresh
midSessionRefreshAt.set(sessionKey, event.estimatedUsedTokens);
touchSession(sessionKey);
const content = coreMemories.map((m) => `- ${m.text}`).join("\n");
api.logger.info?.(
`memory-neo4j: mid-session core refresh at ${usagePercent.toFixed(1)}% context (${coreMemories.length} memories)`,
);
return {
prependContext: `<core-memory-refresh>\nReminder of persistent context (you may have seen this earlier, re-stating for recency):\n${content}\n</core-memory-refresh>`,
};
} catch (err) {
api.logger.warn(`memory-neo4j: mid-session core refresh failed: ${String(err)}`);
}
});
}
// Auto-recall: inject relevant memories before agent starts
api.logger.debug?.(`memory-neo4j: autoRecall=${cfg.autoRecall}`);
if (cfg.autoRecall) {
api.logger.debug?.("memory-neo4j: registering before_agent_start hook for auto-recall");
api.on("before_agent_start", async (event, ctx) => {
if (!event.prompt || event.prompt.length < 5) {
return;
}
const agentId = ctx.agentId || "default";
// ~1000 chars keeps us safely within even small embedding contexts
// (mxbai-embed-large = 512 tokens). Longer recall queries don't improve
// embedding quality — it plateaus well before this limit.
const MAX_QUERY_CHARS = 1000;
const query =
event.prompt.length > MAX_QUERY_CHARS
? event.prompt.slice(0, MAX_QUERY_CHARS)
: event.prompt;
try {
let results = await hybridSearch(
db,
embeddings,
query,
3,
agentId,
extractionConfig.enabled,
{ graphSearchDepth: cfg.graphSearchDepth },
);
// Feature 1: Filter out low-relevance results below min RRF score
results = results.filter((r) => r.score >= cfg.autoRecallMinScore);
// Feature 2: Deduplicate against core memories already in context
const sessionKey = ctx.sessionKey ?? "";
const coreIds = coreMemoryIdsBySession.get(sessionKey);
if (coreIds) {
results = results.filter((r) => !coreIds.has(r.id));
}
if (results.length === 0) {
return;
}
const memoryContext = results.map((r) => `- [${r.category}] ${r.text}`).join("\n");
api.logger.info?.(`memory-neo4j: injecting ${results.length} memories into context`);
api.logger.debug?.(
`memory-neo4j: auto-recall memories: ${JSON.stringify(results.map((r) => ({ id: r.id, text: r.text.slice(0, 80), category: r.category, score: r.score })))}`,
);
return {
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
};
} catch (err) {
api.logger.warn(`memory-neo4j: auto-recall failed: ${String(err)}`);
}
});
}
// Core memories: inject as virtual MEMORY.md at bootstrap time (scoped by agentId).
// Only runs on new sessions and after compaction (not every turn).
api.logger.debug?.(`memory-neo4j: coreMemory.enabled=${cfg.coreMemory.enabled}`);
if (cfg.coreMemory.enabled) {
api.logger.debug?.("memory-neo4j: registering agent_bootstrap hook for core memories");
api.on("agent_bootstrap", async (event, ctx) => {
const sessionKey = ctx.sessionKey;
// Skip if this session was already bootstrapped (avoid re-loading every turn).
// The after_compaction hook clears the flag so we re-inject after compaction.
if (sessionKey && bootstrappedSessions.has(sessionKey)) {
api.logger.debug?.(
`memory-neo4j: skipping core memory injection for already-bootstrapped session=${sessionKey}`,
);
return;
}
// Log when we're about to inject core memories for a session that wasn't tracked
// This helps diagnose cases where context might be lost after gateway restarts
if (sessionKey) {
api.logger.debug?.(
`memory-neo4j: session=${sessionKey} not in bootstrappedSessions (size=${bootstrappedSessions.size}), will check for core memories`,
);
}
try {
const agentId = ctx.agentId || "default";
const maxEntries = cfg.coreMemory.maxEntries;
api.logger.debug?.(
`memory-neo4j: loading core memories for agent=${agentId} session=${sessionKey ?? "unknown"}`,
);
// Core memories are always included (no importance filter) - if marked as core, it's important
// Results are ordered by importance desc, so most important come first up to maxEntries
const coreMemories = await db.listByCategory("core", maxEntries, 0, agentId);
if (coreMemories.length === 0) {
if (sessionKey) {
bootstrappedSessions.add(sessionKey);
touchSession(sessionKey);
}
api.logger.debug?.(
`memory-neo4j: no core memories found for agent=${agentId}, marking session as bootstrapped`,
);
return;
}
// Format core memories into a MEMORY.md-style document
let content = "# Core Memory\n\n";
content += "*Persistent context loaded from long-term memory*\n\n";
for (const mem of coreMemories) {
content += `- ${mem.text}\n`;
}
// Find and replace MEMORY.md in the files list, or add it
const files = [...event.files];
const memoryIndex = files.findIndex(
(f) => f.name === "MEMORY.md" || f.name === "memory.md",
);
const virtualFile = {
name: "MEMORY.md" as const,
path: "memory://neo4j/core-memory",
content,
missing: false,
};
const action = memoryIndex >= 0 ? "replaced" : "added";
if (memoryIndex >= 0) {
files[memoryIndex] = virtualFile;
} else {
files.push(virtualFile);
}
if (sessionKey) {
bootstrappedSessions.add(sessionKey);
coreMemoryIdsBySession.set(sessionKey, new Set(coreMemories.map((m) => m.id)));
touchSession(sessionKey);
}
// Log at info level when actually injecting, debug for skips
api.logger.info?.(
`memory-neo4j: ${action} MEMORY.md with ${coreMemories.length} core memories for agent=${agentId} session=${sessionKey ?? "unknown"}`,
);
return { files };
} catch (err) {
api.logger.warn(`memory-neo4j: core memory injection failed: ${String(err)}`);
}
});
}
// 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}`,
);
if (cfg.autoCapture) {
api.logger.debug?.("memory-neo4j: registering agent_end hook for auto-capture");
api.on("agent_end", (event, ctx) => {
api.logger.debug?.(
`memory-neo4j: agent_end fired (success=${event.success}, messages=${event.messages?.length ?? 0})`,
);
if (!event.success || !event.messages || event.messages.length === 0) {
api.logger.debug?.("memory-neo4j: skipping - no success or empty messages");
return;
}
const agentId = ctx.agentId || "default";
const sessionKey = ctx.sessionKey;
// Fire-and-forget: run auto-capture asynchronously so it doesn't
// block the agent_end hook (which otherwise adds 2-10s per turn).
void runAutoCapture(
event.messages,
agentId,
sessionKey,
db,
embeddings,
extractionConfig,
api.logger,
);
});
}
// ========================================================================
// Service
// ========================================================================
api.registerService({
id: "memory-neo4j",
start: async () => {
try {
await db.ensureInitialized();
api.logger.info(
`memory-neo4j: service started (uri: ${cfg.neo4j.uri}, model: ${cfg.embedding.model})`,
);
} catch (err) {
api.logger.error(
`memory-neo4j: failed to start — ${String(err)}. Memory tools will attempt lazy initialization.`,
);
// Don't throw — allow graceful degradation.
// Tools will retry initialization on first use.
}
},
stop: async () => {
await db.close();
api.logger.info("memory-neo4j: service stopped");
},
});
},
};
// ============================================================================
// Auto-capture pipeline (fire-and-forget from agent_end hook)
// ============================================================================
type AutoCaptureLogger = {
info: (msg: string) => void;
warn: (msg: string) => void;
debug?: (msg: string) => void;
};
/**
* Shared capture logic for both user and assistant messages.
* Extracts the common embed → dedup → rate → store pipeline.
*/
async function captureMessage(
text: string,
source: "auto-capture" | "auto-capture-assistant",
importanceThreshold: number,
importanceDiscount: number,
agentId: string,
sessionKey: string | undefined,
db: import("./neo4j-client.js").Neo4jMemoryClient,
embeddings: import("./embeddings.js").Embeddings,
extractionConfig: import("./config.js").ExtractionConfig,
logger: AutoCaptureLogger,
): Promise<{ stored: boolean; semanticDeduped: boolean }> {
// For assistant messages, rate importance first (before embedding) to skip early
const rateFirst = source === "auto-capture-assistant";
let importance: number | undefined;
if (rateFirst) {
importance = await rateImportance(text, extractionConfig);
if (importance < importanceThreshold) {
return { stored: false, semanticDeduped: false };
}
}
const vector = await embeddings.embed(text);
// Quick dedup (same content already stored — cosine >= 0.95)
const existing = await db.findSimilar(vector, 0.95, 1);
if (existing.length > 0) {
return { stored: false, semanticDeduped: false };
}
// Rate importance if not already done
if (importance === undefined) {
importance = await rateImportance(text, extractionConfig);
if (importance < importanceThreshold) {
return { stored: false, semanticDeduped: false };
}
}
// Semantic dedup: check moderate-similarity memories (0.75-0.95)
// Pass the vector similarity score as a pre-screen to skip LLM calls
// for pairs below SEMANTIC_DEDUP_VECTOR_THRESHOLD.
const candidates = await db.findSimilar(vector, 0.75, 3);
if (candidates.length > 0) {
for (const candidate of candidates) {
if (await isSemanticDuplicate(text, candidate.text, extractionConfig, candidate.score)) {
logger.debug?.(
`memory-neo4j: semantic dedup — skipped "${text.slice(0, 60)}..." (duplicate of "${candidate.text.slice(0, 60)}...")`,
);
return { stored: false, semanticDeduped: true };
}
}
}
await db.storeMemory({
id: randomUUID(),
text,
embedding: vector,
importance: importance * importanceDiscount,
category: "other",
source,
extractionStatus: extractionConfig.enabled ? "pending" : "skipped",
agentId,
sessionKey,
});
return { stored: true, semanticDeduped: false };
}
/**
* Run the full auto-capture pipeline asynchronously.
* Processes user and assistant messages through attention gate → capture.
*/
async function runAutoCapture(
messages: unknown[],
agentId: string,
sessionKey: string | undefined,
db: import("./neo4j-client.js").Neo4jMemoryClient,
embeddings: import("./embeddings.js").Embeddings,
extractionConfig: import("./config.js").ExtractionConfig,
logger: AutoCaptureLogger,
): Promise<void> {
try {
let stored = 0;
let semanticDeduped = 0;
// Process user messages
const userMessages = extractUserMessages(messages);
const retained = userMessages.filter((text) => passesAttentionGate(text));
for (const text of retained) {
try {
const result = await captureMessage(
text,
"auto-capture",
0.3,
1.0,
agentId,
sessionKey,
db,
embeddings,
extractionConfig,
logger,
);
if (result.stored) stored++;
if (result.semanticDeduped) semanticDeduped++;
} catch (err) {
logger.debug?.(`memory-neo4j: auto-capture item failed: ${String(err)}`);
}
}
// Process assistant messages
const assistantMessages = extractAssistantMessages(messages);
const retainedAssistant = assistantMessages.filter((text) =>
passesAssistantAttentionGate(text),
);
for (const text of retainedAssistant) {
try {
const result = await captureMessage(
text,
"auto-capture-assistant",
0.7,
0.75,
agentId,
sessionKey,
db,
embeddings,
extractionConfig,
logger,
);
if (result.stored) stored++;
if (result.semanticDeduped) semanticDeduped++;
} catch (err) {
logger.debug?.(`memory-neo4j: assistant auto-capture item failed: ${String(err)}`);
}
}
if (stored > 0 || semanticDeduped > 0) {
logger.info(
`memory-neo4j: auto-captured ${stored} memories (attention-gated)${semanticDeduped > 0 ? `, ${semanticDeduped} semantic dupes skipped` : ""}`,
);
} else if (userMessages.length > 0 || assistantMessages.length > 0) {
logger.info(
`memory-neo4j: auto-capture ran (0 stored, ${userMessages.length} user msgs, ${retained.length} passed gate, ${assistantMessages.length} assistant msgs, ${retainedAssistant.length} passed gate)`,
);
}
} catch (err) {
logger.warn(`memory-neo4j: auto-capture failed: ${String(err)}`);
}
}
// Re-export attention gate for backwards compatibility (tests import from here)
export { passesAttentionGate, passesAssistantAttentionGate } from "./attention-gate.js";
// ============================================================================
// Export
// ============================================================================
export default memoryNeo4jPlugin;