refactor(integrations): shared agent plugin runtimes and native adapters (#7203)
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
|
||||
[Mem0](https://mem0.ai) long-term memory as a native [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (Cordis) plugin.
|
||||
|
||||
It gives a Harness agent two memory tools backed by the Mem0 SDK, so recall and writes persist across sessions:
|
||||
It gives a Harness agent automatic long-term memory plus two explicit memory tools backed by the Mem0 SDK:
|
||||
|
||||
| Tool | Does |
|
||||
| Capability | Does |
|
||||
|---|---|
|
||||
| Auto-recall | Searches Mem0 for the latest human prompt and adds unseen results to the model context |
|
||||
| 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 |
|
||||
|
||||
@@ -13,33 +15,47 @@ Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed b
|
||||
|
||||
## How it works
|
||||
|
||||
A Cordis plugin is a module exporting `apply(ctx, config)`. This one declares `inject = ['tools']` so it waits for the harness tool registry, then registers the two tools via `ctx.tools.register(defineTool(...))`. When the plugin unmounts, the tools are removed automatically (Cordis revertible effects).
|
||||
A Cordis plugin is a module exporting `apply(ctx, config)`. This one waits for the Harness tool and system-prompt services, then uses the native extension points:
|
||||
|
||||
- `system-prompt/assemble` recalls memory before a model request.
|
||||
- `session/event` captures only completed turns from the durable event stream.
|
||||
- `ctx.tools.register(...)` exposes explicit search and add tools.
|
||||
|
||||
Cordis owns listener and tool cleanup when the plugin unmounts. Every automatic path is fail-open: a memory API failure does not block the agent.
|
||||
|
||||
```
|
||||
[ mem0ai SDK ] <-- managed memory, owned by Mem0
|
||||
|
|
||||
[ deepseek-plugin: apply(ctx) -> ctx.tools.register(...) ] <-- this package
|
||||
[ deepseek-plugin: prompt + session listeners, memory tools ] <-- this package
|
||||
|
|
||||
[ DeepSeek Harness ] <-- the agent, loaded via cordis.yml
|
||||
```
|
||||
|
||||
## Try it locally
|
||||
|
||||
1. Build the plugin:
|
||||
1. Build and pack the plugin:
|
||||
```sh
|
||||
cd integrations/deepseek-plugin
|
||||
pnpm install
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
mkdir -p /tmp/mem0-deepseek-plugin
|
||||
pnpm pack --pack-destination /tmp/mem0-deepseek-plugin
|
||||
```
|
||||
2. Set your Mem0 key:
|
||||
```sh
|
||||
export MEM0_API_KEY=...
|
||||
```
|
||||
3. Point Harness at it. Copy `cordis.example.yml`, set the absolute path to `dist/index.js` and your `userId`, then:
|
||||
3. Install it into a disposable Harness profile:
|
||||
```sh
|
||||
pnpm dsh web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
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
|
||||
```
|
||||
4. Open http://127.0.0.1:3080 and ask the agent to remember something, then recall it in a later turn.
|
||||
4. Copy `cordis.example.yml`, set its installed package path and your `userId`, then run Harness with the same profile:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
```
|
||||
5. Open http://127.0.0.1:3080 and ask the agent to remember something, then recall it in a later turn.
|
||||
|
||||
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` is a Platform base-URL override — it is not a switch to self-hosted Mem0 OSS, whose server exposes a different API surface.
|
||||
|
||||
@@ -49,7 +65,10 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Entity that owns the memories |
|
||||
| `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 |
|
||||
| `autoCapture` | no | `true` | Store completed human/assistant turns |
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -59,4 +78,6 @@ The plugin also sends anonymous usage events (which tool ran, duration, result c
|
||||
|
||||
## Status
|
||||
|
||||
Developer preview. Tracks the DeepSeek Harness v0.1 plugin API (`@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`), which is young and moving; pin versions once it stabilizes. Auto-capture (store turns without an explicit tool call) and auto-recall (inject memory into the prompt at assembly) are planned once the harness session/assembly event API is confirmed.
|
||||
Developer preview. Tracks the DeepSeek Harness v0.1 plugin API, which is young and moving. Harness capability packages are peer dependencies supplied by the host; this package pins matching release-candidate versions for local typechecking and tests.
|
||||
|
||||
Per-call `userId` overrides are rejected unless the operator enables `allowUserOverride: true`. Automatic recall and capture always use the configured user.
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
# Example DeepSeek Harness config that loads deepseek-plugin alongside the built-in
|
||||
# tools plugin. Load with:
|
||||
#
|
||||
# pnpm dsh web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
# DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
# web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
#
|
||||
# The tools plugin (and its system-prompt dependency) must be present, because
|
||||
# the memory tools contribute schemas the system prompt renders.
|
||||
|
||||
- name: "@deepseek-ai/dsh-system-prompt"
|
||||
- name: "@deepseek-ai/dsh-tools"
|
||||
- insert:
|
||||
- id: mem0
|
||||
# Absolute path to the built plugin (run `pnpm build` first), or point
|
||||
# at src/index.ts when running through tsx during development.
|
||||
name: "/absolute/path/to/mem0/integrations/deepseek-plugin/dist/index.js"
|
||||
# Install the packed plugin into this disposable profile first; loading
|
||||
# raw dist bypasses Harness's peer dependency installation.
|
||||
name: "/tmp/mem0-dsh-dev/profiles/headless/node_modules/@mem0/deepseek-plugin/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from the MEM0_API_KEY env var when omitted here.
|
||||
userId: "your-user-id"
|
||||
# autoRecall: true # optional; enabled by default
|
||||
# autoCapture: true # optional; enabled by default
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/deepseek-plugin",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
@@ -31,7 +31,6 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
@@ -46,11 +45,17 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "*",
|
||||
"@deepseek-ai/dsh-agent": "*",
|
||||
"@deepseek-ai/dsh-session": "*",
|
||||
"@deepseek-ai/dsh-system-prompt": "*",
|
||||
"@deepseek-ai/dsh-tools": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@deepseek-ai/dsh-tools": "0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-agent": "0.1.1-rc.2",
|
||||
"@deepseek-ai/dsh-session": "0.1.1-rc.2",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "0.1.1-rc.2",
|
||||
"@types/node": "^22.15.0",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.6.0",
|
||||
|
||||
+70
-14
@@ -15,9 +15,18 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: 0.1.1-rc.2
|
||||
version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: 0.1.1-rc.2
|
||||
version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: 0.1.1-rc.2
|
||||
version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: 0.0.1-rc.1
|
||||
version: 0.0.1-rc.1(@deepseek-ai/cordis@4.0.1)
|
||||
specifier: 0.1.1-rc.2
|
||||
version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-agent@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))
|
||||
'@types/node':
|
||||
specifier: ^22.15.0
|
||||
version: 22.20.1
|
||||
@@ -48,18 +57,47 @@ packages:
|
||||
'@deepseek-ai/cosmokit@1.8.2':
|
||||
resolution: {integrity: sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==}
|
||||
|
||||
'@deepseek-ai/dsh-tools@0.0.1-rc.1':
|
||||
resolution: {integrity: sha512-mrtq+UXc6UVidrGo4GM8RAH0Gqc4iFOmtPeQAz59Phhjl3EmzkFI0Mo6dQlHQamrc2J9jppPtGtUzW9qHmwxDQ==}
|
||||
'@deepseek-ai/dsh-agent@0.1.1-rc.2':
|
||||
resolution: {integrity: sha512-cC7lnJe7JgPFcreNXxcxLMxQd78LnpVO9ZXROjZsGRQN1zGH6i/DduI892F1am85IfzzO+XTxMwwUHmfwamb0g==}
|
||||
peerDependencies:
|
||||
'@deepseek-ai/cordis': ^4.0.1-rc.1
|
||||
'@deepseek-ai/dsh-agent': ^0.0.1-rc.1
|
||||
'@deepseek-ai/dsh-code-runtime': ^0.0.1-rc.1
|
||||
'@deepseek-ai/dsh-invariants': ^0.0.1-rc.1
|
||||
'@deepseek-ai/dsh-llm': ^0.0.1-rc.1
|
||||
'@deepseek-ai/dsh-scope': ^0.0.1-rc.1
|
||||
'@deepseek-ai/dsh-session': ^0.0.1-rc.1
|
||||
'@deepseek-ai/dsh-system-prompt': ^0.0.1-rc.1
|
||||
'@deepseek-ai/dsh-user-approval': ^0.0.1-rc.1
|
||||
'@deepseek-ai/cordis': ^4.0.1
|
||||
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-session': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-system-prompt': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-typert-protocol': ^0.1.1-rc.2
|
||||
|
||||
'@deepseek-ai/dsh-session@0.1.1-rc.2':
|
||||
resolution: {integrity: sha512-4/cv6X9HPhm47eyRhCu/WZwzrtJKegk5J+0xaxcZ9i8S0smdxP57tqy8a0jkSshLQn7BzMFxneQrlYExrLrDhQ==}
|
||||
peerDependencies:
|
||||
'@deepseek-ai/cordis': ^4.0.1
|
||||
'@deepseek-ai/dsh-brand': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-typert-protocol': ^0.1.1-rc.2
|
||||
|
||||
'@deepseek-ai/dsh-system-prompt@0.1.1-rc.2':
|
||||
resolution: {integrity: sha512-on4hjAlYI5uX9q7Sf95YkMMBVe6heywtA/H50ksrIMUub8U2B98hO9iQpHhjwIO1F1vu+5pLcPvRr6yUGGmtXQ==}
|
||||
peerDependencies:
|
||||
'@deepseek-ai/cordis': ^4.0.1
|
||||
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
|
||||
|
||||
'@deepseek-ai/dsh-tools@0.1.1-rc.2':
|
||||
resolution: {integrity: sha512-0GGL4D55MwYDepzZMOI3L0ycu5b2qr96GL0Y7snwhAnpK2Di61rbX3fJE+PB3ZrovGX0csIRdt9n3iJZDVtDrw==}
|
||||
peerDependencies:
|
||||
'@deepseek-ai/cordis': ^4.0.1
|
||||
'@deepseek-ai/dsh-agent': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-code-runtime': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-session': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-system-prompt': ^0.1.1-rc.2
|
||||
'@deepseek-ai/dsh-user-approval': ^0.1.1-rc.2
|
||||
|
||||
'@deepseek-ai/schemastery@3.18.1':
|
||||
resolution: {integrity: sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==}
|
||||
@@ -1238,9 +1276,27 @@ snapshots:
|
||||
|
||||
'@deepseek-ai/cosmokit@1.8.2': {}
|
||||
|
||||
'@deepseek-ai/dsh-tools@0.0.1-rc.1(@deepseek-ai/cordis@4.0.1)':
|
||||
'@deepseek-ai/dsh-agent@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))':
|
||||
dependencies:
|
||||
'@deepseek-ai/cordis': 4.0.1
|
||||
'@deepseek-ai/dsh-session': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)
|
||||
'@deepseek-ai/dsh-system-prompt': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)
|
||||
|
||||
'@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)':
|
||||
dependencies:
|
||||
'@deepseek-ai/cordis': 4.0.1
|
||||
|
||||
'@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)':
|
||||
dependencies:
|
||||
'@deepseek-ai/cordis': 4.0.1
|
||||
'@deepseek-ai/schemastery': 3.18.1
|
||||
|
||||
'@deepseek-ai/dsh-tools@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-agent@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))':
|
||||
dependencies:
|
||||
'@deepseek-ai/cordis': 4.0.1
|
||||
'@deepseek-ai/dsh-agent': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-session@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))
|
||||
'@deepseek-ai/dsh-session': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)
|
||||
'@deepseek-ai/dsh-system-prompt': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)
|
||||
'@deepseek-ai/schemastery': 3.18.1
|
||||
|
||||
'@deepseek-ai/schemastery@3.18.1':
|
||||
|
||||
@@ -1,69 +1,7 @@
|
||||
/**
|
||||
* Compact, token-cheap rendering of Mem0 results for the model context.
|
||||
*
|
||||
* Mirrors the format used by the sibling Mem0 plugins
|
||||
* (integrations/pi-agent-plugin/src/memory/formatting.ts) so a memory reads the
|
||||
* same way across every harness: `[category] text (age) [mem0:id]`. Dumping the
|
||||
* raw search envelope instead would spend most of the tokens on JSON scaffolding.
|
||||
*/
|
||||
|
||||
export interface MemoryLike {
|
||||
id: string;
|
||||
memory?: string;
|
||||
categories?: string[];
|
||||
createdAt?: Date | string;
|
||||
}
|
||||
|
||||
export function formatAge(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const ms = Date.now() - d.getTime();
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function formatMemoryCompact(mem: MemoryLike): string {
|
||||
const cat = mem.categories?.[0] ?? "uncategorized";
|
||||
const age = mem.createdAt ? ` (${formatAge(mem.createdAt)})` : "";
|
||||
return `[${cat}] ${mem.memory ?? "(empty)"}${age} [mem0:${mem.id}]`;
|
||||
}
|
||||
|
||||
export function formatMemoryList(memories: MemoryLike[]): string {
|
||||
if (memories.length === 0) return "No memories found.";
|
||||
return memories
|
||||
.map((m, i) => `${i + 1}. ${formatMemoryCompact(m)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line confirmation for a write.
|
||||
*
|
||||
* `client.add` hits the async `/v3/memories/add/` endpoint, which returns
|
||||
* `{ event_id, status: "PENDING" }` — extraction runs server-side *after* the
|
||||
* call returns, so the extracted memories are not in this response. Report the
|
||||
* write as queued in that case; only render a list when the backend actually
|
||||
* returns memories (older / OSS shapes).
|
||||
*/
|
||||
export function formatAddResult(result: unknown): string {
|
||||
const items: MemoryLike[] = Array.isArray(result)
|
||||
? (result as MemoryLike[])
|
||||
: ((result as { results?: MemoryLike[] } | null)?.results ??
|
||||
(result ? [result as MemoryLike] : []));
|
||||
|
||||
const pending = items.find(
|
||||
(r) => (r as { status?: string }).status === "PENDING",
|
||||
) as { eventId?: string; event_id?: string } | undefined;
|
||||
if (pending) {
|
||||
// The SDK camel-cases response keys (event_id -> eventId); accept either.
|
||||
const id = pending.eventId ?? pending.event_id;
|
||||
const evt = id ? ` (event ${id})` : "";
|
||||
return `Memory queued for background extraction${evt}; it will be searchable shortly.`;
|
||||
}
|
||||
|
||||
if (items.length === 0) return "Memory stored.";
|
||||
const noun = items.length === 1 ? "memory" : "memories";
|
||||
return `Stored ${items.length} ${noun}:\n${formatMemoryList(items)}`;
|
||||
}
|
||||
export {
|
||||
formatAddResult,
|
||||
formatAge,
|
||||
formatMemoryCompact,
|
||||
formatMemoryList,
|
||||
} from "../../agent-plugin-core/typescript/src/formatting.ts";
|
||||
export type { MemoryLike } from "../../agent-plugin-core/typescript/src/formatting.ts";
|
||||
|
||||
@@ -6,20 +6,24 @@
|
||||
* - `add_memory` stores a fact for future sessions
|
||||
*
|
||||
* A plugin is a Cordis module that exports `apply(ctx, config)`. Declaring
|
||||
* `inject = ['tools']` holds the plugin until the harness tool registry exists;
|
||||
* `inject = ['tools', 'systemPrompt']` holds the plugin until the harness services exist;
|
||||
* tools registered via `ctx.tools.register(...)` are auto-unregistered when the
|
||||
* plugin unmounts (Cordis revertible effects).
|
||||
*/
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import type {} from "@deepseek-ai/dsh-agent";
|
||||
import type { PromptAssembly } from "@deepseek-ai/dsh-system-prompt";
|
||||
import type {} from "@deepseek-ai/dsh-session";
|
||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||
import { MemoryClient } from "mem0ai";
|
||||
import { formatMemoryList, formatAddResult } from "./formatting.ts";
|
||||
import { truncateOutput } from "./output.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
import { captureEvent, errorKind } from "./telemetry.ts";
|
||||
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
|
||||
|
||||
export const name = "mem0";
|
||||
export const inject = ["tools"];
|
||||
export const inject = ["tools", "systemPrompt"];
|
||||
|
||||
// Tags writes so Mem0's backend attributes them to this integration in
|
||||
// telemetry. The backend keeps recognized values via its KNOWN_EVENT_SOURCES
|
||||
@@ -29,14 +33,32 @@ export const inject = ["tools"];
|
||||
const SOURCE = "DEEPSEEK_HARNESS";
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 10;
|
||||
const AUTO_RECALL_LIMIT = 5;
|
||||
|
||||
interface HarnessMessage {
|
||||
role: string;
|
||||
content?: unknown;
|
||||
source?: { kind?: string };
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lifecycle: ReturnType<typeof createMemoryLifecycle>;
|
||||
messages: HarnessMessage[];
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
/** Mem0 API key. Defaults to the MEM0_API_KEY env var. */
|
||||
apiKey?: string;
|
||||
/** Default entity that owns the memories (Mem0 user scope). */
|
||||
userId: string;
|
||||
/** Explicitly allow model-selected cross-user access. Defaults to false. */
|
||||
allowUserOverride?: boolean;
|
||||
/** Optional Mem0 Platform base-URL override (on-prem / dedicated); defaults to api.mem0.ai. Not a switch to self-hosted OSS. */
|
||||
host?: string;
|
||||
/** Recall relevant memory before each model request. Defaults to true. */
|
||||
autoRecall?: boolean;
|
||||
/** Store completed turns automatically. Defaults to true. */
|
||||
autoCapture?: boolean;
|
||||
}
|
||||
|
||||
// Both tools return a single text string; the render is identical, so lift it
|
||||
@@ -55,7 +77,7 @@ const scopeParams = {
|
||||
userId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Entity that owns the memory. Defaults to the plugin's configured userId; set this only to read or write another user's memories.",
|
||||
"Entity that owns the memory. Defaults to the plugin's configured userId; cross-user overrides require allowUserOverride in plugin configuration.",
|
||||
},
|
||||
agentId: {
|
||||
type: "string",
|
||||
@@ -72,8 +94,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (!apiKey) {
|
||||
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var");
|
||||
}
|
||||
const userId = config.userId;
|
||||
if (!userId) {
|
||||
const userId = config.userId?.trim();
|
||||
if (!userId || /^\*+$/.test(userId)) {
|
||||
throw new Error("deepseek-plugin: config.userId is required");
|
||||
}
|
||||
|
||||
@@ -81,8 +103,95 @@ export function apply(ctx: Context, config: Config): void {
|
||||
apiKey,
|
||||
...(config.host ? { host: config.host } : {}),
|
||||
});
|
||||
const toolLifecycle = createMemoryLifecycle();
|
||||
const sessionStates = new WeakMap<object, SessionState>();
|
||||
const stateFor = (session: object): SessionState => {
|
||||
let state = sessionStates.get(session);
|
||||
if (!state) {
|
||||
const lifecycle = createMemoryLifecycle();
|
||||
lifecycle.beginSession();
|
||||
state = { lifecycle, messages: [] };
|
||||
sessionStates.set(session, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
captureEvent("deepseek.plugin.mounted", { has_host: Boolean(config.host) }, client);
|
||||
captureEvent("deepseek.plugin.mounted", {
|
||||
has_host: Boolean(config.host),
|
||||
auto_recall: config.autoRecall !== false,
|
||||
auto_capture: config.autoCapture !== false,
|
||||
}, client);
|
||||
|
||||
if (config.autoRecall !== false) {
|
||||
ctx.on("system-prompt/assemble", async (_input, context, next): Promise<PromptAssembly> => {
|
||||
const assembly = await next();
|
||||
const { agent, signal } = context;
|
||||
if (!agent || signal?.aborted) return assembly;
|
||||
|
||||
const state = stateFor(agent.session);
|
||||
const prompt = state.lifecycle.prepareConversation(
|
||||
agent.session
|
||||
.deriveMessages()
|
||||
.filter((message) => message.role === "user" && message.source?.kind === "user"),
|
||||
).at(-1)?.content;
|
||||
if (!prompt) return assembly;
|
||||
|
||||
const memoryContext = await state.lifecycle.recall(prompt, true, async (query) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await client.search(query, {
|
||||
filters: resolveSearchFilters({}, userId),
|
||||
topK: AUTO_RECALL_LIMIT,
|
||||
});
|
||||
captureEvent("deepseek.recall.auto", {
|
||||
success: true,
|
||||
duration_ms: Date.now() - started,
|
||||
result_count: result.results?.length ?? 0,
|
||||
}, client);
|
||||
return result;
|
||||
} catch (err) {
|
||||
captureEvent("deepseek.recall.auto", {
|
||||
success: false,
|
||||
duration_ms: Date.now() - started,
|
||||
error_kind: errorKind(err),
|
||||
}, client);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
if (!memoryContext || signal?.aborted) return assembly;
|
||||
return {
|
||||
...assembly,
|
||||
contexts: [...assembly.contexts, { name: "mem0:recall", text: memoryContext }],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (config.autoCapture !== false) {
|
||||
ctx.on("session/event", (session, event) => {
|
||||
const state = stateFor(session);
|
||||
if (event.type === "turn/start") {
|
||||
state.messages = [];
|
||||
} else if (event.type === "user/message" && event.data.source.kind === "user") {
|
||||
state.messages.push(event.data);
|
||||
} else if (event.type === "assistant/message") {
|
||||
state.messages.push(event.data.message);
|
||||
} else if (event.type === "turn/end") {
|
||||
const conversation = state.lifecycle.prepareConversation(state.messages);
|
||||
state.messages = [];
|
||||
if (event.data.reason.kind !== "completed" || conversation.length === 0) return;
|
||||
void client
|
||||
.add(conversation, { userId, source: SOURCE })
|
||||
.then(() => captureEvent("deepseek.capture.auto", {
|
||||
success: true,
|
||||
message_count: conversation.length,
|
||||
}, client))
|
||||
.catch((err: unknown) => captureEvent("deepseek.capture.auto", {
|
||||
success: false,
|
||||
error_kind: errorKind(err),
|
||||
}, client));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Recall. The platform rejects top-level entity params on search, so scope
|
||||
// goes inside `filters` (unlike add below, which takes them top-level).
|
||||
@@ -101,11 +210,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ query, limit, userId: u, agentId, runId }) {
|
||||
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
|
||||
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
|
||||
}
|
||||
const safeQuery = toolLifecycle.prepareUserText(query);
|
||||
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
|
||||
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { results } = await client.search(query, { filters, topK });
|
||||
const { results } = await client.search(safeQuery, { filters, topK });
|
||||
captureEvent(
|
||||
"deepseek.tool.search_memory",
|
||||
{
|
||||
@@ -150,13 +263,19 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ text, userId: u, agentId, runId }) {
|
||||
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
|
||||
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
|
||||
}
|
||||
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await client.add([{ role: "user", content: text }], {
|
||||
...addParams,
|
||||
source: SOURCE,
|
||||
});
|
||||
const result = await client.add(
|
||||
[{ role: "user", content: toolLifecycle.prepareUserText(text) }],
|
||||
{
|
||||
...addParams,
|
||||
source: SOURCE,
|
||||
},
|
||||
);
|
||||
captureEvent(
|
||||
"deepseek.tool.add_memory",
|
||||
{
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
/**
|
||||
* Hard cap on tool output before it reaches the model context.
|
||||
*
|
||||
* Same guard the sibling plugins apply (200 lines / 50KB, see
|
||||
* integrations/pi-agent-plugin/src/memory/tools.ts): a large recall or a wide
|
||||
* result set can otherwise flood the context window in a single tool call.
|
||||
*/
|
||||
|
||||
export const MAX_OUTPUT_LINES = 200;
|
||||
export const MAX_OUTPUT_BYTES = 50_000;
|
||||
|
||||
export function truncateOutput(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES && text.length <= MAX_OUTPUT_BYTES) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const kept = lines.slice(0, MAX_OUTPUT_LINES);
|
||||
let result = kept.join("\n");
|
||||
const byteCapped = result.length > MAX_OUTPUT_BYTES;
|
||||
if (byteCapped) {
|
||||
result = result.slice(0, MAX_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
const dropped = lines.length - kept.length;
|
||||
const reasons: string[] = [];
|
||||
if (dropped > 0) reasons.push(`showing ${kept.length} of ${lines.length} lines`);
|
||||
if (byteCapped) reasons.push(`cut at ${Math.floor(MAX_OUTPUT_BYTES / 1000)}KB`);
|
||||
if (reasons.length > 0) {
|
||||
result += `\n\n[Output truncated: ${reasons.join(", ")}]`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export {
|
||||
MAX_OUTPUT_BYTES,
|
||||
MAX_OUTPUT_LINES,
|
||||
truncateOutput,
|
||||
} from "../../agent-plugin-core/typescript/src/formatting.ts";
|
||||
|
||||
@@ -1,53 +1,5 @@
|
||||
/**
|
||||
* Per-call memory scoping.
|
||||
*
|
||||
* The plugin is mounted with one default `userId`, but a single harness install
|
||||
* can serve more than one entity, so both tools accept optional `userId` /
|
||||
* `agentId` / `runId` params that override the mount-time default per call.
|
||||
* A missing or blank param falls back to the configured user.
|
||||
*
|
||||
* The two call sites need different key casing, and it is deliberate rather than
|
||||
* incidental: search passes scope inside `filters`, sent to the platform raw, so
|
||||
* it must be snake_case; add takes the entity params top-level, through the
|
||||
* SDK's camel->snake converter, so it must be camelCase. Keeping the split
|
||||
* explicit (like integrations/pi-agent-plugin/src/memory/scoping.ts) means the
|
||||
* asymmetry is visible in the code, not load-bearing on a converter no-op.
|
||||
*/
|
||||
|
||||
export interface EntityParams {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
const clean = (v: string | undefined) => v?.trim() || undefined;
|
||||
|
||||
/** Search: snake_case, spread into `filters` and passed to the platform raw. */
|
||||
export function resolveSearchFilters(
|
||||
params: EntityParams,
|
||||
defaultUserId: string,
|
||||
): Record<string, string> {
|
||||
const filters: Record<string, string> = {
|
||||
user_id: clean(params.userId) ?? defaultUserId,
|
||||
};
|
||||
const agentId = clean(params.agentId);
|
||||
if (agentId) filters.agent_id = agentId;
|
||||
const runId = clean(params.runId);
|
||||
if (runId) filters.run_id = runId;
|
||||
return filters;
|
||||
}
|
||||
|
||||
/** Add: camelCase, top-level params run through the SDK's camel->snake converter. */
|
||||
export function resolveAddParams(
|
||||
params: EntityParams,
|
||||
defaultUserId: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
userId: clean(params.userId) ?? defaultUserId,
|
||||
};
|
||||
const agentId = clean(params.agentId);
|
||||
if (agentId) out.agentId = agentId;
|
||||
const runId = clean(params.runId);
|
||||
if (runId) out.runId = runId;
|
||||
return out;
|
||||
}
|
||||
export {
|
||||
entityAddParams as resolveAddParams,
|
||||
entitySearchFilters as resolveSearchFilters,
|
||||
} from "../../agent-plugin-core/typescript/src/identity.ts";
|
||||
export type { EntityParams } from "../../agent-plugin-core/typescript/src/identity.ts";
|
||||
|
||||
@@ -1,173 +1,98 @@
|
||||
/**
|
||||
* Anonymous usage telemetry for the DeepSeek Harness plugin.
|
||||
*
|
||||
* Fire-and-forget PostHog events over native fetch, batched and flushed every
|
||||
* 5 seconds, at 10 queued events, or on process exit. Never throws, never logs.
|
||||
*
|
||||
* Events carry only tool names, durations, counts, and coarse failure kinds:
|
||||
* never queries, memory text, filters, or API keys.
|
||||
*
|
||||
* Disable with MEM0_TELEMETRY=false.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
const POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/";
|
||||
const FLUSH_INTERVAL_MS = 5_000;
|
||||
const FLUSH_THRESHOLD = 10;
|
||||
const SEND_TIMEOUT_MS = 3_000;
|
||||
import {
|
||||
createTelemetry,
|
||||
errorKind,
|
||||
isTelemetryEnabled,
|
||||
} from "../../agent-plugin-core/typescript/src/telemetry.ts";
|
||||
|
||||
const PLUGIN_VERSION = ((): string => {
|
||||
export { errorKind, isTelemetryEnabled };
|
||||
|
||||
const PLUGIN_VERSION = (() => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
|
||||
).version;
|
||||
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
})();
|
||||
|
||||
/** The Mem0 SDK resolves this to the account email once ping() lands, so events join other Mem0 surfaces. */
|
||||
export interface TelemetryIdentity {
|
||||
telemetryId?: string;
|
||||
}
|
||||
|
||||
let queue: Record<string, unknown>[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let exitHandlerInstalled = false;
|
||||
let cachedAnonymousId: string | undefined;
|
||||
let identified = false;
|
||||
let currentDistinctId = "";
|
||||
|
||||
function identityPath(): string {
|
||||
return path.join(os.homedir(), ".mem0", "deepseek-plugin-telemetry.json");
|
||||
}
|
||||
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
const value = process.env.MEM0_TELEMETRY?.toLowerCase();
|
||||
return value !== "false" && value !== "0" && value !== "no" && value !== "off";
|
||||
}
|
||||
|
||||
function anonymousId(): string {
|
||||
if (cachedAnonymousId) return cachedAnonymousId;
|
||||
try {
|
||||
const stored = JSON.parse(fs.readFileSync(identityPath(), "utf-8"));
|
||||
if (typeof stored.anonymousId === "string" && stored.anonymousId) {
|
||||
cachedAnonymousId = stored.anonymousId;
|
||||
return cachedAnonymousId!;
|
||||
return (cachedAnonymousId = stored.anonymousId);
|
||||
}
|
||||
} catch {
|
||||
/* first run, or an unreadable identity file */
|
||||
// First run or unreadable identity file.
|
||||
}
|
||||
const created = `deepseek-anon-${randomUUID().replace(/-/g, "")}`;
|
||||
try {
|
||||
const target = identityPath();
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, JSON.stringify({ anonymousId: created }), "utf-8");
|
||||
fs.mkdirSync(path.dirname(identityPath()), { recursive: true });
|
||||
fs.writeFileSync(identityPath(), JSON.stringify({ anonymousId: created }), "utf-8");
|
||||
} catch {
|
||||
/* an unwritable home directory must not break the plugin */
|
||||
// An unwritable home directory must not break the plugin.
|
||||
}
|
||||
cachedAnonymousId = created;
|
||||
return created;
|
||||
return (cachedAnonymousId = created);
|
||||
}
|
||||
|
||||
/** Merge the pre-ping anonymous history into the account once the email resolves. */
|
||||
function identifyEvent(distinctId: string): Record<string, unknown> | undefined {
|
||||
function previousAnonymousId(distinctId: string): string | undefined {
|
||||
if (identified || distinctId.startsWith("deepseek-anon-")) return undefined;
|
||||
identified = true;
|
||||
let storedAnonymousId: string | undefined;
|
||||
try {
|
||||
storedAnonymousId = JSON.parse(fs.readFileSync(identityPath(), "utf-8")).anonymousId;
|
||||
const stored = JSON.parse(fs.readFileSync(identityPath(), "utf-8")).anonymousId;
|
||||
fs.unlinkSync(identityPath());
|
||||
cachedAnonymousId = undefined;
|
||||
return typeof stored === "string" && stored ? stored : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
cachedAnonymousId = undefined;
|
||||
if (!storedAnonymousId) return undefined;
|
||||
return {
|
||||
event: "$identify",
|
||||
distinct_id: distinctId,
|
||||
properties: { $anon_distinct_id: storedAnonymousId, $lib: "posthog-node" },
|
||||
};
|
||||
}
|
||||
|
||||
export function flushEvents(): void {
|
||||
if (queue.length === 0) return;
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
fetch(POSTHOG_BATCH_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||
}).catch(() => {
|
||||
/* telemetry never surfaces its own failures */
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (!flushTimer) {
|
||||
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
|
||||
flushTimer.unref?.();
|
||||
}
|
||||
if (!exitHandlerInstalled) {
|
||||
exitHandlerInstalled = true;
|
||||
process.on("beforeExit", flushEvents);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorKind(error: unknown): string {
|
||||
const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
if (text.includes("timeout") || text.includes("aborted")) return "timeout";
|
||||
if (text.includes("401") || text.includes("403") || text.includes("unauthor")) return "auth";
|
||||
if (text.includes("429") || text.includes("rate limit")) return "rate-limited";
|
||||
if (/50[0234]/.test(text)) return "server-error";
|
||||
if (text.includes("400") || text.includes("422")) return "bad-request";
|
||||
if (text.includes("fetch failed") || text.includes("enotfound")) return "network";
|
||||
return error instanceof Error ? error.constructor.name : "other";
|
||||
}
|
||||
const telemetry = createTelemetry({
|
||||
host: "deepseek",
|
||||
source: "DEEPSEEK_HARNESS",
|
||||
version: PLUGIN_VERSION,
|
||||
distinctId: () => currentDistinctId,
|
||||
});
|
||||
|
||||
export function captureEvent(
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
client: TelemetryIdentity,
|
||||
): void {
|
||||
if (!isTelemetryEnabled()) return;
|
||||
try {
|
||||
const distinctId = client.telemetryId || anonymousId();
|
||||
const identify = identifyEvent(distinctId);
|
||||
if (identify) queue.push(identify);
|
||||
queue.push({
|
||||
event,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
source: "DEEPSEEK_HARNESS",
|
||||
language: "node",
|
||||
plugin_version: PLUGIN_VERSION,
|
||||
node_version: process.version,
|
||||
os: process.platform,
|
||||
$process_person_profile: false,
|
||||
$lib: "posthog-node",
|
||||
...properties,
|
||||
},
|
||||
});
|
||||
scheduleFlush();
|
||||
if (queue.length >= FLUSH_THRESHOLD) flushEvents();
|
||||
} catch {
|
||||
/* telemetry never breaks a tool call */
|
||||
}
|
||||
currentDistinctId = client.telemetryId || anonymousId();
|
||||
const anonymous = previousAnonymousId(currentDistinctId);
|
||||
if (anonymous) telemetry.capture("$identify", { $anon_distinct_id: anonymous });
|
||||
telemetry.capture(event, properties);
|
||||
}
|
||||
|
||||
export function flushEvents(): void {
|
||||
void telemetry.flush();
|
||||
}
|
||||
|
||||
export function _queueForTesting(): Record<string, unknown>[] {
|
||||
return queue;
|
||||
return telemetry.queueForTesting();
|
||||
}
|
||||
|
||||
export function _resetForTesting(): void {
|
||||
queue = [];
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = undefined;
|
||||
telemetry.resetForTesting();
|
||||
cachedAnonymousId = undefined;
|
||||
currentDistinctId = "";
|
||||
identified = false;
|
||||
}
|
||||
|
||||
@@ -25,15 +25,28 @@ interface RegisteredTool {
|
||||
execute(args: unknown, exec: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
type HarnessListener = (...args: any[]) => unknown;
|
||||
|
||||
function applyAndCollect(config: Config): Map<string, RegisteredTool> {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
const ctx = {
|
||||
tools: { register: (t: RegisteredTool) => tools.set(t.name, t) },
|
||||
on: vi.fn(),
|
||||
};
|
||||
apply(ctx as never, config);
|
||||
return tools;
|
||||
}
|
||||
|
||||
function applyAndCollectListeners(config: Config): Map<string, HarnessListener> {
|
||||
const listeners = new Map<string, HarnessListener>();
|
||||
const ctx = {
|
||||
tools: { register: vi.fn() },
|
||||
on: (event: string, listener: HarnessListener) => listeners.set(event, listener),
|
||||
};
|
||||
apply(ctx as never, config);
|
||||
return listeners;
|
||||
}
|
||||
|
||||
let savedKey: string | undefined;
|
||||
let savedTelemetry: string | undefined;
|
||||
|
||||
@@ -68,6 +81,97 @@ describe("apply() config validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Harness lifecycle", () => {
|
||||
it("automatically recalls memory into the prompt for the latest human message", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [{ id: "m1", memory: "Likes tea" }],
|
||||
});
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const assemble = listeners.get("system-prompt/assemble")!;
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
|
||||
const result = await assemble(
|
||||
base,
|
||||
{
|
||||
agent: {
|
||||
session: {
|
||||
deriveMessages: () => [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What do I drink?" }],
|
||||
source: { kind: "user" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => base,
|
||||
);
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith("What do I drink?", {
|
||||
filters: { user_id: "u" },
|
||||
topK: 5,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
contexts: [{ name: "mem0:recall", text: expect.stringContaining("Likes tea") }],
|
||||
});
|
||||
});
|
||||
|
||||
it("automatically captures a completed human and assistant turn", async () => {
|
||||
mockAdd.mockResolvedValue({ eventId: "evt-1", status: "PENDING" });
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const onSessionEvent = listeners.get("session/event")!;
|
||||
const session = {};
|
||||
|
||||
onSessionEvent(session, { type: "turn/start", data: { turn: 1 } });
|
||||
onSessionEvent(session, {
|
||||
type: "user/message",
|
||||
data: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Remember I like tea" }],
|
||||
source: { kind: "user" },
|
||||
},
|
||||
});
|
||||
onSessionEvent(session, {
|
||||
type: "assistant/message",
|
||||
data: {
|
||||
turn: 1,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I will remember that." }],
|
||||
source: { kind: "model" },
|
||||
},
|
||||
},
|
||||
});
|
||||
onSessionEvent(session, {
|
||||
type: "turn/end",
|
||||
data: { turn: 1, reason: { kind: "completed" } },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockAdd).toHaveBeenCalledWith(
|
||||
[
|
||||
{ role: "user", content: "Remember I like tea" },
|
||||
{ role: "assistant", content: "I will remember that." },
|
||||
],
|
||||
{ userId: "u", source: "DEEPSEEK_HARNESS" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable automatic recall and capture without removing the memory tools", () => {
|
||||
const listeners = applyAndCollectListeners({
|
||||
apiKey: "k",
|
||||
userId: "u",
|
||||
autoRecall: false,
|
||||
autoCapture: false,
|
||||
});
|
||||
|
||||
expect(listeners.has("system-prompt/assemble")).toBe(false);
|
||||
expect(listeners.has("session/event")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_memory tool", () => {
|
||||
it("returns a formatted list scoped to the configured user", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
@@ -87,7 +191,7 @@ describe("search_memory tool", () => {
|
||||
|
||||
it("honors a per-call userId override and limit", async () => {
|
||||
mockSearch.mockResolvedValue({ results: [] });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u", allowUserOverride: true });
|
||||
|
||||
await tools.get("search_memory")!.execute({ query: "x", userId: "alice", limit: 3 }, {});
|
||||
|
||||
@@ -136,6 +240,18 @@ describe("add_memory tool", () => {
|
||||
expect(out).toContain("[mem0:m1]");
|
||||
});
|
||||
|
||||
it("redacts credentials before storing explicit memory", async () => {
|
||||
mockAdd.mockResolvedValue([]);
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("add_memory")!.execute({ text: "api_key=do-not-store-this" }, {});
|
||||
|
||||
expect(mockAdd).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "api_key=[REDACTED]" }],
|
||||
{ userId: "u", source: "DEEPSEEK_HARNESS" },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a graceful failure line on error", async () => {
|
||||
mockAdd.mockRejectedValue(new Error("boom"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
@@ -146,3 +262,13 @@ describe("add_memory tool", () => {
|
||||
expect(out).toContain("boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool user ownership", () => {
|
||||
it("rejects cross-user reads and writes before contacting Mem0", async () => {
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
await expect(tools.get("search_memory")!.execute({ query: "x", userId: "other" }, {})).rejects.toThrow(/allowUserOverride/);
|
||||
await expect(tools.get("add_memory")!.execute({ text: "x", userId: "other" }, {})).rejects.toThrow(/allowUserOverride/);
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,10 @@ interface RegisteredTool {
|
||||
|
||||
function applyAndCollect(config: Config): Map<string, RegisteredTool> {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
apply({ tools: { register: (t: RegisteredTool) => tools.set(t.name, t) } } as never, config);
|
||||
apply({
|
||||
tools: { register: (t: RegisteredTool) => tools.set(t.name, t) },
|
||||
on: vi.fn(),
|
||||
} as never, config);
|
||||
return tools;
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ describe("tool instrumentation", () => {
|
||||
|
||||
it("records a write with its size but not its text", async () => {
|
||||
mockAdd.mockResolvedValue([{ id: "m1", memory: "Fact" }]);
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u", allowUserOverride: true });
|
||||
|
||||
await tools.get("add_memory")!.execute({ text: "secret fact", userId: "alice" }, {});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user