feat(integrations): dsh-mem0 — Mem0 as a native DeepSeek Harness plugin (#7027)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
@@ -21,6 +21,7 @@ Package workflows keep their own push-to-main and manual triggers. Their `pull_r
|
||||
| Mem0 Plugin | `mem0-plugin-checks.yml` | Push to main (`integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 |
|
||||
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (`.opencode-plugin/`), manual | Bun: tsc + build + dist artifact check |
|
||||
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (`integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup on Node 20, 22 |
|
||||
| DeepSeek Harness Plugin | `dsh-mem0-checks.yml` | Push to main (`integrations/dsh-mem0/`), manual | tsc + vitest + tsup on Node 20, 22 |
|
||||
| n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (`integrations/n8n-nodes-mem0/`), manual | ESLint + tsc build on Node 20 |
|
||||
| Zapier App | `zapier-mem0-checks.yml` | Push to main (`integrations/zapier-mem0/`), manual | tsc + `zapier validate` + offline unit tests on Node 22 |
|
||||
| strands-mem0 | `strands-mem0-checks.yml` | Push to main (`integrations/strands-mem0/`), manual | Ruff + mypy + pytest + hatch build on Python 3.10, 3.11, 3.12 |
|
||||
@@ -59,6 +60,7 @@ Requiring `CI Gate` also means fork PRs from first-time contributors cannot merg
|
||||
| OpenClaw | `openclaw-cd.yml` | `openclaw-v*` | npm (`@mem0/openclaw-mem0`) |
|
||||
| OpenCode Plugin | `opencode-plugin-cd.yml` | `opencode-v*` | npm (`@mem0/opencode-plugin`) |
|
||||
| Pi Agent Plugin | `pi-agent-plugin-cd.yml` | `pi-agent-v*` | npm (`@mem0/pi-agent-plugin`) |
|
||||
| DeepSeek Harness Plugin | `dsh-mem0-cd.yml` | `dsh-mem0-v*` | npm (`@mem0/dsh-mem0`) |
|
||||
| n8n Node | `n8n-nodes-mem0-cd.yml` | `n8n-nodes-mem0-v*` | npm (`@mem0/n8n-nodes-mem0`) |
|
||||
| strands-mem0 | `strands-mem0-cd.yml` | `strands-mem0-v*` | PyPI (`strands-mem0`) |
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }}
|
||||
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
|
||||
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
|
||||
dsh_mem0: ${{ steps.filter.outputs.dsh_mem0 }}
|
||||
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
|
||||
zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }}
|
||||
strands_mem0: ${{ steps.filter.outputs.strands_mem0 }}
|
||||
@@ -89,6 +90,10 @@ jobs:
|
||||
- 'integrations/pi-agent-plugin/**'
|
||||
- '.github/workflows/pi-agent-plugin-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
dsh_mem0:
|
||||
- 'integrations/dsh-mem0/**'
|
||||
- '.github/workflows/dsh-mem0-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
n8n_nodes_mem0:
|
||||
- 'integrations/n8n-nodes-mem0/**'
|
||||
- '.github/workflows/n8n-nodes-mem0-checks.yml'
|
||||
@@ -171,6 +176,13 @@ jobs:
|
||||
uses: ./.github/workflows/pi-agent-plugin-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
dsh-mem0:
|
||||
name: DeepSeek Harness Plugin
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dsh_mem0 == 'true'
|
||||
uses: ./.github/workflows/dsh-mem0-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
n8n-nodes-mem0:
|
||||
name: n8n Node
|
||||
needs: changes
|
||||
@@ -227,6 +239,7 @@ jobs:
|
||||
- mem0-plugin
|
||||
- opencode-plugin
|
||||
- pi-agent-plugin
|
||||
- dsh-mem0
|
||||
- n8n-nodes-mem0
|
||||
- zapier-mem0
|
||||
- strands-mem0
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Publish @mem0/dsh-mem0 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# dsh-mem0-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. dsh-mem0-v0.1.1)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/dsh-mem0 📦 to npm
|
||||
if: startsWith(inputs.tag, 'dsh-mem0-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/dsh-mem0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
npx npm@latest publish --provenance --access public
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
name: dsh-mem0 checks
|
||||
|
||||
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||
# push-to-main and manual runs remain standalone.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'integrations/dsh-mem0/**'
|
||||
- '.github/workflows/dsh-mem0-checks.yml'
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/dsh-mem0 && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: cd integrations/dsh-mem0 && pnpm exec tsc --noEmit
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/dsh-mem0 && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: cd integrations/dsh-mem0 && pnpm exec vitest run
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/dsh-mem0 && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: cd integrations/dsh-mem0 && pnpm build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f integrations/dsh-mem0/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f integrations/dsh-mem0/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
openclaw-v*) workflow="openclaw-cd.yml" ;;
|
||||
opencode-v*) workflow="opencode-plugin-cd.yml" ;;
|
||||
pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;;
|
||||
dsh-mem0-v*) workflow="dsh-mem0-cd.yml" ;;
|
||||
n8n-nodes-mem0-v*) workflow="n8n-nodes-mem0-cd.yml" ;;
|
||||
strands-mem0-v*) workflow="strands-mem0-cd.yml" ;;
|
||||
v*) workflow="cd.yml" ;;
|
||||
|
||||
@@ -191,3 +191,7 @@ qdrant_storage/
|
||||
testing.ipynb
|
||||
.weave/
|
||||
|
||||
|
||||
# TypeScript incremental build info and local, uncommitted e2e scripts (used by the integrations, e.g. integrations/dsh-mem0)
|
||||
*.tsbuildinfo
|
||||
*.local.mjs
|
||||
|
||||
+2
-1
@@ -382,7 +382,8 @@
|
||||
"pages": [
|
||||
"integrations/openclaw",
|
||||
"integrations/hermes",
|
||||
"integrations/pi-agent"
|
||||
"integrations/pi-agent",
|
||||
"integrations/dsh-mem0"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: DeepSeek Harness
|
||||
description: "Add persistent Mem0 memory to the DeepSeek Harness (Cordis) agent with two native tools: search and add."
|
||||
---
|
||||
|
||||
Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-ai/deepseek-harness) with `@mem0/dsh-mem0`. The Harness agent forgets everything between sessions. This plugin gives it two Mem0-backed tools so recall and writes persist across runs, sharing the same memory bank you already use from Claude Code, Codex, and other agents.
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin registers two agent-callable tools:
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `search_memory` | Recall facts from Mem0 relevant to a query |
|
||||
| `add_memory` | Store a fact in Mem0 for future sessions |
|
||||
|
||||
Unlike file-based memory plugins, Mem0 is a managed backend: server-side extraction, semantic dedup, and conflict resolution, with the same memory reusable across every agent you connect.
|
||||
|
||||
## 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(...)`. When the plugin unmounts, the tools are removed automatically (Cordis revertible effects).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Mem0 Platform account and API key:
|
||||
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-dsh-mem0" rel="nofollow">Sign up at app.mem0.ai</a>
|
||||
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-dsh-mem0" rel="nofollow">Get your API key</a> (starts with `m0-`)
|
||||
|
||||
2. The DeepSeek Harness installed.
|
||||
|
||||
3. Your API key exported in your shell:
|
||||
|
||||
<CodeGroup>
|
||||
```bash zsh
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
```bash bash
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Try it locally
|
||||
|
||||
1. Build the plugin:
|
||||
```sh
|
||||
cd integrations/dsh-mem0
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
2. Point the Harness at it. Copy `cordis.example.yml`, set the absolute path to `dist/index.js` and your `userId`, then load it:
|
||||
```sh
|
||||
pnpm dsh web --patch ./integrations/dsh-mem0/cordis.example.yml
|
||||
```
|
||||
|
||||
3. Open the web UI and ask the agent to remember something, then recall it in a later turn.
|
||||
|
||||
The `cordis.yml` entry looks like this:
|
||||
|
||||
```yaml
|
||||
- name: "@deepseek-ai/dsh-system-prompt"
|
||||
- name: "@deepseek-ai/dsh-tools"
|
||||
- insert:
|
||||
- id: mem0
|
||||
name: "/absolute/path/to/integrations/dsh-mem0/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from MEM0_API_KEY when omitted here.
|
||||
userId: "your-user-id"
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
|
||||
```
|
||||
|
||||
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, not a switch to self-hosted Mem0 OSS.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Default entity that owns the memories |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
|
||||
Both tools also accept optional per-call `userId`, `agentId`, and `runId` params so a single install can partition memory by entity, agent, or session; when omitted they fall back to the configured `userId`.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0's backend can attribute usage to this integration.
|
||||
@@ -257,6 +257,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
|
||||
- [ChatDev](https://docs.mem0.ai/integrations/chatdev) [Platform]: Use when the user is on ChatDev.
|
||||
- [Hermes](https://docs.mem0.ai/integrations/hermes) [Both]: Use when the user is on Hermes.
|
||||
- [Pi Agent](https://docs.mem0.ai/integrations/pi-agent) [Platform]: Use when adding persistent memory to Pi Agent with the Mem0 plugin.
|
||||
- [DeepSeek Harness](https://docs.mem0.ai/integrations/dsh-mem0) [Platform]: Use when adding persistent memory to the DeepSeek Harness (Cordis) agent via the Mem0 plugin.
|
||||
- [OpenAI Agents SDK](https://docs.mem0.ai/integrations/openai-agents-sdk) [Platform]: Use when the user is on the OpenAI Agents SDK.
|
||||
- [Google AI ADK](https://docs.mem0.ai/integrations/google-ai-adk) [Platform]: Use when the user is on Google's Agent Development Kit.
|
||||
- [Mastra](https://docs.mem0.ai/integrations/mastra) [Platform]: Use when the user is on Mastra (TypeScript).
|
||||
|
||||
@@ -9,6 +9,7 @@ Agent and editor integrations. Each subdirectory is self-contained: its own `pac
|
||||
| `mem0-plugin/` | Claude Code / Cursor / Codex plugin | none | none | pytest |
|
||||
| `mem0-plugin/.opencode-plugin/` | `@mem0/opencode-plugin` | Bun | none | tsc type-check |
|
||||
| `pi-agent-plugin/` | `@mem0/pi-agent-plugin` | tsup | none | vitest |
|
||||
| `dsh-mem0/` | `@mem0/dsh-mem0` | tsup (ESM) | none | vitest |
|
||||
| `n8n-nodes-mem0/` | `@mem0/n8n-nodes-mem0` | tsc | ESLint (n8n-nodes-base) | none |
|
||||
| `zapier-mem0/` | `@mem0/zapier` | tsc | none | offline unit tests + `zapier validate` |
|
||||
| `strands-mem0/` | `strands-mem0` (PyPI) | hatch | Ruff + mypy | pytest |
|
||||
@@ -40,7 +41,7 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
||||
|
||||
- **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`.
|
||||
- **`mem0-plugin/`** connects Claude Code, Cursor, and Codex to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`.
|
||||
- **`openclaw/`**, **`pi-agent-plugin/`** are editor and agent plugins with the same shape.
|
||||
- **`openclaw/`**, **`pi-agent-plugin/`**, **`dsh-mem0/`** are editor and agent plugins with the same shape. `dsh-mem0/` registers Mem0 search/add tools as a native DeepSeek Harness (Cordis) plugin.
|
||||
- **`n8n-nodes-mem0/`** is an n8n community node: add, search, get, update, delete.
|
||||
- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret).
|
||||
- **`strands-mem0/`** is a native Strands `MemoryStore` (Python, published to PyPI as `strands-mem0`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `strands-mem0/python/`.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# The DeepSeek Harness SDK (@deepseek-ai/dsh-tools) declares the rest of the
|
||||
# harness runtime (dsh-llm, dsh-agent, dsh-scope, ...) as peer dependencies.
|
||||
# The host harness provides those at runtime; this adapter only needs cordis +
|
||||
# dsh-tools to build and type-check. Auto-installing the full peer graph pulls
|
||||
# unpublished internal packages (e.g. dsh-type-meta), so keep it off.
|
||||
auto-install-peers=false
|
||||
package-manager-strict-version=false
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2023] [Taranjeet Singh]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,60 @@
|
||||
# dsh-mem0
|
||||
|
||||
[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:
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `search_memory` | Recall facts from Mem0 relevant to a query |
|
||||
| `add_memory` | Store a fact in Mem0 for future sessions |
|
||||
|
||||
Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed backend: server-side extraction, semantic dedup and conflict resolution, and the same memory bank reusable across Harness, Claude Code, Codex, and other agents.
|
||||
|
||||
## 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).
|
||||
|
||||
```
|
||||
[ mem0ai SDK ] <-- managed memory, owned by Mem0
|
||||
|
|
||||
[ dsh-mem0: apply(ctx) -> ctx.tools.register(...) ] <-- this package
|
||||
|
|
||||
[ DeepSeek Harness ] <-- the agent, loaded via cordis.yml
|
||||
```
|
||||
|
||||
## Try it locally
|
||||
|
||||
1. Build the plugin:
|
||||
```sh
|
||||
cd integrations/dsh-mem0
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
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:
|
||||
```sh
|
||||
pnpm dsh web --patch ./integrations/dsh-mem0/cordis.example.yml
|
||||
```
|
||||
4. 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.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Entity that owns the memories |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
|
||||
## Telemetry
|
||||
|
||||
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0's backend can attribute usage to this integration. For it to surface by name (rather than bucketing into `OTHERS`), `DEEPSEEK_HARNESS` must be present in the backend's `KNOWN_EVENT_SOURCES` allowlist, a one-line platform change matching the existing `ZAPIER` / `STRANDS` sources.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Example DeepSeek Harness config that loads dsh-mem0 alongside the built-in
|
||||
# tools plugin. Load with:
|
||||
#
|
||||
# pnpm dsh web --patch ./integrations/dsh-mem0/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/dsh-mem0/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from the MEM0_API_KEY env var when omitted here.
|
||||
userId: "your-user-id"
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@mem0/dsh-mem0",
|
||||
"version": "0.1.0",
|
||||
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mem0ai/mem0",
|
||||
"directory": "integrations/dsh-mem0"
|
||||
},
|
||||
"keywords": [
|
||||
"deepseek-harness",
|
||||
"dsh",
|
||||
"dsh-plugin",
|
||||
"cordis",
|
||||
"mem0",
|
||||
"memory",
|
||||
"agent"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"mem0ai": "^3.0.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "*",
|
||||
"@deepseek-ai/dsh-tools": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@deepseek-ai/dsh-tools": "0.0.1-rc.1",
|
||||
"@types/node": "^22.15.0",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
Generated
+2094
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* dsh-mem0: Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.
|
||||
*
|
||||
* Registers two agent-callable tools backed by the Mem0 SDK:
|
||||
* - `search_memory` recalls facts relevant to a query
|
||||
* - `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;
|
||||
* tools registered via `ctx.tools.register(...)` are auto-unregistered when the
|
||||
* plugin unmounts (Cordis revertible effects).
|
||||
*/
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
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";
|
||||
|
||||
export const name = "mem0";
|
||||
export const inject = ["tools"];
|
||||
|
||||
// Tags writes so Mem0's backend attributes them to this integration in
|
||||
// telemetry. The backend keeps recognized values via its KNOWN_EVENT_SOURCES
|
||||
// allowlist; unknown values bucket into "OTHERS", so "DEEPSEEK_HARNESS" must be
|
||||
// added to that allowlist for usage to surface by name (a one-line backend PR,
|
||||
// same pattern as the ZAPIER / STRANDS sources).
|
||||
const SOURCE = "DEEPSEEK_HARNESS";
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 10;
|
||||
|
||||
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;
|
||||
/** Optional Mem0 Platform base-URL override (on-prem / dedicated); defaults to api.mem0.ai. Not a switch to self-hosted OSS. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
// Both tools return a single text string; the render is identical, so lift it
|
||||
// into one shared declaration instead of repeating the block per tool.
|
||||
const textOutput = {
|
||||
schema: { type: "string" } as const,
|
||||
render: (_args: unknown, value: string) => [
|
||||
{ type: "text" as const, text: value },
|
||||
],
|
||||
};
|
||||
|
||||
// Optional per-call scoping params, shared by both tools. A single harness
|
||||
// install can serve more than one entity, so the model may override the
|
||||
// mount-time default per call (see scoping.ts).
|
||||
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.",
|
||||
},
|
||||
agentId: {
|
||||
type: "string",
|
||||
description: "Optional agent scope, to partition memories by agent.",
|
||||
},
|
||||
runId: {
|
||||
type: "string",
|
||||
description: "Optional run/session scope, to partition memories by session.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.MEM0_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("dsh-mem0: set config.apiKey or the MEM0_API_KEY env var");
|
||||
}
|
||||
const userId = config.userId;
|
||||
if (!userId) {
|
||||
throw new Error("dsh-mem0: config.userId is required");
|
||||
}
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey,
|
||||
...(config.host ? { host: config.host } : {}),
|
||||
});
|
||||
|
||||
// Recall. The platform rejects top-level entity params on search, so scope
|
||||
// goes inside `filters` (unlike add below, which takes them top-level).
|
||||
ctx.tools.register(
|
||||
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.",
|
||||
parameters: {
|
||||
query: { type: "string", description: "What to recall.", required: true },
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: `Max results to return (default ${DEFAULT_SEARCH_LIMIT}).`,
|
||||
},
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ query, limit, userId: u, agentId, runId }) {
|
||||
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
|
||||
try {
|
||||
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
|
||||
const { results } = await client.search(query, { filters, topK });
|
||||
return truncateOutput(formatMemoryList(results ?? []));
|
||||
} catch (err) {
|
||||
return `search_memory failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Write. `source` tags the memory for telemetry attribution.
|
||||
ctx.tools.register(
|
||||
defineTool({
|
||||
name: "add_memory",
|
||||
description:
|
||||
"Store a fact in the user's long-term Mem0 memory for later sessions. Extraction runs asynchronously server-side, so a stored fact may take a moment to become searchable; do not immediately search to confirm the write.",
|
||||
parameters: {
|
||||
text: { type: "string", description: "The fact to remember.", required: true },
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ text, userId: u, agentId, runId }) {
|
||||
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
|
||||
try {
|
||||
const result = await client.add([{ role: "user", content: text }], {
|
||||
...addParams,
|
||||
source: SOURCE,
|
||||
});
|
||||
return truncateOutput(formatAddResult(result));
|
||||
} catch (err) {
|
||||
return `add_memory failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Offline mock of the Mem0 SDK so these tests never touch the network.
|
||||
const mockSearch = vi.fn();
|
||||
const mockAdd = vi.fn();
|
||||
vi.mock("mem0ai", () => ({
|
||||
MemoryClient: class {
|
||||
search = mockSearch;
|
||||
add = mockAdd;
|
||||
},
|
||||
}));
|
||||
|
||||
// The real `@deepseek-ai/dsh-tools` runtime transitively imports harness peer
|
||||
// packages the host provides at runtime but which aren't installed here. For
|
||||
// these unit tests we only need `defineTool` to hand back the definition it was
|
||||
// given, so the registered tool's `execute`/`name` can be exercised directly.
|
||||
vi.mock("@deepseek-ai/dsh-tools", () => ({
|
||||
defineTool: (options: unknown) => options,
|
||||
}));
|
||||
|
||||
import { apply, type Config } from "../src/index.ts";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute(args: unknown, exec: unknown): Promise<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) },
|
||||
};
|
||||
apply(ctx as never, config);
|
||||
return tools;
|
||||
}
|
||||
|
||||
let savedKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedKey = process.env.MEM0_API_KEY;
|
||||
mockSearch.mockReset();
|
||||
mockAdd.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedKey === undefined) delete process.env.MEM0_API_KEY;
|
||||
else process.env.MEM0_API_KEY = savedKey;
|
||||
});
|
||||
|
||||
describe("apply() config validation", () => {
|
||||
it("throws when no apiKey is set and MEM0_API_KEY is absent", () => {
|
||||
delete process.env.MEM0_API_KEY;
|
||||
expect(() => applyAndCollect({ userId: "u" } as Config)).toThrow(/apiKey|MEM0_API_KEY/);
|
||||
});
|
||||
|
||||
it("throws when userId is missing", () => {
|
||||
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
|
||||
});
|
||||
|
||||
it("registers both memory tools", () => {
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
expect([...tools.keys()].sort()).toEqual(["add_memory", "search_memory"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_memory tool", () => {
|
||||
it("returns a formatted list scoped to the configured user", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [{ id: "m1", memory: "Likes tea", categories: ["preference"] }],
|
||||
});
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("search_memory")!.execute({ query: "drink" }, {});
|
||||
|
||||
expect(out).toContain("Likes tea");
|
||||
expect(out).toContain("[mem0:m1]");
|
||||
expect(mockSearch).toHaveBeenCalledWith("drink", {
|
||||
filters: { user_id: "u" },
|
||||
topK: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors a per-call userId override and limit", async () => {
|
||||
mockSearch.mockResolvedValue({ results: [] });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("search_memory")!.execute({ query: "x", userId: "alice", limit: 3 }, {});
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith("x", {
|
||||
filters: { user_id: "alice" },
|
||||
topK: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a graceful failure line instead of rejecting on error", async () => {
|
||||
mockSearch.mockRejectedValue(new Error("network down"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("search_memory")!.execute({ query: "x" }, {});
|
||||
|
||||
expect(out).toContain("search_memory failed");
|
||||
expect(out).toContain("network down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add_memory tool", () => {
|
||||
it("reports the write as queued on the async PENDING response, with camelCase scope + source", async () => {
|
||||
// The real /v3/memories/add/ response — not an array of memories. The SDK
|
||||
// camel-cases response keys, so it surfaces as `eventId`, not `event_id`.
|
||||
mockAdd.mockResolvedValue({ eventId: "evt-123", status: "PENDING" });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "remember this" }, {});
|
||||
|
||||
expect(out).toContain("queued");
|
||||
expect(out).toContain("evt-123");
|
||||
expect(out).not.toContain("No new distinct memory");
|
||||
expect(mockAdd).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "remember this" }],
|
||||
{ userId: "u", source: "DEEPSEEK_HARNESS" },
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a list when the backend returns memories", async () => {
|
||||
mockAdd.mockResolvedValue([{ id: "m1", memory: "Fact" }]);
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "x" }, {});
|
||||
|
||||
expect(out).toContain("Stored 1 memory");
|
||||
expect(out).toContain("[mem0:m1]");
|
||||
});
|
||||
|
||||
it("returns a graceful failure line on error", async () => {
|
||||
mockAdd.mockRejectedValue(new Error("boom"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "x" }, {});
|
||||
|
||||
expect(out).toContain("add_memory failed");
|
||||
expect(out).toContain("boom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatAge,
|
||||
formatMemoryCompact,
|
||||
formatMemoryList,
|
||||
formatAddResult,
|
||||
} from "../src/formatting.ts";
|
||||
|
||||
describe("formatAge", () => {
|
||||
it("formats minutes, hours, and days", () => {
|
||||
expect(formatAge(new Date(Date.now() - 30 * 60_000))).toBe("30m ago");
|
||||
expect(formatAge(new Date(Date.now() - 3 * 3_600_000))).toBe("3h ago");
|
||||
expect(formatAge(new Date(Date.now() - 5 * 86_400_000))).toBe("5d ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoryCompact", () => {
|
||||
it("renders one line with category, text, and id", () => {
|
||||
const line = formatMemoryCompact({
|
||||
id: "abc-123",
|
||||
memory: "User prefers dark mode",
|
||||
categories: ["preference"],
|
||||
createdAt: new Date(),
|
||||
});
|
||||
expect(line).toContain("[preference]");
|
||||
expect(line).toContain("User prefers dark mode");
|
||||
expect(line).toContain("[mem0:abc-123]");
|
||||
});
|
||||
|
||||
it("falls back to uncategorized and (empty)", () => {
|
||||
expect(formatMemoryCompact({ id: "x" })).toContain("[uncategorized]");
|
||||
expect(formatMemoryCompact({ id: "x" })).toContain("(empty)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMemoryList", () => {
|
||||
it("numbers multiple memories", () => {
|
||||
const output = formatMemoryList([
|
||||
{ id: "id-1", memory: "Fact one", categories: ["insight"] },
|
||||
{ id: "id-2", memory: "Fact two", categories: ["convention"] },
|
||||
]);
|
||||
expect(output).toContain("1.");
|
||||
expect(output).toContain("2.");
|
||||
});
|
||||
|
||||
it("returns a plain message when there are no memories", () => {
|
||||
expect(formatMemoryList([])).toBe("No memories found.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAddResult", () => {
|
||||
it("reports queued for the async PENDING response, with the event id", () => {
|
||||
// SDK camel-cases response keys, so the real shape is `eventId`.
|
||||
const out = formatAddResult({ eventId: "evt-9", status: "PENDING" });
|
||||
expect(out).toContain("queued");
|
||||
expect(out).toContain("evt-9");
|
||||
});
|
||||
|
||||
it("reports the stored count when the backend returns memories", () => {
|
||||
expect(formatAddResult([{ id: "1", memory: "A" }])).toContain("Stored 1 memory");
|
||||
expect(formatAddResult([{ id: "1" }, { id: "2" }])).toContain("Stored 2 memories");
|
||||
});
|
||||
|
||||
it("unwraps a { results: [...] } envelope", () => {
|
||||
expect(formatAddResult({ results: [{ id: "1", memory: "A" }] })).toContain("Stored 1 memory");
|
||||
});
|
||||
|
||||
it("handles an empty result", () => {
|
||||
expect(formatAddResult([])).toBe("Memory stored.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { truncateOutput, MAX_OUTPUT_LINES } from "../src/output.ts";
|
||||
|
||||
describe("truncateOutput", () => {
|
||||
it("passes small output through untouched", () => {
|
||||
expect(truncateOutput("a\nb\nc")).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
it("caps output at MAX_OUTPUT_LINES and appends a notice", () => {
|
||||
const many = Array.from({ length: MAX_OUTPUT_LINES + 50 }, (_, i) => `line ${i}`).join("\n");
|
||||
const out = truncateOutput(many);
|
||||
expect(out.split("\n").length).toBeLessThanOrEqual(MAX_OUTPUT_LINES + 3);
|
||||
expect(out).toContain("[Output truncated:");
|
||||
expect(out).toContain(`of ${MAX_OUTPUT_LINES + 50} lines`);
|
||||
});
|
||||
|
||||
it("caps output that is few lines but very large by bytes", () => {
|
||||
const huge = "x".repeat(60_000);
|
||||
const out = truncateOutput(huge);
|
||||
expect(out.length).toBeLessThan(huge.length);
|
||||
expect(out).toContain("[Output truncated:");
|
||||
});
|
||||
|
||||
it("reports both reasons when the line cap and the byte cap fire together", () => {
|
||||
const wide = Array.from({ length: MAX_OUTPUT_LINES + 50 }, () => "x".repeat(300)).join("\n");
|
||||
const out = truncateOutput(wide);
|
||||
expect(out).toContain(`of ${MAX_OUTPUT_LINES + 50} lines`);
|
||||
expect(out).toContain("cut at 50KB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSearchFilters, resolveAddParams } from "../src/scoping.ts";
|
||||
|
||||
describe("resolveSearchFilters (snake_case, for filters)", () => {
|
||||
it("falls back to the configured default userId", () => {
|
||||
expect(resolveSearchFilters({}, "default-user")).toEqual({ user_id: "default-user" });
|
||||
});
|
||||
|
||||
it("lets a per-call userId override the default", () => {
|
||||
expect(resolveSearchFilters({ userId: "alice" }, "default-user")).toEqual({
|
||||
user_id: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a blank/whitespace userId as absent and falls back", () => {
|
||||
expect(resolveSearchFilters({ userId: " " }, "default-user")).toEqual({
|
||||
user_id: "default-user",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes snake_case agent/run scope only when provided", () => {
|
||||
expect(
|
||||
resolveSearchFilters({ userId: "alice", agentId: "agent-1", runId: "run-9" }, "d"),
|
||||
).toEqual({ user_id: "alice", agent_id: "agent-1", run_id: "run-9" });
|
||||
});
|
||||
|
||||
it("omits blank agent/run scope", () => {
|
||||
const f = resolveSearchFilters({ agentId: " ", runId: "run-9" }, "default");
|
||||
expect(f).toEqual({ user_id: "default", run_id: "run-9" });
|
||||
expect(f).not.toHaveProperty("agent_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAddParams (camelCase, for top-level add params)", () => {
|
||||
it("uses camelCase keys and falls back to the default userId", () => {
|
||||
expect(resolveAddParams({}, "default-user")).toEqual({ userId: "default-user" });
|
||||
});
|
||||
|
||||
it("includes camelCase agent/run scope only when provided", () => {
|
||||
expect(
|
||||
resolveAddParams({ userId: "alice", agentId: "agent-1", runId: "run-9" }, "d"),
|
||||
).toEqual({ userId: "alice", agentId: "agent-1", runId: "run-9" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
// 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