Compare commits

..
Author SHA1 Message Date
kartik-mem0 d5b041e942 fix(memory): handle block-list content in remove_code_blocks
A custom LangChain LLM passed to AsyncMemory._create_procedural_memory
returns AIMessage.content, which is str | list[str | dict]. Providers
that emit content blocks hit content.strip() on a list and raised
AttributeError.

remove_code_blocks already normalized None, so the list case is handled
in the same guard rather than at the single call site.

Closes #6150
2026-08-13 17:23:37 +05:30
KartikandGitHub 96d45b78c7 fix(mem0-plugin): drop unused pytest import breaking make lint on main (#6937) 2026-08-13 16:51:01 +05:30
HimanshuandGitHub ba2fb9f4c3 feat(kimi): Mem0 plugin for Kimi Code (MCP + skills + auto-capture) (#6919) 2026-08-13 16:15:04 +05:30
KartikandGitHub 14c431735b fix(cli): surface agent_custom_instructions on add in both CLIs (#6910) 2026-08-12 20:08:34 +05:30
Anas KhanandGitHub d70cc00ab3 docs(components): point config links to provider overviews (#6126)
Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
2026-08-12 16:11:46 +05:30
36 changed files with 811 additions and 11 deletions
+15
View File
@@ -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"
}
]
}
+41
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+1
View File
@@ -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;
+2
View File
@@ -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)
+2
View File
@@ -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,
+4
View File
@@ -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.",
+1
View File
@@ -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"],
+5
View File
@@ -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");
+47
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+7
View File
@@ -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.",
+1
View File
@@ -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,
+1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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.
@@ -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
View File
@@ -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)
+8 -2
View File
@@ -1,7 +1,7 @@
import hashlib
import logging
import re
from typing import Any, Dict, List
from typing import Any, Dict, List, Union
from mem0.configs.prompts import (
AGENT_MEMORY_EXTRACTION_PROMPT,
@@ -112,7 +112,7 @@ def normalize_facts(raw_facts):
return normalized
def remove_code_blocks(content: str) -> str:
def remove_code_blocks(content: Union[str, List[Union[str, Dict[str, Any]]], None]) -> str:
"""
Removes enclosing code block markers ```[language] and ``` from a given string.
@@ -120,9 +120,15 @@ def remove_code_blocks(content: str) -> str:
- The function uses a regex pattern to match code blocks that may start with ``` followed by an optional language tag (letters or numbers) and end with ```.
- If a code block is detected, it returns only the inner content, stripping out the markers.
- If no code block markers are found, the original content is returned as-is.
- Block-style content (a LangChain message whose `.content` is a list of text
blocks) is flattened to its concatenated text before matching.
"""
if content is None:
return ""
if isinstance(content, list):
content = "".join(
part if isinstance(part, str) else part.get("text", "") for part in content if isinstance(part, (str, dict))
)
pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$"
match = re.match(pattern, content.strip())
match_res=match.group(1).strip() if match else content.strip()
+18
View File
@@ -192,3 +192,21 @@ class TestProcessTelemetryFilters:
class TestRemoveCodeBlocks:
def test_none_content_returns_empty_string(self):
assert remove_code_blocks(None) == ""
def test_block_list_content_is_flattened(self):
assert remove_code_blocks([{"type": "text", "text": "step one"}]) == "step one"
def test_block_list_content_strips_code_fences(self):
content = [{"type": "text", "text": '```json\n{"a": 1}\n```'}]
assert remove_code_blocks(content) == '{"a": 1}'
def test_block_list_content_joins_multiple_blocks(self):
content = [{"type": "text", "text": "step one. "}, {"type": "text", "text": "step two"}]
assert remove_code_blocks(content) == "step one. step two"
def test_block_list_content_ignores_non_text_blocks(self):
content = [{"type": "thinking", "thinking": "hmm"}, {"type": "text", "text": "answer"}]
assert remove_code_blocks(content) == "answer"
def test_plain_string_blocks_are_supported(self):
assert remove_code_blocks(["step one. ", "step two"]) == "step one. step two"
+37 -2
View File
@@ -1235,9 +1235,10 @@ class TestHybridSearchWarning:
def test_warning_for_store_without_keyword_search(
self, mock_vs_factory, mock_emb, mock_llm, mock_sqlite, _cap, caplog
):
from mem0.vector_stores.base import VectorStoreBase
import logging
from mem0.vector_stores.base import VectorStoreBase
class StoreWithoutKeywordSearch(VectorStoreBase):
def create_col(self, *a, **kw): pass
def insert(self, *a, **kw): pass
@@ -1271,9 +1272,10 @@ class TestHybridSearchWarning:
def test_no_warning_for_store_with_keyword_search(
self, mock_vs_factory, mock_emb, mock_llm, mock_sqlite, _cap, caplog
):
from mem0.vector_stores.base import VectorStoreBase
import logging
from mem0.vector_stores.base import VectorStoreBase
class StoreWithKeywordSearch(VectorStoreBase):
def keyword_search(self, query, top_k=5, filters=None):
return []
@@ -1536,6 +1538,39 @@ async def test_async_procedural_memory_langchain_strips_code_blocks(mock_llm_fac
assert "```" not in stored_data
@pytest.mark.asyncio
@patch("mem0.memory.main.VectorStoreFactory")
@patch("mem0.memory.main.EmbedderFactory")
@patch("mem0.memory.main.LlmFactory")
async def test_async_procedural_memory_langchain_block_list_content(mock_llm_factory, mock_emb, mock_vs):
"""Regression #6150: block-list message content must not raise AttributeError."""
mock_vs.return_value = MagicMock()
mock_emb.return_value = MagicMock()
mock_emb.return_value.embed.return_value = [0.1] * 1536
mock_llm_factory.return_value = MagicMock()
from mem0.memory.main import AsyncMemory
config = MemoryConfig()
memory = AsyncMemory(config)
memory.vector_store = MagicMock()
memory.vector_store.insert = MagicMock()
mock_langchain_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = [{"type": "text", "text": '```json\n{"key": "value"}\n```'}]
mock_langchain_llm.invoke.return_value = mock_response
messages = [{"role": "user", "content": "test"}]
metadata = {"user_id": "test_user"}
await memory._create_procedural_memory(messages, metadata=metadata, llm=mock_langchain_llm)
insert_call = memory.vector_store.insert.call_args
stored_data = insert_call[1]["payloads"][0]["data"]
assert stored_data == '{"key": "value"}'
@pytest.mark.asyncio
@patch("mem0.memory.main.VectorStoreFactory")
@patch("mem0.memory.main.EmbedderFactory")