feat(kimi): Mem0 plugin for Kimi Code (MCP + skills + auto-capture) (#6919)
This commit is contained in:
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user