fix(plugins): use shared handoff resources and native patch versions
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.2
|
||||
## 1.1.1
|
||||
|
||||
- `/mem0-handoff codex` uses the current session’s trusted transcript path. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
|
||||
- `/mem0-handoff` uses the current session’s trusted transcript path. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
|
||||
- List and resume shared resources from any supported plugin; the destination is the common local store.
|
||||
- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
|
||||
|
||||
@@ -6,15 +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: `0.3.2`. 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.1.1`. 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.
|
||||
Run `/mem0-handoff` to save the current session, `/mem0-handoff list` to find this project’s resources, or `/mem0-handoff resume /absolute/path.json` to continue from one.
|
||||
|
||||
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.
|
||||
All plugins share local resources in `~/.mem0/handoffs/`, preserving supported active context, images, and completed tool outcomes. Resume reads that context as historical evidence. Requires **Python 3.11+** as `python3`; no destination CLI or Mem0 credentials are required.
|
||||
|
||||
The shared launcher and pinned runtime manifest are packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); the importer is fetched once from its pinned GitHub commit, verified, and cached for all plugins. A valid cache works offline. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../agent-plugin-core/README.md#session-handoff) for supported formats, privacy, and recovery.
|
||||
The shared engine is fetched from a pinned GitHub commit on first use, verified, and cached across all plugins. Cached use works offline; no transcript is sent to GitHub. See the [shared handoff logic](../agent-plugin-core/README.md#session-handoff) for source formats and validation.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ describe("plugin registration modes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps shared resource commands and model tools available before Mem0 setup", () => {
|
||||
const api = createPluginApi();
|
||||
api.pluginConfig.apiKey = "";
|
||||
memoryPlugin.register(api as any);
|
||||
expect(api.registerCommand).toHaveBeenCalledWith(expect.objectContaining({name: "mem0-handoff"}));
|
||||
expect(api.registerTool).toHaveBeenCalledWith(expect.any(Function), {name: "mem0_handoff", optional: false});
|
||||
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
|
||||
});
|
||||
|
||||
it("keeps cli-metadata registration free of runtime side effects", () => {
|
||||
const api = createPluginApi("cli-metadata");
|
||||
|
||||
|
||||
+12
-10
@@ -24,6 +24,17 @@ declare module "openclaw/plugin-sdk" {
|
||||
publicArtifacts?: PublicArtifactsProvider;
|
||||
}
|
||||
|
||||
export type OpenClawPluginTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<{ content: Array<{ type: string; text: string }>; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export interface OpenClawPluginApi {
|
||||
pluginConfig: Record<string, unknown>;
|
||||
registrationMode?: "full" | "cli-metadata" | string;
|
||||
@@ -35,16 +46,7 @@ declare module "openclaw/plugin-sdk" {
|
||||
};
|
||||
resolvePath(p: string): string;
|
||||
registerTool(
|
||||
definition: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<{ content: Array<{ type: string; text: string }>; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
},
|
||||
definition: OpenClawPluginTool | ((ctx: { workspaceDir?: string; sessionId?: string; sessionKey?: string }) => OpenClawPluginTool | null),
|
||||
metadata?: { optional?: boolean; [key: string]: unknown },
|
||||
): void;
|
||||
on(event: string, handler: (event: any, ctx: any) => any): void;
|
||||
|
||||
@@ -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": "0.3.2",
|
||||
"version": "1.1.1",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"commandAliases": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "0.3.2",
|
||||
"version": "1.1.1",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,26 +1,63 @@
|
||||
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() {
|
||||
import {afterEach, beforeEach, expect, it, vi} from "vitest";
|
||||
import {mkdtemp, rm, writeFile} from "node:fs/promises";
|
||||
import {tmpdir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
import {runNativeSession, runHandoffAction} from "../../agent-plugin-core/typescript/src/handoff.ts";
|
||||
import {registerHandoffCommand} from "../tools/handoff.ts";
|
||||
vi.mock("../../agent-plugin-core/typescript/src/handoff.ts", async original => ({...await original<typeof import("../../agent-plugin-core/typescript/src/handoff.ts")>(), runNativeSession: vi.fn(), runHandoffAction: vi.fn()}));
|
||||
let dir: string;
|
||||
beforeEach(async () => {vi.clearAllMocks(); dir = await mkdtemp(join(tmpdir(), "openclaw-handoff-"));});
|
||||
afterEach(async () => {await rm(dir, {recursive: true, force: true});});
|
||||
function setup() {
|
||||
const registerCommand = vi.fn();
|
||||
registerHandoffCommand({registerCommand} as any);
|
||||
return registerCommand.mock.calls[0][0];
|
||||
const registerTool = vi.fn();
|
||||
const hooks = new Map<string, any>();
|
||||
registerHandoffCommand({registerCommand, registerTool, on: (name: string, handler: any) => hooks.set(name, handler)} as any);
|
||||
return {cmd: registerCommand.mock.calls[0][0], tool: registerTool.mock.calls[0][0]({workspaceDir: "/tmp/native-project"}), hooks};
|
||||
}
|
||||
it("uses the trusted current OpenClaw transcript from a user-only command", async () => {
|
||||
const cmd = command();
|
||||
it("saves the trusted current OpenClaw transcript from a user-only command", async () => {
|
||||
const {cmd} = setup();
|
||||
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"});
|
||||
vi.mocked(runNativeSession).mockResolvedValue("Saved shared resource");
|
||||
expect(await cmd.handler({sessionFile: "/tmp/native session.jsonl"})).toEqual({text: "Saved shared resource"});
|
||||
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:");
|
||||
it("loads resumed history only into the matching native session's next model prompt", async () => {
|
||||
const {cmd, hooks} = setup();
|
||||
const sessionFile = join(dir, "native.jsonl");
|
||||
await writeFile(sessionFile, JSON.stringify({type: "session", cwd: "/tmp/native-project"}) + "\n");
|
||||
vi.mocked(runHandoffAction).mockResolvedValue("Full historical context and tools");
|
||||
expect((await cmd.handler({args: "resume /tmp/shared task.json", sessionFile, sessionId: "native"})).text).toContain("loaded");
|
||||
expect(runHandoffAction).toHaveBeenCalledWith(expect.any(URL), "resume", "/tmp/native-project", "/tmp/shared task.json");
|
||||
expect(hooks.get("before_prompt_build")({}, {sessionId: "other"})).toBeUndefined();
|
||||
expect(hooks.get("before_prompt_build")({}, {sessionId: "native"})).toEqual({prependContext: "Full historical context and tools"});
|
||||
expect(hooks.get("before_prompt_build")({}, {sessionId: "native"})).toBeUndefined();
|
||||
});
|
||||
it("clears queued context when the native session ends", async () => {
|
||||
const {cmd, hooks} = setup();
|
||||
const sessionFile = join(dir, "native.jsonl");
|
||||
await writeFile(sessionFile, JSON.stringify({type: "session", cwd: "/tmp"}) + "\n");
|
||||
vi.mocked(runHandoffAction).mockResolvedValue("Full historical context");
|
||||
await cmd.handler({args: "resume /tmp/shared.json", sessionFile, sessionId: "native"});
|
||||
hooks.get("session_end")({sessionId: "native"}, {});
|
||||
expect(hooks.get("before_prompt_build")({}, {sessionId: "native"})).toBeUndefined();
|
||||
});
|
||||
it("provides a model-visible list/resume tool using only the native workspace", async () => {
|
||||
const {tool} = setup();
|
||||
vi.mocked(runHandoffAction).mockResolvedValue("Full historical context and tools");
|
||||
for (const action of ["list", "resume"]) {
|
||||
expect(await tool.execute("call", {action, resource: "/tmp/shared.json", cwd: "/untrusted"})).toEqual({content: [{type: "text", text: "Full historical context and tools"}]});
|
||||
expect(runHandoffAction).toHaveBeenLastCalledWith(expect.any(URL), action, "/tmp/native-project", "/tmp/shared.json");
|
||||
}
|
||||
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");
|
||||
});
|
||||
it("fails explicitly on unavailable context or resource errors", async () => {
|
||||
const {cmd} = setup();
|
||||
expect((await cmd.handler({})).text).toContain("unavailable");
|
||||
expect((await cmd.handler({args: "unknown"})).text).toContain("Usage:");
|
||||
expect(runNativeSession).not.toHaveBeenCalled();
|
||||
vi.mocked(runNativeSession).mockRejectedValue(new Error("invalid native transcript"));
|
||||
expect((await cmd.handler({sessionFile: "/tmp/native.jsonl"})).text).toContain("invalid native transcript");
|
||||
expect((await cmd.handler({args: "resume /tmp/shared.json", sessionFile: "/tmp/native.jsonl"})).text).toContain("identity is unavailable");
|
||||
});
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { runNativeSession } from "../../agent-plugin-core/typescript/src/handoff.ts";
|
||||
import { parseHandoffArgs, runHandoffAction, runNativeSession } from "../../agent-plugin-core/typescript/src/handoff.ts";
|
||||
|
||||
/** Native commands receive the trusted active transcript path from OpenClaw. */
|
||||
async function sessionCwd(sessionFile: string): Promise<string> {
|
||||
const stream = createReadStream(sessionFile, {encoding: "utf8"});
|
||||
const lines = createInterface({input: stream, crlfDelay: Infinity});
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
const header = JSON.parse(line);
|
||||
if (header.type !== "session" || typeof header.cwd !== "string" || !isAbsolute(header.cwd)) break;
|
||||
return header.cwd;
|
||||
}
|
||||
throw new Error("The active session's project directory is unavailable.");
|
||||
} finally { lines.close(); stream.destroy(); }
|
||||
}
|
||||
|
||||
/** Commands use the native transcript; tools receive the native workspace. */
|
||||
export function registerHandoffCommand(api: OpenClawPluginApi): void {
|
||||
const pending = new Map<string, string>();
|
||||
const script = new URL("./session_handoff.py", import.meta.url);
|
||||
api.on("before_prompt_build", (_event, ctx) => {
|
||||
const content = pending.get(ctx.sessionId);
|
||||
if (!content) return;
|
||||
pending.delete(ctx.sessionId);
|
||||
return {prependContext: content};
|
||||
});
|
||||
api.on("session_end", (event) => { pending.delete(event.sessionId); });
|
||||
api.registerTool((ctx) => ({
|
||||
name: "mem0_handoff",
|
||||
description: "List shared session resources for this project or resume one into this conversation as historical context. Use only when the user requests a handoff. To save this session use /mem0-handoff save.",
|
||||
parameters: Type.Object({
|
||||
action: Type.Union([Type.Literal("list"), Type.Literal("resume")]),
|
||||
resource: Type.Optional(Type.String({description: "Shared resource path required for resume"})),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
try {
|
||||
if (!ctx.workspaceDir) throw new Error("The active project directory is unavailable.");
|
||||
if (params.action !== "list" && params.action !== "resume") throw new Error("Choose list or resume.");
|
||||
const text = await runHandoffAction(script, params.action, ctx.workspaceDir, typeof params.resource === "string" ? params.resource : undefined);
|
||||
return {content: [{type: "text", text}]};
|
||||
} catch (error) {
|
||||
return {isError: true, content: [{type: "text", text: `Session handoff failed: ${error instanceof Error ? error.message : String(error)}`}]};
|
||||
}
|
||||
},
|
||||
}), {name: "mem0_handoff", optional: false});
|
||||
api.registerCommand?.({
|
||||
name: "mem0-handoff",
|
||||
description: "Continue this OpenClaw session in a new Codex task",
|
||||
description: "Save, list, or resume shared session handoff resources",
|
||||
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." };
|
||||
async handler(ctx: {args?: string; sessionFile?: string; sessionId?: string}) {
|
||||
try {
|
||||
return { text: await runNativeSession(new URL("./session_handoff.py", import.meta.url), "openclaw", ctx.sessionFile) };
|
||||
const {action, resource} = parseHandoffArgs(ctx.args);
|
||||
if (!ctx.sessionFile) throw new Error("The active OpenClaw transcript is unavailable. Run this command inside a session.");
|
||||
if (action === "save") return {text: await runNativeSession(script, "openclaw", ctx.sessionFile)};
|
||||
if (action === "resume" && !ctx.sessionId) throw new Error("The active session identity is unavailable; use the mem0_handoff tool to resume.");
|
||||
const content = await runHandoffAction(script, action, await sessionCwd(ctx.sessionFile), resource);
|
||||
if (action === "list") return {text: content};
|
||||
pending.set(ctx.sessionId!, content);
|
||||
return {text: "Shared session context loaded. Send your next message to continue from it in this session."};
|
||||
} catch (error) {
|
||||
return { text: `Session handoff failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
return {text: `Session handoff failed: ${error instanceof Error ? error.message : String(error)}`};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user