feat(kimi): Mem0 plugin for Kimi Code (MCP + skills + auto-capture) (#6919)

This commit is contained in:
Himanshu
2026-08-13 16:15:04 +05:30
committed by GitHub
parent 14c431735b
commit ba2fb9f4c3
14 changed files with 624 additions and 3 deletions
@@ -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"):