From ba2fb9f4c3a231ef177ea3b73ffcbdb9e2c4d95b Mon Sep 17 00:00:00 2001 From: Himanshu <104683257+Himanshu-Sangshetti@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:15:04 +0530 Subject: [PATCH] feat(kimi): Mem0 plugin for Kimi Code (MCP + skills + auto-capture) (#6919) --- .kimi-plugin/marketplace.json | 15 +++ .../mem0-plugin/.kimi-plugin/plugin.json | 86 ++++++++++++ .../mem0-plugin/scripts/_instructions.py | 51 +++++++ .../mem0-plugin/scripts/auto_capture.py | 3 + .../scripts/capture_compact_summary.py | 7 +- .../scripts/capture_session_summary.py | 6 +- .../mem0-plugin/scripts/kimi_hook_shim.sh | 120 +++++++++++++++++ .../mem0-plugin/scripts/on_session_start.sh | 8 ++ .../mem0-plugin/scripts/parse_mem0_config.py | 32 +++++ integrations/mem0-plugin/scripts/telemetry.py | 3 + .../mem0-plugin/skills/policy/SKILL.md | 71 ++++++++++ .../mem0-plugin/tests/test_instructions.py | 126 ++++++++++++++++++ .../mem0-plugin/tests/test_kimi_manifests.py | 82 ++++++++++++ .../mem0-plugin/tests/test_telemetry.py | 17 +++ 14 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 .kimi-plugin/marketplace.json create mode 100644 integrations/mem0-plugin/.kimi-plugin/plugin.json create mode 100644 integrations/mem0-plugin/scripts/_instructions.py create mode 100755 integrations/mem0-plugin/scripts/kimi_hook_shim.sh create mode 100644 integrations/mem0-plugin/skills/policy/SKILL.md create mode 100644 integrations/mem0-plugin/tests/test_instructions.py create mode 100644 integrations/mem0-plugin/tests/test_kimi_manifests.py diff --git a/.kimi-plugin/marketplace.json b/.kimi-plugin/marketplace.json new file mode 100644 index 000000000..faaed4998 --- /dev/null +++ b/.kimi-plugin/marketplace.json @@ -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" + } + ] +} diff --git a/integrations/mem0-plugin/.kimi-plugin/plugin.json b/integrations/mem0-plugin/.kimi-plugin/plugin.json new file mode 100644 index 000000000..27d8a8ae4 --- /dev/null +++ b/integrations/mem0-plugin/.kimi-plugin/plugin.json @@ -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" + } +} diff --git a/integrations/mem0-plugin/scripts/_instructions.py b/integrations/mem0-plugin/scripts/_instructions.py new file mode 100644 index 000000000..8b4a5e8ae --- /dev/null +++ b/integrations/mem0-plugin/scripts/_instructions.py @@ -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 diff --git a/integrations/mem0-plugin/scripts/auto_capture.py b/integrations/mem0-plugin/scripts/auto_capture.py index 356c02803..b48a928b4 100755 --- a/integrations/mem0-plugin/scripts/auto_capture.py +++ b/integrations/mem0-plugin/scripts/auto_capture.py @@ -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( diff --git a/integrations/mem0-plugin/scripts/capture_compact_summary.py b/integrations/mem0-plugin/scripts/capture_compact_summary.py index 244913e49..d2f7de0ba 100644 --- a/integrations/mem0-plugin/scripts/capture_compact_summary.py +++ b/integrations/mem0-plugin/scripts/capture_compact_summary.py @@ -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) diff --git a/integrations/mem0-plugin/scripts/capture_session_summary.py b/integrations/mem0-plugin/scripts/capture_session_summary.py index 2f0279179..6c3fa92fa 100644 --- a/integrations/mem0-plugin/scripts/capture_session_summary.py +++ b/integrations/mem0-plugin/scripts/capture_session_summary.py @@ -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__": diff --git a/integrations/mem0-plugin/scripts/kimi_hook_shim.sh b/integrations/mem0-plugin/scripts/kimi_hook_shim.sh new file mode 100755 index 000000000..24e622706 --- /dev/null +++ b/integrations/mem0-plugin/scripts/kimi_hook_shim.sh @@ -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 [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 diff --git a/integrations/mem0-plugin/scripts/on_session_start.sh b/integrations/mem0-plugin/scripts/on_session_start.sh index 9f89e6745..b2e83926b 100755 --- a/integrations/mem0-plugin/scripts/on_session_start.sh +++ b/integrations/mem0-plugin/scripts/on_session_start.sh @@ -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 diff --git a/integrations/mem0-plugin/scripts/parse_mem0_config.py b/integrations/mem0-plugin/scripts/parse_mem0_config.py index 7dc132faa..dd6361f3a 100644 --- a/integrations/mem0-plugin/scripts/parse_mem0_config.py +++ b/integrations/mem0-plugin/scripts/parse_mem0_config.py @@ -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 ``## `` (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 diff --git a/integrations/mem0-plugin/scripts/telemetry.py b/integrations/mem0-plugin/scripts/telemetry.py index afebcb67b..cb29584ff 100644 --- a/integrations/mem0-plugin/scripts/telemetry.py +++ b/integrations/mem0-plugin/scripts/telemetry.py @@ -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"): diff --git a/integrations/mem0-plugin/skills/policy/SKILL.md b/integrations/mem0-plugin/skills/policy/SKILL.md new file mode 100644 index 000000000..43bd64ed2 --- /dev/null +++ b/integrations/mem0-plugin/skills/policy/SKILL.md @@ -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 [] `: + +| Argument | Action | +|---|---| +| _(none)_ or `show` | Show the current policy | +| `set ` or free text | Set/replace the `## Instructions` (custom) policy | +| `agent ` | 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: + Agent Instructions: +``` + +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`. diff --git a/integrations/mem0-plugin/tests/test_instructions.py b/integrations/mem0-plugin/tests/test_instructions.py new file mode 100644 index 000000000..cbbe41005 --- /dev/null +++ b/integrations/mem0-plugin/tests/test_instructions.py @@ -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 diff --git a/integrations/mem0-plugin/tests/test_kimi_manifests.py b/integrations/mem0-plugin/tests/test_kimi_manifests.py new file mode 100644 index 000000000..f9bc046ce --- /dev/null +++ b/integrations/mem0-plugin/tests/test_kimi_manifests.py @@ -0,0 +1,82 @@ +"""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 + +import pytest + +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 . + 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") diff --git a/integrations/mem0-plugin/tests/test_telemetry.py b/integrations/mem0-plugin/tests/test_telemetry.py index 1c15a8158..d33b33393 100644 --- a/integrations/mem0-plugin/tests/test_telemetry.py +++ b/integrations/mem0-plugin/tests/test_telemetry.py @@ -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)