feat(plugins): share native session handoff and soften memory search
This commit is contained in:
@@ -10,10 +10,19 @@ It gives a Harness agent automatic long-term memory plus two explicit memory too
|
||||
| Auto-capture | Stores the human/assistant messages from each completed turn |
|
||||
| `search_memory` | Recall facts from Mem0 relevant to a query |
|
||||
| `add_memory` | Store a fact in Mem0 for future sessions |
|
||||
| `mem0_handoff` | Continue the current DeepSeek session in Codex |
|
||||
|
||||
Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed backend: server-side extraction, semantic dedup and conflict resolution, and memories that other agents can retrieve when their user and entity filters match.
|
||||
|
||||
Current package version: `0.3.0`.
|
||||
Current package version: `0.4.0`.
|
||||
|
||||
## Session handoff
|
||||
|
||||
Explicitly request the `mem0_handoff` tool to continue the current DeepSeek session in Codex. The tool uses the native session context and completed tool outcomes. Invoke it directly; nested code-mode calls are rejected when the enclosing program is still running.
|
||||
|
||||
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.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -52,7 +61,7 @@ Cordis owns listener and tool cleanup when the plugin unmounts. Every automatic
|
||||
3. Install it into a disposable Harness profile:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.0.tgz
|
||||
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.4.0.tgz
|
||||
```
|
||||
4. Copy `cordis.example.yml`, set its installed package path and your `userId`, then run Harness with the same profile:
|
||||
```sh
|
||||
@@ -68,7 +77,7 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
| Field | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Entity that owns the memories |
|
||||
| `userId` | yes | | Entity that owns the memories; required when a Mem0 API key is configured |
|
||||
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
| `autoRecall` | no | `true` | Recall relevant memory before model requests |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/deepseek-plugin",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SEARCH_GUIDANCE } from "../../agent-plugin-core/typescript/src/search_guidance.ts";
|
||||
/**
|
||||
* deepseek-plugin: Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.
|
||||
*
|
||||
@@ -20,6 +21,7 @@ import { formatMemoryList, formatAddResult } from "./formatting.ts";
|
||||
import { truncateOutput } from "./output.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
import { captureEvent, errorKind } from "./telemetry.ts";
|
||||
import { buildHandoffBundle, runHandoff } from "../../agent-plugin-core/typescript/src/handoff.ts";
|
||||
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
|
||||
|
||||
export const name = "mem0";
|
||||
@@ -90,9 +92,44 @@ const scopeParams = {
|
||||
} as const;
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.tools.register(
|
||||
defineTool({
|
||||
name: "mem0_handoff",
|
||||
description: "Continue the current DeepSeek session in Codex, only when the user explicitly requests a handoff. Preserves the full active conversation and completed tool outcomes. Requires Python 3.11+ and a signed-in Codex CLI.",
|
||||
parameters: {},
|
||||
output: textOutput,
|
||||
async execute(_args, exec) {
|
||||
try {
|
||||
if (exec.rootCallId && exec.rootCallId !== exec.callId) throw new Error("Invoke handoff directly, outside a nested code-mode tool call.");
|
||||
const session = exec.agent?.session;
|
||||
if (!session) throw new Error("The active DeepSeek session is unavailable.");
|
||||
if (!session.header.cwd) throw new Error("The native session project directory is unavailable.");
|
||||
const attachments = (ctx as unknown as { attachments?: { readImage(ref: unknown): Promise<{ref: {mediaType: string}; data: Uint8Array}> } }).attachments;
|
||||
// dsh-session-title persists user renames and generated titles as last-wins log events.
|
||||
const titleEvent = [...session.events].reverse().find(event => String(event.type) === "session/title");
|
||||
const nativeTitle = (titleEvent?.data as {title?: unknown} | undefined)?.title;
|
||||
if (titleEvent && (typeof nativeTitle !== "string" || !nativeTitle.trim())) throw new Error("The native session title is invalid.");
|
||||
const bundle = await buildHandoffBundle({
|
||||
host: "deepseek", session_id: session.id,
|
||||
title: typeof nativeTitle === "string" ? nativeTitle : `DeepSeek session ${session.id}`, cwd: session.header.cwd,
|
||||
}, session.deriveMessages(), {
|
||||
excludeCallId: exec.callId,
|
||||
readImage: async (ref) => {
|
||||
if (!attachments) throw new Error("DeepSeek image attachment storage is unavailable.");
|
||||
const image = await attachments.readImage(ref);
|
||||
return { data: image.data, mediaType: image.ref.mediaType };
|
||||
},
|
||||
});
|
||||
return await runHandoff(new URL("./session_handoff.py", import.meta.url), bundle);
|
||||
} catch (error) {
|
||||
return `Session handoff failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
const apiKey = config.apiKey ?? process.env.MEM0_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var");
|
||||
return; // Local handoff remains available before memory is configured.
|
||||
}
|
||||
const userId = config.userId?.trim();
|
||||
if (!userId || /^\*+$/.test(userId)) {
|
||||
@@ -199,7 +236,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
defineTool({
|
||||
name: "search_memory",
|
||||
description:
|
||||
"Search the user's long-term Mem0 memory for facts relevant to a query. Use proactively before answering anything that may depend on what the user told you earlier.",
|
||||
SEARCH_GUIDANCE,
|
||||
parameters: {
|
||||
query: { type: "string", description: "What to recall.", required: true },
|
||||
limit: {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Offline mock of the Mem0 SDK so these tests never touch the network.
|
||||
vi.mock("../../agent-plugin-core/typescript/src/handoff.ts", async (original) => ({
|
||||
...await original<typeof import("../../agent-plugin-core/typescript/src/handoff.ts")>(), runHandoff: vi.fn(),
|
||||
}));
|
||||
import { runHandoff } from "../../agent-plugin-core/typescript/src/handoff.ts";
|
||||
|
||||
const mockSearch = vi.fn();
|
||||
const mockAdd = vi.fn();
|
||||
vi.mock("mem0ai", () => ({
|
||||
@@ -54,6 +59,7 @@ beforeEach(() => {
|
||||
savedKey = process.env.MEM0_API_KEY;
|
||||
savedTelemetry = process.env.MEM0_TELEMETRY;
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
vi.mocked(runHandoff).mockReset();
|
||||
mockSearch.mockReset();
|
||||
mockAdd.mockReset();
|
||||
});
|
||||
@@ -66,18 +72,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("apply() config validation", () => {
|
||||
it("throws when no apiKey is set and MEM0_API_KEY is absent", () => {
|
||||
it("keeps local handoff available without a Mem0 key", () => {
|
||||
delete process.env.MEM0_API_KEY;
|
||||
expect(() => applyAndCollect({ userId: "u" } as Config)).toThrow(/apiKey|MEM0_API_KEY/);
|
||||
expect([...applyAndCollect({ userId: "u" }).keys()]).toEqual(["mem0_handoff"]);
|
||||
});
|
||||
|
||||
it("throws when userId is missing", () => {
|
||||
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
|
||||
});
|
||||
|
||||
it("registers both memory tools", () => {
|
||||
it("registers memory and handoff tools", () => {
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
expect([...tools.keys()].sort()).toEqual(["add_memory", "search_memory"]);
|
||||
expect([...tools.keys()].sort()).toEqual(["add_memory", "mem0_handoff", "search_memory"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,3 +278,42 @@ describe("tool user ownership", () => {
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("mem0_handoff tool", () => {
|
||||
const exec = {callId: "handoff", agent: {session: {
|
||||
id: "native-session", header: {cwd: "/tmp"},
|
||||
events: [{type: "session/title", data: {title: "Old title"}}, {type: "session/title", data: {title: "Renamed native task"}}],
|
||||
deriveMessages: () => [
|
||||
{role: "user", content: [{type: "text", text: "Readable current context"}]},
|
||||
{role: "assistant", content: [{type: "tool-call", id: "handoff", name: "mem0_handoff", arguments: "{}"}]},
|
||||
],
|
||||
}}};
|
||||
it("exports the current native session, excluding only its own in-flight call", async () => {
|
||||
vi.mocked(runHandoff).mockResolvedValue("Created Codex task");
|
||||
const tools = applyAndCollect({apiKey: "k", userId: "u"});
|
||||
expect(await tools.get("mem0_handoff")!.execute({}, exec)).toBe("Created Codex task");
|
||||
expect(runHandoff).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({
|
||||
source: expect.objectContaining({host: "deepseek", session_id: "native-session", title: "Renamed native task"}),
|
||||
items: [{type: "message", role: "user", content: [{type: "input_text", text: "Readable current context"}]}],
|
||||
}));
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses unfinished sibling tools rather than hiding them with its own invocation", async () => {
|
||||
const tools = applyAndCollect({apiKey: "k", userId: "u"});
|
||||
const siblingExec = {...exec, agent: {session: {...exec.agent.session, deriveMessages: () => [
|
||||
...exec.agent.session.deriveMessages(),
|
||||
{role: "assistant", content: [{type: "tool-call", id: "other", name: "read", arguments: "{}"}]},
|
||||
]}}};
|
||||
expect(await tools.get("mem0_handoff")!.execute({}, siblingExec)).toContain("unfinished");
|
||||
expect(runHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reports unavailable native state and importer failures", async () => {
|
||||
const tools = applyAndCollect({apiKey: "k", userId: "u"});
|
||||
expect(await tools.get("mem0_handoff")!.execute({}, {})).toContain("unavailable");
|
||||
expect(runHandoff).not.toHaveBeenCalled();
|
||||
vi.mocked(runHandoff).mockRejectedValue(new Error("saved at /tmp/retry.json"));
|
||||
expect(await tools.get("mem0_handoff")!.execute({}, exec)).toContain("saved at /tmp/retry.json");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import { packageHandoff } from "../agent-plugin-core/build/package_handoff.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
@@ -6,6 +7,7 @@ export default defineConfig({
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
onSuccess: () => packageHandoff(),
|
||||
// The harness runtime and the Mem0 SDK are provided by the host / installed
|
||||
// separately; keep them out of the bundle.
|
||||
external: [/^node:/, /^@deepseek-ai\//, "mem0ai", /^mem0ai\//],
|
||||
|
||||
Reference in New Issue
Block a user