Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e09cc383b | ||
|
|
96d45b78c7 | ||
|
|
ba2fb9f4c3 | ||
|
|
14c431735b | ||
|
|
d70cc00ab3 | ||
|
|
c427a453a8 | ||
|
|
f5b4300449 | ||
|
|
71f2ebefa3 | ||
|
|
35a125585e | ||
|
|
4debc58a83 | ||
|
|
b42cfdd5c8 | ||
|
|
6fe6140dba | ||
|
|
b05dc2740f | ||
|
|
4a0a9a92a6 | ||
|
|
beea626f0a |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -15,6 +15,7 @@ export interface AddOptions {
|
||||
infer?: boolean;
|
||||
expires?: string;
|
||||
customInstructions?: string;
|
||||
agentCustomInstructions?: string;
|
||||
customCategories?: Record<string, string>[];
|
||||
structuredDataSchema?: Record<string, unknown>;
|
||||
timestamp?: number;
|
||||
|
||||
@@ -153,6 +153,8 @@ 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,6 +53,7 @@ export async function cmdAdd(
|
||||
expires?: string;
|
||||
categories?: string;
|
||||
customInstructions?: string;
|
||||
agentCustomInstructions?: string;
|
||||
customCategories?: string;
|
||||
structuredDataSchema?: string;
|
||||
timestamp?: number;
|
||||
@@ -153,6 +154,7 @@ 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,6 +328,10 @@ 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,6 +50,7 @@ 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,6 +84,7 @@ 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,
|
||||
@@ -91,6 +92,9 @@ 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);
|
||||
@@ -109,6 +113,7 @@ 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");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -278,6 +278,11 @@ 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",
|
||||
@@ -327,6 +332,7 @@ 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,
|
||||
@@ -1139,6 +1145,7 @@ 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,6 +26,7 @@ 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,6 +88,7 @@ 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,
|
||||
@@ -117,6 +118,8 @@ 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,6 +77,7 @@ 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,
|
||||
@@ -166,6 +167,7 @@ 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,6 +38,7 @@ 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,12 +22,14 @@ 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
|
||||
@@ -40,6 +42,7 @@ 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
|
||||
|
||||
@@ -7,6 +7,16 @@ 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:**
|
||||
@@ -1196,6 +1206,24 @@ 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:**
|
||||
|
||||
@@ -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](./models) 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](./overview) 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](./models) 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](./overview) 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](./dbs).
|
||||
1. Identify the vector database you want to use from [supported vector databases](./overview).
|
||||
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](./dbs) 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](./overview) section. There you'll find individual pages for each supported vector store with provider-specific usage examples and configuration details.
|
||||
|
||||
@@ -3,6 +3,8 @@ 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
|
||||
|
||||
@@ -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, 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, Oracle AI Vector Search, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store.
|
||||
</Note>
|
||||
|
||||
<CardGroup cols={3}>
|
||||
|
||||
@@ -4,10 +4,14 @@ description: "Retrieve, review, and migrate user memories with structured export
|
||||
---
|
||||
|
||||
|
||||
Mem0 is a dynamic memory store that gives you full control over your data. Along with storing memories, it gives you the ability to retrieve, export, and migrate your data whenever you need.
|
||||
Mem0 is a dynamic memory store that lets you retrieve memories as raw records or transform them into a structured export for downstream use.
|
||||
|
||||
This cookbook shows you how to retrieve and export your data for inspection, migration, or compliance.
|
||||
|
||||
<Warning>
|
||||
The structured export job is not a raw backup. It uses the schema and instructions you provide to transform matching memories. Use paginated `get_all()` calls when you need the original memory records. For an account-level data access or compliance request that the API cannot complete, contact support.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
@@ -254,7 +258,7 @@ You can also export memories directly from the Mem0 platform UI:
|
||||
3. Select your filters and schema
|
||||
4. Download the completed export as JSON
|
||||
|
||||
This is useful for one-off exports or manual data reviews.
|
||||
This is useful for one-off structured exports or manual data reviews. It does not create a raw backup of every memory record; use paginated `get_all()` calls for raw retrieval.
|
||||
|
||||
<Warning>
|
||||
Exported data expires after 7 days. Download and store exports locally if you need long-term archives. After expiration, you'll need to recreate the export job.
|
||||
@@ -272,13 +276,13 @@ A complete memory export system with multiple retrieval methods:
|
||||
- **Export instructions** - Guide conflict resolution and data formatting
|
||||
- **Platform UI exports** - One-off manual downloads via dashboard
|
||||
|
||||
This covers data portability, GDPR compliance, system migrations, and manual reviews.
|
||||
These approaches support data inspection, portability workflows, system migrations, and manual reviews. Use raw retrieval or contact support when a compliance request requires the original records rather than a schema-transformed export.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Use **`get_all()`** for bulk retrieval, **`search()`** for specific questions, and **`create_memory_export()`** for structured data exports with custom schemas. Remember exports expire after 7 days: download them locally for long-term archives.
|
||||
Use **`get_all()`** for paginated raw retrieval, **`search()`** for specific questions, and **`create_memory_export()`** for schema-transformed data. Structured exports expire after 7 days, so download completed results promptly. Do not treat them as raw backups.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Build a Mem0 Companion" icon="users" href="/cookbooks/essentials/building-ai-companion">
|
||||
|
||||
@@ -1035,6 +1035,10 @@
|
||||
"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"
|
||||
|
||||
@@ -7,6 +7,10 @@ description: 'Export memories in a structured format using customizable Pydantic
|
||||
|
||||
The Memory Export feature allows you to create structured exports of memories using customizable Pydantic schemas. This process enables you to transform your stored memories into specific data formats that match your needs. You can apply various filters to narrow down which memories to export and define exactly how the data should be structured.
|
||||
|
||||
<Warning>
|
||||
Memory Export transforms matching memories into the schema you provide. It is not a raw backup or a byte-for-byte download of every stored memory record. If you need the original memory records, retrieve them with the paginated `get_all()` API instead. For an account-level data access or compliance request that cannot be completed through the API, contact support.
|
||||
</Warning>
|
||||
|
||||
## Creating a Memory Export
|
||||
|
||||
To create a memory export, you'll need to:
|
||||
@@ -278,6 +282,8 @@ You can apply various filters to customize which memories are included in the ex
|
||||
The export process may take some time to complete, especially when dealing with a large number of memories or complex schemas.
|
||||
</Note>
|
||||
|
||||
If an export remains in progress unexpectedly, do not submit duplicate jobs. Retrieve the job by its export ID to check its status, then contact support with that ID if it does not complete.
|
||||
|
||||
If you have any questions, please feel free to reach out to us using one of the following methods:
|
||||
|
||||
<Snippet file="get-help.mdx" />
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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,6 +21,7 @@ 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")
|
||||
@@ -126,6 +127,8 @@ 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,6 +26,7 @@ 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")
|
||||
@@ -95,7 +96,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 = "") -> bool:
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "", cwd: str | None = None) -> bool:
|
||||
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
metadata = {
|
||||
"type": "compact_summary",
|
||||
@@ -115,6 +116,8 @@ 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(
|
||||
@@ -182,7 +185,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):
|
||||
if store_summary(api_key, summary, user_id, session_id, project_id, branch, cwd):
|
||||
if session_id:
|
||||
try:
|
||||
os.makedirs(marker_dir, exist_ok=True)
|
||||
|
||||
@@ -24,6 +24,7 @@ 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")
|
||||
@@ -163,6 +164,7 @@ 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 = {
|
||||
@@ -188,6 +190,8 @@ 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(
|
||||
@@ -257,7 +261,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)
|
||||
store_summary(api_key, summary_prompt, user_id, session_id, project_id, branch, files, cwd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
#!/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,6 +148,14 @@ 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,6 +132,28 @@ 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*.
|
||||
|
||||
@@ -203,6 +225,16 @@ 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,6 +34,7 @@ _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")
|
||||
|
||||
@@ -74,6 +75,8 @@ 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"):
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,126 @@
|
||||
"""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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""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,6 +157,22 @@ 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."""
|
||||
@@ -190,6 +206,7 @@ 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)
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@qdrant/js-client-rest": "^1.18.0",
|
||||
"uuid@<11.1.1": ">=11.1.1",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0",
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0",
|
||||
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
|
||||
|
||||
Generated
+5
-5
@@ -13,7 +13,7 @@ overrides:
|
||||
'@qdrant/js-client-rest': ^1.18.0
|
||||
uuid@<11.1.1: '>=11.1.1'
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<6.27.0: '>=6.27.0 <8.0.0'
|
||||
undici@<7.29.0: '>=7.29.0 <8.0.0'
|
||||
axios@<1.18.0: '>=1.18.0 <2.0.0'
|
||||
postcss@<8.5.18: '>=8.5.18 <9.0.0'
|
||||
mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0'
|
||||
@@ -2036,8 +2036,8 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
@@ -2547,7 +2547,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
||||
typescript: 5.9.3
|
||||
undici: 7.28.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -4121,7 +4121,7 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@7.28.0: {}
|
||||
undici@7.29.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ overrides:
|
||||
"@qdrant/js-client-rest": "^1.18.0"
|
||||
"uuid@<11.1.1": ">=11.1.1"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0"
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0"
|
||||
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
"form-data@<4.0.6": ">=4.0.6",
|
||||
"uuid@<11.1.1": ">=11.1.1",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.0",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0",
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0",
|
||||
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0",
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0",
|
||||
|
||||
+10
-10
@@ -8,8 +8,8 @@ overrides:
|
||||
form-data@<4.0.6: '>=4.0.6'
|
||||
uuid@<11.1.1: '>=11.1.1'
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<6.27.0: '>=6.27.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.5.0: '>=8.5.0'
|
||||
undici@<7.29.0: '>=7.29.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0'
|
||||
axios@<1.18.0: '>=1.18.0 <2.0.0'
|
||||
brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0'
|
||||
postcss@<8.5.18: '>=8.5.18 <9.0.0'
|
||||
@@ -2354,12 +2354,12 @@ packages:
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@8.5.0:
|
||||
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
|
||||
undici@8.10.0:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
@@ -2942,7 +2942,7 @@ snapshots:
|
||||
minimatch: 10.2.5
|
||||
proper-lockfile: 4.1.2
|
||||
typebox: 1.1.38
|
||||
undici: 8.5.0
|
||||
undici: 8.10.0
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@mariozechner/clipboard': 0.3.9
|
||||
@@ -3203,7 +3203,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
||||
typescript: 6.0.3
|
||||
undici: 7.28.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -4895,9 +4895,9 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
undici@7.28.0: {}
|
||||
undici@7.29.0: {}
|
||||
|
||||
undici@8.5.0: {}
|
||||
undici@8.10.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ overrides:
|
||||
"form-data@<4.0.6": ">=4.0.6"
|
||||
"uuid@<11.1.1": ">=11.1.1"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0"
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0"
|
||||
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0"
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0"
|
||||
|
||||
@@ -41,10 +41,17 @@
|
||||
],
|
||||
"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": {
|
||||
@@ -62,8 +69,9 @@
|
||||
"form-data@<4.0.6": ">=4.0.6",
|
||||
"uuid@<11.1.1": ">=11.1.1",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0",
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+8
-6
@@ -8,8 +8,9 @@ overrides:
|
||||
form-data@<4.0.6: '>=4.0.6'
|
||||
uuid@<11.1.1: '>=11.1.1'
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<6.27.0: '>=6.27.0 <8.0.0'
|
||||
undici@>=8.0.0 <8.5.0: '>=8.5.0'
|
||||
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'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -413,8 +414,8 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -530,6 +531,7 @@ 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==}
|
||||
@@ -1995,7 +1997,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.41: {}
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
brace-expansion@1.1.18:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
@@ -2792,7 +2794,7 @@ snapshots:
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.15
|
||||
brace-expansion: 1.1.18
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ overrides:
|
||||
"form-data@<4.0.6": ">=4.0.6"
|
||||
"uuid@<11.1.1": ">=11.1.1"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0"
|
||||
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
|
||||
"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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.6",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -292,18 +292,19 @@
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.4",
|
||||
"esbuild": ">=0.28.1",
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0",
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.967.0",
|
||||
"@aws-sdk/client-neptune-graph": "3.966.0",
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0",
|
||||
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0",
|
||||
"brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2 <3.0.0",
|
||||
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <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"
|
||||
"protobufjs@<7.6.5": ">=7.6.5 <8.0.0",
|
||||
"ip-address@<10.3.1": ">=10.3.1 <11.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+17
-16
@@ -25,18 +25,19 @@ overrides:
|
||||
glob@>=10.2.0 <10.5.0: ^10.5.0
|
||||
'@modelcontextprotocol/sdk': ^1.25.4
|
||||
esbuild: '>=0.28.1'
|
||||
undici@<6.27.0: '>=6.27.0 <8.0.0'
|
||||
undici@<7.29.0: '>=7.29.0 <8.0.0'
|
||||
'@aws-sdk/client-bedrock-runtime': 3.967.0
|
||||
'@aws-sdk/client-neptune-graph': 3.966.0
|
||||
axios@<1.18.0: '>=1.18.0 <2.0.0'
|
||||
brace-expansion@<1.1.16: '>=1.1.16 <2.0.0'
|
||||
brace-expansion@>=2.0.0 <2.1.2: '>=2.1.2 <3.0.0'
|
||||
brace-expansion@>=2.0.0 <2.1.4: '>=2.1.4 <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:
|
||||
|
||||
@@ -2165,8 +2166,8 @@ packages:
|
||||
brace-expansion@1.1.16:
|
||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
|
||||
brace-expansion@5.0.8:
|
||||
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
|
||||
@@ -3027,8 +3028,8 @@ packages:
|
||||
resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
ip-address@10.4.0:
|
||||
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
is-arrayish@0.2.1:
|
||||
@@ -4626,8 +4627,8 @@ packages:
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
undici@7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
@@ -6155,7 +6156,7 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
secure-json-parse: 4.1.0
|
||||
tslib: 2.8.1
|
||||
undici: 7.28.0
|
||||
undici: 7.29.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6711,7 +6712,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@qdrant/openapi-typescript-fetch': 1.2.6
|
||||
typescript: 5.5.4
|
||||
undici: 7.28.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@qdrant/openapi-typescript-fetch@1.2.6': {}
|
||||
|
||||
@@ -7183,7 +7184,7 @@ snapshots:
|
||||
'@turbopuffer/turbopuffer@2.5.0':
|
||||
dependencies:
|
||||
pako: 2.2.0
|
||||
undici: 7.28.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
@@ -7542,7 +7543,7 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
brace-expansion@2.1.4:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -8448,7 +8449,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
ip-address@10.4.0: {}
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
@@ -9076,7 +9077,7 @@ snapshots:
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.2
|
||||
brace-expansion: 2.1.4
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -9886,7 +9887,7 @@ snapshots:
|
||||
|
||||
socks@2.8.9:
|
||||
dependencies:
|
||||
ip-address: 10.2.0
|
||||
ip-address: 10.4.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
source-map-support@0.5.13:
|
||||
@@ -10232,7 +10233,7 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
undici@7.28.0: {}
|
||||
undici@7.29.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
|
||||
@@ -25,13 +25,14 @@ overrides:
|
||||
"glob@>=10.2.0 <10.5.0": "^10.5.0"
|
||||
"@modelcontextprotocol/sdk": "^1.25.4"
|
||||
"esbuild": ">=0.28.1"
|
||||
"undici@<6.27.0": ">=6.27.0 <8.0.0"
|
||||
"undici@<7.29.0": ">=7.29.0 <8.0.0"
|
||||
"axios@<1.18.0": ">=1.18.0 <2.0.0"
|
||||
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0"
|
||||
"brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2 <3.0.0"
|
||||
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <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"
|
||||
|
||||
@@ -556,11 +556,18 @@ 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}=${val}`);
|
||||
if (val) parts.push(`${key}=${this.escapeScopeValue(val)}`);
|
||||
}
|
||||
return parts.join("&");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Connection, Pool } from "oracledb";
|
||||
import type { BindParameters, Connection, Pool } from "oracledb";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
@@ -401,7 +401,15 @@ export class OracleAIVectorSearch implements VectorStore {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
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;
|
||||
}
|
||||
@@ -565,19 +573,33 @@ export class OracleAIVectorSearch implements VectorStore {
|
||||
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) => {
|
||||
for (let i = 0; i < vectors.length; i++) {
|
||||
await connection.execute(
|
||||
`INSERT INTO ${this.collectionName} (id, vector, payload) VALUES (:id, :vector, :payload)`,
|
||||
{
|
||||
id: ids[i],
|
||||
vector: this.vectorBind(vectors[i]),
|
||||
payload: this.payloadBind(payloads[i] ?? {}),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -589,8 +611,12 @@ export class OracleAIVectorSearch implements VectorStore {
|
||||
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 =
|
||||
`SELECT id, payload, VECTOR_DISTANCE(vector, :query_vec, ${this.distanceMetric}) distance ` +
|
||||
`${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) => {
|
||||
@@ -687,20 +713,17 @@ export class OracleAIVectorSearch implements VectorStore {
|
||||
|
||||
return this.withConnection(async (connection) => {
|
||||
const listResult = await connection.execute<any[]>(
|
||||
`SELECT id, payload FROM ${this.collectionName} ${whereClause} FETCH FIRST :max_rows ROWS ONLY`,
|
||||
`SELECT id, payload, COUNT(*) OVER () total FROM ${this.collectionName} ${whereClause} FETCH FIRST :max_rows ROWS ONLY`,
|
||||
{ ...filterBinds, max_rows: topK },
|
||||
);
|
||||
const countResult = await connection.execute<any[]>(
|
||||
`SELECT COUNT(*) FROM ${this.collectionName} ${whereClause}`,
|
||||
filterBinds,
|
||||
);
|
||||
|
||||
const results = (listResult.rows ?? []).map((row) => ({
|
||||
const rows = listResult.rows ?? [];
|
||||
const results = rows.map((row) => ({
|
||||
id: row[0],
|
||||
payload: this.loadPayload(row[1]),
|
||||
}));
|
||||
|
||||
return [results, Number(countResult.rows?.[0]?.[0] ?? 0)];
|
||||
return [results, Number(rows[0]?.[2] ?? 0)];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
/** 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" };
|
||||
|
||||
jest.mock("oracledb", () => ({ thin: true, DB_TYPE_VECTOR, DB_TYPE_JSON }), {
|
||||
virtual: true,
|
||||
});
|
||||
const mockCreatePool = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
"oracledb",
|
||||
() => ({
|
||||
thin: true,
|
||||
DB_TYPE_VECTOR,
|
||||
DB_TYPE_JSON,
|
||||
DB_TYPE_VARCHAR,
|
||||
createPool: mockCreatePool,
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
import {
|
||||
OracleAIVectorSearch,
|
||||
@@ -13,7 +24,7 @@ import {
|
||||
quoteIdentifier,
|
||||
} from "../src/vector_stores/oracledb";
|
||||
|
||||
type Call = { sql: string; binds: any };
|
||||
type Call = { sql: string; binds: any; options?: any };
|
||||
|
||||
function fakeConnection(calls: Call[], resultsBySql: Array<any[][]>) {
|
||||
let selectIndex = 0;
|
||||
@@ -26,6 +37,10 @@ function fakeConnection(calls: Call[], resultsBySql: Array<any[][]>) {
|
||||
}
|
||||
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() {},
|
||||
@@ -249,13 +264,56 @@ describe("OracleAIVectorSearch SQL", () => {
|
||||
await makeStore(calls).insert([[1, 2, 3]], ["id-1"], [{ data: "hello" }]);
|
||||
|
||||
const insert = calls.find((c) => c.sql.startsWith("INSERT INTO"))!;
|
||||
expect(insert.binds.id).toBe("id-1");
|
||||
expect(insert.binds.vector.type).toBe(DB_TYPE_VECTOR);
|
||||
expect(insert.binds.vector.val).toEqual(new Float32Array([1, 2, 3]));
|
||||
expect(insert.binds.payload).toEqual({
|
||||
type: DB_TYPE_JSON,
|
||||
val: { data: "hello" },
|
||||
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 () => {
|
||||
@@ -266,7 +324,7 @@ describe("OracleAIVectorSearch SQL", () => {
|
||||
expect(results).toEqual([
|
||||
{ id: "id-1", payload: { data: "hello" }, score: 0.75 },
|
||||
]);
|
||||
const select = calls.find((c) => c.sql.startsWith("SELECT id, payload,"))!;
|
||||
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
|
||||
expect(select.sql).toContain(
|
||||
"VECTOR_DISTANCE(vector, :query_vec, COSINE) distance",
|
||||
);
|
||||
@@ -282,6 +340,24 @@ describe("OracleAIVectorSearch SQL", () => {
|
||||
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({
|
||||
@@ -313,16 +389,87 @@ describe("OracleAIVectorSearch SQL", () => {
|
||||
|
||||
it("returns rows and the total count from list", async () => {
|
||||
const calls: Call[] = [];
|
||||
const store = makeStore(calls, [[["id-1", { data: "hello" }]], [[7]]]);
|
||||
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 list = calls.find((c) =>
|
||||
c.sql.startsWith("SELECT id, payload FROM"),
|
||||
)!;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
# Copyright (c) 2026, Oracle and/or its affiliates.
|
||||
"""Pydantic configuration for the Oracle AI Vector Search integration."""
|
||||
|
||||
import re
|
||||
@@ -91,8 +92,9 @@ class OracleAIVectorSearchConfig(BaseModel):
|
||||
exclude_none=True,
|
||||
)
|
||||
|
||||
if self.index_accuracy and not (0 < self.index_accuracy <= 100):
|
||||
raise ValueError("`index_accuracy` must be between 1 and 100")
|
||||
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 not (0 < self.embedding_model_dims):
|
||||
raise ValueError("`embedding_model_dims` must be bigger than 0")
|
||||
|
||||
+17
-1
@@ -404,13 +404,18 @@ 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}={val}")
|
||||
parts.append(f"{key}={_escape_scope_value(val)}")
|
||||
return "&".join(parts)
|
||||
|
||||
|
||||
@@ -791,6 +796,11 @@ 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
|
||||
@@ -2447,6 +2457,12 @@ 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,3 +1,4 @@
|
||||
# Copyright (c) 2026, Oracle and/or its affiliates.
|
||||
"""Oracle AI Vector Search vector store integration for mem0."""
|
||||
|
||||
import array
|
||||
@@ -245,25 +246,34 @@ class OracleAIVectorSearch(VectorStoreBase):
|
||||
self.client = oracledb.connect(**self.config.connection_params)
|
||||
self._owns_client = True
|
||||
|
||||
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"
|
||||
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 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()
|
||||
self.create_col()
|
||||
except Exception:
|
||||
if self._owns_client:
|
||||
try:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def _get_cursor(self, commit: bool = False):
|
||||
|
||||
@@ -89,6 +89,10 @@ 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
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0ai"
|
||||
version = "2.0.17"
|
||||
version = "2.0.18"
|
||||
description = "Long-term memory for AI Agents"
|
||||
authors = [
|
||||
{ name = "Mem0", email = "support@mem0.ai" }
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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"
|
||||
@@ -1,3 +1,4 @@
|
||||
# Copyright (c) 2026, Oracle and/or its affiliates.
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
@@ -267,6 +268,16 @@ 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):
|
||||
@@ -331,6 +342,27 @@ 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,3 +2508,20 @@ 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"]])
|
||||
|
||||
Reference in New Issue
Block a user