refactor(integrations): shared agent plugin runtimes and native adapters (#7203)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user