refactor(integrations): shared agent plugin runtimes and native adapters (#7203)

This commit is contained in:
Kartik
2026-09-08 23:32:25 +05:30
committed by GitHub
parent dae67f74f5
commit 73e7b8763a
369 changed files with 31056 additions and 20464 deletions
+3 -11
View File
@@ -4,7 +4,7 @@ Long-term memory for [OpenClaw](https://github.com/openclaw/openclaw) agents, po
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant.
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage) and how to recall (recall). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
## Requirements
@@ -74,7 +74,6 @@ Humans should follow the Quick Start below.
"keywordSearch": true,
"identityAlwaysInclude": true
},
"dream": { "enabled": true },
"domain": "companion"
}
}
@@ -210,11 +209,10 @@ All `oss` fields are optional. See the [Mem0 OSS docs](https://docs.mem0.ai/open
### Skills Mode (Default)
Enabled automatically during `openclaw mem0 init`. The agent controls memory through three skills:
Enabled automatically during `openclaw mem0 init`. The agent controls memory through two skills:
- **Triage** — Extracts durable facts from conversations using a structured protocol. Categories, importance gates, and domain overlays control what gets stored.
- **Recall** — Before each turn, rewrites the user message into search queries, retrieves relevant memories with reranking, and injects them into context.
- **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, and prunes stale entries.
When skills mode is active, the skills handle memory operations. `autoRecall` and `autoCapture` remain `true` by default alongside skills mode. The built-in `session-memory` hook is disabled to avoid conflicts.
@@ -280,10 +278,6 @@ openclaw mem0 config set user_id alice
openclaw mem0 event list
openclaw mem0 event status <event_id>
# Memory consolidation
openclaw mem0 dream
openclaw mem0 dream --dry-run
# JSON output (any command)
openclaw mem0 search "preferences" --json
openclaw mem0 list --json
@@ -316,7 +310,6 @@ Enabled by default during `openclaw mem0 init`. `autoRecall` and `autoCapture` a
| `skills.recall.rerank` | `boolean` | `true` | Rerank search results for relevance |
| `skills.recall.keywordSearch` | `boolean` | `true` | Augment with keyword-based search |
| `skills.recall.identityAlwaysInclude` | `boolean` | `true` | Always include identity memories |
| `skills.dream.enabled` | `boolean` | `true` | Enable periodic memory consolidation |
| `skills.domain` | `string` | `"companion"` | Domain overlay for triage rules |
### Platform Mode
@@ -362,7 +355,7 @@ To avoid plaintext credentials:
### Memory Processing
In **skills mode** (default after `openclaw mem0 init`), the agent uses structured protocols (triage, recall, dream) to decide what to store and recall. The built-in `session-memory` hook is disabled to avoid conflicts.
In **skills mode** (default after `openclaw mem0 init`), the agent uses structured triage and recall protocols to decide what to store and recall. The built-in `session-memory` hook is disabled to avoid conflicts.
Without skills, `autoCapture` and `autoRecall` are both enabled by default:
- `autoCapture`: sends conversation content to your configured backend after each agent turn
@@ -377,7 +370,6 @@ In platform mode, conversation content is sent to `api.mem0.ai` for processing.
| `~/.openclaw/openclaw.json` | Plugin configuration (API keys, user ID, settings) |
| `~/.mem0/vector_store.db` | Local vector store (open-source mode only) |
| `~/.mem0/history.db` | Memory edit history (open-source mode only) |
| `<pluginStateDir>/dream-state.json` | Memory consolidation state |
## License
+12 -107
View File
@@ -20,7 +20,6 @@
* - config set : Update a plugin config field
* - event list : List recent background events
* - event status: Get status of a specific event
* - dream : Run memory consolidation
*
* Naming conventions match the Python CLI (`mem0 init`, `mem0 search`, etc.)
*/
@@ -36,7 +35,6 @@ import type {
MemoryItem,
SearchOptions,
} from "../types.ts";
import { loadDreamPrompt } from "../skill-loader.ts";
import { readText } from "../fs-safe.ts";
import type { PluginAuthConfig } from "./config-file.ts";
import {
@@ -1227,7 +1225,18 @@ export function registerCliCommands(
.option("--json", "Output as JSON")
.action(async (opts: { json?: boolean } = {}) => {
try {
const auth = readPluginAuth();
if (cfg.needsSetup || !backend) {
const error = "Mem0 is not configured. Run `openclaw mem0 init`.";
if (jsonOut(opts, {
ok: true,
mode: cfg.mode,
connected: false,
userId: cfg.userId,
error,
})) return;
console.log(error);
return;
}
const result = await backend.status();
if (jsonOut(opts, {
ok: true,
@@ -1714,7 +1723,6 @@ export function registerCliCommands(
status: "Check connectivity and authentication",
config: "Manage mem0 configuration (show, get, set)",
event: "Manage background processing events (list, status)",
dream: "Run memory consolidation (review, merge, prune)",
help: "Show help. Use --json for machine-readable output (for LLM agents)",
},
};
@@ -1736,7 +1744,6 @@ export function registerCliCommands(
status: { description: "Check connectivity", flags: { "--json": "JSON output" } },
config: { description: "Manage configuration (show, get, set)", flags: { "--json": "JSON output" } },
event: { description: "Manage background events (list, status)", flags: { "--json": "JSON output" } },
dream: { description: "Run memory consolidation", flags: { "--dry-run": "Show inventory only", "--json": "JSON output" } },
help: { description: "Show help", flags: { "--json": "JSON output" } },
},
},
@@ -1760,108 +1767,6 @@ export function registerCliCommands(
console.log("");
});
// ====================================================================
// dream
// ====================================================================
mem0
.command("dream")
.description(
"Run memory consolidation (review, merge, prune stored memories)",
)
.option(
"--dry-run",
"Show memory inventory without running consolidation",
)
.option("--json", "Output as JSON")
.action(async (opts: { dryRun?: boolean; json?: boolean }) => {
try {
const uid = cfg.userId;
const memories = await provider.getAll({
user_id: uid,
source: "OPENCLAW",
});
const count = Array.isArray(memories) ? memories.length : 0;
if (count === 0) {
if (jsonOut(opts, { ok: true, count: 0, message: "No memories to consolidate." })) return;
console.log("No memories to consolidate.");
return;
}
const catCounts = new Map<string, number>();
for (const mem of memories) {
const cat =
(mem.metadata as any)?.category ??
mem.categories?.[0] ??
"uncategorized";
catCounts.set(cat, (catCounts.get(cat) ?? 0) + 1);
}
if (opts.dryRun && opts.json) {
jsonOut(opts, { ok: true, count, categories: Object.fromEntries(catCounts) });
return;
}
if (opts.json && !opts.dryRun) {
jsonOut(opts, { ok: true, count, message: `${count} memories available for consolidation` });
return;
}
process.stderr.write(`\nMemory inventory for "${uid}":\n`);
for (const [cat, num] of [...catCounts.entries()].sort(
(a, b) => b[1] - a[1],
)) {
process.stderr.write(` ${cat}: ${num}\n`);
}
process.stderr.write(` TOTAL: ${count}\n\n`);
if (opts.dryRun) {
process.stderr.write("Dry run — no changes made.\n");
return;
}
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
if (!dreamPrompt) {
process.stderr.write(
"Dream skill file not found at skills/memory-dream/SKILL.md\n",
);
return;
}
const memoryDump = (memories as MemoryItem[])
.map((m, i) => {
const cat =
(m.metadata as any)?.category ??
m.categories?.[0] ??
"uncategorized";
const imp = (m.metadata as any)?.importance ?? "?";
const created = m.created_at ?? "unknown";
return `${i + 1}. [${m.id}] (${cat}, importance: ${imp}, created: ${created}) ${m.memory}`;
})
.join("\n");
const fullPrompt = [
"<dream-protocol>",
dreamPrompt,
"</dream-protocol>",
"",
`<all-memories count="${count}" user="${uid}">`,
memoryDump,
"</all-memories>",
"",
"Begin consolidation. Review all memories above and execute merge, delete, and rewrite operations using the available tools.",
].join("\n");
process.stdout.write(fullPrompt + "\n");
process.stderr.write(
`Dream prompt written to stdout (${fullPrompt.length} chars). Paste it into an OpenClaw session to run consolidation.\n`,
);
} catch (err) {
if (jsonErr(opts, `Dream failed: ${String(err)}`)) return;
console.error(`Dream failed: ${String(err)}`);
}
});
},
{
descriptors: [
+1 -2
View File
@@ -189,7 +189,7 @@ export function writePluginConfigField(
/**
* Default skills configuration — matches configure.py output.
* Enables triage, recall (with reranking), and dream consolidation.
* Enables triage and recall with reranking.
*/
const DEFAULT_SKILLS_CONFIG = {
triage: { enabled: true },
@@ -200,7 +200,6 @@ const DEFAULT_SKILLS_CONFIG = {
keywordSearch: true,
identityAlwaysInclude: true,
},
dream: { enabled: true },
domain: "companion",
};
-214
View File
@@ -1,214 +0,0 @@
/**
* Dream Gate — activity tracking, gate logic, and lock mechanism
* for automatic memory consolidation.
*
* State persists in the plugin's stateDir so it survives gateway restarts.
* Lock prevents concurrent consolidation runs.
*/
import * as path from "node:path";
import { readText, writeText, mkdirp, unlink } from "./fs-safe.ts";
// ============================================================================
// Types
// ============================================================================
interface DreamState {
lastConsolidatedAt: number; // ms since epoch, 0 = never
sessionsSince: number; // interactive sessions since last consolidation
lastSessionId: string | null;
}
interface DreamLock {
pid: number;
startedAt: number;
}
interface DreamGateConfig {
minHours: number;
minSessions: number;
minMemories: number;
}
const DEFAULTS: DreamGateConfig = {
minHours: 24,
minSessions: 5,
minMemories: 20,
};
const LOCK_STALE_MS = 60 * 60 * 1000; // 1 hour
// ============================================================================
// State Persistence
// ============================================================================
function statePath(stateDir: string): string {
return path.join(stateDir, "dream-state.json");
}
function lockPath(stateDir: string): string {
return path.join(stateDir, "dream.lock");
}
function ensureDir(dir: string): void {
try {
mkdirp(dir);
} catch {
/* exists */
}
}
function readState(stateDir: string): DreamState {
try {
const raw = readText(statePath(stateDir));
return JSON.parse(raw) as DreamState;
} catch {
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
}
}
function writeState(stateDir: string, state: DreamState): void {
ensureDir(stateDir);
writeText(statePath(stateDir), JSON.stringify(state, null, 2));
}
// ============================================================================
// Session Tracking
// ============================================================================
/**
* Called from agent_end on every interactive turn.
* Increments session counter (deduped by sessionId).
*/
export function incrementSessionCount(
stateDir: string,
sessionId: string,
): void {
const state = readState(stateDir);
if (state.lastSessionId !== sessionId) {
state.sessionsSince++;
state.lastSessionId = sessionId;
writeState(stateDir, state);
}
}
// ============================================================================
// Gate Logic
// ============================================================================
/**
* Check cheap gates (time + sessions). These are local file reads only.
* Call this BEFORE any API calls. If this fails, skip the expensive
* memory count check entirely.
*/
export function checkCheapGates(
stateDir: string,
config: { minHours?: number; minSessions?: number },
): { proceed: boolean; reason?: string } {
const minHours = config.minHours ?? DEFAULTS.minHours;
const minSessions = config.minSessions ?? DEFAULTS.minSessions;
const state = readState(stateDir);
// Gate 1: Time (one local file read)
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
if (hoursSince < minHours) {
return {
proceed: false,
reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h`,
};
}
// Gate 2: Sessions (same file, already read)
if (state.sessionsSince < minSessions) {
return {
proceed: false,
reason: `sessions: ${state.sessionsSince} < ${minSessions}`,
};
}
return { proceed: true };
}
/**
* Check expensive memory count gate. Only call AFTER checkCheapGates passes.
*/
export function checkMemoryGate(
memoryCount: number,
config: { minMemories?: number },
): { pass: boolean; reason?: string } {
const minMemories = config.minMemories ?? DEFAULTS.minMemories;
if (memoryCount < minMemories) {
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
}
return { pass: true };
}
// ============================================================================
// Lock
// ============================================================================
/**
* Try to acquire the dream lock. Returns true if acquired.
* Stale locks (older than 1 hour) are reclaimed.
*/
export function acquireDreamLock(stateDir: string): boolean {
ensureDir(stateDir);
const lp = lockPath(stateDir);
// Check existing lock
try {
const raw = readText(lp);
const lock = JSON.parse(raw) as DreamLock;
const age = Date.now() - lock.startedAt;
if (age < LOCK_STALE_MS) {
return false; // Held and not stale
}
// Stale lock — remove it before attempting exclusive create
try {
unlink(lp);
} catch {
/* race ok */
}
} catch {
// No lock file, proceed
}
// Atomic create with exclusive flag (wx). If two processes race,
// only one succeeds. The other gets EEXIST.
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
try {
writeText(lp, JSON.stringify(lock), { flag: "wx" });
return true;
} catch {
return false; // Lost race
}
}
/**
* Release the dream lock and record successful completion.
*/
export function releaseDreamLock(stateDir: string): void {
try {
unlink(lockPath(stateDir));
} catch {
/* already gone */
}
}
/**
* Record that consolidation completed. Resets session counter.
*/
export function recordDreamCompletion(stateDir: string): void {
const state = readState(stateDir);
state.lastConsolidatedAt = Date.now();
state.sessionsSince = 0;
state.lastSessionId = null;
writeState(stateDir, state);
}
/**
* Get current dream state for logging/diagnostics.
*/
export function getDreamState(stateDir: string): DreamState {
return readState(stateDir);
}
+3 -15
View File
@@ -1,6 +1,6 @@
/**
* Pre-extraction message filtering: noise detection, content stripping,
* generic assistant detection, truncation, and deduplication.
* and generic assistant detection.
*/
import type { MemoryItem } from "./types.ts";
@@ -109,8 +109,6 @@ const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> =
},
];
const MAX_MESSAGE_LENGTH = 2000;
/**
* Patterns indicating an assistant message is a generic acknowledgment with
* no extractable facts. These are produced when the agent receives a
@@ -179,19 +177,9 @@ export function stripNoiseFromContent(content: string): string {
return cleaned;
}
/**
* Truncate a message to `MAX_MESSAGE_LENGTH` characters, preserving the
* opening (which typically contains the summary/conclusion) and appending
* a truncation marker so the extraction model knows content was cut.
*/
function truncateMessage(content: string): string {
if (content.length <= MAX_MESSAGE_LENGTH) return content;
return content.slice(0, MAX_MESSAGE_LENGTH) + "\n[...truncated]";
}
/**
* Full pre-extraction pipeline: drop noise messages, strip noise fragments,
* filter session-specific content, and truncate remaining messages.
* and filter session-specific content without truncating remaining messages.
*/
export function filterMessagesForExtraction(
messages: Array<{ role: string; content: string }>,
@@ -206,7 +194,7 @@ export function filterMessagesForExtraction(
if (isSessionSpecificContent(msg.content)) continue;
const cleaned = stripNoiseFromContent(msg.content);
if (!cleaned) continue;
filtered.push({ role: msg.role, content: truncateMessage(cleaned) });
filtered.push({ role: msg.role, content: cleaned });
}
return filtered;
}
+9 -7
View File
@@ -3,6 +3,7 @@
* message filtering logic.
*/
import { describe, it, expect, vi } from "vitest";
import { createMemoryLifecycle } from "../agent-plugin-core/typescript/src/lifecycle.ts";
import memoryPlugin, {
extractAgentId,
effectiveUserId,
@@ -521,13 +522,14 @@ What is the deployment plan?`,
expect(result[0].content).toBe("What is the deployment plan?");
});
it("truncates long messages", () => {
const longContent = "A".repeat(3000);
const messages = [{ role: "assistant", content: longContent }];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
expect(result[0].content.length).toBeLessThan(2100);
expect(result[0].content).toContain("[...truncated]");
it.each(["user", "assistant"])("preserves long %s messages through extraction preparation", (role) => {
const content = "Repository detail. ".repeat(600) + "Final requirement. password=hidden-extraction-secret";
const result = createMemoryLifecycle().prepareConversation(
filterMessagesForExtraction([{ role, content }]),
);
expect(result).toEqual([
{ role, content: content.replace("hidden-extraction-secret", "[REDACTED]") },
]);
});
it("returns empty array when all messages are noise", () => {
+13 -145
View File
@@ -43,18 +43,9 @@ import {
} from "./isolation.ts";
import {
loadCompactTriagePrompt,
loadDreamPrompt,
isSkillsMode,
} from "./skill-loader.ts";
import { recall as skillRecall, sanitizeQuery } from "./recall.ts";
import {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
} from "./dream-gate.ts";
import { PlatformBackend } from "./backend/platform.ts";
import type { Backend } from "./backend/base.ts";
import { registerCliCommands } from "./cli/commands.ts";
@@ -62,6 +53,7 @@ import { readPluginAuth } from "./cli/config-file.ts";
import { registerAllTools } from "./tools/index.ts";
import type { ToolDeps } from "./tools/index.ts";
import { captureEvent } from "./telemetry.ts";
import { createMemoryLifecycle } from "../agent-plugin-core/typescript/src/lifecycle.ts";
import { bootstrapTelemetryFlag } from "./fs-safe.ts";
// ============================================================================
@@ -176,6 +168,8 @@ const memoryPlugin = definePluginEntry({
}
const provider = createProvider(cfg, api);
const lifecycle = createMemoryLifecycle();
lifecycle.beginSession();
// Create Backend instance — PlatformBackend for platform mode, providerToBackend adapter for OSS
let backend: Backend;
@@ -220,10 +214,6 @@ const memoryPlugin = definePluginEntry({
api.registerMemoryCapability({
publicArtifacts: createPublicArtifactsProvider({
provider,
cfg,
get stateDir() {
return pluginStateDir;
},
effectiveUserId: _effectiveUserId,
}),
runtime: {
@@ -373,10 +363,10 @@ const memoryPlugin = definePluginEntry({
setCurrentSessionId: (id: string) => {
currentSessionId = id;
},
getStateDir: () => pluginStateDir,
},
skillsActive,
_captureEvent,
lifecycle,
);
// ========================================================================
@@ -420,13 +410,13 @@ function registerHooks(
) => SearchOptions,
session: {
setCurrentSessionId: (id: string) => void;
getStateDir: () => string | undefined;
},
skillsActive: boolean = false,
_captureEvent: (
event: string,
props?: Record<string, unknown>,
) => void = () => {},
lifecycle: ReturnType<typeof createMemoryLifecycle> = createMemoryLifecycle(),
) {
// ========================================================================
// SKILLS MODE: Agentic memory via before_prompt_build
@@ -493,7 +483,7 @@ function registerHooks(
if (recallEnabled && recallStrategy !== "manual") {
const recallStart = Date.now();
try {
const query = sanitizeQuery(event.prompt);
const query = lifecycle.prepareUserText(sanitizeQuery(event.prompt));
// Smart mode: skip session search (saves 1 API call per turn)
const sessionIdForRecall =
@@ -533,142 +523,18 @@ function registerHooks(
);
}
// Auto-dream: check CHEAP gates first (local file reads only).
// Only hit the API for memory count if time + session gates pass.
const stateDir = session.getStateDir();
const dreamEnabled =
cfg.skills?.dream?.enabled !== false &&
cfg.skills?.dream?.auto !== false;
let dreamContext = "";
if (dreamEnabled && stateDir && !isSubagent) {
try {
const cheapResult = checkCheapGates(
stateDir,
cfg.skills?.dream ?? {},
);
if (cheapResult.proceed) {
// Cheap gates passed. Now do the expensive memory count check.
const memories = await provider.getAll({
user_id: userId,
source: "OPENCLAW",
});
const memCount = Array.isArray(memories) ? memories.length : 0;
const memResult = checkMemoryGate(
memCount,
cfg.skills?.dream ?? {},
);
if (memResult.pass && acquireDreamLock(stateDir)) {
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
if (dreamPrompt) {
dreamContext =
"\n\n<auto-dream>\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" +
dreamPrompt +
"\n</auto-dream>";
// Track which session triggered dream (session-keyed, not global)
dreamSessionId = sessionId;
_captureEvent("openclaw.hook.dream", {
phase: "triggered",
memory_count: memCount,
});
api.logger.info(
`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`,
);
} else {
releaseDreamLock(stateDir);
}
}
}
} catch (err) {
api.logger.warn(
`openclaw-mem0: auto-dream gate check failed: ${String(err)}`,
);
}
}
return {
prependSystemContext: systemContext, // cached by provider
prependContext: recallContext + dreamContext, // per-turn dynamic
prependContext: recallContext, // per-turn dynamic
};
});
// Session-keyed dream tracking. Only the session that triggered dream
// can complete it. Prevents cross-session false completion.
let dreamSessionId: string | undefined;
api.on("agent_end", async (event: any, ctx: any) => {
const sessionId = ctx?.sessionKey ?? undefined;
const trigger = ctx?.trigger ?? undefined;
if (sessionId) session.setCurrentSessionId(sessionId);
// If dream was triggered for THIS session, handle cleanup regardless
// of success/failure. A failed turn must still release the lock.
const stateDir = session.getStateDir();
if (dreamSessionId && dreamSessionId === sessionId && stateDir) {
dreamSessionId = undefined;
if (!event.success) {
// Turn failed/aborted after lock acquired. Release lock, do not
// record completion. Gates will re-trigger next eligible turn.
releaseDreamLock(stateDir);
api.logger.warn(
"openclaw-mem0: auto-dream turn failed, lock released, will retry",
);
return;
}
// Verify the model actually performed WRITE operations (not just reads).
// Only count memory_add, memory_update, memory_delete.
// Exclude memory_list and memory_search (read-only, orient-only pass).
// Scan only the LAST assistant message (this turn), not the full session
// snapshot, to avoid matching earlier tool calls from prior turns.
const WRITE_TOOLS = new Set([
"memory_add",
"memory_update",
"memory_delete",
]);
const messages = event.messages ?? [];
// Find the last assistant message (this turn's output)
const lastAssistant = [...messages]
.reverse()
.find((m: any) => m.role === "assistant");
const writeToolUsed =
lastAssistant && Array.isArray(lastAssistant.content)
? lastAssistant.content.some(
(block: any) =>
block.type === "tool_use" && WRITE_TOOLS.has(block.name),
)
: false;
if (writeToolUsed) {
releaseDreamLock(stateDir);
recordDreamCompletion(stateDir);
_captureEvent("openclaw.hook.dream", {
phase: "completed",
write_tools_used: true,
});
api.logger.info(
"openclaw-mem0: auto-dream completed (verified write tool usage), lock released",
);
} else {
releaseDreamLock(stateDir);
api.logger.warn(
"openclaw-mem0: auto-dream injected but no write tools executed. Lock released, will retry.",
);
}
return;
}
if (!event.success) return;
// Track session for dream gating (interactive turns only)
if (
stateDir &&
sessionId &&
!isNonInteractiveTrigger(trigger, sessionId)
) {
incrementSessionCount(stateDir, sessionId);
}
api.logger.info("openclaw-mem0: skills-mode agent_end (no auto-capture)");
});
@@ -733,6 +599,7 @@ function registerHooks(
"",
)
.trim();
const safePrompt = lifecycle.prepareUserText(cleanPrompt);
const recallStart = Date.now();
const recallWork = async () => {
@@ -741,7 +608,7 @@ function registerHooks(
// Search long-term memories (user-scoped; subagents read from parent namespace)
let longTermResults = await provider.search(
cleanPrompt,
safePrompt,
buildSearchOptions(
undefined,
recallTopK,
@@ -767,7 +634,7 @@ function registerHooks(
// Only broaden for genuinely new sessions with short prompts
// (cold-start blindness). Skip on subsequent turns to save API calls.
if (isNewSession && cleanPrompt.length < 100) {
if (isNewSession && safePrompt.length < 100) {
const broadOpts = buildSearchOptions(
undefined,
5,
@@ -1007,8 +874,9 @@ function registerHooks(
content: m.content,
}));
// Apply noise filtering pipeline: drop noise, strip fragments, truncate
const formattedMessages = filterMessagesForExtraction(selected);
// Filter noise and redact secrets without truncating message text.
const formattedMessages: Array<{ role: string; content: string }> =
lifecycle.prepareConversation(filterMessagesForExtraction(selected));
if (formattedMessages.length === 0) return;
+1 -1
View File
@@ -1,7 +1,7 @@
declare module "openclaw/plugin-sdk" {
export interface MemoryArtifact {
id: string;
type: "memory" | "dream" | "digest" | "entity";
type: "memory" | "digest" | "entity";
title: string;
content: string;
metadata?: Record<string, unknown>;
+3 -13
View File
@@ -2,7 +2,7 @@
"id": "openclaw-mem0",
"name": "Memory (Mem0)",
"description": "Mem0 memory backend for OpenClaw — platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.",
"version": "1.0.15",
"version": "1.1.0",
"kind": "memory",
"skills": ["skills"],
"commandAliases": [
@@ -145,7 +145,7 @@
"skills": {
"label": "Agentic Memory Skills",
"advanced": true,
"help": "Enable skills-based memory extraction. The agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Disables auto-capture when active."
"help": "Enable skills-based memory extraction. The agent controls what to remember (triage) and how to recall (recall). Disables auto-capture when active."
}
},
"configSchema": {
@@ -279,16 +279,6 @@
"categoryOrder": { "type": "array", "items": { "type": "string" } }
}
},
"dream": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"auto": { "type": "boolean" },
"minHours": { "type": "number" },
"minSessions": { "type": "number" },
"minMemories": { "type": "number" }
}
},
"domain": { "type": "string" },
"customRules": {
"type": "object",
@@ -317,4 +307,4 @@
"hosts": ["us.i.posthog.com"]
}
]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "1.0.16",
"version": "1.1.0",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
+3 -47
View File
@@ -1,18 +1,15 @@
/**
* Public Artifacts Provider for OpenClaw memory-wiki bridge mode.
*
* Exposes Mem0 memories and dream state as artifacts that can be
* Exposes Mem0 memories as artifacts that can be
* consumed by other plugins (e.g., memory-wiki in bridge mode).
*/
import type { Mem0Provider, MemoryItem, Mem0Config } from "./types.ts";
import type { Mem0Provider, MemoryItem } from "./types.ts";
import type { MemoryArtifact } from "openclaw/plugin-sdk";
import { getDreamState } from "./dream-gate.ts";
export interface PublicArtifactsContext {
provider: Mem0Provider;
cfg: Mem0Config;
stateDir?: string;
effectiveUserId: (sessionKey?: string) => string;
}
@@ -28,7 +25,7 @@ export function createPublicArtifactsProvider(ctx: PublicArtifactsContext) {
}): Promise<MemoryArtifact[]> {
const artifacts: MemoryArtifact[] = [];
const userId = options?.userId ?? ctx.effectiveUserId();
const types = options?.types ?? ["memory", "dream", "entity"];
const types = options?.types ?? ["memory", "entity"];
const limit = options?.limit ?? 100;
try {
@@ -44,14 +41,6 @@ export function createPublicArtifactsProvider(ctx: PublicArtifactsContext) {
}
}
// Dream state artifact (if dream enabled and stateDir available)
if (types.includes("dream") && ctx.stateDir && ctx.cfg.skills?.dream?.enabled) {
const dreamArtifact = getDreamArtifact(ctx.stateDir, userId);
if (dreamArtifact) {
artifacts.push(dreamArtifact);
}
}
// Entity artifacts (grouped memories by category)
if (types.includes("entity")) {
const entityArtifacts = extractEntityArtifacts(artifacts.filter(a => a.type === "memory"));
@@ -90,39 +79,6 @@ function memoryToArtifact(mem: MemoryItem): MemoryArtifact {
};
}
/**
* Get dream consolidation state as an artifact.
*/
function getDreamArtifact(stateDir: string, userId: string): MemoryArtifact | null {
try {
const state = getDreamState(stateDir);
if (state.lastConsolidatedAt === 0) {
return null; // No consolidation has occurred yet
}
const lastDate = new Date(state.lastConsolidatedAt).toISOString();
return {
id: `mem0:dream:${userId}:state`,
type: "dream",
title: `Dream State (last: ${lastDate.split("T")[0]})`,
content: [
`Last consolidation: ${lastDate}`,
`Sessions since: ${state.sessionsSince}`,
`Last session: ${state.lastSessionId ?? "none"}`,
].join("\n"),
metadata: {
lastConsolidatedAt: state.lastConsolidatedAt,
sessionsSince: state.sessionsSince,
lastSessionId: state.lastSessionId,
user_id: userId,
},
updatedAt: lastDate,
};
} catch {
return null;
}
}
/**
* Extract entity artifacts from memories (grouped by category).
*/
@@ -54,7 +54,6 @@ def main():
"keywordSearch": True,
"identityAlwaysInclude": True,
},
"dream": {"enabled": True},
"domain": "companion",
},
},
+2 -11
View File
@@ -346,12 +346,12 @@ export function loadSkill(
}
// Inject triage knobs (importanceThreshold, credentialPatterns)
if (skillName === "memory-triage" || skillName === "memory-dream") {
if (skillName === "memory-triage") {
const knobs = renderTriageKnobs(config);
if (knobs) parts.push(knobs);
}
// Append user custom rules (triage-only — extraction rules don't apply to recall/dream)
// Append user custom rules to the triage prompt.
if (skillName === "memory-triage" && config.customRules) {
const rulesBlock: string[] = ["\n## User Custom Rules\n"];
if (config.customRules.include?.length) {
@@ -659,15 +659,6 @@ export function loadCompactTriagePrompt(config: SkillsConfig = {}): string {
return parts.join("\n");
}
/**
* Load the dream skill prompt for consolidation sessions.
*/
export function loadDreamPrompt(config: SkillsConfig = {}): string {
const dream = loadSkill("memory-dream", config);
if (!dream) return "";
return dream.prompt;
}
/**
* Resolve the effective categories — user overrides merged with defaults.
*/
@@ -1,150 +0,0 @@
---
name: memory-dream
description: >
Memory consolidation protocol. Reviews all stored memories, merges duplicates,
removes noise and credentials, rewrites unclear entries, and enforces TTL expiration.
Use when the user asks to clean up, consolidate, or review their memories.
Also triggers automatically after sufficient activity (configurable).
user-invocable: true
metadata:
{"openclaw": {"injected": true, "emoji": "💤", "requires": {"env": ["MEM0_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"], "bins": []}}}
---
# Memory Consolidation
You are performing a memory consolidation pass. Your goal is to review all stored memories for this user and improve their overall quality. Think of this as compressing raw observations into clean, durable knowledge.
## Available Tools
### memory_search
Semantic search across stored memories.
- `query` (required): search query
- `limit`: max results
- `userId`, `agentId`: scope overrides
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
- `categories`: filter by category array
### memory_add
Store new facts in long-term memory.
- `facts` (required): array of facts — ALL must share the same category
- `category`: `"identity"`, `"preference"`, `"decision"`, `"rule"`, `"project"`, `"configuration"`, `"technical"`, `"relationship"`
- `importance`: 0.0–1.0
### memory_get
Retrieve a single memory by ID.
- `memoryId` (required): the memory ID
### memory_list
List all stored memories for a user or agent.
- `userId`, `agentId`: scope overrides
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
### memory_update
Update an existing memory's text in place. Atomic and preserves edit history.
- `memoryId` (required): the memory ID to update
- `text` (required): the new text (replaces old)
### memory_delete
Delete memories by ID, query, or bulk.
- `memoryId`: specific memory ID to delete
- `all`: delete ALL memories (requires `confirm: true`)
- `userId`, `agentId`: scope overrides
### memory_event_list
List recent background processing events (platform mode only).
### memory_event_status
Get status of a specific background event.
- `event_id` (required): the event ID to check
Follow these four phases in order. Do not skip phases.
## Phase 1: Orient
Survey the current memory landscape before making any changes.
1. Call `memory_list` to load all stored memories.
2. Count memories by category. Note the total.
3. Identify the oldest and newest memories by their timestamps.
4. Note any obvious problems visible in the list: duplicates, very short entries, entries without temporal anchors.
Do not modify anything in this phase. The goal is to understand what you are working with.
## Phase 2: Gather Targets
Identify which memories need action. Use the tools to investigate.
**Search for recent additions:**
Call `memory_search` with a `created_at` filter to find memories added since the last consolidation. These are the most likely to need merging or cleanup.
**Classify each target into one of these actions:**
- DELETE: contains credentials, expired by TTL, pure noise, raw tool output, standalone timestamps
- MERGE: two or more memories express the same fact in different words, or a series tracks incremental changes to the same entity
- REWRITE: vague, missing temporal anchor, uses first person instead of third, wrong category, overly verbose
## Phase 3: Consolidate
Execute the actions identified in Phase 2. Work in this priority order:
### 3a. Delete dangerous and expired entries
Delete immediately using `memory_delete`:
- Credentials, API keys, tokens, passwords, secrets (matching known credential prefixes and auth patterns injected by the plugin at runtime)
- Pure timestamps with no context
- Raw tool output stored as memory
- Heartbeat or cron execution records
- Generic acknowledgments stored as memory ("ok", "got it")
- Operational memories older than 7 days
- Project memories older than 90 days
### 3b. Merge duplicates
When two or more memories express the same fact:
1. Pick the most complete version as the base
2. Call `memory_update` on the best version to incorporate missing details from the others
3. Call `memory_delete` on the redundant entries
`memory_update` is preferred over forget-then-store because it is atomic and preserves edit history.
When merging, follow these rules:
- Keep the user's original words for opinions and preferences
- Preserve temporal anchors from both versions
- Do not exceed 50 words in the merged result
- The merged memory must be self-contained (understandable without the deleted ones)
### 3c. Rewrite unclear entries
When a memory needs improvement but is not a duplicate:
1. Call `memory_update` with the improved text
Rewrite when:
- Memory uses first person ("I prefer") instead of third ("User prefers")
- Memory lacks a temporal anchor for time-sensitive information
- Memory is vague ("likes python") and can be made specific ("User prefers Python for backend development")
- Memory has the wrong category assignment
- Memory is over 50 words and can be compressed without losing information
## Phase 4: Report
After completing all operations, summarize what you did:
```
Consolidation complete.
- Reviewed: [total count]
- Deleted (credentials/secrets): [count]
- Deleted (expired/stale): [count]
- Merged: [count] groups into [count] memories
- Rewritten: [count]
- Final count: [total remaining]
- Issues found: [any notable problems or observations]
```
## Quality Targets
After consolidation, the memory store should have:
- Zero memories containing credentials or secrets
- Zero duplicate memories (same fact in different words)
- All project and operational memories have temporal anchors ("As of YYYY-MM-DD")
- All memories use third person voice
- All memories are correctly categorized
- Each memory is 15-50 words, self-contained, and atomic (one fact per memory)
+81 -267
View File
@@ -1,301 +1,115 @@
/**
* Plugin telemetry — anonymous usage tracking via PostHog.
*
* Sends fire-and-forget events to PostHog using native fetch().
* Events are batched and flushed every 5 seconds or when the queue
* reaches 10 events, whichever comes first.
*
* Disable with: MEM0_TELEMETRY=false
*/
import { createHash, randomUUID } from "node:crypto";
import { readPluginAuth, writePluginAuth, getBaseUrl, clearAnonymousTelemetryId } from "./cli/config-file.ts";
import { createTelemetry } from "../agent-plugin-core/typescript/src/telemetry.ts";
import { clearAnonymousTelemetryId, getBaseUrl, readPluginAuth, writePluginAuth } from "./cli/config-file.ts";
declare const __OPENCLAW_PLUGIN_VERSION__: string;
export const PLUGIN_VERSION: string = __OPENCLAW_PLUGIN_VERSION__;
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
let cachedAnonymousId: string | undefined;
let aliasCheckDone = false;
let emailResolutionAttempted = false;
let currentDistinctId = "";
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_THRESHOLD = 10;
let eventQueue: Record<string, unknown>[] = [];
let flushTimer: ReturnType<typeof setInterval> | undefined;
let _cachedAnonymousId: string | undefined;
let _aliasCheckDone = false;
/**
* Return a persistent per-machine anonymous ID, generating one if needed.
*
* Stored in ~/.openclaw/openclaw.json under the plugin's `anonymousTelemetryId`
* field so repeat sessions on the same machine share one PostHog identity
* instead of collapsing into a single shared fallback string. The result is
* cached in module memory after the first read so we don't re-touch disk on
* every queued event.
*/
function getOrCreateAnonymousId(): string {
if (_cachedAnonymousId) return _cachedAnonymousId;
try {
const auth = readPluginAuth();
if (auth.anonymousTelemetryId) {
_cachedAnonymousId = auth.anonymousTelemetryId;
return _cachedAnonymousId;
}
} catch {
/* ignore */
}
const newId = `openclaw-anon-${randomUUID().replace(/-/g, "")}`;
try {
writePluginAuth({ anonymousTelemetryId: newId });
} catch {
/* ignore — return generated id anyway */
}
_cachedAnonymousId = newId;
return newId;
function enabled(): boolean {
const value = (globalThis as any).__mem0_telemetry_override ?? process.env.MEM0_TELEMETRY;
return value === undefined || !["false", "0", "no", "off"].includes(String(value).toLowerCase());
}
/**
* If we just resolved to a real identity but a stored anonymous id exists,
* build a one-shot PostHog $identify event so the pre-signup history gets
* stitched onto the authenticated profile. Returns null when no aliasing is
* needed (already done, or no anon id on disk, or still anonymous).
*
* Caller is responsible for pushing the returned event onto eventQueue ahead
* of the regular event.
*/
function maybeBuildIdentifyEvent(
distinctId: string,
): Record<string, unknown> | null {
if (_aliasCheckDone) return null;
if (!distinctId || distinctId.startsWith("openclaw-anon-")) return null;
function anonymousId(): string {
if (cachedAnonymousId) return cachedAnonymousId;
try {
const auth = readPluginAuth();
const storedAnon = auth.anonymousTelemetryId;
if (!storedAnon) {
_aliasCheckDone = true;
return null;
}
const identifyEvent = {
event: "$identify",
distinct_id: distinctId,
properties: {
$anon_distinct_id: storedAnon,
$lib: "posthog-node",
},
};
// Clear the anonymous ID from config after aliasing (don't write empty string)
try {
clearAnonymousTelemetryId();
} catch {
/* ignore — alias may double-fire next session, harmless */
}
_aliasCheckDone = true;
_cachedAnonymousId = undefined;
return identifyEvent;
const stored = readPluginAuth().anonymousTelemetryId;
if (stored) return (cachedAnonymousId = stored);
} catch {
return null;
// First run or unreadable config.
}
const created = `openclaw-anon-${randomUUID().replace(/-/g, "")}`;
try {
writePluginAuth({ anonymousTelemetryId: created });
} catch {
// An unwritable config must not break the plugin.
}
return (cachedAnonymousId = created);
}
function distinctId(apiKey?: string): string {
try {
const email = readPluginAuth().userEmail;
if (email) return createHash("sha256").update(email).digest("hex");
} catch {
// Fall through to the API key or anonymous identity.
}
return apiKey ? createHash("sha256").update(apiKey).digest("hex") : anonymousId();
}
const telemetry = createTelemetry({
host: "openclaw",
source: "OPENCLAW",
version: PLUGIN_VERSION,
distinctId: () => currentDistinctId,
enabled,
});
function identifyAnonymous(id: string): void {
if (aliasCheckDone || id.startsWith("openclaw-anon-")) return;
try {
const anonymous = readPluginAuth().anonymousTelemetryId;
aliasCheckDone = true;
if (!anonymous) return;
telemetry.capture("$identify", { $anon_distinct_id: anonymous });
clearAnonymousTelemetryId();
cachedAnonymousId = undefined;
} catch {
// Aliasing is best effort.
}
}
let _emailResolutionAttempted = false;
/**
* If we have an apiKey but no cached userEmail, do a one-shot /v1/ping/
* call to resolve the email and cache it. This runs async as a side-effect;
* the current event ships with md5(apiKey) but subsequent events (including
* those flushed by the beforeExit handler in the same process) will use
* the resolved email.
*/
function maybeResolveEmail(apiKey: string): void {
if (_emailResolutionAttempted) return;
_emailResolutionAttempted = true;
const baseUrl = getBaseUrl().replace(/\/+$/, "");
fetch(`${baseUrl}/v1/ping/`, {
function resolveEmail(apiKey: string): void {
if (emailResolutionAttempted) return;
emailResolutionAttempted = true;
fetch(`${getBaseUrl().replace(/\/+$/, "")}/v1/ping/`, {
method: "GET",
headers: {
Authorization: `Token ${apiKey}`,
"Content-Type": "application/json",
},
headers: { Authorization: `Token ${apiKey}`, "Content-Type": "application/json" },
signal: AbortSignal.timeout(5_000),
})
.then((res) => res.json())
.then((response) => response.json())
.then((data: any) => {
const email = data?.user_email;
if (email) {
try {
writePluginAuth({ userEmail: email });
} catch {
/* ignore */
}
const oldId = createHash("sha256").update(apiKey).digest("hex");
const newId = createHash("sha256").update(email).digest("hex");
for (const ev of eventQueue) {
if (ev.distinct_id === oldId) {
ev.distinct_id = newId;
}
}
if (!data?.user_email) return;
writePluginAuth({ userEmail: data.user_email });
const oldId = createHash("sha256").update(apiKey).digest("hex");
const newId = createHash("sha256").update(data.user_email).digest("hex");
for (const event of telemetry.queueForTesting()) {
if (event.distinct_id === oldId) event.distinct_id = newId;
}
})
.catch(() => {
/* silently swallow — md5(apiKey) is used as fallback */
// The API-key hash remains a stable fallback.
});
}
let _telemetryEnabled: boolean | undefined;
function isTelemetryEnabled(): boolean {
if (_telemetryEnabled !== undefined) return _telemetryEnabled;
try {
const val = (globalThis as any).__mem0_telemetry_override;
if (val !== undefined) {
const s = String(val).toLowerCase();
_telemetryEnabled = s !== "false" && s !== "0" && s !== "no";
} else {
_telemetryEnabled = true;
}
} catch {
_telemetryEnabled = true;
}
return _telemetryEnabled;
}
/**
* Return a stable anonymous identifier for the current user.
*
* Priority: cached userEmail (from /v1/ping/) > MD5(apiKey) >
* persistent per-machine anonymous ID.
*/
function getDistinctId(apiKey?: string): string {
try {
const auth = readPluginAuth();
if (auth.userEmail) {
return createHash("sha256").update(auth.userEmail).digest("hex");
}
} catch {
/* ignore */
}
if (apiKey) {
return createHash("sha256").update(apiKey).digest("hex");
}
return getOrCreateAnonymousId();
}
function ensureFlushTimer(): void {
if (flushTimer) return;
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
if (typeof flushTimer === "object" && "unref" in flushTimer) {
flushTimer.unref();
}
}
let _exitHandlerInstalled = false;
/**
* Install a one-time `beforeExit` handler that drains queued events on
* process exit. Without this, short-lived CLI invocations (e.g. one
* `openclaw mem0 status` call) exit before the unref'd flushTimer fires
* and before FLUSH_THRESHOLD is hit, dropping every queued event silently.
*
* Returning a Promise from a `beforeExit` handler keeps the event loop
* alive until that Promise resolves, so the awaited fetch actually has
* time to land at PostHog.
*/
function ensureExitHandler(): void {
if (_exitHandlerInstalled) return;
_exitHandlerInstalled = true;
process.on("beforeExit", async () => {
if (eventQueue.length === 0) return;
const batch = eventQueue;
eventQueue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
try {
await fetch(POSTHOG_HOST, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
},
body,
signal: AbortSignal.timeout(3_000),
});
} catch {
/* silently swallow */
}
});
}
function flushEvents(): void {
if (eventQueue.length === 0) return;
const batch = eventQueue;
eventQueue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
fetch(POSTHOG_HOST, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
},
body,
signal: AbortSignal.timeout(3_000),
}).catch(() => {
/* silently swallow */
});
}
/**
* Capture a PostHog event (non-blocking, never throws).
*/
export function captureEvent(
eventName: string,
properties: Record<string, unknown> = {},
ctx?: { apiKey?: string; mode?: string; skillsActive?: boolean },
context?: { apiKey?: string; mode?: string; skillsActive?: boolean },
): void {
if (!isTelemetryEnabled()) return;
if (!enabled()) return;
try {
const distinctId = getDistinctId(ctx?.apiKey);
currentDistinctId = distinctId(context?.apiKey);
let hasEmail = false;
try { hasEmail = !!readPluginAuth().userEmail; } catch { /* ignore */ }
if (ctx?.apiKey && !hasEmail && !distinctId.startsWith("openclaw-anon-")) {
maybeResolveEmail(ctx.apiKey);
try {
hasEmail = Boolean(readPluginAuth().userEmail);
} catch {
// Resolve it below when possible.
}
// First authenticated event after a previous anonymous session: queue a
// $identify ahead of the regular event so PostHog merges the anonymous
// history onto the authenticated profile in the same batch flush.
const identifyEvent = maybeBuildIdentifyEvent(distinctId);
if (identifyEvent) {
eventQueue.push(identifyEvent);
}
eventQueue.push({
event: eventName,
distinct_id: distinctId,
properties: {
source: "OPENCLAW",
language: "node",
plugin_version: PLUGIN_VERSION,
node_version: process.version,
os: process.platform,
mode: ctx?.mode,
skills_active: ctx?.skillsActive,
$process_person_profile: false,
$lib: "posthog-node",
...properties,
},
if (context?.apiKey && !hasEmail) resolveEmail(context.apiKey);
identifyAnonymous(currentDistinctId);
telemetry.capture(eventName, {
mode: context?.mode,
skills_active: context?.skillsActive,
...properties,
});
ensureFlushTimer();
ensureExitHandler();
if (eventQueue.length >= FLUSH_THRESHOLD) {
flushEvents();
}
} catch {
/* silently swallow */
// Telemetry must never affect plugin behavior.
}
}
+14 -108
View File
@@ -29,11 +29,6 @@ vi.mock("../fs-safe.ts", () => ({
unlink: vi.fn(),
}));
vi.mock("../skill-loader.ts", () => ({
loadDreamPrompt: vi.fn().mockReturnValue("dream prompt"),
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
@@ -46,7 +41,6 @@ import {
enableSkillsConfig,
getBaseUrl,
} from "../cli/config-file.ts";
import { loadDreamPrompt } from "../skill-loader.ts";
// ---------------------------------------------------------------------------
// Mock Commander program builder
@@ -263,8 +257,6 @@ describe("registerCliCommands", () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({});
(writePluginAuth as ReturnType<typeof vi.fn>).mockImplementation(() => {});
(getBaseUrl as ReturnType<typeof vi.fn>).mockReturnValue("https://api.mem0.ai");
(loadDreamPrompt as ReturnType<typeof vi.fn>).mockReturnValue("dream prompt");
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => {}),
error: vi.spyOn(console, "error").mockImplementation(() => {}),
@@ -311,7 +303,6 @@ describe("registerCliCommands", () => {
expect(names).toContain("delete");
expect(names).toContain("status");
expect(names).toContain("config");
expect(names).toContain("dream");
});
it("registers config subcommands: show, get, set", () => {
@@ -1037,6 +1028,19 @@ describe("registerCliCommands", () => {
// ========================================================================
describe("status subcommand", () => {
it("reports setup instructions when Mem0 is not configured", async () => {
const { mem0, backend, cfg } = setup();
(cfg as any).needsSetup = true;
const statusCmd = findCommand(mem0, "status")!;
await statusCmd._action!();
expect(backend.status).not.toHaveBeenCalled();
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("openclaw mem0 init"),
);
});
it("calls backend.status and prints connection info", async () => {
const { mem0, backend } = setup();
const statusCmd = findCommand(mem0, "status")!;
@@ -1280,104 +1284,6 @@ describe("registerCliCommands", () => {
});
});
// ========================================================================
// dream subcommand
// ========================================================================
describe("dream subcommand", () => {
it("fetches memories and outputs dream prompt to stdout", async () => {
const { mem0, provider } = setup();
provider.getAll.mockResolvedValueOnce([
{
id: "m1",
memory: "User is an engineer",
categories: ["identity"],
metadata: { category: "identity", importance: 0.9 },
created_at: "2026-01-01",
},
]);
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const dreamCmd = findCommand(mem0, "dream")!;
await dreamCmd._action!({});
expect(provider.getAll).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "testuser",
source: "OPENCLAW",
}),
);
expect(loadDreamPrompt).toHaveBeenCalled();
// stdout should contain the dream prompt
const stdoutOutput = stdoutSpy.mock.calls.map((c) => c[0]).join("");
expect(stdoutOutput).toContain("<dream-protocol>");
expect(stdoutOutput).toContain("dream prompt");
expect(stdoutOutput).toContain("<all-memories");
expect(stdoutOutput).toContain("User is an engineer");
stdoutSpy.mockRestore();
});
it("prints dry-run message and does not output dream prompt", async () => {
const { mem0, provider } = setup();
provider.getAll.mockResolvedValueOnce([
{ id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
]);
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const dreamCmd = findCommand(mem0, "dream")!;
await dreamCmd._action!({ dryRun: true });
// Dry run should write inventory to stderr, NOT dream prompt to stdout
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining("Dry run"),
);
expect(stdoutSpy).not.toHaveBeenCalled();
stdoutSpy.mockRestore();
});
it("prints message when no memories to consolidate", async () => {
const { mem0, provider } = setup();
provider.getAll.mockResolvedValueOnce([]);
const dreamCmd = findCommand(mem0, "dream")!;
await dreamCmd._action!({});
expect(consoleSpy.log).toHaveBeenCalledWith(
"No memories to consolidate.",
);
});
it("prints error when dream skill file is not found", async () => {
const { mem0, provider } = setup();
provider.getAll.mockResolvedValueOnce([
{ id: "m1", memory: "test", categories: [], metadata: {}, created_at: "2026-01-01" },
]);
(loadDreamPrompt as ReturnType<typeof vi.fn>).mockReturnValueOnce("");
const dreamCmd = findCommand(mem0, "dream")!;
await dreamCmd._action!({});
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining("Dream skill file not found"),
);
});
it("handles dream errors gracefully", async () => {
const { mem0, provider } = setup();
provider.getAll.mockRejectedValueOnce(new Error("dream boom"));
const dreamCmd = findCommand(mem0, "dream")!;
await dreamCmd._action!({});
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Dream failed"),
);
});
});
// ========================================================================
// import subcommand
// ========================================================================
@@ -1647,7 +1553,7 @@ describe("registerCliCommands", () => {
// ========================================================================
describe("--json flag registration", () => {
for (const name of ["search", "add", "get", "list", "update", "delete", "status", "import", "dream"]) {
for (const name of ["search", "add", "get", "list", "update", "delete", "status", "import"]) {
it(`registers --json on ${name}`, () => {
const { mem0 } = setup();
const cmd = findCommand(mem0, name)!;
+10 -7
View File
@@ -1,6 +1,8 @@
/**
* Tests for config.ts — mem0ConfigSchema.parse() and exported constants.
*/
import { readFileSync } from "node:fs";
import { describe, it, expect } from "vitest";
import {
mem0ConfigSchema,
@@ -8,6 +10,14 @@ import {
DEFAULT_CUSTOM_CATEGORIES,
} from "../config.ts";
describe("plugin manifest", () => {
it("matches the package version", () => {
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const manifest = JSON.parse(readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
expect(manifest.version).toBe(packageJson.version);
});
});
// ---------------------------------------------------------------------------
// Exported constants
// ---------------------------------------------------------------------------
@@ -379,13 +389,6 @@ describe("mem0ConfigSchema.parse() — skills config", () => {
tokenBudget: 2000,
maxMemories: 10,
},
dream: {
enabled: true,
auto: true,
minHours: 12,
minSessions: 3,
minMemories: 15,
},
domain: "engineering",
customRules: {
include: ["tool configs"],
@@ -1,376 +0,0 @@
/**
* Tests for dream-gate.ts — activity tracking, gate logic, and lock mechanism
* for automatic memory consolidation.
*
* All filesystem operations are mocked via fs-safe.ts.
* Time-dependent tests use vi.useFakeTimers().
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("../fs-safe.ts", () => ({
readText: vi.fn(),
writeText: vi.fn(),
mkdirp: vi.fn(),
unlink: vi.fn(),
}));
import { readText, writeText, mkdirp, unlink } from "../fs-safe.ts";
import {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
getDreamState,
} from "../dream-gate.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockReadText = readText as ReturnType<typeof vi.fn>;
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
const mockUnlink = unlink as ReturnType<typeof vi.fn>;
const STATE_DIR = "/tmp/test-state";
interface DreamState {
lastConsolidatedAt: number;
sessionsSince: number;
lastSessionId: string | null;
}
function setDreamState(state: DreamState): void {
mockReadText.mockImplementation((filePath: string) => {
if (filePath.endsWith("dream-state.json")) {
return JSON.stringify(state);
}
throw new Error("ENOENT");
});
}
function setNoState(): void {
mockReadText.mockImplementation(() => {
throw new Error("ENOENT");
});
}
function getWrittenState(): DreamState {
const call = mockWriteText.mock.calls.find((c: unknown[]) =>
(c[0] as string).endsWith("dream-state.json"),
);
if (!call) throw new Error("No state file written");
return JSON.parse(call[1] as string);
}
beforeEach(() => {
vi.resetAllMocks();
mockMkdirp.mockReturnValue(undefined);
mockUnlink.mockReturnValue(undefined);
});
// ---------------------------------------------------------------------------
// incrementSessionCount
// ---------------------------------------------------------------------------
describe("incrementSessionCount", () => {
it("increments counter for a new session", () => {
setDreamState({
lastConsolidatedAt: 0,
sessionsSince: 3,
lastSessionId: "session-old",
});
incrementSessionCount(STATE_DIR, "session-new");
const written = getWrittenState();
expect(written.sessionsSince).toBe(4);
expect(written.lastSessionId).toBe("session-new");
});
it("deduplicates same session (no increment)", () => {
setDreamState({
lastConsolidatedAt: 0,
sessionsSince: 3,
lastSessionId: "session-same",
});
incrementSessionCount(STATE_DIR, "session-same");
// writeText should NOT have been called for the state file
const stateWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
(c[0] as string).endsWith("dream-state.json"),
);
expect(stateWrites).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// checkCheapGates
// ---------------------------------------------------------------------------
describe("checkCheapGates", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("fails time gate when consolidation was too recent", () => {
const now = Date.now();
vi.setSystemTime(now);
// Last consolidated 1 hour ago, but minHours is 24
setDreamState({
lastConsolidatedAt: now - 1 * 3_600_000,
sessionsSince: 100,
lastSessionId: null,
});
const result = checkCheapGates(STATE_DIR, { minHours: 24, minSessions: 5 });
expect(result.proceed).toBe(false);
expect(result.reason).toContain("time");
});
it("fails session gate when too few sessions", () => {
const now = Date.now();
vi.setSystemTime(now);
// Last consolidated 48 hours ago (passes time gate), but only 2 sessions
setDreamState({
lastConsolidatedAt: now - 48 * 3_600_000,
sessionsSince: 2,
lastSessionId: null,
});
const result = checkCheapGates(STATE_DIR, {
minHours: 24,
minSessions: 5,
});
expect(result.proceed).toBe(false);
expect(result.reason).toContain("sessions");
});
it("passes both gates when conditions are met", () => {
const now = Date.now();
vi.setSystemTime(now);
// 48 hours ago, 10 sessions — both gates pass
setDreamState({
lastConsolidatedAt: now - 48 * 3_600_000,
sessionsSince: 10,
lastSessionId: null,
});
const result = checkCheapGates(STATE_DIR, {
minHours: 24,
minSessions: 5,
});
expect(result.proceed).toBe(true);
expect(result.reason).toBeUndefined();
});
it("uses defaults when config is empty", () => {
const now = Date.now();
vi.setSystemTime(now);
// Never consolidated (0), 100 sessions — should pass with defaults (24h, 5 sessions)
setDreamState({
lastConsolidatedAt: 0,
sessionsSince: 100,
lastSessionId: null,
});
const result = checkCheapGates(STATE_DIR, {});
expect(result.proceed).toBe(true);
});
});
// ---------------------------------------------------------------------------
// checkMemoryGate
// ---------------------------------------------------------------------------
describe("checkMemoryGate", () => {
it("fails when too few memories", () => {
const result = checkMemoryGate(5, { minMemories: 20 });
expect(result.pass).toBe(false);
expect(result.reason).toContain("memories");
expect(result.reason).toContain("5");
});
it("passes when enough memories", () => {
const result = checkMemoryGate(25, { minMemories: 20 });
expect(result.pass).toBe(true);
expect(result.reason).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// acquireDreamLock
// ---------------------------------------------------------------------------
describe("acquireDreamLock", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("succeeds when no lock exists", () => {
const now = Date.now();
vi.setSystemTime(now);
// readText throws for lock file (not found), writeText succeeds for wx create
mockReadText.mockImplementation(() => {
throw new Error("ENOENT");
});
mockWriteText.mockReturnValue(undefined);
const result = acquireDreamLock(STATE_DIR);
expect(result).toBe(true);
// Verify it wrote a lock file with wx flag
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
(c[0] as string).endsWith("dream.lock"),
);
expect(lockWrite).toBeDefined();
const lockData = JSON.parse(lockWrite![1] as string);
expect(lockData.pid).toBe(process.pid);
expect(lockData.startedAt).toBe(now);
expect(lockWrite![2]).toEqual({ flag: "wx" });
});
it("fails when lock exists and is fresh", () => {
const now = Date.now();
vi.setSystemTime(now);
// Lock was created 10 minutes ago — still fresh (< 1 hour)
mockReadText.mockImplementation((filePath: string) => {
if (filePath.endsWith("dream.lock")) {
return JSON.stringify({
pid: 12345,
startedAt: now - 10 * 60 * 1000,
});
}
throw new Error("ENOENT");
});
const result = acquireDreamLock(STATE_DIR);
expect(result).toBe(false);
// Should NOT have written a new lock
const lockWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
(c[0] as string).endsWith("dream.lock"),
);
expect(lockWrites).toHaveLength(0);
});
it("succeeds when lock is stale (>1hr old)", () => {
const now = Date.now();
vi.setSystemTime(now);
// Lock was created 2 hours ago — stale
mockReadText.mockImplementation((filePath: string) => {
if (filePath.endsWith("dream.lock")) {
return JSON.stringify({
pid: 99999,
startedAt: now - 2 * 60 * 60 * 1000,
});
}
throw new Error("ENOENT");
});
mockWriteText.mockReturnValue(undefined);
const result = acquireDreamLock(STATE_DIR);
expect(result).toBe(true);
// Should have unlinked the stale lock
expect(mockUnlink).toHaveBeenCalled();
// Should have written a new lock
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
(c[0] as string).endsWith("dream.lock"),
);
expect(lockWrite).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// releaseDreamLock
// ---------------------------------------------------------------------------
describe("releaseDreamLock", () => {
it("removes lock file", () => {
releaseDreamLock(STATE_DIR);
expect(mockUnlink).toHaveBeenCalledWith(
expect.stringContaining("dream.lock"),
);
});
});
// ---------------------------------------------------------------------------
// recordDreamCompletion
// ---------------------------------------------------------------------------
describe("recordDreamCompletion", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("resets session counter and records timestamp", () => {
const now = 1700000000000;
vi.setSystemTime(now);
setDreamState({
lastConsolidatedAt: 0,
sessionsSince: 15,
lastSessionId: "session-xyz",
});
recordDreamCompletion(STATE_DIR);
const written = getWrittenState();
expect(written.lastConsolidatedAt).toBe(now);
expect(written.sessionsSince).toBe(0);
expect(written.lastSessionId).toBeNull();
});
});
// ---------------------------------------------------------------------------
// getDreamState
// ---------------------------------------------------------------------------
describe("getDreamState", () => {
it("returns default state when no file exists", () => {
setNoState();
const state = getDreamState(STATE_DIR);
expect(state).toEqual({
lastConsolidatedAt: 0,
sessionsSince: 0,
lastSessionId: null,
});
});
it("returns persisted state when file exists", () => {
const persisted = {
lastConsolidatedAt: 1700000000000,
sessionsSince: 7,
lastSessionId: "session-abc",
};
setDreamState(persisted);
const state = getDreamState(STATE_DIR);
expect(state).toEqual(persisted);
});
});
+2 -2
View File
@@ -7,7 +7,7 @@
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": ".",
"rootDir": "..",
"strict": false,
"noImplicitAny": false,
"types": ["node"],
@@ -19,6 +19,6 @@
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts", "backend/**/*.ts", "tools/**/*.ts", "cli/**/*.ts", "skill-loader.ts", "recall.ts", "dream-gate.ts", "telemetry.ts", "fs-safe.ts"],
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts", "backend/**/*.ts", "tools/**/*.ts", "cli/**/*.ts", "skill-loader.ts", "recall.ts", "telemetry.ts", "fs-safe.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
-11
View File
@@ -87,17 +87,6 @@ export interface SkillsConfig {
identityAlwaysInclude?: boolean;
categoryOrder?: string[];
};
dream?: {
enabled?: boolean;
/** Enable automatic triggering based on activity gates. Default: true when dream enabled. */
auto?: boolean;
/** Minimum hours between consolidations. Default: 24. */
minHours?: number;
/** Minimum interactive sessions before triggering. Default: 5. */
minSessions?: number;
/** Minimum total memories to justify consolidation. Default: 20. */
minMemories?: number;
};
domain?: string;
customRules?: {
include?: string[];