diff --git a/install.sh b/install.sh index 9609ba1..a26908e 100755 --- a/install.sh +++ b/install.sh @@ -16,7 +16,8 @@ # # Usage: ./install.sh [--dry-run] # Env flags: PM_TARGET_HOME (target home dir), PM_SKIP_DOCTOR=1, PM_SKIP_VENV=1, -# PM_SKIP_LAUNCHD=1, PM_SKIP_CODEX=1, PM_SKIP_MCP=1, PM_INSTALL_CODEX=1 (force Codex step). +# PM_SKIP_LAUNCHD=1, PM_SKIP_CODEX=1, PM_SKIP_MCP=1, PM_INSTALL_CODEX=1 (force Codex step), +# PM_SKIP_KIMI=1, PM_INSTALL_KIMI=1 (force Kimi step). set -euo pipefail DRY_RUN=0 @@ -28,11 +29,16 @@ CLAUDE_DIR="$TARGET_HOME/.claude" SKILL_DEST="$CLAUDE_DIR/skills/persistent-memory" SETTINGS_FILE="$CLAUDE_DIR/settings.json" LAUNCH_AGENTS_DIR="$TARGET_HOME/Library/LaunchAgents" +LOGS_DIR="$TARGET_HOME/Library/Logs/persistent-memory" PLIST_DEST="$LAUNCH_AGENTS_DIR/com.persistent-memory.daemon.plist" VENV_DIR="$REPO_ROOT/.venv" CODEX_DIR="$TARGET_HOME/.codex" CODEX_HOOKS_FILE="$CODEX_DIR/hooks.json" CODEX_SKILL_DEST="$CODEX_DIR/skills/persistent-memory" +KIMI_DIR="$TARGET_HOME/.kimi-code" +KIMI_CONFIG="$KIMI_DIR/config.toml" +KIMI_MCP="$KIMI_DIR/mcp.json" +KIMI_SKILL_DEST="$KIMI_DIR/skills/persistent-memory" HOOK_EVENTS=("UserPromptSubmit" "Stop" "PreCompact" "SessionStart" "PreToolUse") @@ -53,12 +59,12 @@ run_doctor() { create_venv() { if [[ $DRY_RUN -eq 1 ]]; then - plan "create .venv at $VENV_DIR and pip install -e .[daemon,mcp]" + plan "create .venv at $VENV_DIR and pip install -e .[daemon,mcp] tomli_w" return fi [[ "${PM_SKIP_VENV:-0}" == "1" ]] && return [[ -d "$VENV_DIR" ]] || python3.12 -m venv "$VENV_DIR" - "$VENV_DIR/bin/pip" install -e "$REPO_ROOT[daemon,mcp]" + "$VENV_DIR/bin/pip" install -e "$REPO_ROOT[daemon,mcp]" tomli_w } install_skill() { @@ -153,6 +159,106 @@ register_mcp() { fi } +should_install_kimi() { + [[ "${PM_SKIP_KIMI:-0}" == "1" ]] && return 1 + [[ "${PM_INSTALL_KIMI:-0}" == "1" ]] && return 0 + command -v kimi >/dev/null 2>&1 && return 0 + [[ -d "$KIMI_DIR" ]] && return 0 + return 1 +} + +install_kimi_skill() { + if [[ $DRY_RUN -eq 1 ]]; then + plan "copy $REPO_ROOT/skill/SKILL.md to $KIMI_SKILL_DEST/SKILL.md (.kimi-code/skills/persistent-memory)" + return + fi + mkdir -p "$KIMI_SKILL_DEST" + cp "$REPO_ROOT/skill/SKILL.md" "$KIMI_SKILL_DEST/SKILL.md" +} + +register_kimi_hooks() { + if [[ $DRY_RUN -eq 1 ]]; then + for event in "${HOOK_EVENTS[@]}"; do + plan "merge kimi hook $event into $KIMI_CONFIG" + done + return + fi + mkdir -p "$KIMI_DIR" + "$VENV_DIR/bin/python" - "$KIMI_CONFIG" "$VENV_DIR" <<'PYEOF' +import json +import os +import sys + +try: + import tomllib +except ImportError: # pragma: no cover + import tomli as tomllib + +import tomli_w + +config_path = sys.argv[1] +venv = sys.argv[2] + +ours = [ + {"event": "UserPromptSubmit", "command": f"{venv}/bin/python -m persistent_memory.hooks.user_prompt_submit"}, + {"event": "Stop", "command": f"{venv}/bin/python -m persistent_memory.hooks.stop_or_session_end"}, + {"event": "PreCompact", "command": f"{venv}/bin/python -m persistent_memory.hooks.pre_compact"}, + {"event": "SessionStart", "command": f"{venv}/bin/python -m persistent_memory.hooks.session_start"}, + {"event": "PreToolUse", "matcher": "Agent|Task", "command": f"{venv}/bin/python -m persistent_memory.hooks.pre_tool_use", "timeout": 5}, +] +our_cmds = {h["command"] for h in ours} + +try: + with open(config_path, "rb") as f: + data = tomllib.load(f) +except FileNotFoundError: + data = {} + +hooks = [h for h in data.get("hooks", []) if not (isinstance(h, dict) and h.get("command") in our_cmds)] +hooks.extend(ours) +data["hooks"] = hooks + +with open(config_path, "wb") as f: + tomli_w.dump(data, f) +PYEOF + say "Kimi hooks written to $KIMI_CONFIG" +} + +register_kimi_mcp() { + [[ "${PM_SKIP_MCP:-0}" == "1" ]] && return + local cmd="$VENV_DIR/bin/python" + if [[ $DRY_RUN -eq 1 ]]; then + plan "register MCP server 'persistent-memory' ($cmd -m persistent_memory.mcp_server) in $KIMI_MCP" + return + fi + mkdir -p "$KIMI_DIR" + "$VENV_DIR/bin/python" - "$KIMI_MCP" "$cmd" <<'PYEOF' +import json +import os +import sys + +mcp_path = sys.argv[1] +cmd = sys.argv[2] + +try: + with open(mcp_path, "r", encoding="utf-8") as f: + data = json.load(f) +except (FileNotFoundError, json.JSONDecodeError): + data = {} + +servers = data.setdefault("mcpServers", {}) +servers["persistent-memory"] = { + "command": cmd, + "args": ["-m", "persistent_memory.mcp_server"], +} + +with open(mcp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") +PYEOF + say "Kimi MCP 'persistent-memory' registered in $KIMI_MCP" +} + _lang_subtag() { echo "$1" | sed 's/[._@-].*//' | tr '[:upper:]' '[:lower:]' } @@ -187,11 +293,12 @@ install_launchd() { fi [[ "${PM_SKIP_LAUNCHD:-0}" == "1" ]] && return mkdir -p "$LAUNCH_AGENTS_DIR" - PYTHONPATH="$REPO_ROOT/src" "$VENV_DIR/bin/python" - "$VENV_DIR/bin/python" "$REPO_ROOT" "$install_lang" <<'PYEOF' > "$PLIST_DEST" + mkdir -p "$LOGS_DIR" + PYTHONPATH="$REPO_ROOT/src" "$VENV_DIR/bin/python" - "$VENV_DIR/bin/python" "$REPO_ROOT" "$install_lang" "$TARGET_HOME" <<'PYEOF' > "$PLIST_DEST" import sys from persistent_memory.daemon.launch_agent import build_launch_agent_plist -python_bin, working_dir, lang = sys.argv[1], sys.argv[2], sys.argv[3] -print(build_launch_agent_plist(python_bin=python_bin, working_dir=working_dir, lang=lang if lang else None), end="") +python_bin, working_dir, lang, home_dir = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +print(build_launch_agent_plist(python_bin=python_bin, working_dir=working_dir, lang=lang if lang else None, home_dir=home_dir), end="") PYEOF launchctl unload "$PLIST_DEST" 2>/dev/null || true launchctl bootout "gui/$(id -u)/com.persistent-memory.daemon" 2>/dev/null || true @@ -205,6 +312,11 @@ create_venv install_skill register_hooks register_codex_hooks +if should_install_kimi; then + install_kimi_skill + register_kimi_hooks + register_kimi_mcp +fi register_mcp install_launchd say "Done." diff --git a/scripts/migrate_worktree_projects.py b/scripts/migrate_worktree_projects.py new file mode 100644 index 0000000..ddb9123 --- /dev/null +++ b/scripts/migrate_worktree_projects.py @@ -0,0 +1,251 @@ +"""One-shot migration: remap worktree-root records to correct project + branch. + +For each record whose provenance.cwd is the root of a linked git worktree +(path matches "*/(.worktrees|.claude/worktrees)/$"), update: + - project → basename of the main repository (parent of the worktrees dir) + - provenance.branch → (the worktree directory name) + +Subdirectory cwds (.worktrees//sub/dir) are intentionally skipped: +their project granularity (e.g. "backend") is deliberate. + +Usage: + python scripts/migrate_worktree_projects.py [--apply] [docs/] + --apply Write changes to disk (default: dry-run, prints what would change) +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +WORKTREE_ROOT_RE = re.compile( + r"^(?P.+?)/(?:\.worktrees|\.claude/worktrees)/(?P[^/]+)$" +) + +DOCS_DIRS = ["decisions", "lessons", "principles"] + + +def _abbrev_ref_from_disk(cwd: str) -> str | None: + """Return HEAD branch name via git if the worktree still exists on disk.""" + try: + result = subprocess.run( + ["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + ref = result.stdout.strip() + if ref and ref != "HEAD": + return ref + except (OSError, subprocess.TimeoutExpired): + pass + return None + + +def _infer_branch(cwd: str, path_branch: str) -> str: + """Return branch name for a worktree record. + + The worktree directory name (path_branch) is always used as the canonical + branch because it names the branch that was checked out at capture time. + The disk git HEAD may have moved since then (worktree reused for a new + branch), so we do NOT override the path-derived name with the live HEAD. + Git is only consulted as a fallback when the path yields no useful name. + """ + if path_branch: + return path_branch + disk_branch = _abbrev_ref_from_disk(cwd) + return disk_branch if disk_branch is not None else path_branch + + +def _is_worktree_root(cwd: str) -> tuple[bool, str, str]: + """Return (is_root, parent_basename, branch_name) for a given cwd.""" + m = WORKTREE_ROOT_RE.match(cwd) + if not m: + return False, "", "" + parent = m.group("parent") + branch = m.group("branch") + return True, Path(parent).name, branch + + +def _parse_frontmatter_raw(text: str) -> tuple[dict, str, str] | None: + """Parse frontmatter into (raw_dict, front_block, body). + + Returns None if the file does not start with ---. + """ + if not text.startswith("---"): + return None + parts = text.split("---", 2) + if len(parts) < 3: + return None + try: + raw = yaml.safe_load(parts[1]) + except yaml.YAMLError: + return None + if not isinstance(raw, dict): + return None + return raw, parts[1], parts[2] + + +def _surgical_update(text: str, new_project: str, branch: str) -> str: + """Apply minimal surgical changes to frontmatter text. + + Only modifies: + - the `project:` line + - adds `branch:` inside the `provenance:` block (after `agent:` line) + + Every other line is left byte-for-byte identical. + """ + lines = text.split("\n") + result: list[str] = [] + in_provenance = False + branch_inserted = False + + for i, line in enumerate(lines): + stripped = line.strip() + + # Detect provenance block start + if re.match(r"^provenance:\s*$", line): + in_provenance = True + result.append(line) + continue + + # Inside provenance block: detect block end (unindented key or ---) + if in_provenance: + if line and not line[0].isspace() and not line.startswith(" "): + # Exiting provenance block — insert branch before exit if not done + if not branch_inserted: + result.append(f" branch: {branch}") + branch_inserted = True + in_provenance = False + elif stripped.startswith("branch:"): + # Already has branch — update it + result.append(f" branch: {branch}") + branch_inserted = True + continue + elif stripped.startswith("agent:") and not branch_inserted: + result.append(line) + result.append(f" branch: {branch}") + branch_inserted = True + continue + + # Update project line + if re.match(r"^project:\s+", line): + result.append(f"project: {new_project}") + continue + + result.append(line) + + return "\n".join(result) + + +def migrate_record( + path: Path, + apply: bool, +) -> tuple[bool, str | None]: + """Process one file. + + Returns (changed, description) where description explains the change + or None if the file was skipped. + """ + text = path.read_text(encoding="utf-8") + parsed = _parse_frontmatter_raw(text) + if parsed is None: + return False, None + + raw, _front, _body = parsed + provenance = raw.get("provenance") + if not isinstance(provenance, dict): + return False, None + + cwd = provenance.get("cwd", "") + if not cwd: + return False, None + + is_root, parent_name, path_branch = _is_worktree_root(cwd) + if not is_root: + return False, None + + branch = _infer_branch(cwd, path_branch) + new_project = parent_name + current_project = str(raw.get("project", "")) + current_branch = provenance.get("branch") + + project_changed = current_project != new_project + branch_changed = current_branch != branch + + if not project_changed and not branch_changed: + return False, None + + description = ( + f"{path.name}: project {current_project!r} → {new_project!r}" + + (f", branch added: {branch!r}" if current_branch is None else f", branch {current_branch!r} → {branch!r}") + ) + + if apply: + new_text = _surgical_update(text, new_project, branch) + path.write_text(new_text, encoding="utf-8") + + return True, description + + +def find_record_files(docs_root: Path) -> list[Path]: + files: list[Path] = [] + for subdir in DOCS_DIRS: + d = docs_root / subdir + if d.is_dir(): + files.extend(sorted(d.glob("*.md"))) + return files + + +def run(docs_root: Path, apply: bool) -> int: + files = find_record_files(docs_root) + changed: list[str] = [] + unchanged = 0 + + for path in files: + did_change, description = migrate_record(path, apply=apply) + if did_change: + changed.append(description) + else: + unchanged += 1 + + mode = "APPLY" if apply else "DRY-RUN" + print(f"[{mode}] scanned {len(files)} files, {len(changed)} to change, {unchanged} unchanged") + for desc in changed: + marker = " WROTE" if apply else " WOULD" + print(f"{marker}: {desc}") + + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "docs", + nargs="?", + default="docs", + help="path to the docs/ directory (default: docs/)", + ) + parser.add_argument( + "--apply", + action="store_true", + help="write changes to disk (default: dry-run)", + ) + args = parser.parse_args(argv) + + docs_root = Path(args.docs) + if not docs_root.is_dir(): + print(f"error: not a directory: {docs_root}", file=sys.stderr) + return 1 + + return run(docs_root, apply=args.apply) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skill/SKILL.md b/skill/SKILL.md index 46b0df0..5fc247d 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -6,7 +6,7 @@ trigger: persistent-memory # persistent-memory -This skill runs AUTOMATICALLY in the background: every 5 messages, decisions (what/why) and mistakes/learnings (what/why/when noticed) are extracted from the accumulated conversation, embedded with local Ollama bge-m3, and at session start the relevant records are injected into context as a fixed ~1200-token recall block. Extraction is source-specific: Codex transcripts are processed by `codex exec --ignore-user-config -m gpt-5.3-codex-spark` with low reasoning effort, while Claude/manual extraction uses `claude -p --model claude-sonnet-4-6 --effort low`. Everything runs LOCALLY; no extra API key is required beyond the user's existing CLI subscription auth. The hook contract is identical in Claude Code and Codex. +This skill runs AUTOMATICALLY in the background: every 5 messages, decisions (what/why) and mistakes/learnings (what/why/when noticed) are extracted from the accumulated conversation, embedded with local Ollama bge-m3, and at session start the relevant records are injected into context as a fixed ~1200-token recall block. Since extraction is a mechanical task it runs on Sonnet 4.6 (~70% cheaper than Opus, equivalent quality — measured by benchmark). Everything runs LOCALLY; no extra API key is required (headless `claude -p` uses subscription auth). The hook contract is identical in Claude Code, Codex, and Kimi Code CLI. Triggering, embedding and recall are managed by the daemon (`127.0.0.1:37778`). Hooks only send signals; the heavy work happens in the daemon, debounced. When the daemon is down, hooks pass silently without blocking the session. @@ -16,8 +16,8 @@ Hooks inject memory automatically (PUSH). You can also query memory ACTIVELY via The agent reading this skill may not be Claude Code — the system is agent-agnostic and can be used in three ways: -1. **Automatic flow (hooks)** — the hook contract is identical in Claude Code and Codex CLI; `install.sh` writes hooks for both tools. Recall injection and extraction triggering happen on their own; the agent does not need to do anything. -2. **Mid-task query (MCP, the recommended PULL path)** — the `persistent-memory` MCP server is registered with both Claude and Codex; any MCP-capable agent can call `search_memory(query, top_k)`, `get_record(id)`, `list_recent(type, limit)`, `get_record_provenance(id)` directly. +1. **Automatic flow (hooks)** — the hook contract is identical in Claude Code, Codex CLI, and Kimi Code CLI; `install.sh` writes hooks for all three tools. Recall injection and extraction triggering happen on their own; the agent does not need to do anything. +2. **Mid-task query (MCP, the recommended PULL path)** — the `persistent-memory` MCP server is registered with Claude, Codex, and Kimi; any MCP-capable agent can call `search_memory(query, top_k)`, `get_record(id)`, `list_recent(type, limit)`, `get_record_provenance(id)` directly. 3. **Plain HTTP (agents or scripts without hooks/MCP)** — the daemon runs on localhost; read endpoints need no token: - `curl 'http://127.0.0.1:37778/api/search?q=QUERY&top_k=5'` — hybrid search - `curl 'http://127.0.0.1:37778/api/prompt-recall?q=QUERY&project=PROJECT'` — memory block to append to a prompt @@ -25,7 +25,7 @@ The agent reading this skill may not be Claude Code — the system is agent-agno - `curl 'http://127.0.0.1:37778/api/records/D-0001/raw'` — record body - Write endpoints (`/api/extract`, accept/reject, `/api/consolidate`) require the `X-PM-Token` header. The token file lives in the MEMORY repo (the daemon's records root), NOT in the project you are currently working in — discover it via `GET /api/health` (`records_dir` field): `/.pm-index/daemon.token`. -Notes: (a) The slash commands below are Claude Code-specific; on other agents use the HTTP endpoint or direct file write described in the "Writing records" section below. (b) Extraction uses per-source backends: Codex transcripts (`~/.codex/...`) are processed by `codex exec --ignore-user-config -m gpt-5.3-codex-spark -c model_reasoning_effort="low"`; Claude transcripts and manual `/api/extract` calls use `claude -p`. `PM_CODEX_BIN`, `PM_CODEX_EXTRACTION_MODEL` and `PM_CODEX_EXTRACTION_EFFORT` can override the Codex defaults; otherwise the macOS Codex.app CLI is preferred over `codex` from PATH when present. Without the relevant CLI, automatic record creation falls back gracefully (codex→claude if `codex` is missing; both CLIs absent → recall/search keep working in degraded mode, only auto-write is disabled). (c) Records are plain markdown (`docs/decisions/*.md`, `docs/lessons/*.md`); worst case, any agent can read the files directly. +Notes: (a) The slash commands below are Claude Code-specific; on other agents use the HTTP endpoint or direct file write described in the "Writing records" section below. (b) Extraction uses per-source backends: Codex transcripts (`~/.codex/...`) are processed by `codex exec`; Claude transcripts and manual `/api/extract` calls use `claude -p`. Without the relevant CLI, automatic record creation falls back gracefully (kimi→claude if `kimi` is missing, codex→claude if `codex` is missing; all CLIs absent → recall/search keep working in degraded mode, only auto-write is disabled). (c) Records are plain markdown (`docs/decisions/*.md`, `docs/lessons/*.md`); worst case, any agent can read the files directly. ## Writing records (any agent) diff --git a/src/persistent_memory/daemon/app.py b/src/persistent_memory/daemon/app.py index 771f4b4..a4fa1f9 100644 --- a/src/persistent_memory/daemon/app.py +++ b/src/persistent_memory/daemon/app.py @@ -92,6 +92,7 @@ class ExtractRequest(BaseModel): session_id: str | None = None flush: bool | None = None reason: str | None = None + branch: str | None = None class BodyUpdate(BaseModel): @@ -121,6 +122,7 @@ class CreateRecordRequest(BaseModel): session: str | None = None cwd: str | None = None agent: str | None = None + branch: str | None = None def _start_observer(cfg: DaemonConfig, loop: asyncio.AbstractEventLoop): @@ -243,6 +245,7 @@ def post_create_record(payload: CreateRecordRequest): session=payload.session or DEFAULT_SESSION, cwd=payload.cwd or DEFAULT_CWD, agent=payload.agent or DEFAULT_AGENT, + branch=payload.branch or None, ) title_prefix = f"# {payload.title}\n\n" if payload.body is not None: @@ -441,6 +444,7 @@ def post_extract(body: ExtractRequest): cwd=body.cwd or "", transcript_path=body.transcript_path, records_dir=cfg.records_dir, + branch=body.branch, ) return JSONResponse(content=result, status_code=HTTP_ACCEPTED) diff --git a/src/persistent_memory/daemon/dashboard_data.py b/src/persistent_memory/daemon/dashboard_data.py index 264df52..d92755c 100644 --- a/src/persistent_memory/daemon/dashboard_data.py +++ b/src/persistent_memory/daemon/dashboard_data.py @@ -197,6 +197,7 @@ def _build_record(record, body: str, title: str) -> dict: "title": title, "status": _ui_status(record.status.value), "project": record.project, + "branch": record.provenance.branch or None, "date": record.date.isoformat(), "importance": round(float(record.salience), 2), "tags": list(record.tags), diff --git a/src/persistent_memory/daemon/launch_agent.py b/src/persistent_memory/daemon/launch_agent.py index e0b3601..c78658c 100644 --- a/src/persistent_memory/daemon/launch_agent.py +++ b/src/persistent_memory/daemon/launch_agent.py @@ -1,19 +1,27 @@ """macOS launchd agent plist for running the daemon at login.""" import plistlib +from pathlib import Path from persistent_memory.daemon.config import DAEMON_HOST, DAEMON_PORT LAUNCH_AGENT_LABEL = "com.persistent-memory.daemon" UVICORN_APP_TARGET = "persistent_memory.daemon.__main__:app" THROTTLE_INTERVAL_SECONDS = 10 -LOG_SUBDIR = "docs/.pm-index" +LOG_SUBDIR = "Library/Logs/persistent-memory" STDOUT_LOG_FILENAME = "daemon.out.log" STDERR_LOG_FILENAME = "daemon.err.log" -def build_launch_agent_plist(*, python_bin: str, working_dir: str, lang: str | None = None) -> str: - log_dir = f"{working_dir}/{LOG_SUBDIR}" +def build_launch_agent_plist( + *, + python_bin: str, + working_dir: str, + lang: str | None = None, + home_dir: str | None = None, +) -> str: + base_home = Path(home_dir).expanduser() if home_dir is not None else Path.home() + log_dir = base_home / LOG_SUBDIR config = { "Label": LAUNCH_AGENT_LABEL, "ProgramArguments": [ @@ -30,8 +38,8 @@ def build_launch_agent_plist(*, python_bin: str, working_dir: str, lang: str | N "RunAtLoad": True, "KeepAlive": {"SuccessfulExit": False}, "ThrottleInterval": THROTTLE_INTERVAL_SECONDS, - "StandardOutPath": f"{log_dir}/{STDOUT_LOG_FILENAME}", - "StandardErrorPath": f"{log_dir}/{STDERR_LOG_FILENAME}", + "StandardOutPath": str(log_dir / STDOUT_LOG_FILENAME), + "StandardErrorPath": str(log_dir / STDERR_LOG_FILENAME), } if lang is not None: config["EnvironmentVariables"] = {"PM_LANG": lang} diff --git a/src/persistent_memory/daemon/services.py b/src/persistent_memory/daemon/services.py index 2716ee5..1e771b5 100644 --- a/src/persistent_memory/daemon/services.py +++ b/src/persistent_memory/daemon/services.py @@ -814,16 +814,23 @@ def project_detail(*, project: str, projects_root: Path, records_dir: Path) -> d CODEX_ROOT = Path.home() / ".codex" +KIMI_ROOT = Path.home() / ".kimi-code" def _extraction_backend_for(transcript_path: "Path | str | None") -> str: - """Return "codex" if transcript_path is under ~/.codex, else "claude".""" + """Return host-specific backend for the transcript path. + + Codex transcripts live under ~/.codex, Kimi transcripts under ~/.kimi-code; + everything else defaults to the Claude backend. + """ if transcript_path is None: return "claude" try: resolved = Path(transcript_path).resolve() if resolved.is_relative_to(CODEX_ROOT.resolve()): return "codex" + if resolved.is_relative_to(KIMI_ROOT.resolve()): + return "kimi" except (TypeError, ValueError): pass return "claude" @@ -885,13 +892,15 @@ def _resolve_claude_bin(env: dict) -> str: def _resolve_codex_bin(env: dict) -> str | None: - from persistent_memory.extraction_prompt import resolve_codex_bin + from persistent_memory.extraction_prompt import CODEX_BIN + + return shutil.which(CODEX_BIN, path=env.get("PATH")) + + +def _resolve_kimi_bin(env: dict) -> str | None: + from persistent_memory.extraction_prompt import KIMI_BIN - configured = resolve_codex_bin() - configured_path = Path(configured) - if configured_path.is_absolute(): - return str(configured_path) if configured_path.exists() else None - return shutil.which(configured, path=env.get("PATH")) + return shutil.which(KIMI_BIN, path=env.get("PATH")) def _index_subdir(records_dir: Path | None, name: str) -> Path: @@ -933,6 +942,7 @@ def _write_watermark(path: Path, count: int) -> None: DEFAULT_TRANSCRIPT_ROOTS = ( Path.home() / ".claude" / "projects", Path.home() / ".codex", + Path.home() / ".kimi-code", ) CWD_ROOTS_ENV = "PM_CWD_ROOTS" DEFAULT_CWD_ROOTS = (Path.home(),) @@ -1050,7 +1060,11 @@ def _build_argv_for_backend( missing so extraction never crashes due to a missing CLI tool. """ from persistent_memory.daemon.token import default_records_dir - from persistent_memory.extraction_prompt import build_codex_extraction_argv, build_extraction_argv + from persistent_memory.extraction_prompt import ( + build_codex_extraction_argv, + build_extraction_argv, + build_kimi_extraction_argv, + ) if backend == "codex": codex_bin = _resolve_codex_bin(env) @@ -1062,6 +1076,15 @@ def _build_argv_for_backend( rdir = Path(records_dir) if records_dir else default_records_dir() argv = build_codex_extraction_argv(prompt=prompt, records_dir=rdir) return argv, codex_bin + if backend == "kimi": + kimi_bin = _resolve_kimi_bin(env) + if kimi_bin is None: + logger.warning( + "kimi binary not found; falling back to claude backend for this extraction" + ) + else: + argv = build_kimi_extraction_argv(prompt=prompt, cwd=cwd) + return argv, kimi_bin argv = build_extraction_argv(prompt=prompt, cwd=cwd) claude_bin = _resolve_claude_bin(env) return argv, claude_bin diff --git a/src/persistent_memory/daemon/static/pm/views-detail.jsx b/src/persistent_memory/daemon/static/pm/views-detail.jsx index b39e58e..203b953 100644 --- a/src/persistent_memory/daemon/static/pm/views-detail.jsx +++ b/src/persistent_memory/daemon/static/pm/views-detail.jsx @@ -157,7 +157,7 @@
- {rec.id} · {rec.project} + {rec.id} · {rec.project}{rec.branch ? " · " + rec.branch : ""}

