Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a1225f8e9 | ||
|
|
1b51ad5f89 |
@@ -7,18 +7,6 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-09-25" description="v2.2.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** `add()` (`Memory` and `AsyncMemory`) no longer reports records the vector store rejected as successful `ADD` events. Only records that were actually inserted are written to history, entity-linked, and returned. If none of the extracted memories could be inserted, `add()` now raises `VectorStoreError` instead of returning memories that were never stored ([#7066](https://github.com/mem0ai/mem0/pull/7066))
|
||||
- **Core:** Restore the context-manager protocol on `Memory` (`with Memory() as m:`) and `AsyncMemory` (`async with AsyncMemory() as m:`), which closes the instance on exit ([#7354](https://github.com/mem0ai/mem0/pull/7354))
|
||||
- **LLMs:** The AWS Bedrock Anthropic path now returns the first Converse content block that carries text instead of always reading `content[0]`. Claude reasoning models can emit a `reasoningContent` block before the text block, which previously made the call fail with a `KeyError` ([#6369](https://github.com/mem0ai/mem0/pull/6369))
|
||||
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only `gte` and `lte` were read before, so any other operator was silently dropped and the query returned unfiltered results. An unsupported operator now raises `ValueError` ([#6564](https://github.com/mem0ai/mem0/pull/6564))
|
||||
- **Vector Stores:** Turbopuffer `search()` scores now respect `distance_metric`. With `euclidean_squared`, the unbounded squared distance maps to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6559](https://github.com/mem0ai/mem0/pull/6559))
|
||||
- **Vector Stores:** S3 Vectors `search()` scores are now metric-aware. With `euclidean`, the distance maps to `1 / (1 + distance)` instead of `max(0, 1 - distance)`, which collapsed most scores to 0 ([#6547](https://github.com/mem0ai/mem0/pull/6547))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-23" description="v2.2.0">
|
||||
|
||||
**New Features:**
|
||||
@@ -1257,16 +1245,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-09-25" description="v3.3.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Config (OSS):** `ConfigManager` no longer injects OpenAI's default `baseURL` and `model` into non-OpenAI LLM providers. The defaults now apply only to `openai` and `openai_structured`, so a DeepSeek, xAI, or other provider config without an explicit `baseURL` or `model` falls back to that provider's own defaults and env vars (`DEEPSEEK_API_BASE`, `XAI_API_BASE`, ...) instead of pointing at OpenAI with an OpenAI model name ([#7350](https://github.com/mem0ai/mem0/pull/7350))
|
||||
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only the range operators were read before, so `eq`, `ne`, `in`, and `nin` were silently dropped. A `"*"` value now matches anything instead of nothing, an array value is treated as `in`, and an unsupported operator throws ([#6578](https://github.com/mem0ai/mem0/pull/6578))
|
||||
- **Vector Stores:** Turbopuffer `search()` with `euclidean_squared` now maps the unbounded squared distance to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6580](https://github.com/mem0ai/mem0/pull/6580))
|
||||
- **Packaging:** `pg`, `@types/pg`, and `natural` are now optional peer dependencies, and `pg` / `@types/pg` accept caret ranges instead of the exact `8.11.3` / `8.11.0` pins, so installing `mem0ai` no longer pulls in `pg` or conflicts with an app's own `pg` version. The PGVector store now imports `pg` only when it is used, so install it alongside `mem0ai` (`npm install pg`) if you use that store. `@types/jest` moved from peer dependencies to dev dependencies ([#7450](https://github.com/mem0ai/mem0/pull/7450))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-09-23" description="v3.3.0">
|
||||
|
||||
**New Features:**
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Add persistent memory to DeepSeek Harness with automatic recall, a
|
||||
|
||||
Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-ai/deepseek-harness) with `@mem0/deepseek-plugin`. The plugin recalls relevant context before a model request, captures completed turns, and provides explicit Mem0 tools when the agent needs them.
|
||||
|
||||
<Info>Current package version: `0.3.2`.</Info>
|
||||
<Info>Current package version: `0.3.0`.</Info>
|
||||
|
||||
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
|
||||
|
||||
@@ -32,7 +32,7 @@ A Cordis plugin is a module exporting `apply(ctx, config)`. This plugin waits fo
|
||||
|
||||
Cordis removes the listeners and tools when the plugin unmounts. Memory failures are fail-open, so a Mem0 outage does not stop the agent from completing its normal work.
|
||||
|
||||
DeepSeek Harness provides subagents through separate host-composition packages. This Mem0 package does not register a named Sidekick or claim child filesystem isolation. Memory availability in children depends on the Harness composition and service scope.
|
||||
DeepSeek Harness provides subagents through separate host-composition packages. This Mem0 package does not register a named Sidekick or claim child filesystem isolation. A Harness child uses Mem0 only when its own agent preset includes the Mem0 plugin.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -40,27 +40,25 @@ DeepSeek Harness provides subagents through separate host-composition packages.
|
||||
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-deepseek-plugin">Sign up at app.mem0.ai</a>
|
||||
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-deepseek-plugin">Get your API key</a> (starts with `m0-`)
|
||||
|
||||
2. The DeepSeek Harness installed and a funded model-provider credential (`DEEPSEEK_API_KEY` for the official DeepSeek provider). This is separate from your Mem0 key.
|
||||
2. The DeepSeek Harness installed.
|
||||
|
||||
3. Your Mem0 key and a stable user identity exported in the shell that launches Harness:
|
||||
3. Your API key exported in your shell:
|
||||
|
||||
<CodeGroup>
|
||||
```bash zsh
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc
|
||||
echo 'export MEM0_USER_ID="your-user-id"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
```bash bash
|
||||
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc
|
||||
echo 'export MEM0_USER_ID="your-user-id"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Try it locally
|
||||
|
||||
1. Build and pack the plugin from the full repository (the source imports the sibling shared core):
|
||||
1. Build and pack the plugin:
|
||||
```sh
|
||||
cd integrations/deepseek-plugin
|
||||
pnpm install --frozen-lockfile
|
||||
@@ -69,35 +67,36 @@ source ~/.bashrc
|
||||
pnpm pack --pack-destination /tmp/mem0-deepseek-plugin
|
||||
```
|
||||
|
||||
2. Install it into a disposable Harness profile so Harness supplies its peer dependencies. This needs pnpm 10 or newer, because the profile's `pnpm-workspace.yaml` settings are ignored by pnpm 9 and the install fails on an unpublished internal package:
|
||||
2. Install it into a disposable Harness profile so Harness supplies its peer dependencies:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
plugin --profile web add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.2.tgz
|
||||
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.0.tgz
|
||||
```
|
||||
|
||||
3. Launch the same profile. The package's `dsh.bundle` activates Mem0 automatically:
|
||||
3. Copy `cordis.example.yml`, set its installed package path and your `userId`, then load it with the same profile:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
web
|
||||
web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
```
|
||||
|
||||
4. Open the web UI, select a workspace, and state a synthetic preference without mentioning Mem0. Allow extraction to finish, start a fresh session, and ask for that preference without tools. Inspect the `mem0:recall` context to confirm automatic recall.
|
||||
4. Open the web UI and ask the agent to remember something, then recall it in a later turn.
|
||||
|
||||
`pnpm pack` builds the package automatically and ships its activation patch. To customize it, copy `cordis.example.yml` and launch with `web --patch /absolute/path/to/cordis.yml`. The override updates the existing bundle row:
|
||||
The `cordis.yml` entry looks like this:
|
||||
|
||||
```yaml
|
||||
- id: mem0
|
||||
config:
|
||||
# A config override replaces the whole config, so restate userId.
|
||||
userId: !!js process.env.MEM0_USER_ID
|
||||
memoryScope: workspace
|
||||
autoRecall: true
|
||||
autoCapture: true
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform dedicated base URL
|
||||
- name: "@deepseek-ai/dsh-system-prompt"
|
||||
- name: "@deepseek-ai/dsh-tools"
|
||||
- insert:
|
||||
- id: mem0
|
||||
name: "/tmp/mem0-dsh-dev/profiles/headless/node_modules/@mem0/deepseek-plugin/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from MEM0_API_KEY when omitted here.
|
||||
userId: "your-user-id"
|
||||
autoRecall: true
|
||||
autoCapture: true
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
|
||||
```
|
||||
|
||||
When upgrading from a manual installation, remove the old patch that inserts a `mem0` row; the bundle now inserts it. Keep custom settings as an override by `id`.
|
||||
|
||||
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` is a Platform base-URL override, not a switch to self-hosted Mem0 OSS.
|
||||
|
||||
## Configuration
|
||||
@@ -106,27 +105,12 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Default entity that owns the memories |
|
||||
| `memoryScope` | no | `user` | `user` shares memory across workspaces; `workspace` isolates automatic and explicit memory operations |
|
||||
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
| `autoRecall` | no | `true` | Recall relevant memory before model requests |
|
||||
| `autoCapture` | no | `true` | Store completed human and assistant turns |
|
||||
|
||||
Both tools also accept optional per-call `userId`, `agentId`, and `runId` params so a single install can partition memory by entity, agent, or session; `userId` defaults to the configured user, while `agentId` and `runId` are omitted unless supplied. Automatic capture preserves full redacted user and assistant message text without a per-message character cutoff.
|
||||
|
||||
## Workspace scope
|
||||
|
||||
By default, automatic capture and recall use the configured user across all workspaces. This is intentional compatibility behavior. To isolate workspaces, set `memoryScope: workspace` as shown above. All writes then attach an `appId` derived from the canonical absolute session workspace path; all searches require its matching `app_id`. The model cannot override that workspace filter.
|
||||
|
||||
Symlink aliases share a scope. Different directories, clones, or worktrees have different scopes; moving a directory changes its scope. Missing or invalid workspace paths skip automatic memory operations and reject explicit tools without falling back to user-wide access. Existing user-only memories are not migrated into workspace scopes.
|
||||
|
||||
User scope searches all memories belonging to the user, including workspace-tagged memories. Workspace isolation applies only when workspace scope is configured; it is application filtering, not a separate Mem0 account or credential.
|
||||
|
||||
## Extraction and recall limits
|
||||
|
||||
Capture sends the full redacted turn to Mem0 for asynchronous extraction. A queued write, or even an initially nonempty memory list, does not mean every extracted fact is ready. Allow processing to finish and inspect stored memories before diagnosing lost preferences or missing filenames and numeric constraints. Exact extraction remains model-dependent; a one-off request should not automatically become a standing preference.
|
||||
|
||||
Automatic recall is a shallow first pass: up to five results, 4,000 context characters, and a two-second wait. The explicit `search_memory` tool can use a more focused query and returns up to ten results by default.
|
||||
Both tools also accept optional per-call `userId`, `agentId`, and `runId` params so a single install can partition memory by entity, agent, or session; `userId` defaults to the configured user, while `agentId` and `runId` are omitted unless supplied. Automatic recall and capture use the configured user without an agent, repository, or session filter. Automatic capture preserves full redacted user and assistant message text without a per-message character cutoff.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -140,6 +124,6 @@ Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to thi
|
||||
|
||||
- **`MISSING_CREDENTIAL` for `deepseek-official`**: Configure `DEEPSEEK_API_KEY` through Harness's Models page or export it in the shell that launches Harness.
|
||||
- **`EMFILE: too many open files, watch` on macOS**: Launch Harness with `CHOKIDAR_USEPOLLING=1`.
|
||||
- **Mem0 tools do not appear**: Run Harness with `--dump-config` for the same profile used at installation and confirm it contains a `mem0` row naming `@mem0/deepseek-plugin`. Export `MEM0_USER_ID` and `MEM0_API_KEY` before launching.
|
||||
- **Mem0 tools do not appear**: Run Harness with `--dump-config` and confirm the final composition contains the `mem0` row and the installed `dist/index.js` path.
|
||||
|
||||
Per-call `userId` overrides are rejected unless the operator enables `allowUserOverride: true`. Automatic recall and capture always use the configured user.
|
||||
|
||||
@@ -75,8 +75,6 @@ interface RecallOptions {
|
||||
maxChars?: number;
|
||||
seenIds?: Set<string>;
|
||||
timeoutMs?: number;
|
||||
/** Gate for marking memories as seen. Return false when the context will not reach the model. */
|
||||
canDeliver?: () => boolean;
|
||||
}
|
||||
|
||||
interface MemoryLifecycleOptions {
|
||||
@@ -112,14 +110,12 @@ class MemoryLifecycle {
|
||||
prompt: string,
|
||||
enabled: boolean,
|
||||
search: (query: string) => Promise<{ results?: unknown[] }>,
|
||||
canDeliver?: () => boolean,
|
||||
): Promise<string> {
|
||||
return buildRecallContext(prompt, enabled, search, {
|
||||
heading: this.#options.recallHeading,
|
||||
maxChars: this.#options.maxContextChars,
|
||||
seenIds: this.#seenMemoryIds,
|
||||
timeoutMs: this.#options.recallTimeoutMs,
|
||||
canDeliver,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -160,7 +156,6 @@ export async function buildRecallContext(
|
||||
const suffix = "\n</mem0-relevant-memories>";
|
||||
const maxChars = options.maxChars ?? DEFAULT_MAX_CONTEXT_CHARS;
|
||||
const lines: string[] = [];
|
||||
const includedIds: string[] = [];
|
||||
for (const memory of unseen) {
|
||||
const line = `${lines.length + 1}. ${redactSecrets(formatMemoryCompact(memory))
|
||||
.replace(/\s+/g, " ")
|
||||
@@ -169,20 +164,15 @@ export async function buildRecallContext(
|
||||
if (candidate.length > maxChars) {
|
||||
if (!lines.length) {
|
||||
const available = maxChars - prefix.length - suffix.length;
|
||||
if (available > 1) {
|
||||
lines.push(`${line.slice(0, available - 1).trimEnd()}…`);
|
||||
includedIds.push(memory.id);
|
||||
}
|
||||
if (available > 1) lines.push(`${line.slice(0, available - 1).trimEnd()}…`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
lines.push(line);
|
||||
includedIds.push(memory.id);
|
||||
options.seenIds?.add(memory.id);
|
||||
}
|
||||
if (!lines.length) return "";
|
||||
if (options.canDeliver?.() ?? true) {
|
||||
for (const id of includedIds) options.seenIds?.add(id);
|
||||
}
|
||||
if (unseen[0] && !options.seenIds?.has(unseen[0].id)) options.seenIds?.add(unseen[0].id);
|
||||
return prefix + lines.join("\n") + suffix;
|
||||
} catch {
|
||||
return "";
|
||||
|
||||
@@ -20,15 +20,6 @@ test("one lifecycle owns recall state and resets it for a new session", async ()
|
||||
assert.match(await lifecycle.recall("package manager", true, search), /Use pnpm/);
|
||||
});
|
||||
|
||||
test("recall leaves memories unseen when delivery is not allowed", async () => {
|
||||
const lifecycle = createMemoryLifecycle({ recallTimeoutMs: 50 });
|
||||
const search = async () => ({ results: [{ id: "m1", memory: "Use pnpm" }] });
|
||||
|
||||
assert.match(await lifecycle.recall("package manager", true, search, () => false), /Use pnpm/);
|
||||
assert.match(await lifecycle.recall("package manager", true, search), /Use pnpm/);
|
||||
assert.equal(await lifecycle.recall("package manager", true, search), "");
|
||||
});
|
||||
|
||||
test("one lifecycle owns capture preparation", () => {
|
||||
const lifecycle = createMemoryLifecycle();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ It gives a Harness agent automatic long-term memory plus two explicit memory too
|
||||
|
||||
Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed backend: server-side extraction, semantic dedup and conflict resolution, and memories that other agents can retrieve when their user and entity filters match.
|
||||
|
||||
Current package version: `0.3.2`.
|
||||
Current package version: `0.3.0`.
|
||||
|
||||
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
|
||||
|
||||
@@ -47,27 +47,21 @@ Cordis owns listener and tool cleanup when the plugin unmounts. Every automatic
|
||||
mkdir -p /tmp/mem0-deepseek-plugin
|
||||
pnpm pack --pack-destination /tmp/mem0-deepseek-plugin
|
||||
```
|
||||
2. Set your Mem0 key, a stable user identity, and a funded model API key:
|
||||
2. Set your Mem0 key:
|
||||
```sh
|
||||
export MEM0_API_KEY=...
|
||||
export MEM0_USER_ID=your-user-id
|
||||
export DEEPSEEK_API_KEY=...
|
||||
```
|
||||
3. Install it into a disposable Harness profile (requires pnpm 10 or newer; pnpm 9 ignores the profile's `pnpm-workspace.yaml` settings and the install fails on an unpublished internal package):
|
||||
3. Install it into a disposable Harness profile:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
plugin --profile web add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.2.tgz
|
||||
plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.0.tgz
|
||||
```
|
||||
4. Run Harness with the same profile. The installed bundle activates Mem0 automatically:
|
||||
4. Copy `cordis.example.yml`, set its installed package path and your `userId`, then run Harness with the same profile:
|
||||
```sh
|
||||
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
web
|
||||
web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
```
|
||||
5. Open http://127.0.0.1:3080, select a workspace, and state a synthetic preference without mentioning Mem0. Wait for asynchronous extraction to finish, then start a fresh session and ask for that preference without tools. Inspect the `mem0:recall` context to verify automatic recall.
|
||||
|
||||
Build from the full repository: the source imports the sibling shared core. `pnpm pack` runs the build and includes the activation patch. An installed tarball is self-contained. For custom settings, copy `cordis.example.yml` and pass its absolute path with `--patch`. When upgrading from the old manual setup, remove the old patch that **inserts** a `mem0` row; the bundle now inserts it.
|
||||
|
||||
On macOS, set `CHOKIDAR_USEPOLLING=1` if Harness reports `EMFILE`. The Mem0 key and model-provider key are separate credentials.
|
||||
5. Open http://127.0.0.1:3080 and ask the agent to remember something, then recall it in a later turn.
|
||||
|
||||
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` overrides the Platform base URL. It does not support the self-hosted Mem0 OSS API.
|
||||
|
||||
@@ -77,7 +71,6 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|---|---|---|---|
|
||||
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
|
||||
| `userId` | yes | | Entity that owns the memories |
|
||||
| `memoryScope` | no | `user` | `user` shares memory across workspaces; `workspace` isolates all automatic and explicit operations by the session workspace |
|
||||
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
|
||||
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
|
||||
| `autoRecall` | no | `true` | Recall relevant memory before model requests |
|
||||
@@ -87,20 +80,10 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|
||||
Automatic capture and recall use the configured `userId` across sessions. Automatic writes do not attach a repository ID or `runId`.
|
||||
|
||||
This cross-workspace sharing is intentional compatibility behavior. Set `memoryScope: workspace` in the profile patch to isolate memory. The plugin then adds an `appId` derived from the canonical absolute session workspace path to every write and the matching `app_id` to every search. Symlink aliases share a scope; different directories (including separate clones or worktrees) do not. Moving a workspace changes its scope. Tools cannot override it. Missing or invalid workspace paths skip automatic memory operations and reject explicit tools, without falling back to user-wide access.
|
||||
|
||||
Workspace scope does not migrate existing user-only memories. User scope still searches all memories for that user, including workspace-tagged memories; isolation applies when the plugin is configured with workspace scope. These filters are application-level separation, not separate Mem0 credentials.
|
||||
|
||||
Both `search_memory` and `add_memory` accept optional `agentId` and `runId`. On search, these narrow the returned memories; on add, they attach those identities to the stored memory. Pass a known `runId` to search memories explicitly saved with that session ID. This does not include automatically captured user-only memories or identify the session making the request.
|
||||
|
||||
Per-call `userId` overrides are rejected unless the operator enables `allowUserOverride: true`. Automatic recall and capture always use the configured user.
|
||||
|
||||
## Extraction and recall limits
|
||||
|
||||
The plugin sends full redacted completed turns to Mem0; Mem0 extracts the memories asynchronously. A queued write is not proof that every extracted fact is already searchable. Even an initial nonempty memory list can be incomplete. Check the stored memories after processing before diagnosing extraction loss.
|
||||
|
||||
Automatic recall is a bounded first pass (up to five results, 4,000 context characters, and a two-second wait). `search_memory` uses a focused query and returns up to ten results by default. Exact preference extraction remains model-dependent; include filenames and numeric constraints in release tests, and distinguish standing preferences from one-off requests.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Writes are tagged `source="DEEPSEEK_HARNESS"`. That value has to exist in the backend's `EventSource` enum for usage to surface by name; until it does, these writes read as `OTHERS`. It is added by [mem0ai/platform#3602](https://github.com/mem0ai/platform/pull/3602), which has to ship before this claim is true.
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
# Optional override for the mem0 row installed by dsh.bundle.
|
||||
# Copy this file, then use: dsh web --patch /absolute/path/to/cordis.yml
|
||||
- id: mem0
|
||||
config:
|
||||
# Replacing a row's config requires restating its userId.
|
||||
# apiKey is read from MEM0_API_KEY when omitted here.
|
||||
userId: !!js process.env.MEM0_USER_ID
|
||||
memoryScope: workspace # optional; default is user (shared across workspaces)
|
||||
# autoRecall: true
|
||||
# autoCapture: true
|
||||
# host: "https://your-onprem.mem0.ai" # Mem0 Platform dedicated deployment
|
||||
# Example DeepSeek Harness config that loads deepseek-plugin alongside the built-in
|
||||
# tools plugin. Load with:
|
||||
#
|
||||
# DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
|
||||
# web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
#
|
||||
- insert:
|
||||
- id: mem0
|
||||
# Install the packed plugin into this disposable profile first; loading
|
||||
# raw dist bypasses Harness's peer dependency installation.
|
||||
name: "/tmp/mem0-dsh-dev/profiles/headless/node_modules/@mem0/deepseek-plugin/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from the MEM0_API_KEY env var when omitted here.
|
||||
userId: "your-user-id"
|
||||
# autoRecall: true # optional; enabled by default
|
||||
# autoCapture: true # optional; enabled by default
|
||||
# host: "https://your-onprem.mem0.ai" # optional: Platform on-prem / dedicated base URL
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- insert:
|
||||
- id: mem0
|
||||
name: "@mem0/deepseek-plugin"
|
||||
config:
|
||||
# Credentials stay in the process environment, not the bundle.
|
||||
userId: !!js process.env.MEM0_USER_ID
|
||||
@@ -29,21 +29,13 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"cordis.patch.yml",
|
||||
"cordis.example.yml",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"prepack": "pnpm build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||
import { MemoryClient } from "mem0ai";
|
||||
import { formatMemoryList, formatAddResult } from "./formatting.ts";
|
||||
import { truncateOutput } from "./output.ts";
|
||||
import { resolveSearchFilters, resolveAddParams, resolveWorkspaceId } from "./scoping.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
import { captureEvent, errorKind } from "./telemetry.ts";
|
||||
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
|
||||
import {
|
||||
@@ -48,7 +48,6 @@ interface HarnessMessage {
|
||||
interface SessionState {
|
||||
lifecycle: ReturnType<typeof createMemoryLifecycle>;
|
||||
messages: HarnessMessage[];
|
||||
currentPrompt?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
@@ -56,8 +55,6 @@ export interface Config {
|
||||
apiKey?: string;
|
||||
/** Default entity that owns the memories (Mem0 user scope). */
|
||||
userId: string;
|
||||
/** Share user memory across workspaces (default), or isolate by session workspace. */
|
||||
memoryScope?: "user" | "workspace";
|
||||
/** Explicitly allow model-selected cross-user access. Defaults to false. */
|
||||
allowUserOverride?: boolean;
|
||||
/** Optional Mem0 Platform base-URL override (on-prem / dedicated); defaults to api.mem0.ai. Not a switch to self-hosted OSS. */
|
||||
@@ -105,19 +102,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (!userId || /^\*+$/.test(userId)) {
|
||||
throw new Error("deepseek-plugin: config.userId is required");
|
||||
}
|
||||
if (config.memoryScope !== undefined && !["user", "workspace"].includes(config.memoryScope)) {
|
||||
throw new Error("deepseek-plugin: config.memoryScope must be user or workspace");
|
||||
}
|
||||
const workspaceId = (session?: { header: { cwd?: string } }) =>
|
||||
config.memoryScope === "workspace" ? resolveWorkspaceId(session?.header.cwd) : undefined;
|
||||
const searchFilters = (params: Parameters<typeof resolveSearchFilters>[0], session?: { header: { cwd?: string } }) => {
|
||||
const appId = workspaceId(session);
|
||||
return { ...resolveSearchFilters(params, userId), ...(appId ? { app_id: appId } : {}) };
|
||||
};
|
||||
const addParams = (params: Parameters<typeof resolveAddParams>[0], session?: { header: { cwd?: string } }) => {
|
||||
const appId = workspaceId(session);
|
||||
return { ...resolveAddParams(params, userId), ...(appId ? { appId } : {}) };
|
||||
};
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey,
|
||||
@@ -143,20 +127,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}, client);
|
||||
|
||||
if (config.autoRecall !== false) {
|
||||
// Harness assembles the prompt before appending claimed messages to history.
|
||||
ctx.on("agent/inbox/claimed", ({ agent, message }) => {
|
||||
if (message.source.kind !== "user") return;
|
||||
const state = stateFor(agent.session);
|
||||
state.currentPrompt = state.lifecycle.prepareConversation([message]).at(-1)?.content;
|
||||
});
|
||||
|
||||
ctx.on("system-prompt/assemble", async (_input, context, next): Promise<PromptAssembly> => {
|
||||
const assembly = await next();
|
||||
const { agent, signal } = context;
|
||||
if (!agent || signal?.aborted) return assembly;
|
||||
|
||||
const state = stateFor(agent.session);
|
||||
const prompt = state.currentPrompt ?? state.lifecycle.prepareConversation(
|
||||
const prompt = state.lifecycle.prepareConversation(
|
||||
agent.session
|
||||
.deriveMessages()
|
||||
.filter((message) => message.role === "user" && message.source?.kind === "user"),
|
||||
@@ -167,7 +144,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await client.search(query, {
|
||||
filters: searchFilters({}, agent.session),
|
||||
filters: resolveSearchFilters({}, userId),
|
||||
topK: AUTO_RECALL_LIMIT,
|
||||
});
|
||||
captureEvent("deepseek.recall.auto", {
|
||||
@@ -184,7 +161,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}, client);
|
||||
throw err;
|
||||
}
|
||||
}, () => !signal?.aborted);
|
||||
});
|
||||
if (!memoryContext || signal?.aborted) return assembly;
|
||||
return {
|
||||
...assembly,
|
||||
@@ -206,8 +183,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const conversation = state.lifecycle.prepareConversation(state.messages);
|
||||
state.messages = [];
|
||||
if (event.data.reason.kind !== "completed" || conversation.length === 0) return;
|
||||
void Promise.resolve()
|
||||
.then(() => client.add(conversation, { ...addParams({}, session), source: SOURCE }))
|
||||
void client
|
||||
.add(conversation, { userId, source: SOURCE })
|
||||
.then(() => captureEvent("deepseek.capture.auto", {
|
||||
success: true,
|
||||
message_count: conversation.length,
|
||||
@@ -235,12 +212,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ query, limit, userId: u, agentId, runId }, exec) {
|
||||
async execute({ query, limit, userId: u, agentId, runId }) {
|
||||
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
|
||||
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
|
||||
}
|
||||
const safeQuery = toolLifecycle.prepareUserText(query);
|
||||
const filters = searchFilters({ userId: u, agentId, runId }, exec.agent?.session);
|
||||
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
|
||||
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
|
||||
const started = Date.now();
|
||||
try {
|
||||
@@ -288,17 +265,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ text, userId: u, agentId, runId }, exec) {
|
||||
async execute({ text, userId: u, agentId, runId }) {
|
||||
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
|
||||
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
|
||||
}
|
||||
const params = addParams({ userId: u, agentId, runId }, exec.agent?.session);
|
||||
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await client.add(
|
||||
[{ role: "user", content: toolLifecycle.prepareUserText(text) }],
|
||||
{
|
||||
...params,
|
||||
...addParams,
|
||||
source: SOURCE,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
export {
|
||||
entityAddParams as resolveAddParams,
|
||||
entitySearchFilters as resolveSearchFilters,
|
||||
} from "../../agent-plugin-core/typescript/src/identity.ts";
|
||||
export type { EntityParams } from "../../agent-plugin-core/typescript/src/identity.ts";
|
||||
|
||||
export function resolveWorkspaceId(cwd: string | undefined): string {
|
||||
if (!cwd || !isAbsolute(cwd)) {
|
||||
throw new Error("Workspace memory scope requires an absolute session workspace path.");
|
||||
}
|
||||
return `deepseek-workspace-${createHash("sha256").update(realpathSync(cwd)).digest("hex")}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Offline mock of the Mem0 SDK so these tests never touch the network.
|
||||
const mockSearch = vi.fn();
|
||||
@@ -78,11 +75,6 @@ describe("apply() config validation", () => {
|
||||
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
|
||||
});
|
||||
|
||||
it("rejects unknown memory scope instead of silently using user-wide access", () => {
|
||||
expect(() => applyAndCollect({ apiKey: "k", userId: "u", memoryScope: "typo" } as unknown as Config))
|
||||
.toThrow(/memoryScope/);
|
||||
});
|
||||
|
||||
it("registers both memory tools", () => {
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
expect([...tools.keys()].sort()).toEqual(["add_memory", "search_memory"]);
|
||||
@@ -90,65 +82,6 @@ describe("apply() config validation", () => {
|
||||
});
|
||||
|
||||
describe("Harness lifecycle", () => {
|
||||
it("delivers a recalled memory on retry after the first attempt was canceled", async () => {
|
||||
const response = { results: [{ id: "m1", memory: "A saved fact" }] };
|
||||
const controller = new AbortController();
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const assemble = listeners.get("system-prompt/assemble")!;
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
const agent = { session: { deriveMessages: () => [] } };
|
||||
const claim = () =>
|
||||
listeners.get("agent/inbox/claimed")!({
|
||||
agent, turn: 1,
|
||||
message: { role: "user", source: { kind: "user" }, content: "Recall my saved information" },
|
||||
});
|
||||
mockSearch
|
||||
.mockImplementationOnce(async () => {
|
||||
controller.abort();
|
||||
return response;
|
||||
})
|
||||
.mockResolvedValue(response);
|
||||
|
||||
claim();
|
||||
expect(await assemble(base, { agent, signal: controller.signal }, async () => base)).toBe(base);
|
||||
claim();
|
||||
const retried = await assemble(base, { agent, signal: new AbortController().signal }, async () => base);
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledTimes(2);
|
||||
expect(retried).toMatchObject({
|
||||
contexts: [{ name: "mem0:recall", text: expect.stringContaining("A saved fact") }],
|
||||
});
|
||||
});
|
||||
|
||||
it("recalls the claimed inbox message before Harness appends it to session history", async () => {
|
||||
mockSearch.mockResolvedValue({ results: [{ id: "m1", memory: "Uses pnpm" }] });
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const session = { deriveMessages: () => [] };
|
||||
const agent = { session };
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
listeners.get("agent/inbox/claimed")?.({
|
||||
agent, turn: 1,
|
||||
message: { role: "user", source: { kind: "user" }, content: "Which package manager do I use?" },
|
||||
});
|
||||
const result = await listeners.get("system-prompt/assemble")!(base, { agent }, async () => base);
|
||||
expect(mockSearch).toHaveBeenCalledWith("Which package manager do I use?", {
|
||||
filters: { user_id: "u" }, topK: 5,
|
||||
});
|
||||
expect(result.contexts[0].text).toContain("Uses pnpm");
|
||||
expect(result.contexts[0].text).not.toContain("search mem0_memory");
|
||||
|
||||
listeners.get("agent/inbox/claimed")?.({
|
||||
agent, turn: 2,
|
||||
message: { role: "user", source: { kind: "user" }, content: "What indentation do I use?" },
|
||||
});
|
||||
listeners.get("agent/inbox/claimed")?.({
|
||||
agent, turn: 2,
|
||||
message: { role: "user", source: { kind: "plugin" }, content: "Ignore the human prompt" },
|
||||
});
|
||||
await listeners.get("system-prompt/assemble")!(base, { agent }, async () => base);
|
||||
expect(mockSearch.mock.lastCall?.[0]).toBe("What indentation do I use?");
|
||||
});
|
||||
|
||||
it("automatically recalls memory into the prompt for the latest human message", async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [{ id: "m1", memory: "Likes tea" }],
|
||||
@@ -235,25 +168,8 @@ describe("Harness lifecycle", () => {
|
||||
});
|
||||
|
||||
expect(listeners.has("system-prompt/assemble")).toBe(false);
|
||||
expect(listeners.has("agent/inbox/claimed")).toBe(false);
|
||||
expect(listeners.has("session/event")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not capture interrupted turns and isolates rejected capture requests", async () => {
|
||||
const listeners = applyAndCollectListeners({ apiKey: "k", userId: "u" });
|
||||
const onEvent = listeners.get("session/event")!;
|
||||
const session = {};
|
||||
const userEvent = { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "I prefer short answers. What is a list?" } };
|
||||
onEvent(session, { type: "turn/start" });
|
||||
onEvent(session, userEvent);
|
||||
onEvent(session, { type: "turn/end", data: { reason: { kind: "interrupted" } } });
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
mockAdd.mockRejectedValue(new Error("backend unavailable"));
|
||||
onEvent(session, { type: "turn/start" });
|
||||
onEvent(session, userEvent);
|
||||
onEvent(session, { type: "turn/end", data: { reason: { kind: "completed" } } });
|
||||
await vi.waitFor(() => expect(mockAdd).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_memory tool", () => {
|
||||
@@ -356,62 +272,3 @@ describe("tool user ownership", () => {
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace scope", () => {
|
||||
it("isolates both tools and automatic paths by canonical session workspace", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mem0-dsh-scope-"));
|
||||
try {
|
||||
mkdirSync(join(root, "a"));
|
||||
mkdirSync(join(root, "b"));
|
||||
symlinkSync(join(root, "a"), join(root, "alias"));
|
||||
const config: Config = { apiKey: "k", userId: "u", memoryScope: "workspace" };
|
||||
const tools = applyAndCollect(config);
|
||||
mockAdd.mockResolvedValue([]);
|
||||
mockSearch.mockResolvedValue({ results: [] });
|
||||
const session = (name: string) => ({
|
||||
header: { cwd: join(root, name) },
|
||||
deriveMessages: () => [{ role: "user", source: { kind: "user" }, content: "preferences" }],
|
||||
});
|
||||
const a = session("a");
|
||||
await tools.get("add_memory")!.execute({ text: "Uses pnpm" }, { agent: { session: a } });
|
||||
const appId = mockAdd.mock.lastCall?.[1].appId;
|
||||
expect(appId).toMatch(/^deepseek-workspace-[a-f0-9]{64}$/);
|
||||
await tools.get("search_memory")!.execute({ query: "preferences" }, { agent: { session: session("alias") } });
|
||||
expect(mockSearch.mock.lastCall?.[1].filters).toEqual({ user_id: "u", app_id: appId });
|
||||
await tools.get("search_memory")!.execute({ query: "preferences" }, { agent: { session: session("b") } });
|
||||
expect(mockSearch.mock.lastCall?.[1].filters.app_id).not.toBe(appId);
|
||||
|
||||
const listeners = applyAndCollectListeners(config);
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
await listeners.get("system-prompt/assemble")!(base, { agent: { session: a } }, async () => base);
|
||||
expect(mockSearch.mock.lastCall?.[1].filters).toEqual({ user_id: "u", app_id: appId });
|
||||
const onEvent = listeners.get("session/event")!;
|
||||
mockAdd.mockClear();
|
||||
onEvent(a, { type: "turn/start" });
|
||||
onEvent(a, { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "I prefer short answers. What is a list?" } });
|
||||
onEvent(a, { type: "turn/end", data: { reason: { kind: "completed" } } });
|
||||
await vi.waitFor(() => expect(mockAdd.mock.lastCall?.[1]).toMatchObject({ userId: "u", appId }));
|
||||
expect(mockAdd).toHaveBeenCalledOnce();
|
||||
expect(mockAdd.mock.lastCall?.[0]).toEqual([
|
||||
{ role: "user", content: "I prefer short answers. What is a list?" },
|
||||
]);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("fails closed without a workspace while automatic paths leave the agent running", async () => {
|
||||
const config: Config = { apiKey: "k", userId: "u", memoryScope: "workspace" };
|
||||
const tools = applyAndCollect(config);
|
||||
await expect(tools.get("add_memory")!.execute({ text: "x" }, {})).rejects.toThrow(/workspace/);
|
||||
await expect(tools.get("search_memory")!.execute({ query: "x" }, {})).rejects.toThrow(/workspace/);
|
||||
const listeners = applyAndCollectListeners(config);
|
||||
const session = { header: {}, deriveMessages: () => [{ role: "user", source: { kind: "user" }, content: "hello" }] };
|
||||
const base = { sections: [], contexts: [], tools: [], variables: {} };
|
||||
expect(await listeners.get("system-prompt/assemble")!(base, { agent: { session } }, async () => base)).toBe(base);
|
||||
const onEvent = listeners.get("session/event")!;
|
||||
onEvent(session, { type: "user/message", data: { role: "user", source: { kind: "user" }, content: "hello" } });
|
||||
expect(() => onEvent(session, { type: "turn/end", data: { reason: { kind: "completed" } } })).not.toThrow();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(mockAdd).not.toHaveBeenCalled();
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("installable Harness bundle", () => {
|
||||
it("ships a native activation patch and builds before packing", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const manifest = JSON.parse(readFileSync(new URL("package.json", root), "utf8"));
|
||||
expect(manifest.scripts.prepack).toBe("pnpm build");
|
||||
expect(manifest.dsh.bundle.patch).toBe("./cordis.patch.yml");
|
||||
expect(manifest.files).toContain("cordis.patch.yml");
|
||||
expect(existsSync(new URL(manifest.dsh.bundle.patch, root))).toBe(true);
|
||||
expect(readFileSync(new URL(manifest.dsh.bundle.patch, root), "utf8")).toContain("@mem0/deepseek-plugin");
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@
|
||||
"@qdrant/js-client-rest": "^1.18.0",
|
||||
"uuid@<11.1.1": ">=11.1.1",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<7.29.1": ">=7.29.1 <8.0.0",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0",
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0",
|
||||
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
|
||||
|
||||
Generated
+5
-5
@@ -13,7 +13,7 @@ overrides:
|
||||
'@qdrant/js-client-rest': ^1.18.0
|
||||
uuid@<11.1.1: '>=11.1.1'
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<7.29.1: '>=7.29.1 <8.0.0'
|
||||
undici@<7.29.0: '>=7.29.0 <8.0.0'
|
||||
axios@<1.18.0: '>=1.18.0 <2.0.0'
|
||||
postcss@<8.5.18: '>=8.5.18 <9.0.0'
|
||||
mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0'
|
||||
@@ -2036,8 +2036,8 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici@7.30.0:
|
||||
resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==}
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
@@ -2547,7 +2547,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
||||
typescript: 5.9.3
|
||||
undici: 7.30.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -4121,7 +4121,7 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@7.30.0: {}
|
||||
undici@7.29.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ overrides:
|
||||
"@qdrant/js-client-rest": "^1.18.0"
|
||||
"uuid@<11.1.1": ">=11.1.1"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<7.29.1": ">=7.29.1 <8.0.0"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0"
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0"
|
||||
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
"form-data@<4.0.6": ">=4.0.6",
|
||||
"uuid@<11.1.1": ">=11.1.1",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<7.29.1": ">=7.29.1 <8.0.0",
|
||||
"undici@>=8.0.0 <8.10.2": ">=8.10.2 <9.0.0",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0",
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0",
|
||||
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0",
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0",
|
||||
|
||||
+10
-10
@@ -8,8 +8,8 @@ overrides:
|
||||
form-data@<4.0.6: '>=4.0.6'
|
||||
uuid@<11.1.1: '>=11.1.1'
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<7.29.1: '>=7.29.1 <8.0.0'
|
||||
undici@>=8.0.0 <8.10.2: '>=8.10.2 <9.0.0'
|
||||
undici@<7.29.0: '>=7.29.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0'
|
||||
axios@<1.18.0: '>=1.18.0 <2.0.0'
|
||||
brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0'
|
||||
postcss@<8.5.18: '>=8.5.18 <9.0.0'
|
||||
@@ -2354,12 +2354,12 @@ packages:
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
undici@7.30.0:
|
||||
resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==}
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@8.11.2:
|
||||
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
|
||||
undici@8.10.0:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
@@ -2942,7 +2942,7 @@ snapshots:
|
||||
minimatch: 10.2.5
|
||||
proper-lockfile: 4.1.2
|
||||
typebox: 1.1.38
|
||||
undici: 8.11.2
|
||||
undici: 8.10.0
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@mariozechner/clipboard': 0.3.9
|
||||
@@ -3203,7 +3203,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
||||
typescript: 6.0.3
|
||||
undici: 7.30.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -4895,9 +4895,9 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
undici@7.30.0: {}
|
||||
undici@7.29.0: {}
|
||||
|
||||
undici@8.11.2: {}
|
||||
undici@8.10.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ overrides:
|
||||
"form-data@<4.0.6": ">=4.0.6"
|
||||
"uuid@<11.1.1": ">=11.1.1"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<7.29.1": ">=7.29.1 <8.0.0"
|
||||
"undici@>=8.0.0 <8.10.2": ">=8.10.2 <9.0.0"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0"
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0"
|
||||
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0"
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.0",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -288,7 +288,7 @@
|
||||
"js-yaml@<3.15.1": ">=3.15.1 <4.0.0",
|
||||
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5.0.0",
|
||||
"sharp@<0.35.0": ">=0.35.0 <0.36.0",
|
||||
"adm-zip@<0.6.1": ">=0.6.1 <0.7.0",
|
||||
"adm-zip@<0.6.0": ">=0.6.0 <0.7.0",
|
||||
"langsmith@<0.6.0": "^0.6.0",
|
||||
"minimatch@<3.1.3": "^3.1.3",
|
||||
"minimatch@>=5.0.0 <5.1.8": "^5.1.8",
|
||||
@@ -303,7 +303,7 @@
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.4",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<7.29.1": ">=7.29.1 <8.0.0",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.967.0",
|
||||
"@aws-sdk/client-neptune-graph": "3.966.0",
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0",
|
||||
@@ -315,7 +315,7 @@
|
||||
"thrift@<0.23.0": "^0.23.0",
|
||||
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0",
|
||||
"protobufjs@<7.6.5": ">=7.6.5 <8.0.0",
|
||||
"ip-address@<10.5.1": ">=10.5.1 <11.0.0",
|
||||
"ip-address@<10.3.1": ">=10.3.1 <11.0.0",
|
||||
"browserslist@<4.28.7": ">=4.28.7 <5.0.0",
|
||||
"mysql2@<3.23.1": ">=3.23.1 <4.0.0"
|
||||
}
|
||||
|
||||
Generated
+18
-18
@@ -12,7 +12,7 @@ overrides:
|
||||
js-yaml@<3.15.1: '>=3.15.1 <4.0.0'
|
||||
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5.0.0'
|
||||
sharp@<0.35.0: '>=0.35.0 <0.36.0'
|
||||
adm-zip@<0.6.1: '>=0.6.1 <0.7.0'
|
||||
adm-zip@<0.6.0: '>=0.6.0 <0.7.0'
|
||||
langsmith@<0.6.0: ^0.6.0
|
||||
minimatch@<3.1.3: ^3.1.3
|
||||
minimatch@>=5.0.0 <5.1.8: ^5.1.8
|
||||
@@ -27,7 +27,7 @@ overrides:
|
||||
glob@>=10.2.0 <10.5.0: ^10.5.0
|
||||
'@modelcontextprotocol/sdk': ^1.25.4
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<7.29.1: '>=7.29.1 <8.0.0'
|
||||
undici@<7.29.0: '>=7.29.0 <8.0.0'
|
||||
'@aws-sdk/client-bedrock-runtime': 3.967.0
|
||||
'@aws-sdk/client-neptune-graph': 3.966.0
|
||||
axios@<1.18.0: '>=1.18.0 <2.0.0'
|
||||
@@ -39,7 +39,7 @@ overrides:
|
||||
thrift@<0.23.0: ^0.23.0
|
||||
mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0'
|
||||
protobufjs@<7.6.5: '>=7.6.5 <8.0.0'
|
||||
ip-address@<10.5.1: '>=10.5.1 <11.0.0'
|
||||
ip-address@<10.3.1: '>=10.3.1 <11.0.0'
|
||||
browserslist@<4.28.7: '>=4.28.7 <5.0.0'
|
||||
mysql2@<3.23.1: '>=3.23.1 <4.0.0'
|
||||
|
||||
@@ -2009,8 +2009,8 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adm-zip@0.6.1:
|
||||
resolution: {integrity: sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==}
|
||||
adm-zip@0.6.0:
|
||||
resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==}
|
||||
engines: {node: '>=14.0'}
|
||||
|
||||
afinn-165-financialmarketnews@3.0.0:
|
||||
@@ -3034,8 +3034,8 @@ packages:
|
||||
resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
ip-address@10.7.2:
|
||||
resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
|
||||
ip-address@10.4.0:
|
||||
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
is-arrayish@0.2.1:
|
||||
@@ -4609,8 +4609,8 @@ packages:
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
undici@7.30.0:
|
||||
resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==}
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
update-browserslist-db@1.3.2:
|
||||
@@ -6138,7 +6138,7 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
secure-json-parse: 4.1.0
|
||||
tslib: 2.8.1
|
||||
undici: 7.30.0
|
||||
undici: 7.29.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6706,7 +6706,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
||||
typescript: 5.5.4
|
||||
undici: 7.30.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -7178,7 +7178,7 @@ snapshots:
|
||||
'@turbopuffer/turbopuffer@2.5.0':
|
||||
dependencies:
|
||||
pako: 2.2.0
|
||||
undici: 7.30.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
@@ -7346,7 +7346,7 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
adm-zip@0.6.1: {}
|
||||
adm-zip@0.6.0: {}
|
||||
|
||||
afinn-165-financialmarketnews@3.0.0: {}
|
||||
|
||||
@@ -7614,7 +7614,7 @@ snapshots:
|
||||
cassandra-driver@4.8.0:
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
adm-zip: 0.6.1
|
||||
adm-zip: 0.6.0
|
||||
long: 5.2.5
|
||||
|
||||
chalk-template@0.4.0:
|
||||
@@ -8435,7 +8435,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ip-address@10.7.2: {}
|
||||
ip-address@10.4.0: {}
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
@@ -9291,7 +9291,7 @@ snapshots:
|
||||
|
||||
onnxruntime-node@1.24.3:
|
||||
dependencies:
|
||||
adm-zip: 0.6.1
|
||||
adm-zip: 0.6.0
|
||||
global-agent: 3.0.0
|
||||
onnxruntime-common: 1.24.3
|
||||
|
||||
@@ -9845,7 +9845,7 @@ snapshots:
|
||||
|
||||
socks@2.8.9:
|
||||
dependencies:
|
||||
ip-address: 10.7.2
|
||||
ip-address: 10.4.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
source-map-support@0.5.13:
|
||||
@@ -10182,7 +10182,7 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
undici@7.30.0: {}
|
||||
undici@7.29.0: {}
|
||||
|
||||
update-browserslist-db@1.3.2(browserslist@4.28.9):
|
||||
dependencies:
|
||||
|
||||
@@ -13,7 +13,7 @@ overrides:
|
||||
"js-yaml@<3.15.1": ">=3.15.1 <4.0.0"
|
||||
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5.0.0"
|
||||
"sharp@<0.35.0": ">=0.35.0 <0.36.0"
|
||||
"adm-zip@<0.6.1": ">=0.6.1 <0.7.0"
|
||||
"adm-zip@<0.6.0": ">=0.6.0 <0.7.0"
|
||||
"langsmith@<0.6.0": "^0.6.0"
|
||||
"minimatch@<3.1.3": "^3.1.3"
|
||||
"minimatch@>=5.0.0 <5.1.8": "^5.1.8"
|
||||
@@ -27,7 +27,7 @@ overrides:
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0"
|
||||
"@modelcontextprotocol/sdk": "^1.25.4"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<7.29.1": ">=7.29.1 <8.0.0"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0"
|
||||
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0"
|
||||
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3.0.0"
|
||||
@@ -37,7 +37,7 @@ overrides:
|
||||
"thrift@<0.23.0": "^0.23.0"
|
||||
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
|
||||
"protobufjs@<7.6.5": ">=7.6.5 <8.0.0"
|
||||
"ip-address@<10.5.1": ">=10.5.1 <11.0.0"
|
||||
"ip-address@<10.3.1": ">=10.3.1 <11.0.0"
|
||||
"browserslist@<4.28.7": ">=4.28.7 <5.0.0"
|
||||
"mysql2@<3.23.1": ">=3.23.1 <4.0.0"
|
||||
|
||||
|
||||
@@ -247,54 +247,23 @@ export class TurbopufferDB implements VectorStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Maps mem0's universal filter operators to Turbopuffer's filter tokens.
|
||||
private static readonly OPERATOR_MAP: Record<string, string> = {
|
||||
eq: "Eq",
|
||||
ne: "NotEq",
|
||||
gt: "Gt",
|
||||
gte: "Gte",
|
||||
lt: "Lt",
|
||||
lte: "Lte",
|
||||
in: "In",
|
||||
nin: "NotIn",
|
||||
};
|
||||
|
||||
private convertFilters(filters?: SearchFilters): any {
|
||||
if (!filters || Object.keys(filters).length === 0) return null;
|
||||
|
||||
const conditions: any[] = [];
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
// "*" is a match-any wildcard: it must not constrain the query. The old
|
||||
// code turned it into `[key, "Eq", "*"]`, matching nothing.
|
||||
if (value === "*") {
|
||||
continue;
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
if ("gte" in value) conditions.push([key, "Gte", value.gte]);
|
||||
if ("lte" in value) conditions.push([key, "Lte", value.lte]);
|
||||
if ("gt" in value) conditions.push([key, "Gt", value.gt]);
|
||||
if ("lt" in value) conditions.push([key, "Lt", value.lt]);
|
||||
} else {
|
||||
conditions.push([key, "Eq", value]);
|
||||
}
|
||||
|
||||
// Array shorthand: { key: [a, b] } means "in".
|
||||
if (Array.isArray(value)) {
|
||||
conditions.push([key, "In", value]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
// Operator dict: every operator present must hold. Previously only
|
||||
// `gte` and `lte` were read, so `gt`/`lt`/`ne`/`eq`/`in`/`nin` were
|
||||
// silently dropped and the filter returned unfiltered results.
|
||||
for (const [op, operand] of Object.entries(value)) {
|
||||
const token = TurbopufferDB.OPERATOR_MAP[op];
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Unsupported Turbopuffer filter operator '${op}' for field '${key}'. ` +
|
||||
`Supported operators: ${Object.keys(TurbopufferDB.OPERATOR_MAP).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
conditions.push([key, token, operand]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scalar shorthand: equality.
|
||||
conditions.push([key, "Eq", value]);
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return null;
|
||||
@@ -305,18 +274,7 @@ export class TurbopufferDB implements VectorStore {
|
||||
private parseRows(rows: any[]): VectorStoreResult[] {
|
||||
return rows.map((row) => {
|
||||
const { id, $dist, vector, ...rest } = row;
|
||||
let score: number | undefined;
|
||||
if ($dist == null) {
|
||||
score = undefined;
|
||||
} else if (this.distanceMetric === "euclidean_squared") {
|
||||
// euclidean_squared $dist is an unbounded squared distance, so 1 - $dist
|
||||
// goes negative for any $dist > 1 and inverts ranking. Convert it to a
|
||||
// bounded higher-is-better score, mirroring the milvus/baidu stores.
|
||||
score = 1 / (1 + $dist);
|
||||
} else {
|
||||
// Cosine distance is in [0, 2]; 1 - $dist stays a meaningful similarity.
|
||||
score = 1 - $dist;
|
||||
}
|
||||
const score = $dist != null ? 1 - $dist : undefined;
|
||||
return { id: String(id), payload: rest, score };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Turbopuffer vector store — score conversion unit tests.
|
||||
*
|
||||
* Drives parseRows() through the public search() API with a mocked
|
||||
* @turbopuffer/turbopuffer peer, asserting the score returned per metric.
|
||||
*/
|
||||
|
||||
const mockQuery = jest.fn();
|
||||
|
||||
jest.mock("@turbopuffer/turbopuffer", () => ({
|
||||
__esModule: true,
|
||||
default: class {
|
||||
namespace() {
|
||||
return { query: mockQuery };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { TurbopufferDB } from "../src/vector_stores/turbopuffer";
|
||||
|
||||
function makeStore(distanceMetric?: string) {
|
||||
return new TurbopufferDB({
|
||||
apiKey: "test-key",
|
||||
collectionName: "mem0",
|
||||
...(distanceMetric ? { distanceMetric } : {}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
async function scoreFor(
|
||||
distanceMetric: string | undefined,
|
||||
row: Record<string, any>,
|
||||
): Promise<number | undefined> {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
||||
const results = await makeStore(distanceMetric).search([0.1, 0.2, 0.3], 5);
|
||||
return results[0].score;
|
||||
}
|
||||
|
||||
describe("TurbopufferDB score conversion", () => {
|
||||
it("keeps cosine distance as 1 - dist (default metric)", async () => {
|
||||
// cosine_distance is the default; a distance of 0.25 -> similarity 0.75.
|
||||
expect(await scoreFor(undefined, { id: "a", $dist: 0.25 })).toBeCloseTo(
|
||||
0.75,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds an unbounded euclidean_squared distance to a 0..1 similarity", async () => {
|
||||
// $dist = 4.0 is a squared distance. 1 - 4.0 = -3.0 would invert ranking;
|
||||
// 1 / (1 + 4.0) = 0.2 keeps it higher-is-better and in range.
|
||||
const score = await scoreFor("euclidean_squared", { id: "a", $dist: 4.0 });
|
||||
expect(score).toBeCloseTo(0.2, 10);
|
||||
expect(score!).toBeGreaterThanOrEqual(0);
|
||||
expect(score!).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("ranks a nearer euclidean_squared hit above a farther one", async () => {
|
||||
mockQuery.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ id: "near", $dist: 1.0 },
|
||||
{ id: "far", $dist: 9.0 },
|
||||
],
|
||||
});
|
||||
const results = await makeStore("euclidean_squared").search(
|
||||
[0.1, 0.2, 0.3],
|
||||
5,
|
||||
);
|
||||
const near = results.find((r) => r.id === "near")!;
|
||||
const far = results.find((r) => r.id === "far")!;
|
||||
expect(near.score!).toBeGreaterThan(far.score!);
|
||||
});
|
||||
|
||||
it("preserves an undefined score when the row has no distance", async () => {
|
||||
expect(await scoreFor("euclidean_squared", { id: "a" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Turbopuffer vector store — filter translation unit tests.
|
||||
*
|
||||
* Drives the private convertFilters() through the public search() API with a
|
||||
* mocked @turbopuffer/turbopuffer peer, and asserts the filter tuple handed to
|
||||
* ns.query().
|
||||
*/
|
||||
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] });
|
||||
|
||||
// createClient() does `new sdk.default({...})`, whose namespace() returns the
|
||||
// object search() calls query() on.
|
||||
jest.mock("@turbopuffer/turbopuffer", () => ({
|
||||
__esModule: true,
|
||||
default: class {
|
||||
namespace() {
|
||||
return { query: mockQuery };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { TurbopufferDB } from "../src/vector_stores/turbopuffer";
|
||||
|
||||
function makeStore() {
|
||||
return new TurbopufferDB({
|
||||
apiKey: "test-key",
|
||||
collectionName: "mem0",
|
||||
} as any);
|
||||
}
|
||||
|
||||
async function filterFor(filters: any): Promise<any> {
|
||||
mockQuery.mockClear();
|
||||
await makeStore().search([0.1, 0.2, 0.3], 5, filters);
|
||||
return mockQuery.mock.calls[0][0].filters;
|
||||
}
|
||||
|
||||
describe("TurbopufferDB convertFilters", () => {
|
||||
it("maps every operator, not just gte/lte", async () => {
|
||||
expect(await filterFor({ age: { gt: 18 } })).toEqual(["age", "Gt", 18]);
|
||||
expect(await filterFor({ age: { lt: 65 } })).toEqual(["age", "Lt", 65]);
|
||||
expect(await filterFor({ age: { ne: 40 } })).toEqual(["age", "NotEq", 40]);
|
||||
expect(await filterFor({ role: { eq: "admin" } })).toEqual([
|
||||
"role",
|
||||
"Eq",
|
||||
"admin",
|
||||
]);
|
||||
expect(await filterFor({ tier: { in: ["a", "b"] } })).toEqual([
|
||||
"tier",
|
||||
"In",
|
||||
["a", "b"],
|
||||
]);
|
||||
expect(await filterFor({ tier: { nin: ["x"] } })).toEqual([
|
||||
"tier",
|
||||
"NotIn",
|
||||
["x"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies every operator in a compound range (AND), not just the first", async () => {
|
||||
const filter = await filterFor({ age: { gt: 18, lt: 65 } });
|
||||
expect(filter[0]).toBe("And");
|
||||
expect(filter[1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
["age", "Gt", 18],
|
||||
["age", "Lt", 65],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a bare array value as an 'in' filter", async () => {
|
||||
expect(await filterFor({ tier: ["gold", "silver"] })).toEqual([
|
||||
"tier",
|
||||
"In",
|
||||
["gold", "silver"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps scalar equality working", async () => {
|
||||
expect(await filterFor({ user_id: "u1" })).toEqual(["user_id", "Eq", "u1"]);
|
||||
});
|
||||
|
||||
it("skips a '*' wildcard value instead of matching it literally", async () => {
|
||||
// Only the real agent_id clause survives; user_id: "*" contributes nothing.
|
||||
expect(await filterFor({ user_id: "*", agent_id: "a1" })).toEqual([
|
||||
"agent_id",
|
||||
"Eq",
|
||||
"a1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws on an unsupported operator rather than silently dropping it", async () => {
|
||||
await expect(
|
||||
makeStore().search([0.1, 0.2, 0.3], 5, { name: { startsWith: "a" } }),
|
||||
).rejects.toThrow(/Unsupported Turbopuffer filter operator 'startsWith'/);
|
||||
});
|
||||
});
|
||||
@@ -579,20 +579,11 @@ class AWSBedrockLLM(LLMBase):
|
||||
# Use converse API for Anthropic models
|
||||
response = self.client.converse(**converse_params)
|
||||
|
||||
# Parse Converse API response. Claude reasoning models can emit a
|
||||
# `reasoningContent` block before the `text` block, so iterate to
|
||||
# find the first block that carries text instead of indexing
|
||||
# content[0] (same approach as the MiniMax branch below).
|
||||
# Parse Converse API response
|
||||
if hasattr(response, 'output') and hasattr(response.output, 'message'):
|
||||
for block in response.output.message.content:
|
||||
if hasattr(block, 'text'):
|
||||
return block.text
|
||||
return ""
|
||||
return response.output.message.content[0].text
|
||||
elif 'output' in response and 'message' in response['output']:
|
||||
for block in response['output']['message']['content']:
|
||||
if 'text' in block:
|
||||
return block['text']
|
||||
return ""
|
||||
return response['output']['message']['content'][0]['text']
|
||||
else:
|
||||
return str(response)
|
||||
|
||||
|
||||
@@ -69,16 +69,6 @@ class S3Vectors(VectorStoreBase):
|
||||
else:
|
||||
raise
|
||||
|
||||
def _distance_to_score(self, raw_distance: Optional[float]) -> Optional[float]:
|
||||
if raw_distance is None:
|
||||
return None
|
||||
# Euclidean distance is unbounded, so 1 - distance would collapse most
|
||||
# scores to 0. Use a bounded monotonic map instead. Cosine distance is
|
||||
# in [0, 2], where 1 - distance is already a valid similarity.
|
||||
if self.distance_metric == "euclidean":
|
||||
return 1.0 / (1.0 + raw_distance)
|
||||
return max(0.0, 1.0 - raw_distance)
|
||||
|
||||
def _parse_output(self, vectors: List[Dict]) -> List[OutputData]:
|
||||
results = []
|
||||
for v in vectors:
|
||||
@@ -91,7 +81,7 @@ class S3Vectors(VectorStoreBase):
|
||||
logger.warning(f"Failed to parse metadata for key {v.get('key')}")
|
||||
payload = {}
|
||||
raw_distance = v.get("distance")
|
||||
score = self._distance_to_score(raw_distance)
|
||||
score = max(0.0, 1.0 - raw_distance) if raw_distance is not None else None
|
||||
results.append(OutputData(id=v.get("key"), score=score, payload=payload))
|
||||
return results
|
||||
|
||||
|
||||
@@ -122,14 +122,7 @@ class TurbopufferDB(VectorStoreBase):
|
||||
dist = row_dict.pop("$dist", None)
|
||||
row_dict.pop("vector", None)
|
||||
|
||||
if dist is None:
|
||||
score = None
|
||||
elif self.distance_metric == "euclidean_squared":
|
||||
# $dist is unbounded squared-L2 (lower = closer); map to a bounded
|
||||
# higher-is-better score, mirroring milvus/baidu. Cosine returns 1 - dist.
|
||||
score = 1.0 / (1.0 + dist)
|
||||
else:
|
||||
score = 1 - dist
|
||||
score = 1 - dist if dist is not None else None
|
||||
|
||||
results.append(OutputData(
|
||||
id=row_id,
|
||||
@@ -138,18 +131,6 @@ class TurbopufferDB(VectorStoreBase):
|
||||
))
|
||||
return results
|
||||
|
||||
# Maps mem0 filter operators to their Turbopuffer equivalents.
|
||||
OPERATOR_MAP = {
|
||||
"eq": "Eq",
|
||||
"ne": "NotEq",
|
||||
"gt": "Gt",
|
||||
"gte": "Gte",
|
||||
"lt": "Lt",
|
||||
"lte": "Lte",
|
||||
"in": "In",
|
||||
"nin": "NotIn",
|
||||
}
|
||||
|
||||
def _convert_filters(self, filters: Optional[Dict]):
|
||||
"""
|
||||
Convert mem0 filters to Turbopuffer filter format.
|
||||
@@ -162,14 +143,10 @@ class TurbopufferDB(VectorStoreBase):
|
||||
conditions = []
|
||||
for key, value in filters.items():
|
||||
if isinstance(value, dict):
|
||||
for op, operand in value.items():
|
||||
tpuf_op = self.OPERATOR_MAP.get(op)
|
||||
if tpuf_op is None:
|
||||
raise ValueError(
|
||||
f"Unsupported filter operator '{op}' for field '{key}'. "
|
||||
f"Supported operators: {sorted(self.OPERATOR_MAP)}"
|
||||
)
|
||||
conditions.append((key, tpuf_op, operand))
|
||||
if "gte" in value:
|
||||
conditions.append((key, "Gte", value["gte"]))
|
||||
if "lte" in value:
|
||||
conditions.append((key, "Lte", value["lte"]))
|
||||
else:
|
||||
conditions.append((key, "Eq", value))
|
||||
|
||||
|
||||
+2
-10
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0ai"
|
||||
version = "2.2.1"
|
||||
version = "2.2.0"
|
||||
description = "Long-term memory for AI Agents"
|
||||
authors = [
|
||||
{ name = "Mem0", email = "support@mem0.ai" }
|
||||
@@ -83,7 +83,7 @@ test = [
|
||||
dev = [
|
||||
"ruff==0.16.0",
|
||||
"isort>=5.13.2",
|
||||
"pre-commit>=3.5.0",
|
||||
"pytest>=8.2.2",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -111,7 +111,6 @@ features = [
|
||||
"vector-stores",
|
||||
"llms",
|
||||
"extras",
|
||||
"dev",
|
||||
]
|
||||
|
||||
[tool.hatch.envs.dev_py_3_11]
|
||||
@@ -121,7 +120,6 @@ features = [
|
||||
"vector-stores",
|
||||
"llms",
|
||||
"extras",
|
||||
"dev",
|
||||
]
|
||||
|
||||
[tool.hatch.envs.dev_py_3_12]
|
||||
@@ -131,12 +129,6 @@ features = [
|
||||
"vector-stores",
|
||||
"llms",
|
||||
"extras",
|
||||
"dev",
|
||||
]
|
||||
|
||||
[tool.hatch.envs.default]
|
||||
features = [
|
||||
"dev",
|
||||
]
|
||||
|
||||
[tool.hatch.envs.default.scripts]
|
||||
|
||||
@@ -506,51 +506,3 @@ class TestParseResponseLegacy:
|
||||
response = {"body": body}
|
||||
result = llm._parse_response(response, tools=None)
|
||||
assert result == "hello from ai21"
|
||||
|
||||
|
||||
class TestAnthropicConverseContentParsing:
|
||||
"""The Anthropic Converse branch must not assume content[0] is the text
|
||||
block: Claude reasoning models emit a reasoningContent block before the
|
||||
text block, and some stop conditions produce an empty content array. The
|
||||
parser iterates for the first block carrying text, like the MiniMax branch.
|
||||
"""
|
||||
|
||||
def test_text_after_reasoning_content_block(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = {
|
||||
"output": {
|
||||
"message": {
|
||||
"content": [
|
||||
{"reasoningContent": {"reasoningText": {"text": "step by step..."}}},
|
||||
{"text": "final answer"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
||||
|
||||
assert llm.generate_response(MESSAGES) == "final answer"
|
||||
|
||||
def test_empty_content_returns_empty_string(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = {"output": {"message": {"content": []}}}
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
||||
|
||||
assert llm.generate_response(MESSAGES) == ""
|
||||
|
||||
def test_plain_text_content_still_returned(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = _converse_response("plain answer")
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
||||
|
||||
assert llm.generate_response(MESSAGES) == "plain answer"
|
||||
|
||||
def test_object_style_response_iterates_blocks(self, mock_boto3):
|
||||
# Defensive attr-style branch: object wrapper with a reasoning block first.
|
||||
from types import SimpleNamespace
|
||||
|
||||
reasoning_block = SimpleNamespace(reasoningContent={"reasoningText": {"text": "hmm"}})
|
||||
text_block = SimpleNamespace(text="object answer")
|
||||
mock_boto3.converse.return_value = SimpleNamespace(
|
||||
output=SimpleNamespace(message=SimpleNamespace(content=[reasoning_block, text_block]))
|
||||
)
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
||||
|
||||
assert llm.generate_response(MESSAGES) == "object answer"
|
||||
|
||||
@@ -186,45 +186,6 @@ def test_search(mock_boto_client):
|
||||
assert results[0].score == pytest.approx(0.1)
|
||||
|
||||
|
||||
def test_search_score_cosine_uses_one_minus_distance(mock_boto_client):
|
||||
"""Cosine metric keeps the 1 - distance similarity mapping."""
|
||||
mock_boto_client.query_vectors.return_value = {"vectors": [{"key": "id1", "distance": 0.25, "metadata": {}}]}
|
||||
store = S3Vectors(
|
||||
vector_bucket_name=BUCKET_NAME,
|
||||
collection_name=INDEX_NAME,
|
||||
embedding_model_dims=EMBEDDING_DIMS,
|
||||
distance_metric="cosine",
|
||||
)
|
||||
|
||||
results = store.search(query="test", vectors=[0.1, 0.2], top_k=1)
|
||||
|
||||
assert results[0].score == pytest.approx(0.75)
|
||||
|
||||
|
||||
def test_search_score_euclidean_uses_bounded_map(mock_boto_client):
|
||||
"""Euclidean distance is unbounded; scores must not collapse to 0."""
|
||||
mock_boto_client.query_vectors.return_value = {
|
||||
"vectors": [
|
||||
{"key": "near", "distance": 0.5, "metadata": {}},
|
||||
{"key": "far", "distance": 4.0, "metadata": {}},
|
||||
]
|
||||
}
|
||||
store = S3Vectors(
|
||||
vector_bucket_name=BUCKET_NAME,
|
||||
collection_name=INDEX_NAME,
|
||||
embedding_model_dims=EMBEDDING_DIMS,
|
||||
distance_metric="euclidean",
|
||||
)
|
||||
|
||||
results = store.search(query="test", vectors=[0.1, 0.2], top_k=2)
|
||||
|
||||
# 1 / (1 + d): a distance > 1 would give a negative (clamped 0) score under
|
||||
# the old cosine-only formula; the bounded map keeps ranking intact.
|
||||
assert results[0].score == pytest.approx(1.0 / 1.5)
|
||||
assert results[1].score == pytest.approx(1.0 / 5.0)
|
||||
assert results[0].score > results[1].score > 0.0
|
||||
|
||||
|
||||
def test_get(mock_boto_client):
|
||||
"""Test retrieving a vector by ID."""
|
||||
mock_boto_client.get_vectors.return_value = {
|
||||
|
||||
@@ -232,35 +232,6 @@ class TestParseOutput:
|
||||
def test_parse_empty_rows(self, db):
|
||||
assert db._parse_output([]) == []
|
||||
|
||||
def test_parse_cosine_score_is_one_minus_dist(self, db):
|
||||
# Default cosine metric: score = 1 - dist, unchanged by the metric fix.
|
||||
results = db._parse_output([_make_row("id1", dist=0.25)])
|
||||
assert results[0].score == pytest.approx(0.75)
|
||||
|
||||
def test_parse_euclidean_squared_score_is_bounded(self, mock_client):
|
||||
# euclidean_squared $dist is unbounded (e.g. 4.0). 1 - dist would give -3.0,
|
||||
# violating the higher-is-better contract; map it to 1/(1+dist) instead.
|
||||
db = TurbopufferDB(
|
||||
collection_name="test_ns",
|
||||
embedding_model_dims=4,
|
||||
api_key="tpuf_test_key",
|
||||
region="gcp-us-central1",
|
||||
distance_metric="euclidean_squared",
|
||||
)
|
||||
results = db._parse_output([_make_row("id1", dist=4.0)])
|
||||
assert results[0].score == pytest.approx(0.2)
|
||||
assert 0.0 <= results[0].score <= 1.0
|
||||
|
||||
def test_parse_euclidean_squared_preserves_none(self, mock_client):
|
||||
db = TurbopufferDB(
|
||||
collection_name="test_ns",
|
||||
embedding_model_dims=4,
|
||||
api_key="tpuf_test_key",
|
||||
region="gcp-us-central1",
|
||||
distance_metric="euclidean_squared",
|
||||
)
|
||||
assert db._parse_output([_make_row("id1")])[0].score is None
|
||||
|
||||
|
||||
# ── _convert_filters ─────────────────────────────────────────────────
|
||||
|
||||
@@ -305,40 +276,6 @@ class TestConvertFilters:
|
||||
assert ("user_id", "Eq", "u1") in conditions
|
||||
assert ("score", "Gte", 0.5) in conditions
|
||||
|
||||
def test_gt_operator_not_dropped(self, db):
|
||||
"""Regression: {"gt": ...} was silently dropped, returning unfiltered results."""
|
||||
result = db._convert_filters({"age": {"gt": 18}})
|
||||
assert result == ("age", "Gt", 18)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"op,expected_token",
|
||||
[
|
||||
("eq", "Eq"),
|
||||
("ne", "NotEq"),
|
||||
("gt", "Gt"),
|
||||
("gte", "Gte"),
|
||||
("lt", "Lt"),
|
||||
("lte", "Lte"),
|
||||
("in", "In"),
|
||||
("nin", "NotIn"),
|
||||
],
|
||||
)
|
||||
def test_all_operators_mapped(self, db, op, expected_token):
|
||||
operand = [1, 2] if op in ("in", "nin") else 5
|
||||
result = db._convert_filters({"age": {op: operand}})
|
||||
assert result == ("age", expected_token, operand)
|
||||
|
||||
def test_multiple_operators_on_one_field(self, db):
|
||||
result = db._convert_filters({"age": {"gt": 18, "lt": 65}})
|
||||
assert result[0] == "And"
|
||||
conditions = result[1]
|
||||
assert ("age", "Gt", 18) in conditions
|
||||
assert ("age", "Lt", 65) in conditions
|
||||
|
||||
def test_unknown_operator_raises(self, db):
|
||||
with pytest.raises(ValueError, match="Unsupported filter operator"):
|
||||
db._convert_filters({"age": {"between": [1, 2]}})
|
||||
|
||||
|
||||
# ── search ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user