From 032d114e58f409da6d62a36410726fd9cfb3c529 Mon Sep 17 00:00:00 2001 From: "gaosong.1984" Date: Thu, 9 Jul 2026 17:12:18 +0800 Subject: [PATCH 1/2] feat(cli): add memory hook adapter --- packages/tdai-memory-cli/.gitignore | 6 + packages/tdai-memory-cli/README.md | 63 +++ .../examples/claude-code/hooks.json | 38 ++ .../tdai-memory-cli/examples/codex/hooks.json | 42 ++ packages/tdai-memory-cli/examples/hooks.json | 37 ++ .../examples/skills/tdai-memory/SKILL.md | 57 +++ packages/tdai-memory-cli/pyproject.toml | 22 + .../tdai_memory_cli/__init__.py | 3 + .../tdai_memory_cli/__main__.py | 133 ++++++ .../tdai-memory-cli/tdai_memory_cli/config.py | 44 ++ .../tdai_memory_cli/formatters.py | 57 +++ .../tdai_memory_cli/gateway_http.py | 91 ++++ .../tdai-memory-cli/tdai_memory_cli/hook.py | 434 ++++++++++++++++++ .../tdai_memory_cli/session.py | 21 + .../tests/e2e/test_gateway_ollama.py | 47 ++ .../tdai-memory-cli/tests/unit/test_cli.py | 99 ++++ .../tests/unit/test_gateway_http.py | 36 ++ .../tdai-memory-cli/tests/unit/test_hook.py | 202 ++++++++ .../tests/unit/test_session_config.py | 30 ++ 19 files changed, 1462 insertions(+) create mode 100644 packages/tdai-memory-cli/.gitignore create mode 100644 packages/tdai-memory-cli/README.md create mode 100644 packages/tdai-memory-cli/examples/claude-code/hooks.json create mode 100644 packages/tdai-memory-cli/examples/codex/hooks.json create mode 100644 packages/tdai-memory-cli/examples/hooks.json create mode 100644 packages/tdai-memory-cli/examples/skills/tdai-memory/SKILL.md create mode 100644 packages/tdai-memory-cli/pyproject.toml create mode 100644 packages/tdai-memory-cli/tdai_memory_cli/__init__.py create mode 100644 packages/tdai-memory-cli/tdai_memory_cli/__main__.py create mode 100644 packages/tdai-memory-cli/tdai_memory_cli/config.py create mode 100644 packages/tdai-memory-cli/tdai_memory_cli/formatters.py create mode 100644 packages/tdai-memory-cli/tdai_memory_cli/gateway_http.py create mode 100644 packages/tdai-memory-cli/tdai_memory_cli/hook.py create mode 100644 packages/tdai-memory-cli/tdai_memory_cli/session.py create mode 100644 packages/tdai-memory-cli/tests/e2e/test_gateway_ollama.py create mode 100644 packages/tdai-memory-cli/tests/unit/test_cli.py create mode 100644 packages/tdai-memory-cli/tests/unit/test_gateway_http.py create mode 100644 packages/tdai-memory-cli/tests/unit/test_hook.py create mode 100644 packages/tdai-memory-cli/tests/unit/test_session_config.py diff --git a/packages/tdai-memory-cli/.gitignore b/packages/tdai-memory-cli/.gitignore new file mode 100644 index 00000000..f388346b --- /dev/null +++ b/packages/tdai-memory-cli/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.pytest_cache/ +.venv/ +*.egg-info/ +*.pyc +*.log \ No newline at end of file diff --git a/packages/tdai-memory-cli/README.md b/packages/tdai-memory-cli/README.md new file mode 100644 index 00000000..cc1a88ae --- /dev/null +++ b/packages/tdai-memory-cli/README.md @@ -0,0 +1,63 @@ +# TencentDB Agent Memory Hook CLI + +This project is the hook-facing CLI adapter for TencentDB Agent Memory Gateway. +It is intentionally separate from the MCP server project and from Codex +workspace installation logic. +It assumes Gateway is already running and does not start, stop, supervise, or +watch the Gateway process. + +Commands: + +```bash +tdai-memory prefetch --query "..." --session-key "..." +tdai-memory sync-turn --user-content "..." --assistant-content "..." --session-key "..." +tdai-memory end-session --session-key "..." +tdai-memory session-start +``` + +Hook wrapper commands read hook event JSON from stdin and call the commands above: + +```bash +tdai-memory-hook prefetch +tdai-memory-hook sync-turn +tdai-memory-hook end-session +tdai-memory-hook session-start +``` + +See `examples/hooks.json`, `examples/codex/hooks.json`, and +`examples/claude-code/hooks.json` for command-hook configuration templates. +Hook failures are non-blocking by default; set `TDAI_HOOK_STRICT=1` to return +non-zero on Gateway or parsing failures. +Set `TDAI_HOOK_LOG=/path/to/hooks.jsonl` to append lightweight diagnostics for +hook invocation tests. The Codex installer defaults this to +`~/.codex/tdai-memory/logs/hooks.jsonl`. + +`session-start` does not write `AGENTS.md` or `CLAUDE.md`. Prompt-file updates +belong to an explicit installer/plugin step, not to runtime hooks. Other +commands send requests to the configured Gateway URL and report errors if +Gateway is unavailable. + +Gateway request configuration: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8420 +TDAI_GATEWAY_API_KEY= +TDAI_SESSION_KEY= +TDAI_USER_ID= +TDAI_REQUEST_TIMEOUT_MS=30000 +``` + +Run from source: + +```bash +cd packages/tdai-memory-cli +python3 -m tdai_memory_cli prefetch --query "..." +``` + +Tests: + +```bash +python3 -m pytest tests/unit -q +# Requires an already running Gateway at TDAI_GATEWAY_URL. +TDAI_MEMORY_CLI_E2E=1 python3 -m pytest tests/e2e -q +``` diff --git a/packages/tdai-memory-cli/examples/claude-code/hooks.json b/packages/tdai-memory-cli/examples/claude-code/hooks.json new file mode 100644 index 00000000..608cb453 --- /dev/null +++ b/packages/tdai-memory-cli/examples/claude-code/hooks.json @@ -0,0 +1,38 @@ +{ + "description": "TencentDB Agent Memory agent hooks for Claude Code", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=SessionStart TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook session-start", + "timeout": 60 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=UserPromptSubmit TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook prefetch", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=Stop TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook sync-turn", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/packages/tdai-memory-cli/examples/codex/hooks.json b/packages/tdai-memory-cli/examples/codex/hooks.json new file mode 100644 index 00000000..3791a9d2 --- /dev/null +++ b/packages/tdai-memory-cli/examples/codex/hooks.json @@ -0,0 +1,42 @@ +{ + "description": "TencentDB Agent Memory agent hooks for Codex", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=SessionStart TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook session-start", + "timeout": 60, + "statusMessage": "Checking TDAI memory Gateway" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=UserPromptSubmit TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook prefetch", + "timeout": 30, + "statusMessage": "Prefetching TDAI memory" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=Stop TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook sync-turn", + "timeout": 60, + "statusMessage": "Capturing TDAI memory turn" + } + ] + } + ] + } +} diff --git a/packages/tdai-memory-cli/examples/hooks.json b/packages/tdai-memory-cli/examples/hooks.json new file mode 100644 index 00000000..7ec48596 --- /dev/null +++ b/packages/tdai-memory-cli/examples/hooks.json @@ -0,0 +1,37 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook session-start", + "timeout": 60 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook prefetch", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli python3 -m tdai_memory_cli.hook sync-turn", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/packages/tdai-memory-cli/examples/skills/tdai-memory/SKILL.md b/packages/tdai-memory-cli/examples/skills/tdai-memory/SKILL.md new file mode 100644 index 00000000..79e00b19 --- /dev/null +++ b/packages/tdai-memory-cli/examples/skills/tdai-memory/SKILL.md @@ -0,0 +1,57 @@ +--- +name: tdai-memory +description: Use when working with TencentDB Agent Memory, including memory recall, conversation capture, agent hooks, MCP lookup tools, and debugging the TDAI memory CLI. +version: 0.1.0 +--- + +# TDAI Memory + +TencentDB Agent Memory exposes long-term memory through two reusable surfaces: + +- MCP tools for agent-facing memory lookup. +- Hook CLI commands for health checks, recall prefetch, capture, and flush. + +## Choose The Surface + +Use MCP tools for answering with memory: + +- `tdai_memory_search`: search structured long-term memories. +- `tdai_conversation_search`: search raw conversation history. + +Use CLI commands for hook behavior: + +- `session-start`: check Gateway health for the hook runtime. +- `prefetch`: recall memory context for a user query. +- `sync-turn`: capture a completed user/assistant turn. +- `end-session`: flush session pipeline work. + +Do not use CLI commands as a substitute for MCP lookup unless debugging the adapter. + +## CLI Commands + +```bash +tdai-memory session-start +tdai-memory prefetch --query "" +tdai-memory sync-turn \ + --user-content "" \ + --assistant-content "" +tdai-memory end-session +``` + +Use hook wrappers when reading agent hook JSON from stdin: + +```bash +tdai-memory-hook session-start +tdai-memory-hook prefetch +tdai-memory-hook sync-turn +tdai-memory-hook end-session +``` + +Hook failures are non-blocking by default. Set `TDAI_HOOK_STRICT=1` only for tests or debugging. + +## Rules + +- Prefer MCP tools when using memory to answer a user question. +- Use CLI commands for health checks, capture, and flush. +- Keep `AGENTS.md` or `CLAUDE.md` updates in an explicit installer/plugin step, not runtime hooks. +- Do not store dynamic recall results in `AGENTS.md` or `CLAUDE.md`. diff --git a/packages/tdai-memory-cli/pyproject.toml b/packages/tdai-memory-cli/pyproject.toml new file mode 100644 index 00000000..bd4d8810 --- /dev/null +++ b/packages/tdai-memory-cli/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "tdai-memory-cli" +version = "0.1.0" +description = "Hook CLI adapter for TencentDB Agent Memory Gateway." +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] +tdai-memory = "tdai_memory_cli.__main__:main" +tdai-memory-hook = "tdai_memory_cli.hook:main" + +[tool.setuptools.packages.find] +include = ["tdai_memory_cli*"] +exclude = ["tests*", "vibe*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/packages/tdai-memory-cli/tdai_memory_cli/__init__.py b/packages/tdai-memory-cli/tdai_memory_cli/__init__.py new file mode 100644 index 00000000..4b636547 --- /dev/null +++ b/packages/tdai-memory-cli/tdai_memory_cli/__init__.py @@ -0,0 +1,3 @@ +"""TencentDB Agent Memory hook CLI adapter.""" + +__version__ = "0.1.0" diff --git a/packages/tdai-memory-cli/tdai_memory_cli/__main__.py b/packages/tdai-memory-cli/tdai_memory_cli/__main__.py new file mode 100644 index 00000000..17c65353 --- /dev/null +++ b/packages/tdai-memory-cli/tdai_memory_cli/__main__.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from .config import load_config +from .formatters import format_capture, format_error, format_recall, format_session_end +from .gateway_http import request_json +from .session import resolve_session_key + + +def main(argv: list[str] | None = None) -> int: + result = run_cli(argv) + if result.get("stdout"): + print(result["stdout"]) + if result.get("stderr"): + print(result["stderr"], file=sys.stderr) + return int(result["code"]) + + +def run_cli( + argv: list[str] | None = None, + *, + env: dict[str, str] | None = None, + cwd: str | None = None, + request_func: Any | None = None, +) -> dict[str, Any]: + parser = _build_parser() + try: + args = parser.parse_args(argv) + config = load_config(env=env, cwd=cwd) + send_request = request_func or request_json + if args.command == "prefetch": + session_key = resolve_session_key(args.session_key, config.default_session_key) + return { + "code": 0, + "stdout": format_recall(send_request(config, "/recall", body={ + "query": args.query, + "session_key": session_key, + "user_id": args.user_id or config.user_id, + })), + } + if args.command == "sync-turn": + session_key = resolve_session_key(args.session_key, config.default_session_key) + return { + "code": 0, + "stdout": format_capture(send_request(config, "/capture", body={ + "user_content": args.user_content, + "assistant_content": args.assistant_content, + "session_key": session_key, + "session_id": args.session_id or "", + "user_id": args.user_id or config.user_id, + "messages": _read_messages(args), + })), + } + if args.command == "end-session": + session_key = resolve_session_key(args.session_key, config.default_session_key) + return { + "code": 0, + "stdout": format_session_end(send_request(config, "/session/end", body={ + "session_key": session_key, + "user_id": args.user_id or config.user_id, + })), + } + if args.command == "session-start": + gateway_status = _check_gateway_health(config, request_func=send_request) + return { + "code": 0, + "stdout": "\n".join([ + "session_start: ok", + f"gateway: {gateway_status}", + ]), + } + return {"code": 2, "stderr": f"Unknown command: {args.command}"} + except SystemExit as error: + return {"code": int(error.code)} + except Exception as error: + return {"code": 1, "stderr": format_error(error)} + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="tdai-memory") + subparsers = parser.add_subparsers(dest="command", required=True) + + prefetch = subparsers.add_parser("prefetch", help="Run Gateway /recall for pre-turn context.") + prefetch.add_argument("--query", required=True) + prefetch.add_argument("--session-key", default="") + prefetch.add_argument("--user-id", default="") + + sync_turn = subparsers.add_parser("sync-turn", help="Run Gateway /capture for a completed turn.") + sync_turn.add_argument("--user-content", required=True) + sync_turn.add_argument("--assistant-content", required=True) + sync_turn.add_argument("--session-key", default="") + sync_turn.add_argument("--session-id", default="") + sync_turn.add_argument("--user-id", default="") + sync_turn.add_argument("--messages-json", default="") + sync_turn.add_argument("--messages-file", default="") + + end_session = subparsers.add_parser("end-session", help="Run Gateway /session/end.") + end_session.add_argument("--session-key", default="") + end_session.add_argument("--user-id", default="") + + session_start = subparsers.add_parser("session-start", help="Check Gateway health.") + session_start.add_argument("--user-id", default="") + + return parser + + +def _read_messages(args: argparse.Namespace) -> list[Any] | None: + if args.messages_json and args.messages_file: + raise ValueError("Use only one of --messages-json or --messages-file") + if not args.messages_json and not args.messages_file: + return None + raw = args.messages_json or Path(args.messages_file).read_text(encoding="utf-8") + parsed = json.loads(raw) + if not isinstance(parsed, list): + raise ValueError("messages must be a JSON array") + return parsed + + +def _check_gateway_health(config: Any, *, request_func: Any) -> str: + result = request_func(config, "/health", method="GET", timeout_ms=min(config.timeout_ms, 3000)) + status = str(result.get("status") or "").strip() + if status not in {"ok", "degraded"}: + raise RuntimeError(f"TDAI Gateway health check failed: {status or 'unknown'}") + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/tdai-memory-cli/tdai_memory_cli/config.py b/packages/tdai-memory-cli/tdai_memory_cli/config.py new file mode 100644 index 00000000..7d7c3011 --- /dev/null +++ b/packages/tdai-memory-cli/tdai_memory_cli/config.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +from .session import generate_default_session_key + +DEFAULT_GATEWAY_URL = "http://127.0.0.1:8420" +DEFAULT_TIMEOUT_MS = 30_000 + + +@dataclass(frozen=True) +class AdapterConfig: + gateway_url: str + api_key: str + default_session_key: str + user_id: str + timeout_ms: int + + +def load_config(env: dict[str, str] | None = None, cwd: str | None = None) -> AdapterConfig: + source = env if env is not None else os.environ + current_cwd = cwd or os.getcwd() + return AdapterConfig( + gateway_url=_normalize_gateway_url(source.get("TDAI_GATEWAY_URL")), + api_key=(source.get("TDAI_GATEWAY_API_KEY") or "").strip(), + default_session_key=(source.get("TDAI_SESSION_KEY") or "").strip() + or generate_default_session_key(current_cwd), + user_id=(source.get("TDAI_USER_ID") or "").strip(), + timeout_ms=_read_positive_int(source.get("TDAI_REQUEST_TIMEOUT_MS"), DEFAULT_TIMEOUT_MS), + ) + + +def _normalize_gateway_url(value: str | None) -> str: + raw = (value or DEFAULT_GATEWAY_URL).strip() or DEFAULT_GATEWAY_URL + return raw.rstrip("/") + + +def _read_positive_int(value: str | None, fallback: int) -> int: + try: + parsed = int(str(value or "").strip()) + except ValueError: + return fallback + return parsed if parsed > 0 else fallback diff --git a/packages/tdai-memory-cli/tdai_memory_cli/formatters.py b/packages/tdai-memory-cli/tdai_memory_cli/formatters.py new file mode 100644 index 00000000..4bb6e389 --- /dev/null +++ b/packages/tdai-memory-cli/tdai_memory_cli/formatters.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Any + +from .gateway_http import GatewayHttpError + + +def format_recall(result: dict[str, Any]) -> str: + context = str(result.get("context") or "").strip() + lines = [ + "# TDAI Memory Recall", + "", + f"strategy: {result.get('strategy', 'unknown')}", + f"memory_count: {result.get('memory_count', 0)}", + ] + if context: + lines.extend(["", "## Context", "", context]) + else: + lines.extend(["", "No recalled memory context was returned."]) + return "\n".join(lines) + + +def format_capture(result: dict[str, Any]) -> str: + return "\n".join([ + "# TDAI Memory Capture", + "", + f"l0_recorded: {result.get('l0_recorded', 0)}", + f"scheduler_notified: {_format_bool(bool(result.get('scheduler_notified')))}", + ]) + + +def format_session_end(result: dict[str, Any]) -> str: + return "\n".join([ + "# TDAI Session End", + "", + f"flushed: {_format_bool(bool(result.get('flushed')))}", + ]) + + +def format_error(error: BaseException) -> str: + status = "error" + path = "" + if isinstance(error, GatewayHttpError): + status = f"HTTP {error.status}" if error.status > 0 else "error" + path = error.path + lines = [ + "# TDAI Memory Adapter Error", + "", + f"{status}: {error}", + ] + if path: + lines.append(f"path: {path}") + return "\n".join(lines) + + +def _format_bool(value: bool) -> str: + return "true" if value else "false" diff --git a/packages/tdai-memory-cli/tdai_memory_cli/gateway_http.py b/packages/tdai-memory-cli/tdai_memory_cli/gateway_http.py new file mode 100644 index 00000000..a40ab267 --- /dev/null +++ b/packages/tdai-memory-cli/tdai_memory_cli/gateway_http.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import http.client +import json +import urllib.error +import urllib.request +from typing import Any + +from .config import AdapterConfig + + +class GatewayHttpError(RuntimeError): + def __init__(self, message: str, *, status: int = 0, path: str = "", body: Any = None): + super().__init__(message) + self.status = status + self.path = path + self.body = body + + +def request_json( + config: AdapterConfig, + path: str, + *, + method: str = "POST", + body: dict[str, Any] | None = None, + timeout_ms: int | None = None, +) -> dict[str, Any]: + data = None + headers: dict[str, str] = {} + if body is not None: + data = json.dumps(_omit_empty(body)).encode("utf-8") + headers["Content-Type"] = "application/json" + if config.api_key: + headers["Authorization"] = f"Bearer {config.api_key}" + + request = urllib.request.Request( + f"{config.gateway_url}{path}", + data=data, + headers=headers, + method=method, + ) + + timeout = (timeout_ms or config.timeout_ms) / 1000 + last_error: BaseException | None = None + for attempt in range(2): + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as error: + raw_body = error.read().decode("utf-8", errors="replace") + parsed = _parse_json(raw_body) + message = parsed.get("error") if isinstance(parsed, dict) else None + raise GatewayHttpError( + message or f"Gateway returned HTTP {error.code}", + status=error.code, + path=path, + body=parsed, + ) from error + except (http.client.RemoteDisconnected, ConnectionResetError) as error: + last_error = error + if attempt == 0: + continue + raise GatewayHttpError(str(error), path=path) from error + except TimeoutError as error: + raise GatewayHttpError( + f"Gateway request timed out after {timeout}s", + path=path, + ) from error + except urllib.error.URLError as error: + raise GatewayHttpError(str(error.reason), path=path) from error + + raise GatewayHttpError(str(last_error), path=path) + + +def _omit_empty(value: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, item in value.items(): + if item is None: + continue + if isinstance(item, str) and not item.strip(): + continue + result[key] = item + return result + + +def _parse_json(value: str) -> Any: + try: + return json.loads(value) if value else {} + except json.JSONDecodeError: + return value[:1000] diff --git a/packages/tdai-memory-cli/tdai_memory_cli/hook.py b/packages/tdai-memory-cli/tdai_memory_cli/hook.py new file mode 100644 index 00000000..3a506a9c --- /dev/null +++ b/packages/tdai-memory-cli/tdai_memory_cli/hook.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from time import time +from typing import Any, Callable + +from .__main__ import run_cli + +HookRunner = Callable[[list[str], dict[str, str] | None, str | None], dict[str, Any]] + + +def main(argv: list[str] | None = None) -> int: + result = run_hook(argv) + if result.get("stdout"): + print(result["stdout"]) + if result.get("stderr"): + print(result["stderr"], file=sys.stderr) + return int(result["code"]) + + +def run_hook( + argv: list[str] | None = None, + *, + stdin: str | None = None, + env: dict[str, str] | None = None, + cwd: str | None = None, + cli_runner: HookRunner | None = None, +) -> dict[str, Any]: + parser = _build_parser() + try: + args = parser.parse_args(argv) + source_env = env if env is not None else os.environ + event = _read_event(stdin) + cli_args = _build_cli_args(args.command, event) + _write_log(source_env, { + "phase": "prepared", + "command": args.command, + "cli_args": cli_args, + "event_keys": sorted(event.keys()), + }) + runner = cli_runner or _run_cli + result = runner(cli_args, source_env, cwd) + _write_log(source_env, { + "phase": "completed", + "command": args.command, + "result_code": result.get("code"), + }) + if result.get("code", 1) == 0: + return _adapt_hook_result(args.command, result, event=event, env=source_env) + if _strict(source_env): + return _adapt_hook_result(args.command, result, event=event, env=source_env) + if _requires_json_stdout(args.command): + return { + "code": 0, + "stdout": _continue_json(args.command, event=event, env=source_env), + "stderr": result.get("stderr") or result.get("stdout") or "TDAI memory hook failed", + } + return { + "code": 0, + "stderr": result.get("stderr") or result.get("stdout") or "TDAI memory hook failed", + } + except SystemExit as error: + return {"code": int(error.code)} + except Exception as error: + _write_log(env if env is not None else os.environ, { + "phase": "error", + "error": str(error), + }) + if _strict(env if env is not None else os.environ): + return {"code": 1, "stderr": f"TDAI memory hook failed: {error}"} + return {"code": 0, "stderr": f"TDAI memory hook skipped: {error}"} + + +def _run_cli(argv: list[str], env: dict[str, str] | None, cwd: str | None) -> dict[str, Any]: + return run_cli(argv, env=env, cwd=cwd) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="tdai-memory-hook") + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("prefetch", help="Read a user-prompt hook event and call tdai-memory prefetch.") + subparsers.add_parser("sync-turn", help="Read a stop hook event and call tdai-memory sync-turn.") + subparsers.add_parser("end-session", help="Read a session-end hook event and call tdai-memory end-session.") + subparsers.add_parser("session-start", help="Read a session-start hook event and call tdai-memory session-start.") + return parser + + +def _adapt_hook_result( + command: str, + result: dict[str, Any], + *, + event: dict[str, Any], + env: dict[str, str] | None, +) -> dict[str, Any]: + if not _requires_json_stdout(command): + return result + adapted = { + "code": result.get("code", 0), + "stdout": _continue_json(command, event=event, env=env), + } + if result.get("stderr"): + adapted["stderr"] = result["stderr"] + return adapted + + +def _requires_json_stdout(command: str) -> bool: + return command in {"sync-turn", "end-session", "session-start"} + + +def _continue_json( + command: str = "", + *, + event: dict[str, Any] | None = None, + env: dict[str, str] | None = None, +) -> str: + payload: dict[str, Any] = {"continue": True} + return json.dumps(payload, separators=(",", ":")) + + +def _read_event(stdin: str | None) -> dict[str, Any]: + raw = sys.stdin.read() if stdin is None else stdin + if not raw.strip(): + return {} + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("hook stdin must be a JSON object") + return parsed + + +def _build_cli_args(command: str, event: dict[str, Any]) -> list[str]: + if command == "prefetch": + return _prefetch_args(event) + if command == "sync-turn": + return _sync_turn_args(event) + if command == "end-session": + return _end_session_args(event) + if command == "session-start": + return _session_start_args(event) + raise ValueError(f"Unknown hook command: {command}") + + +def _prefetch_args(event: dict[str, Any]) -> list[str]: + query = _first_text(event, [ + ("prompt",), + ("user_prompt",), + ("userPrompt",), + ("query",), + ("input",), + ("message",), + ("text",), + ]) + if not query: + raise ValueError("prefetch hook event does not include a user prompt") + args = ["prefetch", "--query", query] + _append_common_identity_args(args, event) + return args + + +def _sync_turn_args(event: dict[str, Any]) -> list[str]: + messages = _messages_from_event(event) + user_content = _first_text(event, [ + ("user_content",), + ("userContent",), + ("prompt",), + ("user_prompt",), + ("userPrompt",), + ]) + assistant_content = _first_text(event, [ + ("assistant_content",), + ("assistantContent",), + ("assistant_response",), + ("assistantResponse",), + ("response",), + ("completion",), + ("output",), + ]) + + if (not user_content or not assistant_content) and messages: + turn = _last_user_assistant_turn(messages) + user_content = user_content or turn[0] + assistant_content = assistant_content or turn[1] + + if not user_content or not assistant_content: + raise ValueError("sync-turn hook event does not include a complete user/assistant turn") + + args = [ + "sync-turn", + "--user-content", + user_content, + "--assistant-content", + assistant_content, + ] + _append_common_identity_args(args, event) + session_id = _session_id(event) + if session_id: + args.extend(["--session-id", session_id]) + if messages: + args.extend(["--messages-json", json.dumps(messages, ensure_ascii=False, separators=(",", ":"))]) + return args + + +def _end_session_args(event: dict[str, Any]) -> list[str]: + args = ["end-session"] + _append_common_identity_args(args, event) + return args + + +def _session_start_args(event: dict[str, Any]) -> list[str]: + args = ["session-start"] + user_id = _first_text(event, [("user_id",), ("userId",), ("user", "id")]) + if user_id: + args.extend(["--user-id", user_id]) + return args + + +def _append_common_identity_args(args: list[str], event: dict[str, Any]) -> None: + session_key = _session_key(event) + if session_key: + args.extend(["--session-key", session_key]) + user_id = _first_text(event, [("user_id",), ("userId",), ("user", "id")]) + if user_id: + args.extend(["--user-id", user_id]) + + +def _session_key(event: dict[str, Any]) -> str: + return _first_text(event, [ + ("session_key",), + ("sessionKey",), + ("session_id",), + ("sessionId",), + ("conversation_id",), + ("conversationId",), + ("thread_id",), + ("threadId",), + ]) + + +def _session_id(event: dict[str, Any]) -> str: + return _first_text(event, [("session_id",), ("sessionId",)]) + + +def _messages_from_event(event: dict[str, Any]) -> list[dict[str, str]]: + direct = _first_list(event, [("messages",), ("conversation",), ("transcript",)]) + if direct: + return _normalize_messages(direct) + transcript_path = _first_text(event, [ + ("transcript_path",), + ("transcriptPath",), + ("conversation_path",), + ("conversationPath",), + ]) + if not transcript_path: + return [] + return _load_transcript(Path(transcript_path)) + + +def _load_transcript(path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + raw = path.read_text(encoding="utf-8") + if not raw.strip(): + return [] + try: + parsed = json.loads(raw) + return _messages_from_json_value(parsed) + except json.JSONDecodeError: + messages: list[Any] = [] + for line in raw.splitlines(): + if not line.strip(): + continue + try: + messages.extend(_messages_from_json_value(json.loads(line))) + except json.JSONDecodeError: + continue + return _normalize_messages(messages) + + +def _messages_from_json_value(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if not isinstance(value, dict): + return [] + for key in ("messages", "conversation", "transcript"): + item = value.get(key) + if isinstance(item, list): + return item + message = value.get("message") + if isinstance(message, dict): + return [message] + codex_message = _message_from_codex_transcript_entry(value) + if codex_message: + return [codex_message] + return [value] + + +def _message_from_codex_transcript_entry(value: dict[str, Any]) -> dict[str, Any]: + entry_type = _string_value(value.get("type")) + payload = value.get("payload") + if not isinstance(payload, dict): + return {} + + if entry_type == "response_item" and payload.get("type") == "message": + role = _string_value(payload.get("role")) + if role in {"user", "assistant", "system", "tool", "developer"}: + return {"role": role, "content": payload.get("content")} + + if entry_type == "event_msg": + event_type = _string_value(payload.get("type")) + if event_type == "user_message": + return {"role": "user", "content": payload.get("message")} + if event_type == "agent_message": + return {"role": "assistant", "content": payload.get("message")} + + return {} + + +def _normalize_messages(values: list[Any]) -> list[dict[str, str]]: + messages: list[dict[str, str]] = [] + for item in values: + if not isinstance(item, dict): + continue + role = _role_from_item(item) + text = _content_from_item(item) + if role and text: + messages.append({"role": role, "content": text}) + return messages + + +def _role_from_item(item: dict[str, Any]) -> str: + role = _string_value(item.get("role")) or _string_value(item.get("type")) or _string_value(item.get("speaker")) + if role in {"human", "user_message"}: + return "user" + if role in {"ai", "assistant_message"}: + return "assistant" + return role if role in {"user", "assistant", "system", "tool"} else "" + + +def _content_from_item(item: dict[str, Any]) -> str: + for key in ("content", "text", "message"): + value = item.get(key) + if isinstance(value, dict): + nested = _content_from_item(value) + if nested: + return nested + text = _text_from_value(value) + if text: + return text + return "" + + +def _last_user_assistant_turn(messages: list[dict[str, str]]) -> tuple[str, str]: + assistant = "" + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if not assistant and message.get("role") == "assistant": + assistant = message.get("content", "") + continue + if assistant and message.get("role") == "user": + return message.get("content", ""), assistant + return "", assistant + + +def _first_text(data: dict[str, Any], paths: list[tuple[str, ...]]) -> str: + for path in paths: + value = _value_at_path(data, path) + text = _text_from_value(value) + if text: + return text + return "" + + +def _first_list(data: dict[str, Any], paths: list[tuple[str, ...]]) -> list[Any]: + for path in paths: + value = _value_at_path(data, path) + if isinstance(value, list): + return value + return [] + + +def _value_at_path(data: dict[str, Any], path: tuple[str, ...]) -> Any: + current: Any = data + for part in path: + if not isinstance(current, dict): + return None + current = current.get(part) + return current + + +def _text_from_value(value: Any) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + parts = [] + for item in value: + text = _text_from_value(item) + if text: + parts.append(text) + return "\n".join(parts).strip() + if isinstance(value, dict): + if _string_value(value.get("type")) == "text": + return _text_from_value(value.get("text")) + for key in ("text", "content", "message"): + text = _text_from_value(value.get(key)) + if text: + return text + return "" + + +def _string_value(value: Any) -> str: + return value.strip() if isinstance(value, str) and value.strip() else "" + + +def _strict(env: dict[str, str] | os._Environ[str]) -> bool: + return str(env.get("TDAI_HOOK_STRICT", "")).strip().lower() in {"1", "true", "yes", "on"} + + +def _write_log(env: dict[str, str] | os._Environ[str], payload: dict[str, Any]) -> None: + path = str(env.get("TDAI_HOOK_LOG", "")).strip() + if not path: + return + entry = {"ts": time(), **payload} + try: + with Path(path).expanduser().open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + "\n") + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/tdai-memory-cli/tdai_memory_cli/session.py b/packages/tdai-memory-cli/tdai_memory_cli/session.py new file mode 100644 index 00000000..62cc195c --- /dev/null +++ b/packages/tdai-memory-cli/tdai_memory_cli/session.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + + +def sanitize_session_part(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "._:-" else "-" for ch in value.strip()) + return cleaned.strip("-") or "default" + + +def generate_default_session_key(cwd: str) -> str: + path = Path(cwd).resolve() + name = sanitize_session_part(path.name) + digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:10] + return f"mcp:{name}:{digest}" + + +def resolve_session_key(value: str | None, default: str) -> str: + candidate = (value or "").strip() + return candidate or default diff --git a/packages/tdai-memory-cli/tests/e2e/test_gateway_ollama.py b/packages/tdai-memory-cli/tests/e2e/test_gateway_ollama.py new file mode 100644 index 00000000..8d2a35d2 --- /dev/null +++ b/packages/tdai-memory-cli/tests/e2e/test_gateway_ollama.py @@ -0,0 +1,47 @@ +import os +import time + +import pytest + +from tdai_memory_cli.__main__ import run_cli + + +pytestmark = pytest.mark.skipif( + os.environ.get("TDAI_MEMORY_CLI_E2E") != "1", + reason="set TDAI_MEMORY_CLI_E2E=1 to run real Gateway e2e", +) + + +def test_real_hook_cli_path_with_gateway_ollama(): + env = { + **os.environ, + "TDAI_GATEWAY_URL": os.environ.get("TDAI_GATEWAY_URL") or "http://127.0.0.1:8420", + "TDAI_REQUEST_TIMEOUT_MS": os.environ.get("TDAI_REQUEST_TIMEOUT_MS") or "120000", + } + session_key = os.environ.get("TDAI_SESSION_KEY") or f"agent:cli-e2e:{int(time.time() * 1000)}" + + capture = run_cli([ + "sync-turn", + "--user-content", + "我正在测试 TencentDB Memory CLI,偏好使用 qwen2.5:7b 和 bge-m3。", + "--assistant-content", + "已记录:本次 CLI 测试偏好 qwen2.5:7b 和 bge-m3。", + "--session-key", + session_key, + ], env=env) + assert capture["code"] == 0, capture.get("stderr") + assert "l0_recorded:" in capture["stdout"] + + recall = run_cli([ + "prefetch", + "--query", + "我刚才说 CLI 测试偏好什么模型?", + "--session-key", + session_key, + ], env=env) + assert recall["code"] == 0, recall.get("stderr") + assert "TDAI Memory Recall" in recall["stdout"] + + session_end = run_cli(["end-session", "--session-key", session_key], env=env) + assert session_end["code"] == 0, session_end.get("stderr") + assert "flushed: true" in session_end["stdout"] diff --git a/packages/tdai-memory-cli/tests/unit/test_cli.py b/packages/tdai-memory-cli/tests/unit/test_cli.py new file mode 100644 index 00000000..39d41f24 --- /dev/null +++ b/packages/tdai-memory-cli/tests/unit/test_cli.py @@ -0,0 +1,99 @@ +from tdai_memory_cli.__main__ import run_cli + + +class FakeRequester: + def __init__(self): + self.calls = [] + + def __call__(self, _config, path, *, body=None, method="POST", **_kwargs): + self.calls.append((path, method, body)) + if path == "/health": + return {"status": "ok"} + if path == "/recall": + return {"context": "remembered context", "strategy": "hybrid", "memory_count": 1} + if path == "/capture": + return {"l0_recorded": 2, "scheduler_notified": True} + if path == "/session/end": + return {"flushed": True} + raise AssertionError(f"unexpected path: {path}") + + +ENV = { + "TDAI_GATEWAY_URL": "http://127.0.0.1:8420", + "TDAI_SESSION_KEY": "session:default", + "TDAI_USER_ID": "user:default", +} + + +def test_prefetch_maps_to_recall(): + requester = FakeRequester() + result = run_cli( + ["prefetch", "--query", "what do I prefer?"], + env=ENV, + request_func=requester, + ) + + assert result["code"] == 0 + assert "remembered context" in result["stdout"] + assert requester.calls[0] == ("/recall", "POST", { + "query": "what do I prefer?", + "session_key": "session:default", + "user_id": "user:default", + }) + + +def test_sync_turn_maps_to_capture(): + requester = FakeRequester() + result = run_cli([ + "sync-turn", + "--user-content", + "hello", + "--assistant-content", + "world", + "--session-key", + "session:explicit", + "--messages-json", + '[{"role":"user","content":"hello"}]', + ], env=ENV, request_func=requester) + + assert result["code"] == 0 + assert "l0_recorded: 2" in result["stdout"] + assert requester.calls[0] == ("/capture", "POST", { + "user_content": "hello", + "assistant_content": "world", + "session_key": "session:explicit", + "session_id": "", + "user_id": "user:default", + "messages": [{"role": "user", "content": "hello"}], + }) + + +def test_end_session_maps_to_session_end(): + requester = FakeRequester() + result = run_cli( + ["end-session", "--user-id", "user:explicit"], + env=ENV, + request_func=requester, + ) + + assert result["code"] == 0 + assert "flushed: true" in result["stdout"] + assert requester.calls[0] == ("/session/end", "POST", { + "session_key": "session:default", + "user_id": "user:explicit", + }) + + +def test_session_start_checks_gateway_only(): + requester = FakeRequester() + result = run_cli( + ["session-start", "--user-id", "codex"], + env=ENV, + request_func=requester, + ) + + assert result["code"] == 0 + assert "session_start: ok" in result["stdout"] + assert "gateway: ok" in result["stdout"] + assert "agents_md:" not in result["stdout"] + assert requester.calls[0] == ("/health", "GET", None) diff --git a/packages/tdai-memory-cli/tests/unit/test_gateway_http.py b/packages/tdai-memory-cli/tests/unit/test_gateway_http.py new file mode 100644 index 00000000..7302e420 --- /dev/null +++ b/packages/tdai-memory-cli/tests/unit/test_gateway_http.py @@ -0,0 +1,36 @@ +import http.client +import json +from unittest.mock import patch + +from tdai_memory_cli.config import load_config +from tdai_memory_cli.gateway_http import request_json + + +class FakeResponse: + def __init__(self, body): + self.body = body + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return self.body + + +def test_request_json_retries_remote_disconnect_once(): + calls = [] + config = load_config({"TDAI_GATEWAY_URL": "http://gateway.local"}, "/tmp/repo") + + def fake_urlopen(_request, timeout): + calls.append(timeout) + if len(calls) == 1: + raise http.client.RemoteDisconnected("remote closed") + return FakeResponse(json.dumps({"context": "ok"}).encode("utf-8")) + + with patch("urllib.request.urlopen", fake_urlopen): + assert request_json(config, "/recall", body={"query": "q"}) == {"context": "ok"} + + assert len(calls) == 2 diff --git a/packages/tdai-memory-cli/tests/unit/test_hook.py b/packages/tdai-memory-cli/tests/unit/test_hook.py new file mode 100644 index 00000000..83452353 --- /dev/null +++ b/packages/tdai-memory-cli/tests/unit/test_hook.py @@ -0,0 +1,202 @@ +import json +from pathlib import Path + +from tdai_memory_cli.hook import run_hook + + +def _runner(calls): + def run(argv, env, cwd): + calls.append((argv, env, cwd)) + return {"code": 0, "stdout": "ok"} + + return run + + +def test_prefetch_reads_prompt_from_stdin_json(): + calls = [] + result = run_hook( + ["prefetch"], + stdin=json.dumps({"prompt": "remember me", "session_id": "session:1", "user_id": "user:1"}), + env={}, + cwd="/repo", + cli_runner=_runner(calls), + ) + + assert result == {"code": 0, "stdout": "ok"} + assert calls[0][0] == [ + "prefetch", + "--query", + "remember me", + "--session-key", + "session:1", + "--user-id", + "user:1", + ] + + +def test_sync_turn_reads_direct_turn_fields(): + calls = [] + result = run_hook( + ["sync-turn"], + stdin=json.dumps({ + "user_content": "hello", + "assistant_content": "world", + "sessionKey": "session:2", + }), + env={}, + cli_runner=_runner(calls), + ) + + assert result["code"] == 0 + assert calls[0][0] == [ + "sync-turn", + "--user-content", + "hello", + "--assistant-content", + "world", + "--session-key", + "session:2", + ] + hook_output = json.loads(result["stdout"]) + assert hook_output["continue"] is True + assert "hookSpecificOutput" not in hook_output + + +def test_sync_turn_reads_last_turn_from_transcript_path(tmp_path: Path): + transcript = tmp_path / "transcript.jsonl" + transcript.write_text( + "\n".join([ + json.dumps({"role": "user", "content": "old"}), + json.dumps({"role": "assistant", "content": "old answer"}), + json.dumps({"role": "user", "content": "current question"}), + json.dumps({"role": "assistant", "content": "current answer"}), + ]), + encoding="utf-8", + ) + calls = [] + + result = run_hook( + ["sync-turn"], + stdin=json.dumps({"transcript_path": str(transcript), "session_id": "session:3"}), + env={}, + cli_runner=_runner(calls), + ) + + assert result["code"] == 0 + argv = calls[0][0] + assert argv[:7] == [ + "sync-turn", + "--user-content", + "current question", + "--assistant-content", + "current answer", + "--session-key", + "session:3", + ] + assert "--messages-json" in argv + + +def test_sync_turn_reads_codex_transcript_jsonl(tmp_path: Path): + transcript = tmp_path / "codex-transcript.jsonl" + transcript.write_text( + "\n".join([ + json.dumps({ + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "codex question"}], + }, + }), + json.dumps({ + "type": "event_msg", + "payload": { + "type": "agent_message", + "message": "codex answer", + "phase": "final_answer", + }, + }), + ]), + encoding="utf-8", + ) + calls = [] + + result = run_hook( + ["sync-turn"], + stdin=json.dumps({"transcript_path": str(transcript), "session_id": "codex-session"}), + env={}, + cli_runner=_runner(calls), + ) + + assert result["code"] == 0 + argv = calls[0][0] + assert argv[:7] == [ + "sync-turn", + "--user-content", + "codex question", + "--assistant-content", + "codex answer", + "--session-key", + "codex-session", + ] + + +def test_end_session_uses_session_key(): + calls = [] + result = run_hook( + ["end-session"], + stdin=json.dumps({"session_id": "session:end"}), + env={}, + cli_runner=_runner(calls), + ) + + assert result["code"] == 0 + assert calls[0][0] == ["end-session", "--session-key", "session:end"] + + +def test_session_start_reads_user_id(): + calls = [] + result = run_hook( + ["session-start"], + stdin=json.dumps({"agents_path": "/repo/AGENTS.md", "user_id": "codex-user"}), + env={}, + cli_runner=_runner(calls), + ) + + assert result["code"] == 0 + hook_output = json.loads(result["stdout"]) + assert hook_output["continue"] is True + assert "hookSpecificOutput" not in hook_output + assert calls[0][0] == [ + "session-start", + "--user-id", + "codex-user", + ] + + +def test_hook_is_non_blocking_by_default_on_cli_failure(): + def fail(_argv, _env, _cwd): + return {"code": 1, "stderr": "gateway down"} + + result = run_hook( + ["prefetch"], + stdin=json.dumps({"prompt": "q"}), + env={}, + cli_runner=fail, + ) + + assert result == {"code": 0, "stderr": "gateway down"} + + +def test_hook_can_be_strict(): + def fail(_argv, _env, _cwd): + return {"code": 1, "stderr": "gateway down"} + + result = run_hook( + ["prefetch"], + stdin=json.dumps({"prompt": "q"}), + env={"TDAI_HOOK_STRICT": "1"}, + cli_runner=fail, + ) + + assert result == {"code": 1, "stderr": "gateway down"} diff --git a/packages/tdai-memory-cli/tests/unit/test_session_config.py b/packages/tdai-memory-cli/tests/unit/test_session_config.py new file mode 100644 index 00000000..00e2514a --- /dev/null +++ b/packages/tdai-memory-cli/tests/unit/test_session_config.py @@ -0,0 +1,30 @@ +from tdai_memory_cli.config import load_config +from tdai_memory_cli.session import generate_default_session_key, resolve_session_key + + +def test_generate_default_session_key_is_stable(): + first = generate_default_session_key("/tmp/tencentdb-agent-memory") + second = generate_default_session_key("/tmp/tencentdb-agent-memory") + assert first == second + assert first.startswith("mcp:tencentdb-agent-memory:") + + +def test_resolve_session_key_prefers_explicit(): + assert resolve_session_key(" explicit ", "default") == "explicit" + assert resolve_session_key("", "default") == "default" + + +def test_load_config_reads_env(): + config = load_config({ + "TDAI_GATEWAY_URL": "http://localhost:9999/", + "TDAI_GATEWAY_API_KEY": " secret ", + "TDAI_SESSION_KEY": "session:abc", + "TDAI_USER_ID": "user-1", + "TDAI_REQUEST_TIMEOUT_MS": "1234", + }, "/fallback") + + assert config.gateway_url == "http://localhost:9999" + assert config.api_key == "secret" + assert config.default_session_key == "session:abc" + assert config.user_id == "user-1" + assert config.timeout_ms == 1234 From 5496e013d0a5906368b7312b26edb3b89b4c08e6 Mon Sep 17 00:00:00 2001 From: "gaosong.1984" Date: Thu, 9 Jul 2026 17:16:24 +0800 Subject: [PATCH 2/2] feat(plugins): add agent memory integrations --- .agents/plugins/marketplace.json | 20 + .claude-plugin/marketplace.json | 20 + .../.claude-plugin/plugin.json | 8 + plugins/tdai-memory-claude-code/.mcp.json | 16 + plugins/tdai-memory-claude-code/README.md | 48 ++ .../examples/claude-code-hooks.json | 38 ++ .../examples/claude-code-mcp.json | 16 + .../tdai-memory-claude-code/hooks/hooks.json | 38 ++ .../skills/tdai-memory/SKILL.md | 122 +++++ plugins/tdai-memory/.codex-plugin/plugin.json | 40 ++ plugins/tdai-memory/.mcp.json | 17 + plugins/tdai-memory/README.md | 49 ++ plugins/tdai-memory/examples/hooks.json | 37 ++ plugins/tdai-memory/hooks/hooks.json | 41 ++ .../tdai-memory/skills/tdai-memory/SKILL.md | 127 +++++ scripts/install-claude-code.sh | 329 +++++++++++ scripts/install-codex.sh | 516 ++++++++++++++++++ 17 files changed, 1482 insertions(+) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugins/tdai-memory-claude-code/.claude-plugin/plugin.json create mode 100644 plugins/tdai-memory-claude-code/.mcp.json create mode 100644 plugins/tdai-memory-claude-code/README.md create mode 100644 plugins/tdai-memory-claude-code/examples/claude-code-hooks.json create mode 100644 plugins/tdai-memory-claude-code/examples/claude-code-mcp.json create mode 100644 plugins/tdai-memory-claude-code/hooks/hooks.json create mode 100644 plugins/tdai-memory-claude-code/skills/tdai-memory/SKILL.md create mode 100644 plugins/tdai-memory/.codex-plugin/plugin.json create mode 100644 plugins/tdai-memory/.mcp.json create mode 100644 plugins/tdai-memory/README.md create mode 100644 plugins/tdai-memory/examples/hooks.json create mode 100644 plugins/tdai-memory/hooks/hooks.json create mode 100644 plugins/tdai-memory/skills/tdai-memory/SKILL.md create mode 100755 scripts/install-claude-code.sh create mode 100755 scripts/install-codex.sh diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..e946ac6d --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "tdai-memory-local", + "interface": { + "displayName": "TencentDB Agent Memory Local" + }, + "plugins": [ + { + "name": "tdai-memory", + "source": { + "source": "local", + "path": "./plugins/tdai-memory" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..8be1c635 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "tdai-memory-local", + "description": "Local TencentDB Agent Memory plugins for Claude Code.", + "owner": { + "name": "TencentDB Agent Memory" + }, + "plugins": [ + { + "name": "tdai-memory-claude-code", + "description": "Connect Claude Code to TencentDB Agent Memory through MCP lookup tools and agent hooks.", + "author": { + "name": "TencentDB Agent Memory" + }, + "category": "productivity", + "source": "./plugins/tdai-memory-claude-code", + "homepage": "https://github.com/TencentDB/TencentDB-Agent-Memory" + } + ] +} diff --git a/plugins/tdai-memory-claude-code/.claude-plugin/plugin.json b/plugins/tdai-memory-claude-code/.claude-plugin/plugin.json new file mode 100644 index 00000000..ee330d45 --- /dev/null +++ b/plugins/tdai-memory-claude-code/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "tdai-memory-claude-code", + "version": "0.1.0", + "description": "TencentDB Agent Memory integration for Claude Code.", + "author": { + "name": "TencentDB Agent Memory" + } +} diff --git a/plugins/tdai-memory-claude-code/.mcp.json b/plugins/tdai-memory-claude-code/.mcp.json new file mode 100644 index 00000000..c939325e --- /dev/null +++ b/plugins/tdai-memory-claude-code/.mcp.json @@ -0,0 +1,16 @@ +{ + "tdai-memory": { + "command": "python3", + "args": [ + "-m", + "tdai_memory_mcp" + ], + "env": { + "PYTHONPATH": "/packages/tdai-memory-mcp", + "TDAI_GATEWAY_URL": "http://127.0.0.1:8421", + "TDAI_GATEWAY_API_KEY": "", + "TDAI_SESSION_KEY": "agent:mcp-claude-code", + "TDAI_USER_ID": "claude-code" + } + } +} diff --git a/plugins/tdai-memory-claude-code/README.md b/plugins/tdai-memory-claude-code/README.md new file mode 100644 index 00000000..cf157e17 --- /dev/null +++ b/plugins/tdai-memory-claude-code/README.md @@ -0,0 +1,48 @@ +# TencentDB Agent Memory For Claude Code + +This Claude Code plugin is a thin integration layer over the shared adapters: + +- `../../packages/tdai-memory-mcp` +- `../../packages/tdai-memory-cli` + +It does not copy or modify the existing Gateway, Core, Hermes provider, or +OpenClaw plugin code. + +## Surfaces + +- MCP tools: + - `tdai_memory_search`: search structured long-term memories. + - `tdai_conversation_search`: search raw conversation history. +- Hooks: + - `SessionStart`: ensure the Gateway is running. + - `UserPromptSubmit`: prefetch memory for the submitted prompt. + - `Stop`: capture the completed user/assistant turn. +- `CLAUDE.md`: + - stable static instructions that tell Claude Code when to use memory tools. + +The plugin assumes Gateway is already running. It does not start, configure, +stop, or watch the Gateway process. + +## Install + +From the repository root: + +```bash +scripts/install-claude-code.sh +``` + +The installer is idempotent. It editable-installs the shared Python packages, +writes Claude Code plugin MCP and hook config, registers the local marketplace, +installs the plugin, and creates or updates only the marked TDAI block in +`~/.claude/CLAUDE.md`. + +Default plugin diagnostics are stored under `~/.claude/tdai-memory/`: + +- Hook diagnostics: `~/.claude/tdai-memory/logs/hooks.jsonl` + +## Notes + +The plugin keeps capture behavior in CLI hooks and only exposes lookup tools +through MCP. Gateway health diagnostics remain a side channel through +`tdai-memory-mcp health`; they are not exposed to Claude Code as LLM-callable +MCP tools. diff --git a/plugins/tdai-memory-claude-code/examples/claude-code-hooks.json b/plugins/tdai-memory-claude-code/examples/claude-code-hooks.json new file mode 100644 index 00000000..11ea482b --- /dev/null +++ b/plugins/tdai-memory-claude-code/examples/claude-code-hooks.json @@ -0,0 +1,38 @@ +{ + "description": "Example only. scripts/install-claude-code.sh writes the real absolute paths to ../hooks/hooks.json.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook session-start", + "timeout": 60 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook prefetch", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook sync-turn", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/plugins/tdai-memory-claude-code/examples/claude-code-mcp.json b/plugins/tdai-memory-claude-code/examples/claude-code-mcp.json new file mode 100644 index 00000000..c939325e --- /dev/null +++ b/plugins/tdai-memory-claude-code/examples/claude-code-mcp.json @@ -0,0 +1,16 @@ +{ + "tdai-memory": { + "command": "python3", + "args": [ + "-m", + "tdai_memory_mcp" + ], + "env": { + "PYTHONPATH": "/packages/tdai-memory-mcp", + "TDAI_GATEWAY_URL": "http://127.0.0.1:8421", + "TDAI_GATEWAY_API_KEY": "", + "TDAI_SESSION_KEY": "agent:mcp-claude-code", + "TDAI_USER_ID": "claude-code" + } + } +} diff --git a/plugins/tdai-memory-claude-code/hooks/hooks.json b/plugins/tdai-memory-claude-code/hooks/hooks.json new file mode 100644 index 00000000..7e003432 --- /dev/null +++ b/plugins/tdai-memory-claude-code/hooks/hooks.json @@ -0,0 +1,38 @@ +{ + "description": "TencentDB Agent Memory agent hooks for Claude Code", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=SessionStart TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook session-start", + "timeout": 60 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=UserPromptSubmit TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook prefetch", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=Stop TDAI_GATEWAY_URL=http://127.0.0.1:8421 TDAI_USER_ID=claude-code TDAI_HOOK_LOG=/.claude/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook sync-turn", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/plugins/tdai-memory-claude-code/skills/tdai-memory/SKILL.md b/plugins/tdai-memory-claude-code/skills/tdai-memory/SKILL.md new file mode 100644 index 00000000..53a355ad --- /dev/null +++ b/plugins/tdai-memory-claude-code/skills/tdai-memory/SKILL.md @@ -0,0 +1,122 @@ +--- +name: tdai-memory +description: This skill should be used when working with TencentDB Agent Memory / memory-tencentdb in Claude Code, including memory recall, conversation capture, Claude Code hooks, CLAUDE.md memory prompt setup, MCP tool usage, or debugging the TDAI memory CLI. +version: 0.1.0 +--- + +# TDAI Memory + +TencentDB Agent Memory exposes long-term memory through three Claude Code-facing surfaces: + +- MCP tools for agent-facing memory lookup. +- Hook CLI commands for health checks, recall prefetch, capture, and flush. +- User-level `CLAUDE.md` stable memory capability instructions installed by `scripts/install-claude-code.sh`. + +## Choose The Surface + +Use MCP tools for answering with memory: + +- `tdai_memory_search`: search structured long-term memories (L1). +- `tdai_conversation_search`: search raw conversation history (L0). + +Use CLI commands for hook behavior: + +- `session-start`: check Gateway health. +- `prefetch`: recall memory context for a user query. +- `sync-turn`: capture a completed user/assistant turn. +- `end-session`: flush session pipeline work. + +Do not use CLI commands as a substitute for MCP lookup unless debugging the adapter. + +## Environment + +The Claude Code plugin is a thin integration layer. The reusable adapter packages live +under `packages/` and should be installed so `tdai-memory-mcp`, +`tdai-memory`, and `tdai-memory-hook` are importable or on PATH. + +Common Gateway environment: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8421 +TDAI_USER_ID=claude-code +``` + +## Install + +Use the install script to set up the plugin, hooks, and user-level `CLAUDE.md`: + +```bash +/scripts/install-claude-code.sh +``` + +The script is idempotent. It installs shared packages, registers the local +Claude Code plugin marketplace, installs the `tdai-memory-claude-code` plugin, +creates or updates only the marked TDAI block in `~/.claude/CLAUDE.md`, and +writes plugin MCP/hook config under `plugins/tdai-memory-claude-code/`. + +Default runtime files are user-level and stable across repositories: + +- Hook diagnostics: `~/.claude/tdai-memory/logs/hooks.jsonl` +- Plugin hooks: `plugins/tdai-memory-claude-code/hooks/hooks.json` +- Plugin MCP config: `plugins/tdai-memory-claude-code/.mcp.json` + +## CLI Commands + +### Session Start + +Normally called by the `SessionStart` hook to check Gateway availability: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8421 \ +tdai-memory session-start +``` + +### Prefetch + +Normally called by the `UserPromptSubmit` hook: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8421 \ +tdai-memory prefetch --query "" +``` + +### Sync Turn + +Normally called by the `Stop` hook: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8421 \ +tdai-memory sync-turn \ + --user-content "" \ + --assistant-content "" +``` + +### End Session + +Use when the Claude Code session/process ends or when manually flushing memory work: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8421 \ +tdai-memory end-session +``` + +## Hook Commands + +Use hook wrappers when reading Claude Code hook JSON from stdin: + +```bash +tdai-memory-hook session-start +tdai-memory-hook prefetch +tdai-memory-hook sync-turn +tdai-memory-hook end-session +``` + +Hook failures are non-blocking by default. Set `TDAI_HOOK_STRICT=1` only for tests or debugging. + +## Rules + +- Prefer MCP tools when using memory to answer a user question. +- Use CLI commands for health checks, capture, and flush. +- Keep `CLAUDE.md` updates in the install script, not runtime hooks. +- Do not store dynamic recall results in `CLAUDE.md`. +- Do not modify the original Gateway, Core, Hermes provider, or OpenClaw plugin code for Claude Code adapter work. diff --git a/plugins/tdai-memory/.codex-plugin/plugin.json b/plugins/tdai-memory/.codex-plugin/plugin.json new file mode 100644 index 00000000..9ea99605 --- /dev/null +++ b/plugins/tdai-memory/.codex-plugin/plugin.json @@ -0,0 +1,40 @@ +{ + "name": "tdai-memory", + "version": "0.1.0", + "description": "TencentDB Agent Memory integration for Codex.", + "author": { + "name": "TencentDB Agent Memory" + }, + "homepage": "https://github.com/TencentDB/TencentDB-Agent-Memory", + "license": "MIT", + "keywords": [ + "memory", + "mcp", + "tencentdb", + "agent-memory", + "codex" + ], + "mcpServers": "./.mcp.json", + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "TencentDB Agent Memory", + "shortDescription": "Search and capture layered long-term memory from Codex.", + "longDescription": "TencentDB Agent Memory connects Codex to a four-layer memory system through MCP lookup tools, hook adapter commands, and static workspace guidance for memory usage.", + "developerName": "TencentDB Agent Memory", + "category": "Productivity", + "capabilities": [ + "MCP", + "Skills", + "Memory" + ], + "websiteURL": "https://github.com/TencentDB/TencentDB-Agent-Memory", + "defaultPrompt": [ + "Search my long-term memory for this project", + "Use memory before answering this question", + "Capture this turn into TencentDB memory" + ], + "brandColor": "#2563EB", + "screenshots": [] + } +} diff --git a/plugins/tdai-memory/.mcp.json b/plugins/tdai-memory/.mcp.json new file mode 100644 index 00000000..8367dc53 --- /dev/null +++ b/plugins/tdai-memory/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "tdai-memory": { + "command": "python3", + "args": [ + "-m", + "tdai_memory_mcp" + ], + "env": { + "TDAI_GATEWAY_URL": "http://127.0.0.1:8420", + "TDAI_GATEWAY_API_KEY": "", + "TDAI_SESSION_KEY": "agent:mcp-codex", + "TDAI_USER_ID": "codex" + } + } + } +} diff --git a/plugins/tdai-memory/README.md b/plugins/tdai-memory/README.md new file mode 100644 index 00000000..c8e8fca8 --- /dev/null +++ b/plugins/tdai-memory/README.md @@ -0,0 +1,49 @@ +# TencentDB Agent Memory Codex Plugin + +This is the Codex-facing integration layer for TencentDB Agent Memory. + +It intentionally contains only plugin metadata, MCP declaration, hook examples, +and skills. The reusable adapter code lives in: + +- `../../packages/tdai-memory-mcp` +- `../../packages/tdai-memory-cli` + +Use the repository install script to install shared packages, register the +Codex plugin, install bundled plugin hooks, and create/update `AGENTS.md` under +`~/.codex`: + +```bash +../../scripts/install-codex.sh +``` + +The install script is idempotent: + +- editable-installs `../../packages/tdai-memory-mcp` +- editable-installs `../../packages/tdai-memory-cli` +- runs `codex plugin marketplace add` +- runs `codex plugin add tdai-memory@tdai-memory-local` +- registers the `tdai-memory` MCP server with `codex mcp add` +- creates or updates `~/.codex/AGENTS.md` +- writes bundled plugin hooks to `hooks/hooks.json` before plugin installation +- removes legacy tdai-memory entries from `~/.codex/hooks.json` +- configures MCP tool approval policy in `~/.codex/config.toml` +- stores hook diagnostics under `~/.codex/tdai-memory/logs/hooks.jsonl` + +`AGENTS.md` setup is intentionally handled only at install time. Runtime hook +commands do not create, rewrite, or dynamically inject `AGENTS.md` content. +Pass `--agents-path /path/to/AGENTS.md` only when you explicitly want a +different target. + +The installer does not start, configure, stop, or watch the Gateway process. +MCP lookup tools and hook commands connect to the configured Gateway URL. Start +Gateway separately before relying on prefetch or capture hooks. + +Codex plugin-bundled hooks require manual trust review after installation. +Open `/hooks` in Codex, review the three TDAI hook commands, and trust them +before expecting automatic prefetch/capture to run. `examples/hooks.json` is +retained only as a manual/debugging reference for older Codex builds or +non-plugin setups. + +The installer sets `default_tools_approval_mode = "auto"` for the tdai-memory +MCP server and explicitly approves the two read-only lookup tools: +`tdai_memory_search` and `tdai_conversation_search`. diff --git a/plugins/tdai-memory/examples/hooks.json b/plugins/tdai-memory/examples/hooks.json new file mode 100644 index 00000000..409280a5 --- /dev/null +++ b/plugins/tdai-memory/examples/hooks.json @@ -0,0 +1,37 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=SessionStart TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex python3 -m tdai_memory_cli.hook session-start", + "timeout": 60 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=UserPromptSubmit TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex python3 -m tdai_memory_cli.hook prefetch", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=Stop TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex python3 -m tdai_memory_cli.hook sync-turn", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/plugins/tdai-memory/hooks/hooks.json b/plugins/tdai-memory/hooks/hooks.json new file mode 100644 index 00000000..a3594d75 --- /dev/null +++ b/plugins/tdai-memory/hooks/hooks.json @@ -0,0 +1,41 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=SessionStart TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook session-start", + "timeout": 60, + "statusMessage": "Checking TDAI memory Gateway" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=UserPromptSubmit TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook prefetch", + "timeout": 30, + "statusMessage": "Prefetching TDAI memory" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "TDAI_MEMORY_HOOK_MARKER=Stop TDAI_GATEWAY_URL=http://127.0.0.1:8420 TDAI_USER_ID=codex TDAI_HOOK_LOG=/.codex/tdai-memory/logs/hooks.jsonl PYTHONPATH=/packages/tdai-memory-cli:/packages/tdai-memory-mcp python3 -m tdai_memory_cli.hook sync-turn", + "timeout": 60, + "statusMessage": "Capturing TDAI memory turn" + } + ] + } + ] + } +} diff --git a/plugins/tdai-memory/skills/tdai-memory/SKILL.md b/plugins/tdai-memory/skills/tdai-memory/SKILL.md new file mode 100644 index 00000000..d4fd8881 --- /dev/null +++ b/plugins/tdai-memory/skills/tdai-memory/SKILL.md @@ -0,0 +1,127 @@ +--- +name: tdai-memory +description: Use when working with TencentDB Agent Memory / memory-tencentdb in Codex, including memory recall, conversation capture, Codex hooks, AGENTS.md memory prompt setup, MCP tool usage, or debugging the TDAI memory CLI. +--- + +# TDAI Memory + +TencentDB Agent Memory exposes long-term memory through three Codex-facing surfaces: + +- MCP tools for agent-facing memory lookup. +- Hook CLI commands for health checks, recall prefetch, capture, and flush. +- User-level `AGENTS.md` stable memory capability instructions installed by `scripts/install-codex.sh`. + +## Choose The Surface + +Use MCP tools for answering with memory: + +- `tdai_memory_search`: search structured long-term memories (L1). +- `tdai_conversation_search`: search raw conversation history (L0). + +Use CLI commands for hook behavior: + +- `session-start`: check Gateway health. +- `prefetch`: recall memory context for a user query. +- `sync-turn`: capture a completed user/assistant turn. +- `end-session`: flush session pipeline work. + +Do not use CLI commands as a substitute for MCP lookup unless debugging the adapter. + +## Environment + +The Codex plugin is a thin integration layer. The reusable adapter packages live +under `packages/` and should be installed so `tdai-memory-mcp`, +`tdai-memory`, and `tdai-memory-hook` are importable or on PATH. + +Common Gateway environment: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8420 +TDAI_USER_ID=codex +``` + +## Install + +Use the install script to set up the plugin, hooks, and user-level `AGENTS.md`: + +```bash +/scripts/install-codex.sh +``` + +The script is idempotent. It installs shared packages, registers the local +Codex plugin, registers the `tdai-memory` MCP server with Codex, creates or +updates only the marked TDAI block in `~/.codex/AGENTS.md`, writes bundled +plugin hooks to `plugins/tdai-memory/hooks/hooks.json`, and removes legacy +tdai-memory entries from `~/.codex/hooks.json`. Use +`--agents-path /path/to/AGENTS.md` only when a non-default AGENTS file should +be updated. + +Codex plugin-bundled hooks require manual trust review after installation. +Open `/hooks`, review the three TDAI hook commands, and trust them before +expecting automatic prefetch/capture to run. + +Default runtime files are user-level and stable across repositories: + +- Hook diagnostics: `~/.codex/tdai-memory/logs/hooks.jsonl` +- Bundled plugin hooks: `plugins/tdai-memory/hooks/hooks.json` + +## CLI Commands + +### Session Start + +Normally called by the `SessionStart` hook to check Gateway availability: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8420 \ +tdai-memory session-start +``` + +### Prefetch + +Normally called by the `UserPromptSubmit` hook: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8420 \ +tdai-memory prefetch --query "" +``` + +### Sync Turn + +Normally called by the `Stop` hook: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8420 \ +tdai-memory sync-turn \ + --user-content "" \ + --assistant-content "" +``` + +### End Session + +Use when the Codex session/process ends or when manually flushing memory work: + +```bash +TDAI_GATEWAY_URL=http://127.0.0.1:8420 \ +tdai-memory end-session +``` + +## Hook Commands + +Use hook wrappers when reading Codex hook JSON from stdin: + +```bash +tdai-memory-hook session-start +tdai-memory-hook prefetch +tdai-memory-hook sync-turn +tdai-memory-hook end-session +``` + +Hook failures are non-blocking by default. Set `TDAI_HOOK_STRICT=1` only for tests or debugging. + +## Rules + +- Prefer MCP tools when using memory to answer a user question. +- Use CLI commands for health checks, capture, and flush. +- Keep `AGENTS.md` updates in the install script, not runtime hooks. +- Do not store dynamic recall results in `AGENTS.md`. +- Do not modify the original Gateway, Core, Hermes provider, or OpenClaw plugin code for Codex adapter work. diff --git a/scripts/install-claude-code.sh b/scripts/install-claude-code.sh new file mode 100755 index 00000000..521a11fd --- /dev/null +++ b/scripts/install-claude-code.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CLAUDE_DIR="${HOME}/.claude" +CLAUDE_MD_PATH="${CLAUDE_DIR}/CLAUDE.md" +TDAI_MEMORY_DIR="${CLAUDE_DIR}/tdai-memory" +USER_ID="${TDAI_USER_ID:-claude-code}" +GATEWAY_URL="${TDAI_GATEWAY_URL:-http://127.0.0.1:8421}" +HOOK_LOG="${TDAI_HOOK_LOG:-${TDAI_MEMORY_DIR}/logs/hooks.jsonl}" +START_MARKER="" +END_MARKER="" +MARKER_PREFIX="TDAI_MEMORY_HOOK_MARKER=" +PLUGIN_DIR="${REPO_ROOT}/plugins/tdai-memory-claude-code" +PLUGIN_ID="tdai-memory-claude-code@tdai-memory-local" +MCP_SERVER_NAME="tdai-memory" + +usage() { + cat <<'USAGE' +Usage: scripts/install-claude-code.sh [options] + +Install TencentDB Agent Memory for Claude Code: + - editable-install shared Python adapter packages + - register and install the Claude Code plugin from this repo + - create or update ~/.claude/CLAUDE.md by default + - write plugin MCP config to plugins/tdai-memory-claude-code/.mcp.json + - write plugin hooks to plugins/tdai-memory-claude-code/hooks/hooks.json + +Options: + --claude-md-path PATH CLAUDE.md path to create/update (default: ~/.claude/CLAUDE.md) + --user-id VALUE Memory user id (default: $TDAI_USER_ID or claude-code) + --gateway-url URL Gateway URL (default: http://127.0.0.1:8421) + --hook-log PATH Hook diagnostic JSONL log path (default: ~/.claude/tdai-memory/logs/hooks.jsonl) + -h, --help Show this help +USAGE +} + +normalize_path() { + local path="$1" + case "${path}" in + "~") + path="${HOME}" + ;; + "~/"*) + path="${HOME}/${path#~/}" + ;; + esac + if [[ "${path}" != /* ]]; then + path="${PWD}/${path}" + fi + printf '%s\n' "${path}" +} + +shell_quote() { + printf '%q' "$1" +} + +json_string() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '"%s"' "${value}" +} + +json_escape_path() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '%s' "${value}" +} + +write_file_if_changed() { + local target="$1" + local tmp_file="$2" + local existed=0 + if [[ -f "${target}" ]]; then + existed=1 + if cmp -s "${tmp_file}" "${target}"; then + rm -f "${tmp_file}" + printf 'unchanged\n' + return + fi + fi + + mkdir -p "$(dirname "${target}")" + mv "${tmp_file}" "${target}" + if [[ "${existed}" -eq 1 ]]; then + printf 'updated\n' + else + printf 'created\n' + fi +} + +build_memory_block() { + cat <L1->L2->L3) with automatic conversation capture, +structured memory extraction, scene blocks, and persona synthesis. + +## Memory Tool Usage + +When the injected memory context is insufficient, actively retrieve deeper context with: + +- \`tdai_memory_search\`: Search structured long-term memories (L1). Use it for user preferences, important historical events, instructions, and durable facts. +- \`tdai_conversation_search\`: Search raw conversation history (L0). Use it for exact wording, timeline details, or to verify structured memory results. + +Call limit: +- Use \`tdai_memory_search\` and \`tdai_conversation_search\` at most 3 times total per turn. +- If the first search has no result, try different keywords or switch tools. +- If 3 searches still find nothing, answer from available context and say the information is not in memory. +${END_MARKER} +EOF +} + +update_claude_md() { + local target="$1" + local block_file + local output_file + block_file="$(mktemp)" + output_file="$(mktemp)" + build_memory_block > "${block_file}" + + if [[ ! -f "${target}" ]]; then + mkdir -p "$(dirname "${target}")" + mv "${block_file}" "${target}" + rm -f "${output_file}" + printf 'created\n' + return + fi + + if grep -Fq "${START_MARKER}" "${target}" && grep -Fq "${END_MARKER}" "${target}"; then + awk -v start="${START_MARKER}" -v end="${END_MARKER}" -v block_file="${block_file}" ' + BEGIN { + while ((getline line < block_file) > 0) { + block = block line ORS + } + close(block_file) + skipping = 0 + replaced = 0 + } + index($0, start) && !replaced { + printf "%s", block + skipping = 1 + replaced = 1 + next + } + skipping && index($0, end) { + skipping = 0 + next + } + !skipping { + print + } + ' "${target}" > "${output_file}" + rm -f "${block_file}" + write_file_if_changed "${target}" "${output_file}" + return + fi + + { + sed -e '${/^$/d;}' "${target}" + printf '\n\n' + cat "${block_file}" + printf '\n' + } > "${output_file}" + rm -f "${block_file}" + write_file_if_changed "${target}" "${output_file}" >/dev/null + printf 'appended\n' +} + +build_hook_command() { + local marker="$1" + local command="$2" + printf '%s%s TDAI_GATEWAY_URL=%s TDAI_USER_ID=%s TDAI_HOOK_LOG=%s PYTHONPATH=%s python3 -m tdai_memory_cli.hook %s' \ + "${MARKER_PREFIX}" \ + "${marker}" \ + "$(shell_quote "${GATEWAY_URL}")" \ + "$(shell_quote "${USER_ID}")" \ + "$(shell_quote "${HOOK_LOG}")" \ + "$(shell_quote "${REPO_ROOT}/packages/tdai-memory-cli:${REPO_ROOT}/packages/tdai-memory-mcp")" \ + "${command}" +} + +write_plugin_hooks() { + local target="${PLUGIN_DIR}/hooks/hooks.json" + local tmp_file + local session_start_command + local prefetch_command + local sync_turn_command + tmp_file="$(mktemp)" + session_start_command="$(build_hook_command "SessionStart" "session-start")" + prefetch_command="$(build_hook_command "UserPromptSubmit" "prefetch")" + sync_turn_command="$(build_hook_command "Stop" "sync-turn")" + + cat > "${tmp_file}" < "${tmp_file}" <&2 + usage >&2 + exit 2 + ;; + esac +done + +CLAUDE_DIR="$(normalize_path "${CLAUDE_DIR}")" +mkdir -p "${CLAUDE_DIR}" +CLAUDE_MD_PATH="$(normalize_path "${CLAUDE_MD_PATH}")" +HOOK_LOG="$(normalize_path "${HOOK_LOG}")" + +echo "Installing shared Python adapter packages..." +python3 -m pip install --user --no-build-isolation -e "${REPO_ROOT}/packages/tdai-memory-mcp" -e "${REPO_ROOT}/packages/tdai-memory-cli" + +echo "Writing Claude Code plugin config..." +mkdir -p "$(dirname "${HOOK_LOG}")" +CLAUDE_MD_UPDATE="$(update_claude_md "${CLAUDE_MD_PATH}")" +PLUGIN_MCP_UPDATE="$(write_plugin_mcp)" +PLUGIN_HOOKS_UPDATE="$(write_plugin_hooks)" + +echo "Registering Claude Code marketplace..." +claude plugin marketplace add "${REPO_ROOT}" + +echo "Installing Claude Code plugin..." +claude plugin uninstall "${PLUGIN_ID}" >/dev/null 2>&1 || true +claude plugin install "${PLUGIN_ID}" --scope user + +echo "claude_md: ${CLAUDE_MD_UPDATE} ${CLAUDE_MD_PATH}" +echo "plugin_mcp: ${PLUGIN_MCP_UPDATE} ${PLUGIN_DIR}/.mcp.json" +echo "plugin_hooks: ${PLUGIN_HOOKS_UPDATE} ${PLUGIN_DIR}/hooks/hooks.json" +echo "plugin_id: ${PLUGIN_ID}" +echo "TDAI memory files:" +echo " gateway url : ${GATEWAY_URL}" +echo " hook log : ${HOOK_LOG}" +echo "TencentDB Agent Memory Claude Code install complete." diff --git a/scripts/install-codex.sh b/scripts/install-codex.sh new file mode 100755 index 00000000..e446a1bd --- /dev/null +++ b/scripts/install-codex.sh @@ -0,0 +1,516 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +AGENTS_PATH="${HOME}/.codex/AGENTS.md" +CODEX_DIR="${HOME}/.codex" +TDAI_MEMORY_DIR="${HOME}/.codex/tdai-memory" +USER_ID="${TDAI_USER_ID:-codex}" +GATEWAY_URL="${TDAI_GATEWAY_URL:-http://127.0.0.1:8420}" +HOOK_LOG="${TDAI_HOOK_LOG:-${TDAI_MEMORY_DIR}/logs/hooks.jsonl}" +START_MARKER="" +END_MARKER="" +MARKER_PREFIX="TDAI_MEMORY_HOOK_MARKER=" +PLUGIN_ID="tdai-memory@tdai-memory-local" +MCP_SERVER_NAME="tdai-memory" +MCP_TOOL_MEMORY_SEARCH="tdai_memory_search" +MCP_TOOL_CONVERSATION_SEARCH="tdai_conversation_search" + +usage() { + cat <<'USAGE' +Usage: scripts/install-codex.sh [options] + +Install TencentDB Agent Memory for Codex: + - editable-install shared Python adapter packages + - register and install the Codex plugin from this repo + - create or update AGENTS.md under ~/.codex by default + - write bundled plugin hooks to plugins/tdai-memory/hooks/hooks.json + - remove legacy tdai-memory entries from ~/.codex/hooks.json + - configure tdai-memory MCP tool approval policy in ~/.codex/config.toml + +Options: + --agents-path PATH AGENTS.md path to create/update (default: ~/.codex/AGENTS.md) + --workspace PATH Deprecated alias for --agents-path PATH/AGENTS.md + --user-id VALUE Memory user id (default: $TDAI_USER_ID or codex) + --gateway-url URL Gateway URL (default: http://127.0.0.1:8420) + --hook-log PATH Hook diagnostic JSONL log path (default: ~/.codex/tdai-memory/logs/hooks.jsonl) + -h, --help Show this help +USAGE +} + +normalize_path() { + local path="$1" + case "${path}" in + "~") + path="${HOME}" + ;; + "~/"*) + path="${HOME}/${path#~/}" + ;; + esac + if [[ "${path}" != /* ]]; then + path="${PWD}/${path}" + fi + printf '%s\n' "${path}" +} + +shell_quote() { + printf '%q' "$1" +} + +json_string() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '"%s"' "${value}" +} + +write_file_if_changed() { + local target="$1" + local tmp_file="$2" + local existed=0 + if [[ -f "${target}" ]]; then + existed=1 + if cmp -s "${tmp_file}" "${target}"; then + rm -f "${tmp_file}" + printf 'unchanged\n' + return + fi + fi + + mkdir -p "$(dirname "${target}")" + mv "${tmp_file}" "${target}" + if [[ "${existed}" -eq 1 ]]; then + printf 'updated\n' + else + printf 'created\n' + fi +} + +build_memory_block() { + cat <L1->L2->L3) with automatic conversation capture, +structured memory extraction, scene blocks, and persona synthesis. + +## Memory Tool Usage + +When the injected memory context is insufficient, actively retrieve deeper context with: + +- \`tdai_memory_search\`: Search structured long-term memories (L1). Use it for user preferences, important historical events, instructions, and durable facts. +- \`tdai_conversation_search\`: Search raw conversation history (L0). Use it for exact wording, timeline details, or to verify structured memory results. + +Call limit: +- Use \`tdai_memory_search\` and \`tdai_conversation_search\` at most 3 times total per turn. +- If the first search has no result, try different keywords or switch tools. +- If 3 searches still find nothing, answer from available context and say the information is not in memory. +${END_MARKER} +EOF +} + +update_agents_md() { + local target="$1" + local block_file + local output_file + block_file="$(mktemp)" + output_file="$(mktemp)" + build_memory_block > "${block_file}" + + if [[ ! -f "${target}" ]]; then + mkdir -p "$(dirname "${target}")" + mv "${block_file}" "${target}" + rm -f "${output_file}" + printf 'created\n' + return + fi + + if grep -Fq "${START_MARKER}" "${target}" && grep -Fq "${END_MARKER}" "${target}"; then + awk -v start="${START_MARKER}" -v end="${END_MARKER}" -v block_file="${block_file}" ' + BEGIN { + while ((getline line < block_file) > 0) { + block = block line ORS + } + close(block_file) + skipping = 0 + replaced = 0 + } + index($0, start) && !replaced { + printf "%s", block + skipping = 1 + replaced = 1 + next + } + skipping && index($0, end) { + skipping = 0 + next + } + !skipping { + print + } + ' "${target}" > "${output_file}" + rm -f "${block_file}" + write_file_if_changed "${target}" "${output_file}" + return + fi + + { + sed -e '${/^$/d;}' "${target}" + printf '\n\n' + cat "${block_file}" + printf '\n' + } > "${output_file}" + rm -f "${block_file}" + write_file_if_changed "${target}" "${output_file}" >/dev/null + printf 'appended\n' +} + +build_hook_command() { + local marker="$1" + local command="$2" + printf '%s%s TDAI_GATEWAY_URL=%s TDAI_USER_ID=%s TDAI_HOOK_LOG=%s PYTHONPATH=%s python3 -m tdai_memory_cli.hook %s' \ + "${MARKER_PREFIX}" \ + "${marker}" \ + "$(shell_quote "${GATEWAY_URL}")" \ + "$(shell_quote "${USER_ID}")" \ + "$(shell_quote "${HOOK_LOG}")" \ + "$(shell_quote "${REPO_ROOT}/packages/tdai-memory-cli:${REPO_ROOT}/packages/tdai-memory-mcp")" \ + "${command}" +} + +write_plugin_hooks() { + local target="${REPO_ROOT}/plugins/tdai-memory/hooks/hooks.json" + local tmp_file + local session_start_command + local prefetch_command + local sync_turn_command + tmp_file="$(mktemp)" + session_start_command="$(build_hook_command "SessionStart" "session-start")" + prefetch_command="$(build_hook_command "UserPromptSubmit" "prefetch")" + sync_turn_command="$(build_hook_command "Stop" "sync-turn")" + + cat > "${tmp_file}" < { + if (!isObject(hook) || typeof hook.command !== "string") return false; + return hook.command.startsWith(marker) || + hook.command.startsWith(markerPrefix) || + hook.command.includes("tdai_memory_cli.hook") || + hook.command.includes("codex-plugin/memory-cli") || + hook.command.includes("packages/tdai-memory-cli"); + }); +} + +let payload; +try { + payload = JSON.parse(fs.readFileSync(hooksPath, "utf8")); +} catch (error) { + console.error(`Invalid hooks JSON at ${hooksPath}: ${error.message}`); + process.exit(1); +} + +if (!isObject(payload)) { + console.error(`Invalid hooks JSON at ${hooksPath}: root must be an object`); + process.exit(1); +} + +const before = JSON.stringify(payload); +if (!isObject(payload.hooks)) { + console.log("unchanged"); + process.exit(0); +} + +for (const eventName of Object.keys(payload.hooks)) { + const entries = payload.hooks[eventName]; + if (!Array.isArray(entries)) continue; + const filtered = entries.filter((entry) => !entryHasTdaiHook(entry, eventName)); + if (filtered.length > 0) { + payload.hooks[eventName] = filtered; + } else { + delete payload.hooks[eventName]; + } +} + +if (Object.keys(payload.hooks).length === 0) { + delete payload.hooks; +} + +if (JSON.stringify(payload) === before) { + console.log("unchanged"); +} else { + fs.writeFileSync(hooksPath, `${JSON.stringify(payload, null, 2)}\n`); + console.log("cleaned"); +} +NODE +} + +install_config() { + local config_path="${CODEX_DIR}/config.toml" + node - "${config_path}" "${PLUGIN_ID}" "${MCP_SERVER_NAME}" "${MCP_TOOL_MEMORY_SEARCH}" "${MCP_TOOL_CONVERSATION_SEARCH}" <<'NODE' +const fs = require("fs"); +const path = require("path"); +const configPath = process.argv[2]; +const pluginId = process.argv[3]; +const mcpServerName = process.argv[4]; +const toolNames = process.argv.slice(5); + +function tableExists(content, tableName) { + const header = `[${tableName}]`; + return content.split(/\r?\n/).some((line) => line.trim() === header); +} + +function findTableStart(lines, header) { + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].trim() === header) return index; + } + return -1; +} + +function findTableEnd(lines, start) { + for (let index = start + 1; index < lines.length; index += 1) { + const stripped = lines[index].trim(); + if (stripped.startsWith("[") && stripped.endsWith("]")) return index; + } + return lines.length; +} + +function upsertTableKeys(content, tableName, keys) { + const lines = content ? content.replace(/\r\n/g, "\n").split("\n") : []; + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + const header = `[${tableName}]`; + const tableStart = findTableStart(lines, header); + if (tableStart === -1) { + if (lines.length > 0 && lines[lines.length - 1].trim() !== "") lines.push(""); + lines.push(header); + for (const [key, value] of Object.entries(keys)) { + lines.push(`${key} = ${value}`); + } + return `${lines.join("\n").trim()}\n`; + } + + const tableEnd = findTableEnd(lines, tableStart); + const existingKeyLines = new Map(); + for (let index = tableStart + 1; index < tableEnd; index += 1) { + const stripped = lines[index].trim(); + if (!stripped || stripped.startsWith("#")) continue; + for (const key of Object.keys(keys)) { + if (stripped.startsWith(`${key} `) || stripped.startsWith(`${key}=`)) { + existingKeyLines.set(key, index); + } + } + } + + const missing = []; + for (const [key, value] of Object.entries(keys)) { + const line = `${key} = ${value}`; + if (existingKeyLines.has(key)) { + lines[existingKeyLines.get(key)] = line; + } else { + missing.push(line); + } + } + if (missing.length > 0) { + lines.splice(tableStart + 1, 0, ...missing); + } + return `${lines.join("\n").trimEnd()}\n`; +} + +const existed = fs.existsSync(configPath); +const original = existed ? fs.readFileSync(configPath, "utf8") : ""; +let updated = original; + +updated = upsertTableKeys(updated, "features", { hooks: "true" }); + +const globalTable = `mcp_servers.${mcpServerName}`; +if (tableExists(updated, globalTable)) { + updated = upsertTableKeys(updated, globalTable, { + default_tools_approval_mode: '"auto"', + }); + for (const toolName of toolNames) { + updated = upsertTableKeys(updated, `${globalTable}.tools.${toolName}`, { + approval_mode: '"approve"', + }); + } +} + +const pluginTable = `plugins."${pluginId}".mcp_servers.${mcpServerName}`; +updated = upsertTableKeys(updated, pluginTable, { + enabled: "true", + default_tools_approval_mode: '"auto"', +}); +for (const toolName of toolNames) { + updated = upsertTableKeys(updated, `${pluginTable}.tools.${toolName}`, { + approval_mode: '"approve"', + }); +} + +if (updated !== original || !existed) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, updated); + console.log(existed ? "updated" : "created"); +} else { + console.log("unchanged"); +} +NODE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --agents-path) + AGENTS_PATH="$2" + shift 2 + ;; + --workspace) + AGENTS_PATH="$2/AGENTS.md" + shift 2 + ;; + --user-id) + USER_ID="$2" + shift 2 + ;; + --gateway-url) + GATEWAY_URL="$2" + shift 2 + ;; + --hook-log) + HOOK_LOG="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +CODEX_DIR="$(normalize_path "${CODEX_DIR}")" +mkdir -p "${CODEX_DIR}" +AGENTS_PATH="$(normalize_path "${AGENTS_PATH}")" +HOOK_LOG="$(normalize_path "${HOOK_LOG}")" + +echo "Installing shared Python adapter packages..." +python3 -m pip install --user -e "${REPO_ROOT}/packages/tdai-memory-mcp" -e "${REPO_ROOT}/packages/tdai-memory-cli" + +echo "Registering Codex marketplace..." +codex plugin marketplace add "${REPO_ROOT}" + +echo "Registering tdai-memory MCP server..." +codex mcp remove tdai-memory >/dev/null 2>&1 || true +codex mcp add tdai-memory \ + --env "PYTHONPATH=${REPO_ROOT}/packages/tdai-memory-mcp" \ + --env "TDAI_GATEWAY_URL=${GATEWAY_URL}" \ + --env "TDAI_SESSION_KEY=agent:mcp-codex" \ + --env "TDAI_USER_ID=${USER_ID}" \ + -- python3 -m tdai_memory_mcp + +echo "Updating Codex AGENTS.md and plugin hooks..." +mkdir -p "$(dirname "${HOOK_LOG}")" +AGENTS_UPDATE="$(update_agents_md "${AGENTS_PATH}")" +PLUGIN_HOOKS_UPDATE="$(write_plugin_hooks)" +LEGACY_ROOT_PLUGIN_HOOKS_UPDATE="$(cleanup_legacy_root_plugin_hooks)" +LEGACY_HOOKS_UPDATE="$(cleanup_legacy_hooks)" +CONFIG_UPDATE="$(install_config)" + +echo "agents_md: ${AGENTS_UPDATE} ${AGENTS_PATH}" +echo "plugin_hooks: ${PLUGIN_HOOKS_UPDATE} ${REPO_ROOT}/plugins/tdai-memory/hooks/hooks.json" +echo "legacy_root_plugin_hooks: ${LEGACY_ROOT_PLUGIN_HOOKS_UPDATE} ${REPO_ROOT}/plugins/tdai-memory/hooks.json" +echo "legacy_hooks_json: ${LEGACY_HOOKS_UPDATE} ${CODEX_DIR}/hooks.json" +echo "config_toml: ${CONFIG_UPDATE} ${CODEX_DIR}/config.toml" + +echo "Installing Codex plugin..." +codex plugin remove tdai-memory@tdai-memory-local >/dev/null 2>&1 || true +codex plugin add tdai-memory@tdai-memory-local + +echo "TDAI memory files:" +echo " gateway url : ${GATEWAY_URL}" +echo " hook log : ${HOOK_LOG}" +echo "TencentDB Agent Memory Codex install complete."