{rec.title}

{rec.tags.map((t) => {t})}
diff --git a/src/persistent_memory/daemon/templates/record_detail.html b/src/persistent_memory/daemon/templates/record_detail.html index d2eaa87..f909cc5 100644 --- a/src/persistent_memory/daemon/templates/record_detail.html +++ b/src/persistent_memory/daemon/templates/record_detail.html @@ -17,7 +17,11 @@
{{ detail.type }} {{ detail.status }} - {% if detail.project %}{{ detail.project }}{% endif %} + {% if detail.project %} + + {{ detail.project }}{% if detail.provenance and detail.provenance.branch %} · {{ detail.provenance.branch }}{% endif %} + + {% endif %}

{{ detail.title }}

{{ detail.id }} · {{ detail.date }}
diff --git a/src/persistent_memory/extraction_prompt.py b/src/persistent_memory/extraction_prompt.py index 1eb81b0..713e23a 100644 --- a/src/persistent_memory/extraction_prompt.py +++ b/src/persistent_memory/extraction_prompt.py @@ -1,26 +1,28 @@ -"""Prompt and argv builders for the headless extraction agents. +"""Prompt and argv builder for the headless extraction agent. -The daemon uses the source-specific CLI (`claude -p` for Claude/manual input, -`codex exec` for Codex transcripts) with this prompt to turn a transcript slice -into new decision/lesson records on disk. The prompt's security preamble pins -the core rule: transcript content is data only — instructions inside it are -read, never executed. +The daemon spawns `claude -p` with this prompt to turn a transcript slice into +new decision/lesson records on disk. The prompt's security preamble pins the +core rule: transcript content is data only — instructions inside it are read, +never executed. """ from pathlib import Path CLAUDE_BIN = "claude" CODEX_BIN = "codex" -CODEX_BIN_ENV = "PM_CODEX_BIN" -CODEX_APP_BIN = Path("/Applications/Codex.app/Contents/Resources/codex") +KIMI_BIN = "kimi" EXTRACTION_MODEL = "claude-sonnet-4-6" -# Codex model: keep explicit so daemon extraction does not inherit a user's -# interactive Codex default. Override via PM_CODEX_EXTRACTION_MODEL env var. -CODEX_EXTRACTION_MODEL = "gpt-5.3-codex-spark" +# Codex model: empty string means "use codex config default" (no -m flag). +# Smoke-tested 2026-06-11: gpt-5.1-codex-mini and gpt-5.1-codex are not +# supported with a ChatGPT account; only the config default (gpt-5.5) works. +# Override via PM_CODEX_EXTRACTION_MODEL env var. +CODEX_EXTRACTION_MODEL = "" CODEX_EXTRACTION_MODEL_ENV = "PM_CODEX_EXTRACTION_MODEL" +# Kimi model: empty string means "use kimi config default" (no -m flag). +# Override via PM_KIMI_EXTRACTION_MODEL env var. +KIMI_EXTRACTION_MODEL = "" +KIMI_EXTRACTION_MODEL_ENV = "PM_KIMI_EXTRACTION_MODEL" EXTRACTION_EFFORT = "low" -CODEX_EXTRACTION_EFFORT = "low" -CODEX_EXTRACTION_EFFORT_ENV = "PM_CODEX_EXTRACTION_EFFORT" OUTPUT_FORMAT = "json" PERMISSION_MODE = "bypassPermissions" DECISIONS_SUBDIR = "decisions" @@ -94,17 +96,6 @@ def _default_records_dir() -> Path: return default_records_dir() -def resolve_codex_bin() -> str: - import os - - override = os.environ.get(CODEX_BIN_ENV) - if override: - return override - if CODEX_APP_BIN.exists(): - return str(CODEX_APP_BIN) - return CODEX_BIN - - def build_extraction_argv(prompt: str, cwd: str) -> list[str]: argv = [ CLAUDE_BIN, @@ -130,21 +121,18 @@ def build_codex_extraction_argv(prompt: str, records_dir: Path) -> list[str]: records_repo_root = str(Path(records_dir).parent) model = os.environ.get(CODEX_EXTRACTION_MODEL_ENV) or CODEX_EXTRACTION_MODEL - effort = os.environ.get(CODEX_EXTRACTION_EFFORT_ENV) or CODEX_EXTRACTION_EFFORT - argv = [ - resolve_codex_bin(), - "exec", - "--ignore-user-config", - "--ephemeral", - "--skip-git-repo-check", - "-C", - records_repo_root, - "-s", - "workspace-write", - "-c", - f'model_reasoning_effort="{effort}"', - ] + argv = [CODEX_BIN, "exec", "--ephemeral", "--skip-git-repo-check", "-C", records_repo_root, "-s", "workspace-write"] if model: argv.extend(["-m", model]) argv.append(prompt) return argv + + +def build_kimi_extraction_argv(prompt: str, cwd: str) -> list[str]: + import os + + model = os.environ.get(KIMI_EXTRACTION_MODEL_ENV) or KIMI_EXTRACTION_MODEL + argv = [KIMI_BIN, "-p", prompt, "-y", "--output-format", "text"] + if model: + argv.extend(["-m", model]) + return argv diff --git a/src/persistent_memory/hooks/common.py b/src/persistent_memory/hooks/common.py index 029308c..bb8340d 100644 --- a/src/persistent_memory/hooks/common.py +++ b/src/persistent_memory/hooks/common.py @@ -1,4 +1,4 @@ -"""Shared plumbing for the Claude Code hook entrypoints. +"""Shared plumbing for the Claude Code / Codex / Kimi hook entrypoints. Hooks are a thin signal layer: they parse the hook payload from stdin, keep a tiny per-project message counter on disk, and fire short-timeout HTTP signals @@ -7,9 +7,11 @@ error degrades to a no-op and the process exits 0. """ +import enum import hashlib import json import os +import subprocess import sys import tempfile from pathlib import Path @@ -26,6 +28,89 @@ DEFAULT_STATE_DIR = Path.home() / ".claude" / "persistent-memory" / "hook-state" +class Host(enum.Enum): + CLAUDE = "claude" + CODEX = "codex" + KIMI = "kimi" + + +def detect_host(payload: dict) -> Host: + """Identify the host CLI from the hook payload. + + Kimi Code CLI uses snake_case keys and exposes ``session_dir``; Claude and + Codex share the same JSON envelope, so they both map to ``Host.CLAUDE``. + """ + if payload.get("session_dir"): + return Host.KIMI + return Host.CLAUDE + + +def state_dir_for_host(host: Host) -> Path: + """Return the per-host state directory for message counters.""" + if host is Host.KIMI: + return Path.home() / ".kimi-code" / "persistent-memory" / "hook-state" + return DEFAULT_STATE_DIR + + +def emit_context(text: str, host: Host, event_name: str) -> None: + """Emit a recall/context block in the format expected by the host CLI. + + - Claude/Codex: JSON envelope with ``hookSpecificOutput.additionalContext``. + - Kimi: plain stdout text (the runner appends it to the agent context). + """ + if host is Host.KIMI: + sys.stdout.write(text) + return + payload = { + "hookSpecificOutput": { + "hookEventName": event_name, + "additionalContext": text, + } + } + sys.stdout.write(json.dumps(payload)) + + +def extract_prompt_text(payload: dict) -> str: + """Extract the user's prompt text from a host-specific payload. + + Kimi passes ``prompt`` as a list of ContentParts; Claude/Codex pass a string + or a ``message`` object. + """ + prompt = payload.get("prompt") + if isinstance(prompt, list): + texts = [ + str(part.get("text", "")) + for part in prompt + if isinstance(part, dict) and part.get("type") == "text" + ] + return " ".join(text for text in texts if text).strip() + if isinstance(prompt, str): + return prompt.strip() + message = payload.get("message") + if isinstance(message, dict): + content = message.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + return "" + + +def transcript_path_from_payload(payload: dict) -> str | None: + """Return a transcript path for the daemon, if one can be determined. + + Kimi exposes ``session_dir``; the active agent wire log lives at + ``/agents/main/wire.jsonl``. + """ + explicit = payload.get("transcript_path") + if explicit: + return explicit + session_dir = payload.get("session_dir") + if session_dir: + path = Path(session_dir) / "agents" / "main" / "wire.jsonl" + if path.is_file(): + return str(path) + return None + + def read_hook_payload() -> dict: raw = sys.stdin.read() if not raw or not raw.strip(): @@ -49,6 +134,71 @@ def project_name(cwd: str) -> str: return name or "unknown" +def _git_run(args: list[str], cwd: str) -> str | None: + try: + result = subprocess.run( + ["git", "-C", cwd, *args], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except (OSError, subprocess.SubprocessError): + return None + + +def _current_branch(cwd: str) -> str | None: + return _git_run(["rev-parse", "--abbrev-ref", "HEAD"], cwd) + + +def _is_worktree_root(cwd: str) -> bool: + git_dir = _git_run(["rev-parse", "--git-dir"], cwd) + common_dir = _git_run(["rev-parse", "--git-common-dir"], cwd) + if not git_dir or not common_dir: + return False + if git_dir == common_dir: + return False + toplevel = _git_run(["rev-parse", "--show-toplevel"], cwd) + if not toplevel: + return False + return Path(cwd).resolve() == Path(toplevel).resolve() + + +def _main_repo_name_from_worktree(cwd: str) -> str | None: + common_dir = _git_run(["rev-parse", "--git-common-dir"], cwd) + if not common_dir: + return None + common_path = Path(common_dir) + if not common_path.is_absolute(): + common_path = (Path(cwd) / common_path).resolve() + return common_path.parent.name or None + + +def derive_project_and_branch(cwd: str) -> tuple[str, str | None]: + """Derive (project_name, branch) from a working directory. + + Rules: + - If cwd is the root of a git linked worktree: project = main repo name, + branch = current worktree branch. + - Any other case: project = basename(cwd) (existing behaviour preserved), + branch = current git branch if inside a git repo, else None. + - git failures are silent: returns (basename, None). + """ + if not cwd: + return "unknown", None + + branch = _current_branch(cwd) + + if branch is not None and _is_worktree_root(cwd): + main_name = _main_repo_name_from_worktree(cwd) + project = main_name if main_name else project_name(cwd) + return project, branch + + return project_name(cwd), branch + + def _state_path(project_key: str, state_dir: Path) -> Path: state_dir.mkdir(parents=True, exist_ok=True) return state_dir / f"{project_key}.json" diff --git a/src/persistent_memory/hooks/pre_compact.py b/src/persistent_memory/hooks/pre_compact.py index 077baa7..a0f8070 100644 --- a/src/persistent_memory/hooks/pre_compact.py +++ b/src/persistent_memory/hooks/pre_compact.py @@ -10,10 +10,13 @@ from persistent_memory.hooks import common from persistent_memory.hooks.common import ( build_project_key, + detect_host, + derive_project_and_branch, post_daemon_signal, - project_name, read_hook_payload, reset_message_counter, + state_dir_for_host, + transcript_path_from_payload, ) EXTRACT_ENDPOINT = "/api/extract" @@ -23,19 +26,22 @@ def main() -> int: payload = read_hook_payload() cwd = payload.get("cwd") or os.getcwd() + host = detect_host(payload) + state_dir = state_dir_for_host(host) project_key = build_project_key(cwd) - post_daemon_signal( - EXTRACT_ENDPOINT, - { - "project": project_name(cwd), - "cwd": cwd, - "session_id": payload.get("session_id"), - "transcript_path": payload.get("transcript_path"), - "flush": True, - "reason": COMPACT_REASON, - }, - ) - reset_message_counter(project_key, state_dir=common.DEFAULT_STATE_DIR) + project, branch = common.derive_project_and_branch(cwd) + signal_body: dict = { + "project": project, + "cwd": cwd, + "session_id": payload.get("session_id"), + "transcript_path": transcript_path_from_payload(payload), + "flush": True, + "reason": COMPACT_REASON, + } + if branch is not None: + signal_body["branch"] = branch + post_daemon_signal(EXTRACT_ENDPOINT, signal_body) + reset_message_counter(project_key, state_dir=state_dir) return 0 diff --git a/src/persistent_memory/hooks/pre_tool_use.py b/src/persistent_memory/hooks/pre_tool_use.py index ef0f4bb..36a8f0c 100644 --- a/src/persistent_memory/hooks/pre_tool_use.py +++ b/src/persistent_memory/hooks/pre_tool_use.py @@ -28,9 +28,11 @@ _DENY_REASON = ( "Model-guard: you must supply an explicit `model` parameter when dispatching " - "a subagent. Rules: mechanical implementation / translation / cleanup / " - "spec-driven TDD / review → \"sonnet\"; read-only scan / inventory / " - "exploration → \"haiku\"; flagship only as a deliberate choice. " + "a subagent. Choose per the task (decide opus-vs-sonnet consciously — do NOT " + "reflexively default to sonnet): genuine reasoning (architecture / hard debug / " + "deep or adversarial analysis / complex synthesis / planning) → \"opus\"; " + "mechanical implementation / translation / cleanup / spec-driven TDD / review " + "→ \"sonnet\"; read-only scan / inventory / exploration → \"haiku\". " "Re-dispatch the same call with `model` set. " "Disable this guard: PM_DISABLE_MODEL_GUARD=1." ) diff --git a/src/persistent_memory/hooks/session_start.py b/src/persistent_memory/hooks/session_start.py index c53a216..bce03a2 100644 --- a/src/persistent_memory/hooks/session_start.py +++ b/src/persistent_memory/hooks/session_start.py @@ -1,22 +1,23 @@ """SessionStart hook — inject the fixed-budget recall block into new sessions. Thin signal layer: fetches the project's recall block from the daemon and -emits it as `additionalContext`, prepending a one-line warning when a critical -prerequisite (ollama, bge-m3, venv) is missing. Degrades to silence on any -failure and always exits 0. +emits it in the host CLI's preferred format, prepending a one-line warning when +a critical prerequisite (ollama, bge-m3, venv) is missing. Degrades to silence +on any failure and always exits 0. """ -import json import os import sys import httpx from persistent_memory.doctor import detect_missing_critical +from persistent_memory.hooks import common from persistent_memory.hooks.common import ( DAEMON_BASE_URL, + detect_host, + emit_context, is_daemon_healthy, - project_name, read_hook_payload, ) from persistent_memory.i18n import t @@ -44,16 +45,6 @@ def fetch_recall_block(project: str) -> str: return response.json().get("block", "") -def _emit(additional_context: str) -> None: - payload = { - "hookSpecificOutput": { - "hookEventName": HOOK_EVENT_NAME, - "additionalContext": additional_context, - } - } - sys.stdout.write(json.dumps(payload)) - - def _critical_label(name: str) -> str: key = CRITICAL_LABEL_KEYS.get(name) if key is None: @@ -83,15 +74,17 @@ def _prepend_warning(block: str, warning: str) -> str: def main() -> int: payload = read_hook_payload() cwd = payload.get("cwd") or os.getcwd() + host = detect_host(payload) warning = _build_warning() if not is_daemon_healthy(): - _emit(_prepend_warning("", warning)) + emit_context(_prepend_warning("", warning), host=host, event_name=HOOK_EVENT_NAME) return 0 + project, _branch = common.derive_project_and_branch(cwd) try: - block = fetch_recall_block(project=project_name(cwd)) + block = fetch_recall_block(project=project) except (httpx.HTTPError, OSError, ValueError, RuntimeError): block = "" - _emit(_prepend_warning(block, warning)) + emit_context(_prepend_warning(block, warning), host=host, event_name=HOOK_EVENT_NAME) return 0 diff --git a/src/persistent_memory/hooks/stop_or_session_end.py b/src/persistent_memory/hooks/stop_or_session_end.py index d5f52c2..a7d8e7d 100644 --- a/src/persistent_memory/hooks/stop_or_session_end.py +++ b/src/persistent_memory/hooks/stop_or_session_end.py @@ -10,11 +10,14 @@ from persistent_memory.hooks import common from persistent_memory.hooks.common import ( build_project_key, + detect_host, + derive_project_and_branch, post_daemon_signal, - project_name, read_hook_payload, read_message_counter, reset_message_counter, + state_dir_for_host, + transcript_path_from_payload, ) EXTRACT_ENDPOINT = "/api/extract" @@ -23,21 +26,24 @@ def main() -> int: payload = read_hook_payload() cwd = payload.get("cwd") or os.getcwd() + host = detect_host(payload) + state_dir = state_dir_for_host(host) project_key = build_project_key(cwd) - pending = read_message_counter(project_key, state_dir=common.DEFAULT_STATE_DIR) + pending = read_message_counter(project_key, state_dir=state_dir) if pending <= 0: return 0 - post_daemon_signal( - EXTRACT_ENDPOINT, - { - "project": project_name(cwd), - "cwd": cwd, - "session_id": payload.get("session_id"), - "transcript_path": payload.get("transcript_path"), - "flush": True, - }, - ) - reset_message_counter(project_key, state_dir=common.DEFAULT_STATE_DIR) + project, branch = common.derive_project_and_branch(cwd) + signal_body: dict = { + "project": project, + "cwd": cwd, + "session_id": payload.get("session_id"), + "transcript_path": transcript_path_from_payload(payload), + "flush": True, + } + if branch is not None: + signal_body["branch"] = branch + post_daemon_signal(EXTRACT_ENDPOINT, signal_body) + reset_message_counter(project_key, state_dir=state_dir) return 0 diff --git a/src/persistent_memory/hooks/user_prompt_submit.py b/src/persistent_memory/hooks/user_prompt_submit.py index 66415ec..c42e848 100644 --- a/src/persistent_memory/hooks/user_prompt_submit.py +++ b/src/persistent_memory/hooks/user_prompt_submit.py @@ -1,12 +1,11 @@ """UserPromptSubmit hook — per-prompt recall plus the 5-message extract pulse. Thin signal layer with two duties: inject a prompt-scoped recall block as -`additionalContext`, and advance the per-project message counter, signalling -the daemon to extract once every EXTRACT_TRIGGER_INTERVAL prompts. Both paths -degrade to a no-op on failure; the hook always exits 0. +context, and advance the per-project message counter, signalling the daemon to +extract once every EXTRACT_TRIGGER_INTERVAL prompts. Both paths degrade to a +no-op on failure; the hook always exits 0. """ -import json import os import sys @@ -15,13 +14,17 @@ from persistent_memory.hooks import common from persistent_memory.hooks.common import ( DAEMON_BASE_URL, - DEFAULT_STATE_DIR, + DEFAULT_STATE_DIR, # re-exported for test backward compatibility build_project_key, + detect_host, + emit_context, + extract_prompt_text, increment_message_counter, - project_name, post_daemon_signal, read_hook_payload, reset_message_counter, + state_dir_for_host, + transcript_path_from_payload, ) EXTRACT_TRIGGER_INTERVAL = 5 @@ -29,20 +32,6 @@ PROMPT_RECALL_ENDPOINT = "/api/prompt-recall" PROMPT_RECALL_HTTP_TIMEOUT_SECONDS = 2.0 HOOK_EVENT_NAME = "UserPromptSubmit" -PROMPT_KEYS = ("prompt", "user_prompt") - - -def _extract_prompt_text(payload: dict) -> str: - for key in PROMPT_KEYS: - value = payload.get(key) - if isinstance(value, str) and value.strip(): - return value - message = payload.get("message") - if isinstance(message, dict): - content = message.get("content") - if isinstance(content, str) and content.strip(): - return content - return "" def fetch_prompt_recall_block(prompt: str, project: str) -> str: @@ -56,49 +45,45 @@ def fetch_prompt_recall_block(prompt: str, project: str) -> str: return response.json().get("block", "") -def _emit_additional_context(block: str) -> None: - payload = { - "hookSpecificOutput": { - "hookEventName": HOOK_EVENT_NAME, - "additionalContext": block, - } - } - sys.stdout.write(json.dumps(payload)) - - -def _inject_recall(prompt: str, project_key: str) -> None: +def _inject_recall(prompt: str, project: str, host: common.Host) -> None: if not prompt: return try: - block = fetch_prompt_recall_block(prompt=prompt, project=project_key) + block = fetch_prompt_recall_block(prompt=prompt, project=project) except (httpx.HTTPError, OSError, ValueError, RuntimeError): return if block: - _emit_additional_context(block) + emit_context(block, host=host, event_name=HOOK_EVENT_NAME) -def _advance_extraction(payload: dict, *, cwd: str, project_key: str) -> None: - count = increment_message_counter(project_key, state_dir=common.DEFAULT_STATE_DIR) +def _advance_extraction( + payload: dict, *, cwd: str, project_key: str, host: common.Host +) -> None: + state_dir = state_dir_for_host(host) + count = increment_message_counter(project_key, state_dir=state_dir) if count < EXTRACT_TRIGGER_INTERVAL: return - post_daemon_signal( - EXTRACT_ENDPOINT, - { - "project": project_name(cwd), - "cwd": cwd, - "session_id": payload.get("session_id"), - "transcript_path": payload.get("transcript_path"), - }, - ) - reset_message_counter(project_key, state_dir=common.DEFAULT_STATE_DIR) + project, branch = common.derive_project_and_branch(cwd) + signal_body: dict = { + "project": project, + "cwd": cwd, + "session_id": payload.get("session_id"), + "transcript_path": transcript_path_from_payload(payload), + } + if branch is not None: + signal_body["branch"] = branch + post_daemon_signal(EXTRACT_ENDPOINT, signal_body) + reset_message_counter(project_key, state_dir=state_dir) def main() -> int: payload = read_hook_payload() cwd = payload.get("cwd") or os.getcwd() + host = detect_host(payload) project_key = build_project_key(cwd) - _inject_recall(_extract_prompt_text(payload), project_name(cwd)) - _advance_extraction(payload, cwd=cwd, project_key=project_key) + project, _branch = common.derive_project_and_branch(cwd) + _inject_recall(extract_prompt_text(payload), project, host) + _advance_extraction(payload, cwd=cwd, project_key=project_key, host=host) return 0 diff --git a/src/persistent_memory/schema.py b/src/persistent_memory/schema.py index 413cd30..2cc3023 100644 --- a/src/persistent_memory/schema.py +++ b/src/persistent_memory/schema.py @@ -34,6 +34,7 @@ class Provenance(BaseModel): session: str cwd: str agent: str + branch: str | None = None ID_PATTERN = re.compile(r"^(D|L|P)-\d{4}$") @@ -95,6 +96,8 @@ def validate_id_prefix_matches_type(self) -> "Record": "salience", ] +PROVENANCE_FIELD_ORDER = ["session", "cwd", "agent", "branch"] + def parse_document(text: str) -> tuple[Record, str]: """Parse a record document into (validated frontmatter, markdown body). @@ -115,9 +118,16 @@ def parse_document(text: str) -> tuple[Record, str]: return record, body +def _serialize_provenance(prov: dict) -> dict: + ordered = {k: prov[k] for k in PROVENANCE_FIELD_ORDER if k in prov and prov[k] is not None} + return ordered + + def serialize_document(record: Record, body: str) -> str: """Render a record back to markdown with frontmatter keys in stable order.""" dumped = record.model_dump(by_alias=True, mode="json") ordered = {key: dumped[key] for key in FRONTMATTER_FIELD_ORDER if key in dumped} + if "provenance" in ordered and isinstance(ordered["provenance"], dict): + ordered["provenance"] = _serialize_provenance(ordered["provenance"]) front = yaml.safe_dump(ordered, sort_keys=False, allow_unicode=True, default_flow_style=False) return f"{FRONTMATTER_DELIMITER}\n{front}{FRONTMATTER_DELIMITER}\n{body.rstrip()}\n" diff --git a/src/persistent_memory/transcripts.py b/src/persistent_memory/transcripts.py index d06389a..b8e75fb 100644 --- a/src/persistent_memory/transcripts.py +++ b/src/persistent_memory/transcripts.py @@ -11,6 +11,7 @@ import json from dataclasses import dataclass, field +from datetime import datetime, timezone from pathlib import Path PROJECTS_ROOT = Path.home() / ".claude" / "projects" @@ -25,6 +26,15 @@ NOISE_PATH_SUBSTRINGS = ("claude-worktrees", ".claude/worktrees", "claude-mem", "pytest-of-") NOISE_DIR_SUBSTRINGS = ("claude-worktrees", "claude-mem-observer-sessions", "pytest-of-") +KIMI_ROOT = Path.home() / ".kimi-code" +KIMI_WIRE_FILENAME = "wire.jsonl" +KIMI_USER_MESSAGE_TYPE = "context.append_message" +KIMI_LOOP_EVENT_TYPE = "context.append_loop_event" +KIMI_TEXT_PART_TYPE = "text" +KIMI_THINK_PART_TYPE = "think" +KIMI_TOOL_CALL_EVENT_TYPE = "tool.call" +KIMI_TOOL_RESULT_EVENT_TYPE = "tool.result" + TOOL_INPUT_PREVIEW_LEN = 80 @@ -128,7 +138,104 @@ def _summarize_tool_use(block: dict) -> str: return f"[{name} {' '.join(parts)}]" +def _is_kimi_transcript(jsonl_path: Path) -> bool: + """Kimi transcripts are named wire.jsonl or live under ~/.kimi-code.""" + if jsonl_path.name == KIMI_WIRE_FILENAME: + return True + try: + if jsonl_path.resolve().is_relative_to(KIMI_ROOT.resolve()): + return True + except (OSError, ValueError): + pass + return False + + +def _kimi_time_to_iso(time_ms: int | None) -> str | None: + if time_ms is None: + return None + try: + return datetime.fromtimestamp(time_ms / 1000.0, tz=timezone.utc).isoformat() + except (OSError, ValueError, TypeError, OverflowError): + return None + + +def _extract_kimi_text(content) -> str: + if isinstance(content, str): + return content.strip() + if not isinstance(content, list): + return "" + texts: list[str] = [] + for part in content: + if isinstance(part, dict) and part.get("type") == KIMI_TEXT_PART_TYPE: + texts.append(str(part.get("text") or "")) + return "\n".join(t for t in texts if t).strip() + + +def _summarize_kimi_tool_call(event: dict) -> str: + name = event.get("name") or "tool" + args = event.get("args") + if not isinstance(args, dict) or not args: + return f"[{name}]" + parts = [] + for key, value in args.items(): + preview = str(value) + if len(preview) > TOOL_INPUT_PREVIEW_LEN: + preview = preview[:TOOL_INPUT_PREVIEW_LEN] + "…" + parts.append(f"{key}={preview}") + return f"[{name} {' '.join(parts)}]" + + +def _summarize_kimi_tool_result(event: dict) -> str: + tool_call_id = event.get("toolCallId") or event.get("parentUuid") or "?" + return f"[tool_result {tool_call_id}]" + + +def _read_kimi_transcript(jsonl_path: Path) -> list[Message]: + messages: list[Message] = [] + for obj in _read_jsonl_lines(jsonl_path): + obj_type = obj.get("type") + time_ms = obj.get("time") + timestamp = _kimi_time_to_iso(time_ms) + if obj_type == KIMI_USER_MESSAGE_TYPE: + message = obj.get("message") + if not isinstance(message, dict): + continue + origin = message.get("origin") or {} + if isinstance(origin, dict) and origin.get("kind") != "user": + continue + role = message.get("role") + if role != "user": + continue + text = _extract_kimi_text(message.get("content")) + if text: + messages.append(Message(role="user", text=text, timestamp=timestamp, is_tool=False)) + elif obj_type == KIMI_LOOP_EVENT_TYPE: + event = obj.get("event") or {} + if not isinstance(event, dict): + continue + event_type = event.get("type") + if event_type == "content.part": + part = event.get("part") or {} + if not isinstance(part, dict): + continue + part_type = part.get("type") + if part_type != KIMI_TEXT_PART_TYPE: + continue + text = str(part.get("text") or "").strip() + if text: + messages.append(Message(role="assistant", text=text, timestamp=timestamp, is_tool=False)) + elif event_type == KIMI_TOOL_CALL_EVENT_TYPE: + text = _summarize_kimi_tool_call(event) + messages.append(Message(role="assistant", text=text, timestamp=timestamp, is_tool=True)) + elif event_type == KIMI_TOOL_RESULT_EVENT_TYPE: + text = _summarize_kimi_tool_result(event) + messages.append(Message(role="user", text=text, timestamp=timestamp, is_tool=True)) + return messages + + def read_transcript(jsonl_path: Path) -> list[Message]: + if _is_kimi_transcript(jsonl_path): + return _read_kimi_transcript(jsonl_path) messages: list[Message] = [] for obj in _read_jsonl_lines(jsonl_path): if obj.get("type") not in MESSAGE_TYPES: diff --git a/tests/daemon/test_codex_extraction_backend.py b/tests/daemon/test_codex_extraction_backend.py index 8f72167..ceb4281 100644 --- a/tests/daemon/test_codex_extraction_backend.py +++ b/tests/daemon/test_codex_extraction_backend.py @@ -95,52 +95,21 @@ def test_env_extra_root_returns_claude(self, tmp_path): # --------------------------------------------------------------------------- class TestBuildCodexExtractionArgv: - def test_starts_with_codex_exec(self, monkeypatch): - monkeypatch.setenv("PM_CODEX_BIN", "codex") + def test_starts_with_codex_exec(self): argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) assert argv[0] == "codex" assert argv[1] == "exec" - def test_codex_bin_env_override_wins(self, monkeypatch): - monkeypatch.setenv("PM_CODEX_BIN", "/custom/codex") - argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) - assert argv[0] == "/custom/codex" - - def test_prefers_codex_app_binary_when_present(self, monkeypatch, tmp_path): - app_bin = tmp_path / "Codex.app" / "Contents" / "Resources" / "codex" - app_bin.parent.mkdir(parents=True) - app_bin.write_text("", encoding="utf-8") - monkeypatch.delenv("PM_CODEX_BIN", raising=False) - monkeypatch.setattr(ep, "CODEX_APP_BIN", app_bin) - argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) - assert argv[0] == str(app_bin) - def test_has_ephemeral_and_skip_git(self): argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) assert "--ephemeral" in argv assert "--skip-git-repo-check" in argv - def test_ignores_user_config(self): - argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) - assert "--ignore-user-config" in argv - def test_has_sandbox_workspace_write(self): argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) idx = argv.index("-s") assert argv[idx + 1] == "workspace-write" - def test_sets_low_reasoning_effort(self, monkeypatch): - monkeypatch.delenv("PM_CODEX_EXTRACTION_EFFORT", raising=False) - argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) - idx = argv.index("-c") - assert argv[idx + 1] == 'model_reasoning_effort="low"' - - def test_effort_env_override_wins(self, monkeypatch): - monkeypatch.setenv("PM_CODEX_EXTRACTION_EFFORT", "medium") - argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/tmp/rec")) - idx = argv.index("-c") - assert argv[idx + 1] == 'model_reasoning_effort="medium"' - def test_has_cd_records_repo_root(self): argv = ep.build_codex_extraction_argv(prompt="HELLO", records_dir=Path("/home/user/repo/docs")) assert "-C" in argv @@ -152,13 +121,7 @@ def test_prompt_passed_as_last_positional(self): argv = ep.build_codex_extraction_argv(prompt="EXTRACT THIS", records_dir=Path("/tmp/rec")) assert argv[-1] == "EXTRACT THIS" - def test_default_model_is_codex_spark(self, monkeypatch): - monkeypatch.delenv("PM_CODEX_EXTRACTION_MODEL", raising=False) - argv = ep.build_codex_extraction_argv(prompt="P", records_dir=Path("/tmp/rec")) - idx = argv.index("-m") - assert argv[idx + 1] == "gpt-5.3-codex-spark" - - def test_no_model_flag_when_model_is_empty(self, monkeypatch): + def test_no_model_flag_when_default_model_is_empty(self, monkeypatch): monkeypatch.delenv("PM_CODEX_EXTRACTION_MODEL", raising=False) monkeypatch.setattr(ep, "CODEX_EXTRACTION_MODEL", "") argv = ep.build_codex_extraction_argv(prompt="P", records_dir=Path("/tmp/rec")) @@ -312,8 +275,6 @@ def test_missing_codex_falls_back_to_claude(self, tmp_path, monkeypatch, caplog) services.reset_extraction_state() monkeypatch.delenv(services.TRANSCRIPT_ROOTS_ENV, raising=False) - monkeypatch.delenv("PM_CODEX_BIN", raising=False) - monkeypatch.setattr(ep, "CODEX_APP_BIN", tmp_path / "missing-codex-app-bin") # Make shutil.which return None for "codex" real_which = services.shutil.which diff --git a/tests/daemon/test_create_record_branch.py b/tests/daemon/test_create_record_branch.py new file mode 100644 index 0000000..442a3e8 --- /dev/null +++ b/tests/daemon/test_create_record_branch.py @@ -0,0 +1,79 @@ +"""TDD: POST /api/records with optional branch field.""" + +import yaml +from starlette.testclient import TestClient + +from persistent_memory.daemon.app import create_app +from persistent_memory.daemon.config import DaemonConfig +from persistent_memory.daemon.token import load_or_create_token + + +def _client(tmp_path): + cfg = DaemonConfig(records_dir=tmp_path, watch_enabled=False) + return TestClient(create_app(records_dir=tmp_path, config=cfg)) + + +def _headers(tmp_path): + return {"X-PM-Token": load_or_create_token(tmp_path)} + + +def test_create_record_without_branch_succeeds(tmp_path): + client = _client(tmp_path) + resp = client.post( + "/api/records", + json={"type": "decision", "title": "No branch", "project": "myapp"}, + headers=_headers(tmp_path), + ) + assert resp.status_code == 201 + + +def test_create_record_with_branch_succeeds(tmp_path): + client = _client(tmp_path) + resp = client.post( + "/api/records", + json={ + "type": "decision", + "title": "Has branch", + "project": "BlackHoleLabs", + "branch": "faz1-backend", + }, + headers=_headers(tmp_path), + ) + assert resp.status_code == 201 + + +def test_create_record_branch_stored_in_frontmatter(tmp_path): + client = _client(tmp_path) + resp = client.post( + "/api/records", + json={ + "type": "decision", + "title": "Branch record", + "project": "BlackHoleLabs", + "branch": "faz1-backend", + }, + headers=_headers(tmp_path), + ) + record_id = resp.json()["id"] + path = tmp_path / "decisions" / f"{record_id}.md" + text = path.read_text(encoding="utf-8") + parts = text.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert fm["provenance"]["branch"] == "faz1-backend" + + +def test_create_record_no_branch_frontmatter_branch_is_none(tmp_path): + client = _client(tmp_path) + resp = client.post( + "/api/records", + json={"type": "decision", "title": "NoBranch", "project": "myapp"}, + headers=_headers(tmp_path), + ) + record_id = resp.json()["id"] + path = tmp_path / "decisions" / f"{record_id}.md" + text = path.read_text(encoding="utf-8") + parts = text.split("---", 2) + fm = yaml.safe_load(parts[1]) + # branch absent or null + prov = fm.get("provenance", {}) + assert prov.get("branch") is None diff --git a/tests/daemon/test_dashboard_branch.py b/tests/daemon/test_dashboard_branch.py new file mode 100644 index 0000000..5a46826 --- /dev/null +++ b/tests/daemon/test_dashboard_branch.py @@ -0,0 +1,124 @@ +"""Tests for branch field propagation through dashboard_data and detail endpoint.""" + +import json + +from starlette.testclient import TestClient + +from persistent_memory.daemon import dashboard_data +from persistent_memory.daemon.app import create_app + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_record(directory, rec_id, rec_type, *, branch=None, project="myproj"): + directory.mkdir(parents=True, exist_ok=True) + branch_line = f"\n branch: {branch}" if branch else "" + front = ( + f"---\n" + f"id: {rec_id}\n" + f"type: {rec_type}\n" + f"status: proposed\n" + f"date: '2026-06-11'\n" + f"project: {project}\n" + f"provenance:\n" + f" session: s-1\n" + f" cwd: /tmp/work\n" + f" agent: claude-sonnet-4-6{branch_line}\n" + f"tags: []\n" + f"supersedes: []\n" + f"superseded-by: []\n" + f"salience: 0.5\n" + f"---\n" + ) + body = f"# Title for {rec_id}\n\n## Context\nsome text\n" + (directory / f"{rec_id}.md").write_text(front + body, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# 1. _build_record includes branch when provenance.branch is set +# --------------------------------------------------------------------------- + +def test_build_record_includes_branch_when_set(tmp_path): + _write_record(tmp_path / "decisions", "D-0001", "decision", branch="feat/i18n") + from persistent_memory.records import read_record + + path = tmp_path / "decisions" / "D-0001.md" + record, body = read_record(path) + assert record.provenance.branch == "feat/i18n" + + result = dashboard_data._build_record(record, body, "Title for D-0001") + assert result["branch"] == "feat/i18n" + + +def test_build_record_branch_is_none_when_absent(tmp_path): + _write_record(tmp_path / "decisions", "D-0002", "decision", branch=None) + from persistent_memory.records import read_record + + path = tmp_path / "decisions" / "D-0002.md" + record, body = read_record(path) + assert record.provenance.branch is None + + result = dashboard_data._build_record(record, body, "Title for D-0002") + assert result.get("branch") is None + + +# --------------------------------------------------------------------------- +# 2. pm_payload JSON round-trips branch +# --------------------------------------------------------------------------- + +def test_pm_payload_json_contains_branch(tmp_path): + _write_record(tmp_path / "decisions", "D-0003", "decision", branch="main") + from persistent_memory.daemon.config import DaemonConfig + + cfg = DaemonConfig(records_dir=tmp_path) + raw = dashboard_data.pm_payload_json(cfg) + data = json.loads(raw) + decision = next(r for r in data["decisions"] if r["id"] == "D-0003") + assert decision["branch"] == "main" + + +def test_pm_payload_json_no_branch_field_is_none(tmp_path): + _write_record(tmp_path / "decisions", "D-0004", "decision", branch=None) + from persistent_memory.daemon.config import DaemonConfig + + cfg = DaemonConfig(records_dir=tmp_path) + raw = dashboard_data.pm_payload_json(cfg) + data = json.loads(raw) + decision = next(r for r in data["decisions"] if r["id"] == "D-0004") + assert decision.get("branch") is None + + +# --------------------------------------------------------------------------- +# 3. /api/records/{id}/detail-equivalent: provenance in record_detail has branch +# --------------------------------------------------------------------------- + +def test_record_detail_service_provenance_includes_branch(tmp_path): + _write_record(tmp_path / "decisions", "D-0005", "decision", branch="hotfix/typo") + from persistent_memory.daemon import services + + detail = services.record_detail(tmp_path, "D-0005") + assert detail["provenance"]["branch"] == "hotfix/typo" + + +def test_record_detail_service_provenance_branch_absent_is_none(tmp_path): + _write_record(tmp_path / "decisions", "D-0006", "decision", branch=None) + from persistent_memory.daemon import services + + detail = services.record_detail(tmp_path, "D-0006") + assert detail["provenance"].get("branch") is None + + +# --------------------------------------------------------------------------- +# 4. SPA detail view: JSX source contains branch chip logic +# --------------------------------------------------------------------------- + +def test_views_detail_jsx_references_branch(): + from pathlib import Path + + jsx = Path(__file__).parent.parent.parent / ( + "src/persistent_memory/daemon/static/pm/views-detail.jsx" + ) + source = jsx.read_text(encoding="utf-8") + assert "rec.branch" in source, "views-detail.jsx must reference rec.branch" diff --git a/tests/daemon/test_kimi_extraction_backend.py b/tests/daemon/test_kimi_extraction_backend.py new file mode 100644 index 0000000..59a60bf --- /dev/null +++ b/tests/daemon/test_kimi_extraction_backend.py @@ -0,0 +1,188 @@ +"""Tests for Kimi Code CLI extraction backend routing and argv builder.""" + +from pathlib import Path + +import pytest + +import persistent_memory.daemon.services as services +import persistent_memory.extraction_prompt as ep +from persistent_memory.daemon.app import create_app +from persistent_memory.daemon.config import DaemonConfig +from persistent_memory.daemon.token import load_or_create_token +from starlette.testclient import TestClient + + +class _FakeProc: + def __init__(self, returncode=None): + self._returncode = returncode + + def poll(self): + return self._returncode + + +def _client(tmp_path): + cfg = DaemonConfig(records_dir=tmp_path, watch_enabled=False) + return TestClient(create_app(records_dir=tmp_path, config=cfg)) + + +def _token(tmp_path): + return load_or_create_token(tmp_path) + + +@pytest.fixture(autouse=True) +def _reset_extraction(monkeypatch, tmp_path): + services.reset_extraction_state() + monkeypatch.setenv(services.CWD_ROOTS_ENV, str(tmp_path)) + yield + services.reset_extraction_state() + + +class TestExtractionBackendFor: + def test_kimi_root_returns_kimi(self): + path = Path.home() / ".kimi-code" / "sessions" / "sess" / "agents" / "main" / "wire.jsonl" + assert services._extraction_backend_for(path) == "kimi" + + def test_kimi_root_nested_returns_kimi(self): + path = Path.home() / ".kimi-code" / "sub" / "wire.jsonl" + assert services._extraction_backend_for(path) == "kimi" + + def test_claude_projects_root_returns_claude(self): + path = Path.home() / ".claude" / "projects" / "-proj" / "sess.jsonl" + assert services._extraction_backend_for(path) == "claude" + + def test_unrelated_path_returns_claude(self): + path = Path("/tmp/transcripts/wire.jsonl") + assert services._extraction_backend_for(path) == "claude" + + def test_none_returns_claude(self): + assert services._extraction_backend_for(None) == "claude" + + +class TestBuildKimiExtractionArgv: + def test_starts_with_kimi_prompt(self): + argv = ep.build_kimi_extraction_argv(prompt="EXTRACT", cwd="/tmp/p") + assert argv[0] == "kimi" + assert argv[1] == "-p" + assert "EXTRACT" in argv + + def test_uses_yolo_and_text_output(self): + argv = ep.build_kimi_extraction_argv(prompt="EXTRACT", cwd="/tmp/p") + assert "-y" in argv + assert "--output-format" in argv + assert "text" in argv + + def test_no_model_flag_when_default_empty(self, monkeypatch): + monkeypatch.delenv("PM_KIMI_EXTRACTION_MODEL", raising=False) + monkeypatch.setattr(ep, "KIMI_EXTRACTION_MODEL", "") + argv = ep.build_kimi_extraction_argv(prompt="P", cwd="") + assert "-m" not in argv + + def test_model_flag_when_constant_set(self, monkeypatch): + monkeypatch.setattr(ep, "KIMI_EXTRACTION_MODEL", "kimi-k2") + monkeypatch.delenv("PM_KIMI_EXTRACTION_MODEL", raising=False) + argv = ep.build_kimi_extraction_argv(prompt="P", cwd="") + assert "-m" in argv + idx = argv.index("-m") + assert argv[idx + 1] == "kimi-k2" + + def test_env_override_wins_over_constant(self, monkeypatch): + monkeypatch.setattr(ep, "KIMI_EXTRACTION_MODEL", "from-constant") + monkeypatch.setenv("PM_KIMI_EXTRACTION_MODEL", "from-env") + argv = ep.build_kimi_extraction_argv(prompt="P", cwd="") + assert "-m" in argv + idx = argv.index("-m") + assert argv[idx + 1] == "from-env" + + +class TestExtractEndpointKimiRouting: + def test_kimi_transcript_spawns_kimi_binary(self, tmp_path, monkeypatch): + services.reset_extraction_state() + monkeypatch.delenv(services.TRANSCRIPT_ROOTS_ENV, raising=False) + monkeypatch.setattr(services, "_resolve_kimi_bin", lambda env: "/usr/local/bin/kimi") + captured = {} + + def fake_popen(argv, *args, **kwargs): + captured["argv"] = argv + captured["kwargs"] = kwargs + return _FakeProc(returncode=None) + + monkeypatch.setattr(services.subprocess, "Popen", fake_popen) + + def fake_prepare(**kwargs): + slice_path = tmp_path / "slice.txt" + slice_path.write_text("msg", encoding="utf-8") + wm_path = services._watermark_path(tmp_path, "fake-sess") + return { + "session_id": "fake-sess", + "total": 3, + "new_count": 3, + "is_baseline": False, + "slice_path": str(slice_path), + "wm_path": str(wm_path), + } + + monkeypatch.setattr(services, "prepare_extraction_input", fake_prepare) + + cwd = tmp_path / "proj" + cwd.mkdir() + kimi_transcript = str(Path.home() / ".kimi-code" / "sessions" / "fake-sess" / "agents" / "main" / "wire.jsonl") + result = services.trigger_extraction( + project="my-kimi-proj", + cwd=str(cwd), + transcript_path=kimi_transcript, + records_dir=tmp_path, + ) + assert result["status"] == services.EXTRACTION_STARTED_STATUS + assert captured["argv"][0] == "kimi" + assert captured["kwargs"].get("cwd") == str(cwd) + + def test_missing_kimi_falls_back_to_claude(self, tmp_path, monkeypatch, caplog): + import logging + + services.reset_extraction_state() + monkeypatch.delenv(services.TRANSCRIPT_ROOTS_ENV, raising=False) + real_which = services.shutil.which + + def fake_which(name, **kwargs): + if name == "kimi": + return None + return real_which(name, **kwargs) + + monkeypatch.setattr(services.shutil, "which", fake_which) + + captured = {} + + def fake_popen(argv, *args, **kwargs): + captured["argv"] = argv + return _FakeProc(returncode=None) + + monkeypatch.setattr(services.subprocess, "Popen", fake_popen) + + def fake_prepare(**kwargs): + slice_path = tmp_path / "slice.txt" + slice_path.write_text("msg", encoding="utf-8") + wm_path = services._watermark_path(tmp_path, "fake-sess") + return { + "session_id": "fake-sess", + "total": 3, + "new_count": 3, + "is_baseline": False, + "slice_path": str(slice_path), + "wm_path": str(wm_path), + } + + monkeypatch.setattr(services, "prepare_extraction_input", fake_prepare) + + kimi_transcript = str(Path.home() / ".kimi-code" / "sessions" / "fake-sess" / "agents" / "main" / "wire.jsonl") + cwd = tmp_path / "proj" + cwd.mkdir() + with caplog.at_level(logging.WARNING, logger="persistent_memory.daemon.services"): + result = services.trigger_extraction( + project="kimi-proj", + cwd=str(cwd), + transcript_path=kimi_transcript, + records_dir=tmp_path, + ) + assert result["status"] == services.EXTRACTION_STARTED_STATUS + assert captured["argv"][0] == "claude" + assert any("kimi" in rec.message.lower() for rec in caplog.records) diff --git a/tests/daemon/test_launch_agent.py b/tests/daemon/test_launch_agent.py index c394a96..8bcce92 100644 --- a/tests/daemon/test_launch_agent.py +++ b/tests/daemon/test_launch_agent.py @@ -1,4 +1,5 @@ import plistlib +from pathlib import Path from persistent_memory.daemon.launch_agent import ( LAUNCH_AGENT_LABEL, @@ -63,14 +64,32 @@ def test_plist_is_valid_and_has_label(): assert parsed["ThrottleInterval"] >= 10 -def test_plist_captures_daemon_logs_under_working_dir(): +def test_plist_captures_daemon_logs_under_user_logs_dir(): raw = build_launch_agent_plist( python_bin="/Users/x/.venv/bin/python", working_dir="/Users/x/proj", ) parsed = plistlib.loads(raw.encode("utf-8")) - assert parsed["StandardErrorPath"].startswith("/Users/x/proj/") - assert parsed["StandardOutPath"].startswith("/Users/x/proj/") + log_dir = Path.home() / "Library/Logs/persistent-memory" + assert parsed["StandardErrorPath"] == str(log_dir / "daemon.err.log") + assert parsed["StandardOutPath"] == str(log_dir / "daemon.out.log") + + +def test_plist_can_capture_daemon_logs_under_target_home(): + raw = build_launch_agent_plist( + python_bin="/Users/x/.venv/bin/python", + working_dir="/Users/x/proj", + home_dir="/Users/target", + ) + parsed = plistlib.loads(raw.encode("utf-8")) + assert ( + parsed["StandardErrorPath"] + == "/Users/target/Library/Logs/persistent-memory/daemon.err.log" + ) + assert ( + parsed["StandardOutPath"] + == "/Users/target/Library/Logs/persistent-memory/daemon.out.log" + ) def test_plist_program_args_run_uvicorn_on_loopback(): diff --git a/tests/hooks/test_derive_project_and_branch.py b/tests/hooks/test_derive_project_and_branch.py new file mode 100644 index 0000000..4bf9faf --- /dev/null +++ b/tests/hooks/test_derive_project_and_branch.py @@ -0,0 +1,136 @@ +"""TDD: derive_project_and_branch — worktree-aware project/branch derivation. + +Tests use real git repos and worktrees (via subprocess) to validate the full +derivation rules: + - Normal git repo: project=basename(cwd), branch=current branch + - Worktree root: project=main repo name, branch=worktree branch + - Subdirectory inside worktree: project=basename(subdir), branch=worktree branch + - Non-git dir: project=basename(cwd), branch=None +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +from persistent_memory.hooks.common import derive_project_and_branch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _git(cwd: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=str(cwd), + check=True, + capture_output=True, + ) + + +def _init_repo(path: Path, branch: str = "main") -> Path: + path.mkdir(parents=True, exist_ok=True) + _git(path, "init", "-b", branch) + _git(path, "config", "user.email", "test@test.com") + _git(path, "config", "user.name", "Test User") + _git(path, "commit", "--allow-empty", "-m", "initial") + return path + + +# --------------------------------------------------------------------------- +# Non-git directory +# --------------------------------------------------------------------------- + +def test_non_git_dir_uses_basename_no_branch(tmp_path): + non_git = tmp_path / "myproject" + non_git.mkdir() + project, branch = derive_project_and_branch(str(non_git)) + assert project == "myproject" + assert branch is None + + +def test_empty_cwd_returns_unknown_no_branch(): + project, branch = derive_project_and_branch("") + assert project == "unknown" + assert branch is None + + +# --------------------------------------------------------------------------- +# Normal git repo (not a worktree) +# --------------------------------------------------------------------------- + +def test_normal_repo_project_is_basename(tmp_path): + repo = tmp_path / "BlackHoleLabs" + _init_repo(repo, branch="main") + project, branch = derive_project_and_branch(str(repo)) + assert project == "BlackHoleLabs" + + +def test_normal_repo_branch_is_current_branch(tmp_path): + repo = tmp_path / "BlackHoleLabs" + _init_repo(repo, branch="main") + project, branch = derive_project_and_branch(str(repo)) + assert branch == "main" + + +def test_normal_repo_subdirectory_uses_subdir_basename(tmp_path): + repo = tmp_path / "BlackHoleLabs" + _init_repo(repo, branch="develop") + subdir = repo / "backend" + subdir.mkdir() + project, branch = derive_project_and_branch(str(subdir)) + assert project == "backend" + assert branch == "develop" + + +# --------------------------------------------------------------------------- +# Linked worktree root — project = main repo name, branch = worktree branch +# --------------------------------------------------------------------------- + +def test_worktree_root_project_is_main_repo_name(tmp_path): + repo = tmp_path / "BlackHoleLabs" + _init_repo(repo, branch="main") + wt_path = tmp_path / "BlackHoleLabs" / ".worktrees" / "faz1-backend" + # Create a new branch for the worktree + _git(repo, "checkout", "-b", "faz1-backend") + _git(repo, "checkout", "main") + _git(repo, "worktree", "add", str(wt_path), "faz1-backend") + + project, branch = derive_project_and_branch(str(wt_path)) + assert project == "BlackHoleLabs" + assert branch == "faz1-backend" + + +def test_worktree_subdirectory_uses_subdir_basename(tmp_path): + repo = tmp_path / "BlackHoleLabs" + _init_repo(repo, branch="main") + wt_path = tmp_path / "BlackHoleLabs" / ".worktrees" / "faz1-backend" + _git(repo, "checkout", "-b", "faz1-backend") + _git(repo, "checkout", "main") + _git(repo, "worktree", "add", str(wt_path), "faz1-backend") + + subdir = wt_path / "src" + subdir.mkdir() + project, branch = derive_project_and_branch(str(subdir)) + # Subdirectory inside worktree: project = basename(subdir), NOT main repo name + assert project == "src" + assert branch == "faz1-backend" + + +# --------------------------------------------------------------------------- +# Error handling — git failures are silent +# --------------------------------------------------------------------------- + +def test_git_error_falls_back_gracefully(tmp_path, monkeypatch): + non_git = tmp_path / "myproject" + non_git.mkdir() + + def fail_run(*args, **kwargs): + raise OSError("git not found") + + monkeypatch.setattr(subprocess, "run", fail_run) + project, branch = derive_project_and_branch(str(non_git)) + assert project == "myproject" + assert branch is None diff --git a/tests/hooks/test_hook_branch_payload.py b/tests/hooks/test_hook_branch_payload.py new file mode 100644 index 0000000..12eaea4 --- /dev/null +++ b/tests/hooks/test_hook_branch_payload.py @@ -0,0 +1,127 @@ +"""TDD: hook payloads include branch field when available.""" + +import io +import json +from unittest.mock import patch + +import pytest + +from persistent_memory.hooks import common +from persistent_memory.hooks import user_prompt_submit as ups +from persistent_memory.hooks import stop_or_session_end as sse +from persistent_memory.hooks import pre_compact as pc + + +def _feed(monkeypatch, payload): + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload))) + + +# --------------------------------------------------------------------------- +# user_prompt_submit: extract signal body includes branch +# --------------------------------------------------------------------------- + +def test_user_prompt_submit_extract_signal_has_branch(monkeypatch, tmp_path): + sent = [] + monkeypatch.setattr(common, "DEFAULT_STATE_DIR", tmp_path) + monkeypatch.setattr(ups, "DEFAULT_STATE_DIR", tmp_path) + monkeypatch.setattr(ups, "post_daemon_signal", lambda ep, body: sent.append((ep, body)) or True) + + # Patch derive_project_and_branch to simulate a worktree cwd + monkeypatch.setattr( + common, + "derive_project_and_branch", + lambda cwd: ("BlackHoleLabs", "faz1-backend"), + ) + + key = common.build_project_key("/tmp/p") + for _ in range(ups.EXTRACT_TRIGGER_INTERVAL - 1): + _feed(monkeypatch, {"cwd": "/tmp/p", "session_id": "s1"}) + ups.main() + + _feed(monkeypatch, {"cwd": "/tmp/p", "session_id": "s1"}) + ups.main() + + assert len(sent) == 1 + endpoint, body = sent[0] + assert body["project"] == "BlackHoleLabs" + assert body.get("branch") == "faz1-backend" + + +def test_user_prompt_submit_extract_signal_no_branch_when_none(monkeypatch, tmp_path): + sent = [] + monkeypatch.setattr(common, "DEFAULT_STATE_DIR", tmp_path) + monkeypatch.setattr(ups, "DEFAULT_STATE_DIR", tmp_path) + monkeypatch.setattr(ups, "post_daemon_signal", lambda ep, body: sent.append((ep, body)) or True) + + monkeypatch.setattr( + common, + "derive_project_and_branch", + lambda cwd: ("myproject", None), + ) + + key = common.build_project_key("/tmp/p") + for _ in range(ups.EXTRACT_TRIGGER_INTERVAL - 1): + _feed(monkeypatch, {"cwd": "/tmp/p", "session_id": "s1"}) + ups.main() + + _feed(monkeypatch, {"cwd": "/tmp/p", "session_id": "s1"}) + ups.main() + + assert len(sent) == 1 + _, body = sent[0] + assert body["project"] == "myproject" + # branch key absent or None — both acceptable + assert body.get("branch") is None + + +# --------------------------------------------------------------------------- +# stop_or_session_end: flush signal includes branch +# --------------------------------------------------------------------------- + +def test_stop_hook_flush_signal_has_branch(monkeypatch, tmp_path): + sent = [] + monkeypatch.setattr(common, "DEFAULT_STATE_DIR", tmp_path) + monkeypatch.setattr(sse, "post_daemon_signal", lambda ep, body: sent.append((ep, body)) or True) + + monkeypatch.setattr( + common, + "derive_project_and_branch", + lambda cwd: ("BlackHoleLabs", "faz2-auth"), + ) + + key = common.build_project_key("/tmp/p") + common.increment_message_counter(key, state_dir=tmp_path) + + _feed(monkeypatch, {"cwd": "/tmp/p", "session_id": "s1"}) + code = sse.main() + + assert code == 0 + assert len(sent) == 1 + _, body = sent[0] + assert body["project"] == "BlackHoleLabs" + assert body.get("branch") == "faz2-auth" + + +# --------------------------------------------------------------------------- +# pre_compact: flush signal includes branch +# --------------------------------------------------------------------------- + +def test_pre_compact_signal_has_branch(monkeypatch, tmp_path): + sent = [] + monkeypatch.setattr(common, "DEFAULT_STATE_DIR", tmp_path) + monkeypatch.setattr(pc, "post_daemon_signal", lambda ep, body: sent.append((ep, body)) or True) + + monkeypatch.setattr( + common, + "derive_project_and_branch", + lambda cwd: ("BlackHoleLabs", "hotfix-99"), + ) + + _feed(monkeypatch, {"cwd": "/tmp/p", "session_id": "s1"}) + code = pc.main() + + assert code == 0 + assert len(sent) == 1 + _, body = sent[0] + assert body["project"] == "BlackHoleLabs" + assert body.get("branch") == "hotfix-99" diff --git a/tests/hooks/test_host_detection.py b/tests/hooks/test_host_detection.py new file mode 100644 index 0000000..d7ce219 --- /dev/null +++ b/tests/hooks/test_host_detection.py @@ -0,0 +1,57 @@ +"""Host detection and Kimi-specific hook behavior.""" + +from persistent_memory.hooks import common + + +def test_detect_host_defaults_to_claude(): + assert common.detect_host({}) is common.Host.CLAUDE + assert common.detect_host({"cwd": "/tmp/p"}) is common.Host.CLAUDE + + +def test_detect_host_kimi_by_session_dir(): + assert common.detect_host({"session_dir": "/Users/x/.kimi-code/sessions/p/s1"}) is common.Host.KIMI + + +def test_state_dir_for_kimi(): + kimi = common.state_dir_for_host(common.Host.KIMI) + assert ".kimi-code" in str(kimi) + assert "persistent-memory" in str(kimi) + + +def test_state_dir_for_claude(): + assert common.state_dir_for_host(common.Host.CLAUDE) == common.DEFAULT_STATE_DIR + + +def test_extract_prompt_text_from_string(): + assert common.extract_prompt_text({"prompt": "hello"}) == "hello" + + +def test_extract_prompt_text_from_kimi_content_parts(): + payload = { + "prompt": [ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": "world"}, + ] + } + assert common.extract_prompt_text(payload) == "Hello world" + + +def test_extract_prompt_text_from_message(): + payload = {"message": {"role": "user", "content": "via message"}} + assert common.extract_prompt_text(payload) == "via message" + + +def test_transcript_path_from_explicit(): + assert common.transcript_path_from_payload({"transcript_path": "/tmp/t.jsonl"}) == "/tmp/t.jsonl" + + +def test_transcript_path_from_kimi_session_dir(tmp_path): + session_dir = tmp_path / "s1" + wire = session_dir / "agents" / "main" / "wire.jsonl" + wire.parent.mkdir(parents=True) + wire.write_text("{}", encoding="utf-8") + assert common.transcript_path_from_payload({"session_dir": str(session_dir)}) == str(wire) + + +def test_transcript_path_missing_session_dir(): + assert common.transcript_path_from_payload({}) is None diff --git a/tests/test_kimi_transcripts.py b/tests/test_kimi_transcripts.py new file mode 100644 index 0000000..815d940 --- /dev/null +++ b/tests/test_kimi_transcripts.py @@ -0,0 +1,132 @@ +"""Tests for Kimi Code CLI wire.jsonl transcript parsing.""" + +import json + +import pytest + +from persistent_memory import transcripts + + +def _line(**kw): + return json.dumps(kw) + + +def _write_wire(path, lines): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +@pytest.fixture +def kimi_wire(tmp_path): + wire_path = tmp_path / "session-abc" / "agents" / "main" / "wire.jsonl" + _write_wire( + wire_path, + [ + _line(type="metadata", protocol_version="1.4", created_at=1781691552269), + _line( + type="context.append_message", + time=1781691600697, + message={ + "role": "user", + "content": [{"type": "text", "text": "skiller var mı?"}], + "toolCalls": [], + "origin": {"kind": "user"}, + }, + ), + _line( + type="context.append_message", + time=1781691600698, + message={ + "role": "user", + "content": [{"type": "text", "text": "\nignore me\n"}], + "toolCalls": [], + "origin": {"kind": "injection", "variant": "todo_list_reminder"}, + }, + ), + _line( + type="context.append_loop_event", + time=1781691609761, + event={ + "type": "content.part", + "part": {"type": "think", "think": "internal reasoning"}, + }, + ), + _line( + type="context.append_loop_event", + time=1781691609762, + event={ + "type": "content.part", + "part": {"type": "text", "text": "Şu anda listede sadece bir skill görünüyor."}, + }, + ), + _line( + type="context.append_loop_event", + time=1781691670546, + event={ + "type": "tool.call", + "toolCallId": "tool_abc", + "name": "Glob", + "args": {"pattern": "**/SKILL.md"}, + }, + ), + _line( + type="context.append_loop_event", + time=1781691674617, + event={ + "type": "tool.result", + "parentUuid": "tool_abc", + "toolCallId": "tool_abc", + "result": {"output": "No matches found"}, + }, + ), + "{ this is not valid json", + ], + ) + return wire_path + + +def test_detect_kimi_by_wire_filename(kimi_wire): + assert transcripts._is_kimi_transcript(kimi_wire) is True + + +def test_detect_kimi_by_kimi_root(): + path = transcripts.KIMI_ROOT / "sessions" / "some" / "wire.jsonl" + assert transcripts._is_kimi_transcript(path) is True + + +def test_claude_path_is_not_kimi(tmp_path): + path = tmp_path / "session.jsonl" + assert transcripts._is_kimi_transcript(path) is False + + +def test_read_kimi_transcript_parses_user_and_assistant(kimi_wire): + messages = transcripts.read_transcript(kimi_wire) + roles = [m.role for m in messages] + assert "user" in roles + assert "assistant" in roles + user_texts = [m.text for m in messages if m.role == "user" and not m.is_tool] + assert "skiller var mı?" in user_texts + assert "" not in " ".join(user_texts) + assistant_texts = [m.text for m in messages if m.role == "assistant" and not m.is_tool] + assert "Şu anda listede sadece bir skill görünüyor." in assistant_texts + assert "internal reasoning" not in " ".join(assistant_texts) + + +def test_read_kimi_transcript_marks_tool_events(kimi_wire): + messages = transcripts.read_transcript(kimi_wire) + tool_messages = [m for m in messages if m.is_tool] + assert len(tool_messages) == 2 + assert any("Glob" in m.text for m in tool_messages) + assert any("tool_result" in m.text for m in tool_messages) + + +def test_read_kimi_transcript_timestamps_are_iso(kimi_wire): + messages = transcripts.read_transcript(kimi_wire) + for message in messages: + assert message.timestamp is not None + assert "T" in message.timestamp + + +def test_read_kimi_transcript_skips_malformed(kimi_wire): + messages = transcripts.read_transcript(kimi_wire) + assert len(messages) >= 4 diff --git a/tests/test_schema_branch.py b/tests/test_schema_branch.py new file mode 100644 index 0000000..3c73f92 --- /dev/null +++ b/tests/test_schema_branch.py @@ -0,0 +1,99 @@ +"""TDD: branch-aware provenance — failing tests written before implementation.""" + +import pytest +from pydantic import ValidationError + +from persistent_memory.schema import Provenance, parse_document, serialize_document, Record, RecordType, RecordStatus +import datetime + + +SAMPLE_WITHOUT_BRANCH = """--- +id: D-0007 +type: decision +status: proposed +date: 2026-06-02 +project: example-app +provenance: + session: S1254 + cwd: /Users/x/proj + agent: claude-opus-4-8 +tags: [] +supersedes: [] +superseded-by: [] +salience: 0.8 +--- +## Context + +body +""" + +SAMPLE_WITH_BRANCH = """--- +id: D-0007 +type: decision +status: proposed +date: 2026-06-02 +project: example-app +provenance: + session: S1254 + cwd: /Users/x/proj + agent: claude-opus-4-8 + branch: faz1-backend +tags: [] +supersedes: [] +superseded-by: [] +salience: 0.8 +--- +## Context + +body +""" + + +# --------------------------------------------------------------------------- +# Schema: branch is optional — old records without it must still parse +# --------------------------------------------------------------------------- + +def test_provenance_branch_defaults_to_none(): + p = Provenance(session="S1", cwd="/p", agent="a") + assert p.branch is None + + +def test_provenance_branch_accepted(): + p = Provenance(session="S1", cwd="/p", agent="a", branch="feature-x") + assert p.branch == "feature-x" + + +def test_provenance_branch_explicit_none(): + p = Provenance(session="S1", cwd="/p", agent="a", branch=None) + assert p.branch is None + + +def test_old_frontmatter_without_branch_parses(): + rec, body = parse_document(SAMPLE_WITHOUT_BRANCH) + assert rec.provenance.branch is None + + +def test_new_frontmatter_with_branch_parses(): + rec, body = parse_document(SAMPLE_WITH_BRANCH) + assert rec.provenance.branch == "faz1-backend" + + +def test_serialize_round_trip_preserves_branch(): + rec, body = parse_document(SAMPLE_WITH_BRANCH) + text = serialize_document(rec, body) + rec2, body2 = parse_document(text) + assert rec2.provenance.branch == "faz1-backend" + + +def test_serialize_round_trip_no_branch(): + rec, body = parse_document(SAMPLE_WITHOUT_BRANCH) + text = serialize_document(rec, body) + rec2, _ = parse_document(text) + assert rec2.provenance.branch is None + + +def test_provenance_dump_includes_branch_when_set(): + p = Provenance(session="S1", cwd="/p", agent="a", branch="main") + d = p.model_dump() + assert "branch" in d + assert d["branch"] == "main" diff --git a/tests/test_schema_provenance.py b/tests/test_schema_provenance.py index cf8e15d..b9e2de8 100644 --- a/tests/test_schema_provenance.py +++ b/tests/test_schema_provenance.py @@ -18,4 +18,4 @@ def test_provenance_requires_fields(): def test_provenance_dump_keys(): p = Provenance(session="S1", cwd="/p", agent="a") - assert set(p.model_dump().keys()) == {"session", "cwd", "agent"} + assert set(p.model_dump().keys()) == {"session", "cwd", "agent", "branch"}