Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e9efa261 | |||
| 10d6717068 | |||
| 1d83bb0835 |
@@ -26,6 +26,7 @@ Package workflows keep their own push-to-main and manual triggers. Their `pull_r
|
||||
| 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 |
|
||||
| mem0-strands | `mem0-strands-checks.yml` | Push to main (`integrations/mem0-strands/`), manual | Ruff + mypy + pytest + hatch build on Python 3.10, 3.11, 3.12 |
|
||||
| Eve | `eve-checks.yml` | Push to main (`integrations/eve/`), manual | tsc + vitest + tsup on Node 24 |
|
||||
| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage |
|
||||
| GitHub Scripts | inline in `ci-gate.yml` | none | `node` over every `.github/scripts/*.test.js` |
|
||||
|
||||
@@ -64,6 +65,7 @@ Requiring `CI Gate` also means fork PRs from first-time contributors cannot merg
|
||||
| DeepSeek Harness Plugin | `deepseek-plugin-cd.yml` | `deepseek-plugin-v*` | npm (`@mem0/deepseek-plugin`) |
|
||||
| n8n Node | `n8n-nodes-mem0-cd.yml` | `n8n-nodes-mem0-v*` | npm (`@mem0/n8n-nodes-mem0`) |
|
||||
| mem0-strands | `mem0-strands-cd.yml` | `mem0-strands-v*` | PyPI (`mem0-strands`) |
|
||||
| Eve | `eve-cd.yml` | `eve-v*` | npm (`@mem0/eve`) |
|
||||
|
||||
- Package CD workflows are `workflow_dispatch`-only, with `tag` and `prerelease` inputs. They check out and build the given tag.
|
||||
- All publishing uses **OIDC trusted publishing**. No tokens, no secrets.
|
||||
|
||||
@@ -46,6 +46,7 @@ jobs:
|
||||
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
|
||||
zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }}
|
||||
mem0_strands: ${{ steps.filter.outputs.mem0_strands }}
|
||||
eve: ${{ steps.filter.outputs.eve }}
|
||||
docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }}
|
||||
github_scripts: ${{ steps.filter.outputs.github_scripts }}
|
||||
steps:
|
||||
@@ -110,6 +111,10 @@ jobs:
|
||||
- 'integrations/mem0-strands/**'
|
||||
- '.github/workflows/mem0-strands-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
eve:
|
||||
- 'integrations/eve/**'
|
||||
- '.github/workflows/eve-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
docs_llms_txt:
|
||||
- 'docs/**/*.mdx'
|
||||
- 'docs/llms.txt'
|
||||
@@ -214,6 +219,13 @@ jobs:
|
||||
uses: ./.github/workflows/mem0-strands-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
eve:
|
||||
name: Eve
|
||||
needs: changes
|
||||
if: needs.changes.outputs.eve == 'true'
|
||||
uses: ./.github/workflows/eve-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
docs-llms-txt:
|
||||
name: docs llms.txt
|
||||
needs: changes
|
||||
@@ -256,6 +268,7 @@ jobs:
|
||||
- n8n-nodes-mem0
|
||||
- zapier-mem0
|
||||
- mem0-strands
|
||||
- eve
|
||||
- docs-llms-txt
|
||||
- github-scripts
|
||||
if: always()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Publish @mem0/eve 📦 to npm
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. eve-v0.1.0)'
|
||||
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/eve 📦 to npm
|
||||
if: startsWith(inputs.tag, 'eve-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/eve
|
||||
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: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/eve/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,85 @@
|
||||
name: eve checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'integrations/eve/**'
|
||||
- '.github/workflows/eve-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: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/eve/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/eve && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: cd integrations/eve && pnpm typecheck
|
||||
|
||||
test:
|
||||
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: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/eve/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/eve && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: cd integrations/eve && pnpm test
|
||||
|
||||
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: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/eve/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/eve && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: cd integrations/eve && pnpm build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f integrations/eve/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f integrations/eve/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
deepseek-plugin-v*) workflow="deepseek-plugin-cd.yml" ;;
|
||||
n8n-nodes-mem0-v*) workflow="n8n-nodes-mem0-cd.yml" ;;
|
||||
mem0-strands-v*) workflow="mem0-strands-cd.yml" ;;
|
||||
eve-v*) workflow="eve-cd.yml" ;;
|
||||
v*) workflow="cd.yml" ;;
|
||||
*)
|
||||
echo "::error::Release tag '$TAG' does not match any known package prefix — nothing will be published. See the tag prefix table in AGENTS.md."
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
"integrations/google-ai-adk",
|
||||
"integrations/mastra",
|
||||
"integrations/vercel-ai-sdk",
|
||||
"integrations/eve",
|
||||
"integrations/vercel",
|
||||
"integrations/chatdev",
|
||||
"integrations/strands"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Eve
|
||||
description: "Use Mem0 as a first-class memory provider for Vercel Eve agents."
|
||||
---
|
||||
|
||||
[Eve](https://eve.dev/docs/memory) is Vercel's agent framework. A memory provider sits in the agent loop: Eve recalls context before each turn, captures completed turns, and exposes provider tools. `@mem0/eve` is Mem0's implementation of that contract.
|
||||
|
||||
This is not the Mem0 MCP connection. MCP gives the model tools it may or may not call. The memory provider runs automatically. The live MCP install is `eve add connection/mem0`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @mem0/eve
|
||||
```
|
||||
|
||||
`eve` is a peer dependency. Get an API key from the [Mem0 dashboard](https://app.mem0.ai/dashboard/api-keys).
|
||||
|
||||
Create `agent/memory/mem0.ts`:
|
||||
|
||||
```ts
|
||||
import { defineMemory } from "eve/memory";
|
||||
import { byPrincipal } from "eve/memory/scope";
|
||||
import { mem0Provider } from "@mem0/eve";
|
||||
|
||||
export default defineMemory({
|
||||
description: "Recall and manage durable context for the current user.",
|
||||
provider: mem0Provider({
|
||||
apiKey: process.env.MEM0_API_KEY!,
|
||||
}),
|
||||
scope: byPrincipal,
|
||||
});
|
||||
```
|
||||
|
||||
`eve add memory/mem0` is not in Eve's catalog yet. After Mem0 is listed in the official registry, that command will create the same slot file.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Recall.** Before each turn and after compaction, Mem0 searches memories for the locked Eve scope and returns `{ id, content }` messages. Eve injects those as user-role messages attributed to the slot.
|
||||
2. **Capture.** After a successful turn, Mem0 adds the user messages from that turn and, when present, that turn's assistant reply. By default (`infer: true`) Mem0 extracts durable facts. Capture is deduplicated per `operationId` on a best-effort basis (an in-process gate plus a durable `metadata.operation_id` lookup); because that check and the write are not atomic and platform writes are asynchronous, a restart or concurrent worker can occasionally capture a turn twice.
|
||||
3. **Tools.** The model can call `search`, `remember`, and `forget`. Eve qualifies them as `mem0__search`, `mem0__remember`, and `mem0__forget` when the slot file is `mem0.ts`. `remember` returns `{ status: "saved" }` on a resolved write or `{ status: "queued" }` when the platform accepts it for asynchronous extraction.
|
||||
|
||||
Eve owns namespace, scope, and when recall/capture run. Mem0 owns storage, ranking, and extraction. Search, capture, remember, and forget are partitioned by `memory.scope.key`. Forget deletes by id only after confirming the memory belongs to that key.
|
||||
|
||||
## Options
|
||||
|
||||
```ts
|
||||
mem0Provider({
|
||||
apiKey: process.env.MEM0_API_KEY!,
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
infer: true,
|
||||
autoSearch: { enabled: true },
|
||||
capture: { enabled: true },
|
||||
});
|
||||
```
|
||||
|
||||
See the [`@mem0/eve` README](https://github.com/mem0ai/mem0/tree/main/integrations/eve) for the full option list.
|
||||
|
||||
## Related
|
||||
|
||||
- [Eve memory docs](https://eve.dev/docs/memory)
|
||||
- [Build a memory provider](https://eve.dev/docs/memory/custom-provider)
|
||||
- [Vercel AI SDK](/integrations/vercel-ai-sdk) for `generateText` / `streamText` apps that are not Eve agents
|
||||
@@ -263,6 +263,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
|
||||
- [Mastra](https://docs.mem0.ai/integrations/mastra) [Platform]: Use when the user is on Mastra (TypeScript).
|
||||
- [OpenClaw](https://docs.mem0.ai/integrations/openclaw) [Both]: Use when wiring Mem0 into Claude Code or editors via OpenClaw.
|
||||
- [Vercel AI SDK](https://docs.mem0.ai/integrations/vercel-ai-sdk) [Both]: Use when the user is on the Vercel AI SDK.
|
||||
- [Eve](https://docs.mem0.ai/integrations/eve) [Platform]: Use when adding Mem0 as a first-class Eve memory provider (`@mem0/eve`, `mem0Provider`, hand-written `agent/memory/mem0.ts`). `eve add memory/mem0` is not available yet. Not the Mem0 MCP connection (`eve add connection/mem0`).
|
||||
- [Vercel](https://docs.mem0.ai/integrations/vercel) [Platform]: Use when deploying on Vercel and installing Mem0 from the Vercel Marketplace.
|
||||
- [Strands Agents](https://docs.mem0.ai/integrations/strands) [Both]: Use when the user is on AWS Strands and wants a native MemoryStore.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Agent and editor integrations. Each subdirectory is self-contained: its own `pac
|
||||
|
||||
| Directory | Package | Build | Lint | Test |
|
||||
|-----------|---------|-------|------|------|
|
||||
| `eve/` | `@mem0/eve` | tsup (ESM) | none | vitest |
|
||||
| `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` | tsup (CJS+ESM) | ESLint + Prettier | jest + vitest (edge/node) |
|
||||
| `openclaw/` | `@mem0/openclaw-mem0` | tsup (ESM) | none | vitest |
|
||||
| `claude-code-plugin/` | Claude Code plugin, installs as `mem0@mem0-plugins` (v0.3.0) | none | ruff | pytest |
|
||||
@@ -20,6 +21,12 @@ pnpm everywhere except `.opencode-plugin/` (Bun) and `mem0-strands/` (Python: pi
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd integrations/eve
|
||||
pnpm install
|
||||
pnpm run typecheck
|
||||
pnpm run test
|
||||
pnpm run build
|
||||
|
||||
cd integrations/vercel-ai-sdk
|
||||
pnpm install
|
||||
pnpm run build # tsup
|
||||
@@ -40,6 +47,7 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
||||
|
||||
## What each one is
|
||||
|
||||
- **`eve/`** is the Eve memory provider (`mem0Provider`). Eve calls recall on `turn.started` and `compaction.completed`, capture on `turn.completed` when enabled, and exposes `search` / `remember` / `forget` tools. This is not the Mem0 MCP connection.
|
||||
- **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`.
|
||||
- **`claude-code-plugin/`** is the Claude Code plugin (v0.3.0, installs as `mem0@mem0-plugins`): local evidence capture via lifecycle hooks, background memory extraction to the Mem0 Platform, a local `search_memories` MCP tool, six `/mem0:*` skills, and the `mem0:sidekick` agent. Pure-stdlib Python — no dependencies to install. Its `core/` + `adapters/claude/` split marks engine vs. harness glue; future per-harness plugins start by copying `core/` and keeping the contract tests verbatim (see its `docs/CONTRACT.md`).
|
||||
- **`mem0-plugin/`** connects Cursor, Codex, Kimi, Antigravity, and OpenCode 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`. The Claude Code plugin moved to [`claude-code-plugin/`](claude-code-plugin/) in v0.3.0 (installs as `mem0@mem0-plugins`); do not run both at the same time.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.tgz
|
||||
@@ -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,95 @@
|
||||
# @mem0/eve
|
||||
|
||||
Mem0 as a first-class [Eve](https://eve.dev/docs/memory) memory provider.
|
||||
|
||||
Eve recalls relevant memories before each turn, captures completed turns automatically, and exposes search / remember / forget tools bound to the locked memory scope. Mem0 owns storage, extraction, and retrieval.
|
||||
|
||||
```ts
|
||||
import { defineMemory } from "eve/memory";
|
||||
import { byPrincipal } from "eve/memory/scope";
|
||||
import { mem0Provider } from "@mem0/eve";
|
||||
|
||||
export default defineMemory({
|
||||
description: "Recall and manage durable context for the current user.",
|
||||
provider: mem0Provider({
|
||||
apiKey: process.env.MEM0_API_KEY!,
|
||||
}),
|
||||
scope: byPrincipal,
|
||||
});
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @mem0/eve
|
||||
```
|
||||
|
||||
`eve` is a peer dependency. Get an API key from the [Mem0 dashboard](https://app.mem0.ai/dashboard/api-keys).
|
||||
|
||||
Until Eve lists Mem0 in the official registry, add the slot file yourself as `agent/memory/mem0.ts`. `eve add memory/mem0` is not available yet. After the registry PR lands, that command will create the same file.
|
||||
|
||||
The live MCP path today is `eve add connection/mem0`. That is a different integration: tools the model may call, not automatic recall/capture.
|
||||
|
||||
## What it does
|
||||
|
||||
| Eve hook | Mem0 behavior |
|
||||
|---|---|
|
||||
| `recall["turn.started"]` | Semantic search over memories for the locked scope |
|
||||
| `recall["compaction.completed"]` | Same search after Eve compacting history (`turn` may be null) |
|
||||
| `capture["turn.completed"]` | Add the completed user turn and, when present, this turn's assistant reply |
|
||||
| `tools()` | `search`, `remember`, `forget` |
|
||||
|
||||
Search, capture, remember, and forget are partitioned by `memory.scope.key`. Forget loads the memory first and deletes only when `userId` matches that key.
|
||||
|
||||
Capture uses `operationId` for **best-effort** deduplication: an in-process gate plus a durable `metadata.operation_id` lookup. This is not atomic — with `infer: true` a prior write can be a PENDING event whose memory is not yet visible, so a restart during that window (or two concurrent workers) can capture the same turn twice. Eliminating that would require a backend-supported atomic idempotency key.
|
||||
|
||||
`remember` returns `{ status: "saved" }` when the write resolves, or `{ status: "queued" }` when the platform accepts it for asynchronous extraction (it becomes searchable a moment later).
|
||||
|
||||
If the slot file is named `mem0.ts`, Eve qualifies tools as `mem0__search`, `mem0__remember`, and `mem0__forget`.
|
||||
|
||||
A throwing `recall` fails the Eve turn. A throwing `capture` is logged by Eve and does not fail the turn. Tool `execute` errors become failed tool results the model can see.
|
||||
|
||||
## Options
|
||||
|
||||
```ts
|
||||
mem0Provider({
|
||||
apiKey: process.env.MEM0_API_KEY!,
|
||||
host: "https://api.mem0.ai",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
infer: true,
|
||||
autoSearch: { enabled: true },
|
||||
capture: { enabled: true },
|
||||
metadata: { app: "support-agent" },
|
||||
});
|
||||
```
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `apiKey` | required without `store` | String or async getter. Function keys are validated on first use. |
|
||||
| `host` | `https://api.mem0.ai` | Mem0 Platform host (`http:` or `https:` URL) |
|
||||
| `topK` | `5` | Result count for automatic recall and the `search` tool |
|
||||
| `threshold` | `0.1` | Minimum similarity for automatic recall and the `search` tool |
|
||||
| `rerank` | `false` | Mem0 reranking for automatic recall and the `search` tool |
|
||||
| `infer` | `true` | Extract facts on capture and on `remember` |
|
||||
| `autoSearch.enabled` | `true` | Recall on `turn.started` and `compaction.completed` |
|
||||
| `capture.enabled` | `true` | Write after each successful turn |
|
||||
| `metadata` | `{}` | Extra metadata on captured turns |
|
||||
| `store` | unset | Test / advanced injection. Replaces the Mem0 client and must honor `userId`. |
|
||||
|
||||
## Official registry
|
||||
|
||||
Eve already listed Mem0 as an MCP connection. This package is the memory-provider path. After `@mem0/eve` is on npm, open a PR against [vercel/eve](https://github.com/vercel/eve) that adds `memory/mem0` using the files in [`registry/`](./registry). See [`registry/README.md`](./registry/README.md).
|
||||
|
||||
## Development
|
||||
|
||||
Requires Node.js 24+.
|
||||
|
||||
```bash
|
||||
cd integrations/eve
|
||||
pnpm install
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@mem0/eve",
|
||||
"version": "0.1.0",
|
||||
"description": "Mem0 memory provider for Eve agents. Recall, capture, and scope-bound tools.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mem0ai/mem0",
|
||||
"directory": "integrations/eve"
|
||||
},
|
||||
"keywords": [
|
||||
"eve",
|
||||
"mem0",
|
||||
"memory",
|
||||
"agents",
|
||||
"vercel"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"mem0ai": "^3.1.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eve": ">=0.47.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"eve": "^0.52.2",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
}
|
||||
Generated
+3429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
# Eve registry contribution
|
||||
|
||||
These files are the Mem0 side of `eve add memory/mem0`. They are not published in the npm tarball. Copy them into a PR against [vercel/eve](https://github.com/vercel/eve) after `@mem0/eve` is on npm.
|
||||
|
||||
Supermemory's version of this change is [vercel/eve#2775](https://github.com/vercel/eve/pull/2775). Use that PR as the catalog/shape reference.
|
||||
|
||||
## What the Eve PR should do
|
||||
|
||||
1. Add a `kind: "memory"` catalog item named `mem0`.
|
||||
2. Install `@mem0/eve` and declare `MEM0_API_KEY`.
|
||||
3. Write `agent/memory/mem0.ts` from [`mem0.ts`](./mem0.ts).
|
||||
4. List Mem0 on [eve.dev/docs/memory](https://eve.dev/docs/memory) next to Supermemory and Upstash.
|
||||
|
||||
[`item.json`](./item.json) is the shadcn-registry shape Eve uses for `eve add`. Adapt field names to Eve's current catalog schema if it has moved since this was written.
|
||||
|
||||
## Suggested install command
|
||||
|
||||
```bash
|
||||
eve add memory/mem0
|
||||
```
|
||||
|
||||
## Suggested docs snippet
|
||||
|
||||
```ts
|
||||
import { mem0Provider } from "@mem0/eve";
|
||||
import { defineMemory } from "eve/memory";
|
||||
import { byPrincipal } from "eve/memory/scope";
|
||||
|
||||
export default defineMemory({
|
||||
description: "Recall and manage durable context for the current user.",
|
||||
provider: mem0Provider({
|
||||
apiKey: process.env.MEM0_API_KEY!,
|
||||
}),
|
||||
scope: byPrincipal,
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"type": "registry:item",
|
||||
"title": "Mem0",
|
||||
"description": "Hosted semantic memory for Eve agents. Recalls relevant context before each turn, captures completed turns, and exposes search, remember, and forget tools.",
|
||||
"dependencies": ["@mem0/eve"],
|
||||
"envVars": {
|
||||
"MEM0_API_KEY": ""
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/mem0.ts",
|
||||
"type": "registry:file",
|
||||
"target": "agent/memory/mem0.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { mem0Provider } from "@mem0/eve";
|
||||
import { defineMemory } from "eve/memory";
|
||||
import { byPrincipal } from "eve/memory/scope";
|
||||
|
||||
export default defineMemory({
|
||||
description: "Recall and manage durable context for the current user.",
|
||||
provider: mem0Provider({
|
||||
apiKey: process.env.MEM0_API_KEY!,
|
||||
}),
|
||||
scope: byPrincipal,
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { completedTurnMessages, type ConversationMessage } from "./messages.js";
|
||||
import type { MemoryStore, StoreMetadata } from "./store.js";
|
||||
|
||||
export async function captureCompletedTurn(input: {
|
||||
store: MemoryStore;
|
||||
scopeKey: string;
|
||||
operationId: string;
|
||||
infer: boolean;
|
||||
metadata: StoreMetadata;
|
||||
messages: readonly ConversationMessage[];
|
||||
turnInput: readonly ConversationMessage[];
|
||||
}): Promise<boolean> {
|
||||
// Best-effort deduplication, not a guarantee. This lookup + add is not atomic,
|
||||
// and with infer:true the prior write can still be a PENDING event whose
|
||||
// memory is not yet visible here. So a restart during that window, or two
|
||||
// workers that both clear this check before either write, can capture the
|
||||
// same operation twice. The in-process gate in provider.ts narrows the common
|
||||
// case; eliminating it would need a backend-supported atomic idempotency key.
|
||||
const existing = await input.store.listByMetadata({
|
||||
userId: input.scopeKey,
|
||||
metadata: { operation_id: input.operationId },
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const messages = completedTurnMessages({
|
||||
messages: input.messages,
|
||||
turnInput: input.turnInput,
|
||||
});
|
||||
if (messages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await input.store.add(messages, {
|
||||
userId: input.scopeKey,
|
||||
infer: input.infer,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown Mem0 error";
|
||||
}
|
||||
|
||||
export function isSetupError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const text = `${error.name} ${error.message}`.toLowerCase();
|
||||
return (
|
||||
text.includes("api key") ||
|
||||
text.includes("cannot be empty") ||
|
||||
text.includes("authentication") ||
|
||||
text.includes("unauthorized") ||
|
||||
text.includes("401") ||
|
||||
text.includes("403")
|
||||
);
|
||||
}
|
||||
|
||||
export function isNotFoundError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const text = `${error.name} ${error.message}`.toLowerCase();
|
||||
return text.includes("not found") || text.includes("404");
|
||||
}
|
||||
|
||||
export function logProviderError(
|
||||
scope: string,
|
||||
error: unknown,
|
||||
extra: Readonly<Record<string, unknown>>,
|
||||
): void {
|
||||
console.error(`[@mem0/eve] ${scope}`, {
|
||||
error,
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
message: errorMessage(error),
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const DEFAULT_LIMIT = 256;
|
||||
|
||||
// In-process fast path only. Durable replay after restart is handled by
|
||||
// listing memories with metadata.operation_id before add.
|
||||
export function createIdempotencyGate(limit = DEFAULT_LIMIT) {
|
||||
const seen = new Map<string, Promise<void>>();
|
||||
|
||||
return function once(operationId: string, work: () => Promise<void>): Promise<void> {
|
||||
const existing = seen.get(operationId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = work().catch((error: unknown) => {
|
||||
seen.delete(operationId);
|
||||
throw error;
|
||||
});
|
||||
|
||||
seen.set(operationId, pending);
|
||||
if (seen.size > limit) {
|
||||
const oldest = seen.keys().next().value;
|
||||
if (oldest && oldest !== operationId) {
|
||||
seen.delete(oldest);
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { mem0Provider, mem0Provider as default } from "./provider.js";
|
||||
export type {
|
||||
Mem0ApiKey,
|
||||
Mem0InjectedProviderOptions,
|
||||
Mem0ProviderOptions,
|
||||
Mem0RemoteProviderOptions,
|
||||
} from "./options.js";
|
||||
export type { MemoryStore, MemoryMessage, SearchHit } from "./store.js";
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { MemoryMessage } from "./store.js";
|
||||
|
||||
export interface ConversationMessage {
|
||||
readonly role: string;
|
||||
readonly content: unknown;
|
||||
}
|
||||
|
||||
function textFromPart(part: unknown): string {
|
||||
if (typeof part === "string") {
|
||||
return part;
|
||||
}
|
||||
if (part && typeof part === "object") {
|
||||
const record = part as { type?: unknown; text?: unknown };
|
||||
// Typed parts must be explicit text. A `reasoning` part also carries a
|
||||
// `text` field, so accepting any object with `text` would leak the model's
|
||||
// private reasoning into long-term memory. Untyped `{ text }` (no `type`
|
||||
// key) stays supported for callers that pass a bare text object.
|
||||
if ("type" in record && record.type !== "text") {
|
||||
return "";
|
||||
}
|
||||
return typeof record.text === "string" ? record.text : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
return textFromPart(content).trim();
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content.map(textFromPart).join("\n").trim();
|
||||
}
|
||||
|
||||
export function lastUserText(messages: readonly ConversationMessage[]): string {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message?.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const text = extractText(message.content);
|
||||
if (text.length > 0) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function conversationMessages(
|
||||
messages: readonly ConversationMessage[],
|
||||
): MemoryMessage[] {
|
||||
const result: MemoryMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user" && message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const text = extractText(message.content);
|
||||
if (text.length === 0) {
|
||||
continue;
|
||||
}
|
||||
result.push({ role: message.role, content: text });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function completedTurnMessages(input: {
|
||||
readonly messages: readonly ConversationMessage[];
|
||||
readonly turnInput: readonly ConversationMessage[];
|
||||
}): MemoryMessage[] {
|
||||
const users = conversationMessages(input.turnInput).filter(
|
||||
(message) => message.role === "user",
|
||||
);
|
||||
if (users.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Pair this turn's users with the assistant reply produced *in this turn*
|
||||
// only. The current turn begins at the last user message in the settled
|
||||
// history; any assistant text after it belongs to this turn. Walking the
|
||||
// whole history instead would attach an older answer to the new user input
|
||||
// when this turn is tool-only (no assistant text of its own).
|
||||
let lastUserIndex = -1;
|
||||
for (let index = input.messages.length - 1; index >= 0; index -= 1) {
|
||||
if (input.messages[index]?.role === "user") {
|
||||
lastUserIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect every assistant text segment produced in this turn, in order, so a
|
||||
// text -> tool-call -> text answer is captured whole rather than losing the
|
||||
// pre-tool content.
|
||||
const assistantSegments: string[] = [];
|
||||
if (lastUserIndex >= 0) {
|
||||
for (
|
||||
let index = lastUserIndex + 1;
|
||||
index < input.messages.length;
|
||||
index += 1
|
||||
) {
|
||||
const message = input.messages[index];
|
||||
if (message?.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const text = extractText(message.content);
|
||||
if (text.length > 0) {
|
||||
assistantSegments.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assistantText = assistantSegments.join("\n");
|
||||
return assistantText
|
||||
? [...users, { role: "assistant", content: assistantText }]
|
||||
: users;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { MemoryStore } from "./store.js";
|
||||
|
||||
export type Mem0ApiKey = string | (() => string | Promise<string>);
|
||||
|
||||
export interface Mem0SharedProviderOptions {
|
||||
readonly topK?: number;
|
||||
readonly threshold?: number;
|
||||
readonly rerank?: boolean;
|
||||
readonly infer?: boolean;
|
||||
readonly autoSearch?: {
|
||||
readonly enabled?: boolean;
|
||||
};
|
||||
readonly capture?: {
|
||||
readonly enabled?: boolean;
|
||||
};
|
||||
readonly metadata?: Readonly<Record<string, string | number | boolean>>;
|
||||
}
|
||||
|
||||
export interface Mem0RemoteProviderOptions extends Mem0SharedProviderOptions {
|
||||
readonly apiKey: Mem0ApiKey;
|
||||
readonly host?: string;
|
||||
}
|
||||
|
||||
export interface Mem0InjectedProviderOptions extends Mem0SharedProviderOptions {
|
||||
readonly store: MemoryStore;
|
||||
readonly apiKey?: Mem0ApiKey;
|
||||
readonly host?: string;
|
||||
}
|
||||
|
||||
export type Mem0ProviderOptions = Mem0RemoteProviderOptions | Mem0InjectedProviderOptions;
|
||||
|
||||
export interface ResolvedMem0ProviderOptions {
|
||||
readonly apiKey?: Mem0ApiKey;
|
||||
readonly host: string;
|
||||
readonly topK: number;
|
||||
readonly threshold: number;
|
||||
readonly rerank: boolean;
|
||||
readonly infer: boolean;
|
||||
readonly autoSearch: {
|
||||
readonly enabled: boolean;
|
||||
};
|
||||
readonly capture: {
|
||||
readonly enabled: boolean;
|
||||
};
|
||||
readonly metadata: Readonly<Record<string, string | number | boolean>>;
|
||||
readonly store?: MemoryStore;
|
||||
}
|
||||
|
||||
function validateApiKey(apiKey: Mem0ApiKey): void {
|
||||
if (typeof apiKey === "string" && apiKey.trim().length === 0) {
|
||||
throw new Error("Mem0 API key cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHost(host: string | undefined): string {
|
||||
const trimmed = host?.trim() ?? "";
|
||||
if (trimmed.length === 0) {
|
||||
return "https://api.mem0.ai";
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new Error("host must be an http or https URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("host must be an http or https URL");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0ProviderOptions {
|
||||
const store = "store" in options ? options.store : undefined;
|
||||
const apiKey = options.apiKey;
|
||||
if (!store && apiKey === undefined) {
|
||||
throw new Error("apiKey is required unless a store is provided");
|
||||
}
|
||||
if (apiKey !== undefined) {
|
||||
validateApiKey(apiKey);
|
||||
}
|
||||
|
||||
const topK = options.topK ?? 5;
|
||||
if (!Number.isInteger(topK) || topK < 1 || topK > 100) {
|
||||
throw new Error("topK must be an integer between 1 and 100");
|
||||
}
|
||||
|
||||
const threshold = options.threshold ?? 0.1;
|
||||
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
|
||||
throw new Error("threshold must be a number between 0 and 1");
|
||||
}
|
||||
|
||||
return {
|
||||
...(apiKey !== undefined ? { apiKey } : {}),
|
||||
host: resolveHost(options.host),
|
||||
topK,
|
||||
threshold,
|
||||
rerank: options.rerank ?? false,
|
||||
infer: options.infer ?? true,
|
||||
autoSearch: {
|
||||
enabled: options.autoSearch?.enabled ?? true,
|
||||
},
|
||||
capture: {
|
||||
enabled: options.capture?.enabled ?? true,
|
||||
},
|
||||
metadata: options.metadata ?? {},
|
||||
...(store ? { store } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { defineMemoryProvider, type MemoryProvider } from "eve/memory";
|
||||
import { captureCompletedTurn } from "./capture.js";
|
||||
import { isSetupError, logProviderError } from "./errors.js";
|
||||
import { createIdempotencyGate } from "./idempotency.js";
|
||||
import { lastUserText, type ConversationMessage } from "./messages.js";
|
||||
import { resolveOptions, type Mem0ProviderOptions } from "./options.js";
|
||||
import { recallMemories } from "./recall.js";
|
||||
import { createLazyStore, createMem0Store, type MemoryStore } from "./store.js";
|
||||
import { createMem0Tools } from "./tools.js";
|
||||
|
||||
function asConversation(messages: readonly unknown[]): ConversationMessage[] {
|
||||
return messages.filter((message): message is ConversationMessage => {
|
||||
if (message === null || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
"role" in message &&
|
||||
typeof (message as { role: unknown }).role === "string" &&
|
||||
"content" in message
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider {
|
||||
const config = resolveOptions(options);
|
||||
const once = createIdempotencyGate();
|
||||
const loadRemoteStore = createLazyStore(async () => {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("Mem0 API key cannot be empty");
|
||||
}
|
||||
return createMem0Store({
|
||||
apiKey: config.apiKey,
|
||||
host: config.host,
|
||||
});
|
||||
});
|
||||
|
||||
const getStore = async (): Promise<MemoryStore> => {
|
||||
if (config.store) {
|
||||
return config.store;
|
||||
}
|
||||
return loadRemoteStore();
|
||||
};
|
||||
|
||||
const recall = async (context: {
|
||||
abortSignal: AbortSignal;
|
||||
messages: readonly unknown[];
|
||||
session: { id: string };
|
||||
memory: { scope: { key: string } };
|
||||
turn?: { input: readonly unknown[] } | null;
|
||||
}) => {
|
||||
if (!config.autoSearch.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const querySource = context.turn?.input ?? context.messages;
|
||||
const conversation = asConversation(querySource);
|
||||
if (querySource.length > 0 && conversation.length === 0) {
|
||||
console.error("[@mem0/eve] dropped all recall messages; no searchable user text", {
|
||||
sessionId: context.session.id,
|
||||
});
|
||||
}
|
||||
const query = lastUserText(conversation);
|
||||
|
||||
try {
|
||||
const store = await getStore();
|
||||
return await recallMemories({
|
||||
store,
|
||||
scopeKey: context.memory.scope.key,
|
||||
query,
|
||||
topK: config.topK,
|
||||
threshold: config.threshold,
|
||||
rerank: config.rerank,
|
||||
});
|
||||
} catch (error) {
|
||||
if (context.abortSignal.aborted || isSetupError(error)) {
|
||||
throw error;
|
||||
}
|
||||
logProviderError("recall failed", error, {
|
||||
sessionId: context.session.id,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return defineMemoryProvider({
|
||||
recall: {
|
||||
"turn.started": recall,
|
||||
"compaction.completed": recall,
|
||||
},
|
||||
...(config.capture.enabled
|
||||
? {
|
||||
capture: {
|
||||
async "turn.completed"(context) {
|
||||
const store = await getStore();
|
||||
await once(context.operationId, async () => {
|
||||
await captureCompletedTurn({
|
||||
store,
|
||||
scopeKey: context.memory.scope.key,
|
||||
operationId: context.operationId,
|
||||
infer: config.infer,
|
||||
metadata: {
|
||||
...config.metadata,
|
||||
source: "eve",
|
||||
operation_id: context.operationId,
|
||||
session_id: context.session.id,
|
||||
slot: context.memory.slot,
|
||||
},
|
||||
messages: asConversation(context.messages),
|
||||
turnInput: asConversation(context.turn.input),
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
async tools(context) {
|
||||
const store = await getStore();
|
||||
return createMem0Tools(store, context.memory.scope.key, {
|
||||
topK: config.topK,
|
||||
threshold: config.threshold,
|
||||
rerank: config.rerank,
|
||||
infer: config.infer,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { SearchHit, MemoryStore } from "./store.js";
|
||||
|
||||
export interface RecallMessage {
|
||||
readonly id: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export function formatRecallMessages(hits: readonly SearchHit[]): RecallMessage[] {
|
||||
return hits.flatMap((hit) => {
|
||||
const id = hit.id.trim();
|
||||
const content = hit.memory.trim();
|
||||
return id.length > 0 && content.length > 0 ? [{ id, content }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function recallMemories(input: {
|
||||
store: MemoryStore;
|
||||
scopeKey: string;
|
||||
query: string;
|
||||
topK: number;
|
||||
threshold: number;
|
||||
rerank: boolean;
|
||||
}): Promise<{ messages: RecallMessage[] } | null> {
|
||||
const query = input.query.trim();
|
||||
if (query.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { results } = await input.store.search(query, {
|
||||
userId: input.scopeKey,
|
||||
topK: input.topK,
|
||||
threshold: input.threshold,
|
||||
rerank: input.rerank,
|
||||
});
|
||||
const messages = formatRecallMessages(results);
|
||||
return messages.length > 0 ? { messages } : null;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Mem0ApiKey } from "./options.js";
|
||||
import { isNotFoundError } from "./errors.js";
|
||||
|
||||
export type MemoryRole = "user" | "assistant";
|
||||
|
||||
export interface MemoryMessage {
|
||||
readonly role: MemoryRole;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
readonly id: string;
|
||||
readonly memory: string;
|
||||
}
|
||||
|
||||
export interface MemoryRecord {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly metadata: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type StoreMetadata = Readonly<Record<string, string | number | boolean>>;
|
||||
|
||||
export interface AddMemoryInput {
|
||||
readonly userId: string;
|
||||
readonly infer: boolean;
|
||||
readonly metadata: StoreMetadata;
|
||||
}
|
||||
|
||||
// A hosted-platform write with `infer: true` is asynchronous: Mem0 accepts the
|
||||
// request and returns an `event_id` with a PENDING status before extraction has
|
||||
// produced (or rejected) any memory. `queued` reflects that accepted-not-yet-
|
||||
// stored state; `completed` means the write resolved synchronously.
|
||||
export interface AddResult {
|
||||
readonly status: "queued" | "completed";
|
||||
readonly eventId?: string;
|
||||
}
|
||||
|
||||
export interface SearchMemoryInput {
|
||||
readonly userId: string;
|
||||
readonly topK: number;
|
||||
readonly threshold: number;
|
||||
readonly rerank: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryStore {
|
||||
add(
|
||||
messages: readonly MemoryMessage[],
|
||||
input: AddMemoryInput,
|
||||
): Promise<AddResult>;
|
||||
search(query: string, input: SearchMemoryInput): Promise<{ results: readonly SearchHit[] }>;
|
||||
get(memoryId: string): Promise<MemoryRecord | null>;
|
||||
listByMetadata(input: {
|
||||
readonly userId: string;
|
||||
readonly metadata: Readonly<Record<string, string>>;
|
||||
}): Promise<readonly MemoryRecord[]>;
|
||||
delete(memoryId: string, userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function parseSearchHit(item: unknown): SearchHit | null {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const nested =
|
||||
record.data && typeof record.data === "object"
|
||||
? (record.data as Record<string, unknown>)
|
||||
: undefined;
|
||||
const idValue = record.id ?? record.memory_id ?? nested?.id;
|
||||
const id =
|
||||
typeof idValue === "string"
|
||||
? idValue.trim()
|
||||
: typeof idValue === "number"
|
||||
? String(idValue)
|
||||
: "";
|
||||
const memoryValue =
|
||||
record.memory ?? record.text ?? nested?.memory ?? nested?.text;
|
||||
const memory = typeof memoryValue === "string" ? memoryValue : "";
|
||||
if (id.length === 0 || memory.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { id, memory };
|
||||
}
|
||||
|
||||
// Event statuses that mean "accepted, extraction not finished". Only these map
|
||||
// to `queued`; a terminal status (SUCCEEDED/FAILED) or an unknown/absent status
|
||||
// falls through to `completed`, so a FAILED write is never reported as still
|
||||
// pending. (The hosted add path returns PENDING synchronously; FAILED only
|
||||
// appears later via event polling.)
|
||||
const PENDING_STATUSES = new Set(["PENDING", "RUNNING", "PROCESSING", "QUEUED"]);
|
||||
|
||||
export function parseAddResult(response: unknown): AddResult {
|
||||
if (response && typeof response === "object" && !Array.isArray(response)) {
|
||||
const record = response as Record<string, unknown>;
|
||||
const eventIdValue = record.event_id ?? record.eventId;
|
||||
const eventId =
|
||||
typeof eventIdValue === "string" && eventIdValue.length > 0
|
||||
? eventIdValue
|
||||
: undefined;
|
||||
const status =
|
||||
typeof record.status === "string" ? record.status.toUpperCase() : "";
|
||||
if (eventId && PENDING_STATUSES.has(status)) {
|
||||
return { status: "queued", eventId };
|
||||
}
|
||||
if (eventId) {
|
||||
return { status: "completed", eventId };
|
||||
}
|
||||
}
|
||||
// A memory-results array (or any non-event payload) means the write already
|
||||
// resolved; there is nothing left pending to report.
|
||||
return { status: "completed" };
|
||||
}
|
||||
|
||||
export function parseMemoryRecord(item: unknown): MemoryRecord | null {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const idValue = record.id ?? record.memory_id;
|
||||
const id = typeof idValue === "string" ? idValue.trim() : "";
|
||||
const userIdValue = record.userId ?? record.user_id;
|
||||
const userId = typeof userIdValue === "string" ? userIdValue : "";
|
||||
if (id.length === 0 || userId.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const metadata =
|
||||
record.metadata && typeof record.metadata === "object"
|
||||
? (record.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
return { id, userId, metadata };
|
||||
}
|
||||
|
||||
function asItemList(response: unknown, operation: string): unknown[] {
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
if (response && typeof response === "object") {
|
||||
const record = response as Record<string, unknown>;
|
||||
const raw = record.results ?? record.memories;
|
||||
if (Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
throw new Error(`Mem0 ${operation} returned an unexpected response shape`);
|
||||
}
|
||||
|
||||
export async function resolveApiKey(apiKey: Mem0ApiKey): Promise<string> {
|
||||
const value = typeof apiKey === "function" ? await apiKey() : apiKey;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error("Mem0 API key cannot be empty");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function createLazyStore(factory: () => Promise<MemoryStore>) {
|
||||
let pending: Promise<MemoryStore> | undefined;
|
||||
|
||||
return async (): Promise<MemoryStore> => {
|
||||
if (!pending) {
|
||||
pending = factory().catch((error: unknown) => {
|
||||
pending = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMem0Store(input: {
|
||||
apiKey: Mem0ApiKey;
|
||||
host: string;
|
||||
}): Promise<MemoryStore> {
|
||||
const MemoryClient = (await import("mem0ai")).default;
|
||||
const client = new MemoryClient({
|
||||
apiKey: await resolveApiKey(input.apiKey),
|
||||
host: input.host,
|
||||
});
|
||||
|
||||
const store: MemoryStore = {
|
||||
async add(messages, options) {
|
||||
const response = await client.add([...messages], {
|
||||
userId: options.userId,
|
||||
infer: options.infer,
|
||||
metadata: { ...options.metadata },
|
||||
});
|
||||
return parseAddResult(response);
|
||||
},
|
||||
async search(query, options) {
|
||||
const response = await client.search(query, {
|
||||
filters: { user_id: options.userId },
|
||||
topK: options.topK,
|
||||
threshold: options.threshold,
|
||||
rerank: options.rerank,
|
||||
});
|
||||
return {
|
||||
results: asItemList(response, "search").flatMap((item) => {
|
||||
const hit = parseSearchHit(item);
|
||||
return hit ? [hit] : [];
|
||||
}),
|
||||
};
|
||||
},
|
||||
async get(memoryId) {
|
||||
try {
|
||||
return parseMemoryRecord(await client.get(memoryId));
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async listByMetadata({ userId, metadata }) {
|
||||
const response = await client.getAll({
|
||||
filters: {
|
||||
AND: [{ user_id: userId }, { metadata }],
|
||||
},
|
||||
pageSize: 5,
|
||||
});
|
||||
return asItemList(response, "getAll").flatMap((item) => {
|
||||
const record = parseMemoryRecord(item);
|
||||
return record && record.userId === userId ? [record] : [];
|
||||
});
|
||||
},
|
||||
async delete(memoryId, userId) {
|
||||
const record = await store.get(memoryId);
|
||||
if (!record || record.userId !== userId) {
|
||||
throw new Error("Memory not found");
|
||||
}
|
||||
await client.delete(memoryId);
|
||||
},
|
||||
};
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { defineTool } from "eve/tools";
|
||||
import { z } from "zod";
|
||||
import type { MemoryStore } from "./store.js";
|
||||
|
||||
export interface Mem0ToolOptions {
|
||||
readonly topK: number;
|
||||
readonly threshold: number;
|
||||
readonly rerank: boolean;
|
||||
readonly infer: boolean;
|
||||
}
|
||||
|
||||
export function createMem0Tools(
|
||||
store: MemoryStore,
|
||||
scopeKey: string,
|
||||
options: Mem0ToolOptions,
|
||||
) {
|
||||
return {
|
||||
search: defineTool({
|
||||
description: "Search long-term memories for the current caller.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().min(1).max(2000),
|
||||
}),
|
||||
async execute({ query }) {
|
||||
const { results } = await store.search(query, {
|
||||
userId: scopeKey,
|
||||
topK: options.topK,
|
||||
threshold: options.threshold,
|
||||
rerank: options.rerank,
|
||||
});
|
||||
return {
|
||||
memories: results.map((hit) => ({
|
||||
id: hit.id,
|
||||
memory: hit.memory,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
remember: defineTool({
|
||||
description:
|
||||
"Save one durable fact or preference about the current caller. On the " +
|
||||
"hosted platform the write is queued for extraction and may take a " +
|
||||
"moment to become searchable; a `queued` status means accepted, not yet " +
|
||||
"stored. Do not claim the fact is saved when the status is queued.",
|
||||
inputSchema: z.object({
|
||||
text: z.string().min(1).max(4000),
|
||||
}),
|
||||
async execute({ text }) {
|
||||
const result = await store.add([{ role: "user", content: text }], {
|
||||
userId: scopeKey,
|
||||
infer: options.infer,
|
||||
metadata: { source: "eve-tool" },
|
||||
});
|
||||
// Report the true write state. With infer:true the platform returns a
|
||||
// PENDING event, so promising "saved" would let the model tell the user
|
||||
// a fact is stored when only the request was queued. Surface the eventId
|
||||
// so the write can be correlated/confirmed rather than left a dead end.
|
||||
return result.status === "queued"
|
||||
? {
|
||||
status: "queued" as const,
|
||||
...(result.eventId ? { eventId: result.eventId } : {}),
|
||||
}
|
||||
: { status: "saved" as const };
|
||||
},
|
||||
}),
|
||||
forget: defineTool({
|
||||
description: "Delete one memory belonging to the current caller by id.",
|
||||
inputSchema: z.object({
|
||||
id: z.string().min(1),
|
||||
}),
|
||||
async execute({ id }) {
|
||||
await store.delete(id, scopeKey);
|
||||
return { deleted: true };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureCompletedTurn } from "../src/capture.js";
|
||||
import { createFakeStore } from "./helpers.js";
|
||||
|
||||
describe("captureCompletedTurn", () => {
|
||||
it("adds the current turn under the locked scope", async () => {
|
||||
const store = createFakeStore();
|
||||
const captured = await captureCompletedTurn({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_1",
|
||||
infer: false,
|
||||
metadata: { source: "eve", operation_id: "op_1" },
|
||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
],
|
||||
});
|
||||
|
||||
expect(captured).toBe(true);
|
||||
expect(store.added).toEqual([
|
||||
{
|
||||
userId: "scope_abc",
|
||||
infer: false,
|
||||
metadata: { source: "eve", operation_id: "op_1" },
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not write when the turn has no user text", async () => {
|
||||
const store = createFakeStore();
|
||||
const captured = await captureCompletedTurn({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_1",
|
||||
infer: true,
|
||||
metadata: { operation_id: "op_1" },
|
||||
turnInput: [],
|
||||
messages: [],
|
||||
});
|
||||
expect(captured).toBe(false);
|
||||
expect(store.added).toEqual([]);
|
||||
});
|
||||
|
||||
it("dedupes a replay once the prior write is visible", async () => {
|
||||
const store = createFakeStore();
|
||||
const args = {
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_dedup",
|
||||
infer: true,
|
||||
metadata: { source: "eve", operation_id: "op_dedup" },
|
||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
],
|
||||
};
|
||||
// First write is immediately visible (completed), so the replay is deduped.
|
||||
expect(await captureCompletedTurn(args)).toBe(true);
|
||||
expect(await captureCompletedTurn(args)).toBe(false);
|
||||
expect(store.added).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cannot dedupe while the prior write is still pending (best-effort)", async () => {
|
||||
// Documents the known limitation: with infer:true the first write is a
|
||||
// PENDING event whose memory is not yet visible, so the metadata lookup
|
||||
// finds nothing and a restart replay writes the same operation again.
|
||||
const store = createFakeStore([], { pendingWrites: true });
|
||||
const args = {
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_pending",
|
||||
infer: true,
|
||||
metadata: { source: "eve", operation_id: "op_pending" },
|
||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
],
|
||||
};
|
||||
expect(await captureCompletedTurn(args)).toBe(true);
|
||||
expect(await captureCompletedTurn(args)).toBe(true);
|
||||
expect(store.added).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not dedupe concurrent replays that both pass the lookup (best-effort)", async () => {
|
||||
// Two workers on separate provider instances both clear the metadata check
|
||||
// before either write lands, so both capture the same operation.
|
||||
const store = createFakeStore([], { pendingWrites: true });
|
||||
const args = {
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_concurrent",
|
||||
infer: true,
|
||||
metadata: { source: "eve", operation_id: "op_concurrent" },
|
||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
],
|
||||
};
|
||||
const [first, second] = await Promise.all([
|
||||
captureCompletedTurn(args),
|
||||
captureCompletedTurn(args),
|
||||
]);
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(true);
|
||||
expect(store.added).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips add when the operation id was already captured", async () => {
|
||||
const store = createFakeStore([
|
||||
{
|
||||
id: "mem_existing",
|
||||
memory: "already stored",
|
||||
metadata: { operation_id: "op_replay" },
|
||||
},
|
||||
]);
|
||||
const captured = await captureCompletedTurn({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
operationId: "op_replay",
|
||||
infer: true,
|
||||
metadata: { operation_id: "op_replay" },
|
||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||
messages: [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "Got it." },
|
||||
],
|
||||
});
|
||||
|
||||
expect(captured).toBe(false);
|
||||
expect(store.added).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
errorMessage,
|
||||
isNotFoundError,
|
||||
isSetupError,
|
||||
logProviderError,
|
||||
} from "../src/errors.js";
|
||||
|
||||
describe("errors", () => {
|
||||
it("classifies setup and not-found errors", () => {
|
||||
expect(isSetupError(new Error("Mem0 API key cannot be empty"))).toBe(true);
|
||||
expect(isSetupError(Object.assign(new Error("denied"), { name: "AuthenticationError" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSetupError(new Error("mem0 down"))).toBe(false);
|
||||
expect(isNotFoundError(new Error("Memory not found"))).toBe(true);
|
||||
expect(isNotFoundError(new Error("HTTP 404"))).toBe(true);
|
||||
expect(errorMessage("x")).toBe("Unknown Mem0 error");
|
||||
});
|
||||
|
||||
it("logs the original error object", () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const failure = Object.assign(new Error("HTTP 401"), {
|
||||
name: "AuthenticationError",
|
||||
});
|
||||
try {
|
||||
logProviderError("recall failed", failure, { sessionId: "sess_1" });
|
||||
expect(error).toHaveBeenCalledWith("[@mem0/eve] recall failed", {
|
||||
error: failure,
|
||||
name: "AuthenticationError",
|
||||
message: "HTTP 401",
|
||||
sessionId: "sess_1",
|
||||
});
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
AddMemoryInput,
|
||||
MemoryMessage,
|
||||
MemoryRecord,
|
||||
MemoryStore,
|
||||
SearchHit,
|
||||
SearchMemoryInput,
|
||||
} from "../src/store.js";
|
||||
|
||||
export type FakeHit = SearchHit & {
|
||||
readonly userId?: string;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type FakeAdd = {
|
||||
messages: readonly MemoryMessage[];
|
||||
userId: string;
|
||||
infer: boolean;
|
||||
metadata: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type FakeSearch = SearchMemoryInput & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function createFakeStore(
|
||||
hits: FakeHit[] = [],
|
||||
options: { pendingWrites?: boolean } = {},
|
||||
): MemoryStore & {
|
||||
added: FakeAdd[];
|
||||
searched: FakeSearch[];
|
||||
deleted: string[];
|
||||
records: MemoryRecord[];
|
||||
} {
|
||||
const added: FakeAdd[] = [];
|
||||
const searched: FakeSearch[] = [];
|
||||
const deleted: string[] = [];
|
||||
const records: MemoryRecord[] = hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
userId: hit.userId ?? "scope_abc",
|
||||
metadata: hit.metadata ?? {},
|
||||
}));
|
||||
|
||||
return {
|
||||
added,
|
||||
searched,
|
||||
deleted,
|
||||
records,
|
||||
async add(messages, input) {
|
||||
added.push({
|
||||
messages,
|
||||
userId: input.userId,
|
||||
infer: input.infer,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
// Model Mem0's async extraction: a pending write is accepted but its
|
||||
// memory is not yet visible to listByMetadata/get, so it cannot dedupe a
|
||||
// replay. A resolved write becomes immediately visible.
|
||||
if (options.pendingWrites) {
|
||||
return { status: "queued" as const, eventId: `evt_${added.length}` };
|
||||
}
|
||||
records.push({
|
||||
id: `mem_added_${added.length}`,
|
||||
userId: input.userId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return { status: "completed" as const };
|
||||
},
|
||||
async search(query, input) {
|
||||
searched.push({ query, ...input });
|
||||
return {
|
||||
results: hits.filter((hit) => (hit.userId ?? "scope_abc") === input.userId),
|
||||
};
|
||||
},
|
||||
async get(memoryId) {
|
||||
return records.find((record) => record.id === memoryId) ?? null;
|
||||
},
|
||||
async listByMetadata({ userId, metadata }) {
|
||||
return records.filter((record) => {
|
||||
if (record.userId !== userId) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(metadata).every(
|
||||
([key, value]) => record.metadata[key] === value,
|
||||
);
|
||||
});
|
||||
},
|
||||
async delete(memoryId, userId) {
|
||||
const record = records.find((item) => item.id === memoryId);
|
||||
if (!record || record.userId !== userId) {
|
||||
throw new Error("Memory not found");
|
||||
}
|
||||
deleted.push(memoryId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function memoryContext(input?: {
|
||||
scopeKey?: string;
|
||||
operationId?: string;
|
||||
aborted?: boolean;
|
||||
turn?:
|
||||
| {
|
||||
id?: string;
|
||||
sequence?: number;
|
||||
input: Array<{ role: string; content: unknown }>;
|
||||
}
|
||||
| null;
|
||||
turnInput?: Array<{ role: string; content: unknown }>;
|
||||
messages?: Array<{ role: string; content: unknown }>;
|
||||
}): {
|
||||
abortSignal: AbortSignal;
|
||||
operationId: string;
|
||||
session: { id: string };
|
||||
memory: {
|
||||
scope: { key: string; namespace: string; value: string };
|
||||
slot: string;
|
||||
};
|
||||
messages: Array<{ role: string; content: unknown }>;
|
||||
turn: {
|
||||
id: string;
|
||||
sequence: number;
|
||||
input: Array<{ role: string; content: unknown }>;
|
||||
} | null;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
if (input?.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
const turnInput =
|
||||
input?.turn === null
|
||||
? undefined
|
||||
: (input?.turn?.input ??
|
||||
input?.turnInput ?? [{ role: "user", content: "I am vegetarian" }]);
|
||||
|
||||
return {
|
||||
abortSignal: controller.signal,
|
||||
operationId: input?.operationId ?? "op_1",
|
||||
session: { id: "sess_1" },
|
||||
memory: {
|
||||
scope: {
|
||||
key: input?.scopeKey ?? "scope_abc",
|
||||
namespace: "default",
|
||||
value: "user_1",
|
||||
},
|
||||
slot: "mem0",
|
||||
},
|
||||
messages: input?.messages ?? [
|
||||
{ role: "user", content: "I am vegetarian" },
|
||||
{ role: "assistant", content: "I will remember that." },
|
||||
],
|
||||
turn:
|
||||
input?.turn === null
|
||||
? null
|
||||
: {
|
||||
id: input?.turn?.id ?? "turn_1",
|
||||
sequence: input?.turn?.sequence ?? 1,
|
||||
input: turnInput ?? [{ role: "user", content: "I am vegetarian" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createIdempotencyGate } from "../src/idempotency.js";
|
||||
|
||||
describe("createIdempotencyGate", () => {
|
||||
it("runs work once per operation id", async () => {
|
||||
const once = createIdempotencyGate();
|
||||
let calls = 0;
|
||||
const work = async () => {
|
||||
calls += 1;
|
||||
};
|
||||
|
||||
await Promise.all([once("op_1", work), once("op_1", work)]);
|
||||
await once("op_1", work);
|
||||
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("retries after a failure", async () => {
|
||||
const once = createIdempotencyGate();
|
||||
let calls = 0;
|
||||
const work = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("nope");
|
||||
}
|
||||
};
|
||||
|
||||
await expect(once("op_1", work)).rejects.toThrow("nope");
|
||||
await once("op_1", work);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("evicts the oldest id after the cap", async () => {
|
||||
const once = createIdempotencyGate(2);
|
||||
let calls = 0;
|
||||
const work = async () => {
|
||||
calls += 1;
|
||||
};
|
||||
|
||||
await once("op_1", work);
|
||||
await once("op_2", work);
|
||||
await once("op_3", work);
|
||||
await once("op_1", work);
|
||||
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
completedTurnMessages,
|
||||
conversationMessages,
|
||||
extractText,
|
||||
lastUserText,
|
||||
} from "../src/messages.js";
|
||||
|
||||
describe("extractText", () => {
|
||||
it("reads a string", () => {
|
||||
expect(extractText(" hello ")).toBe("hello");
|
||||
});
|
||||
|
||||
it("joins text parts", () => {
|
||||
expect(
|
||||
extractText([
|
||||
{ type: "text", text: "I like" },
|
||||
{ type: "text", text: "tea" },
|
||||
]),
|
||||
).toBe("I like\ntea");
|
||||
});
|
||||
|
||||
it("reads a single text object", () => {
|
||||
expect(extractText({ type: "text", text: " solo " })).toBe("solo");
|
||||
});
|
||||
|
||||
it("ignores non-text parts", () => {
|
||||
expect(extractText([{ type: "image", url: "x" }])).toBe("");
|
||||
});
|
||||
|
||||
it("ignores reasoning parts and keeps only the final text", () => {
|
||||
expect(
|
||||
extractText([
|
||||
{ type: "reasoning", text: "speculation" },
|
||||
{ type: "text", text: "Noted." },
|
||||
]),
|
||||
).toBe("Noted.");
|
||||
});
|
||||
|
||||
it("still reads an untyped text object", () => {
|
||||
expect(extractText({ text: " bare " })).toBe("bare");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastUserText", () => {
|
||||
it("returns the last user message", () => {
|
||||
expect(
|
||||
lastUserText([
|
||||
{ role: "user", content: "first" },
|
||||
{ role: "assistant", content: "ok" },
|
||||
{ role: "user", content: "second" },
|
||||
]),
|
||||
).toBe("second");
|
||||
});
|
||||
|
||||
it("returns empty when there is no user text", () => {
|
||||
expect(lastUserText([{ role: "assistant", content: "hi" }])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversationMessages", () => {
|
||||
it("keeps user and assistant text only", () => {
|
||||
expect(
|
||||
conversationMessages([
|
||||
{ role: "system", content: "ignore" },
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "hi" },
|
||||
{ role: "user", content: "" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "hi" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completedTurnMessages", () => {
|
||||
it("pairs the current user turn with the latest assistant reply", () => {
|
||||
expect(
|
||||
completedTurnMessages({
|
||||
turnInput: [{ role: "user", content: "I like tea" }],
|
||||
messages: [
|
||||
{ role: "user", content: "older" },
|
||||
{ role: "assistant", content: "older reply" },
|
||||
{ role: "user", content: "I like tea" },
|
||||
{ role: "assistant", content: "Noted." },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: "user", content: "I like tea" },
|
||||
{ role: "assistant", content: "Noted." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips capture when the turn has no user text", () => {
|
||||
expect(
|
||||
completedTurnMessages({
|
||||
turnInput: [{ role: "assistant", content: "hello" }],
|
||||
messages: [{ role: "assistant", content: "hello" }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not attach a previous turn's answer to a tool-only turn", () => {
|
||||
// Older Q&A, then a new user message whose only assistant output is a
|
||||
// tool call (no text). The stale "older reply" must not be captured.
|
||||
expect(
|
||||
completedTurnMessages({
|
||||
turnInput: [{ role: "user", content: "new question" }],
|
||||
messages: [
|
||||
{ role: "user", content: "older question" },
|
||||
{ role: "assistant", content: "older reply" },
|
||||
{ role: "user", content: "new question" },
|
||||
{ role: "assistant", content: [{ type: "tool-call", id: "t1" }] },
|
||||
],
|
||||
}),
|
||||
).toEqual([{ role: "user", content: "new question" }]);
|
||||
});
|
||||
|
||||
it("captures all assistant text in a text/tool-call/text turn", () => {
|
||||
expect(
|
||||
completedTurnMessages({
|
||||
turnInput: [{ role: "user", content: "Q" }],
|
||||
messages: [
|
||||
{ role: "user", content: "Q" },
|
||||
{ role: "assistant", content: "part A" },
|
||||
{ role: "assistant", content: [{ type: "tool-call", id: "t1" }] },
|
||||
{ role: "assistant", content: "part B" },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: "user", content: "Q" },
|
||||
{ role: "assistant", content: "part A\npart B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("captures this turn's assistant reply, not an earlier one", () => {
|
||||
expect(
|
||||
completedTurnMessages({
|
||||
turnInput: [{ role: "user", content: "new question" }],
|
||||
messages: [
|
||||
{ role: "user", content: "older question" },
|
||||
{ role: "assistant", content: "older reply" },
|
||||
{ role: "user", content: "new question" },
|
||||
{ role: "assistant", content: "new reply" },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: "user", content: "new question" },
|
||||
{ role: "assistant", content: "new reply" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOptions } from "../src/options.js";
|
||||
import { createFakeStore } from "./helpers.js";
|
||||
|
||||
describe("resolveOptions", () => {
|
||||
it("applies defaults", () => {
|
||||
expect(resolveOptions({ apiKey: "m0-test" })).toEqual({
|
||||
apiKey: "m0-test",
|
||||
host: "https://api.mem0.ai",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
infer: true,
|
||||
autoSearch: { enabled: true },
|
||||
capture: { enabled: true },
|
||||
metadata: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an empty api key", () => {
|
||||
expect(() => resolveOptions({ apiKey: " " })).toThrow(/empty/);
|
||||
});
|
||||
|
||||
it("requires an api key when no store is provided", () => {
|
||||
expect(() => resolveOptions({} as never)).toThrow(/apiKey is required/);
|
||||
});
|
||||
|
||||
it("allows a store without an api key", () => {
|
||||
const store = createFakeStore();
|
||||
expect(resolveOptions({ store }).store).toBe(store);
|
||||
});
|
||||
|
||||
it("does not invoke a function api key at resolve time", () => {
|
||||
let calls = 0;
|
||||
const apiKey = () => {
|
||||
calls += 1;
|
||||
return "";
|
||||
};
|
||||
expect(() => resolveOptions({ apiKey })).not.toThrow();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an invalid topK", () => {
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", topK: 0 })).toThrow(/topK/);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", topK: 101 })).toThrow(/topK/);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", topK: 1.5 })).toThrow(/topK/);
|
||||
});
|
||||
|
||||
it("rejects an invalid threshold", () => {
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", threshold: -0.1 })).toThrow(
|
||||
/threshold/,
|
||||
);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", threshold: 1.1 })).toThrow(
|
||||
/threshold/,
|
||||
);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", threshold: Number.NaN })).toThrow(
|
||||
/threshold/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-http host", () => {
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", host: "localhost" })).toThrow(
|
||||
/host/,
|
||||
);
|
||||
expect(() => resolveOptions({ apiKey: "m0-test", host: "ftp://mem0.ai" })).toThrow(
|
||||
/host/,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a blank host as the default", () => {
|
||||
expect(resolveOptions({ apiKey: "m0-test", host: " " }).host).toBe(
|
||||
"https://api.mem0.ai",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an injected store", () => {
|
||||
const store = createFakeStore();
|
||||
expect(resolveOptions({ apiKey: "m0-test", store }).store).toBe(store);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
MemoryCompactionCompletedContext,
|
||||
MemoryToolsContext,
|
||||
MemoryTurnCompletedContext,
|
||||
MemoryTurnStartedContext,
|
||||
} from "eve/memory";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mem0Provider } from "../src/provider.js";
|
||||
import { createFakeStore, memoryContext } from "./helpers.js";
|
||||
|
||||
function startedContext(
|
||||
input?: Parameters<typeof memoryContext>[0],
|
||||
): MemoryTurnStartedContext {
|
||||
return memoryContext(input) as unknown as MemoryTurnStartedContext;
|
||||
}
|
||||
|
||||
function completedContext(
|
||||
input?: Parameters<typeof memoryContext>[0],
|
||||
): MemoryTurnCompletedContext {
|
||||
return memoryContext(input) as unknown as MemoryTurnCompletedContext;
|
||||
}
|
||||
|
||||
function compactionContext(
|
||||
input?: Parameters<typeof memoryContext>[0],
|
||||
): MemoryCompactionCompletedContext {
|
||||
return memoryContext({
|
||||
...input,
|
||||
turn: input?.turn === undefined ? null : input.turn,
|
||||
}) as unknown as MemoryCompactionCompletedContext;
|
||||
}
|
||||
|
||||
function toolsContext(
|
||||
input?: Parameters<typeof memoryContext>[0],
|
||||
): MemoryToolsContext {
|
||||
return memoryContext(input) as unknown as MemoryToolsContext;
|
||||
}
|
||||
|
||||
async function runTool(
|
||||
tool: { execute: (input: never, context: never) => unknown },
|
||||
input: object,
|
||||
): Promise<unknown> {
|
||||
return tool.execute(input as never, {} as never);
|
||||
}
|
||||
|
||||
function requireCapture(
|
||||
provider: ReturnType<typeof mem0Provider>,
|
||||
): NonNullable<NonNullable<typeof provider.capture>["turn.completed"]> {
|
||||
const capture = provider.capture?.["turn.completed"];
|
||||
if (!capture) {
|
||||
throw new Error("expected turn.completed");
|
||||
}
|
||||
return capture;
|
||||
}
|
||||
|
||||
describe("mem0Provider", () => {
|
||||
it("recalls memories for the locked scope on turn start", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({ store });
|
||||
const result = await provider.recall["turn.started"](
|
||||
startedContext({
|
||||
turnInput: [{ role: "user", content: "What do I drink?" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.searched).toEqual([
|
||||
{
|
||||
query: "What do I drink?",
|
||||
userId: "scope_abc",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards recall search options", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({
|
||||
store,
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
await provider.recall["turn.started"](
|
||||
startedContext({
|
||||
turnInput: [{ role: "user", content: "tea" }],
|
||||
}),
|
||||
);
|
||||
expect(store.searched[0]).toMatchObject({
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips search when autoSearch is disabled", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({ store, autoSearch: { enabled: false } });
|
||||
await expect(
|
||||
provider.recall["turn.started"](startedContext()),
|
||||
).resolves.toBeNull();
|
||||
expect(store.searched).toEqual([]);
|
||||
});
|
||||
|
||||
it("recalls after compaction when turn is null", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const provider = mem0Provider({ store });
|
||||
const recallAfterCompaction = provider.recall["compaction.completed"];
|
||||
if (!recallAfterCompaction) {
|
||||
throw new Error("expected compaction.completed");
|
||||
}
|
||||
const result = await recallAfterCompaction(
|
||||
compactionContext({
|
||||
messages: [
|
||||
{ role: "user", content: "What do I drink?" },
|
||||
{ role: "assistant", content: "tea" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.searched).toEqual([
|
||||
{
|
||||
query: "What do I drink?",
|
||||
userId: "scope_abc",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails the turn when search throws", async () => {
|
||||
const store = createFakeStore();
|
||||
store.search = async () => {
|
||||
throw new Error("mem0 down");
|
||||
};
|
||||
const provider = mem0Provider({ store });
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
provider.recall["turn.started"](startedContext()),
|
||||
).rejects.toThrow("mem0 down");
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rethrows when recall is aborted", async () => {
|
||||
const store = createFakeStore();
|
||||
store.search = async () => {
|
||||
throw new Error("cancelled");
|
||||
};
|
||||
const provider = mem0Provider({ store });
|
||||
|
||||
await expect(
|
||||
provider.recall["turn.started"](startedContext({ aborted: true })),
|
||||
).rejects.toThrow("cancelled");
|
||||
});
|
||||
|
||||
it("captures a completed turn once per operation id", async () => {
|
||||
const store = createFakeStore();
|
||||
const provider = mem0Provider({ store });
|
||||
const capture = provider.capture?.["turn.completed"];
|
||||
expect(capture).toBeTypeOf("function");
|
||||
|
||||
const context = completedContext({ operationId: "op_replay" });
|
||||
await capture!(context);
|
||||
await capture!(context);
|
||||
|
||||
expect(store.added).toHaveLength(1);
|
||||
expect(store.added[0]).toMatchObject({
|
||||
userId: "scope_abc",
|
||||
infer: true,
|
||||
metadata: {
|
||||
source: "eve",
|
||||
operation_id: "op_replay",
|
||||
session_id: "sess_1",
|
||||
slot: "mem0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not recapture after process restart when operation metadata exists", async () => {
|
||||
const store = createFakeStore();
|
||||
const first = requireCapture(mem0Provider({ store }));
|
||||
await first(completedContext({ operationId: "op_replay" }));
|
||||
|
||||
const restarted = requireCapture(mem0Provider({ store }));
|
||||
await restarted(completedContext({ operationId: "op_replay" }));
|
||||
|
||||
expect(store.added).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("surfaces capture store failures", async () => {
|
||||
const store = createFakeStore();
|
||||
store.add = async () => {
|
||||
throw new Error("write failed");
|
||||
};
|
||||
|
||||
await expect(
|
||||
requireCapture(mem0Provider({ store }))(completedContext()),
|
||||
).rejects.toThrow("write failed");
|
||||
});
|
||||
|
||||
it("omits capture when it is disabled", () => {
|
||||
const provider = mem0Provider({
|
||||
store: createFakeStore(),
|
||||
capture: { enabled: false },
|
||||
});
|
||||
expect(provider.capture).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binds tools to the locked scope", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "likes tea" }]);
|
||||
const provider = mem0Provider({
|
||||
store,
|
||||
topK: 3,
|
||||
threshold: 0.4,
|
||||
rerank: true,
|
||||
infer: false,
|
||||
});
|
||||
const tools = await provider.tools!(toolsContext());
|
||||
if (!tools) {
|
||||
throw new Error("expected tools");
|
||||
}
|
||||
|
||||
const searchTool = tools.search;
|
||||
const rememberTool = tools.remember;
|
||||
const forgetTool = tools.forget;
|
||||
if (!searchTool || !rememberTool || !forgetTool) {
|
||||
throw new Error("expected search, remember, and forget tools");
|
||||
}
|
||||
|
||||
const search = await runTool(searchTool, { query: "drinks" });
|
||||
const remember = await runTool(rememberTool, {
|
||||
text: "Allergic to peanuts",
|
||||
});
|
||||
const forget = await runTool(forgetTool, { id: "mem_1" });
|
||||
|
||||
expect(search).toEqual({
|
||||
memories: [{ id: "mem_1", memory: "likes tea" }],
|
||||
});
|
||||
expect(remember).toEqual({ status: "saved" });
|
||||
expect(forget).toEqual({ deleted: true });
|
||||
expect(store.searched[0]).toMatchObject({
|
||||
userId: "scope_abc",
|
||||
topK: 3,
|
||||
threshold: 0.4,
|
||||
rerank: true,
|
||||
});
|
||||
expect(store.added[0]).toMatchObject({
|
||||
userId: "scope_abc",
|
||||
infer: false,
|
||||
metadata: { source: "eve-tool" },
|
||||
messages: [{ role: "user", content: "Allergic to peanuts" }],
|
||||
});
|
||||
expect(store.deleted).toEqual(["mem_1"]);
|
||||
});
|
||||
|
||||
it("reports remember as queued when the write is still pending", async () => {
|
||||
const store = createFakeStore([], { pendingWrites: true });
|
||||
const provider = mem0Provider({ store, infer: true });
|
||||
const tools = await provider.tools!(toolsContext());
|
||||
const rememberTool = tools?.remember;
|
||||
if (!rememberTool) {
|
||||
throw new Error("expected remember tool");
|
||||
}
|
||||
const remember = await runTool(rememberTool, { text: "Allergic to peanuts" });
|
||||
expect(remember).toEqual({ status: "queued", eventId: "evt_1" });
|
||||
});
|
||||
|
||||
it("refuses to forget a memory from another scope", async () => {
|
||||
const store = createFakeStore([
|
||||
{ id: "mem_other", memory: "secret", userId: "scope_other" },
|
||||
]);
|
||||
const provider = mem0Provider({ store });
|
||||
const tools = await provider.tools!(toolsContext());
|
||||
const forgetTool = tools?.forget;
|
||||
if (!forgetTool) {
|
||||
throw new Error("expected forget tool");
|
||||
}
|
||||
|
||||
await expect(runTool(forgetTool, { id: "mem_other" })).rejects.toThrow(
|
||||
/Memory not found/,
|
||||
);
|
||||
expect(store.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces tool store failures", async () => {
|
||||
const store = createFakeStore();
|
||||
store.add = async () => {
|
||||
throw new Error("add failed");
|
||||
};
|
||||
const provider = mem0Provider({ store });
|
||||
const tools = await provider.tools!(toolsContext());
|
||||
const rememberTool = tools?.remember;
|
||||
if (!rememberTool) {
|
||||
throw new Error("expected remember tool");
|
||||
}
|
||||
|
||||
await expect(
|
||||
runTool(rememberTool, { text: "remember this" }),
|
||||
).rejects.toThrow("add failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRecallMessages, recallMemories } from "../src/recall.js";
|
||||
import { createFakeStore } from "./helpers.js";
|
||||
|
||||
describe("formatRecallMessages", () => {
|
||||
it("drops empty memories and empty ids", () => {
|
||||
expect(
|
||||
formatRecallMessages([
|
||||
{ id: "1", memory: " likes tea " },
|
||||
{ id: "2", memory: " " },
|
||||
{ id: " ", memory: "kept text" },
|
||||
]),
|
||||
).toEqual([{ id: "1", content: "likes tea" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recallMemories", () => {
|
||||
it("searches inside the locked scope and returns keyed messages", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
||||
const result = await recallMemories({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
query: "What does the user drink?",
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
|
||||
expect(store.searched).toEqual([
|
||||
{
|
||||
query: "What does the user drink?",
|
||||
userId: "scope_abc",
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an empty query", async () => {
|
||||
const store = createFakeStore([{ id: "mem_1", memory: "x" }]);
|
||||
await expect(
|
||||
recallMemories({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
query: " ",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(store.searched).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns null when search has no usable hits", async () => {
|
||||
const store = createFakeStore([{ id: " ", memory: " " }]);
|
||||
await expect(
|
||||
recallMemories({
|
||||
store,
|
||||
scopeKey: "scope_abc",
|
||||
query: "tea",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { MemoryClient, add, search, get, getAll, del } = vi.hoisted(() => {
|
||||
const add = vi.fn();
|
||||
const search = vi.fn();
|
||||
const get = vi.fn();
|
||||
const getAll = vi.fn();
|
||||
const del = vi.fn();
|
||||
const MemoryClient = vi.fn(
|
||||
class {
|
||||
add = add;
|
||||
search = search;
|
||||
get = get;
|
||||
getAll = getAll;
|
||||
delete = del;
|
||||
},
|
||||
);
|
||||
return { MemoryClient, add, search, get, getAll, del };
|
||||
});
|
||||
|
||||
vi.mock("mem0ai", () => ({
|
||||
default: MemoryClient,
|
||||
}));
|
||||
|
||||
import {
|
||||
createLazyStore,
|
||||
createMem0Store,
|
||||
parseAddResult,
|
||||
parseMemoryRecord,
|
||||
parseSearchHit,
|
||||
resolveApiKey,
|
||||
} from "../src/store.js";
|
||||
|
||||
describe("parseAddResult", () => {
|
||||
it("reports a pending event as queued", () => {
|
||||
expect(parseAddResult({ event_id: "evt_1", status: "PENDING" })).toEqual({
|
||||
status: "queued",
|
||||
eventId: "evt_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a succeeded event as completed", () => {
|
||||
expect(parseAddResult({ event_id: "evt_1", status: "SUCCEEDED" })).toEqual({
|
||||
status: "completed",
|
||||
eventId: "evt_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a memory-results array as completed", () => {
|
||||
expect(parseAddResult([{ id: "m1", memory: "tea" }])).toEqual({
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report a failed event as queued", () => {
|
||||
expect(parseAddResult({ event_id: "evt_1", status: "FAILED" })).toEqual({
|
||||
status: "completed",
|
||||
eventId: "evt_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report an event with no status as queued", () => {
|
||||
expect(parseAddResult({ event_id: "evt_1" })).toEqual({
|
||||
status: "completed",
|
||||
eventId: "evt_1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSearchHit", () => {
|
||||
it("accepts id, memory_id, nested text, and numeric ids", () => {
|
||||
expect(parseSearchHit({ id: 12, memory: "tea" })).toEqual({
|
||||
id: "12",
|
||||
memory: "tea",
|
||||
});
|
||||
expect(
|
||||
parseSearchHit({ memory_id: "m1", data: { memory: "nested" } }),
|
||||
).toEqual({ id: "m1", memory: "nested" });
|
||||
expect(parseSearchHit({ id: "m2", text: "plain" })).toEqual({
|
||||
id: "m2",
|
||||
memory: "plain",
|
||||
});
|
||||
expect(parseSearchHit({ id: "", memory: "x" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMemoryRecord", () => {
|
||||
it("requires id and userId", () => {
|
||||
expect(
|
||||
parseMemoryRecord({
|
||||
id: "m1",
|
||||
user_id: "scope_abc",
|
||||
metadata: { source: "eve" },
|
||||
}),
|
||||
).toEqual({
|
||||
id: "m1",
|
||||
userId: "scope_abc",
|
||||
metadata: { source: "eve" },
|
||||
});
|
||||
expect(parseMemoryRecord({ id: "m1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveApiKey", () => {
|
||||
it("trims string keys and rejects empty values", async () => {
|
||||
await expect(resolveApiKey(" token ")).resolves.toBe("token");
|
||||
await expect(resolveApiKey(" ")).rejects.toThrow(/empty/);
|
||||
});
|
||||
|
||||
it("resolves async getters", async () => {
|
||||
await expect(resolveApiKey(async () => " secret ")).resolves.toBe("secret");
|
||||
await expect(resolveApiKey(async () => "")).rejects.toThrow(/empty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLazyStore", () => {
|
||||
it("retries after a failed factory call", async () => {
|
||||
let calls = 0;
|
||||
const load = createLazyStore(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("unavailable");
|
||||
}
|
||||
return { name: "store" } as never;
|
||||
});
|
||||
|
||||
await expect(load()).rejects.toThrow("unavailable");
|
||||
await expect(load()).resolves.toEqual({ name: "store" });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMem0Store", () => {
|
||||
beforeEach(() => {
|
||||
add.mockReset();
|
||||
search.mockReset();
|
||||
get.mockReset();
|
||||
getAll.mockReset();
|
||||
del.mockReset();
|
||||
MemoryClient.mockClear();
|
||||
});
|
||||
|
||||
it("constructs the client with a resolved key and host", async () => {
|
||||
const store = await createMem0Store({
|
||||
apiKey: async () => " m0-live ",
|
||||
host: "https://api.mem0.ai",
|
||||
});
|
||||
expect(store).toBeTruthy();
|
||||
expect(MemoryClient).toHaveBeenCalledWith({
|
||||
apiKey: "m0-live",
|
||||
host: "https://api.mem0.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards add, search, and metadata filters", async () => {
|
||||
search.mockResolvedValue({
|
||||
results: [{ id: "m1", memory: "likes tea" }, { id: 2, memory: "" }],
|
||||
});
|
||||
getAll.mockResolvedValue({
|
||||
results: [{ id: "m9", userId: "scope_abc", metadata: { operation_id: "op_1" } }],
|
||||
});
|
||||
add.mockResolvedValue({ event_id: "evt_1", status: "SUCCEEDED" });
|
||||
|
||||
const store = await createMem0Store({
|
||||
apiKey: "m0-test",
|
||||
host: "https://api.mem0.ai",
|
||||
});
|
||||
|
||||
const addResult = await store.add([{ role: "user", content: "I like tea" }], {
|
||||
userId: "scope_abc",
|
||||
infer: false,
|
||||
metadata: { source: "eve", operation_id: "op_1" },
|
||||
});
|
||||
expect(addResult).toEqual({ status: "completed", eventId: "evt_1" });
|
||||
const found = await store.search("tea", {
|
||||
userId: "scope_abc",
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
const existing = await store.listByMetadata({
|
||||
userId: "scope_abc",
|
||||
metadata: { operation_id: "op_1" },
|
||||
});
|
||||
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "I like tea" }],
|
||||
{
|
||||
userId: "scope_abc",
|
||||
infer: false,
|
||||
metadata: { source: "eve", operation_id: "op_1" },
|
||||
},
|
||||
);
|
||||
expect(search).toHaveBeenCalledWith("tea", {
|
||||
filters: { user_id: "scope_abc" },
|
||||
topK: 3,
|
||||
threshold: 0.5,
|
||||
rerank: true,
|
||||
});
|
||||
expect(found.results).toEqual([{ id: "m1", memory: "likes tea" }]);
|
||||
expect(existing).toEqual([
|
||||
{
|
||||
id: "m9",
|
||||
userId: "scope_abc",
|
||||
metadata: { operation_id: "op_1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces a pending platform write as queued", async () => {
|
||||
const store = await createMem0Store({
|
||||
apiKey: "m0-test",
|
||||
host: "https://api.mem0.ai",
|
||||
});
|
||||
add.mockResolvedValueOnce({ event_id: "evt_9", status: "PENDING" });
|
||||
await expect(
|
||||
store.add([{ role: "user", content: "I like tea" }], {
|
||||
userId: "scope_abc",
|
||||
infer: true,
|
||||
metadata: { source: "eve" },
|
||||
}),
|
||||
).resolves.toEqual({ status: "queued", eventId: "evt_9" });
|
||||
});
|
||||
|
||||
it("accepts a bare search array and rejects unknown envelopes", async () => {
|
||||
const store = await createMem0Store({
|
||||
apiKey: "m0-test",
|
||||
host: "https://api.mem0.ai",
|
||||
});
|
||||
search.mockResolvedValueOnce([{ id: "m1", memory: "tea" }]);
|
||||
await expect(
|
||||
store.search("tea", {
|
||||
userId: "scope_abc",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
}),
|
||||
).resolves.toEqual({ results: [{ id: "m1", memory: "tea" }] });
|
||||
|
||||
search.mockResolvedValueOnce({ memories: "nope" });
|
||||
await expect(
|
||||
store.search("tea", {
|
||||
userId: "scope_abc",
|
||||
topK: 5,
|
||||
threshold: 0.1,
|
||||
rerank: false,
|
||||
}),
|
||||
).rejects.toThrow(/unexpected response shape/);
|
||||
});
|
||||
|
||||
it("deletes only when the memory belongs to the scope", async () => {
|
||||
const store = await createMem0Store({
|
||||
apiKey: "m0-test",
|
||||
host: "https://api.mem0.ai",
|
||||
});
|
||||
|
||||
get.mockResolvedValueOnce({ id: "m1", userId: "scope_abc" });
|
||||
await store.delete("m1", "scope_abc");
|
||||
expect(del).toHaveBeenCalledWith("m1");
|
||||
|
||||
get.mockResolvedValueOnce({ id: "m2", user_id: "other" });
|
||||
await expect(store.delete("m2", "scope_abc")).rejects.toThrow(/Memory not found/);
|
||||
expect(del).toHaveBeenCalledTimes(1);
|
||||
|
||||
get.mockRejectedValueOnce(new Error("Memory not found"));
|
||||
await expect(store.delete("missing", "scope_abc")).rejects.toThrow(
|
||||
/Memory not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["eve", "eve/memory", "eve/tools", "mem0ai", "zod"],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user