Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c224425d3 |
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./integrations/mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||
"version": "0.2.14"
|
||||
"version": "0.2.13"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./integrations/mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
||||
"version": "0.2.14"
|
||||
"version": "0.2.13"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"version": "1",
|
||||
"plugins": [
|
||||
{
|
||||
"id": "mem0",
|
||||
"displayName": "Mem0",
|
||||
"version": "0.1.0",
|
||||
"description": "Persistent memory for Kimi Code. Remembers decisions, patterns, and preferences across sessions.",
|
||||
"homepage": "https://mem0.ai",
|
||||
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
|
||||
"source": "https://github.com/mem0ai/mem0/tree/main/integrations/mem0-plugin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
# Node CLI (`cli/node/`)
|
||||
|
||||
The `@mem0/cli` package on npm. Commander-based, entry point `mem0`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build # tsup (ESM)
|
||||
pnpm run lint # biome check src/
|
||||
pnpm run lint:fix # biome check --write src/
|
||||
pnpm run typecheck # tsc --noEmit
|
||||
pnpm run test # vitest run
|
||||
pnpm run test:watch
|
||||
pnpm run dev # tsx src/index.ts
|
||||
```
|
||||
|
||||
pnpm only. Never npm, never yarn.
|
||||
|
||||
## Conventions
|
||||
|
||||
> **Biome, not ESLint. vitest, not jest.** `mem0-ts/` uses Prettier + jest and
|
||||
> `integrations/vercel-ai-sdk/` uses ESLint + jest. Running those tools here produces
|
||||
> spurious diffs. Every toolchain in this repo is per-package.
|
||||
|
||||
- **Node 18+** required.
|
||||
- **Build:** tsup, ESM output only.
|
||||
- **Linter and formatter:** Biome, configured in `biome.json`.
|
||||
- **Tests:** vitest.
|
||||
- **TypeScript strict mode.** ES module `import` syntax only, never `require()`.
|
||||
|
||||
Run `pnpm run typecheck` after every change.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Commander + Chalk + ora + cli-table3, and `mem0ai` (npm) for API calls.
|
||||
|
||||
## CI and release
|
||||
|
||||
- CI: `cli-node-ci.yml`, Biome + tsc + vitest + tsup build on Node 20 and 22.
|
||||
- Release: tag prefix `cli-node-v*` dispatches `cli-node-cd.yml`, publishing to npm over OIDC.
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.11",
|
||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface AddOptions {
|
||||
infer?: boolean;
|
||||
expires?: string;
|
||||
customInstructions?: string;
|
||||
agentCustomInstructions?: string;
|
||||
customCategories?: Record<string, string>[];
|
||||
structuredDataSchema?: Record<string, unknown>;
|
||||
timestamp?: number;
|
||||
|
||||
@@ -153,8 +153,6 @@ export class PlatformBackend implements Backend {
|
||||
if (opts.expires) payload.expiration_date = opts.expires;
|
||||
if (opts.customInstructions)
|
||||
payload.custom_instructions = opts.customInstructions;
|
||||
if (opts.agentCustomInstructions)
|
||||
payload.agent_custom_instructions = opts.agentCustomInstructions;
|
||||
if (opts.customCategories)
|
||||
payload.custom_categories = opts.customCategories;
|
||||
if (opts.structuredDataSchema)
|
||||
|
||||
@@ -53,7 +53,6 @@ export async function cmdAdd(
|
||||
expires?: string;
|
||||
categories?: string;
|
||||
customInstructions?: string;
|
||||
agentCustomInstructions?: string;
|
||||
customCategories?: string;
|
||||
structuredDataSchema?: string;
|
||||
timestamp?: number;
|
||||
@@ -154,7 +153,6 @@ export async function cmdAdd(
|
||||
infer: opts.infer !== false,
|
||||
expires: opts.expires,
|
||||
customInstructions: opts.customInstructions,
|
||||
agentCustomInstructions: opts.agentCustomInstructions,
|
||||
customCategories: customCats,
|
||||
structuredDataSchema: schema,
|
||||
timestamp: opts.timestamp,
|
||||
|
||||
@@ -328,10 +328,6 @@ program
|
||||
"--custom-instructions <text>",
|
||||
"Custom instructions for fact extraction.",
|
||||
)
|
||||
.option(
|
||||
"--agent-custom-instructions <text>",
|
||||
"Extraction instructions for agent-scoped memories, overriding the project setting.",
|
||||
)
|
||||
.option(
|
||||
"--custom-categories <json>",
|
||||
"Custom categories as a JSON array of {name: description} objects.",
|
||||
|
||||
@@ -50,7 +50,6 @@ const ADD_MAPPING: Record<string, string[]> = {
|
||||
metadata: ["--metadata"],
|
||||
expiration_date: ["--expires"],
|
||||
custom_instructions: ["--custom-instructions"],
|
||||
agent_custom_instructions: ["--agent-custom-instructions"],
|
||||
custom_categories: ["--custom-categories"],
|
||||
infer: ["--no-infer"],
|
||||
immutable: ["--immutable"],
|
||||
|
||||
@@ -84,7 +84,6 @@ describe("PlatformBackend option-parity payloads (MEM-5893)", () => {
|
||||
metadata: { source: "test" },
|
||||
expires: "2099-01-01",
|
||||
customInstructions: "Extract only preferences.",
|
||||
agentCustomInstructions: "Extract only tool outcomes.",
|
||||
customCategories: [{ prefs: "user preferences" }],
|
||||
structuredDataSchema: { type: "object" },
|
||||
timestamp: 1700000000,
|
||||
@@ -92,9 +91,6 @@ describe("PlatformBackend option-parity payloads (MEM-5893)", () => {
|
||||
|
||||
const payload = spy.mock.calls[0][2].json;
|
||||
expect(payload.custom_instructions).toBe("Extract only preferences.");
|
||||
expect(payload.agent_custom_instructions).toBe(
|
||||
"Extract only tool outcomes.",
|
||||
);
|
||||
expect(payload.custom_categories).toEqual([{ prefs: "user preferences" }]);
|
||||
expect(payload.structured_data_schema).toEqual({ type: "object" });
|
||||
expect(payload.timestamp).toBe(1700000000);
|
||||
@@ -113,7 +109,6 @@ describe("PlatformBackend option-parity payloads (MEM-5893)", () => {
|
||||
|
||||
const payload = spy.mock.calls[0][2].json;
|
||||
expect(payload).not.toHaveProperty("custom_instructions");
|
||||
expect(payload).not.toHaveProperty("agent_custom_instructions");
|
||||
expect(payload).not.toHaveProperty("custom_categories");
|
||||
expect(payload).not.toHaveProperty("structured_data_schema");
|
||||
expect(payload).not.toHaveProperty("timestamp");
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Python CLI (`cli/python/`)
|
||||
|
||||
The `mem0-cli` package on PyPI. Typer-based, entry point `mem0`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]" # dev install: ruff + pytest
|
||||
ruff check . # lint
|
||||
ruff format . # format
|
||||
pytest # test
|
||||
hatch build # build
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
> **Line length is 100 here, not 120.** The root Python SDK uses 120. Running the root
|
||||
> `make format` over this directory reformats every file and fails CI. Use the local
|
||||
> `ruff` invocations above.
|
||||
|
||||
- **Python 3.10+.** Not 3.9, unlike the root SDK.
|
||||
- **Ruff** with an extended rule set: `E`, `F`, `I`, `W`, `UP`, `B`, `SIM`, `RUF`.
|
||||
Ignores `E501` (formatter handles it), `B008` (required by Typer's argument defaults),
|
||||
and `SIM108`.
|
||||
- **Ruff format:** double quotes, space indent, `docstring-code-format = true`.
|
||||
- **isort** first-party is `mem0_cli` only.
|
||||
- **pytest** for tests.
|
||||
- Target version pinned to `py310`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
cli/python/
|
||||
├── src/mem0_cli/ package source (src layout)
|
||||
└── tests/
|
||||
```
|
||||
|
||||
Entry point: `mem0 = "mem0_cli.app:main"`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Typer + Rich + httpx. `mem0ai` is **optional**, exposed through the `[oss]` extra for OSS mode. Do not promote it to a required dependency.
|
||||
|
||||
## CI and release
|
||||
|
||||
- CI: `cli-python-ci.yml`, ruff + pytest + `hatch build` on Python 3.10, 3.11, 3.12.
|
||||
- Release: tag prefix `cli-v*` dispatches `cli-python-cd.yml`, publishing to PyPI over OIDC.
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0-cli"
|
||||
version = "0.2.11"
|
||||
version = "0.2.10"
|
||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
||||
|
||||
__version__ = "0.2.11"
|
||||
__version__ = "0.2.10"
|
||||
|
||||
@@ -278,11 +278,6 @@ def add(
|
||||
custom_instructions: str | None = typer.Option(
|
||||
None, "--custom-instructions", help="Custom instructions for fact extraction."
|
||||
),
|
||||
agent_custom_instructions: str | None = typer.Option(
|
||||
None,
|
||||
"--agent-custom-instructions",
|
||||
help="Extraction instructions for agent-scoped memories, overriding the project setting.",
|
||||
),
|
||||
custom_categories: str | None = typer.Option(
|
||||
None,
|
||||
"--custom-categories",
|
||||
@@ -332,7 +327,6 @@ def add(
|
||||
expires=expires,
|
||||
categories=categories,
|
||||
custom_instructions=custom_instructions,
|
||||
agent_custom_instructions=agent_custom_instructions,
|
||||
custom_categories=custom_categories,
|
||||
structured_data_schema=structured_data_schema,
|
||||
timestamp=timestamp,
|
||||
@@ -1145,7 +1139,6 @@ def _build_help_json() -> dict:
|
||||
"--expires": "Expiration date (YYYY-MM-DD).",
|
||||
"--categories": "Not supported on add, use --custom-categories instead.",
|
||||
"--custom-instructions": "Custom instructions for fact extraction.",
|
||||
"--agent-custom-instructions": "Extraction instructions for agent-scoped memories, overriding the project setting.",
|
||||
"--custom-categories": "Custom categories as a JSON array of {name: description} objects.",
|
||||
"--structured-data-schema": "Schema for structured data extraction, as JSON.",
|
||||
"--timestamp": "Unix timestamp for the memory.",
|
||||
|
||||
@@ -26,7 +26,6 @@ class Backend(ABC):
|
||||
infer: bool = True,
|
||||
expires: str | None = None,
|
||||
custom_instructions: str | None = None,
|
||||
agent_custom_instructions: str | None = None,
|
||||
custom_categories: list[dict] | None = None,
|
||||
structured_data_schema: dict | None = None,
|
||||
timestamp: int | None = None,
|
||||
|
||||
@@ -88,7 +88,6 @@ class PlatformBackend(Backend):
|
||||
infer: bool = True,
|
||||
expires: str | None = None,
|
||||
custom_instructions: str | None = None,
|
||||
agent_custom_instructions: str | None = None,
|
||||
custom_categories: list[dict] | None = None,
|
||||
structured_data_schema: dict | None = None,
|
||||
timestamp: int | None = None,
|
||||
@@ -118,8 +117,6 @@ class PlatformBackend(Backend):
|
||||
payload["expiration_date"] = expires
|
||||
if custom_instructions:
|
||||
payload["custom_instructions"] = custom_instructions
|
||||
if agent_custom_instructions:
|
||||
payload["agent_custom_instructions"] = agent_custom_instructions
|
||||
if custom_categories:
|
||||
payload["custom_categories"] = custom_categories
|
||||
if structured_data_schema:
|
||||
|
||||
@@ -77,7 +77,6 @@ def cmd_add(
|
||||
expires: str | None,
|
||||
categories: str | None,
|
||||
custom_instructions: str | None = None,
|
||||
agent_custom_instructions: str | None = None,
|
||||
custom_categories: str | None = None,
|
||||
structured_data_schema: str | None = None,
|
||||
timestamp: int | None = None,
|
||||
@@ -167,7 +166,6 @@ def cmd_add(
|
||||
infer=not no_infer,
|
||||
expires=expires,
|
||||
custom_instructions=custom_instructions,
|
||||
agent_custom_instructions=agent_custom_instructions,
|
||||
custom_categories=custom_cats,
|
||||
structured_data_schema=schema,
|
||||
timestamp=timestamp,
|
||||
|
||||
@@ -38,7 +38,6 @@ ADD_MAPPING: dict[str, list[str]] = {
|
||||
"metadata": ["metadata"],
|
||||
"expiration_date": ["expires"],
|
||||
"custom_instructions": ["custom_instructions"],
|
||||
"agent_custom_instructions": ["agent_custom_instructions"],
|
||||
"custom_categories": ["custom_categories"],
|
||||
"infer": ["no_infer"],
|
||||
"immutable": ["immutable"],
|
||||
|
||||
@@ -22,14 +22,12 @@ class TestAddOptions:
|
||||
metadata={"source": "test"},
|
||||
expires="2099-01-01",
|
||||
custom_instructions="Extract only preferences.",
|
||||
agent_custom_instructions="Extract only tool outcomes.",
|
||||
custom_categories=[{"prefs": "user preferences"}],
|
||||
structured_data_schema={"type": "object"},
|
||||
timestamp=1700000000,
|
||||
)
|
||||
payload = mock_request.call_args.kwargs["json"]
|
||||
assert payload["custom_instructions"] == "Extract only preferences."
|
||||
assert payload["agent_custom_instructions"] == "Extract only tool outcomes."
|
||||
assert payload["custom_categories"] == [{"prefs": "user preferences"}]
|
||||
assert payload["structured_data_schema"] == {"type": "object"}
|
||||
assert payload["timestamp"] == 1700000000
|
||||
@@ -42,7 +40,6 @@ class TestAddOptions:
|
||||
backend.add(content="hello", user_id="alice")
|
||||
payload = mock_request.call_args.kwargs["json"]
|
||||
assert "custom_instructions" not in payload
|
||||
assert "agent_custom_instructions" not in payload
|
||||
assert "custom_categories" not in payload
|
||||
assert "structured_data_schema" not in payload
|
||||
assert "timestamp" not in payload
|
||||
|
||||
@@ -95,12 +95,6 @@ client.project.update(
|
||||
custom_instructions="..."
|
||||
)
|
||||
|
||||
# Separate extraction instructions for agent-scoped memories
|
||||
# (see /platform/features/custom-instructions)
|
||||
client.project.update(
|
||||
agent_custom_instructions="..."
|
||||
)
|
||||
|
||||
# Use the input language for memory storage and retrieval
|
||||
client.project.update(multilingual=True)
|
||||
|
||||
|
||||
@@ -7,43 +7,6 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-08-11" description="v2.0.18">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** Percent-escape `%`, `&`, and `=` in `user_id`, `agent_id`, and `run_id` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892))
|
||||
- **Vector Stores:** Raise `ValueError` when a PGVector `in`/`nin` filter value is not a list. A string value was previously iterated character by character into the generated `= ANY(...)` array (so `{"user_id": {"in": "alice"}}` matched `a`, `l`, `i`, `c`, `e`), and a non-iterable value raised a bare `TypeError` from deep inside filter building ([#6879](https://github.com/mem0ai/mem0/pull/6879))
|
||||
- **Vector Stores:** Reject `index_accuracy=0` in the Oracle AI Vector Search config. The range check sat behind a truthiness test, so `0` skipped validation entirely and was passed through to `WITH TARGET ACCURACY 0` instead of raising ([#6848](https://github.com/mem0ai/mem0/pull/6848))
|
||||
- **Vector Stores:** Close the Oracle connection or pool that Mem0 opened when initialization fails. A client version check, a database version check, or a `create_col()` error previously propagated with the connection still open, leaking it for the life of the process. A caller-supplied `client` is left untouched ([#6839](https://github.com/mem0ai/mem0/pull/6839))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-05" description="v2.0.17">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add `agent_custom_instructions` to `project.update()`/`update_project()` (sync and async) and to the `ProjectUpdateOptions` and `AddMemoryOptions` typed models. It sets a second extraction instruction set that applies only to agent-scoped memories: an add passing `agent_id` without `user_id` uses it, one passing both splits by attribution, and while it is unset `custom_instructions` continues to apply to every memory ([#6809](https://github.com/mem0ai/mem0/pull/6809))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-04" description="v2.0.16">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add `reference_date`, `latest_only`, and `keyword_search` to `SearchMemoryOptions`, and `latest_only` to `GetAllMemoryOptions`, keeping the Python client's typed options in sync with the Platform API and the CLIs ([#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** Stop `add()` metadata from setting a memory's identity scope. `_build_filters_and_metadata()` now strips `user_id`, `agent_id`, `run_id`, and `actor_id` from caller-supplied `metadata` before building the creation template, so metadata can no longer place a memory into a scope that was never passed through the entity params ([#6656](https://github.com/mem0ai/mem0/pull/6656))
|
||||
- **Vector Stores:** Validate Upstash filter keys and values in `search()`, `keyword_search()`, and `list()`. Filter keys must match a safe identifier pattern, values must be `str`/`int`/`float`/`bool`, and string values containing a double quote or backslash are now rejected instead of being interpolated unescaped into the generated query string ([#5981](https://github.com/mem0ai/mem0/pull/5981))
|
||||
- **Embeddings:** `FastEmbedEmbedding.embed()` now converts its result with `.tolist()` before returning, so callers get a plain `List[float]` instead of a numpy array ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Embeddings:** `HuggingFaceEmbedding` now passes `api_key` to the OpenAI-compatible client when `huggingface_base_url` is set. The configured key was previously dropped, so the client fell back to `OPENAI_API_KEY` from the environment or raised `OpenAIError` at construction when that was unset ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Embeddings:** Replace Ollama's interactive `pip install` prompt on import with a plain `ImportError`. Importing `mem0.embeddings.ollama` without the `ollama` package previously blocked on stdin and then called `sys.exit(1)`, killing the host process instead of raising ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Core:** `remove_code_blocks()` now returns an empty string for `None` input instead of raising `AttributeError` ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Core:** `parse_vision_messages()` now chains the original exception (`raise ... from e`) when an image download fails, so the root cause is preserved in the traceback ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Core:** `process_telemetry_filters(None)` now returns `([], {})`, matching the two-value tuple every caller unpacks, instead of `{}` ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Core:** `LlmFactory.create()` no longer mutates the caller's config dict in place via `.update(kwargs)`; the merge now builds a new dict ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Rerankers:** The Cohere, HuggingFace, SentenceTransformer, and Zero Entropy rerankers' failure-fallback path no longer mutates the caller's document dicts in place when stamping `rerank_score`; it now falls back on copies ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
- **Vector Stores:** Remove `logging.basicConfig()` calls from the MongoDB and Vertex AI Vector Search providers, so selecting either provider no longer reconfigures the host application's root logger as a side effect ([#6770](https://github.com/mem0ai/mem0/pull/6770))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-01" description="v2.0.15">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -1206,44 +1169,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-08-11" description="v3.1.6">
|
||||
|
||||
**New Features:**
|
||||
- **Vector Stores:** Add an Oracle AI Vector Search vector store (`oracledb`) to the OSS SDK, with pooled connections, `HNSW`/`IVF` indexes, an optional `indexAccuracy` target, JSON payload filtering, and six selectable distance metrics ([#6690](https://github.com/mem0ai/mem0/pull/6690))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** Percent-escape `%`, `&`, and `=` in `userId`, `agentId`, and `runId` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892))
|
||||
- **Vector Stores:** Close and clear the Oracle pool that Mem0 created when `initialize()` fails, and reset the cached init promise so the next call retries instead of replaying the rejection forever. A caller-supplied `client` is left untouched ([#6839](https://github.com/mem0ai/mem0/pull/6839))
|
||||
- **Vector Stores:** Validate Oracle `insert()` batches before touching the database: `ids` and `payloads` must match `vectors` in length, so a short array can no longer write rows with `undefined` ids or silently drop payloads ([#6839](https://github.com/mem0ai/mem0/pull/6839))
|
||||
|
||||
**Improvements:**
|
||||
- **Vector Stores:** Cut Oracle round trips. `insert()` now sends the whole batch through one `executeMany()` with explicit `bindDefs` instead of one `INSERT` per vector, `list()` reads the total from a `COUNT(*) OVER ()` window in the same statement instead of issuing a second count query, and an unfiltered `search()` adds the `VECTOR_INDEX_TRANSFORM` hint so the vector index is used ([#6835](https://github.com/mem0ai/mem0/pull/6835))
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Patched 8 high and 18 medium severity dependency vulnerabilities across the pnpm workspace via `pnpm.overrides` (`undici`, `brace-expansion`, `ip-address`) ([#6847](https://github.com/mem0ai/mem0/pull/6847))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-05" description="v3.1.5">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add `agentCustomInstructions` to `PromptUpdatePayload`, `AddMemoryOptions`, and `ProjectResponse`. It sets a second extraction instruction set that applies only to agent-scoped memories: an add passing `agentId` without `userId` uses it, one passing both splits by attribution, and while it is unset `customInstructions` continues to apply to every memory ([#6809](https://github.com/mem0ai/mem0/pull/6809))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-04" description="v3.1.4">
|
||||
|
||||
**New Features:**
|
||||
- **Client:** Add `referenceDate` and `keywordSearch` to `SearchMemoryOptions`, keeping the TypeScript client's typed search options in sync with the Platform API ([#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Client:** Take the `/v1/ping/` identity/telemetry call off the request critical path. Every method previously did `if (this.telemetryId === "") await this.ping();`, blocking the first call on each client instance on a network round trip; the ping now resolves in the background through a shared, credentials-keyed promise cache (FIFO-capped at 50 entries), so concurrent clients on the same host/API key share one ping instead of issuing one each, and a failed ping is not cached so the process can retry ([#6788](https://github.com/mem0ai/mem0/pull/6788))
|
||||
- **Client:** `deleteUsers()` now issues its per-entity DELETE requests through the shared `fetch()`-based helper instead of the axios instance, which defaulted to `keepAlive: false`. Deleting every user, agent, app, and run now reuses pooled connections instead of paying a fresh TCP/TLS handshake per entity ([#6788](https://github.com/mem0ai/mem0/pull/6788))
|
||||
- **Memory (OSS):** Stop `add()` metadata from setting or overwriting a memory's identity scope. Metadata now runs through the same `stripIdentityKeys()` helper as `update()`, so `user_id`/`agent_id`/`run_id` (snake_case or camelCase) and `actor_id` passed in metadata can no longer place a memory into a scope the caller didn't request through `userId`/`agentId`/`runId`/`filters` ([#6377](https://github.com/mem0ai/mem0/pull/6377))
|
||||
- **Vector Stores:** Fix Redis `search()` and `list()` building an invalid, empty RediSearch filter expression when `filters` was an empty object or contained only null/undefined values. The shared `buildRedisFilterExpr()` helper now falls back to `"*"` (match all) instead of joining zero conditions into `""` ([#6014](https://github.com/mem0ai/mem0/pull/6014))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-01" description="v3.1.3">
|
||||
|
||||
**New Features:**
|
||||
@@ -1823,26 +1748,6 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
|
||||
|
||||
<Tab title="CLI">
|
||||
|
||||
<Update label="2026-08-04" description="Python v0.2.11 / Node v0.2.12">
|
||||
|
||||
**New Features:**
|
||||
- **`add`:** New `--custom-instructions`, `--custom-categories`, `--structured-data-schema`, and `--timestamp` flags, matching the Platform `/v3/memories/add/` payload fields (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
- **`search`:** New `--show-expired`, `--reference-date`, and `--latest-only` flags, forwarded to `/v3/memories/search/` as `show_expired`, `reference_date`, and `latest_only` (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
- **`list`:** New `--show-expired` and `--latest-only` flags (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
- **`update`:** New `--expires` and `--timestamp` flags (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
- **`delete`:** New `--delete-linked` flag to also delete memories linked to the target memory (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **`help --json`:** Emit JSON automatically under agent mode (`--agent`), not only when `--json` is passed explicitly. Python checks `is_agent_mode()` and now renders through `console.print_json()` instead of a plain `console.print(json.dumps(...))` call; Node also checks the root `--agent` flag in addition to the subcommand's `--json` flag (Python and Node [#6773](https://github.com/mem0ai/mem0/pull/6773))
|
||||
|
||||
**Changes:**
|
||||
- **`add`:** `--categories` is no longer forwarded on `add`. The `/v3/memories/add/` payload has no `categories` field (only `custom_categories`), so the CLI was sending a key the endpoint does not accept. Passing the flag now exits 1 with a message pointing to `--custom-categories` (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
|
||||
|
||||
**Security:**
|
||||
- **Dependencies (Node):** Tighten the `postcss` pnpm override from `<8.5.10 → >=8.5.10` to `<8.5.18 → >=8.5.18 <9.0.0`, closing a newer CVE range the previous floor didn't cover, as part of a wider dependency patch sweep across the pnpm workspaces ([#6639](https://github.com/mem0ai/mem0/pull/6639))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-13" description="Python v0.2.10 / Node v0.2.11">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -2008,19 +1913,6 @@ A full-featured command-line interface for Mem0, available in both Python and No
|
||||
<Tabs>
|
||||
<Tab title="Mem0 Plugin">
|
||||
|
||||
<Update label="2026-08-04" description="mem0-plugin v0.2.14">
|
||||
|
||||
**Fixes:**
|
||||
- **Settings loading no longer crashes on a malformed `settings.json`:** If `~/.mem0/settings.json` parsed as valid JSON but wasn't an object (a list or a bare string, for example), `load_settings()` called `.items()` on it and raised. `resolve_config()` guards only against `ImportError`, so the failure escaped into every hook that resolves identity, not just setup. Settings loading now keeps the defaults instead. Setup also stopped claiming it "Created" the settings file when one already existed; it only prints that when it actually wrote one. Shared across Claude Code, Cursor, Codex, and Antigravity.
|
||||
|
||||
**New Features:**
|
||||
- **Unknown settings key warning:** Setup now flags keys in `settings.json` that the plugin doesn't recognize, so a typo'd setting no longer fails silently.
|
||||
|
||||
**Removed:**
|
||||
- Dropped the `mem0_doc_search` "openmemory" section now that OpenMemory has been removed from the docs site, so the skill no longer links to pages that don't exist.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-14" description="mem0-plugin v0.2.13">
|
||||
|
||||
**Fixes:**
|
||||
@@ -2360,19 +2252,6 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
|
||||
|
||||
<Tab title="Antigravity">
|
||||
|
||||
<Update label="2026-08-04" description="Antigravity plugin v0.1.6">
|
||||
|
||||
**Fixes:**
|
||||
- **Settings loading no longer crashes on a malformed `settings.json`:** If `~/.mem0/settings.json` parsed as valid JSON but wasn't an object, every hook that resolves identity previously raised, not just setup. It now keeps the defaults instead, and setup only reports it "Created" the settings file when it actually wrote one.
|
||||
|
||||
**Improvements:**
|
||||
- **Unknown settings key warning:** Setup now flags keys in `settings.json` it doesn't recognize instead of ignoring them silently.
|
||||
|
||||
**Removed:**
|
||||
- Dropped the doc-search skill's "openmemory" section now that OpenMemory has been removed from the docs site.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-14" description="Antigravity plugin v0.1.5">
|
||||
|
||||
**Fixes:**
|
||||
@@ -2843,21 +2722,6 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
|
||||
<Tab title="n8n">
|
||||
|
||||
<Update label="2026-08-05" description="n8n-nodes-mem0 v0.1.3">
|
||||
|
||||
**Changes:**
|
||||
- **License changed to MIT:** The published `@mem0/n8n-nodes-mem0` package is now MIT (was Apache-2.0). n8n's Creator Portal requires verified community nodes to be MIT, and the failing license check was the blocker for verification. The rest of the mem0 repo stays Apache-2.0 ([#6804](https://github.com/mem0ai/mem0/pull/6804))
|
||||
- **Themed icons:** The node and credential icons now declare `{ light, dark }` variants instead of a single icon, clearing the remaining `icon-prefer-themed-variants` warnings from the Creator Portal scan. No functional changes ([#6804](https://github.com/mem0ai/mem0/pull/6804))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-04" description="n8n-nodes-mem0 v0.1.2">
|
||||
|
||||
**Changes:**
|
||||
- **Package contact:** `author.email` in the published package is now `integrations@mem0.ai` (was `founders@mem0.ai`), so npm and n8n Creator Portal correspondence reaches the integrations team directly. No functional changes ([#6791](https://github.com/mem0ai/mem0/pull/6791))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-30" description="n8n-nodes-mem0 v0.1.1">
|
||||
|
||||
**Changes:**
|
||||
@@ -2887,14 +2751,6 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
|
||||
<Tab title="Zapier">
|
||||
|
||||
<Update label="2026-08-04" description="Zapier app v0.1.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Add Memory:** **User ID** is now a required field. Mem0 rejects a write that carries no entity ID, so a Zap left blank failed at the API instead of in the editor. If you scoped memories only by Agent ID or Run ID, set a User ID as well ([#6790](https://github.com/mem0ai/mem0/pull/6790))
|
||||
- **Deployment:** Add a root `index.js` that re-exports the compiled app from `dist/`, and point `main` at it. Zapier's Lambda wrapper loads `<root>/index.js` and ignores the `package.json` `main` field, so the deployed app could not resolve its entry point ([#6789](https://github.com/mem0ai/mem0/pull/6789))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-29" description="Zapier app v0.1.0">
|
||||
|
||||
**Initial release** of the Mem0 Zapier app, built on the Zapier Platform CLI ([#6518](https://github.com/mem0ai/mem0/pull/6518))
|
||||
|
||||
@@ -97,4 +97,4 @@ Here's a comprehensive list of all parameters that can be used across different
|
||||
|
||||
## Supported Embedding Models
|
||||
|
||||
For detailed information on configuring specific embedders, please visit the [Embedding Models](./overview) section. There you'll find information for each supported embedder with provider-specific usage examples and configuration details.
|
||||
For detailed information on configuring specific embedders, please visit the [Embedding Models](./models) section. There you'll find information for each supported embedder with provider-specific usage examples and configuration details.
|
||||
|
||||
@@ -133,4 +133,4 @@ Here's a comprehensive list of all parameters that can be used across different
|
||||
|
||||
## Supported LLMs
|
||||
|
||||
For detailed information on configuring specific LLMs, please visit the [LLMs](./overview) section. There you'll find information for each supported LLM with provider-specific usage examples and configuration details.
|
||||
For detailed information on configuring specific LLMs, please visit the [LLMs](./models) section. There you'll find information for each supported LLM with provider-specific usage examples and configuration details.
|
||||
|
||||
@@ -123,10 +123,10 @@ Here's a comprehensive list of all parameters that can be used across different
|
||||
|
||||
Each vector database has its own specific configuration requirements. To customize the config for your chosen vector store:
|
||||
|
||||
1. Identify the vector database you want to use from [supported vector databases](./overview).
|
||||
1. Identify the vector database you want to use from [supported vector databases](./dbs).
|
||||
2. Refer to the `Config` section in the respective vector database's documentation.
|
||||
3. Include only the relevant parameters for your chosen database in the `config` dictionary.
|
||||
|
||||
## Supported Vector Databases
|
||||
|
||||
For detailed information on configuring specific vector databases, please visit the [Supported Vector Databases](./overview) section. There you'll find individual pages for each supported vector store with provider-specific usage examples and configuration details.
|
||||
For detailed information on configuring specific vector databases, please visit the [Supported Vector Databases](./dbs) section. There you'll find individual pages for each supported vector store with provider-specific usage examples and configuration details.
|
||||
|
||||
@@ -3,25 +3,17 @@ title: "Oracle AI Vector Search"
|
||||
description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for semantic and relational queries."
|
||||
---
|
||||
|
||||
{/* Copyright (c) 2026, Oracle and/or its affiliates. */}
|
||||
|
||||
[Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/) stores embeddings in an Oracle table using the native `VECTOR` data type, so you can combine semantic search over unstructured data with relational queries over business data in a single database.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Oracle Database 23.4 or later, with a user that can create tables and vector indexes
|
||||
- The `python-oracledb` or `node-oracledb` driver. In thick mode, Oracle Client 23.4 or later is also required.
|
||||
- The `python-oracledb` driver. In thick mode, Oracle Client 23.4 or later is also required.
|
||||
|
||||
<CodeGroup>
|
||||
```bash Python
|
||||
```bash
|
||||
pip install oracledb
|
||||
```
|
||||
|
||||
```bash TypeScript
|
||||
npm install oracledb
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Usage
|
||||
|
||||
<CodeGroup>
|
||||
@@ -55,58 +47,11 @@ messages = [
|
||||
]
|
||||
m.add(messages, user_id="alice", metadata={"category": "movies"})
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import { Memory } from "mem0ai/oss";
|
||||
|
||||
const config = {
|
||||
vectorStore: {
|
||||
provider: "oracledb",
|
||||
config: {
|
||||
collectionName: "mem0",
|
||||
embeddingModelDims: 1536,
|
||||
connectionParams: {
|
||||
user: "mem0_user",
|
||||
password: "your-password",
|
||||
connectString: "localhost:1521/FREEPDB1",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const memory = new Memory(config);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: "I'm planning to watch a movie tonight. Any recommendations?",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "How about thriller movies? They can be quite engaging.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "I'm not a big fan of thriller movies but I love sci-fi movies.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future.",
|
||||
},
|
||||
];
|
||||
|
||||
await memory.add(messages, {
|
||||
userId: "alice",
|
||||
metadata: { category: "movies" },
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
To reuse a connection or pool you already manage, pass it as `client` instead of the connection parameters:
|
||||
To reuse a connection or pool you already manage, pass it as `client` instead of `connection_params`:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
```python
|
||||
import oracledb
|
||||
|
||||
pool = oracledb.create_pool(user="mem0_user", password="your-password", dsn="localhost:1521/FREEPDB1")
|
||||
@@ -119,52 +64,33 @@ config = {
|
||||
}
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import oracledb from "oracledb";
|
||||
|
||||
const pool = await oracledb.createPool({
|
||||
user: "mem0_user",
|
||||
password: "your-password",
|
||||
connectString: "localhost:1521/FREEPDB1",
|
||||
});
|
||||
|
||||
const config = {
|
||||
vectorStore: {
|
||||
provider: "oracledb",
|
||||
config: { client: pool },
|
||||
},
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Config
|
||||
|
||||
Here are the parameters available for configuring Oracle AI Vector Search:
|
||||
|
||||
| Python | TypeScript | Description | Default Value |
|
||||
| --- | --- | --- | --- |
|
||||
| `connection_params` | `connectionParams` | Connection settings passed to the Oracle driver, such as `user`, `password` and `dsn` (`connectString` in TypeScript). See the [Python](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) or [Node.js](https://node-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) connection handling guide. | `None` |
|
||||
| `use_connection_pool` | `useConnectionPool` | Create a connection pool from the connection parameters instead of a single connection | `True` |
|
||||
| `client` | `client` | An existing Oracle connection or pool to use instead of building one from the connection parameters | `None` |
|
||||
| `collection_name` | `collectionName` | Name of the Oracle table that stores vectors and payloads | `mem0` |
|
||||
| `embedding_model_dims` | `embeddingModelDims` | Dimension of your embedding vectors, must be greater than 0 | `1536` |
|
||||
| `distance_metric` | `distanceMetric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` |
|
||||
| `do_create_index` | `doCreateIndex` | Whether to create a vector index on the collection | `True` |
|
||||
| `index_type` | `indexType` | Vector index type: `HNSW` or `IVF` | `HNSW` |
|
||||
| `index_name` | `indexName` | Name of the vector index | `<collection_name>_VEC_IDX` |
|
||||
| `index_parameters` | `indexParameters` | Index tuning parameters. For `HNSW`: `neighbors`, `efconstruction`. For `IVF`: `neighbor partitions`, `samples_per_partition`, `min_vectors_per_partition`. | `None` |
|
||||
| `index_accuracy` | `indexAccuracy` | Target index accuracy from 1 to 100, applied as `WITH TARGET ACCURACY <n>` | `None` |
|
||||
| Parameter | Description | Default Value |
|
||||
| --- | --- | --- |
|
||||
| `connection_params` | Connection settings passed to `python-oracledb`, such as `user`, `password` and `dsn`. See the [connection handling guide](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html). | `None` |
|
||||
| `use_connection_pool` | Create a connection pool from `connection_params` instead of a single connection | `True` |
|
||||
| `client` | An existing `oracledb.Connection` or `oracledb.ConnectionPool` to use instead of building one from `connection_params` | `None` |
|
||||
| `collection_name` | Name of the Oracle table that stores vectors and payloads | `mem0` |
|
||||
| `embedding_model_dims` | Dimension of your embedding vectors, must be greater than 0 | `1536` |
|
||||
| `distance_metric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` |
|
||||
| `do_create_index` | Whether to create a vector index on the collection | `True` |
|
||||
| `index_type` | Vector index type: `HNSW` or `IVF` | `HNSW` |
|
||||
| `index_name` | Name of the vector index | `<collection_name>_VEC_IDX` |
|
||||
| `index_parameters` | Index tuning parameters. For `HNSW`: `neighbors`, `efconstruction`. For `IVF`: `neighbor partitions`, `samples_per_partition`, `min_vectors_per_partition`. | `None` |
|
||||
| `index_accuracy` | Target index accuracy from 1 to 100, applied as `WITH TARGET ACCURACY <n>` | `None` |
|
||||
|
||||
<Note>
|
||||
When you pass a pre-built `client`, Mem0 uses it as-is and ignores the connection parameters and pooling options. Mem0 does not close a client it did not create.
|
||||
When you pass a pre-built `client`, Mem0 uses it as-is and ignores `connection_params` and `use_connection_pool`. Mem0 does not close a client it did not create.
|
||||
</Note>
|
||||
|
||||
### Vector indexes
|
||||
|
||||
Set the index type with `index_type` and tune it with `index_parameters`:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
```python
|
||||
config = {
|
||||
"vector_store": {
|
||||
"provider": "oracledb",
|
||||
@@ -178,25 +104,6 @@ config = {
|
||||
}
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
const config = {
|
||||
vectorStore: {
|
||||
provider: "oracledb",
|
||||
config: {
|
||||
connectionParams: {
|
||||
user: "mem0_user",
|
||||
password: "your-password",
|
||||
connectString: "localhost:1521/FREEPDB1",
|
||||
},
|
||||
indexType: "HNSW",
|
||||
indexParameters: { neighbors: 32, efconstruction: 200 },
|
||||
indexAccuracy: 95,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
For the full list of supported options, see the Oracle [`CREATE VECTOR INDEX`](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/create-vector-index.html) reference.
|
||||
|
||||
### Search scores
|
||||
@@ -214,23 +121,14 @@ Filters run against the JSON `payload` column and support:
|
||||
| Comparison | `{"score": {"gte": 0.5}}`, also `eq`, `ne`, `gt`, `lt`, `lte` |
|
||||
| Membership | `{"category": {"in": ["movies", "books"]}}`, also `nin` |
|
||||
| String matching | `{"title": {"contains": "sci-fi"}}`, also `icontains` for case-insensitive |
|
||||
| Logical groups | `{"AND": [...]}`, `{"OR": [...]}`, `{"NOT": [...]}`, also `$and`, `$or`, `$not` |
|
||||
| Logical groups | `{"AND": [...]}`, `{"OR": [...]}`, `{"NOT": [...]}` |
|
||||
|
||||
Multiple fields at the top level are combined with `AND`:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
```python
|
||||
m.search(
|
||||
"movie recommendations",
|
||||
user_id="alice",
|
||||
filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}},
|
||||
)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
await memory.search("movie recommendations", {
|
||||
userId: "alice",
|
||||
filters: { category: { in: ["movies", "books"] }, rating: { gte: 4 } },
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -10,7 +10,7 @@ Mem0 includes built-in support for various popular databases. Memory can utilize
|
||||
See the list of supported vector databases below.
|
||||
|
||||
<Note>
|
||||
The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Oracle AI Vector Search, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store.
|
||||
The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store.
|
||||
</Note>
|
||||
|
||||
<CardGroup cols={3}>
|
||||
|
||||
@@ -46,7 +46,7 @@ When new messages arrive, Mem0 extracts durable facts and stores them with the i
|
||||
1. **Context lookup.** Mem0 checks related existing memories so it can avoid storing the same fact again.
|
||||
2. **Fact extraction.** An LLM extracts preferences, decisions, plans, and other details your agent can reuse.
|
||||
3. **Deduplication and embedding.** Redundant facts are removed, then each memory is embedded for semantic search.
|
||||
4. **Entity extraction.** Mem0 pulls out the people, places, organizations, and concepts each memory mentions and stores them for entity matching at search time. On Platform these entities also become the nodes of [Graph Memory](/platform/features/graph-memory).
|
||||
4. **Entity linking.** When configured, Mem0 links people, places, organizations, and concepts across memories.
|
||||
|
||||
The automatic extraction path is additive. If a user says, "I moved from Austin to Seattle," Mem0 can store the new fact without silently rewriting the old one. Use explicit `update` or `delete` operations when your application needs to correct or remove a memory.
|
||||
|
||||
@@ -61,7 +61,7 @@ When you call `search`, Mem0 ranks stored memories against your query and filter
|
||||
| **Entity** | Boosts memories linked to entities in the query | Questions about a person, project, or account |
|
||||
| **Temporal** | Scores candidates on time metadata extracted at write time against the query's temporal intent | Temporal questions ("when did...", current state, recency) |
|
||||
|
||||
Platform retrieval fuses these signals in the managed service, where the entity signal is powered by built-in [Graph Memory](/platform/features/graph-memory). OSS retrieval depends on your configured vector store and optional reranker, and boosts on entity overlap alone: it has no graph memory.
|
||||
Platform retrieval fuses these signals in the managed service. OSS retrieval depends on your configured vector store, optional reranker, and graph store.
|
||||
|
||||
<Note>
|
||||
Always scope searches with filters such as `user_id`, `agent_id`, or `run_id`. This keeps memories from different users, agents, or sessions from mixing.
|
||||
@@ -75,7 +75,7 @@ Mem0 stores different parts of a memory in stores built for different lookup pat
|
||||
|---|---|---|
|
||||
| **SQL database** | Facts and metadata | The source of truth for each memory |
|
||||
| **Vector database** | Embeddings | Semantic similarity search |
|
||||
| **Entity store** | Entities extracted from memory text | Boosts memories sharing entities with the query. On Platform it also backs [Graph Memory](/platform/features/graph-memory) |
|
||||
| **Entity or graph store** | Entities and relationships | Relationship-aware retrieval when graph memory is enabled |
|
||||
|
||||
On Mem0 Platform, these stores are managed for you. In OSS, you choose and operate the backing stores through your configuration.
|
||||
|
||||
|
||||
+1
-6
@@ -85,8 +85,7 @@
|
||||
"platform/features/advanced-retrieval",
|
||||
"platform/advanced-memory-operations",
|
||||
"platform/features/custom-instructions",
|
||||
"platform/features/memory-decay",
|
||||
"platform/features/dream"
|
||||
"platform/features/memory-decay"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1035,10 +1034,6 @@
|
||||
"source": "/features/graph-memory",
|
||||
"destination": "/platform/features/graph-memory"
|
||||
},
|
||||
{
|
||||
"source": "/open-source/features/graph-memory",
|
||||
"destination": "/platform/features/graph-memory"
|
||||
},
|
||||
{
|
||||
"source": "/features/:slug",
|
||||
"destination": "/platform/features/:slug"
|
||||
|
||||
+2
-3
@@ -177,14 +177,14 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
- [Platform CLI](https://docs.mem0.ai/platform/cli) [Platform]: Use when the user wants to manage Platform memories from the terminal.
|
||||
- [Mem0 MCP Server](https://docs.mem0.ai/platform/mem0-mcp) [Platform]: Use when connecting memory to AI coding tools over MCP.
|
||||
- [Open Source Overview](https://docs.mem0.ai/open-source/overview) [OSS]: Use when the user needs full infra control and custom provider wiring.
|
||||
- [Open Source Configuration](https://docs.mem0.ai/open-source/configuration) [OSS]: Use when configuring `Memory` - LLM, embedder, vector store, reranker.
|
||||
- [Open Source Configuration](https://docs.mem0.ai/open-source/configuration) [OSS]: Use when configuring `Memory` - LLM, embedder, vector store, graph store.
|
||||
- [Open Source Python Quickstart](https://docs.mem0.ai/open-source/python-quickstart) [OSS]: Use for the first self-hosted Python integration.
|
||||
- [Open Source Node.js Quickstart](https://docs.mem0.ai/open-source/node-quickstart) [OSS]: Use for the first self-hosted Node integration.
|
||||
- [Self-Hosted Setup](https://docs.mem0.ai/open-source/setup) [OSS]: Use when standing up the bundled REST server and dashboard via Docker Compose, including auth, API keys, and the setup wizard.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/entity/history stores, and multi-signal retrieval.
|
||||
- [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/graph/history stores, and multi-signal retrieval.
|
||||
- [Memory Types](https://docs.mem0.ai/core-concepts/memory-types) [Both]: Use when explaining working, factual, episodic, and semantic memory distinctions.
|
||||
- [Memory Operations - Add](https://docs.mem0.ai/core-concepts/memory-operations/add) [Both]: Use when explaining how `add()` extracts facts, resolves conflicts, and writes to both stores.
|
||||
- [Memory Operations - Search](https://docs.mem0.ai/core-concepts/memory-operations/search) [Both]: Use when explaining how queries are processed and ranked.
|
||||
@@ -207,7 +207,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
- [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering.
|
||||
- [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform.
|
||||
- [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones. Opt in per project; applies at search time and never filters candidates out.
|
||||
- [Dream](https://docs.mem0.ai/platform/features/dream) [Platform]: Use when long-lived user memory should stay coherent on its own - synthesizing recurring patterns, superseding outdated facts, and merging duplicates in the background. Synthesis is opt-in (Pro+); Supersede and Merge are always on.
|
||||
- [Advanced Memory Operations](https://docs.mem0.ai/platform/advanced-memory-operations) [Platform]: Use when basic CRUD is not enough - batch ops, complex filters, workflows.
|
||||
|
||||
### Features - Data Management
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Open Source: Migrating to the New Memory Algorithm"
|
||||
description: "Guide for self-hosted Mem0 users to upgrade to the new memory algorithm with ADD-only extraction, hybrid search, and entity-aware retrieval."
|
||||
description: "Guide for self-hosted Mem0 users to upgrade to the new memory algorithm with ADD-only extraction, hybrid search, and entity linking."
|
||||
icon: "arrow-right"
|
||||
iconType: "solid"
|
||||
---
|
||||
@@ -15,8 +15,7 @@ The new Mem0 release redesigns both extraction and retrieval, and cleans up the
|
||||
|
||||
- **Extraction**: Single-pass ADD-only (one LLM call, no UPDATE/DELETE)
|
||||
- **Retrieval**: Multi-signal hybrid search (semantic + BM25 keyword + entity matching)
|
||||
- **Entity matching**: Automatic entity extraction feeds a third scoring signal in hybrid search, boosting memories that share entities with the query
|
||||
- **Graph memory moved to Platform**: The external graph store integration is removed from OSS; graph memory is now a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory)
|
||||
- **Entity linking**: Automatic entity extraction and cross-memory linking
|
||||
- **SDK cleanup**: Deprecated parameters removed, naming conventions standardized
|
||||
- **API surface aligned with Platform**: Entity IDs now follow the same convention across OSS and Platform: top-level kwargs for `add()` / `delete_all()`, inside `filters` for `search()` / `get_all()`
|
||||
|
||||
@@ -38,7 +37,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 → 91.6) and
|
||||
| `add()` events | Returns `ADD`, `UPDATE`, `DELETE` | Returns `ADD` only | Update code expecting UPDATE/DELETE |
|
||||
| Custom extraction prompt | `custom_fact_extraction_prompt` | `custom_instructions` | Rename in config |
|
||||
| Custom update prompt | `custom_update_memory_prompt` | Deprecated | Use `custom_instructions` instead |
|
||||
| Graph memory | `enable_graph` + `graph_store` in config | Removed | Graph memory is removed from OSS. It's a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory) |
|
||||
| Graph memory | `enable_graph` + `graph_store` in config | Removed | Graph store support has been removed entirely |
|
||||
| Qdrant client | `>=1.9.1` | `>=1.12.0` | Update dependency |
|
||||
| Upstash client | `>=0.1.0` | `>=0.6.0` | Update dependency |
|
||||
|
||||
@@ -54,8 +53,8 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 → 91.6) and
|
||||
| `messages` in `add()` | Could be `null` / `undefined` | Required: throws on null/undefined | Always pass a string or array |
|
||||
| Payload key for lemmatized text | `text_lemmatized` (snake_case) | `textLemmatized` (camelCase) | TS-only internal field. If you share a vector store collection between Python and TS SDKs, lemma-based BM25 will not resolve across languages: keep collections language-scoped. |
|
||||
| Custom prompt | `customPrompt` | `customInstructions` | Rename in config |
|
||||
| Graph memory | `enableGraph` + `graphStore` in config | Removed | Graph memory is removed from OSS. It's a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory) |
|
||||
| Default graph config | Neo4j default config applied | No default graph config | Graph store config is no longer read; OSS has no built-in graph config to fall back to |
|
||||
| Graph memory | `enableGraph` + `graphStore` in config | Removed | Graph store support has been removed entirely |
|
||||
| Default graph config | Neo4j default config applied | No default graph config | Graph store config is no longer used |
|
||||
|
||||
### Python Client SDK
|
||||
|
||||
@@ -105,7 +104,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 → 91.6) and
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
The Python `[nlp]` extra installs [spaCy](https://spacy.io/) for entity extraction and keyword lemmatization. Without it, Mem0 still works but falls back to semantic-only search (no entity matching, no BM25 lemmatization).
|
||||
The Python `[nlp]` extra installs [spaCy](https://spacy.io/) for entity extraction and keyword lemmatization. Without it, Mem0 still works but falls back to semantic-only search (no entity linking, no BM25 lemmatization).
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
@@ -136,7 +135,7 @@ pip install fastembed
|
||||
config = {
|
||||
"custom_instructions": "Focus on user preferences", # [OK] New name
|
||||
# custom_update_memory_prompt removed: use custom_instructions
|
||||
# enable_graph and graph_store removed: graph memory is now a Mem0 Platform feature
|
||||
# enable_graph and graph_store removed: graph store support has been removed
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
@@ -155,7 +154,7 @@ pip install fastembed
|
||||
// After
|
||||
const config = {
|
||||
customInstructions: "Focus on user preferences", // [OK] New name
|
||||
// enableGraph and graphStore removed: graph memory is now a Mem0 Platform feature
|
||||
// enableGraph and graphStore removed: graph store support has been removed
|
||||
};
|
||||
```
|
||||
</Tab>
|
||||
@@ -321,31 +320,35 @@ pip install "qdrant-client>=1.12.0"
|
||||
pip install "upstash-vector>=0.6.0"
|
||||
```
|
||||
|
||||
### 6. Entity Matching Store Setup
|
||||
### 6. Entity Store Setup
|
||||
|
||||
The new algorithm automatically creates a parallel collection named `{your_collection}_entities` to power entity matching, the third signal in hybrid search. No manual setup is required: it's created on first use. This is separate from and unrelated to graph memory, which is a Mem0 Platform feature.
|
||||
The new algorithm automatically creates a parallel entity store collection named `{your_collection}_entities`. No manual setup is required: it's created on first use.
|
||||
|
||||
<Warning>
|
||||
Make sure your vector store user/credentials have permission to create new collections. If you're using a managed vector database with restricted permissions, pre-create the `{collection_name}_entities` collection with the same embedding dimensions as your main collection.
|
||||
</Warning>
|
||||
|
||||
## Graph Memory: Platform Only
|
||||
## Graph Memory: Now Built-In
|
||||
|
||||
Graph memory is removed from the open-source SDK. It is not being replaced by an OSS equivalent: graph memory is a **Mem0 Platform** feature, built in and always on, with no external graph database required. See [Graph Memory](/platform/features/graph-memory) for what it does on Platform.
|
||||
External graph **store** support has been removed from the open-source SDK and replaced by **built-in graph memory** (entity linking), which runs natively with no external dependencies.
|
||||
|
||||
**What was removed from OSS:**
|
||||
**What was removed:**
|
||||
- `enable_graph` / `enableGraph` config flag
|
||||
- `graph_store` / `graphStore` configuration block
|
||||
- All external graph store drivers (Neo4j, Memgraph, Kuzu, Apache AGE, Neptune) and their code paths (~4000 lines)
|
||||
- `graph_store` / `graphStore` configuration block (Neo4j, Memgraph, Kuzu, Apache AGE, Neptune)
|
||||
- All external graph store code paths (~4000 lines)
|
||||
|
||||
**What replaces it:**
|
||||
|
||||
Mem0 now builds the graph itself. It extracts entities (proper nouns, quoted text, compound noun phrases) from every memory during the add pipeline and stores them in a parallel collection (`{collection}_entities`) inside your existing vector store. Memories that share an entity are linked, and at search time entities from the query are matched against this collection to boost connected memories. The boost is folded into the combined `score` on each result.
|
||||
|
||||
**Migration:**
|
||||
- Remove `enable_graph` / `enableGraph` from your config
|
||||
- Remove the `graph_store` / `graphStore` block: it is no longer read
|
||||
- Uninstall external graph drivers (neo4j, memgraph, etc.) if you were using them only for Mem0
|
||||
- If you need graph memory, use [Mem0 Platform](/platform/features/graph-memory) instead of self-hosted OSS
|
||||
- No data migration is required. Built-in graph memory activates automatically on the next `add()` call.
|
||||
|
||||
<Warning>
|
||||
The old `relations` field on search results (populated by the external graph store) is no longer returned in OSS. OSS has no graph memory replacement, so there is nothing to populate this field with. If your application read or traversed the `relations` array, either move to Mem0 Platform to keep that data or redesign that part against the new OSS retrieval API.
|
||||
The old `relations` field on search results (populated by the external graph store) is no longer returned. Entity connections are now applied through retrieval ranking rather than exposed as a separate, directly traversable structure. If your application read or traversed the `relations` array, you will need to redesign that part against the new API.
|
||||
</Warning>
|
||||
|
||||
## How the New Algorithm Works
|
||||
@@ -359,7 +362,7 @@ Input conversation
|
||||
→ Batch embed extracted memories
|
||||
→ Hash-based deduplication (MD5, prevents exact duplicates)
|
||||
→ Batch insert into vector store
|
||||
→ Entity extraction (for entity matching)
|
||||
→ Entity extraction + linking
|
||||
```
|
||||
|
||||
The previous algorithm used two LLM calls: one to extract candidate facts, one to decide ADD/UPDATE/DELETE actions against existing memories. The new algorithm collapses this into a single call that only adds. The model spends its capacity on understanding the input rather than diffing against existing state.
|
||||
@@ -372,7 +375,7 @@ Query
|
||||
→ Parallel scoring:
|
||||
1. Semantic search (vector similarity)
|
||||
2. BM25 keyword search (normalized term matching)
|
||||
3. Entity matching (entity overlap boost)
|
||||
3. Entity matching (entity graph boost)
|
||||
→ Score fusion → Top-K selection
|
||||
```
|
||||
|
||||
@@ -405,8 +408,8 @@ The new features degrade gracefully when optional dependencies are missing:
|
||||
| Missing Dependency | Impact | Search Still Works? |
|
||||
|---|---|---|
|
||||
| spaCy (`mem0ai[nlp]`) | No entity extraction, no BM25 lemmatization | Yes (semantic-only) |
|
||||
| `fastembed` (Qdrant) | No BM25 keyword search | Yes (semantic + entity matching) |
|
||||
| Entity matching store unavailable | No entity matching boost | Yes (semantic + BM25) |
|
||||
| `fastembed` (Qdrant) | No BM25 keyword search | Yes (semantic + entity) |
|
||||
| Entity store unavailable | No entity boosting | Yes (semantic + BM25) |
|
||||
|
||||
You always get semantic search. Hybrid search features layer on top when available.
|
||||
|
||||
@@ -446,13 +449,13 @@ These parameters have been removed across all SDKs. Remove them from your code:
|
||||
|
||||
**Config:** `custom_update_memory_prompt` → deprecated, use `custom_instructions`
|
||||
|
||||
**Config:** `enable_graph` + `graph_store` → removed (graph memory is now a [Mem0 Platform feature](/platform/features/graph-memory))
|
||||
**Config:** `enable_graph` + `graph_store` → removed (graph store support removed entirely)
|
||||
|
||||
### TypeScript OSS: Removed/renamed parameters
|
||||
|
||||
**Config:** `customPrompt` → renamed to `customInstructions`
|
||||
|
||||
**Config:** `enableGraph` + `graphStore` → removed (graph memory is now a [Mem0 Platform feature](/platform/features/graph-memory))
|
||||
**Config:** `enableGraph` + `graphStore` → removed (graph store support removed entirely)
|
||||
|
||||
**search():** `limit` → renamed to `topK`
|
||||
|
||||
@@ -518,9 +521,9 @@ If spaCy is not installed at all, install the NLP extras:
|
||||
pip install "mem0ai[nlp]"
|
||||
```
|
||||
|
||||
### Entity matching store collection creation fails
|
||||
### Entity store collection creation fails
|
||||
|
||||
The entity matching store tries to create a `{collection_name}_entities` collection automatically. If your vector database has restricted permissions, pre-create this collection with the same embedding dimensions as your main collection.
|
||||
The entity store tries to create a `{collection_name}_entities` collection automatically. If your vector database has restricted permissions, pre-create this collection with the same embedding dimensions as your main collection.
|
||||
|
||||
### Score values are different from before
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Configure Mem0 OSS in Python or TypeScript with your own LLM, embe
|
||||
icon: "sliders"
|
||||
---
|
||||
|
||||
Mem0 OSS works out of the box with OpenAI defaults. Point it at your own LLM, embedder, vector store, and reranker by passing a config when you create `Memory`.
|
||||
Mem0 OSS works out of the box with OpenAI defaults. Point it at your own LLM, embedder, and vector store by passing a config when you create `Memory`. The Python SDK also supports a reranker and graph memory.
|
||||
|
||||
<Info>
|
||||
**Prerequisites**
|
||||
@@ -90,11 +90,11 @@ Set your provider keys as environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="..."
|
||||
export COHERE_API_KEY="..." # Cohere reranker only
|
||||
export COHERE_API_KEY="..." # Python reranker only
|
||||
```
|
||||
|
||||
<Note>
|
||||
The TypeScript OSS SDK configures the LLM, embedder, vector store, history store, and reranker. Graph memory is not part of OSS in either language: it is a built-in [Mem0 Platform feature](/platform/features/graph-memory).
|
||||
The TypeScript OSS SDK configures the LLM, embedder, vector store, and history store. Reranker and graph memory are Python-only today.
|
||||
</Note>
|
||||
|
||||
Prefer a config file? Load YAML into Python's `from_config`:
|
||||
|
||||
@@ -2784,10 +2784,6 @@
|
||||
"type": "string",
|
||||
"description": "Project-level instructions that guide extraction for this call."
|
||||
},
|
||||
"agent_custom_instructions": {
|
||||
"type": "string",
|
||||
"description": "Extraction instructions for agent-scoped memories, overriding the project-level setting for this call. Applied when `agent_id` is sent without `user_id`; when both are sent it governs the assistant-attributed memories while `custom_instructions` governs the rest."
|
||||
},
|
||||
"custom_categories": {
|
||||
"type": "array",
|
||||
"description": "Category catalog for this call. Replaces the project-level list rather than merging with it. Omit to fall back to the project list, then the default catalog.",
|
||||
@@ -5809,11 +5805,6 @@
|
||||
},
|
||||
"description": "Custom instructions for memory processing in this project"
|
||||
},
|
||||
"agent_custom_instructions": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Extraction instructions for agent-scoped memories. Falls back to `custom_instructions` when unset. Send an empty string to clear it."
|
||||
},
|
||||
"custom_categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -7440,12 +7431,6 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"agent_custom_instructions": {
|
||||
"description": "Extraction instructions that apply only to agent-scoped memories. Used when `agent_id` is sent without `user_id`; when both are sent it governs the assistant-attributed memories while `custom_instructions` governs the rest. Falls back to `custom_instructions` when unset.",
|
||||
"title": "Agent custom instructions",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"immutable": {
|
||||
"description": "Whether the memory is immutable.",
|
||||
"title": "Immutable",
|
||||
|
||||
@@ -95,100 +95,6 @@ Exclude:
|
||||
- [Irrelevant information]
|
||||
```
|
||||
|
||||
## Agent Custom Instructions
|
||||
|
||||
`custom_instructions` applies to every memory your project extracts, no matter whose it is. But what is worth remembering about an agent is rarely what is worth remembering about a user: an agent's useful memories are things like which tools fail, which retry strategies work, and how a given environment behaves, not personal preferences.
|
||||
|
||||
`agent_custom_instructions` is an optional second set of extraction rules that applies only to agent-scoped memories.
|
||||
|
||||
Available from Python SDK `v2.0.17` and TypeScript SDK `v3.1.5`. Upgrade first if you are on an earlier release.
|
||||
|
||||
Set it on the project alongside `custom_instructions`:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.project.update(
|
||||
custom_instructions="Extract the user's preferences, goals, and constraints.",
|
||||
agent_custom_instructions=(
|
||||
"Extract operational lessons for the agent:\n"
|
||||
"- Tools that failed and the error returned\n"
|
||||
"- Retry or fallback strategies that worked\n"
|
||||
"- Environment quirks worth recalling on the next run\n\n"
|
||||
"Exclude: user preferences, personal details."
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.updateProject({
|
||||
customInstructions: "Extract the user's preferences, goals, and constraints.",
|
||||
agentCustomInstructions: `Extract operational lessons for the agent:
|
||||
- Tools that failed and the error returned
|
||||
- Retry or fallback strategies that worked
|
||||
- Environment quirks worth recalling on the next run
|
||||
|
||||
Exclude: user preferences, personal details.`,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Which instructions apply
|
||||
|
||||
Which set governs an `add()` call depends on the entity IDs you pass with it:
|
||||
|
||||
| The add call passes | Instructions applied |
|
||||
|---------------------|----------------------|
|
||||
| `user_id` only | `custom_instructions` |
|
||||
| `agent_id` only | `agent_custom_instructions` |
|
||||
| `user_id` **and** `agent_id` | `agent_custom_instructions` govern the memories attributed to the assistant; `custom_instructions` govern the rest |
|
||||
|
||||
`agent_custom_instructions` is unset by default. While it is unset, `custom_instructions` applies to every memory, so projects that don't set it behave exactly as they did before.
|
||||
|
||||
### Overriding for a single call
|
||||
|
||||
Both fields are also accepted per request, overriding the project setting for that `add()` only:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.add(
|
||||
messages,
|
||||
filters={"agent_id": "support-agent"},
|
||||
agent_custom_instructions="Only remember which tools errored and why.",
|
||||
)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.add(messages, {
|
||||
agentId: "support-agent",
|
||||
agentCustomInstructions: "Only remember which tools errored and why.",
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Reading and clearing
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Read the current value
|
||||
response = client.project.get(fields=["agent_custom_instructions"])
|
||||
print(response["agent_custom_instructions"])
|
||||
|
||||
# Clear it; agent memories fall back to custom_instructions
|
||||
client.project.update(agent_custom_instructions="")
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
// Read the current value
|
||||
const response = await client.getProject({
|
||||
fields: ["agentCustomInstructions"],
|
||||
});
|
||||
console.log(response.agentCustomInstructions);
|
||||
|
||||
// Clear it; agent memories fall back to customInstructions
|
||||
await client.updateProject({ agentCustomInstructions: "" });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
<Tabs>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
---
|
||||
title: Dream
|
||||
description: "Dream keeps a user's memory clean over time by distilling recurring patterns, retiring outdated facts, and merging duplicates in the background."
|
||||
---
|
||||
|
||||
# Dream
|
||||
|
||||
As an application talks to the same user over weeks and months, their memory grows. Some of that growth is signal, meaning new facts worth keeping. A lot of it is noise: the same preference stated three different ways, an old fact that a newer one has quietly replaced, or a set of small observations that only mean something when you look at them together.
|
||||
|
||||
**Dream** is the background layer that keeps a user's memory coherent as it grows. It continuously reviews each user's memories and does three things. It synthesizes higher-order patterns, supersedes outdated facts, and merges duplicates. The result is that what you read back stays sharp instead of drifting into a pile of overlapping, stale entries.
|
||||
|
||||
<Info>
|
||||
**Dream matters when…**
|
||||
- Your users interact with your product over a long period and accumulate a lot of memories.
|
||||
- You want retrieval to return the *current* truth about a user, not a mix of old and new contradictory facts.
|
||||
- You want higher-level insights ("this user consistently prefers X") without writing your own summarization layer.
|
||||
</Info>
|
||||
|
||||
## The three actions of Dream
|
||||
|
||||
Dream is made of three independent actions. Two of them, Supersede and Merge, keep memory clean and run automatically for everyone. The third, Synthesis, produces new insight and is a toggle you turn on per project.
|
||||
|
||||
| Action | What it does | When it runs | Availability |
|
||||
|---|---|---|---|
|
||||
| **Synthesis** | Distills a user's memories into higher-order **pattern memories** | On a schedule, in the background | Opt-in (Pro and above) |
|
||||
| **Supersede** | Marks an older fact as outdated when a newer one contradicts it | As memories are added | Always on, all plans |
|
||||
| **Merge** | Folds a duplicate into a single canonical memory | As memories are added | Always on, all plans |
|
||||
|
||||
### Synthesis
|
||||
|
||||
Over time a user's memories often *imply* something larger than any single entry. Ten separate notes about early-morning meetings, workout logs, and coffee orders together say "this user is an early riser." **Synthesis** finds those recurring threads and writes them back as new **pattern memories**: concise, higher-order facts that capture what the individual memories only hint at.
|
||||
|
||||
- Pattern memories are added *alongside* your existing memories, never in place of them. The source memories stay exactly where they are.
|
||||
- Each pattern memory keeps a link back to the specific memories it was distilled from, so an insight is always traceable to its evidence.
|
||||
- Synthesis is additive and idempotent. Re-running it will not create duplicate patterns for the same underlying evidence.
|
||||
|
||||
**Example.** These four memories accumulate for the same user over time:
|
||||
|
||||
- *"User runs approximately 40 kilometers per week and is currently training for a marathon."*
|
||||
- *"User lifts weights at the gym three times a week, primarily focusing on legs and back."*
|
||||
- *"User tracks all workouts using a Garmin Forerunner watch."*
|
||||
- *"User's goal for 2026 is to run a sub-4-hour marathon."*
|
||||
|
||||
Synthesis distills them into one pattern memory, kept alongside the originals:
|
||||
|
||||
> *"User follows a structured fitness routine that includes weekly long runs (≈40 km), regular leg-and-back strength training, tracks workouts with a Garmin device, and pursues progressive marathon time goals."*
|
||||
|
||||
Each source memory stays exactly where it was, and the new pattern links back to all of them as its evidence.
|
||||
|
||||
<Note>
|
||||
Synthesis only considers memories created **after** you enable it for a project. Turning it on sets a forward boundary, so historical memories aren't reprocessed in bulk on day one. Everything added from that point on is eligible.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Synthesis only looks at memories scoped to a **`user_id` alone**. Memories that also carry another entity (an `agent_id`, `run_id`, or `app_id`) are left out of a user's synthesis run. This keeps each run tied to a single user's own memories and avoids cross-referencing across agents, runs, or apps. So a memory has to be user-scoped, with no other entity attached, to be eligible for a pattern.
|
||||
</Note>
|
||||
|
||||
### Supersede
|
||||
|
||||
When a user tells you something that **contradicts** an earlier memory ("I moved to Berlin" after an earlier "I live in Lisbon"), Dream marks the older memory as **superseded** and links it to the newer fact that replaced it. Superseded memories are not deleted and not hidden by default. A normal `search` or `get` still returns them alongside your active memories, badged as superseded, so you keep the full history. When you want only the current truth, ask for it explicitly with `latest_only=true` (see [How reads change](#how-reads-change-with-dream) below). Supersede runs automatically as part of adding memories, on every plan.
|
||||
|
||||
**Example.** The user has an existing memory *"User drives a 2019 Subaru Outback."* Later they mention selling it, producing a new memory *"User sold their 2019 Subaru Outback and bought a Tesla Model 3."* Dream marks the Subaru memory as superseded and links it to the newer one. A default `search` returns both: the Tesla memory as active, and the Subaru one labelled superseded. Passing `latest_only=true` returns only *"User sold their 2019 Subaru Outback and bought a Tesla Model 3."*
|
||||
|
||||
### Merge
|
||||
|
||||
When a new memory is effectively a **duplicate** of one you already have, Dream keeps a single canonical memory instead of two near-identical copies. When a duplicate is stored as its own memory, Dream marks it **merged** and links it to the canonical one. The merged record is hidden from reads by default (you get the one canonical memory), retained rather than deleted, and surfaced with `include_merged=true`. Merge runs automatically as memories are added, on every plan, and keeps your memory set compact without you deduplicating by hand.
|
||||
|
||||
In practice, most exact or near-duplicate restatements of a fact you already have are recognised and deduplicated as the memory is added. No second copy is created, so you simply keep the one memory. A distinct `merged` record appears when a fuller version of an existing fact arrives and folds the barer one in.
|
||||
|
||||
**Example.** The user has an existing memory *"User has a dog named Rex."* Later they mention *"My dog Rex is a 3-year-old golden retriever."* The richer statement is stored and Dream marks the barer *"User has a dog named Rex"* as **merged** into it. A default read returns the single canonical memory *"User's dog Rex is a 3-year-old golden retriever"*, and `include_merged=true` also returns the merged original.
|
||||
|
||||
<Info>
|
||||
**Nothing Dream does is destructive.** Superseded and merged memories are retained, never erased. Every change is recorded and reviewable, so you always know why a memory was retired or combined.
|
||||
</Info>
|
||||
|
||||
## How reads change with Dream
|
||||
|
||||
Dream doesn't change the shape of your `add`, `search`, or `get` calls, so you don't touch your application code. What it changes is *which* memories a read returns by default, and it gives you two flags to widen or narrow that set:
|
||||
|
||||
| Read mode | Active | Superseded | Merged |
|
||||
|---|:---:|:---:|:---:|
|
||||
| **Default** (`search` / `get`) | ✓ | ✓ | ✗ |
|
||||
| **`latest_only=true`** | ✓ | — | — |
|
||||
| **`include_merged=true`** | ✓ | ✓ | ✓ |
|
||||
|
||||
- **By default**, a read returns active plus superseded memories (superseded ones are still there, labelled as history) and hides merged duplicates. Synthesized pattern memories are returned alongside these too.
|
||||
- **`latest_only=true`** narrows the result to active memories only, meaning the current truth, with superseded and merged both excluded. Use this when you want the cleanest possible snapshot of the user right now.
|
||||
- **`include_merged=true`** returns everything, including the merged duplicates, when you need the complete picture.
|
||||
|
||||
## Enabling Dream
|
||||
|
||||
Supersede and Merge require no setup. They're always on for every project on every plan.
|
||||
|
||||
Synthesis is opt-in per project:
|
||||
|
||||
1. Open the **[Dream settings for your project](https://app.mem0.ai/dashboard/dream)** in the Mem0 dashboard.
|
||||
2. Toggle **Synthesis** on.
|
||||
|
||||
Synthesis is a per-project setting, so you can enable it for one project and compare against another with it off. You can turn it off at any time. Doing so is fully reversible and leaves every existing memory (including already-synthesized patterns) untouched.
|
||||
|
||||
From the [Dream page](https://app.mem0.ai/dashboard/dream) you can also review what Dream has done: recent synthesis runs, the patterns produced and their source memories, and the memories that were superseded or merged.
|
||||
|
||||
## Plan availability
|
||||
|
||||
| Capability | Free | Starter | Pro | Enterprise |
|
||||
|---|:---:|:---:|:---:|:---:|
|
||||
| **Supersede** (outdated facts flagged) | ✓ | ✓ | ✓ | ✓ |
|
||||
| **Merge** (duplicates folded, hidden by default) | ✓ | ✓ | ✓ | ✓ |
|
||||
| **Synthesis** (pattern memories written) | — | — | ✓ | ✓ |
|
||||
| **Dream dashboard** (runs, activity) | — | — | ✓ | ✓ |
|
||||
|
||||
Synthesis requires a **Pro plan or higher**. Enterprise plans also get a faster, configurable schedule (see below).
|
||||
|
||||
## How often Dream runs, and what delay to expect
|
||||
|
||||
Different actions run on different clocks, so the delay you should expect depends on which action.
|
||||
|
||||
### Supersede & Merge, as memories are added
|
||||
|
||||
Supersede and Merge are part of the memory-addition pipeline. They're evaluated when a memory is added, so an outdated fact is superseded or a duplicate is merged as part of that add being processed, on the same timescale as the memory becoming searchable. There's no separate schedule to wait for.
|
||||
|
||||
### Synthesis, on a schedule in the background
|
||||
|
||||
Synthesis runs as a scheduled background job per user, not on every add. Two conditions gate it:
|
||||
|
||||
- **Enough to work with:** a user must have at least **20** memories before Synthesis considers them. Below that threshold there isn't a meaningful pattern to distill yet.
|
||||
- **Cadence elapsed:** each user is re-synthesized at most once per cadence window.
|
||||
|
||||
| Plan | Synthesis cadence (per user) |
|
||||
|---|---|
|
||||
| Pro | Every **7 days** |
|
||||
| Enterprise | **Daily** (and configurable) |
|
||||
|
||||
Because Synthesis is processed in batches in the background, **expect new pattern memories to appear within roughly 24 hours of a scheduled run**, not instantly. In practice, the end-to-end delay from crossing a cadence window to seeing new patterns is up to about a day. This background design is deliberate: it keeps Synthesis from adding any latency to your live `add` and `search` calls.
|
||||
|
||||
<Warning>
|
||||
Synthesis is **not** real-time. If you enable it today, the first pattern memories for an eligible user will appear on the next scheduled run for that user (governed by the cadence above), and can take up to ~24 hours to complete once that run starts. Supersede and Merge, by contrast, keep pace with your adds.
|
||||
</Warning>
|
||||
|
||||
## FAQ
|
||||
|
||||
**Does Dream delete any of my memories?**
|
||||
No. Nothing Dream does is destructive. Superseded memories stay visible in default reads (labelled as history), merged duplicates are hidden by default but retained, and synthesized patterns are added alongside your existing memories, never in place of them. Every change is reviewable from the dashboard.
|
||||
|
||||
**How do I get only the current facts, without the superseded ones?**
|
||||
Pass `latest_only=true` on your read. Superseded and merged memories are both excluded, leaving only active memories. By default (no flag) superseded memories are included so you keep the full history.
|
||||
|
||||
**Do I need to change my code to use Dream?**
|
||||
No. Supersede and Merge are always on, and enabling Synthesis is a project setting. Your `add`, `search`, and `get` calls are unchanged. Dream shapes the memory set behind the same API.
|
||||
|
||||
**Will Synthesis reprocess all my old memories when I turn it on?**
|
||||
No. Enabling Synthesis sets a forward boundary, so only memories created after you turn it on are eligible. This avoids a bulk reprocess of your entire history on day one.
|
||||
|
||||
**Why don't I see pattern memories immediately after enabling Synthesis?**
|
||||
Synthesis runs on a schedule (every 7 days on Pro, daily on Enterprise) and only for users with at least 20 memories. Patterns appear on the next scheduled run for an eligible user and can take up to ~24 hours to complete once that run starts.
|
||||
|
||||
**Which memories does Synthesis include?**
|
||||
Only memories scoped to a `user_id` on its own. If a memory also carries an `agent_id`, `run_id`, or `app_id`, it's excluded from that user's synthesis run. This keeps each run confined to a single user's memories and prevents any cross-referencing across agents, runs, or apps. Supersede and Merge are not affected by this and run across your memories as usual.
|
||||
|
||||
**Are synthesized pattern memories traceable?**
|
||||
Yes. Every pattern memory links back to the specific source memories it was distilled from, so you can always see the evidence behind an insight from the Dream dashboard.
|
||||
|
||||
**Can I turn Synthesis off?**
|
||||
Yes, at any time, per project. Turning it off is fully reversible and leaves all existing memories, including already-synthesized patterns, untouched.
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.0",
|
||||
"description": "Persistent memory for Kimi Code. Remembers decisions, patterns, and preferences across sessions.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
"email": "support@mem0.ai",
|
||||
"url": "https://mem0.ai"
|
||||
},
|
||||
"homepage": "https://mem0.ai",
|
||||
"repository": "https://github.com/mem0ai/mem0",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
|
||||
"skills": "./skills/",
|
||||
"sessionStart": {
|
||||
"skill": "context-loader"
|
||||
},
|
||||
"hooks": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"matcher": "^(startup|resume)$",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_session_start.sh",
|
||||
"timeout": 30
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_user_prompt.sh",
|
||||
"timeout": 12
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"matcher": "^(Write|Edit|MultiEdit)$",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" block_memory_write.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"matcher": "^mcp__.*mem0.*__(add_memory|search_memories|get_memories|delete_all_memories)$",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" enforce_metadata_defaults.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"matcher": "^Read$",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_file_read.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"matcher": "^mcp__.*mem0.*__",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_post_tool_use.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"matcher": "^Bash$",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_bash_output.sh",
|
||||
"timeout": 12
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_stop.sh",
|
||||
"timeout": 30
|
||||
},
|
||||
{
|
||||
"event": "PreCompact",
|
||||
"command": "\"$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh\" on_pre_compact.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
],
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"transport": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"bearerTokenEnvVar": "MEM0_API_KEY"
|
||||
}
|
||||
},
|
||||
"interface": {
|
||||
"displayName": "Mem0",
|
||||
"shortDescription": "Persistent memory layer for AI coding workflows",
|
||||
"longDescription": "Mem0 adds long-term memory to Kimi Code. Store decisions, user preferences, project context, and session state across conversations. Memories are automatically retrieved via semantic search so Kimi always has the right context available.",
|
||||
"developerName": "Mem0",
|
||||
"websiteURL": "https://mem0.ai",
|
||||
"logo": "./logo.svg"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"name": "mem0",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.5",
|
||||
"description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.",
|
||||
"author": { "name": "Mem0", "email": "support@mem0.ai" },
|
||||
"publisher": "mem0ai",
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Resolve the project's mem0 extraction policy from ``mem0.md``.
|
||||
|
||||
A repo's ``mem0.md`` can carry two prose sections that steer what Mem0 extracts:
|
||||
|
||||
## Instructions
|
||||
Remember architecture decisions and conventions. Ignore debug noise and secrets.
|
||||
|
||||
## Agent Instructions
|
||||
For agent-scoped memories, focus on the tools and task outcomes.
|
||||
|
||||
``## Instructions`` maps to Mem0's ``custom_instructions`` (user/project-scoped
|
||||
extraction) and ``## Agent Instructions`` to ``agent_custom_instructions``
|
||||
(agent-scoped extraction). Both are passed verbatim on memory writes, so the
|
||||
policy lives in the repo, travels with it, and is shared by the whole team.
|
||||
|
||||
The hook writers call :func:`load_instructions` and merge the result into their
|
||||
``/v3/memories/add`` body. Returns only the keys that are actually set, so a
|
||||
project with no policy adds nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from parse_mem0_config import load_full_config
|
||||
|
||||
|
||||
def load_instructions(cwd: str | None = None) -> dict[str, str]:
|
||||
"""Return the extraction policy for the project at *cwd* (defaults to the
|
||||
``MEM0_CWD`` env var, then the process cwd).
|
||||
|
||||
Keys (present only when non-empty):
|
||||
- ``custom_instructions`` from ``## Instructions``
|
||||
- ``agent_custom_instructions`` from ``## Agent Instructions``
|
||||
"""
|
||||
if cwd is None:
|
||||
cwd = os.environ.get("MEM0_CWD") or os.getcwd()
|
||||
|
||||
try:
|
||||
config = load_full_config(cwd)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
out: dict[str, str] = {}
|
||||
custom = config.get("instructions")
|
||||
if isinstance(custom, str) and custom.strip():
|
||||
out["custom_instructions"] = custom.strip()
|
||||
agent = config.get("agent_instructions")
|
||||
if isinstance(agent, str) and agent.strip():
|
||||
out["agent_custom_instructions"] = agent.strip()
|
||||
return out
|
||||
@@ -21,7 +21,6 @@ import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _instructions import load_instructions
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-auto-capture")
|
||||
@@ -127,8 +126,6 @@ def store_exchange(api_key: str, messages: list[dict], user_id: str,
|
||||
"metadata": metadata,
|
||||
"infer": True,
|
||||
}
|
||||
# Apply the project's mem0.md extraction policy (custom/agent instructions).
|
||||
body.update(load_instructions())
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
|
||||
@@ -26,7 +26,6 @@ from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _instructions import load_instructions
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-compact-summary")
|
||||
@@ -96,7 +95,7 @@ def find_compact_summary(lines: list[str]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "", cwd: str | None = None) -> bool:
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool:
|
||||
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
metadata = {
|
||||
"type": "compact_summary",
|
||||
@@ -116,8 +115,6 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro
|
||||
"infer": True,
|
||||
"expiration_date": expires,
|
||||
}
|
||||
# Apply the project's mem0.md extraction policy (custom/agent instructions).
|
||||
body.update(load_instructions(cwd))
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
@@ -185,7 +182,7 @@ def main():
|
||||
return
|
||||
|
||||
log.info("Capturing compact summary (%d chars)", len(summary))
|
||||
if store_summary(api_key, summary, user_id, session_id, project_id, branch, cwd):
|
||||
if store_summary(api_key, summary, user_id, session_id, project_id, branch):
|
||||
if session_id:
|
||||
try:
|
||||
os.makedirs(marker_dir, exist_ok=True)
|
||||
|
||||
@@ -24,7 +24,6 @@ from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _instructions import load_instructions
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-session-summary")
|
||||
@@ -164,7 +163,6 @@ def store_summary(
|
||||
project_id: str,
|
||||
branch: str,
|
||||
files: list[str],
|
||||
cwd: str | None = None,
|
||||
) -> bool:
|
||||
expires = (date.today() + timedelta(days=SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
metadata = {
|
||||
@@ -190,8 +188,6 @@ def store_summary(
|
||||
"infer": True,
|
||||
"expiration_date": expires,
|
||||
}
|
||||
# Apply the project's mem0.md extraction policy (custom/agent instructions).
|
||||
body.update(load_instructions(cwd))
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
@@ -261,7 +257,7 @@ def main():
|
||||
summary_prompt = build_summary_prompt(assistant_msg, files)
|
||||
|
||||
log.info("Capturing session summary (%d chars, %d files)", len(assistant_msg), len(files))
|
||||
store_summary(api_key, summary_prompt, user_id, session_id, project_id, branch, files, cwd)
|
||||
store_summary(api_key, summary_prompt, user_id, session_id, project_id, branch, files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Kimi Code hook adapter.
|
||||
#
|
||||
# Kimi Code's hook contract is close to Claude Code's but differs in four ways
|
||||
# that the mem0 hook scripts care about. Rather than fork nine scripts, every
|
||||
# Kimi hook entry in .kimi-plugin/plugin.json routes through this shim.
|
||||
#
|
||||
# 1. cwd Kimi runs hook commands with cwd forced to the PLUGIN root
|
||||
# (agent-core-v2 src/app/plugin/manager.ts -> enabledHooks() sets
|
||||
# `cwd: record.root`). The real project directory only arrives as
|
||||
# the payload's `cwd` field. _project.sh resolves MEM0_PROJECT_ID
|
||||
# from $PWD/`git remote`, so we chdir into the payload cwd first.
|
||||
#
|
||||
# 2. stdin Kimi sends snake_case JSON like Claude, but:
|
||||
# - `prompt` is a ContentPart[] array, not a string
|
||||
# - PostToolUse sends `tool_output`, not `tool_response`
|
||||
# - file tools use `tool_input.path`, not `tool_input.file_path`
|
||||
# - plugin MCP tools are `mcp__plugin-mem0_mem0__*` (hyphen),
|
||||
# not Claude's `mcp__plugin_mem0_mem0__*`
|
||||
# - there is NO `transcript_path` (no equivalent exists)
|
||||
# We normalise the first four into the Claude shape.
|
||||
#
|
||||
# 3. stdout Kimi's hook stdout parser (agent-core-v2
|
||||
# src/agent/externalHooks/runner.ts -> HookJsonOutputSchema) only
|
||||
# understands top-level `message`, `hookSpecificOutput.message`,
|
||||
# `hookSpecificOutput.permissionDecision` and
|
||||
# `hookSpecificOutput.permissionDecisionReason`.
|
||||
# `additionalContext` and `updatedInput` are NOT recognised.
|
||||
# Raw (non-JSON) stdout IS appended to context for UserPromptSubmit
|
||||
# (user-prompt.ts -> userPromptHookMessage falls back to stdout), so
|
||||
# we unwrap additionalContext into plain text.
|
||||
#
|
||||
# 4. exit Same as Claude: 0 = allow, 2 = block (stderr is the reason),
|
||||
# any other code / timeout = fail-open.
|
||||
#
|
||||
# Usage: kimi_hook_shim.sh <script-name.sh> [args...]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
|
||||
[ $# -ge 1 ] || exit 0
|
||||
TARGET_NAME="$1"
|
||||
shift
|
||||
TARGET="$SCRIPT_DIR/$TARGET_NAME"
|
||||
[ -f "$TARGET" ] || exit 0
|
||||
|
||||
# Telemetry attribution (telemetry.py::detect_platform honours MEM0_PLATFORM).
|
||||
export MEM0_PLATFORM="${MEM0_PLATFORM:-kimi}"
|
||||
|
||||
RAW=$(cat)
|
||||
|
||||
_TMP_BASE="${TMPDIR:-/tmp}"
|
||||
IN_FILE="$_TMP_BASE/mem0_kimi_in_$$"
|
||||
OUT_FILE="$_TMP_BASE/mem0_kimi_out_$$"
|
||||
trap 'rm -f "$IN_FILE" "$OUT_FILE"' EXIT
|
||||
|
||||
HAVE_JQ=""
|
||||
command -v jq >/dev/null 2>&1 && HAVE_JQ="true"
|
||||
|
||||
# ---------------------------------------------------------------- stdin shape
|
||||
NORM=""
|
||||
if [ -n "$HAVE_JQ" ]; then
|
||||
NORM=$(printf '%s' "$RAW" | jq -c '
|
||||
def flat_prompt:
|
||||
if type == "array" then
|
||||
[ .[]? | if type == "object" then (.text // "") elif type == "string" then . else "" end ]
|
||||
| map(select(. != "")) | join("\n")
|
||||
elif type == "string" then .
|
||||
else "" end;
|
||||
. as $in
|
||||
| (if has("prompt") then .prompt = ($in.prompt | flat_prompt) else . end)
|
||||
| (if (has("tool_response") | not) and (.tool_output != null)
|
||||
then .tool_response = .tool_output else . end)
|
||||
| (if ((.tool_input | type) == "object") and (.tool_input.file_path == null) and (.tool_input.path != null)
|
||||
then .tool_input.file_path = .tool_input.path else . end)
|
||||
| (if ((.tool_name | type) == "string") and (.tool_name | startswith("mcp__")) and (.tool_name | test("mem0"))
|
||||
then .tool_name = (.tool_name | gsub("-"; "_")) else . end)
|
||||
' 2>/dev/null)
|
||||
fi
|
||||
[ -n "$NORM" ] || NORM="$RAW"
|
||||
|
||||
printf '%s' "$NORM" >"$IN_FILE" 2>/dev/null || exit 0
|
||||
|
||||
# ------------------------------------------------------------------- real cwd
|
||||
if [ -n "$HAVE_JQ" ]; then
|
||||
PROJECT_CWD=$(printf '%s' "$NORM" | jq -r '.cwd // ""' 2>/dev/null || printf '')
|
||||
if [ -n "$PROJECT_CWD" ] && [ -d "$PROJECT_CWD" ]; then
|
||||
cd "$PROJECT_CWD" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------- dispatch
|
||||
# Redirect (not pipe) stdout so backgrounded children inside the hook scripts
|
||||
# cannot hold the shim open until Kimi's timeout fires.
|
||||
bash "$TARGET" "$@" <"$IN_FILE" >"$OUT_FILE"
|
||||
CODE=$?
|
||||
|
||||
OUT=$(cat "$OUT_FILE" 2>/dev/null || printf '')
|
||||
|
||||
# --------------------------------------------------------------- stdout shape
|
||||
if [ -n "$OUT" ] && [ -n "$HAVE_JQ" ] && printf '%s' "$OUT" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
||||
if printf '%s' "$OUT" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then
|
||||
# Kimi understands deny natively — pass the envelope straight through.
|
||||
printf '%s' "$OUT"
|
||||
else
|
||||
CTX=$(printf '%s' "$OUT" | jq -r '
|
||||
(.hookSpecificOutput.additionalContext
|
||||
// .additionalContext
|
||||
// .message
|
||||
// .hookSpecificOutput.message
|
||||
// "")
|
||||
| gsub("\\\\n"; "\n")' 2>/dev/null || printf '')
|
||||
[ -n "$CTX" ] && printf '%s\n' "$CTX"
|
||||
fi
|
||||
elif [ -n "$OUT" ]; then
|
||||
printf '%s' "$OUT"
|
||||
fi
|
||||
|
||||
exit $CODE
|
||||
@@ -148,14 +148,6 @@ if command -v python3 >/dev/null 2>&1; then
|
||||
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
|
||||
_CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?")
|
||||
echo "mem0.md loaded (${_CONFIG_KEYS} sections configured)."
|
||||
# Surface the project's memory policy so the model applies it when choosing
|
||||
# what to store via add_memory (the hook writes also send it as an
|
||||
# extraction param — see _instructions.py).
|
||||
_INSTR=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('instructions',''))" 2>/dev/null || echo "")
|
||||
if [ -n "$_INSTR" ]; then
|
||||
echo ""
|
||||
echo "Project memory policy (from mem0.md): ${_INSTR}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -132,28 +132,6 @@ def parse_section_list(content: str, heading: str) -> list[str]:
|
||||
return items
|
||||
|
||||
|
||||
def parse_section_text(content: str, heading: str) -> str:
|
||||
"""Parse a free-text prose section from mem0.md.
|
||||
|
||||
Looks for ``## <heading>`` (case-insensitive) and returns the prose beneath
|
||||
it (up to the next ``##`` heading) collapsed to a single line. Blank lines and
|
||||
full-line ``#`` comments are dropped; inline ``#`` is preserved (prose may
|
||||
reference e.g. issue ``#123``).
|
||||
"""
|
||||
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
|
||||
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
||||
if not match:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
for line in match.group(1).splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
lines.append(stripped)
|
||||
return " ".join(lines).strip()
|
||||
|
||||
|
||||
def parse_ignore_patterns(content: str) -> list[str]:
|
||||
"""Parse the ``## Ignore`` section of *content*.
|
||||
|
||||
@@ -225,16 +203,6 @@ def load_full_config(cwd: str | None = None) -> dict:
|
||||
if settings:
|
||||
config["settings"] = settings
|
||||
|
||||
# Extraction policy: `## Instructions` -> custom_instructions (user/project
|
||||
# scope), `## Agent Instructions` -> agent_custom_instructions (agent scope).
|
||||
instructions = parse_section_text(content, "Instructions")
|
||||
if instructions:
|
||||
config["instructions"] = instructions
|
||||
|
||||
agent_instructions = parse_section_text(content, "Agent Instructions")
|
||||
if agent_instructions:
|
||||
config["agent_instructions"] = agent_instructions
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ _PLATFORM_MANIFESTS = {
|
||||
"claude-code": ("..", ".claude-plugin", "plugin.json"),
|
||||
"cursor": ("..", ".cursor-plugin", "plugin.json"),
|
||||
"codex": ("..", ".codex-plugin", "plugin.json"),
|
||||
"kimi": ("..", ".kimi-plugin", "plugin.json"),
|
||||
}
|
||||
_DEFAULT_MANIFEST = ("..", ".claude-plugin", "plugin.json")
|
||||
|
||||
@@ -75,8 +74,6 @@ def detect_platform() -> str:
|
||||
return explicit
|
||||
if os.environ.get("ANTIGRAVITY_PLUGIN_ROOT"):
|
||||
return "antigravity"
|
||||
if os.environ.get("KIMI_PLUGIN_ROOT"):
|
||||
return "kimi"
|
||||
if os.environ.get("PLUGIN_ROOT"):
|
||||
return "codex"
|
||||
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
name: policy
|
||||
description: Views or sets the project's memory-extraction policy (what Mem0 remembers vs ignores) stored in mem0.md. Use when the user says set a memory policy, custom instructions, what should mem0 remember, tell mem0 to ignore X, or asks to see/change the current instructions.
|
||||
---
|
||||
|
||||
# Mem0 Policy
|
||||
|
||||
The project's memory policy lives in a `## Instructions` section of `mem0.md` at
|
||||
the repo root. It is version-controlled and shared by the whole team, and it maps
|
||||
to Mem0's `custom_instructions` (what to extract / ignore). An optional
|
||||
`## Agent Instructions` section maps to `agent_custom_instructions` (guidance for
|
||||
agent-scoped memories only).
|
||||
|
||||
The policy takes effect on the **next session** (mem0.md is re-parsed on
|
||||
SessionStart) and is applied automatically on the plugin's memory writes.
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: Determine intent from the argument
|
||||
|
||||
`/mem0:policy` is invoked as `/mem0:policy [<subcommand>] <text>`:
|
||||
|
||||
| Argument | Action |
|
||||
|---|---|
|
||||
| _(none)_ or `show` | Show the current policy |
|
||||
| `set <text>` or free text | Set/replace the `## Instructions` (custom) policy |
|
||||
| `agent <text>` | Set/replace the `## Agent Instructions` policy |
|
||||
| `clear` | Remove both policy sections |
|
||||
|
||||
### Step 2: Locate mem0.md
|
||||
|
||||
`mem0.md` sits at the repo root (the current working directory). If it does not
|
||||
exist yet and the user is setting a policy, you will create it.
|
||||
|
||||
### Step 3a: Show
|
||||
|
||||
Run `python3 "$CLAUDE_PLUGIN_ROOT/scripts/parse_mem0_config.py" --key instructions .`
|
||||
and `--key agent_instructions .` (or read `mem0.md` directly). Print:
|
||||
|
||||
```
|
||||
Memory policy for this project (mem0.md):
|
||||
Instructions: <text, or "(none)">
|
||||
Agent Instructions: <text, or "(none)">
|
||||
```
|
||||
|
||||
If `mem0.md` is absent, say there is no policy yet and offer to set one.
|
||||
|
||||
### Step 3b: Set / clear
|
||||
|
||||
Edit `mem0.md`, adding or replacing the target section. Keep the instruction a
|
||||
short prose paragraph (what to remember, what to ignore). Example:
|
||||
|
||||
```markdown
|
||||
## Instructions
|
||||
Remember architecture decisions, API contracts, and team conventions.
|
||||
Ignore transient debugging output, stack traces, and anything resembling a secret.
|
||||
```
|
||||
|
||||
- Preserve any other existing sections (`## Retention`, `## Categories`, etc.).
|
||||
- If the section already exists, replace its body; otherwise append the section.
|
||||
- For `clear`, delete the `## Instructions` and `## Agent Instructions` sections.
|
||||
|
||||
### Step 4: Confirm
|
||||
|
||||
```
|
||||
Updated memory policy in mem0.md.
|
||||
Takes effect next session. Commit mem0.md to share it with your team.
|
||||
```
|
||||
|
||||
Remind the user that `## Agent Instructions` only affects agent-scoped memories,
|
||||
so it is a no-op unless memories are written with an `agent_id`.
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Tests for the mem0.md extraction-policy feature.
|
||||
|
||||
Covers the parser (`parse_section_text` + the Instructions sections in
|
||||
`load_full_config`) and the `_instructions.load_instructions` helper that the
|
||||
hook writers use to attach `custom_instructions` / `agent_custom_instructions`
|
||||
to a memory write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MEM0_MD = """\
|
||||
# mem0.md
|
||||
|
||||
## Categories
|
||||
- architecture_decisions
|
||||
|
||||
## Instructions
|
||||
Remember architecture decisions and conventions.
|
||||
Ignore transient debug output and secrets (issue #123 refs are fine).
|
||||
|
||||
## Agent Instructions
|
||||
For agent memories, focus on tools and task outcomes.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_section_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_section_text_collapses_prose():
|
||||
from parse_mem0_config import parse_section_text
|
||||
|
||||
text = parse_section_text(MEM0_MD, "Instructions")
|
||||
assert text == (
|
||||
"Remember architecture decisions and conventions. "
|
||||
"Ignore transient debug output and secrets (issue #123 refs are fine)."
|
||||
)
|
||||
|
||||
|
||||
def test_parse_section_text_preserves_inline_hash():
|
||||
"""Inline '#' (e.g. issue refs) is kept; only full-line comments are dropped."""
|
||||
from parse_mem0_config import parse_section_text
|
||||
|
||||
assert "#123" in parse_section_text(MEM0_MD, "Instructions")
|
||||
|
||||
|
||||
def test_instructions_and_agent_instructions_are_distinct():
|
||||
"""'## Instructions' must not swallow '## Agent Instructions'."""
|
||||
from parse_mem0_config import parse_section_text
|
||||
|
||||
assert parse_section_text(MEM0_MD, "Instructions").startswith("Remember architecture")
|
||||
assert parse_section_text(MEM0_MD, "Agent Instructions") == (
|
||||
"For agent memories, focus on tools and task outcomes."
|
||||
)
|
||||
|
||||
|
||||
def test_parse_section_text_missing_returns_empty():
|
||||
from parse_mem0_config import parse_section_text
|
||||
|
||||
assert parse_section_text("## Retention\nx: 1d\n", "Instructions") == ""
|
||||
|
||||
|
||||
def test_load_full_config_includes_instructions(tmp_path):
|
||||
from parse_mem0_config import load_full_config
|
||||
|
||||
(tmp_path / "mem0.md").write_text(MEM0_MD)
|
||||
config = load_full_config(str(tmp_path))
|
||||
assert config["instructions"].startswith("Remember architecture")
|
||||
assert config["agent_instructions"].startswith("For agent memories")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_instructions (the helper the hook writers call)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_instructions_maps_to_api_field_names(tmp_path):
|
||||
from _instructions import load_instructions
|
||||
|
||||
(tmp_path / "mem0.md").write_text(MEM0_MD)
|
||||
out = load_instructions(str(tmp_path))
|
||||
assert out == {
|
||||
"custom_instructions": (
|
||||
"Remember architecture decisions and conventions. "
|
||||
"Ignore transient debug output and secrets (issue #123 refs are fine)."
|
||||
),
|
||||
"agent_custom_instructions": "For agent memories, focus on tools and task outcomes.",
|
||||
}
|
||||
|
||||
|
||||
def test_load_instructions_no_config_is_empty(tmp_path):
|
||||
"""No mem0.md -> empty dict, so a write body gains nothing."""
|
||||
from _instructions import load_instructions
|
||||
|
||||
assert load_instructions(str(tmp_path)) == {}
|
||||
|
||||
|
||||
def test_load_instructions_only_custom(tmp_path):
|
||||
"""A project with only '## Instructions' omits the agent key entirely."""
|
||||
from _instructions import load_instructions
|
||||
|
||||
(tmp_path / "mem0.md").write_text("## Instructions\nRemember decisions.\n")
|
||||
out = load_instructions(str(tmp_path))
|
||||
assert out == {"custom_instructions": "Remember decisions."}
|
||||
|
||||
|
||||
def test_load_instructions_defaults_cwd_to_env(tmp_path, monkeypatch):
|
||||
"""With no arg, cwd falls back to MEM0_CWD."""
|
||||
from _instructions import load_instructions
|
||||
|
||||
(tmp_path / "mem0.md").write_text("## Instructions\nRemember decisions.\n")
|
||||
monkeypatch.setenv("MEM0_CWD", str(tmp_path))
|
||||
assert load_instructions() == {"custom_instructions": "Remember decisions."}
|
||||
|
||||
|
||||
def test_load_instructions_body_merge_shape(tmp_path):
|
||||
"""The result merges cleanly into an add body without clobbering other keys."""
|
||||
from _instructions import load_instructions
|
||||
|
||||
(tmp_path / "mem0.md").write_text("## Instructions\nRemember decisions.\n")
|
||||
body = {"messages": [], "user_id": "u", "infer": True}
|
||||
body.update(load_instructions(str(tmp_path)))
|
||||
assert body["user_id"] == "u" and body["infer"] is True
|
||||
assert body["custom_instructions"] == "Remember decisions."
|
||||
assert "agent_custom_instructions" not in body
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Validate the Kimi Code plugin manifests and hook wiring.
|
||||
|
||||
Kimi Code loads a plugin from `.kimi-plugin/plugin.json`. This test locks down
|
||||
the structural contract so a stray edit cannot silently break the plugin:
|
||||
|
||||
- both manifests parse and carry the required Kimi fields
|
||||
- the MCP server uses Kimi's supported auth (transport/url/bearerTokenEnvVar)
|
||||
- every hook command routes through kimi_hook_shim.sh and targets a script
|
||||
that actually exists in scripts/
|
||||
- the sessionStart skill and skills/ path resolve on disk
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(PLUGIN_DIR))
|
||||
|
||||
SUBDIR_MANIFEST = os.path.join(PLUGIN_DIR, ".kimi-plugin", "plugin.json")
|
||||
ROOT_MARKETPLACE = os.path.join(REPO_ROOT, ".kimi-plugin", "marketplace.json")
|
||||
SHIM = os.path.join(PLUGIN_DIR, "scripts", "kimi_hook_shim.sh")
|
||||
|
||||
|
||||
def _load(path):
|
||||
with open(path) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def test_manifest_parses_and_has_required_fields():
|
||||
m = _load(SUBDIR_MANIFEST)
|
||||
assert m["name"] == "mem0"
|
||||
for field in ("version", "description", "skills", "mcpServers", "interface", "hooks"):
|
||||
assert field in m, f"manifest missing {field}"
|
||||
|
||||
|
||||
def test_mcp_uses_kimi_supported_auth():
|
||||
srv = _load(SUBDIR_MANIFEST)["mcpServers"]["mem0"]
|
||||
assert srv["transport"] == "http"
|
||||
assert srv["url"] == "https://mcp.mem0.ai/mcp/" # trailing slash avoids the 307 redirect
|
||||
# Kimi reads the named env var and sends Authorization: Bearer <value>.
|
||||
assert srv["bearerTokenEnvVar"] == "MEM0_API_KEY"
|
||||
assert "headers" not in srv # Kimi does not interpolate ${env:...} inside headers
|
||||
|
||||
|
||||
def test_sessionstart_skill_and_skills_dir_resolve():
|
||||
m = _load(SUBDIR_MANIFEST)
|
||||
assert m["skills"] == "./skills/"
|
||||
assert os.path.isdir(os.path.join(PLUGIN_DIR, "skills"))
|
||||
start_skill = m["sessionStart"]["skill"]
|
||||
assert os.path.isdir(os.path.join(PLUGIN_DIR, "skills", start_skill))
|
||||
|
||||
|
||||
def test_shim_exists_and_is_executable():
|
||||
assert os.path.isfile(SHIM)
|
||||
assert os.access(SHIM, os.X_OK), "kimi_hook_shim.sh must be executable"
|
||||
|
||||
|
||||
def test_every_hook_routes_through_shim_to_a_real_script():
|
||||
hooks = _load(SUBDIR_MANIFEST)["hooks"]
|
||||
assert hooks, "no hooks declared"
|
||||
valid_events = {
|
||||
"SessionStart", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "PreCompact",
|
||||
}
|
||||
for hook in hooks:
|
||||
assert hook["event"] in valid_events, f"unknown event {hook['event']}"
|
||||
cmd = hook["command"]
|
||||
assert "kimi_hook_shim.sh" in cmd, f"hook must route through the shim: {cmd}"
|
||||
assert "$KIMI_PLUGIN_ROOT/scripts/kimi_hook_shim.sh" in cmd
|
||||
target = cmd.rsplit('"', 1)[-1].strip() # the script name after the shim
|
||||
assert target.endswith(".sh")
|
||||
assert os.path.isfile(os.path.join(PLUGIN_DIR, "scripts", target)), \
|
||||
f"hook target script missing: {target}"
|
||||
|
||||
|
||||
def test_root_marketplace_catalog():
|
||||
cat = _load(ROOT_MARKETPLACE)
|
||||
entry = cat["plugins"][0]
|
||||
assert entry["id"] == "mem0" # Kimi marketplace entries key on id
|
||||
assert entry["source"].startswith("https://github.com/mem0ai/mem0")
|
||||
@@ -157,22 +157,6 @@ def test_platform_codex(monkeypatch):
|
||||
assert telemetry.detect_platform() == "codex"
|
||||
|
||||
|
||||
def test_platform_kimi(monkeypatch):
|
||||
"""Kimi Code usage must be attributed to its own platform so it can be
|
||||
counted distinctly. The shim also pins MEM0_PLATFORM=kimi; this covers the
|
||||
KIMI_PLUGIN_ROOT auto-detection fallback."""
|
||||
import telemetry
|
||||
|
||||
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
|
||||
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.delenv("CLAUDECODE", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.delenv("PLUGIN_ROOT", raising=False)
|
||||
monkeypatch.setenv("KIMI_PLUGIN_ROOT", "/path")
|
||||
assert telemetry.detect_platform() == "kimi"
|
||||
|
||||
|
||||
def test_platform_explicit_override(monkeypatch):
|
||||
"""MEM0_PLATFORM wins over auto-detection so each editor can label
|
||||
itself reliably even when host env vars are ambiguous or absent."""
|
||||
@@ -206,7 +190,6 @@ def test_plugin_version_is_per_editor(monkeypatch):
|
||||
"claude-code": os.path.join(".claude-plugin", "plugin.json"),
|
||||
"cursor": os.path.join(".cursor-plugin", "plugin.json"),
|
||||
"codex": os.path.join(".codex-plugin", "plugin.json"),
|
||||
"kimi": os.path.join(".kimi-plugin", "plugin.json"),
|
||||
}
|
||||
for plat, rel in manifests.items():
|
||||
monkeypatch.setenv("MEM0_PLATFORM", plat)
|
||||
|
||||
@@ -1,21 +1,201 @@
|
||||
MIT License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2023-2026 Taranjeet Singh
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
1. Definitions.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"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 [2026] [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.
|
||||
|
||||
@@ -11,7 +11,7 @@ export class Mem0Api implements ICredentialType {
|
||||
|
||||
displayName = 'Mem0 API';
|
||||
|
||||
icon: Icon = { light: 'file:mem0.svg', dark: 'file:mem0.svg' };
|
||||
icon: Icon = 'file:mem0.svg';
|
||||
|
||||
documentationUrl = 'https://docs.mem0.ai/platform/quickstart';
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export class Mem0 implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Mem0',
|
||||
name: 'mem0',
|
||||
icon: { light: 'file:mem0.svg', dark: 'file:mem0.svg' },
|
||||
icon: 'file:mem0.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/n8n-nodes-mem0",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.1",
|
||||
"description": "n8n community node for Mem0 — the memory layer for AI agents. Add, search, get, update, and delete long-term memories.",
|
||||
"keywords": [
|
||||
"n8n-community-node-package",
|
||||
@@ -10,11 +10,11 @@
|
||||
"agents",
|
||||
"llm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://mem0.ai",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
"email": "integrations@mem0.ai"
|
||||
"email": "founders@mem0.ai"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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.0": ">=7.29.0 <8.0.0",
|
||||
"undici@<6.27.0": ">=6.27.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.0: '>=7.29.0 <8.0.0'
|
||||
undici@<6.27.0: '>=6.27.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.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
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.29.0
|
||||
undici: 7.28.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -4121,7 +4121,7 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@7.29.0: {}
|
||||
undici@7.28.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.0": ">=7.29.0 <8.0.0"
|
||||
"undici@<6.27.0": ">=6.27.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"
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
"form-data@<4.0.6": ">=4.0.6",
|
||||
"uuid@<11.1.1": ">=11.1.1",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0",
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.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.0: '>=7.29.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0'
|
||||
undici@<6.27.0: '>=6.27.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.5.0: '>=8.5.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.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@8.10.0:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
undici@8.5.0:
|
||||
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
|
||||
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.10.0
|
||||
undici: 8.5.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.29.0
|
||||
undici: 7.28.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -4895,9 +4895,9 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
undici@7.29.0: {}
|
||||
undici@7.28.0: {}
|
||||
|
||||
undici@8.10.0: {}
|
||||
undici@8.5.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.0": ">=7.29.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0"
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.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,3 +0,0 @@
|
||||
// Zapier's Lambda wrapper requires `<root>/index.js` and ignores package.json
|
||||
// "main", so re-export the compiled app from the root. Run `npm run build` first.
|
||||
module.exports = require('./dist/index.js');
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/zapier",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"description": "Zapier integration for Mem0 — the memory layer for AI agents.",
|
||||
"keywords": [
|
||||
"zapier",
|
||||
@@ -20,7 +20,7 @@
|
||||
"directory": "integrations/zapier-mem0"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"main": "index.js",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest --testTimeout 180000",
|
||||
@@ -41,17 +41,10 @@
|
||||
],
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"testMatch": [
|
||||
"**/test/**/*.test.ts"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/test/setup.ts"
|
||||
],
|
||||
"testMatch": ["**/test/**/*.test.ts"],
|
||||
"setupFiles": ["<rootDir>/test/setup.ts"],
|
||||
"transform": {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
{}
|
||||
]
|
||||
"^.+\\.tsx?$": ["ts-jest", {}]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -69,9 +62,8 @@
|
||||
"form-data@<4.0.6": ">=4.0.6",
|
||||
"uuid@<11.1.1": ">=11.1.1",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0",
|
||||
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0"
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+6
-8
@@ -8,9 +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.0: '>=7.29.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0'
|
||||
brace-expansion@<1.1.16: '>=1.1.16 <2.0.0'
|
||||
undici@<6.27.0: '>=6.27.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.5.0: '>=8.5.0'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -414,8 +413,8 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
brace-expansion@1.1.15:
|
||||
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -531,7 +530,6 @@ packages:
|
||||
|
||||
crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
@@ -1997,7 +1995,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.41: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
brace-expansion@1.1.15:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
@@ -2794,7 +2792,7 @@ snapshots:
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
brace-expansion: 1.1.15
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
|
||||
@@ -5,6 +5,5 @@ overrides:
|
||||
"form-data@<4.0.6": ">=4.0.6"
|
||||
"uuid@<11.1.1": ">=11.1.1"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0"
|
||||
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0"
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
|
||||
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.1.6",
|
||||
"version": "3.1.3",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -86,7 +86,6 @@
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/oracledb": "^7.0.1",
|
||||
"@types/node": "^22.7.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -140,7 +139,6 @@
|
||||
"mongodb": "^7.0.0",
|
||||
"weaviate-client": "^3.0.0",
|
||||
"ollama": "^0.5.14",
|
||||
"oracledb": "^6.5.0 || ^7.0.0",
|
||||
"pg": "8.11.3",
|
||||
"redis": "^4.6.13",
|
||||
"@elastic/elasticsearch": "^9.0.0",
|
||||
@@ -254,9 +252,6 @@
|
||||
},
|
||||
"iovalkey": {
|
||||
"optional": true
|
||||
},
|
||||
"oracledb": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -292,19 +287,18 @@
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.4",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"undici@<6.27.0": ">=6.27.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",
|
||||
"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",
|
||||
"brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2 <3.0.0",
|
||||
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0",
|
||||
"fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0",
|
||||
"tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0",
|
||||
"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.3.1": ">=10.3.1 <11.0.0"
|
||||
"protobufjs@<7.6.5": ">=7.6.5 <8.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+16
-36
@@ -25,19 +25,18 @@ overrides:
|
||||
glob@>=10.2.0 <10.5.0: ^10.5.0
|
||||
'@modelcontextprotocol/sdk': ^1.25.4
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<7.29.0: '>=7.29.0 <8.0.0'
|
||||
undici@<6.27.0: '>=6.27.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'
|
||||
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'
|
||||
brace-expansion@>=2.0.0 <2.1.2: '>=2.1.2 <3.0.0'
|
||||
brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0'
|
||||
fast-xml-parser@>=5.0.0 <5.10.1: '>=5.10.1 <6.0.0'
|
||||
tar@>=7.0.0 <7.5.21: '>=7.5.21 <8.0.0'
|
||||
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.3.1: '>=10.3.1 <11.0.0'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -154,9 +153,6 @@ importers:
|
||||
openai:
|
||||
specifier: ^4.93.0
|
||||
version: 4.104.0(ws@5.2.5)(zod@3.25.76)
|
||||
oracledb:
|
||||
specifier: ^6.5.0 || ^7.0.0
|
||||
version: 7.0.1
|
||||
pg:
|
||||
specifier: 8.11.3
|
||||
version: 8.11.3
|
||||
@@ -185,9 +181,6 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.7.6
|
||||
version: 22.19.21
|
||||
'@types/oracledb':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
'@types/uuid':
|
||||
specifier: ^9.0.8
|
||||
version: 9.0.8
|
||||
@@ -1918,9 +1911,6 @@ packages:
|
||||
'@types/normalize-package-data@2.4.4':
|
||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||
|
||||
'@types/oracledb@7.0.1':
|
||||
resolution: {integrity: sha512-0A6m9YE4yu73KXehr5D6cbyALUzZoANGY4bG5cAPQIpJoJG4eMVPbR1Vau8ycnC25V/+F4Au1pdFSy8ODOAD0w==}
|
||||
|
||||
'@types/pad-left@2.1.1':
|
||||
resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==}
|
||||
|
||||
@@ -2166,8 +2156,8 @@ packages:
|
||||
brace-expansion@1.1.16:
|
||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
brace-expansion@2.1.2:
|
||||
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
|
||||
|
||||
brace-expansion@5.0.8:
|
||||
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
|
||||
@@ -3028,8 +3018,8 @@ packages:
|
||||
resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
ip-address@10.4.0:
|
||||
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
is-arrayish@0.2.1:
|
||||
@@ -3806,10 +3796,6 @@ packages:
|
||||
openid-client@5.7.1:
|
||||
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||
|
||||
oracledb@7.0.1:
|
||||
resolution: {integrity: sha512-xlM0Ceh6A5stQLAdEfKf3pgCSkbOjQLo2ZPEi3+kXklz+KbZD3fLi/nsTSbQeZZNFBSFDNxdn1Ek3+bxG40M8w==}
|
||||
engines: {node: '>=14.17'}
|
||||
|
||||
p-finally@1.0.0:
|
||||
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4627,8 +4613,8 @@ packages:
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
@@ -6156,7 +6142,7 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
secure-json-parse: 4.1.0
|
||||
tslib: 2.8.1
|
||||
undici: 7.29.0
|
||||
undici: 7.28.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6712,7 +6698,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
||||
typescript: 5.5.4
|
||||
undici: 7.29.0
|
||||
undici: 7.28.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -7184,7 +7170,7 @@ snapshots:
|
||||
'@turbopuffer/turbopuffer@2.5.0':
|
||||
dependencies:
|
||||
pako: 2.2.0
|
||||
undici: 7.29.0
|
||||
undici: 7.28.0
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
@@ -7261,10 +7247,6 @@ snapshots:
|
||||
|
||||
'@types/normalize-package-data@2.4.4': {}
|
||||
|
||||
'@types/oracledb@7.0.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.21
|
||||
|
||||
'@types/pad-left@2.1.1': {}
|
||||
|
||||
'@types/pg@8.11.0':
|
||||
@@ -7543,7 +7525,7 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
brace-expansion@2.1.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -8449,7 +8431,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ip-address@10.4.0: {}
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
@@ -9077,7 +9059,7 @@ snapshots:
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -9367,8 +9349,6 @@ snapshots:
|
||||
object-hash: 2.2.0
|
||||
oidc-token-hash: 5.2.0
|
||||
|
||||
oracledb@7.0.1: {}
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
|
||||
p-limit@2.3.0:
|
||||
@@ -9887,7 +9867,7 @@ snapshots:
|
||||
|
||||
socks@2.8.9:
|
||||
dependencies:
|
||||
ip-address: 10.4.0
|
||||
ip-address: 10.2.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
source-map-support@0.5.13:
|
||||
@@ -10233,7 +10213,7 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
undici@7.29.0: {}
|
||||
undici@7.28.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
|
||||
@@ -25,14 +25,13 @@ overrides:
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0"
|
||||
"@modelcontextprotocol/sdk": "^1.25.4"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"undici@<6.27.0": ">=6.27.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"
|
||||
"brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2 <3.0.0"
|
||||
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0"
|
||||
"fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0"
|
||||
"tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0"
|
||||
"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.3.1": ">=10.3.1 <11.0.0"
|
||||
|
||||
+35
-87
@@ -81,20 +81,8 @@ class APIError extends Error {
|
||||
interface ClientOptions {
|
||||
apiKey: string;
|
||||
host?: string;
|
||||
/** Max cached identities per process. Defaults to 50. */
|
||||
identityCacheMax?: number;
|
||||
}
|
||||
|
||||
interface ClientIdentity {
|
||||
telemetryId: string;
|
||||
organizationId: string | number | null;
|
||||
projectId: string | number | null;
|
||||
}
|
||||
|
||||
// Shares one ping per (host, api key) across clients; FIFO-capped.
|
||||
const IDENTITY_CACHE_MAX_DEFAULT = 50;
|
||||
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
|
||||
|
||||
export default class MemoryClient {
|
||||
apiKey: string;
|
||||
host: string;
|
||||
@@ -103,8 +91,6 @@ export default class MemoryClient {
|
||||
headers: Record<string, string>;
|
||||
client: any;
|
||||
telemetryId: string;
|
||||
private initialized: Promise<void>;
|
||||
private identityCacheMax: number;
|
||||
|
||||
_validateApiKey(): any {
|
||||
if (!this.apiKey) {
|
||||
@@ -123,8 +109,6 @@ export default class MemoryClient {
|
||||
this.host = options.host || "https://api.mem0.ai";
|
||||
this.organizationId = null;
|
||||
this.projectId = null;
|
||||
this.identityCacheMax =
|
||||
options.identityCacheMax ?? IDENTITY_CACHE_MAX_DEFAULT;
|
||||
|
||||
this.headers = {
|
||||
Authorization: `Token ${this.apiKey}`,
|
||||
@@ -138,44 +122,11 @@ export default class MemoryClient {
|
||||
});
|
||||
|
||||
this._validateApiKey();
|
||||
|
||||
this.telemetryId = "";
|
||||
|
||||
// Memory requests never wait on this; telemetry and _awaitIdentity do.
|
||||
this.initialized = this._resolveIdentity();
|
||||
this._initializeClient();
|
||||
}
|
||||
|
||||
// One ping per credential pair per process, shared via identityByCredentials.
|
||||
private _resolveIdentity(): Promise<void> {
|
||||
const credentials = `${this.host}\u0000${this.apiKey}`;
|
||||
let shared = identityByCredentials.get(credentials);
|
||||
if (!shared) {
|
||||
shared = this._initializeClient();
|
||||
if (identityByCredentials.size >= this.identityCacheMax) {
|
||||
identityByCredentials.delete(
|
||||
identityByCredentials.keys().next().value!,
|
||||
);
|
||||
}
|
||||
identityByCredentials.set(credentials, shared);
|
||||
// A failed ping must not be cached, or the process never recovers.
|
||||
shared.then((identity) => {
|
||||
if (!identity.telemetryId) identityByCredentials.delete(credentials);
|
||||
});
|
||||
}
|
||||
return shared.then((identity) => {
|
||||
this.telemetryId = identity.telemetryId;
|
||||
if (identity.organizationId != null)
|
||||
this.organizationId = identity.organizationId;
|
||||
if (identity.projectId != null) this.projectId = identity.projectId;
|
||||
});
|
||||
}
|
||||
|
||||
// Blocks until the ping has populated organizationId/projectId.
|
||||
private async _awaitIdentity(): Promise<void> {
|
||||
await this.initialized;
|
||||
}
|
||||
|
||||
private async _initializeClient(): Promise<ClientIdentity> {
|
||||
private async _initializeClient() {
|
||||
try {
|
||||
await this.ping();
|
||||
|
||||
@@ -197,12 +148,6 @@ export default class MemoryClient {
|
||||
stack: error?.stack || "No stack trace",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
telemetryId: this.telemetryId,
|
||||
organizationId: this.organizationId,
|
||||
projectId: this.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
private async _maybeAliasAnonToEmail(): Promise<void> {
|
||||
@@ -230,18 +175,13 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
private _captureEvent(methodName: string, args: any[]) {
|
||||
// Deferred until ping() has resolved telemetryId, off the request path.
|
||||
this.initialized
|
||||
.then(() =>
|
||||
captureClientEvent(methodName, this, {
|
||||
success: true,
|
||||
args_count: args.length,
|
||||
keys: args.length > 0 ? args[0] : [],
|
||||
}),
|
||||
)
|
||||
.catch((error: any) => {
|
||||
console.error("Failed to capture event:", error);
|
||||
});
|
||||
captureClientEvent(methodName, this, {
|
||||
success: true,
|
||||
args_count: args.length,
|
||||
keys: args.length > 0 ? args[0] : [],
|
||||
}).catch((error: any) => {
|
||||
console.error("Failed to capture event:", error);
|
||||
});
|
||||
}
|
||||
|
||||
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
|
||||
@@ -322,6 +262,8 @@ export default class MemoryClient {
|
||||
throw new Error("Cannot process an empty messages payload.");
|
||||
}
|
||||
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
|
||||
const payload = this._preparePayload(messages, options);
|
||||
const payloadKeys = Object.keys(payload);
|
||||
this._captureEvent("add", [payloadKeys]);
|
||||
@@ -362,6 +304,7 @@ export default class MemoryClient {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
const payload: Record<string, any> = {};
|
||||
if (text !== undefined) payload.text = text;
|
||||
if (metadata !== undefined) payload.metadata = metadata;
|
||||
@@ -383,6 +326,7 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<Memory> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("get", []);
|
||||
return this._fetchWithErrorHandling(
|
||||
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
|
||||
@@ -396,6 +340,7 @@ export default class MemoryClient {
|
||||
// Reject top-level entity params - must use filters instead
|
||||
rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
|
||||
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("get_all", [payloadKeys]);
|
||||
const { page, pageSize, filters, ...rest } = options ?? {};
|
||||
@@ -424,6 +369,7 @@ export default class MemoryClient {
|
||||
// Reject top-level entity params - must use filters instead
|
||||
rejectTopLevelEntityParams(options as Record<string, any>, "search");
|
||||
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("search", [payloadKeys]);
|
||||
const { filters, ...rest } = options ?? {};
|
||||
@@ -449,6 +395,7 @@ export default class MemoryClient {
|
||||
memoryId: string,
|
||||
options: DeleteMemoryOptions = {},
|
||||
): Promise<{ message: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("delete", [Object.keys(options || {})]);
|
||||
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
|
||||
// @ts-ignore
|
||||
@@ -465,6 +412,7 @@ export default class MemoryClient {
|
||||
async deleteAll(
|
||||
options: DeleteAllMemoryOptions = {},
|
||||
): Promise<{ message: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("delete_all", [payloadKeys]);
|
||||
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
|
||||
@@ -481,6 +429,7 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<Array<MemoryHistory>> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("history", []);
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
|
||||
@@ -495,6 +444,7 @@ export default class MemoryClient {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<AllUsers> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("users", []);
|
||||
let url = `${this.host}/v1/entities/`;
|
||||
const params: string[] = [];
|
||||
@@ -514,6 +464,7 @@ export default class MemoryClient {
|
||||
entity_id: number;
|
||||
entity_type: string;
|
||||
}): Promise<{ message: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("delete_user", []);
|
||||
if (!data.entity_type) {
|
||||
data.entity_type = "user";
|
||||
@@ -536,6 +487,8 @@ export default class MemoryClient {
|
||||
runId?: string;
|
||||
} = {},
|
||||
): Promise<{ message: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
|
||||
let to_delete: Array<{ type: string; name: string }> = [];
|
||||
const { userId, agentId, appId, runId } = params;
|
||||
|
||||
@@ -561,14 +514,8 @@ export default class MemoryClient {
|
||||
|
||||
for (const entity of to_delete) {
|
||||
try {
|
||||
// fetch() reuses the pooled connection; axios here defaulted to
|
||||
// keepAlive: false, one handshake per entity.
|
||||
await this._fetchWithErrorHandling(
|
||||
`${this.host}/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: this.headers,
|
||||
},
|
||||
await this.client.delete(
|
||||
`/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
throw new APIError(
|
||||
@@ -590,6 +537,7 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("batch_update", []);
|
||||
const memoriesBody = memories.map((memory) => ({
|
||||
memory_id: memory.memoryId,
|
||||
@@ -607,6 +555,7 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
async batchDelete(memories: Array<string>): Promise<string> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("batch_delete", []);
|
||||
const memoriesBody = memories.map((memory) => ({
|
||||
memory_id: memory,
|
||||
@@ -623,10 +572,10 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
async getProject(options: ProjectOptions): Promise<ProjectResponse> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("get_project", [payloadKeys]);
|
||||
const { fields } = options;
|
||||
await this._awaitIdentity();
|
||||
|
||||
if (!(this.organizationId && this.projectId)) {
|
||||
throw new Error(
|
||||
@@ -649,8 +598,8 @@ export default class MemoryClient {
|
||||
async updateProject(
|
||||
prompts: PromptUpdatePayload,
|
||||
): Promise<Record<string, any>> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("update_project", []);
|
||||
await this._awaitIdentity();
|
||||
if (!(this.organizationId && this.projectId)) {
|
||||
throw new Error(
|
||||
"organizationId and projectId must be set to update instructions or categories",
|
||||
@@ -670,12 +619,9 @@ export default class MemoryClient {
|
||||
|
||||
// WebHooks
|
||||
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("get_webhooks", []);
|
||||
if (!data?.projectId) await this._awaitIdentity();
|
||||
const project_id = data?.projectId || this.projectId;
|
||||
if (!project_id) {
|
||||
throw new Error("projectId must be set to access webhooks");
|
||||
}
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/api/v1/webhooks/projects/${project_id}/`,
|
||||
{
|
||||
@@ -686,11 +632,8 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("create_webhook", []);
|
||||
await this._awaitIdentity();
|
||||
if (!this.projectId) {
|
||||
throw new Error("projectId must be set to create a webhook");
|
||||
}
|
||||
const body = {
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
@@ -710,6 +653,7 @@ export default class MemoryClient {
|
||||
async updateWebhook(
|
||||
webhook: WebhookUpdatePayload,
|
||||
): Promise<{ message: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("update_webhook", []);
|
||||
const body: Record<string, any> = {};
|
||||
if (webhook.name != null) body.name = webhook.name;
|
||||
@@ -729,6 +673,7 @@ export default class MemoryClient {
|
||||
async deleteWebhook(data: {
|
||||
webhookId: string;
|
||||
}): Promise<{ message: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("delete_webhook", []);
|
||||
const webhook_id = data.webhookId || data;
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
@@ -742,6 +687,7 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
async feedback(data: FeedbackPayload): Promise<{ message: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
const payloadKeys = Object.keys(data || {});
|
||||
this._captureEvent("feedback", [payloadKeys]);
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
@@ -758,6 +704,7 @@ export default class MemoryClient {
|
||||
async createMemoryExport(
|
||||
data: CreateMemoryExportPayload,
|
||||
): Promise<{ message: string; id: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("create_memory_export", []);
|
||||
|
||||
if (!data.filters || !data.schema) {
|
||||
@@ -787,6 +734,7 @@ export default class MemoryClient {
|
||||
async getMemoryExport(
|
||||
data: GetMemoryExportPayload,
|
||||
): Promise<{ message: string; id: string }> {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("get_memory_export", []);
|
||||
|
||||
if (!data.memoryExportId && !data.filters) {
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface AddMemoryOptions extends EntityOptions {
|
||||
infer?: boolean;
|
||||
customCategories?: custom_categories[];
|
||||
customInstructions?: string;
|
||||
agentCustomInstructions?: string;
|
||||
timestamp?: number;
|
||||
expirationDate?: string;
|
||||
structuredDataSchema?: Record<string, any>;
|
||||
@@ -61,7 +60,6 @@ export interface ProjectOptions {
|
||||
|
||||
export interface PromptUpdatePayload {
|
||||
customInstructions?: string;
|
||||
agentCustomInstructions?: string;
|
||||
customCategories?: custom_categories[];
|
||||
version?: string;
|
||||
memoryDepth?: string | null;
|
||||
@@ -176,7 +174,6 @@ export interface PaginatedMemories {
|
||||
|
||||
export interface ProjectResponse {
|
||||
customInstructions?: string;
|
||||
agentCustomInstructions?: string;
|
||||
// The API returns category objects (`[{ "<name>": "<description>" }]`),
|
||||
// not bare strings (see issue #5738).
|
||||
customCategories?: custom_categories[];
|
||||
|
||||
@@ -74,35 +74,6 @@ describe("MemoryClient - add()", () => {
|
||||
expect(getFetchBody(call!).expiration_date).toBe("2030-01-31");
|
||||
});
|
||||
|
||||
test("serializes agentCustomInstructions as agent_custom_instructions", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "test" }], {
|
||||
agentId: "a1",
|
||||
agentCustomInstructions: "Remember tool failures",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
|
||||
expect(getFetchBody(call!).agent_custom_instructions).toBe(
|
||||
"Remember tool failures",
|
||||
);
|
||||
});
|
||||
|
||||
test("omits agent_custom_instructions when it is not passed", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "test" }], { userId: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
|
||||
expect(getFetchBody(call!)).not.toHaveProperty("agent_custom_instructions");
|
||||
});
|
||||
|
||||
test("throws an error when given an empty messages array", async () => {
|
||||
setupMockFetch();
|
||||
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — org/project identity resolution ordering.
|
||||
*
|
||||
* Each test calls the method under test as the first awaited operation on a
|
||||
* fresh client. Any earlier await on the instance resolves identity and voids
|
||||
* the test.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
|
||||
import { setupMockFetch, installConsoleSuppression } from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// Distinct key per client keeps each test off the module-scope identity cache.
|
||||
let keySeq = 0;
|
||||
const freshClient = () =>
|
||||
new MemoryClient({ apiKey: `test-api-key-identity-${keySeq++}` });
|
||||
|
||||
// Selects by path; the ping and PostHog calls also land in the mock.
|
||||
const findUrlOrNone = (mock: jest.Mock, needle: string): string | undefined =>
|
||||
mock.mock.calls
|
||||
.map((c: [string, RequestInit]) => c[0])
|
||||
.find((u: string) => u.includes(needle));
|
||||
|
||||
const findUrl = (mock: jest.Mock, needle: string): string => {
|
||||
const url = findUrlOrNone(mock, needle);
|
||||
expect(url).toBeDefined();
|
||||
return url as string;
|
||||
};
|
||||
|
||||
describe("MemoryClient - project-scoped URLs on a fresh client", () => {
|
||||
test("getProject uses the resolved org and project ids", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Be helpful" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
await freshClient().getProject({ fields: ["custom_instructions"] });
|
||||
|
||||
const url = findUrl(mock, "/api/v1/orgs/organizations/");
|
||||
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
|
||||
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
|
||||
});
|
||||
|
||||
test("updateProject uses the resolved org and project ids", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
await freshClient().updateProject({ customInstructions: "Updated" });
|
||||
|
||||
const url = findUrl(mock, "/api/v1/orgs/organizations/");
|
||||
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
|
||||
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
|
||||
});
|
||||
|
||||
test("getWebhooks targets the resolved project, not null", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
await freshClient().getWebhooks();
|
||||
|
||||
const url = findUrl(mock, "/api/v1/webhooks/projects/");
|
||||
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
|
||||
expect(url).not.toContain("/projects/null/");
|
||||
expect(url).not.toContain("/projects/undefined/");
|
||||
});
|
||||
|
||||
test("createWebhook targets the resolved project, not null", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
await freshClient().createWebhook({
|
||||
name: "hook",
|
||||
url: "https://example.com/hook",
|
||||
eventTypes: ["memory_add"],
|
||||
});
|
||||
|
||||
const url = findUrl(mock, "/api/v1/webhooks/projects/");
|
||||
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
|
||||
expect(url).not.toContain("/projects/null/");
|
||||
expect(url).not.toContain("/projects/undefined/");
|
||||
});
|
||||
|
||||
test("an explicit projectId is honored without waiting on identity", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
await freshClient().getWebhooks({ projectId: "proj_explicit_789" });
|
||||
|
||||
expect(findUrl(mock, "/api/v1/webhooks/projects/")).toContain(
|
||||
"/api/v1/webhooks/projects/proj_explicit_789/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - memory endpoints do not wait on identity", () => {
|
||||
test("search completes while the ping is still pending", async () => {
|
||||
// The ping never settles.
|
||||
const mock = jest.fn((url: string) => {
|
||||
if (url.includes("/v1/ping/")) return new Promise(() => {});
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ results: [] }),
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
});
|
||||
global.fetch = mock as unknown as typeof global.fetch;
|
||||
|
||||
await expect(
|
||||
freshClient().search("query", { filters: { user_id: "alice" } }),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(findUrl(mock, "/v3/memories/search/")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - identity cache overflow", () => {
|
||||
const pingCount = (mock: jest.Mock) =>
|
||||
mock.mock.calls.filter((c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/ping/"),
|
||||
).length;
|
||||
|
||||
// The cache is module scope, so each test needs its own module registry.
|
||||
const isolatedClient = (): typeof MemoryClient => {
|
||||
jest.resetModules();
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require("../mem0").MemoryClient;
|
||||
};
|
||||
|
||||
const projectOk = () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", { status: 200, body: {} });
|
||||
return setupMockFetch(extra);
|
||||
};
|
||||
|
||||
test("a reused credential pair pings once", async () => {
|
||||
const mock = projectOk();
|
||||
const Client = isolatedClient();
|
||||
|
||||
const opts = { apiKey: "cache-warm-key", identityCacheMax: 1 };
|
||||
await new Client(opts).getProject({ fields: [] });
|
||||
await new Client(opts).getProject({ fields: [] });
|
||||
|
||||
expect(pingCount(mock)).toBe(1);
|
||||
});
|
||||
|
||||
test("an evicted credential pair re-pings and still resolves identity", async () => {
|
||||
const mock = projectOk();
|
||||
const Client = isolatedClient();
|
||||
const opts = (apiKey: string) => ({ apiKey, identityCacheMax: 1 });
|
||||
|
||||
await new Client(opts("key-a")).getProject({ fields: [] });
|
||||
// Evicts key-a.
|
||||
await new Client(opts("key-b")).getProject({ fields: [] });
|
||||
expect(pingCount(mock)).toBe(2);
|
||||
|
||||
await new Client(opts("key-a")).getProject({ fields: [] });
|
||||
|
||||
// Eviction costs a ping; it never yields an unresolved identity.
|
||||
expect(pingCount(mock)).toBe(3);
|
||||
expect(findUrl(mock, "/api/v1/orgs/organizations/")).toContain(
|
||||
`/api/v1/orgs/organizations/${TEST_ORG_ID}/`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - unresolved identity", () => {
|
||||
const pingFails = () => {
|
||||
const mock = jest.fn((url: string) => {
|
||||
if (url.includes("/v1/ping/")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("boom"),
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([]),
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
});
|
||||
global.fetch = mock as unknown as typeof global.fetch;
|
||||
return mock;
|
||||
};
|
||||
|
||||
test("getProject reports the unset ids", async () => {
|
||||
pingFails();
|
||||
await expect(freshClient().getProject({ fields: [] })).rejects.toThrow(
|
||||
"organizationId and projectId must be set",
|
||||
);
|
||||
});
|
||||
|
||||
test("getWebhooks reports the unset project instead of requesting null", async () => {
|
||||
const mock = pingFails();
|
||||
await expect(freshClient().getWebhooks()).rejects.toThrow(
|
||||
"projectId must be set",
|
||||
);
|
||||
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("createWebhook reports the unset project instead of requesting null", async () => {
|
||||
const mock = pingFails();
|
||||
await expect(
|
||||
freshClient().createWebhook({
|
||||
name: "hook",
|
||||
url: "https://example.com/hook",
|
||||
eventTypes: ["memory_add"],
|
||||
}),
|
||||
).rejects.toThrow("projectId must be set");
|
||||
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -96,84 +96,6 @@ describe("MemoryClient - updateProject()", () => {
|
||||
"Updated instructions",
|
||||
);
|
||||
});
|
||||
|
||||
test("camelCases agentCustomInstructions into agent_custom_instructions", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { message: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
await client.updateProject({
|
||||
agentCustomInstructions: "Remember tool failures",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(getFetchBody(call!).agent_custom_instructions).toBe(
|
||||
"Remember tool failures",
|
||||
);
|
||||
});
|
||||
|
||||
test("sends both instruction sets in one PATCH", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { message: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
await client.updateProject({
|
||||
customInstructions: "Remember user preferences",
|
||||
agentCustomInstructions: "Remember tool failures",
|
||||
});
|
||||
|
||||
const body = getFetchBody(
|
||||
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
|
||||
);
|
||||
expect(body.custom_instructions).toBe("Remember user preferences");
|
||||
expect(body.agent_custom_instructions).toBe("Remember tool failures");
|
||||
});
|
||||
|
||||
test("an empty string round-trips, so the field can be cleared", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { message: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
await client.updateProject({ agentCustomInstructions: "" });
|
||||
|
||||
const body = getFetchBody(
|
||||
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
|
||||
);
|
||||
expect(body).toHaveProperty("agent_custom_instructions", "");
|
||||
});
|
||||
|
||||
test("omits agent_custom_instructions when it is not passed", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { message: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
await client.updateProject({ customInstructions: "Be helpful" });
|
||||
|
||||
const body = getFetchBody(
|
||||
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
|
||||
);
|
||||
expect(body).not.toHaveProperty("agent_custom_instructions");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── feedback() ─────────────────────────────────────────
|
||||
|
||||
@@ -98,23 +98,16 @@ describe("MemoryClient - deleteUser() (deprecated)", () => {
|
||||
|
||||
describe("MemoryClient - deleteUsers()", () => {
|
||||
test("URL-encodes entity path segments", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/user/org%2Fteam%3Factive%23frag/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
client.telemetryId = "test@example.com";
|
||||
const deleteMock = jest.fn().mockResolvedValue({});
|
||||
client.client = { delete: deleteMock };
|
||||
|
||||
await client.deleteUsers({ userId: "org/team?active#frag" });
|
||||
|
||||
expect(
|
||||
findFetchCall(
|
||||
mock,
|
||||
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
|
||||
"DELETE",
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,6 @@ export * from "./vector_stores/milvus";
|
||||
export * from "./vector_stores/mongodb";
|
||||
export * from "./vector_stores/opensearch";
|
||||
export * from "./vector_stores/weaviate";
|
||||
export * from "./vector_stores/oracledb";
|
||||
export * from "./rerankers/base";
|
||||
export * from "./rerankers/cohere";
|
||||
export * from "./rerankers/llm";
|
||||
|
||||
@@ -100,19 +100,10 @@ const ENTITY_PARAMS = [
|
||||
"agentId",
|
||||
"runId",
|
||||
];
|
||||
// Identity keys stripped from caller metadata in add() and update(): ENTITY_PARAMS covers
|
||||
// user_id/agent_id/run_id in both casings (the default store promotes camelCase on read);
|
||||
// actor_id has no camelCase alias.
|
||||
const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"];
|
||||
|
||||
// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367 / #6371).
|
||||
function stripIdentityKeys(
|
||||
metadata: Record<string, any> = {},
|
||||
): Record<string, any> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).filter(([key]) => !IDENTITY_KEYS.includes(key)),
|
||||
);
|
||||
}
|
||||
// Identity keys stripped from update() metadata: ENTITY_PARAMS covers user_id/agent_id/run_id
|
||||
// in both casings (the default store promotes camelCase on read); actor_id has no camelCase alias.
|
||||
const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"];
|
||||
|
||||
// Batch size for deleteAll pagination. Larger than most vector store default
|
||||
// page limits (~100) to minimize roundtrips while bounded to avoid memory pressure.
|
||||
@@ -556,18 +547,11 @@ export class Memory {
|
||||
}
|
||||
}
|
||||
|
||||
private escapeScopeValue(val: unknown): string {
|
||||
return String(val)
|
||||
.replace(/%/g, "%25")
|
||||
.replace(/&/g, "%26")
|
||||
.replace(/=/g, "%3D");
|
||||
}
|
||||
|
||||
private buildSessionScope(filters: SearchFilters): string {
|
||||
const parts: string[] = [];
|
||||
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
|
||||
const val = (filters as any)[key];
|
||||
if (val) parts.push(`${key}=${this.escapeScopeValue(val)}`);
|
||||
if (val) parts.push(`${key}=${val}`);
|
||||
}
|
||||
return parts.join("&");
|
||||
}
|
||||
@@ -756,8 +740,7 @@ export class Memory {
|
||||
has_filters: !!config.filters,
|
||||
infer: config.infer,
|
||||
});
|
||||
const { filters = {}, infer = true } = config;
|
||||
const metadata = stripIdentityKeys(config.metadata);
|
||||
const { metadata = {}, filters = {}, infer = true } = config;
|
||||
|
||||
// Validate and trim entity IDs
|
||||
const userId = validateAndTrimEntityId(config.userId, "userId");
|
||||
@@ -768,9 +751,6 @@ export class Memory {
|
||||
if (userId) filters.user_id = metadata.user_id = userId;
|
||||
if (agentId) filters.agent_id = metadata.agent_id = agentId;
|
||||
if (runId) filters.run_id = metadata.run_id = runId;
|
||||
if (filters.user_id) metadata.user_id = filters.user_id;
|
||||
if (filters.agent_id) metadata.agent_id = filters.agent_id;
|
||||
if (filters.run_id) metadata.run_id = filters.run_id;
|
||||
|
||||
// Normalize expiration date into the stored metadata (round-trips via get()).
|
||||
if (config.expirationDate != null) {
|
||||
@@ -1991,7 +1971,10 @@ export class Memory {
|
||||
existingEmbeddings[newData] ||
|
||||
(await this.embedder.embed(newData, "update"));
|
||||
|
||||
const sanitizedMetadata = stripIdentityKeys(metadata);
|
||||
// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367).
|
||||
const sanitizedMetadata = Object.fromEntries(
|
||||
Object.entries(metadata).filter(([k]) => !IDENTITY_KEYS.includes(k)),
|
||||
);
|
||||
|
||||
const newMetadata = {
|
||||
...existingMemory.payload,
|
||||
|
||||
@@ -71,7 +71,6 @@ import { TurbopufferDB } from "../vector_stores/turbopuffer";
|
||||
import { Milvus } from "../vector_stores/milvus";
|
||||
import { MongoDB } from "../vector_stores/mongodb";
|
||||
import { WeaviateDB } from "../vector_stores/weaviate";
|
||||
import { OracleAIVectorSearch } from "../vector_stores/oracledb";
|
||||
|
||||
export class EmbedderFactory {
|
||||
static create(provider: string, config: EmbeddingConfig): Embedder {
|
||||
@@ -206,8 +205,6 @@ export class VectorStoreFactory {
|
||||
return new MongoDB(config as any);
|
||||
case "weaviate":
|
||||
return new WeaviateDB(config as any);
|
||||
case "oracledb":
|
||||
return new OracleAIVectorSearch(config as any);
|
||||
default:
|
||||
throw new Error(`Unsupported vector store provider: ${provider}`);
|
||||
}
|
||||
|
||||
@@ -1,764 +0,0 @@
|
||||
import type { BindParameters, Connection, Pool } from "oracledb";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
import { loadPeer } from "../utils/load_peer";
|
||||
|
||||
const DISTANCE_METRICS = [
|
||||
"COSINE",
|
||||
"EUCLIDEAN",
|
||||
"EUCLIDEAN_SQUARED",
|
||||
"DOT",
|
||||
"HAMMING",
|
||||
"MANHATTAN",
|
||||
] as const;
|
||||
|
||||
type DistanceMetric = (typeof DISTANCE_METRICS)[number];
|
||||
type IndexType = "HNSW" | "IVF";
|
||||
|
||||
const SCORE_FROM_DISTANCE: Record<DistanceMetric, (d: number) => number> = {
|
||||
COSINE: (d) => Math.max(0, Math.min(1, 1 - d)),
|
||||
EUCLIDEAN: (d) => 1 / (1 + Math.max(0, d)),
|
||||
EUCLIDEAN_SQUARED: (d) => 1 / (1 + Math.sqrt(Math.max(0, d))),
|
||||
HAMMING: (d) => 1 / (1 + Math.max(0, d)),
|
||||
MANHATTAN: (d) => 1 / (1 + Math.max(0, d)),
|
||||
DOT: (d) => -d,
|
||||
};
|
||||
|
||||
const INDEX_PARAMETER_RANGES: Record<
|
||||
IndexType,
|
||||
Record<string, [number, number]>
|
||||
> = {
|
||||
HNSW: {
|
||||
neighbors: [2, 2048],
|
||||
efconstruction: [1, 65535],
|
||||
},
|
||||
IVF: {
|
||||
"neighbor partitions": [1, 10_000_000],
|
||||
samples_per_partition: [1, Number.MAX_SAFE_INTEGER],
|
||||
min_vectors_per_partition: [0, Number.MAX_SAFE_INTEGER],
|
||||
},
|
||||
};
|
||||
|
||||
const IDENTIFIER_RE = /^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$/;
|
||||
const METADATA_KEY_RE = /^[a-zA-Z0-9_.[\],\s*]+$/;
|
||||
|
||||
export function quoteIdentifier(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!IDENTIFIER_RE.test(trimmed)) {
|
||||
throw new Error(`Identifier name ${name} is not valid.`);
|
||||
}
|
||||
return [...trimmed.matchAll(/"([^"]+)"|([^".]+)/g)]
|
||||
.map((m) => `"${m[1] ?? m[2]}"`)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
function jsonPath(metadataKey: string): string {
|
||||
if (!METADATA_KEY_RE.test(metadataKey)) {
|
||||
throw new Error(
|
||||
`Invalid metadata key '${metadataKey}'. Only letters, numbers, underscores, ` +
|
||||
`nesting via '.', and array wildcards '[*]' are allowed.`,
|
||||
);
|
||||
}
|
||||
return metadataKey
|
||||
.split(".")
|
||||
.map((part) =>
|
||||
part.endsWith("[*]") ? `."${part.slice(0, -3)}"[*]` : `."${part}"`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const COMPARISON_OPERATORS: Record<string, string> = {
|
||||
eq: "==",
|
||||
ne: "!=",
|
||||
gt: ">",
|
||||
gte: ">=",
|
||||
lt: "<",
|
||||
lte: "<=",
|
||||
};
|
||||
|
||||
const FIELD_OPERATORS = new Set([
|
||||
...Object.keys(COMPARISON_OPERATORS),
|
||||
"in",
|
||||
"nin",
|
||||
"contains",
|
||||
"icontains",
|
||||
]);
|
||||
|
||||
const LOGICAL_OPERATORS: Record<string, "and" | "or" | "not"> = {
|
||||
$and: "and",
|
||||
$or: "or",
|
||||
$not: "not",
|
||||
AND: "and",
|
||||
OR: "or",
|
||||
NOT: "not",
|
||||
};
|
||||
|
||||
function isScalar(value: any): boolean {
|
||||
return value === null || (typeof value !== "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function bindFilterValue(
|
||||
value: any,
|
||||
binds: Record<string, any>,
|
||||
): [string, string] {
|
||||
const name = `f_${Object.keys(binds).length}`;
|
||||
binds[name] = value;
|
||||
return [`$${name}`, `:${name} AS "${name}"`];
|
||||
}
|
||||
|
||||
function jsonExists(
|
||||
path: string,
|
||||
predicate: string,
|
||||
passings: string[],
|
||||
): string {
|
||||
const passingClause =
|
||||
passings.length > 0 ? ` PASSING ${passings.join(", ")}` : "";
|
||||
return `JSON_EXISTS(payload, '$${path}?(${predicate})'${passingClause})`;
|
||||
}
|
||||
|
||||
function buildFieldCondition(
|
||||
metadataKey: string,
|
||||
value: any,
|
||||
binds: Record<string, any>,
|
||||
): string {
|
||||
const path = jsonPath(metadataKey);
|
||||
|
||||
if (value === "*") {
|
||||
return `JSON_EXISTS(payload, '$${path}')`;
|
||||
}
|
||||
|
||||
if (isScalar(value)) {
|
||||
if (value === null) {
|
||||
return jsonExists(path, "@ == null", []);
|
||||
}
|
||||
const [variable, passing] = bindFilterValue(value, binds);
|
||||
return jsonExists(path, `@ == ${variable}`, [passing]);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
throw new Error(
|
||||
`Oracle filter for field '${metadataKey}' must be a scalar or an operator object`,
|
||||
);
|
||||
}
|
||||
|
||||
const operators = Object.entries(value);
|
||||
if (operators.length === 0) {
|
||||
throw new Error(
|
||||
`Operator filter for field '${metadataKey}' must not be empty`,
|
||||
);
|
||||
}
|
||||
|
||||
const unsupported = operators
|
||||
.map(([op]) => op)
|
||||
.filter((op) => !FIELD_OPERATORS.has(op));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`Unsupported Oracle filter operator(s) for field '${metadataKey}': ${unsupported.sort().join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const predicates: string[] = [];
|
||||
const passings: string[] = [];
|
||||
const additionalClauses: string[] = [];
|
||||
|
||||
for (const [operator, operand] of operators) {
|
||||
if (operator in COMPARISON_OPERATORS) {
|
||||
if (!isScalar(operand)) {
|
||||
throw new Error(
|
||||
`Oracle filter operator '${operator}' requires a scalar value`,
|
||||
);
|
||||
}
|
||||
if (operand === null) {
|
||||
if (operator !== "eq" && operator !== "ne") {
|
||||
throw new Error(
|
||||
`Oracle filter operator '${operator}' does not support null`,
|
||||
);
|
||||
}
|
||||
predicates.push(`@ ${COMPARISON_OPERATORS[operator]} null`);
|
||||
continue;
|
||||
}
|
||||
const [variable, passing] = bindFilterValue(operand, binds);
|
||||
predicates.push(`@ ${COMPARISON_OPERATORS[operator]} ${variable}`);
|
||||
passings.push(passing);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operator === "in" || operator === "nin") {
|
||||
if (!Array.isArray(operand) || operand.length === 0) {
|
||||
throw new Error(
|
||||
`Oracle filter operator '${operator}' requires a non-empty array`,
|
||||
);
|
||||
}
|
||||
|
||||
const variables: string[] = [];
|
||||
const listPassings: string[] = [];
|
||||
for (const item of operand) {
|
||||
if (!isScalar(item)) {
|
||||
throw new Error(
|
||||
`Oracle filter operator '${operator}' requires scalar values`,
|
||||
);
|
||||
}
|
||||
if (item === null) {
|
||||
variables.push("null");
|
||||
continue;
|
||||
}
|
||||
const [variable, passing] = bindFilterValue(item, binds);
|
||||
variables.push(variable);
|
||||
listPassings.push(passing);
|
||||
}
|
||||
|
||||
const membership = jsonExists(
|
||||
path,
|
||||
`@ in (${variables.join(", ")})`,
|
||||
listPassings,
|
||||
);
|
||||
additionalClauses.push(
|
||||
operator === "in" ? membership : `NOT (${membership})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof operand !== "string") {
|
||||
throw new Error(
|
||||
`Oracle filter operator '${operator}' requires a string value`,
|
||||
);
|
||||
}
|
||||
|
||||
if (operator === "contains") {
|
||||
const [variable, passing] = bindFilterValue(operand, binds);
|
||||
predicates.push(`@ has substring ${variable}`);
|
||||
passings.push(passing);
|
||||
} else {
|
||||
const [variable, passing] = bindFilterValue(operand.toLowerCase(), binds);
|
||||
predicates.push(`@.lower() has substring ${variable}`);
|
||||
passings.push(passing);
|
||||
}
|
||||
}
|
||||
|
||||
const clauses = [...additionalClauses];
|
||||
if (predicates.length > 0) {
|
||||
clauses.unshift(jsonExists(path, predicates.join(" && "), passings));
|
||||
}
|
||||
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`;
|
||||
}
|
||||
|
||||
export function buildFilterGroup(
|
||||
filters: Record<string, any>,
|
||||
binds: Record<string, any>,
|
||||
): string {
|
||||
const entries = Object.entries(filters ?? {});
|
||||
if (entries.length === 0) {
|
||||
throw new Error("Oracle filter groups must be non-empty objects");
|
||||
}
|
||||
|
||||
const clauses: string[] = [];
|
||||
for (const [key, value] of entries) {
|
||||
const logicalOperator = LOGICAL_OPERATORS[key];
|
||||
if (logicalOperator) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error(
|
||||
`Logical filter operator '${key}' requires a non-empty array`,
|
||||
);
|
||||
}
|
||||
const nested = value.map((condition) =>
|
||||
buildFilterGroup(condition, binds),
|
||||
);
|
||||
if (logicalOperator === "not") {
|
||||
clauses.push(`NOT (${nested.join(" OR ")})`);
|
||||
} else {
|
||||
clauses.push(
|
||||
`(${nested.join(logicalOperator === "and" ? " AND " : " OR ")})`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.startsWith("$")) {
|
||||
throw new Error(`Unsupported Oracle logical filter operator: ${key}`);
|
||||
}
|
||||
|
||||
clauses.push(buildFieldCondition(key, value, binds));
|
||||
}
|
||||
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`;
|
||||
}
|
||||
|
||||
export function buildWhereClause(
|
||||
filters?: SearchFilters,
|
||||
): [string, Record<string, any>] {
|
||||
if (!filters || Object.keys(filters).length === 0) {
|
||||
return ["", {}];
|
||||
}
|
||||
const binds: Record<string, any> = {};
|
||||
return [`WHERE ${buildFilterGroup(filters, binds)}`, binds];
|
||||
}
|
||||
|
||||
interface OracleDBConfig extends VectorStoreConfig {
|
||||
connectionParams?: Record<string, any>;
|
||||
useConnectionPool?: boolean;
|
||||
client?: Connection | Pool;
|
||||
collectionName?: string;
|
||||
embeddingModelDims?: number;
|
||||
distanceMetric?: DistanceMetric;
|
||||
doCreateIndex?: boolean;
|
||||
indexType?: IndexType;
|
||||
indexName?: string;
|
||||
indexParameters?: Record<string, number>;
|
||||
indexAccuracy?: number;
|
||||
}
|
||||
|
||||
export class OracleAIVectorSearch implements VectorStore {
|
||||
private readonly collectionName: string;
|
||||
private readonly indexName: string;
|
||||
private readonly embeddingModelDims: number;
|
||||
private readonly distanceMetric: DistanceMetric;
|
||||
private readonly indexType: IndexType;
|
||||
private readonly indexParameters: Record<string, number>;
|
||||
private readonly indexAccuracy?: number;
|
||||
private readonly doCreateIndex: boolean;
|
||||
private readonly config: OracleDBConfig;
|
||||
private oracledb: any;
|
||||
private client?: Connection | Pool;
|
||||
private ownsClient = false;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: OracleDBConfig) {
|
||||
if (!config.connectionParams && !config.client) {
|
||||
throw new Error(
|
||||
"Must provide at least one of `connectionParams` and `client`",
|
||||
);
|
||||
}
|
||||
|
||||
this.collectionName = quoteIdentifier(config.collectionName || "mem0");
|
||||
this.indexName = quoteIdentifier(
|
||||
config.indexName || `${config.collectionName || "mem0"}_VEC_IDX`,
|
||||
);
|
||||
|
||||
this.embeddingModelDims = config.embeddingModelDims ?? 1536;
|
||||
if (
|
||||
!Number.isInteger(this.embeddingModelDims) ||
|
||||
this.embeddingModelDims <= 0
|
||||
) {
|
||||
throw new Error("`embeddingModelDims` must be a positive integer");
|
||||
}
|
||||
|
||||
const distanceMetric = (config.distanceMetric ??
|
||||
"COSINE") as string as DistanceMetric;
|
||||
this.distanceMetric = distanceMetric.toUpperCase() as DistanceMetric;
|
||||
if (!DISTANCE_METRICS.includes(this.distanceMetric)) {
|
||||
throw new Error(`Unsupported distance metric: ${config.distanceMetric}`);
|
||||
}
|
||||
|
||||
const indexType = (config.indexType ?? "HNSW") as string;
|
||||
this.indexType = indexType.toUpperCase() as IndexType;
|
||||
if (this.indexType !== "HNSW" && this.indexType !== "IVF") {
|
||||
throw new Error(`Unsupported index type: ${config.indexType}`);
|
||||
}
|
||||
|
||||
this.indexAccuracy = config.indexAccuracy;
|
||||
if (
|
||||
this.indexAccuracy !== undefined &&
|
||||
(!Number.isInteger(this.indexAccuracy) ||
|
||||
this.indexAccuracy <= 0 ||
|
||||
this.indexAccuracy > 100)
|
||||
) {
|
||||
throw new Error("`indexAccuracy` must be an integer between 1 and 100");
|
||||
}
|
||||
|
||||
this.indexParameters = this.validateIndexParameters(config.indexParameters);
|
||||
this.doCreateIndex = config.doCreateIndex ?? true;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private validateIndexParameters(
|
||||
parameters?: Record<string, number>,
|
||||
): Record<string, number> {
|
||||
if (!parameters) return {};
|
||||
|
||||
const allowed = INDEX_PARAMETER_RANGES[this.indexType];
|
||||
const validated: Record<string, number> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
const range = allowed[key];
|
||||
if (!range) {
|
||||
throw new Error(
|
||||
`Unsupported ${this.indexType} index parameter '${key}'. ` +
|
||||
`Allowed: ${Object.keys(allowed).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(value) || value < range[0] || value > range[1]) {
|
||||
throw new Error(
|
||||
`Index parameter '${key}' must be an integer between ${range[0]} and ${range[1]}`,
|
||||
);
|
||||
}
|
||||
validated[key] = value;
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize().catch(async (error) => {
|
||||
if (this.ownsClient && this.client) {
|
||||
await Promise.resolve(this.client.close()).catch(() => {});
|
||||
this.client = undefined;
|
||||
this.ownsClient = false;
|
||||
}
|
||||
this._initPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
const sdk = await loadPeer(
|
||||
"oracledb",
|
||||
"Oracle AI Vector Search",
|
||||
() => import("oracledb"),
|
||||
);
|
||||
this.oracledb = sdk.default ?? sdk;
|
||||
|
||||
if (this.config.client) {
|
||||
this.client = this.config.client;
|
||||
} else if (this.config.useConnectionPool ?? true) {
|
||||
this.client = await this.oracledb.createPool({
|
||||
poolMin: 1,
|
||||
poolMax: 4,
|
||||
...this.config.connectionParams,
|
||||
});
|
||||
this.ownsClient = true;
|
||||
} else {
|
||||
this.client = await this.oracledb.getConnection(
|
||||
this.config.connectionParams,
|
||||
);
|
||||
this.ownsClient = true;
|
||||
}
|
||||
|
||||
await this.assertVectorSupport();
|
||||
await this.createCol();
|
||||
}
|
||||
|
||||
private isPool(client: Connection | Pool): client is Pool {
|
||||
return typeof (client as Pool).getConnection === "function";
|
||||
}
|
||||
|
||||
private async withConnection<T>(
|
||||
fn: (connection: Connection) => Promise<T>,
|
||||
commit = false,
|
||||
): Promise<T> {
|
||||
const client = this.client!;
|
||||
|
||||
if (!this.isPool(client)) {
|
||||
const connection = client as Connection;
|
||||
try {
|
||||
const result = await fn(connection);
|
||||
if (commit) await connection.commit();
|
||||
return result;
|
||||
} catch (err) {
|
||||
await connection.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const connection = await client.getConnection();
|
||||
try {
|
||||
const result = await fn(connection);
|
||||
if (commit) await connection.commit();
|
||||
return result;
|
||||
} catch (err) {
|
||||
await connection.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async assertVectorSupport(): Promise<void> {
|
||||
if (!this.oracledb.thin) {
|
||||
const [major, minor] = [
|
||||
Math.floor(this.oracledb.oracleClientVersion / 100000000),
|
||||
Math.floor(this.oracledb.oracleClientVersion / 100000) % 100,
|
||||
];
|
||||
if (major < 23 || (major === 23 && minor < 4)) {
|
||||
throw new Error(
|
||||
`Oracle DB client driver version ${this.oracledb.oracleClientVersionString} ` +
|
||||
"not supported, must be >=23.4 for vector support",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const version = await this.withConnection(
|
||||
async (connection) => connection.oracleServerVersionString,
|
||||
);
|
||||
const [major, minor] = version.split(".").map(Number);
|
||||
if (major < 23 || (major === 23 && minor < 4)) {
|
||||
throw new Error(
|
||||
`Oracle DB version ${version} not supported, must be >=23.4 for vector support`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createIndexDdl(): string {
|
||||
const accuracy = this.indexAccuracy
|
||||
? `WITH TARGET ACCURACY ${this.indexAccuracy}`
|
||||
: "";
|
||||
|
||||
const parameterEntries = Object.entries(this.indexParameters);
|
||||
const parameters =
|
||||
parameterEntries.length > 0
|
||||
? `PARAMETERS (${[
|
||||
`type ${this.indexType}`,
|
||||
...parameterEntries.map(([key, value]) => `${key} ${value}`),
|
||||
].join(", ")})`
|
||||
: "";
|
||||
|
||||
const organization =
|
||||
this.indexType === "HNSW"
|
||||
? "INMEMORY NEIGHBOR GRAPH"
|
||||
: "NEIGHBOR PARTITIONS";
|
||||
|
||||
return (
|
||||
`CREATE VECTOR INDEX IF NOT EXISTS ${this.indexName} ON ${this.collectionName} (vector) ` +
|
||||
`ORGANIZATION ${organization} DISTANCE ${this.distanceMetric} ${accuracy} ${parameters}`
|
||||
);
|
||||
}
|
||||
|
||||
private async createCol(): Promise<void> {
|
||||
await this.withConnection(async (connection) => {
|
||||
await connection.execute(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.collectionName} (
|
||||
id VARCHAR2(36) PRIMARY KEY,
|
||||
vector VECTOR(${this.embeddingModelDims}),
|
||||
payload JSON
|
||||
)
|
||||
`);
|
||||
|
||||
await connection.execute(`
|
||||
CREATE TABLE IF NOT EXISTS memory_migrations (
|
||||
id NUMBER PRIMARY KEY,
|
||||
user_id VARCHAR2(255) NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
if (this.doCreateIndex) {
|
||||
await connection.execute(this.createIndexDdl());
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
private loadPayload(value: any): Record<string, any> {
|
||||
if (value === null || value === undefined) return {};
|
||||
if (typeof value === "string") return JSON.parse(value);
|
||||
if (Buffer.isBuffer(value)) return JSON.parse(value.toString("utf-8"));
|
||||
return value;
|
||||
}
|
||||
|
||||
private vectorBind(vector: number[]) {
|
||||
return {
|
||||
type: this.oracledb.DB_TYPE_VECTOR,
|
||||
val: new Float32Array(vector),
|
||||
};
|
||||
}
|
||||
|
||||
private payloadBind(payload: Record<string, any>) {
|
||||
return { type: this.oracledb.DB_TYPE_JSON, val: payload };
|
||||
}
|
||||
|
||||
async insert(
|
||||
vectors: number[][],
|
||||
ids: string[],
|
||||
payloads: Record<string, any>[],
|
||||
): Promise<void> {
|
||||
if (ids.length !== vectors.length) {
|
||||
throw new Error("ids and vectors must have the same length");
|
||||
}
|
||||
if (payloads.length !== vectors.length) {
|
||||
throw new Error("payloads and vectors must have the same length");
|
||||
}
|
||||
|
||||
if (vectors.length === 0) return;
|
||||
|
||||
await this.initialize();
|
||||
|
||||
await this.withConnection(async (connection) => {
|
||||
await connection.executeMany(
|
||||
`INSERT INTO ${this.collectionName} (id, vector, payload) VALUES (:id, :vector, :payload)`,
|
||||
vectors.map((vector, i) => ({
|
||||
id: ids[i],
|
||||
vector: new Float32Array(vector),
|
||||
payload: payloads[i] ?? {},
|
||||
})) as BindParameters[],
|
||||
{
|
||||
bindDefs: {
|
||||
id: { type: this.oracledb.DB_TYPE_VARCHAR, maxSize: 36 },
|
||||
vector: { type: this.oracledb.DB_TYPE_VECTOR },
|
||||
payload: { type: this.oracledb.DB_TYPE_JSON },
|
||||
},
|
||||
},
|
||||
);
|
||||
}, true);
|
||||
}
|
||||
|
||||
async search(
|
||||
query: number[],
|
||||
topK: number = 5,
|
||||
filters?: SearchFilters,
|
||||
): Promise<VectorStoreResult[]> {
|
||||
await this.initialize();
|
||||
|
||||
const [whereClause, filterBinds] = buildWhereClause(filters);
|
||||
const hasFilter = whereClause.length > 0;
|
||||
const selectClause = hasFilter
|
||||
? "SELECT"
|
||||
: `SELECT /*+ VECTOR_INDEX_TRANSFORM(${this.collectionName}) */`;
|
||||
const sql =
|
||||
`${selectClause} id, payload, VECTOR_DISTANCE(vector, :query_vec, ${this.distanceMetric}) distance ` +
|
||||
`FROM ${this.collectionName} ${whereClause} ORDER BY distance FETCH APPROX FIRST :max_rows ROWS ONLY`;
|
||||
|
||||
const rows = await this.withConnection(async (connection) => {
|
||||
const result = await connection.execute<any[]>(sql, {
|
||||
query_vec: this.vectorBind(query),
|
||||
max_rows: topK,
|
||||
...filterBinds,
|
||||
});
|
||||
return result.rows ?? [];
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row[0],
|
||||
payload: this.loadPayload(row[1]),
|
||||
score: SCORE_FROM_DISTANCE[this.distanceMetric](Number(row[2])),
|
||||
}));
|
||||
}
|
||||
|
||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||
await this.initialize();
|
||||
|
||||
const rows = await this.withConnection(async (connection) => {
|
||||
const result = await connection.execute<any[]>(
|
||||
`SELECT id, payload FROM ${this.collectionName} WHERE id = :vector_id`,
|
||||
{ vector_id: vectorId },
|
||||
);
|
||||
return result.rows ?? [];
|
||||
});
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return { id: rows[0][0], payload: this.loadPayload(rows[0][1]) };
|
||||
}
|
||||
|
||||
async update(
|
||||
vectorId: string,
|
||||
vector: number[],
|
||||
payload: Record<string, any>,
|
||||
): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
const assignments: string[] = [];
|
||||
const binds: Record<string, any> = { vector_id: vectorId };
|
||||
|
||||
if (vector) {
|
||||
assignments.push("vector = :vector");
|
||||
binds.vector = this.vectorBind(vector);
|
||||
}
|
||||
if (payload) {
|
||||
assignments.push("payload = :payload");
|
||||
binds.payload = this.payloadBind(payload);
|
||||
}
|
||||
if (assignments.length === 0) return;
|
||||
|
||||
await this.withConnection(
|
||||
(connection) =>
|
||||
connection.execute(
|
||||
`UPDATE ${this.collectionName} SET ${assignments.join(", ")} WHERE id = :vector_id`,
|
||||
binds,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async delete(vectorId: string): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
await this.withConnection(
|
||||
(connection) =>
|
||||
connection.execute(
|
||||
`DELETE FROM ${this.collectionName} WHERE id = :vector_id`,
|
||||
{ vector_id: vectorId },
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCol(): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
await this.withConnection(
|
||||
(connection) =>
|
||||
connection.execute(`DROP TABLE ${this.collectionName} PURGE`),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async list(
|
||||
filters?: SearchFilters,
|
||||
topK: number = 100,
|
||||
): Promise<[VectorStoreResult[], number]> {
|
||||
await this.initialize();
|
||||
|
||||
const [whereClause, filterBinds] = buildWhereClause(filters);
|
||||
|
||||
return this.withConnection(async (connection) => {
|
||||
const listResult = await connection.execute<any[]>(
|
||||
`SELECT id, payload, COUNT(*) OVER () total FROM ${this.collectionName} ${whereClause} FETCH FIRST :max_rows ROWS ONLY`,
|
||||
{ ...filterBinds, max_rows: topK },
|
||||
);
|
||||
|
||||
const rows = listResult.rows ?? [];
|
||||
const results = rows.map((row) => ({
|
||||
id: row[0],
|
||||
payload: this.loadPayload(row[1]),
|
||||
}));
|
||||
|
||||
return [results, Number(rows[0]?.[2] ?? 0)];
|
||||
});
|
||||
}
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
const rows = await this.withConnection(async (connection) => {
|
||||
const result = await connection.execute<any[]>(
|
||||
"SELECT user_id FROM memory_migrations WHERE id = 1",
|
||||
);
|
||||
return result.rows ?? [];
|
||||
});
|
||||
|
||||
if (rows.length > 0) return rows[0][0];
|
||||
|
||||
const generatedUserId = uuidv4();
|
||||
await this.setUserId(generatedUserId);
|
||||
return generatedUserId;
|
||||
}
|
||||
|
||||
async setUserId(userId: string): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
await this.withConnection(async (connection) => {
|
||||
await connection.execute("DELETE FROM memory_migrations WHERE id = 1");
|
||||
await connection.execute(
|
||||
"INSERT INTO memory_migrations (id, user_id) VALUES (1, :user_id)",
|
||||
{ user_id: userId },
|
||||
);
|
||||
}, true);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.client && this.ownsClient) {
|
||||
await this.client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,78 +8,55 @@ import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock("../src/utils/factory", () => {
|
||||
const { MemoryVectorStore } = jest.requireActual(
|
||||
"../src/vector_stores/memory",
|
||||
);
|
||||
const { MemoryHistoryManager } = jest.requireActual(
|
||||
"../src/storage/MemoryHistoryManager",
|
||||
);
|
||||
const testEmbedding = new Array(1536).fill(0.1);
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
class MockEmbedder {
|
||||
embeddingDims = 1536;
|
||||
|
||||
async embed(): Promise<number[]> {
|
||||
return testEmbedding;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
return texts.map(() => testEmbedding);
|
||||
}
|
||||
}
|
||||
|
||||
class MockLLM {
|
||||
async generateResponse(messages: Array<{ role: string; content: string }>) {
|
||||
const userMsg = messages.find((m) => m.role === "user");
|
||||
const content = userMsg?.content ?? "";
|
||||
const newMsgMatch = content.match(
|
||||
/## New Messages\n([\s\S]*?)(?=\n##|$)/,
|
||||
);
|
||||
const extracted = newMsgMatch
|
||||
? newMsgMatch[1].trim()
|
||||
: "extracted fact from input";
|
||||
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "0",
|
||||
text: extracted,
|
||||
attributed_to: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
EmbedderFactory: {
|
||||
create: jest.fn(() => new MockEmbedder()),
|
||||
},
|
||||
LLMFactory: {
|
||||
create: jest.fn(() => new MockLLM()),
|
||||
},
|
||||
VectorStoreFactory: {
|
||||
create: jest.fn((provider: string, config: any) => {
|
||||
if (provider.toLowerCase() !== "memory") {
|
||||
throw new Error(
|
||||
`Unsupported vector store provider in test: ${provider}`,
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
// V3 pipeline: single LLM call with additive extraction prompt.
|
||||
const userMsg = messages.find((m) => m.role === "user");
|
||||
const content = userMsg?.content ?? "";
|
||||
const newMsgMatch = content.match(
|
||||
/## New Messages\n([\s\S]*?)(?=\n##|$)/,
|
||||
);
|
||||
}
|
||||
return new MemoryVectorStore(config);
|
||||
}),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn(() => new MemoryHistoryManager()),
|
||||
},
|
||||
RerankerFactory: {
|
||||
create: jest.fn(() => {
|
||||
throw new Error("RerankerFactory is not used in memory.add.test.ts");
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const extracted = newMsgMatch
|
||||
? newMsgMatch[1].trim()
|
||||
: "extracted fact from input";
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "0",
|
||||
text: extracted,
|
||||
attributed_to: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockEmbedding = new Array(1536).fill(0.1);
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(mockEmbedding),
|
||||
embedBatch: jest
|
||||
.fn()
|
||||
.mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => mockEmbedding)),
|
||||
),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
@@ -195,138 +172,6 @@ describe("Memory - add()", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("does not allow metadata to set identity scope", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId: "u1",
|
||||
metadata: {
|
||||
agent_id: "other",
|
||||
agentId: "other-camel",
|
||||
run_id: "other-run",
|
||||
runId: "other-run-camel",
|
||||
actor_id: "x",
|
||||
source: "issue-6371",
|
||||
nested: { preserved: true },
|
||||
},
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
|
||||
expect(stored).toEqual(
|
||||
expect.objectContaining({
|
||||
user_id: "u1",
|
||||
metadata: expect.objectContaining({
|
||||
source: "issue-6371",
|
||||
nested: { preserved: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(stored).not.toHaveProperty("agent_id");
|
||||
expect(stored).not.toHaveProperty("run_id");
|
||||
expect(stored!.metadata).not.toHaveProperty("agent_id");
|
||||
expect(stored!.metadata).not.toHaveProperty("agentId");
|
||||
expect(stored!.metadata).not.toHaveProperty("run_id");
|
||||
expect(stored!.metadata).not.toHaveProperty("runId");
|
||||
expect(stored!.metadata).not.toHaveProperty("actor_id");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["userId", { userId: "u1" }, true, "user_id", "u1"],
|
||||
["agentId", { agentId: "a1" }, false, "agent_id", "a1"],
|
||||
["runId", { runId: "r1" }, true, "run_id", "r1"],
|
||||
] as const)(
|
||||
"preserves typed %s scope while stripping conflicting metadata identities",
|
||||
async (_mode, scope, infer, canonicalKey, canonicalValue) => {
|
||||
const result: SearchResult = await memory.add("scoped content", {
|
||||
...scope,
|
||||
infer,
|
||||
metadata: {
|
||||
user_id: "metadata-user",
|
||||
userId: "metadata-user-camel",
|
||||
agent_id: "metadata-agent",
|
||||
agentId: "metadata-agent-camel",
|
||||
run_id: "metadata-run",
|
||||
runId: "metadata-run-camel",
|
||||
actor_id: "metadata-actor",
|
||||
ordinary: "preserved",
|
||||
},
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
|
||||
expect(stored).toHaveProperty(canonicalKey, canonicalValue);
|
||||
for (const key of ["user_id", "agent_id", "run_id"]) {
|
||||
if (key !== canonicalKey) {
|
||||
expect(stored).not.toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({ ordinary: "preserved" }),
|
||||
);
|
||||
for (const key of [
|
||||
"user_id",
|
||||
"userId",
|
||||
"agent_id",
|
||||
"agentId",
|
||||
"run_id",
|
||||
"runId",
|
||||
"actor_id",
|
||||
]) {
|
||||
if (key !== canonicalKey) {
|
||||
expect(stored!.metadata).not.toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[true, "user_id", "filter-user"],
|
||||
[false, "user_id", "filter-user"],
|
||||
[false, "agent_id", "filter-agent"],
|
||||
[false, "run_id", "filter-run"],
|
||||
] as const)(
|
||||
"preserves infer=%s %s filters scope after sanitization",
|
||||
async (infer, filterKey, filterValue) => {
|
||||
const result: SearchResult = await memory.add("filter-scoped content", {
|
||||
filters: { [filterKey]: filterValue },
|
||||
infer,
|
||||
metadata: {
|
||||
user_id: "metadata-user",
|
||||
userId: "metadata-user-camel",
|
||||
agent_id: "metadata-agent",
|
||||
agentId: "metadata-agent-camel",
|
||||
run_id: "metadata-run",
|
||||
runId: "metadata-run-camel",
|
||||
actor_id: "metadata-actor",
|
||||
ordinary: "preserved",
|
||||
},
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
|
||||
expect(stored).toHaveProperty(filterKey, filterValue);
|
||||
for (const key of ["user_id", "agent_id", "run_id"]) {
|
||||
if (key !== filterKey) {
|
||||
expect(stored).not.toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
[filterKey]: filterValue,
|
||||
ordinary: "preserved",
|
||||
}),
|
||||
);
|
||||
for (const key of [
|
||||
"user_id",
|
||||
"userId",
|
||||
"agent_id",
|
||||
"agentId",
|
||||
"run_id",
|
||||
"runId",
|
||||
"actor_id",
|
||||
]) {
|
||||
if (key === filterKey) continue;
|
||||
expect(stored!.metadata).not.toHaveProperty(key);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("with infer=false skips LLM and stores messages directly", async () => {
|
||||
const result: SearchResult = await memory.add("Direct storage content", {
|
||||
userId,
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/** Oracle AI Vector Search filter, config and SQL tests. The driver is mocked, so no database is needed. */
|
||||
const DB_TYPE_VECTOR = { name: "DB_TYPE_VECTOR" };
|
||||
const DB_TYPE_JSON = { name: "DB_TYPE_JSON" };
|
||||
const DB_TYPE_VARCHAR = { name: "DB_TYPE_VARCHAR" };
|
||||
|
||||
const mockCreatePool = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
"oracledb",
|
||||
() => ({
|
||||
thin: true,
|
||||
DB_TYPE_VECTOR,
|
||||
DB_TYPE_JSON,
|
||||
DB_TYPE_VARCHAR,
|
||||
createPool: mockCreatePool,
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
import {
|
||||
OracleAIVectorSearch,
|
||||
buildWhereClause,
|
||||
quoteIdentifier,
|
||||
} from "../src/vector_stores/oracledb";
|
||||
|
||||
type Call = { sql: string; binds: any; options?: any };
|
||||
|
||||
function fakeConnection(calls: Call[], resultsBySql: Array<any[][]>) {
|
||||
let selectIndex = 0;
|
||||
return {
|
||||
oracleServerVersionString: "23.4.0.24.05",
|
||||
async execute(sql: string, binds: any = {}) {
|
||||
calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds });
|
||||
if (/^\s*SELECT/i.test(sql)) {
|
||||
return { rows: resultsBySql[selectIndex++] ?? [] };
|
||||
}
|
||||
return { rows: [] };
|
||||
},
|
||||
async executeMany(sql: string, binds: any[], options: any = {}) {
|
||||
calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds, options });
|
||||
return { rows: [] };
|
||||
},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
async close() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(
|
||||
calls: Call[],
|
||||
results: Array<any[][]> = [],
|
||||
overrides = {},
|
||||
) {
|
||||
return new OracleAIVectorSearch({
|
||||
client: fakeConnection(calls, results) as any,
|
||||
collectionName: "mem0",
|
||||
embeddingModelDims: 3,
|
||||
...overrides,
|
||||
} as any);
|
||||
}
|
||||
|
||||
describe("quoteIdentifier", () => {
|
||||
it("quotes a bare name", () => {
|
||||
expect(quoteIdentifier("mem0")).toBe('"mem0"');
|
||||
});
|
||||
|
||||
it("quotes each segment of a schema-qualified name", () => {
|
||||
expect(quoteIdentifier("app.mem0")).toBe('"app"."mem0"');
|
||||
});
|
||||
|
||||
it("preserves already-quoted segments", () => {
|
||||
expect(quoteIdentifier('"App"."Mem0"')).toBe('"App"."Mem0"');
|
||||
});
|
||||
|
||||
it("rejects a name that would break out of the quoting", () => {
|
||||
expect(() => quoteIdentifier('mem0" (x); DROP TABLE t--')).toThrow(
|
||||
/is not valid/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWhereClause", () => {
|
||||
it("returns no clause for empty filters", () => {
|
||||
expect(buildWhereClause(undefined)).toEqual(["", {}]);
|
||||
expect(buildWhereClause({})).toEqual(["", {}]);
|
||||
});
|
||||
|
||||
it("binds a scalar equality instead of inlining it", () => {
|
||||
const [clause, binds] = buildWhereClause({ user_id: "alice" });
|
||||
expect(clause).toBe(
|
||||
`WHERE JSON_EXISTS(payload, '$."user_id"?(@ == $f_0)' PASSING :f_0 AS "f_0")`,
|
||||
);
|
||||
expect(binds).toEqual({ f_0: "alice" });
|
||||
});
|
||||
|
||||
it("ANDs multiple fields", () => {
|
||||
const [clause, binds] = buildWhereClause({
|
||||
user_id: "alice",
|
||||
agent_id: "bot",
|
||||
});
|
||||
expect(clause.startsWith("WHERE (")).toBe(true);
|
||||
expect(clause).toContain(" AND ");
|
||||
expect(binds).toEqual({ f_0: "alice", f_1: "bot" });
|
||||
});
|
||||
|
||||
it("applies every operator in a compound range filter", () => {
|
||||
const [clause, binds] = buildWhereClause({ age: { gte: 10, lte: 20 } });
|
||||
expect(clause).toContain("@ >= $f_0 && @ <= $f_1");
|
||||
expect(binds).toEqual({ f_0: 10, f_1: 20 });
|
||||
});
|
||||
|
||||
it("builds an existence check for the wildcard filter", () => {
|
||||
const [clause, binds] = buildWhereClause({ user_id: "*" });
|
||||
expect(clause).toBe(`WHERE JSON_EXISTS(payload, '$."user_id"')`);
|
||||
expect(binds).toEqual({});
|
||||
});
|
||||
|
||||
it("builds membership for in and negates it for nin", () => {
|
||||
const [inClause] = buildWhereClause({ user_id: { in: ["a", "b"] } });
|
||||
expect(inClause).toContain("@ in ($f_0, $f_1)");
|
||||
expect(inClause).not.toContain("NOT (");
|
||||
|
||||
const [ninClause] = buildWhereClause({ user_id: { nin: ["a"] } });
|
||||
expect(ninClause).toContain("NOT (");
|
||||
});
|
||||
|
||||
it("lowercases the operand for icontains", () => {
|
||||
const [clause, binds] = buildWhereClause({ data: { icontains: "SciFi" } });
|
||||
expect(clause).toContain("@.lower() has substring $f_0");
|
||||
expect(binds).toEqual({ f_0: "scifi" });
|
||||
});
|
||||
|
||||
it("ORs the branches of a $or group", () => {
|
||||
const [clause, binds] = buildWhereClause({
|
||||
$or: [{ user_id: "alice" }, { agent_id: "bot" }],
|
||||
});
|
||||
expect(clause).toContain(" OR ");
|
||||
expect(binds).toEqual({ f_0: "alice", f_1: "bot" });
|
||||
});
|
||||
|
||||
it("negates a $not group", () => {
|
||||
const [clause] = buildWhereClause({ $not: [{ user_id: "alice" }] });
|
||||
expect(clause.startsWith("WHERE NOT (")).toBe(true);
|
||||
});
|
||||
|
||||
it("nests logical groups", () => {
|
||||
const [clause, binds] = buildWhereClause({
|
||||
user_id: "alice",
|
||||
$or: [{ agent_id: "bot" }, { run_id: "r1" }],
|
||||
});
|
||||
expect(clause).toContain(" AND ");
|
||||
expect(clause).toContain(" OR ");
|
||||
expect(Object.keys(binds)).toEqual(["f_0", "f_1", "f_2"]);
|
||||
});
|
||||
|
||||
it("compares against JSON null without a bind", () => {
|
||||
const [clause, binds] = buildWhereClause({ agent_id: null });
|
||||
expect(clause).toBe(
|
||||
`WHERE JSON_EXISTS(payload, '$."agent_id"?(@ == null)')`,
|
||||
);
|
||||
expect(binds).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects a metadata key that could escape the JSON path", () => {
|
||||
expect(() => buildWhereClause({ 'a"?(1==1))--': "x" })).toThrow(
|
||||
/Invalid metadata key/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unsupported field operator", () => {
|
||||
expect(() => buildWhereClause({ age: { regex: "^a" } })).toThrow(
|
||||
/Unsupported Oracle filter operator/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unsupported logical operator", () => {
|
||||
expect(() => buildWhereClause({ $nor: [{ a: 1 }] })).toThrow(
|
||||
/Unsupported Oracle logical filter operator/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty in list", () => {
|
||||
expect(() => buildWhereClause({ user_id: { in: [] } })).toThrow(
|
||||
/non-empty array/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-scalar comparison operand", () => {
|
||||
expect(() => buildWhereClause({ age: { gt: [1] } })).toThrow(
|
||||
/requires a scalar value/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OracleAIVectorSearch config validation", () => {
|
||||
it("requires connectionParams or client", () => {
|
||||
expect(() => new OracleAIVectorSearch({} as any)).toThrow(
|
||||
/connectionParams.*client/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unsupported distance metric", () => {
|
||||
expect(() => makeStore([], [], { distanceMetric: "JACCARD" })).toThrow(
|
||||
/Unsupported distance metric/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-positive embedding dimension", () => {
|
||||
expect(() => makeStore([], [], { embeddingModelDims: 0 })).toThrow(
|
||||
/positive integer/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range index accuracy", () => {
|
||||
expect(() => makeStore([], [], { indexAccuracy: 101 })).toThrow(
|
||||
/between 1 and 100/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an index parameter that does not belong to the index type", () => {
|
||||
expect(() =>
|
||||
makeStore([], [], {
|
||||
indexType: "HNSW",
|
||||
indexParameters: { samples_per_partition: 10 },
|
||||
}),
|
||||
).toThrow(/Unsupported HNSW index parameter/);
|
||||
});
|
||||
|
||||
it("rejects an index parameter outside its allowed range", () => {
|
||||
expect(() =>
|
||||
makeStore([], [], { indexParameters: { neighbors: 1 } }),
|
||||
).toThrow(/between 2 and 2048/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OracleAIVectorSearch SQL", () => {
|
||||
it("creates the table and a vector index on initialize", async () => {
|
||||
const calls: Call[] = [];
|
||||
await makeStore(calls, [], {
|
||||
indexParameters: { neighbors: 32, efconstruction: 200 },
|
||||
indexAccuracy: 95,
|
||||
}).initialize();
|
||||
|
||||
const ddl = calls.map((c) => c.sql).join("\n");
|
||||
expect(ddl).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS "mem0" ( id VARCHAR2(36) PRIMARY KEY, vector VECTOR(3), payload JSON )',
|
||||
);
|
||||
expect(ddl).toContain(
|
||||
'CREATE VECTOR INDEX IF NOT EXISTS "mem0_VEC_IDX" ON "mem0" (vector) ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE COSINE WITH TARGET ACCURACY 95 PARAMETERS (type HNSW, neighbors 32, efconstruction 200)',
|
||||
);
|
||||
});
|
||||
|
||||
it("skips index creation when doCreateIndex is false", async () => {
|
||||
const calls: Call[] = [];
|
||||
await makeStore(calls, [], { doCreateIndex: false }).initialize();
|
||||
expect(calls.map((c) => c.sql).join("\n")).not.toContain(
|
||||
"CREATE VECTOR INDEX",
|
||||
);
|
||||
});
|
||||
|
||||
it("binds vectors as DB_TYPE_VECTOR and payloads as DB_TYPE_JSON on insert", async () => {
|
||||
const calls: Call[] = [];
|
||||
await makeStore(calls).insert([[1, 2, 3]], ["id-1"], [{ data: "hello" }]);
|
||||
|
||||
const insert = calls.find((c) => c.sql.startsWith("INSERT INTO"))!;
|
||||
expect(insert.binds).toEqual([
|
||||
{
|
||||
id: "id-1",
|
||||
vector: new Float32Array([1, 2, 3]),
|
||||
payload: { data: "hello" },
|
||||
},
|
||||
]);
|
||||
expect(insert.options.bindDefs.id).toEqual({
|
||||
type: DB_TYPE_VARCHAR,
|
||||
maxSize: 36,
|
||||
});
|
||||
expect(insert.options.bindDefs.vector.type).toBe(DB_TYPE_VECTOR);
|
||||
expect(insert.options.bindDefs.payload.type).toBe(DB_TYPE_JSON);
|
||||
});
|
||||
|
||||
it("issues a single executeMany call with one bind row per vector on a multi-row insert", async () => {
|
||||
const calls: Call[] = [];
|
||||
await makeStore(calls).insert(
|
||||
[
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
],
|
||||
["id-1", "id-2"],
|
||||
[{ a: 1 }, { b: 2 }],
|
||||
);
|
||||
|
||||
const inserts = calls.filter((c) => c.sql.startsWith("INSERT INTO"));
|
||||
expect(inserts).toHaveLength(1);
|
||||
expect(inserts[0].binds).toHaveLength(2);
|
||||
expect(inserts[0].binds[0].id).toBe("id-1");
|
||||
expect(inserts[0].binds[1].id).toBe("id-2");
|
||||
});
|
||||
|
||||
it("issues no insert statement when inserting an empty batch", async () => {
|
||||
const calls: Call[] = [];
|
||||
await makeStore(calls).insert([], [], []);
|
||||
expect(calls.filter((c) => c.sql.startsWith("INSERT INTO"))).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects insert batches whose IDs or payloads do not match vectors", async () => {
|
||||
const store = makeStore([]);
|
||||
|
||||
await expect(store.insert([[1, 2, 3]], [], [{}])).rejects.toThrow(
|
||||
"ids and vectors must have the same length",
|
||||
);
|
||||
await expect(store.insert([[1, 2, 3]], ["id-1"], [])).rejects.toThrow(
|
||||
"payloads and vectors must have the same length",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts cosine distance to a similarity score", async () => {
|
||||
const calls: Call[] = [];
|
||||
const store = makeStore(calls, [[["id-1", { data: "hello" }, 0.25]]]);
|
||||
const results = await store.search([1, 2, 3], 5);
|
||||
|
||||
expect(results).toEqual([
|
||||
{ id: "id-1", payload: { data: "hello" }, score: 0.75 },
|
||||
]);
|
||||
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
|
||||
expect(select.sql).toContain(
|
||||
"VECTOR_DISTANCE(vector, :query_vec, COSINE) distance",
|
||||
);
|
||||
expect(select.sql).toContain("FETCH APPROX FIRST :max_rows ROWS ONLY");
|
||||
expect(select.binds.max_rows).toBe(5);
|
||||
});
|
||||
|
||||
it("inverts the sign of a DOT distance", async () => {
|
||||
const store = makeStore([], [[["id-1", {}, -0.4]]], {
|
||||
distanceMetric: "DOT",
|
||||
});
|
||||
const [result] = await store.search([1, 2, 3]);
|
||||
expect(result.score).toBeCloseTo(0.4);
|
||||
});
|
||||
|
||||
it("adds the VECTOR_INDEX_TRANSFORM hint when searching without filters", async () => {
|
||||
const calls: Call[] = [];
|
||||
const store = makeStore(calls, [[["id-1", {}, 0.1]]]);
|
||||
await store.search([1, 2, 3], 5);
|
||||
|
||||
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
|
||||
expect(select.sql).toContain('/*+ VECTOR_INDEX_TRANSFORM("mem0") */');
|
||||
});
|
||||
|
||||
it("omits the VECTOR_INDEX_TRANSFORM hint when searching with filters", async () => {
|
||||
const calls: Call[] = [];
|
||||
const store = makeStore(calls, [[["id-1", {}, 0.1]]]);
|
||||
await store.search([1, 2, 3], 5, { user_id: "alice" });
|
||||
|
||||
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
|
||||
expect(select.sql).not.toContain("VECTOR_INDEX_TRANSFORM");
|
||||
});
|
||||
|
||||
it("parses a payload returned as a JSON string", async () => {
|
||||
const store = makeStore([], [[["id-1", '{"data":"hello"}']]]);
|
||||
expect(await store.get("id-1")).toEqual({
|
||||
id: "id-1",
|
||||
payload: { data: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when get finds no row", async () => {
|
||||
expect(await makeStore([], [[]]).get("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("generates and persists a UUID user id when none is stored", async () => {
|
||||
const calls: Call[] = [];
|
||||
const userId = await makeStore(calls, [[]]).getUserId();
|
||||
|
||||
expect(userId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
const insert = calls.find((c) =>
|
||||
c.sql.startsWith("INSERT INTO memory_migrations"),
|
||||
)!;
|
||||
expect(insert.binds).toEqual({ user_id: userId });
|
||||
});
|
||||
|
||||
it("returns the stored user id when one exists", async () => {
|
||||
expect(await makeStore([], [[["alice"]]]).getUserId()).toBe("alice");
|
||||
});
|
||||
|
||||
it("returns rows and the total count from list", async () => {
|
||||
const calls: Call[] = [];
|
||||
const store = makeStore(calls, [[["id-1", { data: "hello" }, 7]]]);
|
||||
const [results, count] = await store.list({ user_id: "alice" }, 10);
|
||||
|
||||
expect(results).toEqual([{ id: "id-1", payload: { data: "hello" } }]);
|
||||
expect(count).toBe(7);
|
||||
|
||||
const selects = calls.filter((c) => /^SELECT/i.test(c.sql));
|
||||
expect(selects).toHaveLength(1);
|
||||
const [list] = selects;
|
||||
expect(list.sql).toContain(
|
||||
"SELECT id, payload, COUNT(*) OVER () total FROM",
|
||||
);
|
||||
expect(list.sql).toContain("WHERE JSON_EXISTS(payload,");
|
||||
expect(list.binds).toEqual({ f_0: "alice", max_rows: 10 });
|
||||
});
|
||||
|
||||
it("returns a total of 0 from list when no rows match", async () => {
|
||||
const store = makeStore([], [[]]);
|
||||
const [results, count] = await store.list();
|
||||
expect(results).toEqual([]);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OracleAIVectorSearch initialization failure", () => {
|
||||
beforeEach(() => mockCreatePool.mockReset());
|
||||
|
||||
function fakePool(serverVersion: string) {
|
||||
const connection = {
|
||||
...fakeConnection([], []),
|
||||
oracleServerVersionString: serverVersion,
|
||||
};
|
||||
return {
|
||||
close: jest.fn(async () => {}),
|
||||
getConnection: jest.fn(async () => connection),
|
||||
};
|
||||
}
|
||||
|
||||
function makePoolStore() {
|
||||
return new OracleAIVectorSearch({
|
||||
connectionParams: { user: "u", password: "p", connectString: "d" },
|
||||
collectionName: "mem0",
|
||||
embeddingModelDims: 3,
|
||||
} as any);
|
||||
}
|
||||
|
||||
it("closes the pool it owns when initialization fails", async () => {
|
||||
const pool = fakePool("23.3.0.24.05");
|
||||
mockCreatePool.mockResolvedValue(pool);
|
||||
|
||||
await expect(makePoolStore().initialize()).rejects.toThrow(
|
||||
"Oracle DB version 23.3.0.24.05 not supported",
|
||||
);
|
||||
expect(pool.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache the rejection, so a later attempt can succeed", async () => {
|
||||
const failing = fakePool("23.3.0.24.05");
|
||||
const working = fakePool("23.4.0.24.05");
|
||||
mockCreatePool
|
||||
.mockResolvedValueOnce(failing)
|
||||
.mockResolvedValueOnce(working);
|
||||
|
||||
const store = makePoolStore();
|
||||
await expect(store.initialize()).rejects.toThrow("not supported");
|
||||
await expect(store.initialize()).resolves.toBeUndefined();
|
||||
expect(mockCreatePool).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("leaves a caller-supplied client open when initialization fails", async () => {
|
||||
const client = fakeConnection([], []);
|
||||
client.oracleServerVersionString = "23.3.0.24.05";
|
||||
const close = jest.spyOn(client, "close");
|
||||
|
||||
const store = new OracleAIVectorSearch({
|
||||
client: client as any,
|
||||
collectionName: "mem0",
|
||||
embeddingModelDims: 3,
|
||||
} as any);
|
||||
|
||||
await expect(store.initialize()).rejects.toThrow("not supported");
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Unit tests for Memory.buildSessionScope. The recent-conversation buffer key
|
||||
* derived from user_id/agent_id/run_id must be unique per id combination.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
jest.mock("../src/utils/factory", () => {
|
||||
const { MemoryVectorStore } = jest.requireActual(
|
||||
"../src/vector_stores/memory",
|
||||
);
|
||||
const { SQLiteManager } = jest.requireActual("../src/storage/SQLiteManager");
|
||||
const testEmbedding = new Array(1536).fill(0.1);
|
||||
class MockEmbedder {
|
||||
embeddingDims = 1536;
|
||||
async embed(): Promise<number[]> {
|
||||
return testEmbedding;
|
||||
}
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
return texts.map(() => testEmbedding);
|
||||
}
|
||||
}
|
||||
class MockLLM {
|
||||
async generateResponse() {
|
||||
return JSON.stringify({ memory: [] });
|
||||
}
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
EmbedderFactory: { create: jest.fn(() => new MockEmbedder()) },
|
||||
LLMFactory: { create: jest.fn(() => new MockLLM()) },
|
||||
VectorStoreFactory: {
|
||||
create: jest.fn(
|
||||
() => new MemoryVectorStore({ collectionName: "t", dimension: 1536 }),
|
||||
),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn(() => new SQLiteManager(":memory:")),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Memory } from "../src/memory";
|
||||
import { SearchFilters } from "../src/types";
|
||||
|
||||
function scopeOf(memory: Memory, filters: SearchFilters): string {
|
||||
return (memory as any).buildSessionScope(filters);
|
||||
}
|
||||
|
||||
function cartesian(values: string[], length: number): string[][] {
|
||||
if (length === 0) return [[]];
|
||||
const rest = cartesian(values, length - 1);
|
||||
const result: string[][] = [];
|
||||
for (const value of values) {
|
||||
for (const tail of rest) {
|
||||
result.push([value, ...tail]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("Memory session scope key", () => {
|
||||
let memory: Memory;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = new Memory();
|
||||
await (memory as any)._initPromise;
|
||||
});
|
||||
|
||||
it("keeps the unchanged key format for ids without delimiter characters", () => {
|
||||
expect(
|
||||
scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }),
|
||||
).toBe("user_id=550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(scopeOf(memory, { agent_id: "agent.assistant:v2" })).toBe(
|
||||
"agent_id=agent.assistant:v2",
|
||||
);
|
||||
expect(scopeOf(memory, { run_id: "12345" })).toBe("run_id=12345");
|
||||
expect(
|
||||
scopeOf(memory, { user_id: "user@example.com", agent_id: "support-bot" }),
|
||||
).toBe("agent_id=support-bot&user_id=user@example.com");
|
||||
});
|
||||
|
||||
it("no longer collides an id embedding the join syntax with the equivalent split filters", () => {
|
||||
const collapsedRun = scopeOf(memory, { run_id: "proj-x&user_id=u1" });
|
||||
const splitRun = scopeOf(memory, { user_id: "u1", run_id: "proj-x" });
|
||||
expect(collapsedRun).not.toBe(splitRun);
|
||||
|
||||
const collapsedAgent = scopeOf(memory, { run_id: "proj-y&agent_id=a1" });
|
||||
const splitAgent = scopeOf(memory, { agent_id: "a1", run_id: "proj-y" });
|
||||
expect(collapsedAgent).not.toBe(splitAgent);
|
||||
});
|
||||
|
||||
it("maps every distinct filter combination of delimiter-heavy ids to a distinct key", () => {
|
||||
const keys: string[] = ["user_id", "agent_id", "run_id"];
|
||||
const values = [
|
||||
"u1",
|
||||
"r1",
|
||||
"a1",
|
||||
"%",
|
||||
"&",
|
||||
"=",
|
||||
"a==",
|
||||
"a1&run_id=r1",
|
||||
"a1&user_id=u1",
|
||||
"r1&user_id=u1",
|
||||
"a1&run_id=r1&user_id=u1",
|
||||
];
|
||||
const seen = new Map<string, SearchFilters>();
|
||||
|
||||
for (let mask = 1; mask < 1 << keys.length; mask++) {
|
||||
const subset = keys.filter((_key, i) => mask & (1 << i));
|
||||
for (const combo of cartesian(values, subset.length)) {
|
||||
const filters: SearchFilters = {};
|
||||
subset.forEach((key, i) => (filters[key] = combo[i]));
|
||||
const scope = scopeOf(memory, filters);
|
||||
if (seen.has(scope)) {
|
||||
expect(seen.get(scope)).toEqual(filters);
|
||||
} else {
|
||||
seen.set(scope, filters);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives ids containing delimiter characters a new key format", () => {
|
||||
expect(scopeOf(memory, { user_id: "dXNlcl9pZDE=" })).toBe(
|
||||
"user_id=dXNlcl9pZDE%3D",
|
||||
);
|
||||
expect(scopeOf(memory, { agent_id: "x&y" })).toBe("agent_id=x%26y");
|
||||
expect(scopeOf(memory, { run_id: "50% off" })).toBe("run_id=50%25 off");
|
||||
});
|
||||
|
||||
it("routes the add pipeline through the builder", async () => {
|
||||
const db = (memory as any).db;
|
||||
const spy = jest.spyOn(db, "getLastMessages");
|
||||
await memory.add([{ role: "user", content: "hello" }], {
|
||||
runId: "proj-x&user_id=u1",
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith("run_id=proj-x%26user_id%3Du1", 10);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("pins the exact key strings shared with the Python test suite", () => {
|
||||
expect(
|
||||
scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }),
|
||||
).toBe("user_id=550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(
|
||||
scopeOf(memory, { user_id: "u1", agent_id: "a1", run_id: "r1" }),
|
||||
).toBe("agent_id=a1&run_id=r1&user_id=u1");
|
||||
expect(scopeOf(memory, { run_id: "proj-x&user_id=u1" })).toBe(
|
||||
"run_id=proj-x%26user_id%3Du1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,6 @@ const external = [
|
||||
"@elastic/elasticsearch",
|
||||
"chromadb",
|
||||
"weaviate-client",
|
||||
"oracledb",
|
||||
];
|
||||
|
||||
const define = {
|
||||
|
||||
@@ -734,7 +734,6 @@ class MemoryClient:
|
||||
memory_depth: Optional[str] = None,
|
||||
usecase_setting: Optional[str] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
agent_custom_instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the project settings.
|
||||
|
||||
@@ -745,7 +744,6 @@ class MemoryClient:
|
||||
memory_depth: Memory depth for the project.
|
||||
usecase_setting: Usecase setting for the project.
|
||||
multilingual: Whether to use the input language for memory storage and retrieval.
|
||||
agent_custom_instructions: New extraction instructions for agent-scoped memories.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -770,7 +768,6 @@ class MemoryClient:
|
||||
"memory_depth": memory_depth,
|
||||
"usecase_setting": usecase_setting,
|
||||
"multilingual": multilingual,
|
||||
"agent_custom_instructions": agent_custom_instructions,
|
||||
}.items()
|
||||
if v is not None
|
||||
},
|
||||
@@ -1639,7 +1636,6 @@ class AsyncMemoryClient:
|
||||
memory_depth: Optional[str] = None,
|
||||
usecase_setting: Optional[str] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
agent_custom_instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the project settings.
|
||||
|
||||
@@ -1650,7 +1646,6 @@ class AsyncMemoryClient:
|
||||
memory_depth: Memory depth for the project.
|
||||
usecase_setting: Usecase setting for the project.
|
||||
multilingual: Whether to use the input language for memory storage and retrieval.
|
||||
agent_custom_instructions: New extraction instructions for agent-scoped memories.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -1675,7 +1670,6 @@ class AsyncMemoryClient:
|
||||
"memory_depth": memory_depth,
|
||||
"usecase_setting": usecase_setting,
|
||||
"multilingual": multilingual,
|
||||
"agent_custom_instructions": agent_custom_instructions,
|
||||
}.items()
|
||||
if v is not None
|
||||
},
|
||||
|
||||
+4
-28
@@ -177,7 +177,6 @@ class BaseProject(ABC):
|
||||
self,
|
||||
custom_instructions: Optional[str] = None,
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
agent_custom_instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -185,7 +184,6 @@ class BaseProject(ABC):
|
||||
Args:
|
||||
custom_instructions: New instructions for the project
|
||||
custom_categories: New categories for the project
|
||||
agent_custom_instructions: New extraction instructions for agent-scoped memories
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -398,7 +396,6 @@ class Project(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
decay: Optional[bool] = None,
|
||||
agent_custom_instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -410,7 +407,6 @@ class Project(BaseProject):
|
||||
decay: Toggle Memory Decay for this project. When True, search-time
|
||||
ranking boosts recently-used memories and gently dampens stale ones; when
|
||||
False, ranking is restored to the pre-decay behaviour. Off by default.
|
||||
agent_custom_instructions: New extraction instructions for agent-scoped memories
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -422,17 +418,10 @@ class Project(BaseProject):
|
||||
NetworkError: If network connectivity issues occur.
|
||||
ValueError: If org_id or project_id are not set.
|
||||
"""
|
||||
if (
|
||||
custom_instructions is None
|
||||
and custom_categories is None
|
||||
and multilingual is None
|
||||
and decay is None
|
||||
and agent_custom_instructions is None
|
||||
):
|
||||
if custom_instructions is None and custom_categories is None and multilingual is None and decay is None:
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, multilingual, decay, "
|
||||
"agent_custom_instructions"
|
||||
"custom_instructions, custom_categories, multilingual, decay"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -441,7 +430,6 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"agent_custom_instructions": agent_custom_instructions,
|
||||
}
|
||||
)
|
||||
response = self._client.patch(
|
||||
@@ -457,7 +445,6 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"agent_custom_instructions": agent_custom_instructions,
|
||||
"sync_type": "sync",
|
||||
},
|
||||
)
|
||||
@@ -722,7 +709,6 @@ class AsyncProject(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
decay: Optional[bool] = None,
|
||||
agent_custom_instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -734,7 +720,6 @@ class AsyncProject(BaseProject):
|
||||
decay: Toggle Memory Decay for this project. When True, search-time
|
||||
ranking boosts recently-used memories and gently dampens stale ones; when
|
||||
False, ranking is restored to the pre-decay behaviour. Off by default.
|
||||
agent_custom_instructions: New extraction instructions for agent-scoped memories
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -746,17 +731,10 @@ class AsyncProject(BaseProject):
|
||||
NetworkError: If network connectivity issues occur.
|
||||
ValueError: If org_id or project_id are not set.
|
||||
"""
|
||||
if (
|
||||
custom_instructions is None
|
||||
and custom_categories is None
|
||||
and multilingual is None
|
||||
and decay is None
|
||||
and agent_custom_instructions is None
|
||||
):
|
||||
if custom_instructions is None and custom_categories is None and multilingual is None and decay is None:
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, multilingual, decay, "
|
||||
"agent_custom_instructions"
|
||||
"custom_instructions, custom_categories, multilingual, decay"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -765,7 +743,6 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"agent_custom_instructions": agent_custom_instructions,
|
||||
}
|
||||
)
|
||||
response = await self._client.patch(
|
||||
@@ -781,7 +758,6 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"agent_custom_instructions": agent_custom_instructions,
|
||||
"sync_type": "async",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,9 +28,6 @@ class AddMemoryOptions(BaseModel):
|
||||
default=None, description="Custom categories for memory classification"
|
||||
)
|
||||
custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction")
|
||||
agent_custom_instructions: Optional[str] = Field(
|
||||
default=None, description="Custom instructions for fact extraction from agent-scoped memories"
|
||||
)
|
||||
timestamp: Optional[int] = Field(default=None, description="Unix timestamp for the memory")
|
||||
expiration_date: Optional[str] = Field(default=None, description="Expiration date in YYYY-MM-DD format")
|
||||
structured_data_schema: Optional[Dict[str, Any]] = Field(
|
||||
@@ -110,9 +107,6 @@ class ProjectUpdateOptions(BaseModel):
|
||||
"""Options for project update operations."""
|
||||
|
||||
custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction")
|
||||
agent_custom_instructions: Optional[str] = Field(
|
||||
default=None, description="Custom instructions for fact extraction from agent-scoped memories"
|
||||
)
|
||||
custom_categories: Optional[List[Dict[str, Any]]] = Field(
|
||||
default=None, description="Custom categories for classification"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# Copyright (c) 2026, Oracle and/or its affiliates.
|
||||
"""Pydantic configuration for the Oracle AI Vector Search integration."""
|
||||
|
||||
import re
|
||||
@@ -92,9 +91,8 @@ class OracleAIVectorSearchConfig(BaseModel):
|
||||
exclude_none=True,
|
||||
)
|
||||
|
||||
if self.index_accuracy is not None:
|
||||
if not (0 < self.index_accuracy <= 100):
|
||||
raise ValueError("`index_accuracy` must be between 1 and 100")
|
||||
if self.index_accuracy and not (0 < self.index_accuracy <= 100):
|
||||
raise ValueError("`index_accuracy` must be between 1 and 100")
|
||||
|
||||
if not (0 < self.embedding_model_dims):
|
||||
raise ValueError("`embedding_model_dims` must be bigger than 0")
|
||||
|
||||
+7
-42
@@ -135,30 +135,18 @@ _SENSITIVE_SUFFIXES = (
|
||||
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "run_id"})
|
||||
DELETE_ALL_BATCH_SIZE = 1000
|
||||
|
||||
# Tenant-scoping fields that caller-supplied metadata must never set, on either the
|
||||
# creation or the update path (issues #4490, #6277, #6655).
|
||||
# Tenant-scoping fields that update() must never let caller-supplied metadata overwrite (issues #4490, #6277).
|
||||
_IDENTITY_KEYS = ENTITY_PARAMS | {"actor_id"}
|
||||
|
||||
|
||||
def _strip_identity_keys(
|
||||
metadata: Dict[str, Any],
|
||||
existing_payload: Dict[str, Any],
|
||||
*,
|
||||
context: str = "update()",
|
||||
) -> Dict[str, Any]:
|
||||
"""Drop identity keys from caller metadata; scope is set by the entity params, not metadata.
|
||||
|
||||
On the update path `existing_payload` carries the memory's current scope, so
|
||||
re-sending an identical value is silently accepted; only a changed value warns.
|
||||
On the creation path there is no prior payload, so pass an empty dict and every
|
||||
identity key present in `metadata` is dropped with a warning.
|
||||
"""
|
||||
def _strip_identity_keys(metadata: Dict[str, Any], existing_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Drop identity keys from caller metadata; they are immutable after creation (issues #4490, #6277)."""
|
||||
clean = {}
|
||||
for key, value in metadata.items():
|
||||
if key not in _IDENTITY_KEYS:
|
||||
clean[key] = value
|
||||
elif value != existing_payload.get(key):
|
||||
logger.warning(f"{context}: ignoring metadata['{key}'] - identity fields cannot be set through metadata")
|
||||
logger.warning(f"update(): ignoring metadata['{key}'] - identity fields are immutable after creation")
|
||||
return clean
|
||||
|
||||
|
||||
@@ -327,9 +315,7 @@ def _build_filters_and_metadata(
|
||||
for flexible session scoping and optionally narrows queries to a specific `actor_id`. It returns two dicts:
|
||||
|
||||
1. `base_metadata_template`: Used as a template for metadata when storing new memories.
|
||||
It includes all provided session identifier(s) and any `input_metadata`. Identity
|
||||
scope is set from the entity params only; identity keys in `input_metadata` are
|
||||
dropped, so freeform metadata cannot place a memory into an unrequested scope.
|
||||
It includes all provided session identifier(s) and any `input_metadata`.
|
||||
2. `effective_query_filters`: Used for querying existing memories. It includes all
|
||||
provided session identifier(s), any `input_filters`, and a resolved actor
|
||||
identifier for targeted filtering if specified by any actor-related inputs.
|
||||
@@ -357,12 +343,7 @@ def _build_filters_and_metadata(
|
||||
scoped to the provided session(s) and potentially a resolved actor.
|
||||
"""
|
||||
|
||||
# Identity scope is set below from the entity params only. Stripping the keys here
|
||||
# stops caller metadata from placing a memory into a scope the caller did not pass,
|
||||
# which the re-pins below cannot prevent for a param that was left unset (issue #6655).
|
||||
base_metadata_template = (
|
||||
_strip_identity_keys(deepcopy(input_metadata), {}, context="add()") if input_metadata else {}
|
||||
)
|
||||
base_metadata_template = deepcopy(input_metadata) if input_metadata else {}
|
||||
effective_query_filters = deepcopy(input_filters) if input_filters else {}
|
||||
|
||||
# ---------- validate and add all provided session ids ----------
|
||||
@@ -404,18 +385,13 @@ def _build_filters_and_metadata(
|
||||
return base_metadata_template, effective_query_filters
|
||||
|
||||
|
||||
def _escape_scope_value(val: Any) -> str:
|
||||
"""Escape the structural delimiters of the session scope key."""
|
||||
return str(val).replace("%", "%25").replace("&", "%26").replace("=", "%3D")
|
||||
|
||||
|
||||
def _build_session_scope(filters):
|
||||
"""Build deterministic session scope string from entity IDs."""
|
||||
parts = []
|
||||
for key in sorted(["user_id", "agent_id", "run_id"]):
|
||||
val = filters.get(key)
|
||||
if val:
|
||||
parts.append(f"{key}={_escape_scope_value(val)}")
|
||||
parts.append(f"{key}={val}")
|
||||
return "&".join(parts)
|
||||
|
||||
|
||||
@@ -796,11 +772,6 @@ class Memory(MemoryBase):
|
||||
are treated as general conversational/factual memories.
|
||||
prompt (str, optional): Prompt to use for the memory creation. Defaults to None.
|
||||
|
||||
Note:
|
||||
`search()` and `get_all()` scope queries via `filters={"user_id": "...", "agent_id": "...", "run_id": "..."}` —
|
||||
they reject top-level `user_id`/`agent_id`/`run_id` arguments. `add()` accepts them top-level, but passing
|
||||
the same arguments to `search()`/`get_all()` raises a `ValueError`; use the `filters` form there instead.
|
||||
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the result of the memory addition operation, typically
|
||||
@@ -2457,12 +2428,6 @@ class AsyncMemory(MemoryBase):
|
||||
Pass "procedural_memory" to create procedural memories.
|
||||
prompt (str, optional): Prompt to use for the memory creation. Defaults to None.
|
||||
llm (BaseChatModel, optional): LLM class to use for generating procedural memories. Defaults to None. Useful when user is using LangChain ChatModel.
|
||||
|
||||
Note:
|
||||
`search()` and `get_all()` scope queries via `filters={"user_id": "...", "agent_id": "...", "run_id": "..."}` —
|
||||
they reject top-level `user_id`/`agent_id`/`run_id` arguments. `add()` accepts them top-level, but passing
|
||||
the same arguments to `search()`/`get_all()` raises a `ValueError`; use the `filters` form there instead.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the result of the memory addition operation.
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from mem0.configs.prompts import (
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
@@ -112,7 +112,7 @@ def normalize_facts(raw_facts):
|
||||
return normalized
|
||||
|
||||
|
||||
def remove_code_blocks(content: Union[str, List[Union[str, Dict[str, Any]]], None]) -> str:
|
||||
def remove_code_blocks(content: str) -> str:
|
||||
"""
|
||||
Removes enclosing code block markers ```[language] and ``` from a given string.
|
||||
|
||||
@@ -120,15 +120,9 @@ def remove_code_blocks(content: Union[str, List[Union[str, Dict[str, Any]]], Non
|
||||
- The function uses a regex pattern to match code blocks that may start with ``` followed by an optional language tag (letters or numbers) and end with ```.
|
||||
- If a code block is detected, it returns only the inner content, stripping out the markers.
|
||||
- If no code block markers are found, the original content is returned as-is.
|
||||
- Block-style content (a LangChain message whose `.content` is a list of text
|
||||
blocks) is flattened to its concatenated text before matching.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, list):
|
||||
content = "".join(
|
||||
part if isinstance(part, str) else part.get("text", "") for part in content if isinstance(part, (str, dict))
|
||||
)
|
||||
pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$"
|
||||
match = re.match(pattern, content.strip())
|
||||
match_res=match.group(1).strip() if match else content.strip()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# Copyright (c) 2026, Oracle and/or its affiliates.
|
||||
"""Oracle AI Vector Search vector store integration for mem0."""
|
||||
|
||||
import array
|
||||
@@ -246,34 +245,25 @@ class OracleAIVectorSearch(VectorStoreBase):
|
||||
self.client = oracledb.connect(**self.config.connection_params)
|
||||
self._owns_client = True
|
||||
|
||||
try:
|
||||
if not (hasattr(self.client, "thin") and self.client.thin):
|
||||
if oracledb.clientversion()[:2] < (23, 4):
|
||||
raise RuntimeError(
|
||||
f"Oracle DB client driver version {'.'.join(map(str, oracledb.clientversion()))} "
|
||||
"not supported, must be >=23.4 for vector support"
|
||||
)
|
||||
|
||||
if isinstance(self.client, oracledb.Connection):
|
||||
db_version = tuple([int(v) for v in self.client.version.split(".")])
|
||||
else:
|
||||
with self.client.acquire() as conn:
|
||||
db_version = tuple([int(v) for v in conn.version.split(".")])
|
||||
|
||||
if db_version < (23, 4):
|
||||
raise ValueError(
|
||||
f"Oracle DB version {'.'.join(map(str, db_version))} not supported, "
|
||||
"must be >=23.4 for vector support"
|
||||
if not (hasattr(self.client, "thin") and self.client.thin):
|
||||
if oracledb.clientversion()[:2] < (23, 4):
|
||||
raise RuntimeError(
|
||||
f"Oracle DB client driver version {'.'.join(map(str, oracledb.clientversion()))} "
|
||||
"not supported, must be >=23.4 for vector support"
|
||||
)
|
||||
|
||||
self.create_col()
|
||||
except Exception:
|
||||
if self._owns_client:
|
||||
try:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
if isinstance(self.client, oracledb.Connection):
|
||||
db_version = tuple([int(v) for v in self.client.version.split(".")])
|
||||
else:
|
||||
with self.client.acquire() as conn:
|
||||
db_version = tuple([int(v) for v in conn.version.split(".")])
|
||||
|
||||
if db_version < (23, 4):
|
||||
raise ValueError(
|
||||
f"Oracle DB version {'.'.join(map(str, db_version))} not supported, must be >=23.4 for vector support"
|
||||
)
|
||||
|
||||
self.create_col()
|
||||
|
||||
@contextmanager
|
||||
def _get_cursor(self, commit: bool = False):
|
||||
|
||||
@@ -89,10 +89,6 @@ def _build_filter_conditions(filters):
|
||||
raise ValueError(f"Unsupported filter operator: {op}")
|
||||
template, is_numeric = OPERATOR_SQL_MAP[op]
|
||||
if op in ("in", "nin"):
|
||||
if not isinstance(op_value, list):
|
||||
raise ValueError(
|
||||
f"Filter operator {op!r} for key {key!r} requires a list value, got {type(op_value).__name__}"
|
||||
)
|
||||
str_list = [str(v) for v in op_value]
|
||||
conditions.append(template)
|
||||
params.extend([key, str_list])
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -14,23 +13,6 @@ except ImportError:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAFE_FILTER_KEY = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*\Z")
|
||||
|
||||
|
||||
def _validate_filter(key: str, value: Any) -> None:
|
||||
if not isinstance(key, str) or not _SAFE_FILTER_KEY.fullmatch(key):
|
||||
raise ValueError(f"Invalid filter key: {key!r}")
|
||||
if not isinstance(value, (str, int, float, bool)):
|
||||
raise ValueError(
|
||||
f"Filter value for {key!r} must be str, int, float, or bool, "
|
||||
f"got {type(value).__name__}"
|
||||
)
|
||||
if isinstance(value, str) and ('"' in value or "\\" in value):
|
||||
raise ValueError(
|
||||
f"Filter value for {key!r} contains prohibited characters "
|
||||
f"(double quote or backslash): {value!r}"
|
||||
)
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str] # memory id
|
||||
@@ -110,9 +92,7 @@ class UpstashVector(VectorStoreBase):
|
||||
)
|
||||
|
||||
def _stringify(self, x):
|
||||
if isinstance(x, str):
|
||||
return f'"{x}"'
|
||||
return x
|
||||
return f'"{x}"' if isinstance(x, str) else x
|
||||
|
||||
def search(
|
||||
self,
|
||||
@@ -133,9 +113,6 @@ class UpstashVector(VectorStoreBase):
|
||||
List[OutputData]: Search results.
|
||||
"""
|
||||
|
||||
if filters:
|
||||
for k, v in filters.items():
|
||||
_validate_filter(k, v)
|
||||
filters_str = " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) if filters else None
|
||||
|
||||
response = []
|
||||
@@ -183,16 +160,13 @@ class UpstashVector(VectorStoreBase):
|
||||
Returns:
|
||||
List[OutputData]: Search results, or None if sparse/BM25 search is not supported.
|
||||
"""
|
||||
if filters:
|
||||
for k, v in filters.items():
|
||||
_validate_filter(k, v)
|
||||
filters_str = (
|
||||
" AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()])
|
||||
if filters
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
filters_str = (
|
||||
" AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()])
|
||||
if filters
|
||||
else None
|
||||
)
|
||||
|
||||
response = self.client.query(
|
||||
data=query,
|
||||
top_k=top_k,
|
||||
@@ -278,9 +252,6 @@ class UpstashVector(VectorStoreBase):
|
||||
Returns:
|
||||
List[OutputData]: Search results.
|
||||
"""
|
||||
if filters:
|
||||
for k, v in filters.items():
|
||||
_validate_filter(k, v)
|
||||
filters_str = " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) if filters else None
|
||||
|
||||
info = self.client.info()
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0ai"
|
||||
version = "2.0.18"
|
||||
version = "2.0.15"
|
||||
description = "Long-term memory for AI Agents"
|
||||
authors = [
|
||||
{ name = "Mem0", email = "support@mem0.ai" }
|
||||
|
||||
@@ -453,95 +453,6 @@ def test_update_memory_metadata_cannot_change_identity_fields(mocker, caplog):
|
||||
assert "ignoring metadata['user_id']" in caplog.text
|
||||
|
||||
|
||||
_ATTACKER_ADD_METADATA = {
|
||||
"agent_id": "victim-agent",
|
||||
"run_id": "victim-run",
|
||||
"actor_id": "victim-actor",
|
||||
"category": "sports",
|
||||
}
|
||||
|
||||
|
||||
def _captured_add_metadata(memory, mocker, **add_kwargs):
|
||||
"""Run add() with the pipeline stubbed and return the metadata template it produced."""
|
||||
captured = {}
|
||||
|
||||
def _capture(messages, metadata, filters, infer, **kwargs):
|
||||
captured.update(metadata)
|
||||
return []
|
||||
|
||||
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
|
||||
memory.add("I like coffee", infer=False, **add_kwargs)
|
||||
return captured
|
||||
|
||||
|
||||
def test_add_metadata_cannot_set_identity_fields(mocker, caplog):
|
||||
"""Regression (issue #6655): add() metadata must not inject identity scope.
|
||||
|
||||
The caller scopes by user_id only, so the agent_id/run_id re-pins in
|
||||
_build_filters_and_metadata never fire and cannot defend the payload.
|
||||
"""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mem0.memory.main"):
|
||||
metadata = _captured_add_metadata(
|
||||
memory, mocker, user_id="attacker", metadata=dict(_ATTACKER_ADD_METADATA)
|
||||
)
|
||||
|
||||
assert metadata["user_id"] == "attacker"
|
||||
for key in ("agent_id", "run_id", "actor_id"):
|
||||
assert key not in metadata, f"{key} was injected through add() metadata"
|
||||
# Non-identity metadata is untouched.
|
||||
assert metadata["category"] == "sports"
|
||||
assert "ignoring metadata['agent_id']" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_add_metadata_cannot_set_identity_fields(mocker):
|
||||
"""Async counterpart of test_add_metadata_cannot_set_identity_fields."""
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
captured = {}
|
||||
|
||||
async def _capture(messages, metadata, filters, infer, **kwargs):
|
||||
captured.update(metadata)
|
||||
return []
|
||||
|
||||
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
|
||||
await memory.add(
|
||||
"I like coffee",
|
||||
user_id="attacker",
|
||||
metadata=dict(_ATTACKER_ADD_METADATA),
|
||||
infer=False,
|
||||
)
|
||||
|
||||
assert captured["user_id"] == "attacker"
|
||||
for key in ("agent_id", "run_id", "actor_id"):
|
||||
assert key not in captured, f"{key} was injected through async add() metadata"
|
||||
assert captured["category"] == "sports"
|
||||
|
||||
|
||||
def test_add_entity_params_still_set_scope(mocker):
|
||||
"""The documented top-level params remain the only way to set scope."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
|
||||
metadata = _captured_add_metadata(
|
||||
memory, mocker, user_id="u1", agent_id="a1", run_id="r1", metadata={"category": "sports"}
|
||||
)
|
||||
|
||||
assert metadata["user_id"] == "u1"
|
||||
assert metadata["agent_id"] == "a1"
|
||||
assert metadata["run_id"] == "r1"
|
||||
assert metadata["category"] == "sports"
|
||||
|
||||
|
||||
def test_add_without_metadata_is_unaffected(mocker):
|
||||
"""No metadata argument means no stripping and no behaviour change."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
|
||||
metadata = _captured_add_metadata(memory, mocker, user_id="u1")
|
||||
|
||||
assert metadata == {"user_id": "u1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_update_memory_metadata_cannot_change_identity_fields(mocker):
|
||||
"""Async counterpart of test_update_memory_metadata_cannot_change_identity_fields."""
|
||||
|
||||
@@ -192,21 +192,3 @@ class TestProcessTelemetryFilters:
|
||||
class TestRemoveCodeBlocks:
|
||||
def test_none_content_returns_empty_string(self):
|
||||
assert remove_code_blocks(None) == ""
|
||||
|
||||
def test_block_list_content_is_flattened(self):
|
||||
assert remove_code_blocks([{"type": "text", "text": "step one"}]) == "step one"
|
||||
|
||||
def test_block_list_content_strips_code_fences(self):
|
||||
content = [{"type": "text", "text": '```json\n{"a": 1}\n```'}]
|
||||
assert remove_code_blocks(content) == '{"a": 1}'
|
||||
|
||||
def test_block_list_content_joins_multiple_blocks(self):
|
||||
content = [{"type": "text", "text": "step one. "}, {"type": "text", "text": "step two"}]
|
||||
assert remove_code_blocks(content) == "step one. step two"
|
||||
|
||||
def test_block_list_content_ignores_non_text_blocks(self):
|
||||
content = [{"type": "thinking", "thinking": "hmm"}, {"type": "text", "text": "answer"}]
|
||||
assert remove_code_blocks(content) == "answer"
|
||||
|
||||
def test_plain_string_blocks_are_supported(self):
|
||||
assert remove_code_blocks(["step one. ", "step two"]) == "step one. step two"
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.memory.main import Memory, _build_session_scope, _escape_scope_value
|
||||
|
||||
DELIMITER_VALUES = [
|
||||
"u1",
|
||||
"r1",
|
||||
"a1",
|
||||
"%",
|
||||
"&",
|
||||
"=",
|
||||
"%25",
|
||||
"%26",
|
||||
"%3D",
|
||||
"a==",
|
||||
"a1&run_id=r1",
|
||||
"a1&user_id=u1",
|
||||
"r1&user_id=u1",
|
||||
"a1&run_id=r1&user_id=u1",
|
||||
]
|
||||
|
||||
|
||||
class TestBuildSessionScope:
|
||||
"""Tests that _build_session_scope produces a unique key per id combination."""
|
||||
|
||||
def test_ordinary_ids_produce_unchanged_scope_keys(self):
|
||||
"""Ids without delimiter characters keep producing the pre-fix key format."""
|
||||
cases = [
|
||||
({"user_id": "550e8400-e29b-41d4-a716-446655440000"}, "user_id=550e8400-e29b-41d4-a716-446655440000"),
|
||||
({"agent_id": "agent.assistant:v2"}, "agent_id=agent.assistant:v2"),
|
||||
({"run_id": "12345"}, "run_id=12345"),
|
||||
(
|
||||
{"user_id": "user@example.com", "agent_id": "support-bot"},
|
||||
"agent_id=support-bot&user_id=user@example.com",
|
||||
),
|
||||
(
|
||||
{"user_id": "u1", "agent_id": "a1", "run_id": "r1"},
|
||||
"agent_id=a1&run_id=r1&user_id=u1",
|
||||
),
|
||||
]
|
||||
for filters, expected in cases:
|
||||
assert _build_session_scope(filters) == expected
|
||||
|
||||
def test_ids_containing_delimiters_do_not_collide(self):
|
||||
"""A value that embeds the join syntax no longer maps to the same key as the equivalent split filters."""
|
||||
collapsed_run = {"run_id": "proj-x&user_id=u1"}
|
||||
split_run = {"user_id": "u1", "run_id": "proj-x"}
|
||||
assert _build_session_scope(collapsed_run) != _build_session_scope(split_run)
|
||||
assert _build_session_scope(collapsed_run) == "run_id=proj-x%26user_id%3Du1"
|
||||
|
||||
collapsed_agent = {"run_id": "proj-y&agent_id=a1"}
|
||||
split_agent = {"agent_id": "a1", "run_id": "proj-y"}
|
||||
assert _build_session_scope(collapsed_agent) != _build_session_scope(split_agent)
|
||||
|
||||
def test_ids_containing_delimiters_get_a_new_key_format(self):
|
||||
"""Ids holding a delimiter character map to a new key, so their buffer starts empty once after upgrade."""
|
||||
assert _build_session_scope({"user_id": "dXNlcl9pZDE="}) == "user_id=dXNlcl9pZDE%3D"
|
||||
assert _build_session_scope({"agent_id": "x&y"}) == "agent_id=x%26y"
|
||||
assert _build_session_scope({"run_id": "50% off"}) == "run_id=50%25 off"
|
||||
|
||||
def test_scope_keys_are_unique_per_filter_combination(self):
|
||||
"""Every distinct filter dict built from delimiter-heavy id values maps to a distinct scope key."""
|
||||
keys = ["user_id", "agent_id", "run_id"]
|
||||
seen = {}
|
||||
for size in range(1, len(keys) + 1):
|
||||
for key_subset in itertools.combinations(keys, size):
|
||||
for combo in itertools.product(DELIMITER_VALUES, repeat=size):
|
||||
filters = dict(zip(key_subset, combo))
|
||||
scope = _build_session_scope(filters)
|
||||
if scope in seen:
|
||||
assert seen[scope] == filters, f"{seen[scope]} and {filters} both map to {scope!r}"
|
||||
else:
|
||||
seen[scope] = filters
|
||||
|
||||
|
||||
class TestEscapeScopeValue:
|
||||
"""Tests for the low-level per-value escaping helper."""
|
||||
|
||||
def test_non_string_input_is_stringified(self):
|
||||
assert _escape_scope_value(42) == "42"
|
||||
|
||||
def test_percent_is_escaped_before_other_delimiters(self):
|
||||
assert _escape_scope_value("%26") == "%2526"
|
||||
assert _escape_scope_value("%26") != "%26"
|
||||
|
||||
def test_each_delimiter_is_escaped(self):
|
||||
assert _escape_scope_value("%") == "%25"
|
||||
assert _escape_scope_value("&") == "%26"
|
||||
assert _escape_scope_value("=") == "%3D"
|
||||
|
||||
|
||||
class TestSessionScopeWiring:
|
||||
"""Tests that the add pipeline keys the conversation buffer through the builder."""
|
||||
|
||||
@pytest.fixture
|
||||
def memory(self, mocker):
|
||||
mocker.patch("mem0.memory.main.capture_event")
|
||||
mock_embedder = mocker.MagicMock()
|
||||
mock_embedder.return_value.embed.return_value = [0.1, 0.2, 0.3]
|
||||
mocker.patch("mem0.utils.factory.EmbedderFactory.create", mock_embedder)
|
||||
mock_vector_store = mocker.MagicMock()
|
||||
mock_vector_store.return_value.search.return_value = []
|
||||
mocker.patch(
|
||||
"mem0.utils.factory.VectorStoreFactory.create",
|
||||
side_effect=[mock_vector_store.return_value, mocker.MagicMock()],
|
||||
)
|
||||
mocker.patch("mem0.utils.factory.LlmFactory.create", mocker.MagicMock())
|
||||
mocker.patch("mem0.memory.storage.SQLiteManager", mocker.MagicMock())
|
||||
|
||||
memory = Memory()
|
||||
memory.config = mocker.MagicMock()
|
||||
memory.config.custom_instructions = None
|
||||
memory.custom_instructions = None
|
||||
memory.api_version = "v1.1"
|
||||
memory.db.get_last_messages = mocker.MagicMock(return_value=[])
|
||||
memory.db.save_messages = mocker.MagicMock()
|
||||
memory.llm.generate_response.return_value = '{"memory": []}'
|
||||
return memory
|
||||
|
||||
def test_add_pipeline_uses_the_escaped_key(self, memory):
|
||||
"""The pipeline must route through the builder, not assemble the key inline."""
|
||||
memory._add_to_vector_store(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
metadata={},
|
||||
filters={"run_id": "proj-x&user_id=u1"},
|
||||
infer=True,
|
||||
)
|
||||
assert memory.db.get_last_messages.call_args[0][0] == "run_id=proj-x%26user_id%3Du1"
|
||||
assert memory.db.save_messages.call_args[0][1] == "run_id=proj-x%26user_id%3Du1"
|
||||
@@ -440,51 +440,3 @@ class TestValidateApiKeyHttpError:
|
||||
|
||||
assert not isinstance(exc_info.value, requests.exceptions.JSONDecodeError)
|
||||
assert "Error:" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestAddAgentCustomInstructions:
|
||||
"""Per-request override of the project-level setting, forwarded to the add payload."""
|
||||
|
||||
def _mock_add(self, client):
|
||||
response = MagicMock()
|
||||
response.json.return_value = {"results": []}
|
||||
response.raise_for_status.return_value = None
|
||||
client.client.post.return_value = response
|
||||
return response
|
||||
|
||||
def test_kwarg_reaches_the_add_payload(self, mock_memory_client):
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add(
|
||||
"hello",
|
||||
filters={"agent_id": "a1"},
|
||||
agent_custom_instructions="remember tool failures",
|
||||
)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
|
||||
|
||||
def test_typed_option_reaches_the_add_payload(self, mock_memory_client):
|
||||
from mem0.client.types import AddMemoryOptions
|
||||
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add(
|
||||
"hello",
|
||||
AddMemoryOptions(
|
||||
filters={"agent_id": "a1"},
|
||||
agent_custom_instructions="remember tool failures",
|
||||
),
|
||||
)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
|
||||
|
||||
def test_absent_when_not_passed(self, mock_memory_client):
|
||||
"""Callers that don't use the feature send an unchanged payload."""
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add("hello", filters={"user_id": "u1"})
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert "agent_custom_instructions" not in kwargs["json"]
|
||||
|
||||
+2
-37
@@ -1235,9 +1235,8 @@ class TestHybridSearchWarning:
|
||||
def test_warning_for_store_without_keyword_search(
|
||||
self, mock_vs_factory, mock_emb, mock_llm, mock_sqlite, _cap, caplog
|
||||
):
|
||||
import logging
|
||||
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
import logging
|
||||
|
||||
class StoreWithoutKeywordSearch(VectorStoreBase):
|
||||
def create_col(self, *a, **kw): pass
|
||||
@@ -1272,9 +1271,8 @@ class TestHybridSearchWarning:
|
||||
def test_no_warning_for_store_with_keyword_search(
|
||||
self, mock_vs_factory, mock_emb, mock_llm, mock_sqlite, _cap, caplog
|
||||
):
|
||||
import logging
|
||||
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
import logging
|
||||
|
||||
class StoreWithKeywordSearch(VectorStoreBase):
|
||||
def keyword_search(self, query, top_k=5, filters=None):
|
||||
@@ -1538,39 +1536,6 @@ async def test_async_procedural_memory_langchain_strips_code_blocks(mock_llm_fac
|
||||
assert "```" not in stored_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.memory.main.VectorStoreFactory")
|
||||
@patch("mem0.memory.main.EmbedderFactory")
|
||||
@patch("mem0.memory.main.LlmFactory")
|
||||
async def test_async_procedural_memory_langchain_block_list_content(mock_llm_factory, mock_emb, mock_vs):
|
||||
"""Regression #6150: block-list message content must not raise AttributeError."""
|
||||
mock_vs.return_value = MagicMock()
|
||||
mock_emb.return_value = MagicMock()
|
||||
mock_emb.return_value.embed.return_value = [0.1] * 1536
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import AsyncMemory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = AsyncMemory(config)
|
||||
memory.vector_store = MagicMock()
|
||||
memory.vector_store.insert = MagicMock()
|
||||
|
||||
mock_langchain_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [{"type": "text", "text": '```json\n{"key": "value"}\n```'}]
|
||||
mock_langchain_llm.invoke.return_value = mock_response
|
||||
|
||||
messages = [{"role": "user", "content": "test"}]
|
||||
metadata = {"user_id": "test_user"}
|
||||
|
||||
await memory._create_procedural_memory(messages, metadata=metadata, llm=mock_langchain_llm)
|
||||
|
||||
insert_call = memory.vector_store.insert.call_args
|
||||
stored_data = insert_call[1]["payloads"][0]["data"]
|
||||
assert stored_data == '{"key": "value"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.memory.main.VectorStoreFactory")
|
||||
@patch("mem0.memory.main.EmbedderFactory")
|
||||
|
||||
+2
-46
@@ -3,8 +3,8 @@ parameter-passthrough surface.
|
||||
|
||||
Verifies the kwarg → JSON payload mapping for every supported field
|
||||
(``custom_instructions``, ``custom_categories``, ``multilingual``,
|
||||
``decay``, ``agent_custom_instructions``), the ValueError when no field
|
||||
is provided, and the URL/method shape. The HTTP layer is mocked.
|
||||
``decay``), the ValueError when no field is provided, and the
|
||||
URL/method shape. The HTTP layer is mocked.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -84,50 +84,6 @@ class TestProjectUpdateDecay:
|
||||
assert args[0] == "/api/v1/orgs/organizations/org1/projects/proj1/"
|
||||
|
||||
|
||||
class TestProjectUpdateAgentCustomInstructions:
|
||||
def test_agent_custom_instructions_sent_in_payload(self, project):
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="remember tool failures")
|
||||
assert _patch_payload(http) == {"agent_custom_instructions": "remember tool failures"}
|
||||
|
||||
def test_agent_custom_instructions_alone_satisfies_the_guard(self, project):
|
||||
"""It is a standalone field, so setting only it must not raise."""
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="remember tool failures")
|
||||
assert http.patch.called
|
||||
|
||||
def test_empty_string_round_trips_to_clear_the_field(self, project):
|
||||
"""An empty string must survive the ``is not None`` filter, not be dropped as falsy."""
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="")
|
||||
assert _patch_payload(http) == {"agent_custom_instructions": ""}
|
||||
|
||||
def test_combined_with_custom_instructions(self, project):
|
||||
"""Both sets in one PATCH: the split-instruction configuration."""
|
||||
proj, http = project
|
||||
proj.update(
|
||||
custom_instructions="remember user preferences",
|
||||
agent_custom_instructions="remember tool failures",
|
||||
)
|
||||
assert _patch_payload(http) == {
|
||||
"custom_instructions": "remember user preferences",
|
||||
"agent_custom_instructions": "remember tool failures",
|
||||
}
|
||||
|
||||
def test_omitted_when_none(self, project):
|
||||
"""Callers that don't pass it must send an unchanged payload."""
|
||||
proj, http = project
|
||||
proj.update(custom_instructions="be concise")
|
||||
payload = _patch_payload(http)
|
||||
assert payload == {"custom_instructions": "be concise"}
|
||||
assert "agent_custom_instructions" not in payload
|
||||
|
||||
def test_no_args_raises_with_agent_instructions_in_message(self, project):
|
||||
proj, _ = project
|
||||
with pytest.raises(ValueError, match=r"agent_custom_instructions"):
|
||||
proj.update()
|
||||
|
||||
|
||||
class TestProjectUpdateBackwardsCompat:
|
||||
def test_multilingual_only_still_works(self, project):
|
||||
"""Pre-decay callers (multilingual only) keep working unchanged."""
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# Copyright (c) 2026, Oracle and/or its affiliates.
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
@@ -268,16 +267,6 @@ def test_index_parameters_reject_non_string_keys():
|
||||
)
|
||||
|
||||
|
||||
def test_index_accuracy_rejects_zero():
|
||||
with pytest.raises(ValueError, match="index_accuracy.*between 1 and 100"):
|
||||
OracleAIVectorSearchConfig(
|
||||
collection_name=_unique_collection_name(),
|
||||
embedding_model_dims=DIM,
|
||||
client=object(),
|
||||
index_accuracy=0,
|
||||
)
|
||||
|
||||
|
||||
def test_index_parameters_canonicalize_int_subclasses():
|
||||
class FormattedInt(int):
|
||||
def __format__(self, format_spec):
|
||||
@@ -342,27 +331,6 @@ def test_config_rejects_none_for_non_optional_fields(field, value):
|
||||
OracleAIVectorSearchConfig(client=object(), **{field: value})
|
||||
|
||||
|
||||
def test_init_closes_owned_client_when_post_connect_setup_fails(monkeypatch):
|
||||
fake_connection = MagicMock(spec=oracledb.Connection)
|
||||
fake_connection.thin = True
|
||||
fake_connection.version = "23.4.0.0"
|
||||
|
||||
monkeypatch.setattr(oracledb, "connect", MagicMock(return_value=fake_connection))
|
||||
monkeypatch.setattr(OracleAIVectorSearch, "create_col", MagicMock(side_effect=RuntimeError("boom")))
|
||||
monkeypatch.setattr(OracleAIVectorSearch, "__del__", lambda self: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
OracleAIVectorSearch(
|
||||
collection_name=_unique_collection_name(),
|
||||
embedding_model_dims=DIM,
|
||||
connection_params={"user": "u", "password": "p", "dsn": "d"},
|
||||
use_connection_pool=False,
|
||||
do_create_index=False,
|
||||
)
|
||||
|
||||
fake_connection.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("metric", "distance", "expected_score"),
|
||||
[
|
||||
|
||||
@@ -2508,20 +2508,3 @@ class TestBuildFilterConditions(unittest.TestCase):
|
||||
def test_numeric_scalar_becomes_string(self):
|
||||
conditions, params = _build_filter_conditions({"priority": 42})
|
||||
self.assertEqual(params, ["priority", "42"])
|
||||
|
||||
def test_in_rejects_string_value(self):
|
||||
"""Passing a string to 'in' would iterate characters and produce a misleading ANY() clause."""
|
||||
with self.assertRaises(ValueError, msg="Expected ValueError for non-list 'in' value"):
|
||||
_build_filter_conditions({"user_id": {"in": "alice"}})
|
||||
|
||||
def test_in_rejects_dict_value(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_build_filter_conditions({"user_id": {"in": {"$gt": 0}}})
|
||||
|
||||
def test_nin_rejects_string_value(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_build_filter_conditions({"user_id": {"nin": "alice"}})
|
||||
|
||||
def test_in_accepts_list_value(self):
|
||||
conditions, params = _build_filter_conditions({"user_id": {"in": ["alice", "bob"]}})
|
||||
self.assertEqual(params, ["user_id", ["alice", "bob"]])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user