diff --git a/.npmignore b/.npmignore index 6a9a7e99..b16bae80 100644 --- a/.npmignore +++ b/.npmignore @@ -6,7 +6,6 @@ __tests__/ # 开发文档与辅助 -docs/ benchmark-runs/ workspace/ @@ -19,4 +18,6 @@ workspace/ # 运行时产物 node_modules/ -*.tgz \ No newline at end of file +**/__pycache__/ +*.py[cod] +*.tgz diff --git a/README.md b/README.md index 22e61944..4b960ae0 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,10 @@ Debugging no longer means probing an opaque database — it becomes a determinis | Document | Contents | | :--- | :--- | +| [`docs/coding-agent-adapter-quickstart.md`](./docs/coding-agent-adapter-quickstart.md) | Generic Gateway client and coding-agent lifecycle mapping | +| [`docs/claude-code-adapter-setup.md`](./docs/claude-code-adapter-setup.md) | Install and configure the Claude Code hook adapter | +| [`docs/hermes-adapter-setup.md`](./docs/hermes-adapter-setup.md) | Hermes Provider and Gateway setup | +| [`docs/platform-adapter-comparison.md`](./docs/platform-adapter-comparison.md) | Cross-platform adapter architecture and trade-offs | | [`scripts/README.memory-tencentdb-ctl.md`](./scripts/README.memory-tencentdb-ctl.md) | Operations & management tooling | | [`CHANGELOG.md`](./CHANGELOG.md) | Release notes and version history | | [`openclaw.plugin.json`](./openclaw.plugin.json) | OpenClaw plugin manifest and configuration schema | diff --git a/README_CN.md b/README_CN.md index 6788f193..28ff28fb 100644 --- a/README_CN.md +++ b/README_CN.md @@ -507,6 +507,10 @@ export MEMORY_TENCENTDB_GATEWAY_API_KEY="<与 Gateway 同一份密钥>" | 文档 | 内容 | | :--- | :--- | +| [`docs/coding-agent-adapter-quickstart.md`](./docs/coding-agent-adapter-quickstart.md) | 通用 Gateway 客户端与 Coding Agent 生命周期映射 | +| [`docs/claude-code-adapter-setup.md`](./docs/claude-code-adapter-setup.md) | Claude Code Hook 适配器安装与配置 | +| [`docs/hermes-adapter-setup.md`](./docs/hermes-adapter-setup.md) | Hermes Provider 与 Gateway 配置 | +| [`docs/platform-adapter-comparison.md`](./docs/platform-adapter-comparison.md) | 跨平台适配架构与差异对比 | | [`scripts/README.memory-tencentdb-ctl.md`](./scripts/README.memory-tencentdb-ctl.md) | 运维管理工具说明 | | [`CHANGELOG.md`](./CHANGELOG.md) | 版本变更记录 | | [`openclaw.plugin.json`](./openclaw.plugin.json) | OpenClaw 插件声明与配置 Schema | diff --git a/docs/claude-code-adapter-setup.md b/docs/claude-code-adapter-setup.md new file mode 100644 index 00000000..919cd3be --- /dev/null +++ b/docs/claude-code-adapter-setup.md @@ -0,0 +1,161 @@ +# Claude Code Adapter Setup + +This guide shows how to connect Claude Code to TencentDB Agent Memory through +Claude Code hooks and the existing TDAI Gateway. + +## Data Flow + +```mermaid +flowchart LR + Claude["Claude Code"] + Hook["Claude Code hook"] + Adapter["handleClaudeCodeHook"] + Gateway["TDAI Gateway"] + Core["TdaiCore"] + Store["L0/L1/L2/L3 storage"] + + Claude --> Hook + Hook --> Adapter + Adapter --> Gateway + Gateway --> Core + Core --> Store +``` + +## Hook Mapping + +| Claude Code hook | Adapter action | Gateway endpoint | +| --- | --- | --- | +| `UserPromptSubmit` | Recall memory and inject additional prompt context | `POST /recall` | +| `Stop` | Pair the transcript's latest user prompt with `last_assistant_message` | `POST /capture` | +| `SessionStart` | Health-check the Gateway | `GET /health` | +| `SessionEnd` | Flush pending work without capturing the last turn again | `POST /session/end` | + +## Gateway Configuration + +Start the Gateway before launching Claude Code: + +```bash +cd /path/to/TencentDB-Agent-Memory +TDAI_GATEWAY_HOST="127.0.0.1" \ +TDAI_GATEWAY_PORT="8420" \ +TDAI_LLM_API_KEY="sk-your-api-key" \ +TDAI_LLM_BASE_URL="https://api.deepseek.com/v1" \ +TDAI_LLM_MODEL="deepseek-v4-pro" \ +TDAI_LLM_DISABLE_THINKING="deepseek" \ +npx tsx src/gateway/server.ts +``` + +For DeepSeek, use the OpenAI-compatible `/v1` endpoint for the Gateway. Claude +Code may use DeepSeek's Anthropic-compatible `/anthropic` endpoint, but the +Gateway's standalone runner calls OpenAI-compatible chat completions. + +## Install the Hook Command + +Install the package globally so Claude Code can resolve the hook command: + +```bash +npm install --global @tencentdb-agent-memory/memory-tencentdb +``` + +For a source checkout, build the standalone entry and use its absolute path in +the settings below: + +```bash +npm install +npm run build:plugin +node /absolute/path/to/TencentDB-Agent-Memory/dist/memory-tencentdb-claude-hook.mjs +``` + +## Claude Code Settings + +Add hooks to `~/.claude/settings.json` or a project-level Claude Code settings +file. Hook commands inherit the environment of the `claude` process, so export +`TDAI_GATEWAY_URL` and, when enabled, `TDAI_GATEWAY_API_KEY` before starting +Claude Code. + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "memory-tencentdb-claude-hook", + "timeout": 15 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "memory-tencentdb-claude-hook", + "timeout": 15 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "memory-tencentdb-claude-hook", + "timeout": 15 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "memory-tencentdb-claude-hook", + "timeout": 15 + } + ] + } + ] + } +} +``` + +Claude Code normally gives `SessionEnd` hooks only a short shutdown budget. +The explicit timeout above gives the Gateway's session flush enough time while +the adapter's HTTP timeout remains bounded by `TDAI_GATEWAY_TIMEOUT_MS` +(default: 10000). + +The adapter keys memory by Claude's stable `session_id`, so changing directories +inside one Claude session does not split recall, capture, and flush across +different Gateway sessions. + +## Smoke Test + +Simulate a `UserPromptSubmit` hook: + +```bash +printf '%s\n' '{"hook_event_name":"UserPromptSubmit","session_id":"demo","cwd":"'$PWD'","prompt":"What should I remember?"}' \ + | TDAI_GATEWAY_URL=http://127.0.0.1:8420 memory-tencentdb-claude-hook +``` + +The hook prints JSON with `hookSpecificOutput.additionalContext` when recall +returns non-empty context. Empty recall exits successfully with no output. + +At `Stop`, Claude Code supplies the final response in +`last_assistant_message`. The adapter uses that field because the JSONL +transcript is written asynchronously, and reads the transcript only to find the +corresponding human prompt. Tool-result rows and meta/sidechain rows are not +captured as user messages. Transcript timestamps are forwarded to the Gateway, +allowing its atomic checkpoint to ignore exact hook retries. Gateway errors are +written to stderr for diagnostics but always return exit code 0 so memory cannot +block Claude Code. + +The hook provides automatic recall and capture only. It removes the core's +`memory-tools-guide` block because this adapter does not register +`tdai_memory_search` or `tdai_conversation_search` as Claude Code tools. Hosts +that expose those searches separately can use `CodingAgentGatewayClient`'s +`searchMemories()` and `searchConversations()` methods. diff --git a/docs/coding-agent-adapter-quickstart.md b/docs/coding-agent-adapter-quickstart.md new file mode 100644 index 00000000..f2ed7456 --- /dev/null +++ b/docs/coding-agent-adapter-quickstart.md @@ -0,0 +1,89 @@ +# Coding Agent Adapter Quickstart + +This guide shows the smallest useful adapter shape for coding-agent hosts such +as Codex, Claude Code, Cursor, Continue, or other tools that can call a local +HTTP sidecar. + +The adapter talks to the existing TDAI Gateway. It does not embed `TdaiCore` +inside the host process, so each platform only needs to map its own session and +message events into Gateway requests. + +## Architecture + +```mermaid +flowchart LR + Host["Coding-agent host"] --> Adapter["CodingAgentGatewayClient"] + Adapter --> Gateway["TDAI Gateway HTTP API"] + Gateway --> Core["TdaiCore"] + Core --> Store["L0/L1/L2/L3 storage"] +``` + +## Minimal usage + +```ts +import { CodingAgentGatewayClient } from "@tencentdb-agent-memory/memory-tencentdb"; + +const memory = new CodingAgentGatewayClient({ + baseUrl: process.env.TDAI_GATEWAY_URL ?? "http://127.0.0.1:8420", + apiKey: process.env.TDAI_GATEWAY_API_KEY, +}); + +const sessionKey = `${workspacePath}:${threadId}`; + +const recall = await memory.recall({ + query: userPrompt, + sessionKey, + userId, +}); + +const recalledContext = [ + recall.prepend_context, + recall.append_system_context ?? recall.context, +].filter(Boolean).join("\n\n"); + +const promptWithMemory = recalledContext + ? `${recalledContext}\n\n${userPrompt}` + : userPrompt; + +await memory.capture({ + userContent: userPrompt, + assistantContent: assistantReply, + sessionKey, + sessionId: threadId, + userId, + // Preserve host timestamps when available so retries are checkpoint-idempotent. + messages: hostMessages, + startedAt: turnStartedAt, +}); +``` + +## Event mapping + +| Host event | Gateway call | Required fields | +| --- | --- | --- | +| Before prompt/model call | `recall()` | `query`, `sessionKey` | +| After assistant response | `capture()` | `userContent`, `assistantContent`, `sessionKey` | +| User searches memory | `searchMemories()` | `query` | +| User searches raw turns | `searchConversations()` | `query` | +| Thread/workspace closes | `endSession()` | `sessionKey` | + +## Session key guidance + +Use a stable key that isolates unrelated work while still allowing continuity +inside one project. Good candidates: + +- `workspace:` +- `repo:#` +- `thread:` +- `workspace::thread:` when the host supports multiple concurrent + conversations per workspace + +## Validation checklist + +- `health()` returns `status: "ok"` or `status: "degraded"`. +- A `capture()` call records at least one L0 turn. +- A later `recall()` call returns dynamic L1 content in `prepend_context` and + stable persona/scene content in `append_system_context` after the pipeline + has processed the turn. `context` remains the legacy stable-context field. +- If `TDAI_GATEWAY_API_KEY` is enabled on the Gateway, the adapter passes the + same key as a Bearer token. diff --git a/docs/hermes-adapter-setup.md b/docs/hermes-adapter-setup.md new file mode 100644 index 00000000..1f1bb2cb --- /dev/null +++ b/docs/hermes-adapter-setup.md @@ -0,0 +1,144 @@ +# Hermes Adapter Setup + +This guide is the Hermes-specific path for the platform-adapter work in issue +#235. Hermes uses the existing Python `memory_tencentdb` provider and the Node +TDAI Gateway sidecar. + +## Data Flow + +```mermaid +flowchart LR + Hermes["Hermes Agent"] + Provider["memory_tencentdb provider"] + Supervisor["GatewaySupervisor"] + Client["MemoryTencentdbSdkClient"] + Gateway["TDAI Gateway"] + Core["TdaiCore"] + Store["L0/L1/L2/L3 storage"] + + Hermes --> Provider + Provider --> Supervisor + Provider --> Client + Supervisor --> Gateway + Client --> Gateway + Gateway --> Core + Core --> Store +``` + +## Enable the Provider + +The provider directory must be named `memory_tencentdb`. + +```bash +rm -rf ~/.hermes/hermes-agent/plugins/memory/memory_tencentdb +ln -sf ~/.memory-tencentdb/tdai-memory-openclaw-plugin/hermes-plugin/memory/memory_tencentdb \ + ~/.hermes/hermes-agent/plugins/memory/memory_tencentdb +``` + +Then enable it in `~/.hermes/config.yaml`: + +```yaml +memory: + provider: memory_tencentdb +``` + +## Configure the Gateway + +For a developer checkout, the explicit command is the most predictable option: + +```bash +export MEMORY_TENCENTDB_GATEWAY_CMD="sh -c 'cd ~/.memory-tencentdb/tdai-memory-openclaw-plugin && exec npx tsx src/gateway/server.ts'" +export MEMORY_TENCENTDB_GATEWAY_HOST="127.0.0.1" +export MEMORY_TENCENTDB_GATEWAY_PORT="8420" +``` + +The provider also supports zero-config auto-discovery when the checkout lives +under one of the documented default locations. + +## Configure LLM Credentials + +Hermes-facing config can use the provider names: + +```bash +export MEMORY_TENCENTDB_LLM_API_KEY="sk-your-api-key" +export MEMORY_TENCENTDB_LLM_BASE_URL="https://api.openai.com/v1" +export MEMORY_TENCENTDB_LLM_MODEL="gpt-4o" +``` + +When the Hermes provider starts the Gateway, `GatewaySupervisor` bridges these +variables into the Gateway names: + +```bash +TDAI_LLM_API_KEY +TDAI_LLM_BASE_URL +TDAI_LLM_MODEL +``` + +If both names are set, the explicit `TDAI_LLM_*` value wins. + +For a Gateway started by the provider, the supervisor also mirrors its resolved +host and port into `TDAI_GATEWAY_HOST` and `TDAI_GATEWAY_PORT`. The Node Gateway +therefore listens on the same endpoint that the Hermes client probes, including +when `MEMORY_TENCENTDB_GATEWAY_HOST` or +`MEMORY_TENCENTDB_GATEWAY_PORT` uses a non-default value. + +### DeepSeek Example + +Claude Code may be configured with DeepSeek's Anthropic-compatible endpoint: + +```bash +ANTHROPIC_BASE_URL="https://api.deepseek.com/anthropic" +ANTHROPIC_MODEL="deepseek-v4-pro[1M]" +``` + +The TDAI Gateway uses OpenAI-compatible chat completions, so configure Hermes +memory with the DeepSeek `/v1` endpoint and a Gateway-supported model name: + +```bash +export MEMORY_TENCENTDB_LLM_API_KEY="$ANTHROPIC_AUTH_TOKEN" +export MEMORY_TENCENTDB_LLM_BASE_URL="https://api.deepseek.com/v1" +export MEMORY_TENCENTDB_LLM_MODEL="deepseek-v4-pro" +export TDAI_LLM_DISABLE_THINKING="deepseek" +``` + +When using `TDAI_LLM_*` directly, use the same values: + +```bash +export TDAI_LLM_API_KEY="$ANTHROPIC_AUTH_TOKEN" +export TDAI_LLM_BASE_URL="https://api.deepseek.com/v1" +export TDAI_LLM_MODEL="deepseek-v4-pro" +export TDAI_LLM_DISABLE_THINKING="deepseek" +``` + +## Lifecycle Mapping + +| Hermes call | Gateway endpoint | Result | +| --- | --- | --- | +| `prefetch(query)` | `POST /recall` | Combines dynamic L1 and stable persona/scene context for prompt injection | +| `sync_turn(user, assistant)` | `POST /capture` | Records the turn and notifies the pipeline | +| `handle_tool_call(memory_tencentdb_memory_search)` | `POST /search/memories` | Searches L1 structured memories | +| `handle_tool_call(memory_tencentdb_conversation_search)` | `POST /search/conversations` | Searches L0 conversation history | +| `shutdown()` / session end | `POST /session/end` | Flushes pending session work | + +## Smoke Test + +Start the Gateway manually: + +```bash +cd ~/.memory-tencentdb/tdai-memory-openclaw-plugin +TDAI_LLM_API_KEY="sk-your-api-key" \ +TDAI_LLM_BASE_URL="https://api.deepseek.com/v1" \ +TDAI_LLM_MODEL="deepseek-v4-pro" \ +TDAI_LLM_DISABLE_THINKING="deepseek" \ +npx tsx src/gateway/server.ts +``` + +Verify health: + +```bash +curl http://127.0.0.1:8420/health +``` + +Then launch Hermes with the provider enabled. A successful first conversation +should call `prefetch()` before the model turn and `sync_turn()` after the +assistant response. diff --git a/docs/platform-adapter-comparison.md b/docs/platform-adapter-comparison.md new file mode 100644 index 00000000..40c2c896 --- /dev/null +++ b/docs/platform-adapter-comparison.md @@ -0,0 +1,65 @@ +# Platform Adapter Comparison + +This document compares the platform adapter shapes relevant to issue #235. +All paths reuse the same `TdaiCore` capability boundary: recall, capture, +memory search, conversation search, and session flush. + +## Summary + +| Platform | Adapter shape | Runtime boundary | Recall timing | Capture timing | Status | +| --- | --- | --- | --- | --- | --- | +| OpenClaw | In-process plugin | `index.ts` calls `TdaiCore` directly | `before_prompt_build` | `agent_end` / committed turn | Existing | +| Hermes | Python provider + Node Gateway | HTTP sidecar | `prefetch(query)` -> `/recall` | `sync_turn()` -> `/capture` | Existing, documented | +| Claude Code | Hook adapter + Node Gateway | Hook command over stdio, then HTTP | `UserPromptSubmit` -> `/recall` | `Stop` -> `/capture`; `SessionEnd` -> `/session/end` | Added as lightweight adapter | +| Dify | Tool plugin + Node Gateway | Dify tool runtime, then HTTP | `tdai_recall` tool | `tdai_capture` tool | Covered by upstream PR #394 | + +## Core Data Flow + +```mermaid +flowchart LR + Core["TdaiCore"] + Store["L0/L1/L2/L3 storage"] + OpenClaw["OpenClaw plugin"] + Gateway["TDAI Gateway"] + Hermes["Hermes provider"] + Claude["Claude Code hooks"] + Dify["Dify tool plugin"] + + OpenClaw --> Core + Hermes --> Gateway + Claude --> Gateway + Dify --> Gateway + Gateway --> Core + Core --> Store +``` + +## Adapter Differences + +| Dimension | OpenClaw | Hermes | Claude Code | Dify | +| --- | --- | --- | --- | --- | +| Host integration | Plugin SDK hooks | `MemoryProvider` implementation | Claude Code hook commands | Dify tool plugin | +| Transport | In-process TypeScript | HTTP Gateway | Hook stdin/stdout + HTTP Gateway | Tool invocation + HTTP Gateway | +| Session key | OpenClaw session/runtime state | Hermes session id | Stable Claude `session_id` | Workflow/session variables | +| Prompt injection | Direct `prependContext` mutation | `prefetch()` return string | `hookSpecificOutput.additionalContext` | Tool result consumed by workflow | +| Turn capture | Direct committed-turn object | Background `sync_turn()` thread | Latest transcript user/assistant turn | Tool call payload | +| Gateway auth | Not applicable in-process | Optional Bearer token | Optional Bearer token | Optional Bearer token | +| Best fit | Native OpenClaw users | Hermes users who want memory provider semantics | Claude Code users who can configure hooks | Dify workflows and tool nodes | + +## Claude Code Notes + +The Claude Code adapter intentionally stays thin: + +- `UserPromptSubmit` calls recall and returns dynamic L1 plus stable + persona/scene context as `additionalContext`. +- `Stop` pairs the latest human prompt from the transcript with Claude Code's + authoritative `last_assistant_message`, then captures it through the Gateway. +- `SessionEnd` flushes the session without capturing the final turn twice. + +This keeps platform-specific behavior in `src/adapters/claude-code/` while +reusing the generic `CodingAgentGatewayClient` HTTP wrapper. + +## Dify Notes + +Dify is a good target platform for this issue, but a full implementation is +already covered by PR #394. For this branch, Dify is treated as a comparison +point rather than reimplemented from scratch. diff --git a/hermes-plugin/memory/memory_tencentdb/README.md b/hermes-plugin/memory/memory_tencentdb/README.md index 831fac62..25288d29 100644 --- a/hermes-plugin/memory/memory_tencentdb/README.md +++ b/hermes-plugin/memory/memory_tencentdb/README.md @@ -148,6 +148,24 @@ export MEMORY_TENCENTDB_LLM_BASE_URL="https://api.openai.com/v1" # optional export MEMORY_TENCENTDB_LLM_MODEL="gpt-4o" # optional ``` +When this provider starts the Gateway subprocess, the supervisor bridges +these Hermes-facing names to the Gateway-facing `TDAI_LLM_*` names unless +the operator already set explicit `TDAI_LLM_*` values. This keeps existing +Hermes config schemas working while matching the Node Gateway's actual config +reader (`src/gateway/config.ts`). It also sets `TDAI_GATEWAY_HOST` and +`TDAI_GATEWAY_PORT` from the provider's resolved endpoint so the child Gateway +and the Hermes client cannot silently use different ports. + +For DeepSeek, use the OpenAI-compatible endpoint, not the Anthropic-compatible +Claude Code endpoint: + +```bash +export MEMORY_TENCENTDB_LLM_API_KEY="$ANTHROPIC_AUTH_TOKEN" +export MEMORY_TENCENTDB_LLM_BASE_URL="https://api.deepseek.com/v1" +export MEMORY_TENCENTDB_LLM_MODEL="deepseek-v4-pro" +export TDAI_LLM_DISABLE_THINKING="deepseek" +``` + ### 3. Start the Gateway You have three options; pick whichever fits your deployment. @@ -237,10 +255,12 @@ eliminates a silent no-op. | `MEMORY_TENCENTDB_LLM_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible API base URL | | `MEMORY_TENCENTDB_LLM_MODEL` | `gpt-4o` | Model name | -> ⚠️ Only `MEMORY_TENCENTDB_*` env vars are honored by this provider for the -> Gateway location and LLM credentials. Data-directory resolution is -> deliberately delegated to the Gateway via `TDAI_DATA_DIR` (see above) so -> the provider and the Gateway can never disagree about where L0~L3 live. +> ⚠️ `MEMORY_TENCENTDB_*` env vars are honored by this provider for Gateway +> location and provider-facing LLM credentials. The spawned Node Gateway reads +> `TDAI_LLM_*`; the supervisor bridges the LLM aliases for subprocess mode. +> Data-directory resolution is deliberately delegated to the Gateway via +> `TDAI_DATA_DIR` (see above) so the provider and the Gateway can never +> disagree about where L0~L3 live. ## LLM Tools diff --git a/hermes-plugin/memory/memory_tencentdb/__init__.py b/hermes-plugin/memory/memory_tencentdb/__init__.py index 86350fad..3037cfd1 100644 --- a/hermes-plugin/memory/memory_tencentdb/__init__.py +++ b/hermes-plugin/memory/memory_tencentdb/__init__.py @@ -844,7 +844,15 @@ def prefetch(self, query: str, *, session_id: str = "") -> str: session_key=effective_session, user_id=self._user_id, ) - context = result.get("context", "") + dynamic_context = result.get("prepend_context", "") + stable_context = result.get("append_system_context") or result.get("context", "") + context_parts = [] + for value in (dynamic_context, stable_context): + if isinstance(value, str): + value = value.strip() + if value and value not in context_parts: + context_parts.append(value) + context = "\n\n".join(context_parts) self._record_success() if context: return f"## memory-tencentdb Memory\n{context}" diff --git a/hermes-plugin/memory/memory_tencentdb/supervisor.py b/hermes-plugin/memory/memory_tencentdb/supervisor.py index 1b025cc4..6ef3986d 100644 --- a/hermes-plugin/memory/memory_tencentdb/supervisor.py +++ b/hermes-plugin/memory/memory_tencentdb/supervisor.py @@ -166,8 +166,7 @@ def ensure_running(self) -> bool: try: env = os.environ.copy() - env["MEMORY_TENCENTDB_GATEWAY_PORT"] = str(self._port) - env["MEMORY_TENCENTDB_GATEWAY_HOST"] = self._host + self._bridge_gateway_env(env) # Note: we deliberately do NOT inject TDAI_GATEWAY_API_KEY into # the child's env from here. Whether the Gateway enforces auth is # the operator's call — they configure it on the Gateway side @@ -218,6 +217,36 @@ def ensure_running(self) -> bool: # Wait for health check return self._wait_for_health() + def _bridge_gateway_env(self, env: dict) -> None: + """Prepare endpoint and LLM variables for the Node Gateway child.""" + port = str(self._port) + env["MEMORY_TENCENTDB_GATEWAY_HOST"] = self._host + env["MEMORY_TENCENTDB_GATEWAY_PORT"] = port + env["TDAI_GATEWAY_HOST"] = self._host + env["TDAI_GATEWAY_PORT"] = port + self._bridge_hermes_llm_env(env) + + @staticmethod + def _bridge_hermes_llm_env(env: dict) -> None: + """Bridge Hermes-facing LLM env names to Gateway-facing names. + + Hermes provider configuration exposes ``MEMORY_TENCENTDB_LLM_*`` keys, + while the Node Gateway reads ``TDAI_LLM_*``. When the supervisor owns + the Gateway child process, copy the Hermes names into their Gateway + counterparts unless the operator already set an explicit ``TDAI_*`` + value. This keeps existing Hermes configs runnable without hiding an + intentional Gateway-side override. + """ + for hermes_key, gateway_key in ( + ("MEMORY_TENCENTDB_LLM_API_KEY", "TDAI_LLM_API_KEY"), + ("MEMORY_TENCENTDB_LLM_BASE_URL", "TDAI_LLM_BASE_URL"), + ("MEMORY_TENCENTDB_LLM_MODEL", "TDAI_LLM_MODEL"), + ): + hermes_value = (env.get(hermes_key) or "").strip() + gateway_value = (env.get(gateway_key) or "").strip() + if hermes_value and not gateway_value: + env[gateway_key] = hermes_value + def _resolve_log_dir(self) -> str: """Pick a directory to store Gateway stdout/stderr logs. diff --git a/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py b/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py index 81934aff..eaa0779e 100644 --- a/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py +++ b/hermes-plugin/memory/memory_tencentdb/tests/test_memory_tencentdb_recovery.py @@ -223,6 +223,56 @@ def test_reap_dead_process_drops_handle(): assert sup._process is None +def test_supervisor_bridges_hermes_llm_env_to_gateway_env(): + env = { + "MEMORY_TENCENTDB_LLM_API_KEY": " sk-hermes ", + "MEMORY_TENCENTDB_LLM_BASE_URL": " https://example.test/v1 ", + "MEMORY_TENCENTDB_LLM_MODEL": " hermes-model ", + } + + supervisor_module.GatewaySupervisor._bridge_hermes_llm_env(env) + + assert env["TDAI_LLM_API_KEY"] == "sk-hermes" + assert env["TDAI_LLM_BASE_URL"] == "https://example.test/v1" + assert env["TDAI_LLM_MODEL"] == "hermes-model" + + +def test_supervisor_aligns_child_gateway_endpoint_env(): + supervisor = supervisor_module.GatewaySupervisor( + host="127.0.0.2", + port=18420, + gateway_cmd="fake-cmd", + ) + env = { + "TDAI_GATEWAY_HOST": "stale-host", + "TDAI_GATEWAY_PORT": "9999", + } + + supervisor._bridge_gateway_env(env) + + assert env["MEMORY_TENCENTDB_GATEWAY_HOST"] == "127.0.0.2" + assert env["MEMORY_TENCENTDB_GATEWAY_PORT"] == "18420" + assert env["TDAI_GATEWAY_HOST"] == "127.0.0.2" + assert env["TDAI_GATEWAY_PORT"] == "18420" + + +def test_supervisor_does_not_clobber_explicit_gateway_llm_env(): + env = { + "MEMORY_TENCENTDB_LLM_API_KEY": "sk-hermes", + "MEMORY_TENCENTDB_LLM_BASE_URL": "https://hermes.example/v1", + "MEMORY_TENCENTDB_LLM_MODEL": "hermes-model", + "TDAI_LLM_API_KEY": "sk-tdai", + "TDAI_LLM_BASE_URL": "https://tdai.example/v1", + "TDAI_LLM_MODEL": "tdai-model", + } + + supervisor_module.GatewaySupervisor._bridge_hermes_llm_env(env) + + assert env["TDAI_LLM_API_KEY"] == "sk-tdai" + assert env["TDAI_LLM_BASE_URL"] == "https://tdai.example/v1" + assert env["TDAI_LLM_MODEL"] == "tdai-model" + + def test_reap_dead_process_keeps_alive_handle(): sup = supervisor_module.GatewaySupervisor(gateway_cmd="") alive = _FakePopen(returncode=None) @@ -357,6 +407,24 @@ def test_prefetch_recovers_when_stuck_false_and_breaker_closed( assert fake.ensure_running_calls >= 1 +def test_prefetch_combines_dynamic_and_stable_gateway_context( + provider_with_fake_supervisor, +): + provider = provider_with_fake_supervisor + fake = provider._fake + provider._stop_watchdog() + fake.client.recall.return_value = { + "context": "stable persona", + "prepend_context": "dynamic L1", + "append_system_context": "stable persona", + } + + result = provider.prefetch(query="what did we decide?") + + assert "dynamic L1\n\nstable persona" in result + assert result.count("stable persona") == 1 + + def test_prefetch_respects_open_breaker(provider_with_fake_supervisor): """Breaker should still take precedence — the lazy probe must not turn every request into a respawn attempt during a confirmed outage.""" diff --git a/index.ts b/index.ts index 868a7701..2942f6c9 100644 --- a/index.ts +++ b/index.ts @@ -45,6 +45,23 @@ import { } from "./src/utils/ensure-hook-policy.js"; import { resolveOpenClawStateDir } from "./src/utils/openclaw-state-dir.js"; +export { CodingAgentGatewayClient, CodingAgentGatewayError } from "./src/adapters/coding-agent/index.js"; +export type { + CodingAgentConversationSearchRequest, + CodingAgentGatewayClientOptions, + CodingAgentMemorySearchRequest, + CodingAgentRecallRequest, + CodingAgentTurn, +} from "./src/adapters/coding-agent/index.js"; +export { buildSessionKey, extractLatestTurn, handleClaudeCodeHook } from "./src/adapters/claude-code/index.js"; +export type { + ClaudeCodeHookClient, + ClaudeCodeHookInput, + ClaudeCodeHookOptions, + ClaudeCodeHookResult, + TranscriptTurn, +} from "./src/adapters/claude-code/index.js"; + const TAG = "[memory-tdai]"; /** diff --git a/package.json b/package.json index 09abe4d8..101aca3b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "bin": { "migrate-sqlite-to-tcvdb": "./bin/migrate-sqlite-to-tcvdb.mjs", "export-tencent-vdb": "./bin/export-tencent-vdb.mjs", - "read-local-memory": "./bin/read-local-memory.mjs" + "read-local-memory": "./bin/read-local-memory.mjs", + "memory-tencentdb-claude-hook": "./dist/memory-tencentdb-claude-hook.mjs" }, "exports": { ".": { @@ -47,8 +48,15 @@ "hermes-plugin/", "openclaw.plugin.json", "README.md", + "docs/coding-agent-adapter-quickstart.md", + "docs/claude-code-adapter-setup.md", + "docs/hermes-adapter-setup.md", + "docs/platform-adapter-comparison.md", "CHANGELOG.md", "LICENSE", + "!**/__pycache__/", + "!**/*.pyc", + "!**/*.pyo", "!src/**/*.test.ts", "!src/**/*.spec.ts", "!src/**/__tests__/" diff --git a/src/adapters/claude-code/hook-handler.test.ts b/src/adapters/claude-code/hook-handler.test.ts new file mode 100644 index 00000000..53613faf --- /dev/null +++ b/src/adapters/claude-code/hook-handler.test.ts @@ -0,0 +1,247 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + buildSessionKey, + extractLatestTurn, + handleClaudeCodeHook, + type ClaudeCodeHookClient, +} from "./hook-handler.js"; + +function createClient(): ClaudeCodeHookClient { + return { + health: vi.fn(async () => ({ status: "ok" })), + recall: vi.fn(async () => ({ context: "use pnpm" })), + capture: vi.fn(async () => ({ l0_recorded: 2 })), + endSession: vi.fn(async () => ({ flushed: true })), + }; +} + +function writeTranscript(rows?: unknown[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tdai-claude-hook-")); + const file = path.join(dir, "session.jsonl"); + fs.writeFileSync(file, (rows ?? [ + JSON.stringify({ + cwd: "/tmp/project", + sessionId: "s1", + type: "user", + message: { role: "user", content: [{ type: "text", text: "remember pnpm" }] }, + }), + JSON.stringify({ + cwd: "/tmp/project", + sessionId: "s1", + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "noted" }] }, + }), + ]).map((row) => typeof row === "string" ? row : JSON.stringify(row)).join("\n")); + return file; +} + +describe("Claude Code hook adapter", () => { + it("returns additional context for UserPromptSubmit", async () => { + const client = createClient(); + const result = await handleClaudeCodeHook({ + hook_event_name: "UserPromptSubmit", + session_id: "s1", + cwd: "/tmp/project", + prompt: "what package manager?", + }, { client }); + + expect(client.recall).toHaveBeenCalledWith({ + query: "what package manager?", + sessionKey: "claude-code:s1", + }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout ?? "{}")).toEqual({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: "use pnpm", + }, + }); + }); + + it("captures the latest transcript turn on Stop", async () => { + const client = createClient(); + const transcriptPath = writeTranscript(); + + const result = await handleClaudeCodeHook({ + hook_event_name: "Stop", + session_id: "s1", + transcript_path: transcriptPath, + }, { client }); + + expect(result.exitCode).toBe(0); + expect(client.capture).toHaveBeenCalledWith({ + userContent: "remember pnpm", + assistantContent: "noted", + sessionKey: "claude-code:s1", + sessionId: "s1", + }); + }); + + it("extracts the latest turn from a Claude transcript", () => { + const transcriptPath = writeTranscript(); + + expect(extractLatestTurn({ transcript_path: transcriptPath })).toEqual({ + userContent: "remember pnpm", + assistantContent: "noted", + }); + }); + + it("uses the stable Claude session id even when cwd changes", () => { + expect(buildSessionKey({ + cwd: "/tmp/project", + session_id: "abc", + })).toBe("claude-code:abc"); + expect(buildSessionKey({ + cwd: "/tmp/project/packages/api", + session_id: "abc", + })).toBe("claude-code:abc"); + }); + + it("combines dynamic L1 and stable recall context", async () => { + const client = createClient(); + vi.mocked(client.recall).mockResolvedValue({ + context: "stable persona", + prepend_context: "dynamic L1", + append_system_context: [ + "stable persona", + "unavailable tdai tools", + ].join("\n\n"), + }); + + const result = await handleClaudeCodeHook({ + hook_event_name: "UserPromptSubmit", + session_id: "s1", + prompt: "what package manager?", + }, { client }); + + expect(JSON.parse(result.stdout ?? "{}").hookSpecificOutput.additionalContext) + .toBe("dynamic L1\n\nstable persona"); + }); + + it("uses last_assistant_message when the transcript lags behind Stop", async () => { + const client = createClient(); + const transcriptPath = writeTranscript([ + { + type: "user", + promptId: "old-prompt", + message: { role: "user", content: [{ type: "text", text: "old prompt" }] }, + }, + { + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "old reply" }] }, + }, + { + type: "user", + promptId: "current-prompt", + timestamp: "2026-07-12T06:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "current prompt" }] }, + }, + ]); + + await handleClaudeCodeHook({ + hook_event_name: "Stop", + session_id: "s1", + prompt_id: "current-prompt", + transcript_path: transcriptPath, + last_assistant_message: "current reply", + }, { client }); + + expect(client.capture).toHaveBeenCalledWith({ + userContent: "current prompt", + assistantContent: "current reply", + sessionKey: "claude-code:s1", + sessionId: "s1", + messages: [ + { role: "user", content: "current prompt", timestamp: 1_783_836_000_000 }, + { role: "assistant", content: "current reply", timestamp: 1_783_836_000_001 }, + ], + startedAt: 1_783_835_999_999, + }); + }); + + it("skips capture instead of pairing a current reply with an old prompt", async () => { + const client = createClient(); + const transcriptPath = writeTranscript([ + { + type: "user", + promptId: "old-prompt", + message: { role: "user", content: "old prompt" }, + }, + { + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "old reply" }] }, + }, + ]); + + await handleClaudeCodeHook({ + hook_event_name: "Stop", + session_id: "s1", + prompt_id: "current-prompt", + transcript_path: transcriptPath, + last_assistant_message: "current reply", + }, { client }); + + expect(client.capture).not.toHaveBeenCalled(); + }); + + it("does not treat tool results as user prompts", () => { + const transcriptPath = writeTranscript([ + { + type: "user", + message: { role: "user", content: [{ type: "text", text: "inspect the build" }] }, + }, + { + type: "assistant", + message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Bash" }] }, + }, + { + type: "user", + sourceToolAssistantUUID: "assistant-tool-row", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "large command output" }], + }, + }, + { + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "the build passes" }] }, + }, + ]); + + expect(extractLatestTurn({ transcript_path: transcriptPath })).toEqual({ + userContent: "inspect the build", + assistantContent: "the build passes", + }); + }); + + it("flushes on SessionEnd without capturing the last turn again", async () => { + const client = createClient(); + + const result = await handleClaudeCodeHook({ + hook_event_name: "SessionEnd", + session_id: "s1", + cwd: "/tmp/project", + }, { client }); + + expect(result).toEqual({ exitCode: 0 }); + expect(client.capture).not.toHaveBeenCalled(); + expect(client.endSession).toHaveBeenCalledWith("claude-code:s1"); + }); + + it("fails open when the Gateway is unavailable", async () => { + const client = createClient(); + vi.mocked(client.recall).mockRejectedValue(new Error("connection refused")); + + const result = await handleClaudeCodeHook({ + hook_event_name: "UserPromptSubmit", + session_id: "s1", + prompt: "continue", + }, { client }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("connection refused"); + }); +}); diff --git a/src/adapters/claude-code/hook-handler.ts b/src/adapters/claude-code/hook-handler.ts new file mode 100644 index 00000000..4af769e3 --- /dev/null +++ b/src/adapters/claude-code/hook-handler.ts @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import { CodingAgentGatewayClient } from "../coding-agent/index.js"; +import type { + CodingAgentGatewayClientOptions, + CodingAgentRecallRequest, + CodingAgentTurn, +} from "../coding-agent/index.js"; + +export interface ClaudeCodeHookInput { + hook_event_name?: string; + hookEventName?: string; + session_id?: string; + sessionId?: string; + prompt_id?: string; + promptId?: string; + transcript_path?: string; + transcriptPath?: string; + cwd?: string; + prompt?: string; + message?: unknown; + last_assistant_message?: string; + lastAssistantMessage?: string; + stop_hook_active?: boolean; + stopHookActive?: boolean; + reason?: string; +} + +export interface ClaudeCodeHookClient { + health(): Promise; + recall(request: CodingAgentRecallRequest): Promise<{ + context?: string; + prepend_context?: string; + append_system_context?: string; + }>; + capture(turn: CodingAgentTurn): Promise; + endSession(sessionKey: string): Promise; +} + +export interface ClaudeCodeHookOptions { + client?: ClaudeCodeHookClient; + gateway?: CodingAgentGatewayClientOptions; +} + +export interface ClaudeCodeHookResult { + exitCode: number; + stdout?: string; + stderr?: string; +} + +export interface TranscriptTurn { + userContent: string; + assistantContent: string; + userTimestamp?: number; + assistantTimestamp?: number; +} + +export async function handleClaudeCodeHook( + input: ClaudeCodeHookInput, + options: ClaudeCodeHookOptions = {}, +): Promise { + const client = options.client ?? new CodingAgentGatewayClient(options.gateway); + const eventName = getEventName(input); + + try { + if (eventName === "UserPromptSubmit") { + return await handleUserPromptSubmit(input, client); + } + if (eventName === "Stop") { + return await handleStop(input, client); + } + if (eventName === "SessionEnd") { + const sessionKey = buildSessionKey(input); + if (sessionKey) await client.endSession(sessionKey); + return { exitCode: 0 }; + } + if (eventName === "SessionStart") { + await client.health(); + return { exitCode: 0 }; + } + return { exitCode: 0 }; + } catch (err) { + return { + exitCode: 0, + stderr: `tdai claude-code hook skipped: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +async function handleUserPromptSubmit( + input: ClaudeCodeHookInput, + client: ClaudeCodeHookClient, +): Promise { + const query = extractPrompt(input); + const sessionKey = buildSessionKey(input); + if (!query || !sessionKey) return { exitCode: 0 }; + + const result = await client.recall({ + query, + sessionKey, + }); + + const context = combineRecallContext(result); + if (!context) return { exitCode: 0 }; + + return { + exitCode: 0, + stdout: JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: context, + }, + }), + }; +} + +async function handleStop( + input: ClaudeCodeHookInput, + client: ClaudeCodeHookClient, +): Promise { + const turn = extractLatestTurn(input); + const sessionKey = buildSessionKey(input); + if (turn && sessionKey) { + const hasTimestamps = turn.userTimestamp !== undefined && turn.assistantTimestamp !== undefined; + await client.capture({ + userContent: turn.userContent, + assistantContent: turn.assistantContent, + sessionKey, + sessionId: getSessionId(input), + ...(hasTimestamps ? { + messages: [ + { role: "user", content: turn.userContent, timestamp: turn.userTimestamp }, + { role: "assistant", content: turn.assistantContent, timestamp: turn.assistantTimestamp }, + ], + startedAt: Math.max(0, turn.userTimestamp! - 1), + } : {}), + }); + } + + return { exitCode: 0 }; +} + +export function buildSessionKey(input: ClaudeCodeHookInput): string | null { + const sessionId = getSessionId(input); + return sessionId ? `claude-code:${sessionId}` : null; +} + +export function extractLatestTurn(input: ClaudeCodeHookInput): TranscriptTurn | null { + const transcriptPath = input.transcript_path ?? input.transcriptPath; + if (!transcriptPath || !fs.existsSync(transcriptPath)) return null; + + let latestUser = ""; + let latestCompletedTurn: TranscriptTurn | null = null; + let latestUserTimestamp: number | undefined; + let lastAssistantRow = -1; + let lastToolResultRow = -1; + const targetPromptId = getPromptId(input); + + const lines = fs.readFileSync(transcriptPath, "utf-8").split(/\r?\n/); + for (const [rowIndex, line] of lines.entries()) { + if (!line.trim()) continue; + let row: unknown; + try { + row = JSON.parse(line); + } catch { + continue; + } + const record = typeof row === "object" && row !== null + ? row as Record + : undefined; + if (!record || record.isMeta === true || record.isSidechain === true) continue; + const message = record.message; + if (!message || typeof message !== "object") continue; + const role = (message as Record).role; + const rawContent = (message as Record).content; + if ( + role === "user" && + (containsToolResult(rawContent) || record.sourceToolAssistantUUID !== undefined) + ) { + lastToolResultRow = rowIndex; + continue; + } + const content = contentToText(rawContent); + if (!content) continue; + if (role === "user") { + if (targetPromptId && getTranscriptPromptId(record) !== targetPromptId) continue; + latestUser = content; + latestUserTimestamp = parseTranscriptTimestamp(record.timestamp); + } else if (role === "assistant" && latestUser) { + const assistantTimestamp = parseTranscriptTimestamp(record.timestamp); + latestCompletedTurn = { + userContent: latestUser, + assistantContent: content, + ...(latestUserTimestamp !== undefined ? { userTimestamp: latestUserTimestamp } : {}), + ...(assistantTimestamp !== undefined ? { assistantTimestamp } : {}), + }; + lastAssistantRow = rowIndex; + } + } + + const currentAssistant = getLastAssistantMessage(input); + if (latestUser && currentAssistant) { + const completedTimestamp = latestCompletedTurn?.userContent === latestUser + ? latestCompletedTurn.assistantTimestamp + : undefined; + const assistantTimestamp = completedTimestamp ?? ( + latestUserTimestamp !== undefined ? latestUserTimestamp + 1 : undefined + ); + return { + userContent: latestUser, + assistantContent: currentAssistant, + ...(latestUserTimestamp !== undefined ? { userTimestamp: latestUserTimestamp } : {}), + ...(assistantTimestamp !== undefined ? { assistantTimestamp } : {}), + }; + } + if ( + !latestCompletedTurn || + latestCompletedTurn.userContent !== latestUser || + lastToolResultRow > lastAssistantRow + ) { + return null; + } + return latestCompletedTurn; +} + +function getEventName(input: ClaudeCodeHookInput): string { + return input.hook_event_name ?? input.hookEventName ?? ""; +} + +function getSessionId(input: ClaudeCodeHookInput): string { + return input.session_id ?? input.sessionId ?? ""; +} + +function getPromptId(input: ClaudeCodeHookInput): string { + return input.prompt_id ?? input.promptId ?? ""; +} + +function getTranscriptPromptId(record: Record): string { + const value = record.promptId ?? record.prompt_id; + return typeof value === "string" ? value : ""; +} + +function parseTranscriptTimestamp(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function extractPrompt(input: ClaudeCodeHookInput): string { + if (typeof input.prompt === "string") return input.prompt; + return contentToText(input.message); +} + +function getLastAssistantMessage(input: ClaudeCodeHookInput): string { + const value = input.last_assistant_message ?? input.lastAssistantMessage; + return typeof value === "string" ? value.trim() : ""; +} + +function combineRecallContext(result: { + context?: string; + prepend_context?: string; + append_system_context?: string; +}): string { + const dynamicContext = result.prepend_context?.trim(); + const stableContext = (result.append_system_context ?? result.context) + ?.replace(/[\s\S]*?<\/memory-tools-guide>/gi, "") + .trim(); + return [...new Set([dynamicContext, stableContext].filter((value): value is string => !!value))] + .join("\n\n"); +} + +function contentToText(value: unknown): string { + if (typeof value === "string") return value.trim(); + if (!Array.isArray(value)) return ""; + + return value + .map((part) => { + if (typeof part === "string") return part; + if (!part || typeof part !== "object") return ""; + const record = part as Record; + if (record.type !== undefined && record.type !== "text") return ""; + if (typeof record.text === "string") return record.text; + return ""; + }) + .filter(Boolean) + .join("\n") + .trim(); +} + +function containsToolResult(value: unknown): boolean { + return Array.isArray(value) && value.some((part) => ( + !!part && typeof part === "object" && (part as Record).type === "tool_result" + )); +} diff --git a/src/adapters/claude-code/hook.ts b/src/adapters/claude-code/hook.ts new file mode 100644 index 00000000..04ed2f73 --- /dev/null +++ b/src/adapters/claude-code/hook.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { handleClaudeCodeHook } from "./hook-handler.js"; + +async function main(): Promise { + const raw = readFileSync(0, "utf-8"); + const input = raw.trim() ? JSON.parse(raw) : {}; + const timeoutMs = Number.parseInt(process.env.TDAI_GATEWAY_TIMEOUT_MS ?? "", 10); + const result = await handleClaudeCodeHook(input, { + gateway: { + baseUrl: process.env.TDAI_GATEWAY_URL, + apiKey: process.env.TDAI_GATEWAY_API_KEY, + ...(Number.isFinite(timeoutMs) && timeoutMs > 0 ? { timeoutMs } : {}), + }, + }); + + if (result.stdout) process.stdout.write(`${result.stdout}\n`); + if (result.stderr) process.stderr.write(`${result.stderr}\n`); + process.exitCode = result.exitCode; +} + +main().catch((err) => { + process.stderr.write(`tdai claude-code hook failed: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 0; +}); diff --git a/src/adapters/claude-code/index.ts b/src/adapters/claude-code/index.ts new file mode 100644 index 00000000..9fc96aa0 --- /dev/null +++ b/src/adapters/claude-code/index.ts @@ -0,0 +1,12 @@ +export { + buildSessionKey, + extractLatestTurn, + handleClaudeCodeHook, +} from "./hook-handler.js"; +export type { + ClaudeCodeHookClient, + ClaudeCodeHookInput, + ClaudeCodeHookOptions, + ClaudeCodeHookResult, + TranscriptTurn, +} from "./hook-handler.js"; diff --git a/src/adapters/coding-agent/gateway-client.test.ts b/src/adapters/coding-agent/gateway-client.test.ts new file mode 100644 index 00000000..36412ebd --- /dev/null +++ b/src/adapters/coding-agent/gateway-client.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { CodingAgentGatewayClient, CodingAgentGatewayError } from "./gateway-client.js"; + +function mockFetch(responseBody: unknown, init: ResponseInit = {}) { + const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = []; + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ input, init }); + return new Response(JSON.stringify(responseBody), { + status: 200, + ...init, + headers: { "Content-Type": "application/json", ...init.headers }, + }); + }) as unknown as typeof globalThis.fetch; + + return { fetchImpl, calls }; +} + +describe("CodingAgentGatewayClient", () => { + it("sends recall requests using Gateway field names", async () => { + const { fetchImpl, calls } = mockFetch({ context: "memory", memory_count: 1 }); + const client = new CodingAgentGatewayClient({ + baseUrl: "http://127.0.0.1:8420/", + apiKey: " secret ", + fetch: fetchImpl, + }); + + const response = await client.recall({ + query: "what did we decide?", + sessionKey: "repo:thread-1", + userId: "alice", + }); + + expect(response.context).toBe("memory"); + expect(calls[0].input).toBe("http://127.0.0.1:8420/recall"); + expect(calls[0].init?.method).toBe("POST"); + expect(calls[0].init?.headers).toMatchObject({ + "Content-Type": "application/json", + Authorization: "Bearer secret", + }); + expect(JSON.parse(calls[0].init?.body as string)).toEqual({ + query: "what did we decide?", + session_key: "repo:thread-1", + user_id: "alice", + }); + }); + + it("captures a coding-agent turn with optional raw messages", async () => { + const { fetchImpl, calls } = mockFetch({ l0_recorded: 2, scheduler_notified: true }); + const client = new CodingAgentGatewayClient({ fetch: fetchImpl }); + + await client.capture({ + userContent: "fix the failing test", + assistantContent: "patched the assertion", + sessionKey: "workspace:/tmp/project", + sessionId: "thread-42", + messages: [{ role: "user", content: "fix the failing test" }], + startedAt: 1_720_000_000_000, + }); + + expect(calls[0].input).toBe("http://127.0.0.1:8420/capture"); + expect(JSON.parse(calls[0].init?.body as string)).toEqual({ + user_content: "fix the failing test", + assistant_content: "patched the assertion", + session_key: "workspace:/tmp/project", + session_id: "thread-42", + messages: [{ role: "user", content: "fix the failing test" }], + started_at: 1_720_000_000_000, + }); + }); + + it("uses GET for health and omits auth when no api key is configured", async () => { + const { fetchImpl, calls } = mockFetch({ + status: "ok", + version: "0.1.0", + uptime: 1, + stores: { vectorStore: true, embeddingService: true }, + }); + const client = new CodingAgentGatewayClient({ fetch: fetchImpl }); + + await client.health(); + + expect(calls[0].input).toBe("http://127.0.0.1:8420/health"); + expect(calls[0].init?.method).toBe("GET"); + expect(calls[0].init?.headers).toEqual({}); + expect(calls[0].init?.body).toBeUndefined(); + }); + + it("throws a typed error for non-2xx responses", async () => { + const fetchImpl = vi.fn(async () => new Response("unauthorized", { status: 401 })) as unknown as typeof globalThis.fetch; + const client = new CodingAgentGatewayClient({ fetch: fetchImpl }); + + await expect(client.searchMemories({ query: "secret" })).rejects.toMatchObject({ + name: "CodingAgentGatewayError", + status: 401, + responseBody: "unauthorized", + } satisfies Partial); + }); + + it("maps search and session lifecycle requests to Gateway fields", async () => { + const { fetchImpl, calls } = mockFetch({ results: "ok", total: 1 }); + const client = new CodingAgentGatewayClient({ fetch: fetchImpl }); + + await client.searchMemories({ + query: "package manager", + limit: 3, + type: "preference", + scene: "workspace", + }); + await client.searchConversations({ + query: "pnpm", + limit: 2, + sessionKey: "repo:thread-1", + }); + await client.endSession("repo:thread-1", "alice"); + + expect(calls.map((call) => call.input)).toEqual([ + "http://127.0.0.1:8420/search/memories", + "http://127.0.0.1:8420/search/conversations", + "http://127.0.0.1:8420/session/end", + ]); + expect(JSON.parse(calls[0].init?.body as string)).toEqual({ + query: "package manager", + limit: 3, + type: "preference", + scene: "workspace", + }); + expect(JSON.parse(calls[1].init?.body as string)).toEqual({ + query: "pnpm", + limit: 2, + session_key: "repo:thread-1", + }); + expect(JSON.parse(calls[2].init?.body as string)).toEqual({ + session_key: "repo:thread-1", + user_id: "alice", + }); + }); + + it("aborts requests after the configured timeout", async () => { + vi.useFakeTimers(); + try { + const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => ( + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }) + )) as unknown as typeof globalThis.fetch; + const client = new CodingAgentGatewayClient({ timeoutMs: 25, fetch: fetchImpl }); + + const assertion = expect(client.health()).rejects.toMatchObject({ name: "AbortError" }); + await vi.advanceTimersByTimeAsync(25); + await assertion; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/adapters/coding-agent/gateway-client.ts b/src/adapters/coding-agent/gateway-client.ts new file mode 100644 index 00000000..d00007dc --- /dev/null +++ b/src/adapters/coding-agent/gateway-client.ts @@ -0,0 +1,148 @@ +import type { + CaptureResponse, + ConversationSearchResponse, + HealthResponse, + MemorySearchResponse, + RecallResponse, + SessionEndResponse, +} from "../../gateway/types.js"; + +export interface CodingAgentGatewayClientOptions { + baseUrl?: string; + apiKey?: string; + timeoutMs?: number; + fetch?: typeof globalThis.fetch; +} + +export interface CodingAgentTurn { + userContent: string; + assistantContent: string; + sessionKey: string; + sessionId?: string; + userId?: string; + messages?: unknown[]; + startedAt?: number; +} + +export interface CodingAgentRecallRequest { + query: string; + sessionKey: string; + userId?: string; +} + +export interface CodingAgentMemorySearchRequest { + query: string; + limit?: number; + type?: string; + scene?: string; +} + +export interface CodingAgentConversationSearchRequest { + query: string; + limit?: number; + sessionKey?: string; +} + +export class CodingAgentGatewayError extends Error { + readonly status: number; + readonly responseBody: string; + + constructor(status: number, responseBody: string) { + super(`TDAI Gateway request failed with HTTP ${status}: ${responseBody}`); + this.name = "CodingAgentGatewayError"; + this.status = status; + this.responseBody = responseBody; + } +} + +/** + * Thin HTTP client for coding-agent platforms that connect to TDAI through + * the Gateway sidecar instead of embedding TdaiCore in-process. + */ +export class CodingAgentGatewayClient { + private readonly baseUrl: string; + private readonly apiKey?: string; + private readonly timeoutMs: number; + private readonly fetchImpl: typeof globalThis.fetch; + + constructor(options: CodingAgentGatewayClientOptions = {}) { + this.baseUrl = (options.baseUrl ?? "http://127.0.0.1:8420").replace(/\/+$/, ""); + this.apiKey = options.apiKey?.trim() || undefined; + this.timeoutMs = options.timeoutMs ?? 10_000; + this.fetchImpl = options.fetch ?? globalThis.fetch; + } + + health(): Promise { + return this.request("GET", "/health"); + } + + recall(request: CodingAgentRecallRequest): Promise { + return this.request("POST", "/recall", { + query: request.query, + session_key: request.sessionKey, + ...(request.userId ? { user_id: request.userId } : {}), + }); + } + + capture(turn: CodingAgentTurn): Promise { + return this.request("POST", "/capture", { + user_content: turn.userContent, + assistant_content: turn.assistantContent, + session_key: turn.sessionKey, + ...(turn.sessionId ? { session_id: turn.sessionId } : {}), + ...(turn.userId ? { user_id: turn.userId } : {}), + ...(turn.messages ? { messages: turn.messages } : {}), + ...(turn.startedAt !== undefined ? { started_at: turn.startedAt } : {}), + }); + } + + searchMemories(request: CodingAgentMemorySearchRequest): Promise { + return this.request("POST", "/search/memories", { + query: request.query, + ...(request.limit !== undefined ? { limit: request.limit } : {}), + ...(request.type ? { type: request.type } : {}), + ...(request.scene ? { scene: request.scene } : {}), + }); + } + + searchConversations(request: CodingAgentConversationSearchRequest): Promise { + return this.request("POST", "/search/conversations", { + query: request.query, + ...(request.limit !== undefined ? { limit: request.limit } : {}), + ...(request.sessionKey ? { session_key: request.sessionKey } : {}), + }); + } + + endSession(sessionKey: string, userId?: string): Promise { + return this.request("POST", "/session/end", { + session_key: sessionKey, + ...(userId ? { user_id: userId } : {}), + }); + } + + private async request(method: "GET" | "POST", path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const headers: Record = {}; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`; + + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + + const text = await response.text(); + if (!response.ok) { + throw new CodingAgentGatewayError(response.status, text); + } + return (text ? JSON.parse(text) : {}) as T; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/src/adapters/coding-agent/index.ts b/src/adapters/coding-agent/index.ts new file mode 100644 index 00000000..544ff413 --- /dev/null +++ b/src/adapters/coding-agent/index.ts @@ -0,0 +1,12 @@ +export { + CodingAgentGatewayClient, + CodingAgentGatewayError, +} from "./gateway-client.js"; +export type { + CodingAgentConversationSearchRequest, + CodingAgentGatewayClientOptions, + CodingAgentMemorySearchRequest, + CodingAgentRecallRequest, + CodingAgentTurn, +} from "./gateway-client.js"; + diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 4bf0b95b..7810e6d3 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -6,8 +6,10 @@ * * Directory structure: * adapters/ - * ├── openclaw/ — OpenClaw plugin host (in-process, runEmbeddedPiAgent) - * └── standalone/ — Gateway / Hermes sidecar (HTTP, OpenAI-compatible API) + * ├── openclaw/ — OpenClaw plugin host (in-process, runEmbeddedPiAgent) + * ├── standalone/ — Gateway / Hermes sidecar (HTTP, OpenAI-compatible API) + * └── coding-agent/ — Gateway client for coding-agent hosts (Codex, Claude Code, Cursor) + * └── claude-code/ — Claude Code hook adapter (UserPromptSubmit / Stop) */ // OpenClaw adapter @@ -17,3 +19,23 @@ export type { OpenClawHostAdapterOptions, OpenClawLLMRunnerFactoryOptions } from // Standalone adapter export { StandaloneHostAdapter, StandaloneLLMRunner, StandaloneLLMRunnerFactory } from "./standalone/index.js"; export type { StandaloneHostAdapterOptions, StandaloneLLMConfig, StandaloneLLMRunnerFactoryOptions } from "./standalone/index.js"; + +// Coding-agent Gateway client +export { CodingAgentGatewayClient, CodingAgentGatewayError } from "./coding-agent/index.js"; +export type { + CodingAgentConversationSearchRequest, + CodingAgentGatewayClientOptions, + CodingAgentMemorySearchRequest, + CodingAgentRecallRequest, + CodingAgentTurn, +} from "./coding-agent/index.js"; + +// Claude Code hook adapter +export { buildSessionKey, extractLatestTurn, handleClaudeCodeHook } from "./claude-code/index.js"; +export type { + ClaudeCodeHookClient, + ClaudeCodeHookInput, + ClaudeCodeHookOptions, + ClaudeCodeHookResult, + TranscriptTurn, +} from "./claude-code/index.js"; diff --git a/src/gateway/recall-response.test.ts b/src/gateway/recall-response.test.ts new file mode 100644 index 00000000..05ab8ed9 --- /dev/null +++ b/src/gateway/recall-response.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { buildGatewayRecallResponse } from "./recall-response.js"; + +describe("buildGatewayRecallResponse", () => { + it("preserves dynamic and stable context as separate fields", () => { + expect(buildGatewayRecallResponse({ + prependContext: "dynamic L1", + appendSystemContext: "stable persona", + recallStrategy: "hybrid", + recalledL1Memories: [{ content: "x", score: 1, type: "fact" }], + })).toEqual({ + context: "stable persona", + prepend_context: "dynamic L1", + append_system_context: "stable persona", + strategy: "hybrid", + memory_count: 1, + }); + }); + + it("keeps the legacy context field backward compatible", () => { + expect(buildGatewayRecallResponse({ prependContext: "dynamic only" })).toEqual({ + context: "", + prepend_context: "dynamic only", + strategy: undefined, + memory_count: 0, + }); + }); +}); diff --git a/src/gateway/recall-response.ts b/src/gateway/recall-response.ts new file mode 100644 index 00000000..67160f2b --- /dev/null +++ b/src/gateway/recall-response.ts @@ -0,0 +1,16 @@ +import type { RecallResult } from "../core/types.js"; +import type { RecallResponse } from "./types.js"; + +/** Preserve the core's stable/dynamic context boundary across HTTP. */ +export function buildGatewayRecallResponse(result: RecallResult): RecallResponse { + return { + // Keep the legacy field unchanged for existing Gateway clients. + context: result.appendSystemContext ?? "", + ...(result.prependContext ? { prepend_context: result.prependContext } : {}), + ...(result.appendSystemContext + ? { append_system_context: result.appendSystemContext } + : {}), + strategy: result.recallStrategy, + memory_count: result.recalledL1Memories?.length ?? 0, + }; +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 1da5592e..60d62c1e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -39,6 +39,7 @@ import type { SeedResponse, GatewayErrorResponse, } from "./types.js"; +import { buildGatewayRecallResponse } from "./recall-response.js"; import type { Logger } from "../core/types.js"; import { validateAndNormalizeRaw, fillTimestamps, SeedValidationError } from "../core/seed/input.js"; import { executeSeed } from "../core/seed/seed-runtime.js"; @@ -380,13 +381,12 @@ export class TdaiGateway { const result = await this.core.handleBeforeRecall(body.query, body.session_key); const elapsed = Date.now() - startMs; - this.logger.info(`Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars`); + this.logger.info( + `Recall completed in ${elapsed}ms: stable=${result.appendSystemContext?.length ?? 0} chars, ` + + `dynamic=${result.prependContext?.length ?? 0} chars`, + ); - const response: RecallResponse = { - context: result.appendSystemContext ?? "", - strategy: result.recallStrategy, - memory_count: result.recalledL1Memories?.length ?? 0, - }; + const response: RecallResponse = buildGatewayRecallResponse(result); sendJson(res, 200, response); } @@ -408,6 +408,7 @@ export class TdaiGateway { ], sessionKey: body.session_key, sessionId: body.session_id, + startedAt: body.started_at, }); const elapsed = Date.now() - startMs; diff --git a/src/gateway/types.ts b/src/gateway/types.ts index 50b2ff4c..728ed034 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -36,7 +36,12 @@ export interface RecallRequest { } export interface RecallResponse { + /** Legacy stable-context field kept for backward compatibility. */ context: string; + /** Dynamic L1 context associated with the current user turn. */ + prepend_context?: string; + /** Stable persona, scene, and tool guidance context. */ + append_system_context?: string; strategy?: string; memory_count?: number; } @@ -52,6 +57,7 @@ export interface CaptureRequest { session_id?: string; user_id?: string; messages?: unknown[]; + started_at?: number; } export interface CaptureResponse { diff --git a/tsdown.config.ts b/tsdown.config.ts index 16b0073e..8b528f1e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,7 +11,10 @@ function collectExternalDependencies(): string[] { } export default defineConfig({ - entry: ["./index.ts"], + entry: { + index: "./index.ts", + "memory-tencentdb-claude-hook": "./src/adapters/claude-code/hook.ts", + }, outDir: "./dist", format: "esm", platform: "node",