refactor(opencode): use existing mem0 SDK instead of delegating to MCP, load skills properly instead of dumping them in .opencode (#5323)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
co-authored by
kartik-mem0
parent
703e8a035d
commit
9ed1983b85
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Auto-dream: gated automatic memory consolidation for the Mem0 OpenCode plugin.
|
||||
*
|
||||
* Ported from the (stable) pi-agent plugin's dream module and adapted to
|
||||
* OpenCode's hook model. When the cheap gates (time since last consolidation +
|
||||
* sessions since) and the memory-count gate all pass, the plugin injects the
|
||||
* DREAM_PROTOCOL into the agent's context so it consolidates memories (merge
|
||||
* duplicates, drop stale/sensitive entries, rewrite vague ones) before
|
||||
* answering. A filesystem lock prevents concurrent sessions from dreaming at
|
||||
* once, and completion is recorded so it won't re-trigger until the next cycle.
|
||||
*
|
||||
* State + lock live in ~/.mem0/ alongside settings.json. Opt out with
|
||||
* MEM0_DREAM=false, or tune via the `dream` block in ~/.mem0/settings.json.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface DreamConfig {
|
||||
enabled: boolean;
|
||||
auto: boolean;
|
||||
minHours: number;
|
||||
minSessions: number;
|
||||
minMemories: number;
|
||||
}
|
||||
|
||||
interface DreamState {
|
||||
lastConsolidatedAt: number;
|
||||
sessionsSince: number;
|
||||
lastSessionId: string | null;
|
||||
}
|
||||
|
||||
interface DreamLock {
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
const LOCK_STALE_MS = 60 * 60 * 1000;
|
||||
|
||||
export const DREAM_DEFAULTS: DreamConfig = {
|
||||
enabled: true,
|
||||
auto: true,
|
||||
minHours: 24,
|
||||
minSessions: 5,
|
||||
minMemories: 20,
|
||||
};
|
||||
|
||||
function statePath(stateDir: string): string {
|
||||
return join(stateDir, "mem0-dream-state.json");
|
||||
}
|
||||
|
||||
function lockPath(stateDir: string): string {
|
||||
return join(stateDir, "mem0-dream.lock");
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
} catch {
|
||||
/* exists */
|
||||
}
|
||||
}
|
||||
|
||||
function readState(stateDir: string): DreamState {
|
||||
try {
|
||||
return JSON.parse(readFileSync(statePath(stateDir), "utf-8")) as DreamState;
|
||||
} catch {
|
||||
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(stateDir: string, state: DreamState): void {
|
||||
ensureDir(stateDir);
|
||||
writeFileSync(statePath(stateDir), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dream config from ~/.mem0/settings.json (`dream` block), applying
|
||||
* defaults. MEM0_DREAM=false (or 0/no/off) force-disables regardless.
|
||||
*/
|
||||
export function loadDreamConfig(settingsDir: string): DreamConfig {
|
||||
let envEnabled: boolean | undefined;
|
||||
const env = process.env.MEM0_DREAM;
|
||||
if (env !== undefined) {
|
||||
const s = env.toLowerCase();
|
||||
envEnabled = s !== "false" && s !== "0" && s !== "no" && s !== "off";
|
||||
}
|
||||
|
||||
let cfg: DreamConfig = { ...DREAM_DEFAULTS };
|
||||
try {
|
||||
const sp = join(settingsDir, "settings.json");
|
||||
if (existsSync(sp)) {
|
||||
const settings = JSON.parse(readFileSync(sp, "utf-8"));
|
||||
const d = settings?.dream;
|
||||
if (d && typeof d === "object") {
|
||||
cfg = {
|
||||
enabled: typeof d.enabled === "boolean" ? d.enabled : cfg.enabled,
|
||||
auto: typeof d.auto === "boolean" ? d.auto : cfg.auto,
|
||||
minHours: typeof d.minHours === "number" ? d.minHours : cfg.minHours,
|
||||
minSessions: typeof d.minSessions === "number" ? d.minSessions : cfg.minSessions,
|
||||
minMemories: typeof d.minMemories === "number" ? d.minMemories : cfg.minMemories,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* defaults */
|
||||
}
|
||||
|
||||
if (envEnabled !== undefined) cfg.enabled = envEnabled;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Count a new session toward the dream gate (once per distinct 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Cheap gates that don't need an API call: time since last + sessions since. */
|
||||
export function checkCheapGates(
|
||||
stateDir: string,
|
||||
config: Partial<DreamConfig>,
|
||||
): { proceed: boolean; reason?: string } {
|
||||
const minHours = config.minHours ?? DREAM_DEFAULTS.minHours;
|
||||
const minSessions = config.minSessions ?? DREAM_DEFAULTS.minSessions;
|
||||
const state = readState(stateDir);
|
||||
|
||||
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
|
||||
if (hoursSince < minHours) {
|
||||
return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` };
|
||||
}
|
||||
if (state.sessionsSince < minSessions) {
|
||||
return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` };
|
||||
}
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
/** Memory-count gate (uses the count already fetched at session init). */
|
||||
export function checkMemoryGate(
|
||||
memoryCount: number,
|
||||
config: Partial<DreamConfig>,
|
||||
): { pass: boolean; reason?: string } {
|
||||
const minMemories = config.minMemories ?? DREAM_DEFAULTS.minMemories;
|
||||
if (memoryCount < minMemories) {
|
||||
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
|
||||
}
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
/** Acquire an exclusive dream lock (stale locks > 1h are reclaimed). */
|
||||
export function acquireDreamLock(stateDir: string): boolean {
|
||||
ensureDir(stateDir);
|
||||
const lp = lockPath(stateDir);
|
||||
|
||||
try {
|
||||
const lock = JSON.parse(readFileSync(lp, "utf-8")) as DreamLock;
|
||||
if (Date.now() - lock.startedAt < LOCK_STALE_MS) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
unlinkSync(lp);
|
||||
} catch {
|
||||
/* race ok */
|
||||
}
|
||||
} catch {
|
||||
/* no lock file */
|
||||
}
|
||||
|
||||
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
|
||||
try {
|
||||
writeFileSync(lp, JSON.stringify(lock), { flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseDreamLock(stateDir: string): void {
|
||||
try {
|
||||
unlinkSync(lockPath(stateDir));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the gates after a successful consolidation. */
|
||||
export function recordDreamCompletion(stateDir: string): void {
|
||||
const state = readState(stateDir);
|
||||
state.lastConsolidatedAt = Date.now();
|
||||
state.sessionsSince = 0;
|
||||
state.lastSessionId = null;
|
||||
writeState(stateDir, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation protocol injected into the agent context when a dream is
|
||||
* triggered. Uses the plugin's native OpenCode memory tools (get_memories /
|
||||
* add_memory / delete_memory) rather than an MCP tool.
|
||||
*/
|
||||
export const DREAM_PROTOCOL = `<mem0-dream>
|
||||
You are running memory consolidation. Complete these steps using the mem0 memory tools (get_memories, add_memory, delete_memory):
|
||||
|
||||
1. ORIENT — Call get_memories to list all memories. Count by category. Note oldest/newest.
|
||||
|
||||
2. GATHER TARGETS — Review each memory. Classify as:
|
||||
- DELETE: sensitive information (API keys, passwords, tokens), expired/stale entries, noise, redundant operational details
|
||||
- MERGE: near-duplicates (same fact stated differently). Keep the better-worded one, delete the other.
|
||||
- REWRITE: vague, first-person, or poorly-categorized entries. add_memory with improved text, then delete_memory the old one.
|
||||
- KEEP: everything else.
|
||||
Skip any memory starting with "[PINNED]".
|
||||
|
||||
3. CONSOLIDATE — Execute the changes:
|
||||
- Delete stale/duplicate entries with delete_memory
|
||||
- For merges: add_memory the merged text, delete_memory both originals
|
||||
- For rewrites: add_memory the improved version, delete_memory the original
|
||||
|
||||
4. REPORT — Summarize: how many reviewed, deleted, merged, rewritten, final count.
|
||||
|
||||
Quality targets: zero sensitive data stored, zero duplicates, all entries are atomic (one fact each), 15-50 words each.
|
||||
After consolidation, respond to the user's message normally.
|
||||
</mem0-dream>`;
|
||||
Reference in New Issue
Block a user