feat(plugins): share native session handoff and soften memory search
This commit is contained in:
@@ -6,7 +6,15 @@ Your agent forgets everything between sessions. This plugin fixes that — it st
|
||||
|
||||
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage) and how to recall (recall). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
|
||||
Current package version: `1.1.0`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); OpenClaw keeps its own tools, skills, and memory scopes.
|
||||
Current package version: `1.2.0`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); OpenClaw keeps its own tools, skills, and memory scopes.
|
||||
|
||||
## Session handoff
|
||||
|
||||
Run `/mem0-handoff codex` to continue the current OpenClaw session in Codex. The plugin reads native session context, including readable compaction summaries and completed tool outcomes.
|
||||
|
||||
Requires **Python 3.11+** available as `python3` and an installed, signed-in Codex CLI with native session import support. Handoff works independently of Mem0 credentials and never sends the transcript through the Mem0 API. Unsupported content, missing results, and unrelated unfinished calls fail explicitly.
|
||||
|
||||
The shared engine is packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); it is not maintained separately in this plugin. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../../docs/integrations/session-handoff.mdx) for supported formats, privacy, and recovery.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ function createPluginApi(registrationMode?: string) {
|
||||
}
|
||||
|
||||
describe("plugin registration modes", () => {
|
||||
it("registers the explicit native handoff command", () => {
|
||||
const api = createPluginApi();
|
||||
memoryPlugin.register(api as any);
|
||||
expect(api.registerCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "mem0-handoff" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps cli-metadata registration free of runtime side effects", () => {
|
||||
const api = createPluginApi("cli-metadata");
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import type { Backend } from "./backend/base.ts";
|
||||
import { registerCliCommands } from "./cli/commands.ts";
|
||||
import { readPluginAuth } from "./cli/config-file.ts";
|
||||
import { registerAllTools } from "./tools/index.ts";
|
||||
import { registerHandoffCommand } from "./tools/handoff.ts";
|
||||
import type { ToolDeps } from "./tools/index.ts";
|
||||
import { captureEvent } from "./telemetry.ts";
|
||||
import { createMemoryLifecycle } from "../agent-plugin-core/typescript/src/lifecycle.ts";
|
||||
@@ -134,6 +135,8 @@ const memoryPlugin = definePluginEntry({
|
||||
return;
|
||||
}
|
||||
|
||||
registerHandoffCommand(api);
|
||||
|
||||
if (cfg.needsSetup) {
|
||||
api.logger.warn(
|
||||
"openclaw-mem0: API key not configured. Memory features are disabled.\n" +
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "openclaw-mem0",
|
||||
"name": "Memory (Mem0)",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"commandAliases": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SEARCH_GUIDANCE } from "../agent-plugin-core/typescript/src/search_guidance.ts";
|
||||
/**
|
||||
* Tests for path traversal prevention in skill-loader.
|
||||
*/
|
||||
@@ -107,7 +108,8 @@ describe("loadCompactTriagePrompt", () => {
|
||||
|
||||
expect(prompt).toContain("Use `memory_add` tool for ALL user facts");
|
||||
expect(prompt).toContain("Batch facts by CATEGORY");
|
||||
expect(prompt).toContain("ALWAYS rewrite the query");
|
||||
expect(prompt).toContain(SEARCH_GUIDANCE);
|
||||
expect(prompt).not.toContain("ALWAYS rewrite the query");
|
||||
expect(prompt).not.toContain("## Worked Examples");
|
||||
expect(prompt).not.toContain("### memory_search");
|
||||
expect(prompt).not.toContain("Conference requires at least 4 breakout rooms");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SEARCH_GUIDANCE } from "../agent-plugin-core/typescript/src/search_guidance.ts";
|
||||
/**
|
||||
* Skill Loader — reads skill markdown files, merges domain overlays,
|
||||
* injects user config, and produces the final injectable prompt string.
|
||||
@@ -429,20 +430,7 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
|
||||
|
||||
// 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("No automatic recall happens in manual mode.");
|
||||
} else if (strategy === "always") {
|
||||
parts.push(
|
||||
"Automatic recall runs for both long-term and session memory. Use manual searches only when you need more specific context.",
|
||||
@@ -450,23 +438,7 @@ export function loadTriagePrompt(config: SkillsConfig = {}): string {
|
||||
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(SEARCH_GUIDANCE);
|
||||
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.",
|
||||
@@ -619,7 +591,7 @@ export function loadCompactTriagePrompt(config: SkillsConfig = {}): string {
|
||||
|
||||
if (strategy === "manual") {
|
||||
parts.push(
|
||||
"No automatic recall happens in manual mode. Use memory_search proactively at conversation start, when context is missing, when topics shift, and before updating a memory.",
|
||||
"No automatic recall happens in manual mode.",
|
||||
);
|
||||
} else if (strategy === "always") {
|
||||
parts.push(
|
||||
@@ -631,20 +603,7 @@ export function loadCompactTriagePrompt(config: SkillsConfig = {}): string {
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.",
|
||||
);
|
||||
parts.push(
|
||||
"Convert the request into 3-6 factual keywords that match stored memory language: user, decided, prefers, rule, configured, based in, plus the concrete nouns and names from the request.",
|
||||
);
|
||||
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(SEARCH_GUIDANCE);
|
||||
// Intentionally omitted from the compact path: ENTITY SCOPING and SEARCH SCOPE.
|
||||
// loadTriagePrompt() keeps the full explanatory sections for the non-compact path.
|
||||
parts.push(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: memory-recall
|
||||
description: Protocol for searching and using recalled memories. Defines query rewriting for retrieval.
|
||||
description: Guidance for using recalled memories and focused searches when context is missing.
|
||||
applies_to: memory-triage
|
||||
---
|
||||
|
||||
@@ -33,7 +33,7 @@ A memory is a claim about what was true when it was written. It may no longer be
|
||||
Use `memory_search` when:
|
||||
|
||||
- The user references something not covered by your recalled memories
|
||||
- The conversation topic shifts to a new domain
|
||||
- A new topic needs earlier context that is not already available
|
||||
- The user asks "do you remember" or "what was" or references a past conversation
|
||||
- You need to find an existing memory before updating it
|
||||
|
||||
@@ -44,112 +44,13 @@ Do NOT search when:
|
||||
|
||||
## 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 |
|
||||
Use a focused question about the missing context. The user's question is suitable
|
||||
when it already identifies what you need. Search again only if a specific gap
|
||||
remains; skip another search when the available context answers the question.
|
||||
|
||||
## 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.
|
||||
The `filters` parameter narrows search results by time, category, or metadata. Use it alongside your query. The query handles semantic relevance. Filters handle structural constraints.
|
||||
|
||||
### When to Add Filters
|
||||
|
||||
@@ -212,7 +113,7 @@ Call: memory_search("user context discussed", filters: {"created_at": {"gte": "2
|
||||
|
||||
### When NOT to Add Filters
|
||||
|
||||
- The user's message has no time signal and no category signal. Just use the rewritten query.
|
||||
- The user's message has no time signal and no category signal. Use the question as your 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { runNativeSession } from "../../agent-plugin-core/typescript/src/handoff.ts";
|
||||
import { registerHandoffCommand } from "../tools/handoff.ts";
|
||||
vi.mock("../../agent-plugin-core/typescript/src/handoff.ts", () => ({runNativeSession: vi.fn()}));
|
||||
beforeEach(() => { vi.mocked(runNativeSession).mockReset(); });
|
||||
function command() {
|
||||
const registerCommand = vi.fn();
|
||||
registerHandoffCommand({registerCommand} as any);
|
||||
return registerCommand.mock.calls[0][0];
|
||||
}
|
||||
it("uses the trusted current OpenClaw transcript from a user-only command", async () => {
|
||||
const cmd = command();
|
||||
expect(cmd.name).toBe("mem0-handoff");
|
||||
expect(cmd.requireAuth).toBe(true);
|
||||
vi.mocked(runNativeSession).mockResolvedValue("Created Codex task");
|
||||
expect(await cmd.handler({args: "codex", sessionFile: "/tmp/native session.jsonl"})).toEqual({text: "Created Codex task"});
|
||||
expect(runNativeSession).toHaveBeenCalledWith(expect.any(URL), "openclaw", "/tmp/native session.jsonl");
|
||||
});
|
||||
it("fails explicitly on unavailable context or importer errors", async () => {
|
||||
const cmd = command();
|
||||
expect((await cmd.handler({args: "codex"})).text).toContain("unavailable");
|
||||
expect((await cmd.handler({args: "claude"})).text).toContain("Usage:");
|
||||
expect(runNativeSession).not.toHaveBeenCalled();
|
||||
vi.mocked(runNativeSession).mockRejectedValue(new Error("saved at /tmp/retry.json"));
|
||||
expect((await cmd.handler({args: "codex", sessionFile: "/tmp/native.jsonl"})).text).toContain("saved at /tmp/retry.json");
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { runNativeSession } from "../../agent-plugin-core/typescript/src/handoff.ts";
|
||||
|
||||
/** Native commands receive the trusted active transcript path from OpenClaw. */
|
||||
export function registerHandoffCommand(api: OpenClawPluginApi): void {
|
||||
api.registerCommand?.({
|
||||
name: "mem0-handoff",
|
||||
description: "Continue this OpenClaw session in a new Codex task",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
async handler(ctx: { args?: string; sessionFile?: string }) {
|
||||
if (ctx.args?.trim() !== "codex") return { text: "Usage: /mem0-handoff codex" };
|
||||
if (!ctx.sessionFile) return { text: "The active OpenClaw transcript is unavailable. Run this command inside a session." };
|
||||
try {
|
||||
return { text: await runNativeSession(new URL("./session_handoff.py", import.meta.url), "openclaw", ctx.sessionFile) };
|
||||
} catch (error) {
|
||||
return { text: `Session handoff failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SEARCH_GUIDANCE } from "../../agent-plugin-core/typescript/src/search_guidance.ts";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { MemoryItem, SearchOptions } from "../types.ts";
|
||||
import type { ToolDeps } from "./index.ts";
|
||||
@@ -8,7 +9,7 @@ export function createMemorySearchTool(deps: ToolDeps) {
|
||||
return {
|
||||
name: "memory_search",
|
||||
label: "Memory Search",
|
||||
description: "Search long-term memories stored in Mem0 by semantic meaning. Use this proactively before answering when the request may depend on the user's past work, preferences, or decisions -- relevant memories are not always already in context. For multi-part or comparative questions, run several searches with different phrasings and combine the results rather than stopping after one (multi-hop).",
|
||||
description: SEARCH_GUIDANCE,
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Search query" }),
|
||||
limit: Type.Optional(Type.Number({ description: `Max results (default: ${cfg.topK})` })),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import { packageHandoff } from "../agent-plugin-core/build/package_handoff.mjs";
|
||||
import pkg from "./package.json";
|
||||
|
||||
export default defineConfig({
|
||||
@@ -8,6 +9,7 @@ export default defineConfig({
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
onSuccess: () => packageHandoff(),
|
||||
external: [/^node:/, /^openclaw\//, "fs", "os", "path", "url", "readline", "module",
|
||||
"mem0ai", /^mem0ai\//, "better-sqlite3", "@sinclair/typebox"],
|
||||
define: {
|
||||
|
||||
Reference in New Issue
Block a user