diff --git a/docs/compatibility.md b/docs/compatibility.md index eb9f7cb9..2090e016 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,22 +2,26 @@ TrueMemory integrates with multiple AI CLI tools. Each CLI has different config formats and hook systems, but TrueMemory provides a unified adapter layer. -## Feature Support +## Configuration support + +This table describes what the adapters register or write. A `Registered` entry is not native lifecycle acceptance. See [validation status](#validation-status) before relying on automatic recall or extraction. | Feature | Claude Code | ChatGPT Desktop* | Codex CLI | Cursor | Gemini CLI | Kimi CLI | Hermes Agent | OpenClaw | |---------|:-----------:|:-------:|:---------:|:------:|:----------:|:--------:|:------------:|:--------:| | MCP server | JSON | JSON (experimental) | TOML | JSON | JSON | JSON | YAML | JSON | -| Auto-recall at session start | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | -| Auto-extract at session end | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | -| Mid-session extraction (PreCompact) | Yes | No | No | Yes | Yes | Yes | No | No | -| Message buffering (UserPromptSubmit) | Yes | No | Yes | No | No | No | No | No | -| System prompt injection | Yes | No | Yes | Yes | Yes | No | No | No | -| Hook protocol | JSON stdin/stdout | None | JSON stdin/stdout | JSON stdin/stdout | JSON stdin/stdout | JSON stdin/stdout | JSON stdin/stdout | JS plugin API | +| Session-start hook | Registered | No | Registered | Registered | Registered | Registered | Registered | Staged JS | +| Extraction boundary | Registered | No | Turn stop | Turn stop | Session end | Turn stop | Session end | Staged JS | +| Compaction/finalization hook | Registered | No | PreCompact | preCompact | PreCompress | PreCompact | Session finalize | Staged JS | +| Prompt/call hook | Registered | No | UserPromptSubmit | beforeSubmitPrompt | BeforeAgent | UserPromptSubmit | pre_llm_call | before_tool_call | +| Instruction file written | Yes | No | AGENTS.md | .cursorrules* | GEMINI.md | truememory_prompt.md* | truememory_prompt.md* | truememory_prompt.md* | +| Hook wire format | JSON stdin/stdout | None | JSON stdin/stdout | JSON stdin/stdout | JSON stdin/stdout | JSON stdin/stdout | JSON stdin/stdout | JS plugin API | | Config format | JSON | JSON | TOML | JSON (2 files) | JSON | TOML + JSON | YAML | JSON5 + JS | | Non-interactive install | `--cli claude` | `--cli chatgpt` | `--cli codex` | `--cli cursor` | `--cli gemini` | `--cli kimi` | `--cli hermes` | `--cli openclaw` | \* **ChatGPT Desktop is experimental and forward-looking:** ChatGPT Desktop does **not** currently load local MCP servers. OpenAI supports only remote HTTPS MCP connectors, enabled via developer mode in the ChatGPT *web* UI, and the macOS desktop app cannot enable developer mode (as of mid-2026). The adapter pre-stages `mcp.json` for when/if OpenAI enables local MCP support — TrueMemory will not appear in ChatGPT until then. See [ChatGPT Setup](setup-chatgpt.md). +\* An instruction file being written does not establish that the host loads that file. Current loading of the adapter's legacy `.cursorrules` path and its generic `truememory_prompt.md` files has not been established by these checks. + ## Config Locations | CLI | MCP Config | Hook Config | Detection Path | @@ -27,18 +31,43 @@ TrueMemory integrates with multiple AI CLI tools. Each CLI has different config | Codex CLI | `~/.codex/config.toml` | `~/.codex/config.toml` | `~/.codex/` | | Cursor | `~/.cursor/mcp.json` | `~/.cursor/hooks.json` | `~/.cursor/` | | Gemini CLI | `~/.gemini/settings.json` | `~/.gemini/settings.json` | `~/.gemini/` | -| Kimi CLI | `~/.kimi/mcp.json` | `~/.kimi/config.toml` | `~/.kimi/` | -| Hermes Agent | `~/.hermes/config.yaml` | `~/.hermes/cli-config.yaml` | `~/.hermes/` | -| OpenClaw | `~/.openclaw/openclaw.json` | `~/.openclaw/plugins/truememory/` | `~/.openclaw/` | +| Kimi CLI (legacy adapter target) | `~/.kimi/mcp.json` | `~/.kimi/config.toml` | `~/.kimi/` | +| Hermes Agent | `~/.hermes/config.yaml` | `~/.hermes/config.yaml` under `hooks` | `~/.hermes/` | +| OpenClaw | `~/.openclaw/openclaw.json` | Manual: `~/.openclaw/extensions/truememory/`; adapter staging: `~/.openclaw/plugins/truememory/` | `~/.openclaw/` | ## Hook Events | Event | Claude Code | ChatGPT Desktop | Codex CLI | Cursor | Gemini CLI | Kimi CLI | Hermes Agent | OpenClaw | |-------|------------|---------|-----------|--------|------------|----------|-------------|----------| -| Session start | `SessionStart` | — | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `on_session_start` | `before_agent_run` | -| Session end | `SessionEnd` | — | `Stop` | `stop` | `SessionEnd` | `Stop` | `on_session_end` | `agent_end` | -| Pre-compact | `PreCompact` | — | — | `preCompact` | `PreCompress` | `PreCompact` | — | — | -| User message | `UserPromptSubmit` | — | `UserPromptSubmit` | — | — | — | — | — | +| Session start | `SessionStart` | None | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | `on_session_start` | `session_start` | +| Extraction trigger | `SessionEnd` | None | `Stop` (turn) | `stop` (turn) | `SessionEnd` | `Stop` (turn) | `on_session_end` | `session_end` | +| Compaction/finalization | `PreCompact` | None | `PreCompact` | `preCompact` | `PreCompress` | `PreCompact` | `on_session_finalize` | `before_compaction` | +| Prompt/call registration | `UserPromptSubmit` | None | `UserPromptSubmit` | `beforeSubmitPrompt` | `BeforeAgent` | `UserPromptSubmit` | `pre_llm_call` | `before_tool_call` | + +Hermes `pre_llm_call` and OpenClaw `before_tool_call` are not exact user-message boundaries. OpenClaw names above are the shipped JavaScript registrations, whose native payload and lifecycle contract remain unverified. + +## Hook schemas and timeout units + +| Host | Registration shape | Session-start timeout | Other registered timeouts | Unit | +| --- | --- | ---: | --- | --- | +| Codex | `hooks.Event[]` groups with nested `hooks[]` and `type = "command"` | 10 | 5 | Seconds | +| Cursor | `version: 1`, camelCase event arrays | 10 | 5 | Seconds | +| Gemini | Event arrays of HookDefinition groups with nested `hooks[]` and `type: "command"` | 10000 | 5000 | Milliseconds | +| Kimi legacy CLI | Flat `[[hooks]]` entries with `event` | 10 | 5 | Seconds | +| Hermes | `config.yaml` event lists under `hooks` | 30 | Session end 30, pre-LLM 10, finalize 15 | Seconds | +| OpenClaw | `openclaw.plugin.json` + `package.json` entrypoint + `index.js` | 10000 | Sync prompt 5000, compaction 5000 | JavaScript subprocess milliseconds | + +For second-based hooks, `10000 / 60 = 166.67` minutes and `5000 / 60 = 83.33` minutes, which are the old unintended values. The corrected values are 10 and 5 seconds. Gemini's native conversion is `10000 / 1000 = 10` seconds and `5000 / 1000 = 5` seconds. + +## Validation status + +Configuration contracts were reviewed against the adapters and official host documentation on 2026-10-10. Documentation checks parse the snippets and use synthetic isolated configurations; they do not launch a native host. Native tested versions for Codex, Cursor, Gemini, Kimi, Hermes and OpenClaw: none in this parity validation. + +Codex and Gemini recall commands identify both `--host` and the exact `--event`; their context output uses `hookSpecificOutput.additionalContext` and the corresponding `hookEventName`. Cursor `sessionStart` uses `additional_context`. Cursor `beforeSubmitPrompt` supports `continue`/`user_message`, so per-prompt recall injection is unsupported there; its hook remains registered for capture. These contracts depend on the host-aware hook changes in [#761](https://github.com/buildingjoshbetter/TrueMemory/issues/761) and [#763](https://github.com/buildingjoshbetter/TrueMemory/issues/763). + +Config presence and `truememory-ingest status` do not prove execution, payload translation, transcript capture or model-visible recall. [C05/#761](https://github.com/buildingjoshbetter/TrueMemory/issues/761), [C07/#763](https://github.com/buildingjoshbetter/TrueMemory/issues/763), [C08/#764](https://github.com/buildingjoshbetter/TrueMemory/issues/764) and [C09/#765](https://github.com/buildingjoshbetter/TrueMemory/issues/765) remain incomplete. The separately implemented [C06/#762 Codex rollout reader](https://github.com/buildingjoshbetter/TrueMemory/issues/762) does not establish the other host lifecycles. C10/#766 corrects manual configuration only. + +Contract references: [official OpenAI hooks](https://learn.chatgpt.com/docs/hooks), [Cursor hooks](https://prod.cursor.com/docs/hooks), [Gemini hooks](https://geminicli.com/docs/hooks/reference/), [legacy Kimi source](https://github.com/MoonshotAI/kimi-cli/blob/main/docs/en/customization/hooks.md), [current Kimi Code hooks](https://www.kimi.ai/help/kimi-code/cli-hooks), [Hermes shell hooks](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/), [OpenClaw discovery](https://docs.openclaw.ai/cli/plugins/install) and [package entrypoints](https://docs.openclaw.ai/plugins/manifest/package-json). ## Shared Memory @@ -46,11 +75,11 @@ All CLIs share the same TrueMemory database (`~/.truememory/memories.db`). Memor ## Known Limitations -- **Codex CLI**: MCP and hooks share a single `config.toml`. TrueMemory uses additive merges to avoid overwriting other settings. +- **Codex CLI**: MCP and inline hooks share `config.toml`. Hook handlers are nested under event groups, use second-based timeouts and require Codex hook trust. `Stop` is a turn boundary. - **ChatGPT Desktop**: Local MCP configs are **not currently loaded by ChatGPT Desktop** — OpenAI supports only remote HTTPS MCP connectors via developer mode on the web UI (the macOS app cannot enable it as of mid-2026). The adapter pre-stages config for a possible future and requires the actual app to be installed. Lifecycle hooks and system prompt file injection are not supported. There is no ChatGPT Desktop for Linux. -- **Cursor**: MCP (`mcp.json`) and hooks (`hooks.json`) are separate files. Event names are camelCase. `hooks.json` requires `"version": 1` top-level key. -- **Gemini CLI**: MCP and hooks share a single `settings.json`. Uses different event names than Claude Code (`SessionEnd` instead of `Stop`, `PreCompress` instead of `PreCompact`). -- **Kimi CLI**: Hooks are in beta. Event availability may change. -- **Hermes Agent**: Gateway hooks (Telegram/Discord/etc.) require separate `handler.py` setup. -- **OpenClaw**: Uses a JS plugin system — requires Node.js at runtime for the plugin. +- **Cursor**: MCP (`mcp.json`) and hooks (`hooks.json`) are separate files. Event names are camelCase, timeouts are seconds and `hooks.json` requires `"version": 1`. Local user hooks are unavailable to cloud agents. Per-prompt recall injection is unsupported by `beforeSubmitPrompt`. +- **Gemini CLI**: MCP and nested hook definitions share `settings.json`; timeouts are milliseconds. Its `SessionEnd`, `BeforeAgent` and `PreCompress` events differ from Claude Code. +- **Kimi CLI**: The adapter targets legacy `~/.kimi/` and its beta hooks. Current Kimi Code documentation uses `~/.kimi-code/`; support for that location is unverified. +- **Hermes Agent**: MCP and shell hooks share `config.yaml`. Hermes supports shell hooks on CLI and gateway surfaces, but TrueMemory's native translation remains unverified. Separate gateway extensions use `HOOK.yaml`/`handler.py`. +- **OpenClaw**: Requires a JavaScript runtime and all three package files. The adapter's `plugins` staging directory differs from documented `extensions` discovery. Plugin user/database propagation, native payloads and context delivery remain unverified. - **All CLIs**: The first search after model download may be slow (model loading). diff --git a/docs/setup-codex.md b/docs/setup-codex.md index 80e441ed..1e903874 100644 --- a/docs/setup-codex.md +++ b/docs/setup-codex.md @@ -23,29 +23,46 @@ truememory-ingest setup ### 1. MCP Server + Lifecycle Hooks -Both MCP and hooks live in `~/.codex/config.toml`. Add the following: +Both MCP and inline hooks live in `~/.codex/config.toml`. Merge the MCP entry and append the TrueMemory matcher groups below, preserving other settings and hook handlers. Replace the placeholder paths with absolute paths in the environment where TrueMemory is installed. ```toml [mcp_servers.truememory] command = "/path/to/python" args = ["-m", "truememory.mcp_server"] -[[hooks]] -event = "SessionStart" -command = "/path/to/python /path/to/truememory/ingest/hooks/session_start.py" -timeout = 10000 +[[hooks.SessionStart]] -[[hooks]] -event = "Stop" -command = "/path/to/python /path/to/truememory/ingest/hooks/stop.py" -timeout = 5000 +[[hooks.SessionStart.hooks]] +type = "command" +command = "/path/to/python /path/to/truememory/ingest/hooks/session_start.py --host codex --event SessionStart" +timeout = 10 -[[hooks]] -event = "UserPromptSubmit" -command = "/path/to/python /path/to/truememory/ingest/hooks/user_prompt_submit.py" -timeout = 5000 +[[hooks.Stop]] + +[[hooks.Stop.hooks]] +type = "command" +command = "/path/to/python /path/to/truememory/ingest/hooks/stop.py --host codex --event Stop" +timeout = 5 + +[[hooks.UserPromptSubmit]] + +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "/path/to/python /path/to/truememory/ingest/hooks/user_prompt_submit.py --host codex --event UserPromptSubmit" +timeout = 5 + +[[hooks.PreCompact]] + +[[hooks.PreCompact.hooks]] +type = "command" +command = "/path/to/python /path/to/truememory/ingest/hooks/compact.py --host codex --event PreCompact" +timeout = 5 ``` +Codex uses event groups with nested command handlers, and `timeout` is in seconds. Keep one hook representation per configuration layer to avoid duplicate execution. Review and trust the new definitions through Codex's `/hooks` command before expecting execution. See the [official OpenAI hooks documentation](https://learn.chatgpt.com/docs/hooks). + +The installed command identifies the host and exact event independently of stdin. `SessionStart` and `UserPromptSubmit` use `hookSpecificOutput.additionalContext` with the matching `hookEventName`. `Stop` is a turn completion event, not proof that the session ended. Native execution, transcript capture and model-visible context remain unverified here; see [compatibility limits](compatibility.md#validation-status). + Find your Python path: `python3 -c "import sys; print(sys.executable)"` Find hook paths: `python3 -c "from pathlib import Path; import truememory; print(Path(truememory.__file__).parent / 'ingest' / 'hooks')"` @@ -58,7 +75,7 @@ For auto-recall/auto-store instructions, run: truememory-ingest setup --cli codex ``` -This creates `~/.codex/AGENTS.md` with TrueMemory system prompt instructions. +This adds TrueMemory instructions to `~/.codex/AGENTS.md`; preserve unrelated instructions when editing manually. ## Verification @@ -66,9 +83,11 @@ This creates `~/.codex/AGENTS.md` with TrueMemory system prompt instructions. truememory-ingest status ``` +This checks configuration presence. It does not prove that a Codex event ran or that its output reached the model. No native Codex version was exercised by the documentation parity checks. + ## Troubleshooting - **MCP not connecting**: Verify the Python path in `config.toml` points to the environment with TrueMemory installed. - **Existing config**: TrueMemory uses additive merges — your existing Codex config is preserved. -- **Hooks not firing**: Check that the hook script paths are absolute and the Python executable is correct. +- **Hooks not firing**: Check absolute paths, the Python environment and `/hooks` trust status. Quote each path inside `command` if it contains spaces. - **Windows Defender ASR**: If commands are blocked, use `python -m` form instead. See [debugging guide](guides/debugging.md#windows-defender-asr-blocks-truememory-mcpexe). diff --git a/docs/setup-cursor.md b/docs/setup-cursor.md index 6c1ce088..ad5c53cd 100644 --- a/docs/setup-cursor.md +++ b/docs/setup-cursor.md @@ -23,7 +23,7 @@ truememory-ingest setup ### 1. MCP Server -Add to `~/.cursor/mcp.json`: +Merge this server entry into `~/.cursor/mcp.json`, preserving other servers and settings: ```json { @@ -38,7 +38,7 @@ Add to `~/.cursor/mcp.json`: ### 2. Lifecycle Hooks -Add to `~/.cursor/hooks.json`: +Merge these event entries into `~/.cursor/hooks.json`, preserving other handlers. Replace the placeholder paths with absolute installed paths: ```json { @@ -46,26 +46,36 @@ Add to `~/.cursor/hooks.json`: "hooks": { "sessionStart": [ { - "command": "/path/to/python /path/to/truememory/ingest/hooks/session_start.py", - "timeout": 10000 + "command": "/path/to/python /path/to/truememory/ingest/hooks/session_start.py --host cursor --event sessionStart", + "timeout": 10 } ], "stop": [ { - "command": "/path/to/python /path/to/truememory/ingest/hooks/stop.py", - "timeout": 5000 + "command": "/path/to/python /path/to/truememory/ingest/hooks/stop.py --host cursor --event stop", + "timeout": 5 + } + ], + "beforeSubmitPrompt": [ + { + "command": "/path/to/python /path/to/truememory/ingest/hooks/user_prompt_submit.py --host cursor --event beforeSubmitPrompt", + "timeout": 5 } ], "preCompact": [ { - "command": "/path/to/python /path/to/truememory/ingest/hooks/compact.py", - "timeout": 5000 + "command": "/path/to/python /path/to/truememory/ingest/hooks/compact.py --host cursor --event preCompact", + "timeout": 5 } ] } } ``` +Cursor's `timeout` is in seconds. `sessionStart` accepts `additional_context`; `beforeSubmitPrompt` accepts `continue` and `user_message`, so TrueMemory registers it for prompt capture and does not inject per-prompt recall there. `stop` marks agent completion, and `preCompact` observes compaction. See the [official Cursor hooks contract](https://prod.cursor.com/docs/hooks). + +These are local user hooks. Cursor cloud agents do not load `~/.cursor/hooks.json`. Native delivery, capture and model-visible recall remain unverified here; see [compatibility limits](compatibility.md#validation-status). + Find your Python path: `python3 -c "import sys; print(sys.executable)"` Find hook paths: `python3 -c "from pathlib import Path; import truememory; print(Path(truememory.__file__).parent / 'ingest' / 'hooks')"` @@ -78,7 +88,7 @@ For auto-recall/auto-store instructions, run: truememory-ingest setup --cli cursor ``` -This creates `~/.cursor/.cursorrules` with TrueMemory system prompt instructions. +The adapter writes `~/.cursor/.cursorrules`. This documentation check does not establish that the current Cursor release loads that legacy location as rules. ## Verification @@ -86,11 +96,13 @@ This creates `~/.cursor/.cursorrules` with TrueMemory system prompt instructions truememory-ingest status ``` +This checks configuration presence, not event execution or model-visible context. No native Cursor version was exercised by the documentation parity checks. + ## Troubleshooting - **MCP not connecting**: Verify the Python path in `mcp.json` points to the environment with TrueMemory installed. - **Existing config**: TrueMemory uses additive merges — your existing Cursor config is preserved. -- **Hooks not firing**: Check that event names are camelCase (`sessionStart`, `stop`, `preCompact`). PascalCase will silently fail. +- **Hooks not firing**: Check exact camelCase event names (`sessionStart`, `stop`, `beforeSubmitPrompt`, `preCompact`), installed paths and the Python environment. Quote paths inside `command` if they contain spaces. - **Two config files**: MCP lives in `~/.cursor/mcp.json`, hooks live in `~/.cursor/hooks.json`. They are separate files. - **Version key**: `hooks.json` requires `"version": 1` at the top level. - **Windows Defender ASR**: If commands are blocked, use `python -m` form instead. See [debugging guide](guides/debugging.md#windows-defender-asr-blocks-truememory-mcpexe). diff --git a/docs/setup-gemini.md b/docs/setup-gemini.md index bdd69e93..481c39fe 100644 --- a/docs/setup-gemini.md +++ b/docs/setup-gemini.md @@ -23,7 +23,7 @@ truememory-ingest setup ### 1. MCP Server + Lifecycle Hooks -Both MCP and hooks live in `~/.gemini/settings.json`. Add the following: +Both MCP and hooks live in `~/.gemini/settings.json`. Merge the server entry and append the TrueMemory hook definitions below, preserving other settings and handlers. Replace the placeholder paths with absolute installed paths: ```json { @@ -36,26 +36,60 @@ Both MCP and hooks live in `~/.gemini/settings.json`. Add the following: "hooks": { "SessionStart": [ { - "command": "/path/to/python /path/to/truememory/ingest/hooks/session_start.py", - "timeout": 10000 + "hooks": [ + { + "type": "command", + "command": "/path/to/python /path/to/truememory/ingest/hooks/session_start.py --host gemini --event SessionStart", + "name": "truememory-sessionstart", + "timeout": 10000 + } + ] } ], "SessionEnd": [ { - "command": "/path/to/python /path/to/truememory/ingest/hooks/stop.py", - "timeout": 5000 + "hooks": [ + { + "type": "command", + "command": "/path/to/python /path/to/truememory/ingest/hooks/stop.py --host gemini --event SessionEnd", + "name": "truememory-sessionend", + "timeout": 5000 + } + ] + } + ], + "BeforeAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "/path/to/python /path/to/truememory/ingest/hooks/user_prompt_submit.py --host gemini --event BeforeAgent", + "name": "truememory-beforeagent", + "timeout": 5000 + } + ] } ], "PreCompress": [ { - "command": "/path/to/python /path/to/truememory/ingest/hooks/compact.py", - "timeout": 5000 + "hooks": [ + { + "type": "command", + "command": "/path/to/python /path/to/truememory/ingest/hooks/compact.py --host gemini --event PreCompress", + "name": "truememory-precompress", + "timeout": 5000 + } + ] } ] } } ``` +Each event contains HookDefinition groups, each with a `hooks` array of `type: "command"` handlers. Gemini's `timeout` is in milliseconds: `10000 / 1000 = 10` seconds and `5000 / 1000 = 5` seconds. See the [official Gemini hooks reference](https://geminicli.com/docs/hooks/reference/). + +`SessionStart` and `BeforeAgent` use `hookSpecificOutput.additionalContext` with the matching `hookEventName`. Installed commands supply the trusted host and event; stdin alone does not select the host. Native delivery, Gemini transcript handling and model-visible context remain unverified here; see [compatibility limits](compatibility.md#validation-status). + Find your Python path: `python3 -c "import sys; print(sys.executable)"` Find hook paths: `python3 -c "from pathlib import Path; import truememory; print(Path(truememory.__file__).parent / 'ingest' / 'hooks')"` @@ -68,7 +102,7 @@ For auto-recall/auto-store instructions, run: truememory-ingest setup --cli gemini ``` -This creates `~/.gemini/GEMINI.md` with TrueMemory system prompt instructions. +This adds TrueMemory instructions to `~/.gemini/GEMINI.md`; preserve unrelated instructions when editing manually. ## Verification @@ -76,9 +110,11 @@ This creates `~/.gemini/GEMINI.md` with TrueMemory system prompt instructions. truememory-ingest status ``` +This checks configuration presence, not event execution or model-visible context. No native Gemini CLI version was exercised by the documentation parity checks. + ## Troubleshooting - **MCP not connecting**: Verify the Python path in `settings.json` points to the environment with TrueMemory installed. - **Existing config**: TrueMemory uses additive merges — your existing Gemini config is preserved. -- **Hooks not firing**: Check that the hook script paths are absolute and the Python executable is correct. Event names are PascalCase: `SessionStart`, `SessionEnd`, `PreCompress`. +- **Hooks not firing**: Check absolute paths and the Python environment. Event names are PascalCase: `SessionStart`, `SessionEnd`, `BeforeAgent`, `PreCompress`. Quote paths inside `command` if they contain spaces. - **Windows Defender ASR**: If commands are blocked, use `python -m` form instead. See [debugging guide](guides/debugging.md#windows-defender-asr-blocks-truememory-mcpexe). diff --git a/docs/setup-hermes.md b/docs/setup-hermes.md index 9fb243e4..925a1934 100644 --- a/docs/setup-hermes.md +++ b/docs/setup-hermes.md @@ -23,7 +23,7 @@ truememory-ingest setup ### 1. MCP Server -Add to `~/.hermes/config.yaml`: +Merge this server entry into `~/.hermes/config.yaml`, preserving other servers and settings: ```yaml mcp_servers: @@ -34,27 +34,33 @@ mcp_servers: - truememory.mcp_server ``` -### 2. Plugin Hooks (CLI Mode) +### 2. Shell hooks -Add to `~/.hermes/cli-config.yaml`: +Merge this `hooks` map into the same `~/.hermes/config.yaml`, preserving other event handlers. Each event contains a list of command entries. Replace the placeholder paths with absolute installed paths: ```yaml -plugins: - - name: truememory-session-start - event: on_session_start - command: /path/to/python /path/to/truememory/ingest/hooks/session_start.py - - name: truememory-session-end - event: on_session_end - command: /path/to/python /path/to/truememory/ingest/hooks/stop.py +hooks: + on_session_start: + - command: /path/to/python /path/to/truememory/ingest/hooks/session_start.py + timeout: 30 + on_session_end: + - command: /path/to/python /path/to/truememory/ingest/hooks/stop.py + timeout: 30 + pre_llm_call: + - command: /path/to/python /path/to/truememory/ingest/hooks/user_prompt_submit.py + timeout: 10 + on_session_finalize: + - command: /path/to/python /path/to/truememory/ingest/hooks/compact.py + timeout: 15 ``` -## Hermes Learning Loop +Hermes shell-hook timeouts are in seconds. Shell hooks are supported by Hermes in CLI and gateway sessions, as well as its other chat surfaces. They are distinct from gateway-only `HOOK.yaml`/`handler.py` extensions. See the [official Hermes hook documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/). -Hermes has a built-in self-improving learning loop. TrueMemory is complementary: -- **TrueMemory**: Cross-session persistent memory (facts, preferences, decisions) -- **Hermes learning loop**: Within-session self-improvement +The entries above match the current TrueMemory adapter. `pre_llm_call` can fire repeatedly for continuations and retries, so it is not an exact user-prompt boundary. `on_session_finalize` is not a pre-compaction event. Native payload translation, context injection and transcript capture remain unverified under [Hermes integration #765](https://github.com/buildingjoshbetter/TrueMemory/issues/765). -Both work together — TrueMemory provides long-term context while Hermes optimizes per-session. +Find your Python path: `python3 -c "import sys; print(sys.executable)"` + +Find hook paths: `python3 -c "from pathlib import Path; import truememory; print(Path(truememory.__file__).parent / 'ingest' / 'hooks')"` ## Verification @@ -62,9 +68,12 @@ Both work together — TrueMemory provides long-term context while Hermes optimi truememory-ingest status ``` +This checks configuration presence. Hermes's `hermes hooks list` also shows its configured events and consent status; neither establishes that TrueMemory's output reached the model. No native Hermes version was exercised by the documentation parity checks. + ## Troubleshooting -- **Plugins not loading**: Check that `cli-config.yaml` is valid YAML (no tab characters). +- **Hooks not loading**: Check that `config.yaml` is valid YAML and that each event under `hooks` maps to a list. Review the hook consent status in `hermes hooks list`. - **MCP not connecting**: Verify the Python path in `config.yaml`. -- **Gateway users**: For Telegram/Discord/etc., a separate `handler.py` gateway hook is needed (see architecture docs). +- **Existing config**: Merge only the TrueMemory server and hook entries. Keep unrelated YAML keys and handlers. +- **Paths with spaces**: Quote each path inside the hook `command` string. - **Windows Defender ASR**: If commands are blocked, use `python -m` form instead. See [debugging guide](guides/debugging.md#windows-defender-asr-blocks-truememory-mcpexe). diff --git a/docs/setup-kimi.md b/docs/setup-kimi.md index cce39b00..e5bd43c0 100644 --- a/docs/setup-kimi.md +++ b/docs/setup-kimi.md @@ -6,6 +6,8 @@ - TrueMemory installed: `uv tool install truememory` - Python 3.10+ +This guide targets the legacy `MoonshotAI/kimi-cli` configuration at `~/.kimi/`, which matches the current TrueMemory adapter. Newer Kimi Code documentation uses `~/.kimi-code/`; automatic discovery and installation for that location are unverified. See [legacy config source](https://github.com/MoonshotAI/kimi-cli/blob/main/docs/en/configuration/config-files.md) and [current Kimi Code hooks](https://www.kimi.ai/help/kimi-code/cli-hooks). + ## Automatic Setup ```bash @@ -23,7 +25,7 @@ truememory-ingest setup ### 1. MCP Server -Add to `~/.kimi/mcp.json`: +Merge this server entry into `~/.kimi/mcp.json`, preserving other servers and settings: ```json { @@ -40,25 +42,32 @@ Find your Python path: `python3 -c "import sys; print(sys.executable)"` ### 2. Lifecycle Hooks -Add to `~/.kimi/config.toml`: +Append these TrueMemory entries to `~/.kimi/config.toml`, preserving other settings and hook entries. Replace the placeholder paths with absolute installed paths: ```toml [[hooks]] event = "SessionStart" command = "/path/to/python /path/to/truememory/ingest/hooks/session_start.py" -timeout = 10000 +timeout = 10 [[hooks]] event = "Stop" command = "/path/to/python /path/to/truememory/ingest/hooks/stop.py" -timeout = 5000 +timeout = 5 + +[[hooks]] +event = "UserPromptSubmit" +command = "/path/to/python /path/to/truememory/ingest/hooks/user_prompt_submit.py" +timeout = 5 [[hooks]] event = "PreCompact" command = "/path/to/python /path/to/truememory/ingest/hooks/compact.py" -timeout = 5000 +timeout = 5 ``` +Kimi hook timeouts are seconds. The flat `[[hooks]]` array is the Kimi contract; it differs from Codex's nested event groups. These entries match the current adapter's registration. Native event delivery, host-specific output and transcript capture remain unverified; no native Kimi version was exercised by this documentation check. See [compatibility limits](compatibility.md#validation-status). + Find hook paths: `python3 -c "from pathlib import Path; import truememory; print(Path(truememory.__file__).parent / 'ingest' / 'hooks')"` ## Verification @@ -67,9 +76,12 @@ Find hook paths: `python3 -c "from pathlib import Path; import truememory; print truememory-ingest status ``` +This checks configuration presence. It does not establish lifecycle execution in either legacy Kimi CLI or current Kimi Code. + ## Troubleshooting - **Hooks not firing**: Kimi hooks are in beta. Check `kimi --version` for compatibility. - **MCP not connecting**: Verify the Python path in `mcp.json` points to the environment with TrueMemory installed. - **Existing config**: TrueMemory uses additive merges — your existing Kimi config is preserved. +- **Paths with spaces**: Quote each path inside the hook `command` string. - **Windows Defender ASR**: If commands are blocked, use `python -m` form instead. See [debugging guide](guides/debugging.md#windows-defender-asr-blocks-truememory-mcpexe). diff --git a/docs/setup-openclaw.md b/docs/setup-openclaw.md index a8a7a756..45c1d1f0 100644 --- a/docs/setup-openclaw.md +++ b/docs/setup-openclaw.md @@ -13,6 +13,8 @@ truememory-ingest setup --cli openclaw ``` +The current adapter stages plugin files under `~/.openclaw/plugins/truememory/`. Current OpenClaw documentation describes discovery under `~/.openclaw/extensions/` or explicit `plugins.load.paths`. Automatic staging does not establish discovery or lifecycle support; the adapter path and plugin runtime remain tracked under [OpenClaw integration #764](https://github.com/buildingjoshbetter/TrueMemory/issues/764). + Or during the interactive setup wizard: ```bash @@ -24,7 +26,7 @@ truememory-ingest setup ### 1. MCP Server -Add to `~/.openclaw/openclaw.json`: +Merge this server entry into `~/.openclaw/openclaw.json`, preserving other servers and settings: ```json { @@ -41,28 +43,34 @@ Add to `~/.openclaw/openclaw.json`: ### 2. Plugin Installation -Copy the TrueMemory plugin to `~/.openclaw/plugins/truememory/`: +Copy the shipped package to the documented global discovery directory `~/.openclaw/extensions/truememory/`. The script refuses to replace an existing package; inspect an existing installation before updating it. ```bash -python3 -c " +python3 - <<'PY' from pathlib import Path import shutil, truememory src = Path(truememory.__file__).parent / 'hooks' / 'templates' / 'openclaw' -dst = Path.home() / '.openclaw' / 'plugins' / 'truememory' -dst.mkdir(parents=True, exist_ok=True) -for f in ('plugin.json', 'index.js'): +dst = Path.home() / '.openclaw' / 'extensions' / 'truememory' +files = ('openclaw.plugin.json', 'package.json', 'index.js') +for f in files: + if not (src / f).is_file(): + raise FileNotFoundError(src / f) +dst.mkdir(parents=True, exist_ok=False) +for f in files: shutil.copy2(src / f, dst / f) print(f'Plugin installed to {dst}') -" +PY ``` -The plugin registers two event handlers: -- `before_agent_run` — recalls relevant memories and injects them as context -- `agent_end` — captures the transcript and triggers background extraction +`openclaw.plugin.json` contains the plugin ID and configuration schema. `package.json` declares `type: "module"` and `openclaw.extensions: ["./index.js"]`, which selects the JavaScript entrypoint. All three files are required by this package. See [OpenClaw plugin installation and discovery](https://docs.openclaw.ai/cli/plugins/install) and [manifest versus package metadata](https://docs.openclaw.ai/plugins/manifest/package-json). + +OpenClaw policy also determines activation. If you already use `plugins.allow`, add `truememory` to that list while preserving its other entries. Keep existing `plugins.deny`, `plugins.enabled` and per-plugin policy intact. Use `openclaw plugins list` to inspect discovery and activation before expecting execution. + +The shipped entrypoint registers `session_start`, `session_end`, `before_tool_call` and `before_compaction`. These are source registrations, not a verified mapping to every installed OpenClaw version. Its payload handling and context delivery remain unverified under #764; copying the package does not prove native recall or extraction. ## Multi-Surface Memory -OpenClaw connects to WhatsApp, Telegram, Slack, Discord, and more. Memories stored from any surface are available on all others — TrueMemory's `user_id` scoping ensures a single memory database serves all platforms. +Cross-surface memory requires every surface to use the same TrueMemory database and user identity. The current plugin installer does not propagate its `user_id` or `db_path` arguments into the JavaScript hook commands, so explicit identity consistency remains unverified. ## Verification @@ -70,9 +78,11 @@ OpenClaw connects to WhatsApp, Telegram, Slack, Discord, and more. Memories stor truememory-ingest status ``` +This checks the adapter's config and staging location. It does not verify the manual `extensions` package, native discovery, event execution or model-visible context. No native OpenClaw version was exercised by the documentation parity checks. + ## Troubleshooting -- **Plugin not loading**: Verify `~/.openclaw/plugins/truememory/plugin.json` exists and is valid JSON. +- **Plugin not loading**: Verify `~/.openclaw/extensions/truememory/openclaw.plugin.json`, `package.json` and `index.js` exist, then inspect `openclaw plugins list` and plugin activation policy. The automatic adapter's `plugins` directory differs from this documented discovery location. - **Python not found**: Set the `TRUEMEMORY_PYTHON` environment variable to your Python path. - **JSON5 config**: OpenClaw uses JSON5. TrueMemory reads it but writes standard JSON — comments may be stripped on config updates. - **Windows Defender ASR**: If commands are blocked, use `python -m` form instead. See [debugging guide](guides/debugging.md#windows-defender-asr-blocks-truememory-mcpexe). diff --git a/tests/test-kimi-toml-serialization-833.py b/tests/test-kimi-toml-serialization-833.py new file mode 100644 index 00000000..c5cfd0e1 --- /dev/null +++ b/tests/test-kimi-toml-serialization-833.py @@ -0,0 +1,366 @@ +"""Synthetic Kimi command/TOML roundtrips using actual adapter and writer AST.""" +from __future__ import annotations + +import ast +import __future__ +import builtins +import json +import logging +import shlex +import subprocess +import unittest +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath +from types import SimpleNamespace +from typing import Iterator, Protocol, cast +from unittest.mock import patch + +try: + import tomllib +except ModuleNotFoundError: + tomllib = None + +ROOT = Path(__file__).resolve().parents[1] +KIMI_SOURCE = ROOT / "truememory/hooks/adapters/kimi.py" +BASE_SOURCE = ROOT / "truememory/hooks/adapters/base.py" +EVENTS = { + "SessionStart": ("session_start.py", 10), + "Stop": ("stop.py", 5), + "UserPromptSubmit": ("user_prompt_submit.py", 5), + "PreCompact": ("compact.py", 5), +} +FOREIGN_TOML = ( + '# Synthetic foreign settings\ntheme = "plain"\n\n' + '[[hooks]]\nevent = "SessionStart"\ncommand = "printf foreign-hook"\n' + 'timeout = 37\n\n[features]\ntelemetry = false\n' +) +FOREIGN_MCP = {"mcpServers": {"foreign": {"command": "synthetic-foreign"}}, "theme": "plain"} + + +@dataclass +class MemoryFS: + platform: str + files: dict[str, str] = field(default_factory=dict) + directories: set[str] = field(default_factory=set) + replace_calls: int = 0 + fail_next_replace: bool = False + + def path(self, value: str | MemoryPath) -> MemoryPath: + if isinstance(value, MemoryPath): + if value.fs is not self: + raise AssertionError("Path belongs to another synthetic filesystem") + return value + pure = PureWindowsPath(value) if self.platform == "win32" else PurePosixPath(value) + return MemoryPath(self, pure) + + def replace(self, source: MemoryPath, destination: MemoryPath) -> None: + self.replace_calls += 1 + if self.fail_next_replace: + self.fail_next_replace = False + raise OSError("Synthetic atomic replace failure") + self.files[str(destination)] = self.files.pop(str(source)) + + +@dataclass(frozen=True) +class MemoryPath: + fs: MemoryFS + pure: PurePath + + def __str__(self) -> str: + return str(self.pure) + + def __truediv__(self, component: str) -> MemoryPath: + return MemoryPath(self.fs, self.pure / component) + + @property + def parent(self) -> MemoryPath: + return MemoryPath(self.fs, self.pure.parent) + + @property + def name(self) -> str: + return self.pure.name + + def with_name(self, name: str) -> MemoryPath: + return MemoryPath(self.fs, self.pure.with_name(name)) + + def mkdir(self, *, parents: bool = False, exist_ok: bool = False) -> None: + if not parents or not exist_ok: + raise AssertionError("Unexpected synthetic mkdir arguments") + self.fs.directories.add(str(self)) + + def exists(self) -> bool: + return str(self) in self.fs.files or str(self) in self.fs.directories + + def is_dir(self) -> bool: + return str(self) in self.fs.directories + + def read_text(self, *, encoding: str) -> str: + if encoding != "utf-8": + raise AssertionError("Unexpected encoding") + try: + return self.fs.files[str(self)] + except KeyError as error: + raise FileNotFoundError(str(self)) from error + + def write_text(self, text: str, *, encoding: str) -> int: + if encoding != "utf-8": + raise AssertionError("Unexpected encoding") + text.encode(encoding) + self.fs.files[str(self)] = text + return len(text) + + def unlink(self) -> None: + try: + del self.fs.files[str(self)] + except KeyError as error: + raise FileNotFoundError(str(self)) from error + + +@dataclass +class MemoryPathFactory: + fs: MemoryFS + + def __call__(self, value: str | MemoryPath) -> MemoryPath: + return self.fs.path(value) + + def home(self) -> MemoryPath: + home = r"C:\synthetic-home" if self.fs.platform == "win32" else "/synthetic-home" + return self.fs.path(home) + + +class HookAdapter(Protocol): + def install_hooks(self, python_path: str, user_id: str = "", db_path: str = "") -> None: ... + def install_mcp(self, python_path: str) -> None: ... + def verify(self) -> bool: ... + def uninstall(self) -> None: ... + def _parse_existing_hooks(self, text: str) -> list[dict]: ... + + +@dataclass +class Fixture: + fs: MemoryFS + adapter: HookAdapter + hook_config: MemoryPath + mcp_config: MemoryPath + hooks_dir: MemoryPath + + def text(self) -> str: + return self.hook_config.read_text(encoding="utf-8") + + +def load_fixture(platform: str) -> Fixture: + """Execute source AST with memory-only paths and the actual atomic writer.""" + fs = MemoryFS(platform) + path_factory = MemoryPathFactory(fs) + source_file = ( + r'C:\synthetic modules "quoted"\truememory\hooks\adapters\kimi.py' + if platform == "win32" + else '/synthetic modules "quoted"/truememory/hooks/adapters/kimi.py' + ) + namespace: dict[str, object] = { + "__name__": "synthetic_kimi_adapter", + "__file__": source_file, + "Path": path_factory, + "CLIAdapter": object, + "json": json, + "logging": logging, + "shlex": shlex, + "shutil": SimpleNamespace(which=lambda executable: None), + "sys": SimpleNamespace(platform=platform, executable="synthetic-python"), + "os": SimpleNamespace(getpid=lambda: 833, replace=fs.replace), + } + base_tree = ast.parse(BASE_SOURCE.read_text(encoding="utf-8")) + writer = next( + node for node in base_tree.body + if isinstance(node, ast.FunctionDef) and node.name == "atomic_write_text" + ) + writer_module = ast.Module(body=[writer], type_ignores=[]) + exec(compile(writer_module, str(BASE_SOURCE), "exec", flags=__future__.annotations.compiler_flag), namespace) + adapter_tree = ast.parse(KIMI_SOURCE.read_text(encoding="utf-8")) + adapter_tree.body = [ + node for node in adapter_tree.body + if not isinstance(node, (ast.Import, ast.ImportFrom)) + ] + exec(compile(adapter_tree, str(KIMI_SOURCE), "exec", flags=__future__.annotations.compiler_flag), namespace) + adapter_type = namespace["KimiAdapter"] + if not isinstance(adapter_type, type): + raise AssertionError("Actual Kimi adapter class was not loaded") + hook_config = cast(MemoryPath, namespace["_HOOK_CONFIG"]) + mcp_config = cast(MemoryPath, namespace["_MCP_CONFIG"]) + fs.files[str(hook_config)] = FOREIGN_TOML + fs.files[str(mcp_config)] = json.dumps(FOREIGN_MCP) + hooks_dir = fs.path(source_file).parent.parent.parent / "ingest" / "hooks" + return Fixture(fs, cast(HookAdapter, adapter_type()), hook_config, mcp_config, hooks_dir) + + +@contextmanager +def scoped_imports(*, no_tomllib: bool) -> Iterator[None]: + original_import = builtins.__import__ + + def checked_import( + name: str, globals: dict | None = None, locals: dict | None = None, + fromlist: tuple | list = (), level: int = 0, + ) -> object: + if name == "tomllib" and no_tomllib: + raise ModuleNotFoundError("Synthetic Python 3.10 tomllib absence") + if name == "truememory" or name.startswith("truememory."): + raise AssertionError("Actual application import is forbidden in this fixture") + return original_import(name, globals, locals, fromlist, level) + + with patch("builtins.__import__", side_effect=checked_import): + yield + + +def expected_commands(fixture: Fixture, python: str, user: str, db: str) -> list[str]: + commands: list[str] = [] + for script, _ in EVENTS.values(): + parts = [python, str(fixture.hooks_dir / script)] + if user: + parts.extend(["--user", user]) + if db: + parts.extend(["--db", db]) + commands.append( + subprocess.list2cmdline(parts) if fixture.fs.platform == "win32" + else " ".join(shlex.quote(part) for part in parts) + ) + return commands + + +def strict_generated_commands(text: str) -> list[str]: + """Validate emitted JSON-compatible TOML scalars without a TOML dependency.""" + commands: list[str] = [] + for line in text.split("\n"): + if not line.startswith("command = "): + continue + scalar = line[len("command = "):] + if any(ord(char) < 0x20 or ord(char) == 0x7F for char in scalar): + raise AssertionError("Raw TOML-forbidden control character") + decoded = json.loads(scalar) + if not isinstance(decoded, str): + raise AssertionError("Generated command must be a string") + if any(0xD800 <= ord(char) <= 0xDFFF for char in decoded): + raise AssertionError("Surrogate is not a TOML Unicode scalar") + commands.append(decoded) + return commands + + +class TestKimiTomlSerialization(unittest.TestCase): + def assert_roundtrip( + self, fixture: Fixture, python: str, user: str, db: str, *, fallback: bool, + ) -> None: + expected = expected_commands(fixture, python, user, db) + with scoped_imports(no_tomllib=fallback): + fixture.adapter.install_hooks(python, user, db) + hooks = fixture.adapter._parse_existing_hooks(fixture.text()) + installed = fixture.text() + replacements = fixture.fs.replace_calls + fixture.adapter.install_hooks(python, user, db) + self.assertEqual(fixture.text(), installed) + self.assertEqual(fixture.fs.replace_calls, replacements) + self.assertEqual([hook["command"] for hook in hooks[1:]], expected) + self.assertEqual([hook["event"] for hook in hooks[1:]], list(EVENTS)) + self.assertEqual([int(hook["timeout"]) for hook in hooks[1:]], [10, 5, 5, 5]) + self.assertEqual(strict_generated_commands(fixture.text())[1:], expected) + self.assertTrue(fixture.text().startswith(FOREIGN_TOML.rstrip() + "\n")) + if tomllib is not None: + parsed = tomllib.loads(fixture.text()) + self.assertEqual([hook["command"] for hook in parsed["hooks"][1:]], expected) + self.assertEqual([hook["timeout"] for hook in parsed["hooks"][1:]], [10, 5, 5, 5]) + self.assertEqual(parsed["features"], {"telemetry": False}) + + def test_native_and_fallback_commands_preserve_platform_quoting(self) -> None: + for platform in ("linux", "win32"): + for fallback in (False, True): + with self.subTest(platform=platform, fallback=fallback): + fixture = load_fixture(platform) + python = r'C:\Synthetic Python "quoted"\python.exe' if platform == "win32" else '/synthetic Python "quoted"/python' + self.assert_roundtrip( + fixture, python, 'synthetic user "quoted" \\ trailing\\', + r'C:\synthetic db "quoted"\store.db' if platform == "win32" else '/synthetic db "quoted"/store.db', + fallback=fallback, + ) + + def test_all_control_and_unicode_characters_roundtrip(self) -> None: + characters = [chr(codepoint) for codepoint in range(32)] + [ + chr(0x7F), chr(0x85), chr(0x2028), chr(0x2029), chr(0xE9), chr(0x4E2D), chr(0x20000), + '"', "'", "\\", "#", + ] + for platform in ("linux", "win32"): + for fallback in (False, True): + for character in characters: + with self.subTest(platform=platform, fallback=fallback, codepoint=ord(character)): + fixture = load_fixture(platform) + self.assert_roundtrip( + fixture, "synthetic-python", "synthetic" + character + "user", + "synthetic" + character + "db", fallback=fallback, + ) + + def test_reinstall_verify_uninstall_preserve_foreign_configuration(self) -> None: + for platform in ("linux", "win32"): + for fallback in (False, True): + with self.subTest(platform=platform, fallback=fallback): + fixture = load_fixture(platform) + with scoped_imports(no_tomllib=fallback): + fixture.adapter.install_mcp("synthetic-python") + fixture.adapter.install_hooks("synthetic-python", 'user "quoted"', "db\\path") + first = fixture.text() + first_mcp = fixture.mcp_config.read_text(encoding="utf-8") + replacements = fixture.fs.replace_calls + fixture.adapter.install_hooks("synthetic-python", 'user "quoted"', "db\\path") + self.assertEqual(fixture.text(), first) + self.assertEqual(fixture.fs.replace_calls, replacements) + self.assertEqual(fixture.mcp_config.read_text(encoding="utf-8"), first_mcp) + self.assertTrue(fixture.adapter.verify()) + self.assertEqual(len(fixture.adapter._parse_existing_hooks(first)), 5) + fixture.adapter.uninstall() + self.assertFalse(fixture.adapter.verify()) + remaining = fixture.adapter._parse_existing_hooks(fixture.text()) + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0]["command"], "printf foreign-hook") + self.assertTrue(fixture.text().startswith(FOREIGN_TOML)) + self.assertNotIn("truememory", fixture.text()) + self.assertEqual(json.loads(fixture.mcp_config.read_text(encoding="utf-8")), FOREIGN_MCP) + + def test_atomic_failure_preserves_original_and_retry_installs_once(self) -> None: + for platform in ("linux", "win32"): + for fallback in (False, True): + with self.subTest(platform=platform, fallback=fallback): + fixture = load_fixture(platform) + original_files = dict(fixture.fs.files) + fixture.fs.fail_next_replace = True + with scoped_imports(no_tomllib=fallback): + with self.assertRaisesRegex(OSError, "Synthetic atomic replace failure"): + fixture.adapter.install_hooks("synthetic-python", "synthetic\\user", "synthetic\\db") + self.assertEqual(fixture.fs.files, original_files) + self.assertEqual(fixture.fs.replace_calls, 1) + self.assert_roundtrip(fixture, "synthetic-python", "synthetic\\user", "synthetic\\db", fallback=fallback) + self.assertEqual(fixture.fs.replace_calls, 2) + self.assertFalse(any(name.endswith(".tmp") for name in fixture.fs.files)) + + def test_fallback_rejects_malformed_complete_strings(self) -> None: + malformed = [ + r'"truememory\q"', r'"truememory\u12zz"', '"truememory" trailing', + '"truememory" "second"', '"truememory', + ] + for scalar in malformed: + with self.subTest(scalar=scalar): + fixture = load_fixture("win32") + text = '[[hooks]]\nevent = "SessionStart"\ncommand = ' + scalar + '\ntimeout = 10\n' + with scoped_imports(no_tomllib=True): + self.assertEqual(fixture.adapter._parse_existing_hooks(text), []) + + def test_fallback_allows_complete_string_and_trailing_comment(self) -> None: + fixture = load_fixture("win32") + command = r'"C:\Synthetic Python\python.exe" truememory --user "quoted user"' + text = '[[hooks]]\nevent = "SessionStart"\ncommand = ' + json.dumps(command) + ' # "synthetic comment"\ntimeout = 10\n' + with scoped_imports(no_tomllib=True): + hooks = fixture.adapter._parse_existing_hooks(text) + self.assertEqual(len(hooks), 1) + self.assertEqual(hooks[0]["command"], command) + self.assertEqual(hooks[0]["event"], "SessionStart") + self.assertEqual(hooks[0]["timeout"], "10") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test-manual-integration-docs-766.py b/tests/test-manual-integration-docs-766.py new file mode 100644 index 00000000..836d371c --- /dev/null +++ b/tests/test-manual-integration-docs-766.py @@ -0,0 +1,454 @@ +"""Manual setup parity with actual constructors, isolated files and stdlib only. + +No TrueMemory package, native host, hook script, model or database is loaded. +Fixture directories are retained, including any failed installation output. +Hermes's YAML codec is stubbed with JSON (valid YAML); its documented YAML is +parsed separately with a deliberately restricted, fail-closed subset parser. +""" +from __future__ import annotations + +import ast +import copy +import io +import json +import os +import re +import shlex +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path, PureWindowsPath +from typing import Protocol, cast +from unittest.mock import patch + +try: + import tomllib +except ModuleNotFoundError: + tomllib = None + + +ROOT = Path(__file__).resolve().parents[1] +EVENTS: dict[str, dict[str, tuple[str, int]]] = { + "codex": {"SessionStart": ("session_start.py", 10), "Stop": ("stop.py", 5), + "UserPromptSubmit": ("user_prompt_submit.py", 5), "PreCompact": ("compact.py", 5)}, + "cursor": {"sessionStart": ("session_start.py", 10), "stop": ("stop.py", 5), + "beforeSubmitPrompt": ("user_prompt_submit.py", 5), "preCompact": ("compact.py", 5)}, + "gemini": {"SessionStart": ("session_start.py", 10000), "SessionEnd": ("stop.py", 5000), + "BeforeAgent": ("user_prompt_submit.py", 5000), "PreCompress": ("compact.py", 5000)}, + "kimi": {"SessionStart": ("session_start.py", 10), "Stop": ("stop.py", 5), + "UserPromptSubmit": ("user_prompt_submit.py", 5), "PreCompact": ("compact.py", 5)}, + "hermes": {"on_session_start": ("session_start.py", 30), "on_session_end": ("stop.py", 30), + "pre_llm_call": ("user_prompt_submit.py", 10), "on_session_finalize": ("compact.py", 15)}, +} +CLASS_NAMES = {"codex": "CodexAdapter", "cursor": "CursorAdapter", "gemini": "GeminiAdapter", + "kimi": "KimiAdapter", "hermes": "HermesAdapter", "openclaw": "OpenClawAdapter"} + + +class Adapter(Protocol): + def install_mcp(self, python_path: str | None = None) -> None: ... + def install_hooks(self, python_path: str | None = None, user_id: str = "", db_path: str = "") -> None: ... + def detect(self) -> bool: ... + + +def fenced(text: str, language: str) -> list[str]: + return re.findall(rf"^```{re.escape(language)}\n(.*?)^```", text, flags=re.MULTILINE | re.DOTALL) + + +def toml_subset(text: str) -> dict[str, object]: + """Parse the documented table/array-table and JSON-compatible scalar subset.""" + result: dict[str, object] = {} + current = result + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("["): + array_table = line.startswith("[[") + bracket = "[[" if array_table else "[" + closing = "]]" if array_table else "]" + if not line.endswith(closing): + raise ValueError(f"Invalid TOML section: {line}") + names = line[len(bracket):-len(closing)].split(".") + if not all(re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name) for name in names): + raise ValueError(f"Unsupported TOML section: {line}") + current = result + for index, name in enumerate(names): + if index == len(names) - 1 and array_table: + collection = current.setdefault(name, []) + if not isinstance(collection, list): + raise ValueError(f"Conflicting TOML collection: {line}") + entry: dict[str, object] = {} + collection.append(entry) + current = entry + else: + child = current.setdefault(name, {}) + if isinstance(child, list) and child: + child = child[-1] + if not isinstance(child, dict): + raise ValueError(f"Conflicting TOML section: {line}") + current = child + else: + match = re.fullmatch(r"([A-Za-z_][A-Za-z_0-9]*)\s*=\s*(.+)", line) + if match is None or match.group(1) in current: + raise ValueError(f"Unsupported or duplicate TOML field: {line}") + current[match.group(1)] = json.loads(match.group(2)) + return result + + +def parse_toml(text: str) -> dict[str, object]: + return tomllib.loads(text) if tomllib is not None else toml_subset(text) + + +def yaml_subset(text: str) -> dict[str, object]: + """Parse only indentation maps/lists and scalars used by the Hermes guide.""" + if "\t" in text: + raise ValueError("YAML tabs are unsupported") + rows = [(len(line) - len(line.lstrip()), line.strip()) + for line in text.splitlines() if line.strip()] + + def scalar(value: str) -> object: + if re.fullmatch(r"[0-9]+", value): + return int(value) + if value.startswith('"'): + decoded = json.loads(value) + if not isinstance(decoded, str): + raise ValueError(f"Expected YAML string: {value!r}") + return decoded + if not value or any(token in value for token in ("&", "*", "{", "[", "#", "\"", "'")): + raise ValueError(f"Unsupported YAML scalar: {value!r}") + return value + + def pair(value: str) -> tuple[str, str]: + match = re.fullmatch(r"([A-Za-z_][A-Za-z_0-9]*):(?: (.*))?", value) + if match is None: + raise ValueError(f"Unsupported YAML mapping: {value!r}") + return match.group(1), match.group(2) or "" + + def block(index: int, indent: int) -> tuple[object, int]: + is_list = rows[index][1].startswith("- ") + result: object = [] if is_list else {} + while index < len(rows) and rows[index][0] == indent: + line = rows[index][1] + if is_list: + if not line.startswith("- "): + raise ValueError("Mixed YAML collection") + item = line[2:] + if re.match(r"[A-Za-z_][A-Za-z_0-9]*:", item): + key, value = pair(item) + entry = {key: scalar(value)} + index += 1 + if index < len(rows) and rows[index][0] > indent: + extra, index = block(index, rows[index][0]) + if not isinstance(extra, dict) or key in extra: + raise ValueError("Invalid YAML list mapping") + entry.update(extra) + cast(list[object], result).append(entry) + else: + cast(list[object], result).append(scalar(item)) + index += 1 + else: + key, value = pair(line) + target = cast(dict[str, object], result) + if key in target: + raise ValueError(f"Duplicate YAML key: {key}") + index += 1 + if value: + target[key] = scalar(value) + elif index < len(rows) and rows[index][0] > indent: + target[key], index = block(index, rows[index][0]) + else: + raise ValueError(f"Missing YAML value: {key}") + return result, index + + if not rows or rows[0][0] != 0: + raise ValueError("Expected root YAML map") + result, consumed = block(0, 0) + if consumed != len(rows) or not isinstance(result, dict): + raise ValueError("Unsupported YAML structure") + return result + + +def merge(base: dict[str, object], additions: dict[str, object]) -> dict[str, object]: + result = copy.deepcopy(base) + for key, value in additions.items(): + current = result.get(key) + if isinstance(value, dict) and isinstance(current, dict): + result[key] = merge(current, value) + elif isinstance(value, list) and isinstance(current, list): + result[key] = current + copy.deepcopy(value) + else: + result[key] = copy.deepcopy(value) + return result + + +def load_adapter(source: Path, home: Path) -> Adapter: + """Execute actual stdlib-only adapter source with the shared base stubbed.""" + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + tree.body = [node for node in tree.body + if not (isinstance(node, ast.ImportFrom) + and (node.module or "").startswith("truememory"))] + base_source = ROOT / "truememory/hooks/adapters/base.py" + base_tree = ast.parse(base_source.read_text(encoding="utf-8")) + shared_nodes = [node for node in base_tree.body + if isinstance(node, ast.FunctionDef) + and node.name in {"atomic_write_text", "is_owned_hook_command"}] + namespace: dict[str, object] = {"Path": Path, "os": os, "re": re, "shlex": shlex, + "sys": sys, "CLIAdapter": object, + "__file__": str(source), "__name__": "synthetic_docs766"} + exec(compile(ast.Module(body=shared_nodes, type_ignores=[]), str(base_source), "exec"), namespace) + with patch("pathlib.Path.home", return_value=home): + exec(compile(tree, str(source), "exec"), namespace) + if source.stem == "hermes": + namespace["_yaml_safe_load"] = json.loads + namespace["_yaml_safe_dump"] = json.dumps + adapter_class = cast(type[Adapter], namespace[CLASS_NAMES[source.stem]]) + return adapter_class() + + +def command_tokens(command: str) -> list[str]: + parts = shlex.split(command, posix=sys.platform != "win32") + if sys.platform == "win32": + return [part[1:-1] if part.startswith('"') and part.endswith('"') else part + for part in parts] + return parts + + +def canonical(value: object) -> object: + if isinstance(value, list): + return [canonical(item) for item in value] + if isinstance(value, dict): + result = {key: canonical(item) for key, item in value.items()} + command = value.get("command") + if isinstance(command, str) and "ingest/hooks/" in command.replace("\\", "/"): + parts = command_tokens(command) + script = PureWindowsPath(parts[1]) if sys.platform == "win32" else Path(parts[1]) + result["command"] = ["PYTHON", script.name, *parts[2:]] + return result + return value + + +class ManualSetupParityTests(unittest.TestCase): + def setUp(self) -> None: + self.case = Path(tempfile.mkdtemp(prefix="truememory-manual-docs-766-")) + self.home = self.case / "adapter-home" + self.home.mkdir() + self.python = self.case / "bin/python" + self.python.parent.mkdir() + self.python.write_text("#!/bin/sh\nexit 71\n", encoding="utf-8") + self.python.chmod(0o700) + + def document(self, host: str) -> str: + text = (ROOT / f"docs/setup-{host}.md").read_text(encoding="utf-8") + + def substitute(value: str) -> str: + if value == "/path/to/python": + return str(self.python) + if value.startswith("/path/to/python "): + parts = shlex.split(value) + parts = [str(self.python) if part == "/path/to/python" else + str(ROOT / "truememory" / part.removeprefix("/path/to/truememory/")) + if part.startswith("/path/to/truememory/") else part for part in parts] + return (subprocess.list2cmdline(parts) if sys.platform == "win32" else + " ".join(shlex.quote(part) for part in parts)) + return value + + def config_block(match: re.Match[str]) -> str: + language, content = match.group(1), match.group(2) + if language == "yaml": + content = re.sub(r"(?m)^(\s*(?:- )?command: )(/path/to/python[^\n]*)$", + lambda line: line.group(1) + json.dumps(substitute(line.group(2))), + content) + else: + content = re.sub(r'"(?:\\.|[^"\\])*"', + lambda scalar: json.dumps(substitute(json.loads(scalar.group()))), + content) + return f"```{language}\n{content}```" + + return re.sub(r"^```(json|toml|yaml)\n(.*?)^```", config_block, text, + flags=re.MULTILINE | re.DOTALL) + + def install(self, host: str, paths: dict[str, str]) -> Adapter: + for relative, text in paths.items(): + target = self.home / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + executable = self.python.parent / host + executable.write_text("#!/bin/sh\nexit 71\n", encoding="utf-8") + executable.chmod(0o700) + adapter = load_adapter(ROOT / f"truememory/hooks/adapters/{host}.py", self.home) + with patch("shutil.which", return_value=str(executable)): + self.assertTrue(adapter.detect()) + adapter.install_mcp(str(self.python)) + adapter.install_hooks(str(self.python)) + return adapter + + def assert_contract(self, host: str, additions: dict[str, object]) -> None: + hooks = additions["hooks"] + if host == "kimi": + self.assertIsInstance(hooks, list) + entries = {entry["event"]: entry for entry in cast(list[dict[str, object]], hooks)} + else: + self.assertIsInstance(hooks, dict) + entries = {} + for event, groups in cast(dict[str, list[dict[str, object]]], hooks).items(): + self.assertEqual(len(groups), 1) + entry = groups[0] + if host in {"codex", "gemini"}: + self.assertEqual(set(entry), {"hooks"}) + self.assertEqual(len(cast(list[object], entry["hooks"])), 1) + entry = cast(list[dict[str, object]], entry["hooks"])[0] + self.assertEqual(entry["type"], "command") + entries[event] = entry + self.assertEqual(set(entries), set(EVENTS[host])) + for event, (script, timeout) in EVENTS[host].items(): + entry = entries[event] + self.assertEqual(entry["timeout"], timeout) + tokens = command_tokens(cast(str, entry["command"])) + self.assertEqual(tokens[:2], [str(self.python), str(ROOT / "truememory/ingest/hooks" / script)]) + if host in {"codex", "cursor", "gemini"}: + self.assertEqual(tokens[2:], ["--host", host, "--event", event]) + else: + self.assertEqual(tokens[2:], []) + + def assert_preserved(self, host: str, additions: dict[str, object], generated: dict[str, object], + seed: dict[str, object]) -> None: + self.assert_contract(host, additions) + expected = merge(seed, additions) + manual = self.case / "manual-config.json" + manual.write_text(json.dumps(expected, indent=2), encoding="utf-8") + self.assertEqual(canonical(json.loads(manual.read_text())), canonical(generated)) + self.assertEqual(generated["unrelated"], seed["unrelated"]) + + def test_codex_nested_toml_groups_seconds_and_identity(self) -> None: + additions = parse_toml(fenced(self.document("codex"), "toml")[0]) + seed_text = ('unrelated = "keep"\n[mcp_servers.other]\ncommand = "/usr/bin/printf"\n' + '[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\n' + 'command = "/usr/bin/printf synthetic-other"\ntimeout = 37\n') + self.install("codex", {".codex/config.toml": seed_text}) + generated = parse_toml((self.home / ".codex/config.toml").read_text()) + self.assert_preserved("codex", additions, generated, parse_toml(seed_text)) + + def test_cursor_separate_json_seconds_and_identity(self) -> None: + mcp, hooks = [json.loads(block) for block in fenced(self.document("cursor"), "json")] + self.assertEqual(hooks["version"], 1) + mcp_seed = {"unrelated": "keep", "mcpServers": {"other": {"command": "/usr/bin/printf"}}} + hook_seed = {"unrelated": "keep", "version": 1, "hooks": { + "sessionStart": [{"command": "/usr/bin/printf synthetic-other", "timeout": 37}]}} + self.install("cursor", {".cursor/mcp.json": json.dumps(mcp_seed), + ".cursor/hooks.json": json.dumps(hook_seed)}) + self.assertEqual(json.loads((self.home / ".cursor/mcp.json").read_text()), merge(mcp_seed, mcp)) + self.assert_preserved("cursor", hooks, json.loads((self.home / ".cursor/hooks.json").read_text()), + hook_seed) + text = self.document("cursor") + self.assertIn("does not inject per-prompt recall", text) + self.assertIn("additional_context", text) + + def test_gemini_nested_json_milliseconds_and_identity(self) -> None: + additions = json.loads(fenced(self.document("gemini"), "json")[0]) + seed = {"unrelated": "keep", "mcpServers": {"other": {"command": "/usr/bin/printf"}}, + "hooks": {"SessionStart": [{"hooks": [{"type": "command", "name": "other", + "command": "/usr/bin/printf synthetic-other", "timeout": 37}]}]}} + self.install("gemini", {".gemini/settings.json": json.dumps(seed)}) + self.assert_preserved("gemini", additions, + json.loads((self.home / ".gemini/settings.json").read_text()), seed) + + def test_kimi_flat_toml_seconds_and_legacy_home(self) -> None: + document = self.document("kimi") + mcp = json.loads(fenced(document, "json")[0]) + additions = parse_toml(fenced(document, "toml")[0]) + seed_text = ('unrelated = "keep"\n[[hooks]]\nevent = "SessionStart"\n' + 'command = "/usr/bin/printf synthetic-other"\ntimeout = 37\n') + mcp_seed = {"unrelated": "keep", "mcpServers": {"other": {"command": "/usr/bin/printf"}}} + self.install("kimi", {".kimi/config.toml": seed_text, ".kimi/mcp.json": json.dumps(mcp_seed)}) + self.assertEqual(json.loads((self.home / ".kimi/mcp.json").read_text()), merge(mcp_seed, mcp)) + self.assert_preserved("kimi", additions, + parse_toml((self.home / ".kimi/config.toml").read_text()), + parse_toml(seed_text)) + self.assertIn("~/.kimi-code/", document) + self.assertIn("unverified", document) + + def test_hermes_same_yaml_file_hooks_map_seconds(self) -> None: + document = self.document("hermes") + mcp, hooks = [yaml_subset(block) for block in fenced(document, "yaml")] + additions = merge(mcp, hooks) + seed = {"unrelated": "keep", "mcp_servers": {"other": {"command": "/usr/bin/printf"}}, + "hooks": {"on_session_start": [{"command": "/usr/bin/printf synthetic-other", + "timeout": 37}]}} + self.install("hermes", {".hermes/config.yaml": json.dumps(seed)}) + self.assert_preserved("hermes", additions, + json.loads((self.home / ".hermes/config.yaml").read_text()), seed) + self.assertFalse((self.home / ".hermes/cli-config.yaml").exists()) + self.assertNotIn("cli-config.yaml", document) + self.assertIn("continuations and retries", document) + + def test_openclaw_copy_package_discovery_and_known_adapter_divergence(self) -> None: + document = self.document("openclaw") + additions = json.loads(fenced(document, "json")[0]) + seed = {"unrelated": "keep", "mcp": {"servers": {"other": {"command": "/usr/bin/printf"}}}, + "plugins": {"allow": ["other"], "entries": {"other": {"enabled": False}}}} + self.install("openclaw", {".openclaw/openclaw.json": json.dumps(seed)}) + generated = json.loads((self.home / ".openclaw/openclaw.json").read_text()) + self.assertEqual(generated, merge(seed, additions)) + shell = next(block for block in fenced(document, "bash") if "<<'PY'" in block) + snippet = shell.split("\n", 1)[1].rsplit("\nPY", 1)[0] + tree = ast.parse(snippet) + for node in tree.body: + if isinstance(node, ast.Import): + node.names = [alias for alias in node.names if alias.name != "truememory"] + tree.body = [node for node in tree.body if not isinstance(node, ast.Import) or node.names] + namespace: dict[str, object] = {"truememory": type("SyntheticPackage", (), { + "__file__": str(ROOT / "truememory/__init__.py")})} + manual_home = self.case / "manual-home" + with patch("pathlib.Path.home", return_value=manual_home), redirect_stdout(io.StringIO()): + exec(compile(tree, "documented-openclaw-copy", "exec"), namespace) + package = manual_home / ".openclaw/extensions/truememory" + files = {"openclaw.plugin.json", "package.json", "index.js"} + self.assertEqual({path.name for path in package.iterdir()}, files) + manifest = json.loads((package / "openclaw.plugin.json").read_text()) + metadata = json.loads((package / "package.json").read_text()) + self.assertEqual(manifest["id"], "truememory") + self.assertEqual(metadata["type"], "module") + self.assertEqual(metadata["openclaw"]["extensions"], ["./index.js"]) + template = ROOT / "truememory/hooks/templates/openclaw" + for filename in files: + self.assertEqual((package / filename).read_bytes(), (template / filename).read_bytes()) + self.assertTrue((self.home / ".openclaw/plugins/truememory" / filename).is_file()) + before = {name: (package / name).read_bytes() for name in files} + with patch("pathlib.Path.home", return_value=manual_home), self.assertRaises(FileExistsError): + exec(compile(tree, "documented-openclaw-copy", "exec"), namespace) + self.assertEqual(before, {name: (package / name).read_bytes() for name in files}) + self.assertIn("adapter path and plugin runtime remain tracked", document) + self.assertIn("does not verify the manual", document) + + def test_compatibility_distinguishes_config_from_native_acceptance(self) -> None: + text = (ROOT / "docs/compatibility.md").read_text(encoding="utf-8") + self.assertIn("Native tested versions", text) + self.assertIn("none in this parity validation", text) + self.assertIn("per-prompt recall injection is unsupported", text) + self.assertIn("10000 / 60 = 166.67", text) + self.assertIn("5000 / 60 = 83.33", text) + self.assertIn("10000 / 1000 = 10", text) + self.assertNotIn("~/.hermes/cli-config.yaml", text) + self.assertNotIn("before_agent_run", text) + self.assertNotIn("agent_end", text) + + def test_restricted_parsers_reject_obsolete_or_conflicting_structures(self) -> None: + for host in ("codex", "kimi"): + snippet = fenced(self.document(host), "toml")[0] + self.assertEqual(toml_subset(snippet), parse_toml(snippet)) + with self.assertRaises(AssertionError): + self.assert_contract("codex", {"hooks": [{"event": "SessionStart"}]}) + with self.assertRaises(AssertionError): + self.assert_contract("gemini", {"hooks": {"SessionStart": [{"command": "old", "timeout": 10000}]}}) + with self.assertRaises(ValueError): + toml_subset('hooks = 4\n[[hooks.SessionStart]]\n') + with self.assertRaises(ValueError): + yaml_subset("hooks:\n on_session_start:\n - command: one\n command: two\n") + with self.assertRaises(ValueError): + yaml_subset("hooks:\n on_session_start: *anchor\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/truememory/hooks/adapters/kimi.py b/truememory/hooks/adapters/kimi.py index 1b25a0c5..7470cf48 100644 --- a/truememory/hooks/adapters/kimi.py +++ b/truememory/hooks/adapters/kimi.py @@ -124,11 +124,15 @@ def install_hooks( script_path = hooks_dir / info["script"] cmd = self._build_command(py, script_path, user_id, db_path) + toml_command = json.dumps(cmd, ensure_ascii=False) + # TOML forbids DEL; splitlines also treats these Unicode characters as boundaries. + for codepoint in (0x7F, 0x85, 0x2028, 0x2029): + toml_command = toml_command.replace(chr(codepoint), f"\\u{codepoint:04x}") lines_to_append.append("") lines_to_append.append("[[hooks]]") lines_to_append.append(f'event = "{event}"') - lines_to_append.append(f'command = "{cmd}"') + lines_to_append.append(f"command = {toml_command}") lines_to_append.append(f'timeout = {info["timeout"]}') if lines_to_append: @@ -190,10 +194,15 @@ def _parse_existing_hooks(toml_text: str) -> list[dict]: line = line.strip() if not line or line.startswith("["): break - m = re.match(r'(\w+)\s*=\s*"([^"]*)"', line) + m = re.match(r'(\w+)\s*=\s*("(?:\\.|[^"\\])*")\s*(?:#.*)?$', line) if m: - entry[m.group(1)] = m.group(2) + try: + entry[m.group(1)] = json.loads(m.group(2)) + except json.JSONDecodeError: + return [] else: + if re.match(r'\w+\s*=\s*"', line): + return [] m = re.match(r'(\w+)\s*=\s*(\d+)', line) if m: entry[m.group(1)] = m.group(2)