feat(openclaw): skills-based memory architecture with batched extraction (#4624)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e2b439c42a
commit
c250ccfb5c
@@ -0,0 +1,175 @@
|
||||
# ============================================================================
|
||||
# Agentic Memory Skills — OpenClaw Plugin Installer
|
||||
# ============================================================================
|
||||
#
|
||||
# Usage:
|
||||
# make install # Interactive — prompts for API key
|
||||
# make install MEM0_API_KEY=m0-xxx # Non-interactive
|
||||
# make uninstall # Revert to stock plugin
|
||||
# make restart # Rebuild + restart gateway
|
||||
# make status # Check everything is working
|
||||
# make logs # Tail gateway logs filtered to mem0
|
||||
# make clean # Full teardown
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
SHELL := /bin/bash
|
||||
.PHONY: install uninstall restart status logs clean build check-deps configure help
|
||||
|
||||
# Defaults
|
||||
MEM0_API_KEY ?=
|
||||
MEM0_USER_ID ?= $(shell whoami)
|
||||
OPENCLAW_CONFIG := $(HOME)/.openclaw/openclaw.json
|
||||
PLUGIN_DIR := $(shell pwd)
|
||||
|
||||
help: ## Show this help
|
||||
@echo ""
|
||||
@echo " Agentic Memory Skills — OpenClaw Plugin"
|
||||
@echo " ========================================"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Dependency checks
|
||||
# ============================================================================
|
||||
|
||||
check-deps:
|
||||
@echo "Checking dependencies..."
|
||||
@command -v node >/dev/null 2>&1 || { echo "Error: Node.js is required. Install from https://nodejs.org"; exit 1; }
|
||||
@NODE_MAJOR=$$(node -v | sed 's/v//' | cut -d. -f1); \
|
||||
if [ "$$NODE_MAJOR" -lt 18 ]; then echo "Error: Node.js >= 18 required (found $$(node -v))"; exit 1; fi
|
||||
@command -v npm >/dev/null 2>&1 || { echo "Error: npm is required"; exit 1; }
|
||||
@if ! command -v openclaw >/dev/null 2>&1; then \
|
||||
echo "OpenClaw not found. Installing..."; \
|
||||
npm install -g openclaw || { echo "Error: Failed to install OpenClaw. Try: sudo npm install -g openclaw"; exit 1; }; \
|
||||
echo "OpenClaw installed: $$(openclaw --version)"; \
|
||||
else \
|
||||
echo "OpenClaw: $$(openclaw --version 2>/dev/null || echo 'installed')"; \
|
||||
fi
|
||||
@echo "Node.js: $$(node -v)"
|
||||
@echo "All dependencies OK."
|
||||
|
||||
# ============================================================================
|
||||
# Build
|
||||
# ============================================================================
|
||||
|
||||
build: ## Build the plugin from source
|
||||
@echo "Installing npm dependencies..."
|
||||
@npm install --silent 2>/dev/null
|
||||
@echo "Building plugin..."
|
||||
@npm run build --silent 2>/dev/null
|
||||
@echo "Build OK: $$(ls -lh dist/index.js | awk '{print $$5}') ESM bundle"
|
||||
|
||||
# ============================================================================
|
||||
# Install
|
||||
# ============================================================================
|
||||
|
||||
install: check-deps build ## Full install — build, link, configure, restart
|
||||
@# Remove existing plugin (suppress errors if not installed)
|
||||
@echo "Removing existing openclaw-mem0 plugin (if any)..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@# Link local build
|
||||
@echo "Linking local plugin build..."
|
||||
@openclaw plugins install "$(PLUGIN_DIR)" --link 2>&1 | grep -v "plugins.allow"
|
||||
@# Configure AFTER link (link may overwrite config)
|
||||
@$(MAKE) --no-print-directory configure
|
||||
@# Restart gateway
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo ""
|
||||
@echo "============================================"
|
||||
@echo " Installation complete!"
|
||||
@echo "============================================"
|
||||
@echo ""
|
||||
@echo " Plugin: openclaw-mem0 (skills mode)"
|
||||
@echo " User: $(MEM0_USER_ID)"
|
||||
@echo " Web UI: http://127.0.0.1:18789"
|
||||
@echo ""
|
||||
@echo " Verify with: make status"
|
||||
@echo " View logs: make logs"
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Configure — patch openclaw.json with required settings
|
||||
# ============================================================================
|
||||
|
||||
configure:
|
||||
@if [ -z "$(MEM0_API_KEY)" ]; then \
|
||||
echo ""; \
|
||||
echo " Enter your Mem0 API key (from https://app.mem0.ai/dashboard/api-keys):"; \
|
||||
echo -n " > "; \
|
||||
read -r key; \
|
||||
if [ -z "$$key" ]; then echo "Error: API key is required."; exit 1; fi; \
|
||||
MEM0_API_KEY="$$key" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
|
||||
else \
|
||||
MEM0_API_KEY="$(MEM0_API_KEY)" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Status & Logs
|
||||
# ============================================================================
|
||||
|
||||
status: ## Check plugin health and skills registration
|
||||
@echo "Plugin health:"
|
||||
@openclaw plugins doctor 2>&1 | grep -v "plugins.allow"
|
||||
@echo ""
|
||||
@echo "Skills:"
|
||||
@openclaw skills list 2>&1 | grep -E "memory|Status|---" | head -10
|
||||
@echo ""
|
||||
@echo "Last gateway registration:"
|
||||
@tail -50 /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep "openclaw-mem0: registered" | tail -1 | grep -o '"1":"[^"]*"' || echo " No gateway log found. Is the gateway running?"
|
||||
|
||||
logs: ## Tail gateway logs (mem0 activity only)
|
||||
@tail -f /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep --line-buffered -o '"1":"[^"]*"' | grep --line-buffered -i "mem0\|skills-mode\|stored\|inject\|recall"
|
||||
|
||||
# ============================================================================
|
||||
# Restart
|
||||
# ============================================================================
|
||||
|
||||
restart: build ## Rebuild plugin and restart gateway
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo "Gateway restarted. Verify: make status"
|
||||
|
||||
# ============================================================================
|
||||
# Uninstall — revert to stock plugin
|
||||
# ============================================================================
|
||||
|
||||
uninstall: ## Revert to stock openclaw-mem0 from npm
|
||||
@echo "Uninstalling local plugin..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@echo "Installing stock plugin from npm..."
|
||||
@openclaw plugins install @mem0/openclaw-mem0 2>&1 | grep -v "plugins.allow"
|
||||
@# Restore config backup if it exists
|
||||
@if [ -f "$(OPENCLAW_CONFIG).pre-skills-backup" ]; then \
|
||||
cp "$(OPENCLAW_CONFIG).pre-skills-backup" "$(OPENCLAW_CONFIG)"; \
|
||||
echo "Restored config from backup."; \
|
||||
else \
|
||||
echo "Note: No config backup found. You may need to manually revert openclaw.json changes."; \
|
||||
fi
|
||||
@echo "Restarting gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@sleep 2
|
||||
@openclaw gateway >/dev/null 2>&1 &
|
||||
@sleep 4
|
||||
@echo "Reverted to stock openclaw-mem0."
|
||||
|
||||
# ============================================================================
|
||||
# Clean
|
||||
# ============================================================================
|
||||
|
||||
clean: ## Full teardown — stop gateway, remove plugin, clean build
|
||||
@echo "Stopping gateway..."
|
||||
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
|
||||
@echo "Removing plugin..."
|
||||
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf dist/ node_modules/
|
||||
@echo "Clean complete."
|
||||
@@ -171,6 +171,7 @@ const ALLOWED_KEYS = [
|
||||
"searchThreshold",
|
||||
"topK",
|
||||
"oss",
|
||||
"skills",
|
||||
];
|
||||
|
||||
function assertAllowedKeys(
|
||||
@@ -241,6 +242,10 @@ export const mem0ConfigSchema = {
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
oss: ossConfig,
|
||||
skills:
|
||||
cfg.skills && typeof cfg.skills === "object" && !Array.isArray(cfg.skills)
|
||||
? (cfg.skills as Mem0Config["skills"])
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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 fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ============================================================================
|
||||
// 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 {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch { /* exists */ }
|
||||
}
|
||||
|
||||
function readState(stateDir: string): DreamState {
|
||||
try {
|
||||
const raw = fs.readFileSync(statePath(stateDir), "utf-8");
|
||||
return JSON.parse(raw) as DreamState;
|
||||
} catch {
|
||||
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(stateDir: string, state: DreamState): void {
|
||||
ensureDir(stateDir);
|
||||
fs.writeFileSync(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 all three gates. Returns true only if ALL pass.
|
||||
* Gates are checked cheapest-first (time, session, memory).
|
||||
*/
|
||||
export async function shouldDream(
|
||||
stateDir: string,
|
||||
config: { minHours?: number; minSessions?: number; minMemories?: number },
|
||||
memoryCount: number,
|
||||
): Promise<{ pass: boolean; reason?: string }> {
|
||||
const cfg: DreamGateConfig = {
|
||||
minHours: config.minHours ?? DEFAULTS.minHours,
|
||||
minSessions: config.minSessions ?? DEFAULTS.minSessions,
|
||||
minMemories: config.minMemories ?? DEFAULTS.minMemories,
|
||||
};
|
||||
|
||||
const state = readState(stateDir);
|
||||
|
||||
// Gate 1: Time
|
||||
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
|
||||
if (hoursSince < cfg.minHours) {
|
||||
return { pass: false, reason: `time: ${hoursSince.toFixed(1)}h < ${cfg.minHours}h` };
|
||||
}
|
||||
|
||||
// Gate 2: Sessions
|
||||
if (state.sessionsSince < cfg.minSessions) {
|
||||
return { pass: false, reason: `sessions: ${state.sessionsSince} < ${cfg.minSessions}` };
|
||||
}
|
||||
|
||||
// Gate 3: Memory count
|
||||
if (memoryCount < cfg.minMemories) {
|
||||
return { pass: false, reason: `memories: ${memoryCount} < ${cfg.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 = fs.readFileSync(lp, "utf-8");
|
||||
const lock = JSON.parse(raw) as DreamLock;
|
||||
const age = Date.now() - lock.startedAt;
|
||||
if (age < LOCK_STALE_MS) {
|
||||
// Lock is held and not stale
|
||||
return false;
|
||||
}
|
||||
// Stale lock, reclaim
|
||||
} catch {
|
||||
// No lock file, proceed
|
||||
}
|
||||
|
||||
// Write lock
|
||||
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
|
||||
fs.writeFileSync(lp, JSON.stringify(lock));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the dream lock and record successful completion.
|
||||
*/
|
||||
export function releaseDreamLock(stateDir: string): void {
|
||||
try {
|
||||
fs.unlinkSync(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);
|
||||
}
|
||||
+558
-21
@@ -38,6 +38,21 @@ import {
|
||||
isNonInteractiveTrigger,
|
||||
isSubagentSession,
|
||||
} from "./isolation.ts";
|
||||
import {
|
||||
loadTriagePrompt,
|
||||
loadDreamPrompt,
|
||||
resolveCategories,
|
||||
ttlToExpirationDate,
|
||||
isSkillsMode,
|
||||
} from "./skill-loader.ts";
|
||||
import { recall as skillRecall, sanitizeQuery } from "./recall.ts";
|
||||
import {
|
||||
incrementSessionCount,
|
||||
shouldDream,
|
||||
acquireDreamLock,
|
||||
releaseDreamLock,
|
||||
recordDreamCompletion,
|
||||
} from "./dream-gate.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports (for tests and external consumers)
|
||||
@@ -95,8 +110,9 @@ const memoryPlugin = {
|
||||
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
|
||||
resolveUserId(cfg.userId, opts, currentSessionId);
|
||||
|
||||
const skillsActive = isSkillsMode(cfg.skills);
|
||||
api.logger.info(
|
||||
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
||||
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`,
|
||||
);
|
||||
|
||||
// Helper: build add options
|
||||
@@ -115,22 +131,24 @@ const memoryPlugin = {
|
||||
return opts;
|
||||
}
|
||||
|
||||
// Helper: build search options
|
||||
// Helper: build search options (skills config overrides legacy defaults)
|
||||
function buildSearchOptions(
|
||||
userIdOverride?: string,
|
||||
limit?: number,
|
||||
runId?: string,
|
||||
sessionKey?: string,
|
||||
): SearchOptions {
|
||||
const recallCfg = cfg.skills?.recall;
|
||||
const opts: SearchOptions = {
|
||||
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
||||
top_k: limit ?? cfg.topK,
|
||||
limit: limit ?? cfg.topK,
|
||||
threshold: cfg.searchThreshold,
|
||||
keyword_search: true,
|
||||
reranking: true,
|
||||
threshold: recallCfg?.threshold ?? cfg.searchThreshold,
|
||||
keyword_search: recallCfg?.keywordSearch !== false,
|
||||
reranking: recallCfg?.rerank !== false,
|
||||
source: "OPENCLAW",
|
||||
};
|
||||
if (recallCfg?.filterMemories) opts.filter_memories = true;
|
||||
if (runId) opts.run_id = runId;
|
||||
return opts;
|
||||
}
|
||||
@@ -139,7 +157,7 @@ const memoryPlugin = {
|
||||
// Tools
|
||||
// ========================================================================
|
||||
|
||||
registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId);
|
||||
registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId, skillsActive);
|
||||
|
||||
// ========================================================================
|
||||
// CLI Commands
|
||||
@@ -153,17 +171,22 @@ const memoryPlugin = {
|
||||
|
||||
registerHooks(api, provider, cfg, _effectiveUserId, buildAddOptions, buildSearchOptions, {
|
||||
setCurrentSessionId: (id: string) => { currentSessionId = id; },
|
||||
});
|
||||
getStateDir: () => pluginStateDir,
|
||||
}, skillsActive);
|
||||
|
||||
// ========================================================================
|
||||
// Service
|
||||
// ========================================================================
|
||||
|
||||
// State directory for persistent gate tracking (dream consolidation)
|
||||
let pluginStateDir: string | undefined;
|
||||
|
||||
api.registerService({
|
||||
id: "openclaw-mem0",
|
||||
start: () => {
|
||||
start: (...args: any[]) => {
|
||||
pluginStateDir = args[0]?.stateDir;
|
||||
api.logger.info(
|
||||
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
||||
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, stateDir: ${pluginStateDir ?? "none"})`,
|
||||
);
|
||||
},
|
||||
stop: () => {
|
||||
@@ -187,6 +210,7 @@ function registerTools(
|
||||
buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions,
|
||||
buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions,
|
||||
getCurrentSessionId: () => string | undefined,
|
||||
skillsActive: boolean = false,
|
||||
) {
|
||||
api.registerTool(
|
||||
{
|
||||
@@ -223,14 +247,28 @@ function registerTools(
|
||||
'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
|
||||
}),
|
||||
),
|
||||
categories: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description:
|
||||
'Filter results by category (e.g. ["identity", "preference"]). Only returns memories tagged with these categories.',
|
||||
}),
|
||||
),
|
||||
filters: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description:
|
||||
'Advanced filters object. Supports date ranges and metadata filtering. Examples: {"created_at": {"gte": "2026-03-01"}} for recent memories, {"AND": [{"categories": {"contains": "decision"}}, {"created_at": {"gte": "2026-01-01"}}]} for decisions this year. Operators: eq, ne, gt, gte, lt, lte, in, contains, icontains. Logical: AND, OR, NOT.',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { query, limit, userId, agentId, scope = "all" } = params as {
|
||||
const { query, limit, userId, agentId, scope = "all", categories: filterCategories, filters: agentFilters } = params as {
|
||||
query: string;
|
||||
limit?: number;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
scope?: "session" | "long-term" | "all";
|
||||
categories?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -238,29 +276,36 @@ function registerTools(
|
||||
const uid = _resolveUserId({ agentId, userId });
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
|
||||
// Apply agent-provided filters to search options
|
||||
const applyFilters = (opts: SearchOptions): SearchOptions => {
|
||||
if (filterCategories?.length) opts.categories = filterCategories;
|
||||
if (agentFilters) opts.filters = agentFilters;
|
||||
return opts;
|
||||
};
|
||||
|
||||
if (scope === "session") {
|
||||
if (currentSessionId) {
|
||||
results = await provider.search(
|
||||
query,
|
||||
buildSearchOptions(uid, limit, currentSessionId),
|
||||
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
|
||||
);
|
||||
}
|
||||
} else if (scope === "long-term") {
|
||||
results = await provider.search(
|
||||
query,
|
||||
buildSearchOptions(uid, limit),
|
||||
applyFilters(buildSearchOptions(uid, limit)),
|
||||
);
|
||||
} else {
|
||||
// "all" — search both scopes and combine
|
||||
const longTermResults = await provider.search(
|
||||
query,
|
||||
buildSearchOptions(uid, limit),
|
||||
applyFilters(buildSearchOptions(uid, limit)),
|
||||
);
|
||||
let sessionResults: MemoryItem[] = [];
|
||||
if (currentSessionId) {
|
||||
sessionResults = await provider.search(
|
||||
query,
|
||||
buildSearchOptions(uid, limit, currentSessionId),
|
||||
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
|
||||
);
|
||||
}
|
||||
// Deduplicate by ID, preferring long-term
|
||||
@@ -327,7 +372,25 @@ function registerTools(
|
||||
description:
|
||||
"Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.",
|
||||
parameters: Type.Object({
|
||||
text: Type.String({ description: "Information to remember" }),
|
||||
text: Type.Optional(
|
||||
Type.String({ description: "Single fact to remember. Use 'facts' array instead when storing multiple facts from one conversation turn." }),
|
||||
),
|
||||
facts: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Array of facts to store in one call. ALL facts MUST share the same category. If a turn has facts in different categories, make one call per category. Category determines retention policy (TTL, immutability).",
|
||||
}),
|
||||
),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Memory category. Determines retention policy (TTL, immutability). All facts in this call inherit this category. Options: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"',
|
||||
}),
|
||||
),
|
||||
importance: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Importance override (0.0-1.0). Omit to use category default. Applies to all facts in this call. Defaults: identity/config 0.95, rules 0.90, preferences 0.85, decisions 0.80, projects 0.75, operational 0.60",
|
||||
}),
|
||||
),
|
||||
userId: Type.Optional(
|
||||
Type.String({
|
||||
description: "User ID to scope this memory",
|
||||
@@ -341,7 +404,7 @@ function registerTools(
|
||||
),
|
||||
metadata: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description: "Optional metadata to attach to this memory",
|
||||
description: "Additional metadata to attach to this memory",
|
||||
}),
|
||||
),
|
||||
longTerm: Type.Optional(
|
||||
@@ -352,22 +415,121 @@ function registerTools(
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { text, userId, agentId, longTerm = true } = params as {
|
||||
text: string;
|
||||
const p = params as {
|
||||
text?: string;
|
||||
facts?: string[];
|
||||
category?: string;
|
||||
importance?: number;
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
longTerm?: boolean;
|
||||
};
|
||||
const { userId, agentId, longTerm = true } = p;
|
||||
|
||||
// Resolve facts: prefer 'facts' array, fall back to single 'text'
|
||||
const allFacts: string[] = p.facts?.length ? p.facts : (p.text ? [p.text] : []);
|
||||
if (allFacts.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No facts provided. Pass 'text' or 'facts' array." }],
|
||||
details: { error: "missing_facts" },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const uid = _resolveUserId({ agentId, userId });
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
|
||||
// Block subagent writes at the tool level. The system prompt
|
||||
// instructs subagents not to store, but a disobedient tool call
|
||||
// would write to a transient namespace that is never read again.
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_store from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Memory storage is not available in subagent sessions. The main agent handles memory." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
const uid = _resolveUserId({ agentId, userId });
|
||||
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
|
||||
|
||||
// Skills mode: bypass extraction LLM, store directly via infer=false
|
||||
if (skillsActive) {
|
||||
// Enforce batch homogeneity: if no category provided for a multi-fact
|
||||
// batch, warn. The prompt teaches batch-by-category but this is the
|
||||
// runtime safety net.
|
||||
if (allFacts.length > 1 && !p.category) {
|
||||
api.logger.warn(
|
||||
`openclaw-mem0: multi-fact batch (${allFacts.length} facts) without category. Retention policy defaults to uncategorized. Prompt instructs batch-by-category.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve metadata: prefer explicit params, fall back to metadata record
|
||||
const rawMetadata = p.metadata;
|
||||
const category = p.category ?? rawMetadata?.category as string | undefined;
|
||||
const importance = p.importance ?? rawMetadata?.importance as number | undefined;
|
||||
const parsedMetadata: Record<string, unknown> = {
|
||||
...(rawMetadata ?? {}),
|
||||
...(category && { category }),
|
||||
...(importance !== undefined && { importance }),
|
||||
};
|
||||
const categories = resolveCategories(cfg.skills);
|
||||
const catConfig = category ? categories[category] : undefined;
|
||||
const expirationDate = catConfig ? ttlToExpirationDate(catConfig.ttl) : undefined;
|
||||
const isImmutable = catConfig?.immutable ?? false;
|
||||
|
||||
// Single API call: all facts go as deduced_memories array
|
||||
const addOpts: AddOptions = {
|
||||
user_id: uid,
|
||||
source: "OPENCLAW",
|
||||
infer: false,
|
||||
deduced_memories: allFacts,
|
||||
metadata: parsedMetadata ?? {},
|
||||
...(expirationDate && { expiration_date: expirationDate }),
|
||||
...(isImmutable && { immutable: true }),
|
||||
};
|
||||
if (runId) addOpts.run_id = runId;
|
||||
if (cfg.mode === "platform") {
|
||||
addOpts.output_format = "v1.1";
|
||||
if (cfg.enableGraph || cfg.skills?.triage?.enableGraph) {
|
||||
addOpts.enable_graph = true;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await provider.add(
|
||||
[{ role: "user", content: allFacts.join("\n") }],
|
||||
addOpts,
|
||||
);
|
||||
|
||||
const count = result.results?.length ?? 0;
|
||||
api.logger.info(
|
||||
`openclaw-mem0: skills-mode stored ${count} memor${count === 1 ? "y" : "ies"} from ${allFacts.length} fact(s) in 1 API call (infer=false, category=${category ?? "none"})`,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map(f => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "stored",
|
||||
mode: "skills",
|
||||
infer: false,
|
||||
category,
|
||||
factCount: allFacts.length,
|
||||
results: result.results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy mode: let mem0 extraction LLM handle it
|
||||
const combinedText = allFacts.join("\n");
|
||||
|
||||
// Pre-check for near-duplicates so the extraction model has
|
||||
// context about existing memories and can UPDATE rather than ADD
|
||||
const preview = text.slice(0, 200);
|
||||
const preview = combinedText.slice(0, 200);
|
||||
const dedupOpts = buildSearchOptions(uid, 3);
|
||||
dedupOpts.threshold = 0.85;
|
||||
const existing = await provider.search(preview, dedupOpts);
|
||||
@@ -378,7 +540,7 @@ function registerTools(
|
||||
}
|
||||
|
||||
const result = await provider.add(
|
||||
[{ role: "user", content: text }],
|
||||
[{ role: "user", content: combinedText }],
|
||||
buildAddOptions(uid, runId, currentSessionId),
|
||||
);
|
||||
|
||||
@@ -610,6 +772,16 @@ function registerTools(
|
||||
};
|
||||
|
||||
try {
|
||||
// Block subagent deletes at the tool level.
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_forget from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Memory deletion is not available in subagent sessions. The main agent handles memory." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
if (memoryId) {
|
||||
await provider.delete(memoryId);
|
||||
return {
|
||||
@@ -698,6 +870,150 @@ function registerTools(
|
||||
},
|
||||
{ name: "memory_forget" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memory_update",
|
||||
label: "Memory Update",
|
||||
description:
|
||||
"Update an existing memory's text in place. Use when a fact has changed and you have the memory ID. This is atomic and preserves the memory's history. Preferred over delete-then-store for corrections.",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.String({ description: "The memory ID to update" }),
|
||||
text: Type.String({ description: "The new text for this memory (replaces the old text)" }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { memoryId, text } = params as { memoryId: string; text: string };
|
||||
|
||||
try {
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_update from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Memory update is not available in subagent sessions." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
await provider.update(memoryId, text);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"` },
|
||||
],
|
||||
details: { action: "updated", id: memoryId },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Memory update failed: ${String(err)}` },
|
||||
],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memory_update" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memory_delete_all",
|
||||
label: "Memory Delete All",
|
||||
description:
|
||||
"Delete ALL memories for a user. Use with extreme caution. This is irreversible. Only use when the user explicitly asks to forget everything or reset their memory.",
|
||||
parameters: Type.Object({
|
||||
confirm: Type.Boolean({
|
||||
description: "Must be true to proceed. Safety gate to prevent accidental bulk deletion.",
|
||||
}),
|
||||
userId: Type.Optional(
|
||||
Type.String({ description: "User ID to delete all memories for (default: configured userId)" }),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { confirm, userId } = params as { confirm: boolean; userId?: string };
|
||||
|
||||
try {
|
||||
const currentSessionId = getCurrentSessionId();
|
||||
if (isSubagentSession(currentSessionId)) {
|
||||
api.logger.warn("openclaw-mem0: blocked memory_delete_all from subagent session");
|
||||
return {
|
||||
content: [{ type: "text", text: "Bulk memory deletion is not available in subagent sessions." }],
|
||||
details: { error: "subagent_blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
if (!confirm) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Bulk deletion requires confirm: true. Ask the user to confirm before proceeding." }],
|
||||
details: { error: "confirmation_required" },
|
||||
};
|
||||
}
|
||||
|
||||
const uid = _resolveUserId({ userId });
|
||||
await provider.deleteAll(uid);
|
||||
api.logger.info(`openclaw-mem0: deleted all memories for user ${uid}`);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `All memories deleted for user "${uid}".` },
|
||||
],
|
||||
details: { action: "deleted_all", user_id: uid },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Bulk memory deletion failed: ${String(err)}` },
|
||||
],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memory_delete_all" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memory_history",
|
||||
label: "Memory History",
|
||||
description:
|
||||
"View the edit history of a specific memory. Shows all changes over time including previous values, new values, and timestamps. Useful for understanding how a memory evolved.",
|
||||
parameters: Type.Object({
|
||||
memoryId: Type.String({ description: "The memory ID to view history for" }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { memoryId } = params as { memoryId: string };
|
||||
|
||||
try {
|
||||
const history = await provider.history(memoryId);
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No history found for memory ${memoryId}.` }],
|
||||
details: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const text = history
|
||||
.map((h, i) => `${i + 1}. [${h.event}] ${h.created_at}\n Old: ${h.old_memory || "(none)"}\n New: ${h.new_memory || "(none)"}`)
|
||||
.join("\n\n");
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `History for memory ${memoryId} (${history.length} entries):\n\n${text}` },
|
||||
],
|
||||
details: { count: history.length, history },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Memory history failed: ${String(err)}` },
|
||||
],
|
||||
details: { error: String(err) },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memory_history" },
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -813,6 +1129,72 @@ function registerCli(
|
||||
console.error(`Stats failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
mem0
|
||||
.command("dream")
|
||||
.description("Run memory consolidation (review, merge, prune stored memories)")
|
||||
.option("--dry-run", "Show memory inventory without running consolidation")
|
||||
.action(async (opts: { dryRun?: 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) {
|
||||
console.log("No memories to consolidate.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show current state summary on stderr (keeps stdout clean for piping)
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Load dream prompt and format it with the full memory inventory
|
||||
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
|
||||
if (!dreamPrompt) {
|
||||
process.stderr.write("Dream skill file not found at skills/memory-dream/SKILL.md\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the full dream context: protocol + memory dump
|
||||
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");
|
||||
|
||||
// Only the prompt goes to stdout — safe to pipe directly
|
||||
process.stdout.write(fullPrompt + "\n");
|
||||
process.stderr.write(`Dream prompt written to stdout (${fullPrompt.length} chars). Pipe with: openclaw mem0 dream | openclaw run --stdin\n`);
|
||||
} catch (err) {
|
||||
console.error(`Dream failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ commands: ["mem0"] },
|
||||
);
|
||||
@@ -831,8 +1213,163 @@ function registerHooks(
|
||||
buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions,
|
||||
session: {
|
||||
setCurrentSessionId: (id: string) => void;
|
||||
getStateDir: () => string | undefined;
|
||||
},
|
||||
skillsActive: boolean = false,
|
||||
) {
|
||||
// ========================================================================
|
||||
// SKILLS MODE: Agentic memory via before_prompt_build
|
||||
// ========================================================================
|
||||
if (skillsActive) {
|
||||
// Use before_prompt_build instead of before_agent_start:
|
||||
// - prependSystemContext: static memory protocol (provider-cacheable, no per-turn cost)
|
||||
// - prependContext: dynamic recalled memories (changes every turn)
|
||||
//
|
||||
// NOTE: We previously used a shared `lastCleanUserMessage` variable populated
|
||||
// by message_received to get clean user content. That variable was process-global
|
||||
// mutable state vulnerable to cross-session races. Removed in favor of using
|
||||
// sanitizeQuery() on event.prompt within this hook, where ctx.sessionKey is
|
||||
// available and the execution is scoped to the correct session.
|
||||
api.on("before_prompt_build", async (event: any, ctx: any) => {
|
||||
if (!event.prompt || event.prompt.length < 5) return;
|
||||
|
||||
const trigger = ctx?.trigger ?? undefined;
|
||||
const sessionId = ctx?.sessionKey ?? undefined;
|
||||
if (isNonInteractiveTrigger(trigger, sessionId)) {
|
||||
api.logger.info("openclaw-mem0: skills-mode skipping non-interactive trigger");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip recall for system/bootstrap prompts. These are OpenClaw internal
|
||||
// commands (/new, /reset) that contain system instructions, not user queries.
|
||||
// Sending them to mem0 search wastes API calls and returns noise.
|
||||
const promptLower = event.prompt.toLowerCase();
|
||||
const isSystemPrompt =
|
||||
promptLower.includes("a new session was started") ||
|
||||
promptLower.includes("session startup sequence") ||
|
||||
promptLower.includes("/new or /reset") ||
|
||||
promptLower.startsWith("system:") ||
|
||||
promptLower.startsWith("run your session");
|
||||
if (isSystemPrompt) {
|
||||
api.logger.info("openclaw-mem0: skills-mode skipping recall for system/bootstrap prompt");
|
||||
// Still inject the protocol, just skip recall search
|
||||
const systemContext = loadTriagePrompt(cfg.skills ?? {});
|
||||
return { prependSystemContext: systemContext };
|
||||
}
|
||||
|
||||
if (sessionId) session.setCurrentSessionId(sessionId);
|
||||
|
||||
const isSubagent = isSubagentSession(sessionId);
|
||||
const userId = _effectiveUserId(isSubagent ? undefined : sessionId);
|
||||
|
||||
// Static protocol goes in prependSystemContext (cacheable across turns)
|
||||
let systemContext = loadTriagePrompt(cfg.skills ?? {});
|
||||
if (isSubagent) {
|
||||
systemContext = "You are a subagent — use these memories for context but do not assume you are this user. Do NOT store new memories.\n\n" + systemContext;
|
||||
}
|
||||
|
||||
// Dynamic recall goes in prependContext (changes every turn).
|
||||
// Strategy controls how much the plugin searches automatically:
|
||||
// "always" — long-term + session search every turn (2 searches)
|
||||
// "smart" — long-term search only, no session search (1 search) [default]
|
||||
// "manual" — no auto-recall; agent controls all search via memory_search (0 searches)
|
||||
let recallContext = "";
|
||||
const recallEnabled = cfg.skills?.recall?.enabled !== false;
|
||||
const recallStrategy = cfg.skills?.recall?.strategy ?? "smart";
|
||||
|
||||
if (recallEnabled && recallStrategy !== "manual") {
|
||||
try {
|
||||
const query = sanitizeQuery(event.prompt);
|
||||
|
||||
// Smart mode: skip session search (saves 1 API call per turn)
|
||||
const sessionIdForRecall = recallStrategy === "always"
|
||||
? (isSubagent ? undefined : sessionId)
|
||||
: undefined; // smart: long-term only
|
||||
|
||||
const recallResult = await skillRecall(
|
||||
provider,
|
||||
query,
|
||||
userId,
|
||||
cfg.skills ?? {},
|
||||
sessionIdForRecall,
|
||||
);
|
||||
|
||||
api.logger.info(
|
||||
`openclaw-mem0: skills-mode recall (strategy=${recallStrategy}) injecting ${recallResult.memories.length} memories (~${recallResult.tokenEstimate} tokens)`,
|
||||
);
|
||||
|
||||
recallContext = recallResult.context;
|
||||
} catch (err) {
|
||||
api.logger.warn(`openclaw-mem0: skills-mode recall failed: ${String(err)}`);
|
||||
}
|
||||
} else if (recallEnabled && recallStrategy === "manual") {
|
||||
api.logger.info("openclaw-mem0: skills-mode recall strategy=manual, agent controls search");
|
||||
}
|
||||
|
||||
// Auto-dream: check gates and inject consolidation protocol if due
|
||||
const stateDir = session.getStateDir();
|
||||
const dreamEnabled = cfg.skills?.dream?.enabled !== false && cfg.skills?.dream?.auto !== false;
|
||||
let dreamContext = "";
|
||||
if (dreamEnabled && stateDir && !isSubagent) {
|
||||
try {
|
||||
const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" });
|
||||
const memCount = Array.isArray(memories) ? memories.length : 0;
|
||||
const gateResult = await shouldDream(stateDir, cfg.skills?.dream ?? {}, memCount);
|
||||
if (gateResult.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>";
|
||||
dreamJustRan = true;
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
// Skills mode: NO auto-capture. Track sessions for dream gating.
|
||||
let dreamJustRan = false;
|
||||
|
||||
api.on("agent_end", async (event: any, ctx: any) => {
|
||||
if (!event.success) return;
|
||||
const sessionId = ctx?.sessionKey ?? undefined;
|
||||
const trigger = ctx?.trigger ?? undefined;
|
||||
if (sessionId) session.setCurrentSessionId(sessionId);
|
||||
|
||||
// If dream just ran, release lock and record completion
|
||||
const stateDir = session.getStateDir();
|
||||
if (dreamJustRan && stateDir) {
|
||||
releaseDreamLock(stateDir);
|
||||
recordDreamCompletion(stateDir);
|
||||
api.logger.info("openclaw-mem0: auto-dream completed, lock released");
|
||||
dreamJustRan = false;
|
||||
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)");
|
||||
});
|
||||
|
||||
return; // Skip legacy hook registration
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// LEGACY MODE: Original auto-recall + auto-capture behavior
|
||||
// ========================================================================
|
||||
|
||||
// Auto-recall: inject relevant memories before agent starts
|
||||
if (cfg.autoRecall) {
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "openclaw-mem0",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"uiHints": {
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
@@ -68,6 +69,11 @@
|
||||
"label": "Open-Source Configuration",
|
||||
"advanced": true,
|
||||
"help": "Optional. Configure custom embedder, vector store, LLM, or history DB for open-source mode. Has sensible defaults — only override what you need."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
@@ -161,6 +167,56 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"triage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"importanceThreshold": { "type": "number" },
|
||||
"enableGraph": { "type": "boolean" },
|
||||
"credentialPatterns": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"recall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"strategy": { "type": "string", "enum": ["always", "smart", "manual"] },
|
||||
"tokenBudget": { "type": "number" },
|
||||
"maxMemories": { "type": "number" },
|
||||
"rerank": { "type": "boolean" },
|
||||
"keywordSearch": { "type": "boolean" },
|
||||
"filterMemories": { "type": "boolean" },
|
||||
"threshold": { "type": "number" },
|
||||
"identityAlwaysInclude": { "type": "boolean" },
|
||||
"categoryOrder": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"dream": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"schedule": { "type": "string" },
|
||||
"mergeThreshold": { "type": "number" },
|
||||
"maxMemoriesPerUser": { "type": "number" },
|
||||
"preserveImmutable": { "type": "boolean" },
|
||||
"credentialScan": { "type": "boolean" },
|
||||
"expireStaleAfterDays": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"domain": { "type": "string" },
|
||||
"customRules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include": { "type": "array", "items": { "type": "string" } },
|
||||
"exclude": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"categories": { "type": "object" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
+73
-4
@@ -111,6 +111,12 @@ class PlatformProvider implements Mem0Provider {
|
||||
if (options.enable_graph) opts.enable_graph = options.enable_graph;
|
||||
if (options.output_format) opts.output_format = options.output_format;
|
||||
if (options.source) opts.source = options.source;
|
||||
// Agentic harness: direct storage bypass
|
||||
if (options.infer !== undefined) opts.infer = options.infer;
|
||||
if (options.deduced_memories) opts.deduced_memories = options.deduced_memories;
|
||||
if (options.metadata) opts.metadata = options.metadata;
|
||||
if (options.expiration_date) opts.expiration_date = options.expiration_date;
|
||||
if (options.immutable) opts.immutable = options.immutable;
|
||||
|
||||
const result = await this.client.add(messages, opts);
|
||||
return normalizeAddResult(result);
|
||||
@@ -118,17 +124,26 @@ class PlatformProvider implements Mem0Provider {
|
||||
|
||||
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
||||
await this.ensureClient();
|
||||
const filters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) filters.run_id = options.run_id;
|
||||
// Base filters: always scope by user_id, optionally by run_id
|
||||
const baseFilters: Record<string, unknown> = { user_id: options.user_id };
|
||||
if (options.run_id) baseFilters.run_id = options.run_id;
|
||||
|
||||
// Merge agent-provided filters (created_at ranges, metadata, etc.)
|
||||
// with base filters. Agent filters extend, never override user scoping.
|
||||
const mergedFilters = options.filters
|
||||
? { AND: [baseFilters, options.filters] }
|
||||
: baseFilters;
|
||||
|
||||
const opts: Record<string, unknown> = {
|
||||
api_version: "v2",
|
||||
filters,
|
||||
filters: mergedFilters,
|
||||
};
|
||||
if (options.top_k != null) opts.top_k = options.top_k;
|
||||
if (options.threshold != null) opts.threshold = options.threshold;
|
||||
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
||||
if (options.reranking != null) opts.rerank = options.reranking;
|
||||
if (options.filter_memories != null) opts.filter_memories = options.filter_memories;
|
||||
if (options.categories != null) opts.categories = options.categories;
|
||||
|
||||
const results = await this.client.search(query, opts);
|
||||
return normalizeSearchResults(results);
|
||||
@@ -155,10 +170,26 @@ class PlatformProvider implements Mem0Provider {
|
||||
return [];
|
||||
}
|
||||
|
||||
async update(memoryId: string, text: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.update(memoryId, { text });
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.delete(memoryId);
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.ensureClient();
|
||||
await this.client.deleteAll({ user_id: userId });
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
|
||||
await this.ensureClient();
|
||||
const result = await this.client.history(memoryId);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -237,7 +268,24 @@ class OSSProvider implements Mem0Provider {
|
||||
const addOpts: Record<string, unknown> = { userId: options.user_id };
|
||||
if (options.run_id) addOpts.runId = options.run_id;
|
||||
if (options.source) addOpts.source = options.source;
|
||||
const result = await this.memory.add(messages, addOpts);
|
||||
// Agentic harness: direct storage bypass
|
||||
if (options.infer !== undefined) addOpts.infer = options.infer;
|
||||
if (options.metadata) addOpts.metadata = options.metadata;
|
||||
if (options.expiration_date) addOpts.expirationDate = options.expiration_date;
|
||||
if (options.immutable) addOpts.immutable = options.immutable;
|
||||
|
||||
// OSS SDK doesn't support deduced_memories — when infer=false, it stores
|
||||
// raw message content directly. Rewrite messages to contain the facts so
|
||||
// OSS stores the right text.
|
||||
let effectiveMessages = messages;
|
||||
if (options.infer === false && options.deduced_memories?.length) {
|
||||
effectiveMessages = options.deduced_memories.map((fact) => ({
|
||||
role: "user",
|
||||
content: fact,
|
||||
}));
|
||||
}
|
||||
|
||||
const result = await this.memory.add(effectiveMessages, addOpts);
|
||||
return normalizeAddResult(result);
|
||||
}
|
||||
|
||||
@@ -283,10 +331,31 @@ class OSSProvider implements Mem0Provider {
|
||||
return [];
|
||||
}
|
||||
|
||||
async update(memoryId: string, text: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.update(memoryId, { data: text });
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.delete(memoryId);
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.ensureMemory();
|
||||
await this.memory.deleteAll({ userId });
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
|
||||
await this.ensureMemory();
|
||||
try {
|
||||
const result = await this.memory.history(memoryId);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch {
|
||||
// OSS may not support history depending on config
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Token-budgeted, category-ranked recall engine.
|
||||
*
|
||||
* Replaces naive "dump all search results" with:
|
||||
* 1. Search with rerank + keyword_search
|
||||
* 2. Rank by category priority (identity first)
|
||||
* 3. Token-budget the results
|
||||
* 4. Format by category with importance scores
|
||||
*/
|
||||
|
||||
import type { Mem0Provider, MemoryItem, SkillsConfig, SearchOptions } from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Defaults
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_TOKEN_BUDGET = 1500;
|
||||
const DEFAULT_MAX_MEMORIES = 15;
|
||||
const DEFAULT_THRESHOLD = 0.4;
|
||||
const DEFAULT_CATEGORY_ORDER = [
|
||||
"identity",
|
||||
"configuration",
|
||||
"rule",
|
||||
"preference",
|
||||
"decision",
|
||||
"technical",
|
||||
"relationship",
|
||||
"project",
|
||||
"operational",
|
||||
];
|
||||
|
||||
// Rough token estimate: ~4 chars per token for English text
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface RecallResult {
|
||||
/** Formatted context string for injection */
|
||||
context: string;
|
||||
/** Raw memories retrieved */
|
||||
memories: MemoryItem[];
|
||||
/** Token count estimate */
|
||||
tokenEstimate: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Category Detection
|
||||
// ============================================================================
|
||||
|
||||
function getMemoryCategory(memory: MemoryItem): string {
|
||||
// Check metadata first (skill-stored memories have explicit category)
|
||||
if (memory.metadata?.category && typeof memory.metadata.category === "string") {
|
||||
return memory.metadata.category;
|
||||
}
|
||||
// Check categories array (mem0-extracted memories)
|
||||
if (memory.categories?.length) {
|
||||
return memory.categories[0];
|
||||
}
|
||||
return "uncategorized";
|
||||
}
|
||||
|
||||
function getMemoryImportance(memory: MemoryItem): number {
|
||||
if (memory.metadata?.importance && typeof memory.metadata.importance === "number") {
|
||||
return memory.metadata.importance;
|
||||
}
|
||||
// Default importance by category
|
||||
const cat = getMemoryCategory(memory);
|
||||
const defaults: Record<string, number> = {
|
||||
identity: 0.95,
|
||||
configuration: 0.95,
|
||||
rule: 0.90,
|
||||
preference: 0.85,
|
||||
decision: 0.80,
|
||||
technical: 0.80,
|
||||
relationship: 0.75,
|
||||
project: 0.75,
|
||||
operational: 0.60,
|
||||
};
|
||||
return defaults[cat] ?? 0.50;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Estimation
|
||||
// ============================================================================
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory Ranking
|
||||
// ============================================================================
|
||||
|
||||
function rankMemories(
|
||||
memories: MemoryItem[],
|
||||
categoryOrder: string[],
|
||||
): MemoryItem[] {
|
||||
const orderMap = new Map(categoryOrder.map((cat, i) => [cat, i]));
|
||||
|
||||
return [...memories].sort((a, b) => {
|
||||
const catA = getMemoryCategory(a);
|
||||
const catB = getMemoryCategory(b);
|
||||
const orderA = orderMap.get(catA) ?? 999;
|
||||
const orderB = orderMap.get(catB) ?? 999;
|
||||
|
||||
// Primary sort: category priority
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
|
||||
// Secondary sort: importance (higher first)
|
||||
const impA = getMemoryImportance(a);
|
||||
const impB = getMemoryImportance(b);
|
||||
if (impA !== impB) return impB - impA;
|
||||
|
||||
// Tertiary sort: search relevance score
|
||||
return (b.score ?? 0) - (a.score ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Budgeting
|
||||
// ============================================================================
|
||||
|
||||
function budgetMemories(
|
||||
rankedMemories: MemoryItem[],
|
||||
tokenBudget: number,
|
||||
maxMemories: number,
|
||||
identityAlwaysInclude: boolean,
|
||||
): MemoryItem[] {
|
||||
const selected: MemoryItem[] = [];
|
||||
let usedTokens = 0;
|
||||
|
||||
for (const memory of rankedMemories) {
|
||||
if (selected.length >= maxMemories) break;
|
||||
|
||||
const memTokens = estimateTokens(memory.memory);
|
||||
const isIdentity = getMemoryCategory(memory) === "identity" ||
|
||||
getMemoryCategory(memory) === "configuration";
|
||||
|
||||
// Identity/config always included if flag is set
|
||||
if (identityAlwaysInclude && isIdentity) {
|
||||
selected.push(memory);
|
||||
usedTokens += memTokens;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Budget check for non-identity memories
|
||||
if (usedTokens + memTokens > tokenBudget) continue;
|
||||
|
||||
selected.push(memory);
|
||||
usedTokens += memTokens;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Formatting
|
||||
// ============================================================================
|
||||
|
||||
function formatRecalledMemories(
|
||||
memories: MemoryItem[],
|
||||
userId: string,
|
||||
): string {
|
||||
if (memories.length === 0) {
|
||||
return `<recalled-memories>\nNo stored memories found for "${userId}".\n</recalled-memories>`;
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped = new Map<string, MemoryItem[]>();
|
||||
for (const mem of memories) {
|
||||
const cat = getMemoryCategory(mem);
|
||||
const existing = grouped.get(cat) || [];
|
||||
existing.push(mem);
|
||||
grouped.set(cat, existing);
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`<recalled-memories>`,
|
||||
`Stored memories for "${userId}" (${memories.length} total, ranked by importance):`,
|
||||
"",
|
||||
];
|
||||
|
||||
// Format each category group
|
||||
for (const [category, mems] of grouped.entries()) {
|
||||
const label = category.charAt(0).toUpperCase() + category.slice(1);
|
||||
lines.push(`${label}:`);
|
||||
for (const mem of mems) {
|
||||
const imp = getMemoryImportance(mem);
|
||||
const cats = mem.categories?.length ? ` [${mem.categories.join(", ")}]` : "";
|
||||
lines.push(`- ${mem.memory}${cats} (${Math.round(imp * 100)}%)`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("</recalled-memories>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Sanitization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Strip OpenClaw metadata prefix from event.prompt before using as search query.
|
||||
* This only removes framework noise (sender metadata, timestamps) — NOT
|
||||
* conversational rewriting. Query rewriting is the agent's responsibility
|
||||
* via the skill protocol (the agent formulates search queries with context).
|
||||
*/
|
||||
export function sanitizeQuery(raw: string): string {
|
||||
let cleaned = raw.replace(/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "");
|
||||
cleaned = cleaned.replace(/^\[.*?\]\s*/g, "");
|
||||
cleaned = cleaned.trim();
|
||||
return cleaned || raw;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Perform token-budgeted, category-ranked recall.
|
||||
*/
|
||||
export async function recall(
|
||||
provider: Mem0Provider,
|
||||
query: string,
|
||||
userId: string,
|
||||
config: SkillsConfig = {},
|
||||
sessionId?: string,
|
||||
): Promise<RecallResult> {
|
||||
const recallConfig = config.recall ?? {};
|
||||
const tokenBudget = recallConfig.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
|
||||
const maxMemories = recallConfig.maxMemories ?? DEFAULT_MAX_MEMORIES;
|
||||
const threshold = recallConfig.threshold ?? DEFAULT_THRESHOLD;
|
||||
const categoryOrder = recallConfig.categoryOrder ?? DEFAULT_CATEGORY_ORDER;
|
||||
const identityAlwaysInclude = recallConfig.identityAlwaysInclude !== false;
|
||||
|
||||
// Build search options with enhanced features
|
||||
const searchOpts: SearchOptions = {
|
||||
user_id: userId,
|
||||
top_k: maxMemories * 2, // Over-fetch for ranking
|
||||
threshold,
|
||||
keyword_search: recallConfig.keywordSearch !== false, // Default on
|
||||
reranking: recallConfig.rerank !== false, // Default on
|
||||
};
|
||||
if (recallConfig.filterMemories) {
|
||||
searchOpts.filter_memories = true;
|
||||
}
|
||||
|
||||
// Sanitize query: strip OpenClaw metadata prefix before searching
|
||||
const cleanQuery = sanitizeQuery(query);
|
||||
|
||||
// Search long-term memories
|
||||
let longTermMemories: MemoryItem[] = [];
|
||||
try {
|
||||
longTermMemories = await provider.search(cleanQuery, searchOpts);
|
||||
} catch (err) {
|
||||
// Graceful degradation — recall failure shouldn't block the agent
|
||||
console.warn("[mem0] Recall search failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Search session memories if we have a session
|
||||
let sessionMemories: MemoryItem[] = [];
|
||||
if (sessionId) {
|
||||
try {
|
||||
sessionMemories = await provider.search(cleanQuery, {
|
||||
...searchOpts,
|
||||
run_id: sessionId,
|
||||
top_k: 5,
|
||||
});
|
||||
} catch {
|
||||
// Session search failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: session memories that are also in long-term
|
||||
const longTermIds = new Set(longTermMemories.map((m) => m.id));
|
||||
const uniqueSession = sessionMemories.filter((m) => !longTermIds.has(m.id));
|
||||
|
||||
// Combine and rank
|
||||
const allMemories = [...longTermMemories, ...uniqueSession];
|
||||
const ranked = rankMemories(allMemories, categoryOrder);
|
||||
const budgeted = budgetMemories(ranked, tokenBudget, maxMemories, identityAlwaysInclude);
|
||||
|
||||
// Format for injection
|
||||
const context = formatRecalledMemories(budgeted, userId);
|
||||
const tokenEstimate = estimateTokens(context);
|
||||
|
||||
return { context, memories: budgeted, tokenEstimate };
|
||||
}
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch ~/.openclaw/openclaw.json with skills-mode settings."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
||||
config_path = os.path.expanduser("~/.openclaw/openclaw.json")
|
||||
|
||||
if not api_key:
|
||||
print("Error: MEM0_API_KEY not set.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
print(f"Error: {config_path} not found. Run 'openclaw configure' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Backup original config
|
||||
backup_path = config_path + ".pre-skills-backup"
|
||||
if not os.path.exists(backup_path):
|
||||
shutil.copy2(config_path, backup_path)
|
||||
print(f" Backed up config to {backup_path}")
|
||||
else:
|
||||
print(f" Backup already exists at {backup_path}")
|
||||
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
# 1. Tools profile = full (exposes plugin tools to the model)
|
||||
cfg["tools"] = {"profile": "full"}
|
||||
|
||||
# 2. Disable built-in session-memory hook
|
||||
cfg.setdefault("hooks", {}).setdefault("internal", {}).setdefault("entries", {})
|
||||
cfg["hooks"]["internal"]["entries"]["session-memory"] = {"enabled": False}
|
||||
|
||||
# 3. Plugin config with skills enabled
|
||||
entries = cfg.setdefault("plugins", {}).setdefault("entries", {})
|
||||
entries["openclaw-mem0"] = {
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"apiKey": api_key,
|
||||
"userId": user_id,
|
||||
"skills": {
|
||||
"triage": {"enabled": True},
|
||||
"recall": {
|
||||
"enabled": True,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": True,
|
||||
"keywordSearch": True,
|
||||
"identityAlwaysInclude": True,
|
||||
},
|
||||
"dream": {"enabled": True},
|
||||
"domain": "companion",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
|
||||
print(" Config updated:")
|
||||
print(" tools.profile = full")
|
||||
print(" session-memory = disabled")
|
||||
print(f" skills = enabled (user: {user_id}, domain: companion)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Skill Loader — reads skill markdown files, merges domain overlays,
|
||||
* injects user config, and produces the final injectable prompt string.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { SkillsConfig, CategoryConfig } from "./types.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Defaults
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
configuration: { importance: 0.95, ttl: null },
|
||||
rule: { importance: 0.90, ttl: null },
|
||||
identity: { importance: 0.95, ttl: null, immutable: true },
|
||||
preference: { importance: 0.85, ttl: null },
|
||||
decision: { importance: 0.80, ttl: null },
|
||||
technical: { importance: 0.80, ttl: null },
|
||||
relationship: { importance: 0.75, ttl: null },
|
||||
project: { importance: 0.75, ttl: "90d" },
|
||||
operational: { importance: 0.60, ttl: "7d" },
|
||||
};
|
||||
|
||||
const DEFAULT_CREDENTIAL_PATTERNS = [
|
||||
"sk-", "m0-", "ghp_", "AKIA", "ak_", "Bearer ",
|
||||
"bot\\d+:AA", "password=", "token=", "secret=",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Skill File Reader
|
||||
// ============================================================================
|
||||
|
||||
interface SkillFrontmatter {
|
||||
name: string;
|
||||
description?: string;
|
||||
"user-invocable"?: boolean;
|
||||
metadata?: string;
|
||||
applies_to?: string;
|
||||
}
|
||||
|
||||
interface ParsedSkill {
|
||||
frontmatter: SkillFrontmatter;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseSkillFile(content: string): ParsedSkill {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!fmMatch) {
|
||||
return {
|
||||
frontmatter: { name: "unknown" },
|
||||
body: content,
|
||||
};
|
||||
}
|
||||
|
||||
const fmBlock = fmMatch[1];
|
||||
const body = fmMatch[2].trim();
|
||||
|
||||
// Simple YAML-like parsing (no dependency needed)
|
||||
const fm: Record<string, any> = {};
|
||||
for (const line of fmBlock.split("\n")) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value: any = line.slice(colonIdx + 1).trim();
|
||||
if (value === "false") value = false;
|
||||
else if (value === "true") value = true;
|
||||
fm[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: fm as SkillFrontmatter,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Loader
|
||||
// ============================================================================
|
||||
|
||||
// Resolve skills directory with multiple fallback strategies.
|
||||
// OpenClaw may load the plugin via jiti or custom loaders that break
|
||||
// import.meta.url, so we try several paths.
|
||||
function resolveSkillsDir(): string {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// Strategy 1: import.meta.url (works in native ESM)
|
||||
try {
|
||||
const metaDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
candidates.push(path.join(metaDir, "skills"));
|
||||
candidates.push(path.join(metaDir, "..", "skills"));
|
||||
} catch { /* import.meta.url may not be available */ }
|
||||
|
||||
// Strategy 2: __dirname (works in CJS / jiti)
|
||||
if (typeof __dirname !== "undefined") {
|
||||
candidates.push(path.join(__dirname, "skills"));
|
||||
candidates.push(path.join(__dirname, "..", "skills"));
|
||||
}
|
||||
|
||||
// Validate: must contain the expected subdirectory structure
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(path.join(dir, "memory-triage", "SKILL.md"))) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0] ?? "skills"; // Will fail gracefully in readSkillFile
|
||||
}
|
||||
|
||||
const SKILLS_DIR = resolveSkillsDir();
|
||||
|
||||
function readSkillFile(skillName: string): string | null {
|
||||
// Skills use OpenClaw directory format: <skill-name>/SKILL.md
|
||||
const filePath = path.join(SKILLS_DIR, skillName, "SKILL.md");
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a domain overlay, scoped to a specific skill.
|
||||
* Domain overlays live inside the skill directory: <skill>/domains/<domain>.md
|
||||
* The `applies_to` frontmatter field is checked for backward compatibility.
|
||||
*/
|
||||
function readDomainOverlay(domain: string, targetSkill: string): string | null {
|
||||
// Domain overlays are stored inside the target skill's directory
|
||||
const filePath = path.join(SKILLS_DIR, targetSkill, "domains", `${domain}.md`);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = parseSkillFile(content);
|
||||
// Check applies_to for backward compat (skip if targeting a different skill)
|
||||
const appliesTo = parsed.frontmatter.applies_to;
|
||||
if (appliesTo && appliesTo !== targetSkill) {
|
||||
return null;
|
||||
}
|
||||
return parsed.body;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config Injection — render user-configured knobs into prompt text
|
||||
// ============================================================================
|
||||
|
||||
function renderCategoriesBlock(categories: Record<string, CategoryConfig>): string {
|
||||
const lines: string[] = ["\n## Active Category Configuration (overrides defaults above)\n"];
|
||||
for (const [name, cat] of Object.entries(categories)) {
|
||||
const ttlLabel = cat.ttl ? `expires: ${cat.ttl}` : "permanent";
|
||||
const immLabel = cat.immutable ? ", immutable" : "";
|
||||
lines.push(`- **${name.toUpperCase()}** (importance: ${cat.importance} | ${ttlLabel}${immLabel})`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderTriageKnobs(config: SkillsConfig): string {
|
||||
const triage = config.triage;
|
||||
if (!triage) return "";
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (triage.importanceThreshold !== undefined) {
|
||||
lines.push(`- Only store facts with importance >= ${triage.importanceThreshold}`);
|
||||
}
|
||||
|
||||
const patterns = resolveCredentialPatterns(config);
|
||||
if (config.triage?.credentialPatterns) {
|
||||
lines.push(`- Credential patterns to scan: ${patterns.join(", ")}`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) return "";
|
||||
return "\n## Active Configuration Overrides\n\n" + lines.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TTL Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Convert TTL string like "7d", "90d" to ISO date from today */
|
||||
export function ttlToExpirationDate(ttl: string | null): string | null {
|
||||
if (!ttl) return null;
|
||||
const match = ttl.match(/^(\d+)d$/);
|
||||
if (!match) return null;
|
||||
const days = parseInt(match[1], 10);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string;
|
||||
prompt: string;
|
||||
frontmatter: SkillFrontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a skill by name, merge domain overlays and user config,
|
||||
* and return the final injectable prompt string.
|
||||
*/
|
||||
export function loadSkill(
|
||||
skillName: string,
|
||||
config: SkillsConfig = {},
|
||||
): LoadedSkill | null {
|
||||
const raw = readSkillFile(skillName);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = parseSkillFile(raw);
|
||||
const parts: string[] = [parsed.body];
|
||||
|
||||
// Domain overlays only apply to the skill they target (checked via applies_to)
|
||||
if (config.domain) {
|
||||
const overlay = readDomainOverlay(config.domain, skillName);
|
||||
if (overlay) {
|
||||
parts.push("\n" + overlay);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject user-configured categories into triage skill prompt
|
||||
if (skillName === "memory-triage" && config.categories) {
|
||||
const mergedCats = resolveCategories(config);
|
||||
parts.push(renderCategoriesBlock(mergedCats));
|
||||
}
|
||||
|
||||
// Inject triage knobs (maxFactsPerTurn, importanceThreshold, credentialPatterns)
|
||||
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)
|
||||
if (skillName === "memory-triage" && config.customRules) {
|
||||
const rulesBlock: string[] = ["\n## User Custom Rules\n"];
|
||||
if (config.customRules.include?.length) {
|
||||
rulesBlock.push("Additionally extract:");
|
||||
for (const rule of config.customRules.include) {
|
||||
rulesBlock.push(`- ${rule}`);
|
||||
}
|
||||
}
|
||||
if (config.customRules.exclude?.length) {
|
||||
rulesBlock.push("\nAdditionally skip:");
|
||||
for (const rule of config.customRules.exclude) {
|
||||
rulesBlock.push(`- ${rule}`);
|
||||
}
|
||||
}
|
||||
parts.push(rulesBlock.join("\n"));
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
prompt: parts.join("\n"),
|
||||
frontmatter: parsed.frontmatter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the memory system prompt for injection via prependSystemContext.
|
||||
*
|
||||
* Primary path: load the full SKILL.md via loadSkill(), which merges
|
||||
* domain overlays, category overrides, custom rules, and triage knobs.
|
||||
* This ensures the config surface (skills.domain, customRules, categories)
|
||||
* is what the live before_prompt_build path actually sends.
|
||||
*
|
||||
* Fallback: if SKILL.md cannot be read (missing file, broken path), use
|
||||
* a minimal inline protocol so memory still functions.
|
||||
*/
|
||||
export function loadTriagePrompt(config: SkillsConfig = {}): string {
|
||||
// Try to load the full skill with all config-driven overlays
|
||||
const triage = loadSkill("memory-triage", config);
|
||||
|
||||
if (triage) {
|
||||
// Full SKILL.md loaded with domain overlays, categories, custom rules, knobs merged.
|
||||
// Wrap in <memory-system> and append the operational instructions that
|
||||
// are not part of the SKILL.md (tool format, batching, search protocol).
|
||||
const parts: string[] = [];
|
||||
parts.push("<memory-system>");
|
||||
parts.push("IMPORTANT: Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
|
||||
parts.push("");
|
||||
parts.push(triage.prompt);
|
||||
parts.push("");
|
||||
parts.push("## Tool Usage");
|
||||
parts.push("");
|
||||
parts.push("Batch facts by CATEGORY. All facts in one memory_store call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.");
|
||||
parts.push("");
|
||||
parts.push("FORMAT (single category):");
|
||||
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
|
||||
parts.push("FORMAT (mixed categories in one turn, separate calls):");
|
||||
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
|
||||
parts.push(' memory_store(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")');
|
||||
// Only include search instructions if recall is enabled
|
||||
if (config.recall?.enabled !== false) {
|
||||
const strategy = config.recall?.strategy ?? "smart";
|
||||
parts.push("");
|
||||
parts.push("## Searching Memory");
|
||||
parts.push("");
|
||||
|
||||
// In manual mode, the agent is fully responsible for all search
|
||||
if (strategy === "manual") {
|
||||
parts.push("You control all memory search. No automatic recall happens. Use memory_search proactively:");
|
||||
parts.push("- At the start of a new conversation, search for user identity and context.");
|
||||
parts.push("- When the user references something you do not have context for.");
|
||||
parts.push("- When the conversation topic shifts to a new domain.");
|
||||
parts.push("- Before updating a memory, search to find the existing version.");
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push("When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.");
|
||||
parts.push("Stored memories are third-person factual statements. Write a query that matches storage language, not conversation language.");
|
||||
parts.push("Process: (1) Name your target. (2) Extract signal: proper nouns, technical terms, domain concepts. (3) Bridge to storage language: add terms the stored memory contains (user, decided, prefers, rule, configured, based in). (4) Compose 3-6 keywords.");
|
||||
parts.push('WRONG: memory_search("Who was that nutritionist my wife recommended?")');
|
||||
parts.push('RIGHT: memory_search("nutritionist wife recommended relationship")');
|
||||
parts.push('WRONG: memory_search("What timezone am I in?")');
|
||||
parts.push('RIGHT: memory_search("user timezone location based")');
|
||||
parts.push("");
|
||||
parts.push("ENTITY SCOPING: Memories are scoped by user_id, agent_id, and run_id. You do not need to pass these in most cases. The plugin handles scoping automatically based on the current session.");
|
||||
parts.push("- Default behavior: all memory operations use the configured userId and current session. You do not need to pass userId or agentId.");
|
||||
parts.push("- Use agentId only when you need to read or write memories for a DIFFERENT agent (e.g., querying what the 'researcher' agent knows). This accesses a separate namespace.");
|
||||
parts.push("- Use userId only when explicitly instructed to operate on a different user's memories.");
|
||||
parts.push("- Do not pass run_id directly. The plugin manages session scoping through the scope parameter.");
|
||||
parts.push("- In multi-agent setups, each agent has isolated memory. The main agent's memories are separate from subagent memories.");
|
||||
parts.push("");
|
||||
parts.push("SEARCH SCOPE: Choose the right scope for each search:");
|
||||
parts.push('- scope: "long-term" for user context, identity, preferences, decisions (default, most common)');
|
||||
parts.push('- scope: "session" for facts from this conversation only');
|
||||
parts.push('- scope: "all" only when you truly need both scopes combined');
|
||||
parts.push("Using a specific scope avoids unnecessary backend fan-out.");
|
||||
parts.push("");
|
||||
parts.push("SEARCH FILTERS: When the user's intent implies a time range or category constraint, pass a `filters` object alongside your rewritten query.");
|
||||
parts.push('- Time: "last week" -> filters: {"created_at": {"gte": "2026-03-24"}}');
|
||||
parts.push('- Category: "my preferences" -> categories: ["preference"]');
|
||||
parts.push("- Available operators: eq, ne, gt, gte, lt, lte, in, contains. Logical: AND, OR, NOT.");
|
||||
}
|
||||
parts.push("</memory-system>");
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Fallback: SKILL.md not found. Minimal inline protocol.
|
||||
const parts: string[] = [];
|
||||
parts.push("<memory-system>");
|
||||
parts.push("You have persistent long-term memory via mem0. After EVERY response, evaluate the turn for facts worth storing.");
|
||||
parts.push("Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
|
||||
parts.push("Most turns produce ZERO memory operations. That is correct.");
|
||||
parts.push("Only store facts a new agent would need days later: identity, preferences, decisions, rules, projects, configs.");
|
||||
parts.push("Batch facts by CATEGORY. All facts in one call must share the same category.");
|
||||
parts.push('Format: memory_store(facts: ["fact text"], category: "identity")');
|
||||
parts.push("NEVER store credentials (sk-, m0-, ghp_, AKIA, Bearer tokens, passwords).");
|
||||
if (config.recall?.enabled !== false) {
|
||||
parts.push("When searching, rewrite queries for retrieval. Do not pass raw user messages.");
|
||||
}
|
||||
parts.push("</memory-system>");
|
||||
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.
|
||||
*/
|
||||
export function resolveCategories(
|
||||
config: SkillsConfig = {},
|
||||
): Record<string, CategoryConfig> {
|
||||
return { ...DEFAULT_CATEGORIES, ...(config.categories || {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve credential patterns — user overrides merged with defaults.
|
||||
*/
|
||||
export function resolveCredentialPatterns(config: SkillsConfig = {}): string[] {
|
||||
return config.triage?.credentialPatterns ?? DEFAULT_CREDENTIAL_PATTERNS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if skills mode is active (triage enabled).
|
||||
*/
|
||||
export function isSkillsMode(config: SkillsConfig | undefined): boolean {
|
||||
if (!config) return false;
|
||||
return config.triage?.enabled !== false; // enabled by default when skills config exists
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
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": {"emoji": "💤"}}
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
**Check edit history on suspicious entries:**
|
||||
If a memory looks like it was updated multiple times or has contradictory content, call `memory_history` on it to understand how it evolved.
|
||||
|
||||
**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_forget`:
|
||||
- Credentials, API keys, tokens, passwords, secrets (patterns: sk-, m0-, ghp_, AKIA, Bearer, password=, token=, secret=)
|
||||
- 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_forget` 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)
|
||||
@@ -0,0 +1,340 @@
|
||||
---
|
||||
name: memory-triage
|
||||
description: >
|
||||
ALWAYS USE THIS SKILL. Persistent long-term memory protocol powered by mem0.
|
||||
After EVERY response, evaluate the conversation for durable facts worth storing
|
||||
via memory_store. Handles identity, preferences, decisions, configurations, rules,
|
||||
projects, and relationships. Use memory_store (NOT workspace files) for all user facts.
|
||||
Read this skill at the start of every session.
|
||||
user-invocable: false
|
||||
metadata:
|
||||
{"openclaw": {"always": true, "emoji": "🧠"}}
|
||||
---
|
||||
|
||||
# Memory Protocol
|
||||
|
||||
You have persistent long-term memory powered by mem0. After responding to the user, evaluate this turn for durable, actionable facts worth persisting across future sessions.
|
||||
|
||||
Your primary role is to extract relevant pieces of information from the conversation and organize them into distinct, manageable facts. This allows for easy retrieval and personalization in future interactions.
|
||||
|
||||
**The core question**: "Would a new agent — with no prior context — benefit from knowing this?" If no → do nothing. Most turns produce zero memory operations. That is correct and expected.
|
||||
|
||||
## Decision Gate
|
||||
|
||||
Every candidate fact must pass ALL four gates:
|
||||
|
||||
**Gate 1 — FUTURE UTILITY**: Would this matter to a new agent days or weeks from now?
|
||||
- Pass: identity, configurations, standing rules, preferences with rationale, decisions, project milestones, relationships, important personal details
|
||||
- Fail: tool outputs, status checks, one-time commands, transient state, small talk, generic responses → SKIP
|
||||
|
||||
**Gate 2 — NOVELTY**: Check your recalled memories below — is this already known?
|
||||
- Already known and unchanged → SKIP
|
||||
- Known but materially changed → UPDATE (find old → forget → store new)
|
||||
- Genuinely new → proceed
|
||||
- **Material difference test**: Only UPDATE if new information adds real context, details, or changes meaning. Cosmetic differences (synonyms, rephrasing, punctuation) are NOT updates. "Loves daily walks" vs "enjoys daily walks" = no material change = SKIP.
|
||||
|
||||
**Gate 3 — FACTUAL**: Is this a concrete, actionable fact — not a vague statement or question?
|
||||
- Pass: specific names, configs, choices with rationale, deadlines, system states, plans, preferences
|
||||
- Fail: vague impressions, questions, small talk, acknowledgments, generic assistant responses ("Sure, I can help") → SKIP
|
||||
|
||||
**Gate 4 — SAFE**: Does this contain ANY credential, secret, or token?
|
||||
- Scan for: `sk-`, `m0-`, `ghp_`, `AKIA`, `ak_`, `Bearer `, bot tokens (digits:alphanumeric), webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env context, `password=`, `token=`, `secret=`, `.env` values
|
||||
- ANY match → NEVER STORE the value. Instead, store that the credential was configured:
|
||||
- WRONG: "User's API key is sk-abc123..."
|
||||
- RIGHT: "API key was configured for the service (as of 2026-03-30)"
|
||||
- When in doubt → SKIP. No exceptions.
|
||||
|
||||
All four gates must pass. If any fails → do nothing.
|
||||
|
||||
## What to Extract (Priority Order)
|
||||
|
||||
### 1. Configuration & System State (importance: 0.95 | permanent)
|
||||
Tools/services configured, installed, or removed (with versions/dates). Model assignments for agents. Cron schedules, automation pipelines, deployment configs. Architecture decisions. Specific identifiers: file paths, sheet IDs, channel IDs, machine specs.
|
||||
```
|
||||
"User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)"
|
||||
"User's executive orchestrator agent Quin runs on Claude Opus, heartbeat every 10 min"
|
||||
```
|
||||
|
||||
### 2. Standing Rules & Policies (importance: 0.90 | permanent)
|
||||
Explicit user directives about behavior. Workflow policies. Security constraints, permission boundaries. Always capture the reason.
|
||||
```
|
||||
"User rule: never create accounts without explicit user consent. Reason: security policy"
|
||||
"User rule: each agent must review model selection before completing a task"
|
||||
```
|
||||
|
||||
### 3. Identity & Demographics (importance: 0.95 | permanent)
|
||||
Name, location, timezone, language preferences. Occupation, employer, job role, industry. Keep related facts together in a single memory.
|
||||
```
|
||||
"User is Chris, senior platform engineer at Mem0, based in EST timezone"
|
||||
```
|
||||
|
||||
### 4. Preferences & Opinions (importance: 0.85 | permanent)
|
||||
Communication style, tool preferences, technology opinions. Always capture the WHY when stated. Preserve the user's exact words for feelings and opinions.
|
||||
```
|
||||
"User prefers Cursor over VS Code for AI-assisted coding because of inline completions"
|
||||
"User prefers terse responses with no trailing summaries"
|
||||
```
|
||||
|
||||
### 5. Goals, Projects & Milestones (importance: 0.75 | expires: 90 days)
|
||||
Active projects with name, description, current status. Completed milestones with dates. Deadlines, roadmaps, progress.
|
||||
```
|
||||
"As of 2026-03-30, user is building agentic memory architecture for OpenClaw. Status: active development, team demo planned early April"
|
||||
"ElevenLabs voice integration fully configured as of 2026-02-20"
|
||||
```
|
||||
|
||||
### 6. Technical Context (importance: 0.80 | permanent)
|
||||
Tech stack, development environment, agent ecosystem structure (names, roles, relationships). Skill levels.
|
||||
```
|
||||
"User's stack: Python/Django backend, Next.js 15 frontend, PostgreSQL with pgvector, deployed on EKS"
|
||||
```
|
||||
|
||||
### 7. Relationships & People (importance: 0.75 | permanent)
|
||||
Names and roles of people mentioned. Team structure, key contacts.
|
||||
```
|
||||
"Deshraj owns the frontend, Taranjeet owns the backend platform at Mem0"
|
||||
```
|
||||
|
||||
### 8. Decisions & Lessons (importance: 0.80 | permanent)
|
||||
Important decisions made with reasoning. Lessons learned. Strategies that worked or failed.
|
||||
```
|
||||
"As of 2026-03-30, user decided to use infer=false for all skill-based memory storage — agent extracts, mem0 stores directly without re-extraction"
|
||||
```
|
||||
|
||||
## CRITICAL: Memory Completeness and Self-Containment
|
||||
|
||||
Each memory you store must be a **self-contained, independently understandable fact**. This is the single most important quality rule.
|
||||
|
||||
### Entity-Based Grouping
|
||||
|
||||
**ALWAYS group all information about the same entity, concept, event, or subject into a SINGLE unified memory.** If multiple pieces of information refer to the same entity (e.g., a conference, a project, a person, a system), they MUST be combined into one comprehensive memory.
|
||||
|
||||
**DO NOT split requirements, specifications, or details about the same entity across multiple memory_store calls.** Even if information is phrased differently ("Budget for X", "X requires Y", "X needs Z"), if they all refer to the same entity, combine ALL into ONE call.
|
||||
|
||||
**WRONG** — fragmented into separate facts:
|
||||
```
|
||||
memory_store(facts: ["Conference requires at least 4 breakout rooms", "Conference requires vegan options", "Conference requires parking"], category: "project")
|
||||
```
|
||||
|
||||
**CORRECT** — grouped into one self-contained fact:
|
||||
```
|
||||
memory_store(facts: ["Conference requires at least 4 breakout rooms for 30-40 people each, robust vegan and vegetarian options with allergen-free alternatives, parking for at least 100 vehicles, venue within walking distance of transit"], category: "project")
|
||||
```
|
||||
|
||||
**WRONG** — same entity split into separate facts:
|
||||
```
|
||||
memory_store(facts: ["Budget is $150-175 per person for TechForward event", "TechForward event requires strong WiFi", "TechForward event requires hybrid capabilities"], category: "project")
|
||||
```
|
||||
|
||||
**CORRECT** — combined into one fact about TechForward:
|
||||
```
|
||||
memory_store(facts: ["TechForward event has a budget of $150-175 per person per day including venue rental, standard AV setup, and catering. Requires strong WiFi and hybrid event capabilities for remote attendees."], category: "project")
|
||||
```
|
||||
|
||||
**Only create separate memories when information refers to genuinely different entities, concepts, or unrelated topics** (e.g., "TechForward event" vs "Marketing campaign" are separate).
|
||||
|
||||
### No Pronouns — Use Specific Names
|
||||
|
||||
DO NOT create memories that rely on pronouns (they, them, he, she, it). Always use specific names and entities.
|
||||
|
||||
- **WRONG**: "They work at Google" and "They live in San Francisco"
|
||||
- **CORRECT**: "John works at Google and lives in San Francisco"
|
||||
|
||||
### No Inference
|
||||
|
||||
Do not infer unstated attributes (gender, age, ethnicity, beliefs) from names or context.
|
||||
- **WRONG**: "Kiran's sister visited him last week"
|
||||
- **CORRECT**: "Kiran's sister visited last week"
|
||||
|
||||
### No Assistant Attribution
|
||||
|
||||
Do not store characterizations from assistant messages (e.g., "user seems excited") unless the user explicitly confirmed them.
|
||||
|
||||
## How to Store
|
||||
|
||||
Use `memory_store` with the `facts` array. All facts in one call MUST share the same category because category determines retention policy (TTL, immutability).
|
||||
|
||||
```
|
||||
memory_store(
|
||||
facts: ["fact one in third person", "fact two in third person"],
|
||||
category: "identity"
|
||||
)
|
||||
```
|
||||
|
||||
If a turn produces facts in different categories, make one call per category:
|
||||
|
||||
```
|
||||
memory_store(facts: ["User is Alex, senior engineer at Stripe, PST timezone"], category: "identity")
|
||||
memory_store(facts: ["As of 2026-04-01, user decided to migrate from Postgres to CockroachDB"], category: "decision")
|
||||
```
|
||||
|
||||
Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `technical`, `relationship`, `project`
|
||||
|
||||
### Storage Principles
|
||||
|
||||
**15-50 WORDS per fact**: Each fact should be 1-2 sentences. If combining would exceed this, consolidate into key facts rather than creating a paragraph. Distill rather than append.
|
||||
|
||||
**OUTCOMES OVER INTENT**: Extract what WAS DONE, not what was requested.
|
||||
- GOOD: "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates"
|
||||
- BAD: "User wants to update call scripts"
|
||||
|
||||
**TEMPORAL ANCHORING**: Time-sensitive facts MUST include "As of YYYY-MM-DD, ..."
|
||||
- If no date available, note "date unknown" rather than omitting.
|
||||
- Extract dates from conversation context or the current date.
|
||||
|
||||
**PRESERVE USER'S WORDS**: When the user expresses feelings, opinions, or preferences, keep their exact phrasing.
|
||||
- GOOD: "User says daily walks with Poppy are the best part of their day"
|
||||
- BAD: "User finds emotional significance in walking their dog"
|
||||
|
||||
**THIRD PERSON**: "User prefers..." not "I prefer..."
|
||||
|
||||
**NO PRONOUNS**: Use specific names and entities. Not "they" or "it."
|
||||
|
||||
**PRESERVE LANGUAGE**: If the user speaks Spanish, store in Spanish. Do not translate.
|
||||
|
||||
**BATCH BY CATEGORY**: Group all same-category facts into one call. Different categories require separate calls. Most turns need zero or one call.
|
||||
|
||||
### Updating Existing Memories
|
||||
|
||||
When a recalled memory needs updating (fact changed, status changed, new detail added):
|
||||
1. `memory_search` to find the existing memory
|
||||
2. `memory_forget` on the old memory's ID
|
||||
3. `memory_store` with the corrected/expanded fact
|
||||
|
||||
**Choose the MORE COMPLETE version.** When both old and new have unique context, COMBINE them into a unified memory using the user's stated words.
|
||||
|
||||
**Material difference test**: Only update if the new version adds real information.
|
||||
- "User likes Python" → "User prefers Python for backend services because of async support" = material update (added rationale, specificity)
|
||||
- "User likes Python" → "User enjoys Python" = NOT material = SKIP
|
||||
- When both have unique context, combine: Old "Trip to Paris in September with Jack" + New "User can't wait to visit Eiffel Tower" → "Trip to Paris in September 2025 with friend Jack, user says they can't wait to visit the Eiffel Tower and try authentic French pastries"
|
||||
|
||||
**Consolidation**: When a rich new fact encompasses multiple existing memories, update one to the comprehensive version and forget the others.
|
||||
- Old: "User has a dog" + "Dog's name is Poppy" + "User walks dog daily"
|
||||
- New: "User has a dog named Poppy and says taking him for walks is the best part of their day"
|
||||
- Action: forget all three old memories, store one consolidated memory
|
||||
|
||||
**Temporary vs permanent changes**: A temporary constraint (e.g., injury pausing a hobby) does NOT contradict the underlying preference. Store the constraint as a new memory; don't delete the preference.
|
||||
- Old: "User enjoys hiking on weekends"
|
||||
- New: "User has temporarily paused hiking due to knee injury"
|
||||
- Action: store the new constraint, leave old preference untouched
|
||||
|
||||
## What NEVER to Store
|
||||
|
||||
- **Credentials and secrets** — even embedded in config blocks, setup logs, or tool output. Includes sk-, m0-, ak_, ghp_, bot tokens, bearer tokens, webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env contexts. Record that the credential was configured, never the value itself.
|
||||
- **Raw tool output** — bash results, file contents, API responses, logs, diffs, test output. Extract only the durable OUTCOME or ROOT CAUSE.
|
||||
- **One-time commands** — "stop the script", "continue where you left off", "run this"
|
||||
- **Acknowledgments and emotional reactions** — "ok", "sure", "sounds good", "sir", "got it", "thanks", "you're right"
|
||||
- **Transient UI/navigation states** — "user is in admin panel", "relay is attached"
|
||||
- **Ephemeral process status** — "download at 50%", "daemon not running", "still syncing"
|
||||
- **Cron heartbeat outputs** — NO_REPLY, HEARTBEAT_OK, compaction directives
|
||||
- **Timestamps as standalone facts** — "Current time is 3:25 PM" is NEVER worth storing. But DO use timestamps to anchor other facts.
|
||||
- **System routing metadata** — message IDs, sender IDs, channel routing info
|
||||
- **Generic small talk** — no informational content
|
||||
- **Raw code snippets** — capture the intent/decision, not the code itself
|
||||
- **Information the user explicitly asks not to remember**
|
||||
- **Facts already in recalled memories that haven't materially changed**
|
||||
- **Generic assistant responses** — "Sure, I can help", "How can I assist you?"
|
||||
|
||||
## Worked Examples
|
||||
|
||||
### Example 1: Configuration extraction (entity-grouped)
|
||||
```
|
||||
User: "I set up the research agent on Claude Sonnet with a 30-min cron. It checks HackerNews and sends summaries to #research-feed in Slack."
|
||||
Agent: [responds helpfully]
|
||||
→ memory_store(facts: ["User's research agent runs on Claude Sonnet, cron every 30 minutes, monitors HackerNews and posts summaries to Slack #research-feed"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 2: NOOP — tool output
|
||||
```
|
||||
User: "Run the healthcheck on all services"
|
||||
Agent: [executes healthcheck, returns results]
|
||||
→ No memory operations. Tool output fails Gate 1.
|
||||
```
|
||||
|
||||
### Example 3: NOOP — already recalled, no material change
|
||||
```
|
||||
Recalled: ["User is Chris, senior platform engineer at Mem0"]
|
||||
User: "Hey Chris here again"
|
||||
→ No memory operations. Already known, no material change.
|
||||
```
|
||||
|
||||
### Example 4: Rule with rationale (preserving user's words)
|
||||
```
|
||||
User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac mini only has 256GB"
|
||||
→ memory_store(facts: ["User rule: avoid Docker for local dev. Reason: ate 40GB of disk on 256GB Mac mini"], category: "rule")
|
||||
```
|
||||
|
||||
### Example 5: UPDATE — combining contexts from both versions
|
||||
```
|
||||
Recalled: ["As of 2026-03-15, user is planning trip to Paris in September with friend Jack"]
|
||||
User: "Can't wait for the Paris trip, definitely want to hit the Eiffel Tower and try authentic French pastries"
|
||||
→ memory_search("Paris trip planning")
|
||||
→ memory_forget("mem-id-of-old")
|
||||
→ memory_store(facts: ["As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries"], category: "project")
|
||||
```
|
||||
|
||||
### Example 6: Outcome over intent
|
||||
```
|
||||
User: "Update the call scripts sheet with the new truth-based templates"
|
||||
Agent: [updates the sheet successfully]
|
||||
→ memory_store(facts: ["Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates (as of 2026-03-30)"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 7: Credential — store the fact, not the value
|
||||
```
|
||||
User: "Use this API key for the new service: sk-proj-abc123def456"
|
||||
Agent: [configures the service]
|
||||
→ memory_store(facts: ["API key was configured for the new service (as of 2026-03-30)"], category: "configuration")
|
||||
```
|
||||
|
||||
### Example 8: NOOP — cosmetic difference, not material
|
||||
```
|
||||
Recalled: ["User has a dog named Poppy and enjoys their daily walks together"]
|
||||
User: "Yeah me and Poppy love our daily walks"
|
||||
→ No memory operations. Semantically equivalent. No new context.
|
||||
```
|
||||
|
||||
### Example 9: Entity grouping — single call, not fragmented
|
||||
```
|
||||
User: "The budget for the offsite is $200 per head. We need a venue with WiFi, parking for 50 cars, and a projector."
|
||||
→ memory_store(facts: ["Team offsite budget is $200 per person. Venue requirements: WiFi, parking for 50 vehicles, and projector setup."], category: "project")
|
||||
All details about the same entity (offsite) go in one fact, one call.
|
||||
```
|
||||
|
||||
### Example 10: Temporary constraint — don't delete the preference
|
||||
```
|
||||
Recalled: ["User enjoys hiking on weekends and finds it therapeutic"]
|
||||
User: "I hurt my knee last week, can't hike for a while"
|
||||
→ memory_store(facts: ["As of 2026-03-30, user has temporarily paused hiking due to knee injury"], category: "project")
|
||||
DO NOT delete the hiking preference. It is temporarily paused, not contradicted.
|
||||
```
|
||||
|
||||
### Example 11: Mixed categories in one turn — separate calls
|
||||
```
|
||||
User: "I'm Sarah, I work at Cloudflare. I just decided to switch our monitoring from Datadog to Grafana because of cost."
|
||||
→ memory_store(facts: ["User is Sarah, works at Cloudflare"], category: "identity")
|
||||
→ memory_store(facts: ["As of 2026-03-30, user decided to switch monitoring from Datadog to Grafana due to cost"], category: "decision")
|
||||
Two calls because identity and decision have different retention policies.
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting
|
||||
```
|
||||
User: "Hi"
|
||||
Agent: "Hello! How can I help?"
|
||||
→ No memory operations. No extractable facts.
|
||||
```
|
||||
|
||||
### Example 11: Consolidation — rich memory absorbs atomic ones
|
||||
```
|
||||
Recalled: ["User has a dog", "Dog's name is Poppy", "User walks dog daily"]
|
||||
User: "Poppy learned fetch! Our walks are even better now, honestly it's the best part of my day"
|
||||
→ memory_search("dog Poppy walks") → find all three old memory IDs
|
||||
→ memory_forget(id-1), memory_forget(id-2), memory_forget(id-3)
|
||||
→ memory_store(facts: ["User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable."], category: "preference")
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting, nothing to store
|
||||
```
|
||||
User: "Hi"
|
||||
Agent: "Hello! How can I help?"
|
||||
→ No memory operations. No extractable facts.
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: companion
|
||||
description: Domain overlay for personal AI companion / conversational buddy use cases
|
||||
applies_to: memory-triage
|
||||
---
|
||||
|
||||
## Companion-Specific Extraction Rules
|
||||
|
||||
In addition to the base triage protocol, apply these rules for personal AI companion interactions:
|
||||
|
||||
### Additionally Extract
|
||||
|
||||
- **Personal preferences**: Likes, dislikes, specific preferences in food, products, activities, entertainment — always with the WHY when stated
|
||||
- "User loves Italian food, especially homemade pasta because it reminds them of childhood visits to their grandmother"
|
||||
- "User prefers hiking over gym workouts because they find nature therapeutic"
|
||||
|
||||
- **Important personal details**: Names of family, friends, pets. Relationships and their significance. Important dates (birthdays, anniversaries)
|
||||
- "User has a dog named Poppy and says taking him for walks is the best part of their day"
|
||||
- "User's sister Maya lives in Portland and they talk every Sunday"
|
||||
|
||||
- **Plans and intentions**: Upcoming events, trips, goals the user has shared — with dates when available
|
||||
- "As of 2026-03-30, user is planning a trip to Paris in September with friend Jack, excited about visiting the Eiffel Tower"
|
||||
- "User wants to learn piano by end of 2026, looking into online courses"
|
||||
|
||||
- **Activity and service preferences**: Dining, travel, hobbies, routines
|
||||
- "User plays cricket with childhood friends every Sunday morning at the local park, a tradition maintained for over 5 years"
|
||||
- "User prefers window seats on flights and always books aisle for trains"
|
||||
|
||||
- **Health and wellness**: Dietary restrictions, fitness routines, wellness habits (only what user voluntarily shares)
|
||||
- "User is vegetarian and allergic to nuts"
|
||||
- "User switched from coffee to green tea because their doctor recommended it"
|
||||
|
||||
- **Emotional context and life events**: Major life transitions, milestones, things the user cares deeply about — preserve the user's own words for feelings
|
||||
- "User says their new apartment is the first place that truly feels like home"
|
||||
- "User recently got promoted to team lead and says they're nervous but excited"
|
||||
|
||||
- **Routines and patterns**: Daily habits, work patterns, schedules
|
||||
- "User does yoga every morning at 6 AM before work"
|
||||
- "User has a Friday night tradition of ordering pizza and watching movies"
|
||||
|
||||
### Additionally Skip
|
||||
|
||||
- Momentary emotional reactions without lasting significance ("ugh, traffic was bad today")
|
||||
- Weather small talk unless it relates to a plan or preference
|
||||
- Generic social pleasantries ("how are you", "good morning")
|
||||
- Play-by-play of daily activities with no lasting value ("I ate lunch, then went back to work")
|
||||
- Conversation about the AI itself (compliments, complaints about responses) unless it reveals a user preference
|
||||
|
||||
### Companion-Specific Guidelines
|
||||
|
||||
- **Preserve warmth**: When storing preferences and feelings, keep the user's language and tone. "User says Poppy is the best part of their day" is better than "User owns a dog."
|
||||
- **Relationships matter**: People the user mentions are important. Always store name + role + context. "User's friend Jake from college" is better than "User mentioned Jake."
|
||||
- **Evolving preferences**: If a user's taste changes, UPDATE the memory rather than contradicting. "User switched from coffee to green tea" preserves the journey.
|
||||
- **Temporary vs permanent**: Injuries, short-term moods, or temporary states should be ADD'd as separate memories, not used to DELETE existing preferences.
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: memory-recall
|
||||
description: Protocol for searching and using recalled memories. Defines query rewriting for retrieval.
|
||||
applies_to: memory-triage
|
||||
---
|
||||
|
||||
# Recalled Memories
|
||||
|
||||
Below your instructions you will find a `<recalled-memories>` section containing stored facts about this user. These memories persist across sessions and channels.
|
||||
|
||||
## Acting on Recalled Memories
|
||||
|
||||
Personalize naturally. If you know the user's name, use it. If you know their preferences, respect them. Do not announce that you are using memory. Never say "I remember that you..." or "According to my memory..." Act on the information without drawing attention to the mechanism.
|
||||
|
||||
Identity memories are ground truth. Trust name, role, timezone, system configurations unless the user explicitly corrects them.
|
||||
|
||||
Rules are mandatory. If a recalled memory says "User rule: never do X", follow it. Rules override your defaults.
|
||||
|
||||
Check timestamps. Project and operational memories have temporal anchors ("As of ..."). If a memory looks outdated, verify before relying on it.
|
||||
|
||||
## Before Recommending from Memory
|
||||
|
||||
A memory is a claim about what was true when it was written. It may no longer be true. Before recommending based on a memory:
|
||||
|
||||
- If the memory names a tool, service, or configuration: confirm it is still in use.
|
||||
- If the memory names a preference: it may have evolved. Use it as a default, not an absolute.
|
||||
- If the user is about to act on your recommendation, verify the memory first.
|
||||
|
||||
"The memory says X" is not the same as "X is true now."
|
||||
|
||||
## When to Search for More Context
|
||||
|
||||
Use `memory_search` when:
|
||||
|
||||
- The user references something not covered by your recalled memories
|
||||
- The conversation topic shifts to a new domain
|
||||
- The user asks "do you remember" or "what was" or references a past conversation
|
||||
- You need to find an existing memory before updating it
|
||||
|
||||
Do NOT search when:
|
||||
- Recalled memories already cover the topic
|
||||
- The turn has no memory-relevant content
|
||||
- A search query would be too generic to return useful results
|
||||
|
||||
## Constructing Search Queries
|
||||
|
||||
This section defines exactly how to write a memory_search query. Follow this process for every call. Do not skip steps. Do not pass the user's raw message.
|
||||
|
||||
### Why Rewriting Matters
|
||||
|
||||
The search engine matches your query against stored memories using vector similarity and keyword overlap. Stored memories are factual third-person statements like "User is a data scientist based in Berlin" or "User decided to adopt weekly sprint reviews because biweekly was too slow." The user's conversational message contains noise words ("can you", "I was wondering", "help me") that dilute the signal and match nothing useful in the memory store.
|
||||
|
||||
### The Process
|
||||
|
||||
For every memory_search call, follow these four steps:
|
||||
|
||||
**Step 1. Name your target.**
|
||||
Before writing the query, identify what category of stored memory you expect to find. This prevents aimless retrieval.
|
||||
|
||||
**Step 2. Extract signal words.**
|
||||
Pull out every proper noun, technical term, domain concept, and specific detail from the user's message. Drop conversational framing, questions, pronouns, and filler.
|
||||
|
||||
**Step 3. Bridge to storage language.**
|
||||
Think about how the memory was written when it was stored. Memories are third-person factual statements. They contain words like "User", "configured", "decided", "prefers", "rule", "team", "project", "based in", "works at". Add the relevant category term if it helps: "identity", "decision", "rule", "preference", "configuration", "relationship".
|
||||
|
||||
**Step 4. Compose a keyword query.**
|
||||
Join the terms from steps 2 and 3 into a string of 3 to 6 keywords. No question marks. No pronouns. No sentence structure. The query should read like index terms, not natural language.
|
||||
|
||||
### Worked Examples
|
||||
|
||||
Each example shows the full reasoning chain. The examples deliberately span different domains to prevent anchoring on any single use case.
|
||||
|
||||
**Example 1: Looking for a person**
|
||||
```
|
||||
User: "Who was that nutritionist my wife recommended?"
|
||||
Step 1: Target = a relationship or reference memory about a nutritionist
|
||||
Step 2: Signal = nutritionist, wife, recommended
|
||||
Step 3: Bridge = stored memory likely contains the name, "nutritionist", "wife recommended", "relationship"
|
||||
Step 4: memory_search("nutritionist wife recommended relationship")
|
||||
```
|
||||
|
||||
**Example 2: Looking for a preference**
|
||||
```
|
||||
User: "How do I like my reports formatted again?"
|
||||
Step 1: Target = a preference about report formatting
|
||||
Step 2: Signal = reports, formatted
|
||||
Step 3: Bridge = stored memory likely says "User prefers", "reports", "format", a specific style
|
||||
Step 4: memory_search("report format preference style")
|
||||
```
|
||||
|
||||
**Example 3: Looking for a technical decision**
|
||||
```
|
||||
User: "Remind me why we picked that message queue"
|
||||
Step 1: Target = a decision memory about message queue technology
|
||||
Step 2: Signal = message queue, picked, why
|
||||
Step 3: Bridge = stored memory likely says "decided", "chose", the queue name, "because", a rationale
|
||||
Step 4: memory_search("message queue decision chose rationale")
|
||||
```
|
||||
|
||||
**Example 4: Looking for identity info**
|
||||
```
|
||||
User: "What timezone am I in?"
|
||||
Step 1: Target = identity memory with timezone
|
||||
Step 2: Signal = timezone
|
||||
Step 3: Bridge = stored memory likely says "User is based in", a city, a timezone abbreviation
|
||||
Step 4: memory_search("user timezone location based")
|
||||
```
|
||||
|
||||
**Example 5: Looking for a rule**
|
||||
```
|
||||
User: "Is there anything I told you to always do before deploying?"
|
||||
Step 1: Target = a rule memory about deployment
|
||||
Step 2: Signal = deploy, always do, before
|
||||
Step 3: Bridge = stored memory likely says "User rule:", "always", "before deploying", a specific action
|
||||
Step 4: memory_search("rule deploy always before")
|
||||
```
|
||||
|
||||
**Example 6: Looking for a project status**
|
||||
```
|
||||
User: "Where are we with the onboarding redesign?"
|
||||
Step 1: Target = a project memory about onboarding
|
||||
Step 2: Signal = onboarding, redesign
|
||||
Step 3: Bridge = stored memory likely says "As of", "onboarding", "redesign", "status", a milestone
|
||||
Step 4: memory_search("onboarding redesign project status")
|
||||
```
|
||||
|
||||
**Example 7: Looking for a life event**
|
||||
```
|
||||
User: "When's my sister's birthday?"
|
||||
Step 1: Target = a relationship or life event memory about the user's sister
|
||||
Step 2: Signal = sister, birthday
|
||||
Step 3: Bridge = stored memory likely contains "sister", a name, "birthday", a date
|
||||
Step 4: memory_search("sister birthday date relationship")
|
||||
```
|
||||
|
||||
### Failure Patterns
|
||||
|
||||
These query patterns produce poor results. Recognize and avoid them.
|
||||
|
||||
| Pattern | Why it fails | Fix |
|
||||
|---|---|---|
|
||||
| Raw user message as query | Noise words ("can you", "help me") dilute signal | Extract entities and concepts only |
|
||||
| Question words in query | "what", "how", "when", "who" are not in stored memories | Drop all question framing |
|
||||
| Pronouns in query | "we", "our", "my", "I" do not appear in third-person memories | Use "user" or the entity name |
|
||||
| Single keyword | Too narrow, misses related context | Use 3 to 6 terms |
|
||||
| More than 8 keywords | Too broad, ranks everything equally | Trim to strongest 4-5 terms |
|
||||
| Vague category words only | "user information stuff" matches everything | Include at least one specific entity or concept |
|
||||
| Repeating the same search | If a search returned nothing, a rephrased version of the same query will likely also return nothing | Try a different angle or accept the memory does not exist |
|
||||
|
||||
## Constructing Filters
|
||||
|
||||
The `filters` parameter narrows search results by time, category, or metadata. Use it alongside your rewritten query. The query handles semantic relevance. Filters handle structural constraints.
|
||||
|
||||
### When to Add Filters
|
||||
|
||||
Add filters when the user's intent implies a structural constraint beyond semantic similarity:
|
||||
|
||||
- Time references ("last week", "recently", "in January", "yesterday"): add `created_at` filter with gte/lte dates
|
||||
- Category requests ("my preferences", "any rules", "what decisions"): add `categories` filter
|
||||
- Recency bias ("latest", "most recent", "current"): add `created_at` with recent date
|
||||
- No time or category signal in the user's message: do not add filters. Let the query handle it alone.
|
||||
|
||||
### Filter Syntax
|
||||
|
||||
Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `contains`, `icontains`
|
||||
Logical: `AND`, `OR`, `NOT` (wrap conditions in arrays)
|
||||
Date format: YYYY-MM-DD
|
||||
|
||||
### Worked Examples with Filters
|
||||
|
||||
```
|
||||
User: "What did we decide last week about the migration?"
|
||||
Query: "decision migration chose rationale"
|
||||
Filter: created_at >= 7 days ago
|
||||
Call: memory_search("decision migration chose rationale", filters: {"created_at": {"gte": "2026-03-25"}})
|
||||
```
|
||||
|
||||
```
|
||||
User: "What are all my standing rules?"
|
||||
Query: "user rule always never"
|
||||
Filter: category = rule
|
||||
Call: memory_search("user rule always never", categories: ["rule"])
|
||||
```
|
||||
|
||||
```
|
||||
User: "Show me recent project updates"
|
||||
Query: "project status milestone update"
|
||||
Filter: category + time
|
||||
Call: memory_search("project status milestone", categories: ["project"], filters: {"created_at": {"gte": "2026-03-01"}})
|
||||
```
|
||||
|
||||
```
|
||||
User: "What preferences have I shared?"
|
||||
Query: "user prefers preference"
|
||||
Filter: category = preference
|
||||
Call: memory_search("user prefers preference", categories: ["preference"])
|
||||
```
|
||||
|
||||
```
|
||||
User: "What do you know about me?"
|
||||
Query: "user identity name role location timezone"
|
||||
Filter: category = identity
|
||||
Call: memory_search("user identity name role location", categories: ["identity"])
|
||||
```
|
||||
|
||||
```
|
||||
User: "Anything from our conversation yesterday?"
|
||||
Query: "user context discussed"
|
||||
Filter: date range = yesterday
|
||||
Call: memory_search("user context discussed", filters: {"created_at": {"gte": "2026-03-31", "lte": "2026-04-01"}})
|
||||
```
|
||||
|
||||
### When NOT to Add Filters
|
||||
|
||||
- The user's message has no time signal and no category signal. Just use the rewritten query.
|
||||
- You are unsure of the exact date. Do not guess dates. Omit the filter and let vector search handle it.
|
||||
- The query is already narrow enough. Adding filters to a very specific query risks filtering out the answer.
|
||||
|
||||
## When NOT to Search
|
||||
|
||||
- Recalled memories already cover the topic. Do not re-search for what is in front of you.
|
||||
- The turn has no memory-relevant content. Most turns do not need a search.
|
||||
- The query would be too generic to return useful results.
|
||||
@@ -28,6 +28,8 @@ export type Mem0Config = {
|
||||
autoRecall: boolean;
|
||||
searchThreshold: number;
|
||||
topK: number;
|
||||
// Agentic harness skills
|
||||
skills?: SkillsConfig;
|
||||
};
|
||||
|
||||
export interface AddOptions {
|
||||
@@ -38,6 +40,12 @@ export interface AddOptions {
|
||||
enable_graph?: boolean;
|
||||
output_format?: string;
|
||||
source?: string;
|
||||
// Agentic harness additions
|
||||
infer?: boolean;
|
||||
deduced_memories?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
expiration_date?: string;
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
@@ -48,9 +56,65 @@ export interface SearchOptions {
|
||||
limit?: number;
|
||||
keyword_search?: boolean;
|
||||
reranking?: boolean;
|
||||
filter_memories?: boolean;
|
||||
categories?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skills Configuration Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CategoryConfig {
|
||||
importance: number;
|
||||
ttl: string | null; // e.g. "7d", "90d", null = permanent
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsConfig {
|
||||
triage?: {
|
||||
enabled?: boolean;
|
||||
importanceThreshold?: number;
|
||||
enableGraph?: boolean;
|
||||
credentialPatterns?: string[];
|
||||
};
|
||||
recall?: {
|
||||
/** Master switch. false = no auto-recall regardless of strategy. */
|
||||
enabled?: boolean;
|
||||
/** Controls auto-recall behavior. Only consulted when enabled !== false.
|
||||
* "smart" (default): long-term search only, 1 search/turn.
|
||||
* "manual": zero plugin searches, agent controls all search.
|
||||
* "always": long-term + session search, 2 searches/turn. */
|
||||
strategy?: "always" | "smart" | "manual";
|
||||
tokenBudget?: number;
|
||||
maxMemories?: number;
|
||||
rerank?: boolean;
|
||||
keywordSearch?: boolean;
|
||||
filterMemories?: boolean;
|
||||
threshold?: number;
|
||||
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[];
|
||||
exclude?: string[];
|
||||
};
|
||||
categories?: Record<string, CategoryConfig>;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
user_id: string;
|
||||
run_id?: string;
|
||||
@@ -87,5 +151,8 @@ export interface Mem0Provider {
|
||||
search(query: string, options: SearchOptions): Promise<MemoryItem[]>;
|
||||
get(memoryId: string): Promise<MemoryItem>;
|
||||
getAll(options: ListOptions): Promise<MemoryItem[]>;
|
||||
update(memoryId: string, text: string): Promise<void>;
|
||||
delete(memoryId: string): Promise<void>;
|
||||
deleteAll(userId: string): Promise<void>;
|
||||
history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user