diff --git a/README.md b/README.md index 386429a2..537e9866 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,16 @@ agentarts-sdk-python/ │ └── toolkit/ # CLI toolkit │ ├── cli/ # Command-line interface │ ├── operations/ # CLI operation handlers +│ ├── plugins/memory/ # Memory plugins (ai_agent, server, installer) │ └── utils/templates/ # Project templates ├── docs/ # Documentation │ └── cn/ # Chinese documentation │ ├── sdk_user_guide/ # SDK usage guides │ └── toolkit_user_guide/ # CLI usage guides └── tests/ # Test suites + ├── unit/ # Unit tests mirroring src/ tree + │ └── toolkit/plugins/memory/ # Memory plugin tests + └── integration/ # Integration tests ``` ## Wrapping Your Agent as HTTP Server @@ -323,6 +327,32 @@ agentarts destroy | `agentarts invoke` | Invoke deployed agent | | `agentarts destroy` | Remove deployed agent | | `agentarts gateway` | Manage gateways | +| `agentarts memory` | Manage Memory Spaces and memory plugins | + +### Memory Commands + +The `agentarts memory` command provides two groups of subcommands. + +**Space Management** (requires AK/SK authentication): + +| Command | Description | +| --------------------------- | ---------------------------------------------- | +| `agentarts memory create` | Create a Memory Space | +| `agentarts memory get` | Get Space details | +| `agentarts memory list` | List Spaces | +| `agentarts memory update` | Update a Space | +| `agentarts memory delete` | Delete a Space | +| `agentarts memory status` | Check Space status and health | + +**Plugin Installation & Local Server:** + +| Command | Description | +| -------------------------------- | --------------------------------------------------------------------- | +| `agentarts memory install` | Install memory plugin into an AI agent (codex, claude, opencode, hermes, openclaw) | +| `agentarts memory uninstall` | Uninstall memory plugin from an AI agent | +| `agentarts memory server start` | Start the local memory adapter server (127.0.0.1:8719) | +| `agentarts memory server stop` | Stop the local memory adapter server | +| `agentarts memory server status` | Check the local adapter server status | ## Limitations & Requirements @@ -397,4 +427,3 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for det - **Issues:** [GitHub Issues](https://github.com/huaweicloud/agentarts-sdk-python/issues) - **Documentation:** - **Email:** - diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/AGENTS.md b/agentarts-memory-plugins/agentarts-memory-code_agent/AGENTS.md deleted file mode 100644 index 730e69b1..00000000 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/AGENTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Repository Guidelines - -This sub-package, `agentarts-memory-code_agent`, is part of the AgentArts SDK Python monorepo. -It provides a local HTTP adapter server + multi-agent hook scripts that wire Huawei Cloud -AgentArts Memory into Claude Code / Codex / OpenCode as a long-term memory backend. - -## Structure - -- `server/` — Python FastAPI adapter over `agentarts.sdk.memory.MemoryClient` -- `scripts/` — Node hook scripts (`.mjs`) shared by Claude Code / Codex -- `hooks/` — `hooks.json` (Claude Code) and `hooks.codex.json` (Codex) -- `opencode/` — TypeScript plugin + slash commands for OpenCode -- `.claude-plugin/`, `.codex-plugin/` — platform plugin manifests - -## Commands - -```bash -pip install -e ".[dev]" # install dev deps -pytest tests/agentarts-memory-code_agent/ -q -black . && isort . # format -ruff check . # lint -mypy server # type check -``` - -## Conventions - -- black (line-length=100), isort (profile=black), mypy strict, ruff. -- Node scripts are ESM (`.mjs`), shared logic in `scripts/_shared.mjs`. -- Tests live in `tests/agentarts-memory-code_agent/` and mock `MemoryClient` (no cloud calls). diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/README.md b/agentarts-memory-plugins/agentarts-memory-code_agent/README.md deleted file mode 100644 index 61ab664f..00000000 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/README.md +++ /dev/null @@ -1,279 +0,0 @@ -# agentarts-memory-code_agent - -**Huawei Cloud AgentArts Memory** 作为 **Claude Code / Codex / OpenCode** 三平台 AI 编程助手的长期记忆后端。 - -一个插件目录同时覆盖三平台,共享同一个本地 HTTP 适配 server + hook 脚本逻辑。 - -## 关于AgentArts Memory - -华为云 AgentArts Memory 是智能体云端记忆解决方案,对智能体记忆数据提供全生命周期管理。 - -### AgentArts Memory优势 - -1、开箱即用: -短期记忆 + 长期记忆:支持短期记忆(7~365天)和长期记忆(持久化存储),满足不同时间跨度的记忆需求。 - -2、多种记忆策略: -支持语义记忆、用户偏好、会话摘要、情景记忆等策略,满足不同场景的记忆需求。 - -3、多维度隔离: -按策略类型隔离:支持按空间、会话、用户维度进行记忆隔离,确保数据的安全性和独立性。 - -4、全托管免运维: -云上全托管:无需管理数据库等基础设施和记忆处理引擎,实现业务快速上线,降低运维成本和复杂度。 -> 官方文档:[记忆库概述](https://support.huaweicloud.com/highcode-agentarts/agentarts_10_015.html) · [SDK 集成示例](https://support.huaweicloud.com/highcode-agentarts/agentarts_10_016.html) · [创建记忆库](https://support.huaweicloud.com/highcode-agentarts/agentarts_10_017.html) - - -## 实现方式 - -| 平台 | 接入方式 | hook 数 | 命令 | -|---|---|---|---| -| Claude Code | `.claude-plugin/plugin.json` + marketplace | 12 | — | -| Codex | `.codex-plugin/plugin.json` + marketplace | 6 | — | -| OpenCode | TypeScript 插件 + opencode.json 配置 | session/message/system 事件 | `/recall` `/remember` | - -所有平台共享同一个 `scripts/_shared.mjs`(hook 脚本)和 `opencode/` 下的 TS 插件,只是配置入口不同。所有捕获与注入都调用本地适配 server 的 REST API(`127.0.0.1:8719`),server 再调用云端 AgentArts Memory SDK。 - -### 数据流 - -``` -Claude Code / Codex / OpenCode agent loop - │ - ├── hooks / plugin 事件 (生命周期拦截) - │ ├── session-start → /health (探测 server) - │ ├── prompt-submit → /add_messages/ (后台记录用户 query) + /search_memory/ + /search_summary/ → stdout 注入 - │ └── pre-compact → /search_memory/ + /search_summary/ → stdout 注入防丢 - │ │ - │ └── 本地适配 server (127.0.0.1:8719, FastAPI) - │ └── AgentArts MemoryClient → 华为云 AgentArts Memory - │ - └── OpenCode commands (/recall /remember) - └── 手动搜索 / 保存记忆 -``` - -## 前置条件 - -### 1. 安装适配 server - -```bash -cd agentarts-memory-plugins/agentarts-memory-code_agent -pip install -e ".[cloud,dev]" # 需要 agentarts-sdk + fastapi + uvicorn -``` - -### 2. 启动适配 server - -直接运行 server,如缺少必要配置会自动提示输入: - -```bash -agentarts-memory-server -``` - -启动时会检查环境变量: -- ✅ 已配置 → 直接启动 -- ❌ 缺少配置 → 交互式提示输入 - -交互示例: - -``` -============================================================ -AgentArts Memory Server Configuration -============================================================ - -⚠️ Missing required configuration: - -AgentArts Memory Space ID: my-space-id-12345 - ✓ Configured: my-****45 - -Huawei Cloud Memory API Key: ************************ - ✓ Configured: ************************ - -ℹ️ Optional: AgentArts Memory Region - Configure AgentArts Memory Region? [y/N]: y -AgentArts Memory Region (default: cn-southwest-2): cn-north-4 - ✓ Configured: cn-north-4 - ------------------------------------------------------------- -Save configuration to ~/.zshrc for persistence? [y/N]: y - ✓ Configuration saved to /Users/xxx/.zshrc - Run 'source ~/.zshrc' or restart terminal to apply. - -============================================================ -Starting AgentArts Memory Server on 127.0.0.1:8719 -============================================================ -``` - -可指定日志级别: - -```bash -AGENTARTS_MEMORY_LOG_LEVEL=debug agentarts-memory-server -``` - -### 3. 验证 - -```bash -curl http://127.0.0.1:8719/health # {"status":"healthy","space_id":true,"api_key":true} -``` - -## 安装插件 - -### Claude Code - -```bash -# 从github注册 marketplace -/plugin marketplace add huaweicloud/agentarts-sdk-python -# 安装 -/plugin install agentarts-memory-plugins@agentarts-memory-code_agent -/plugin install jiuwen_memory@jiuwen-memory-plugins -``` - -hook 配置由 `hooks/hooks.json` 提供(12 个生命周期 hook),使用 `${CLAUDE_PLUGIN_ROOT}` 变量。 - -### Codex - -```bash -codex plugin marketplace add -codex plugin add agentarts_memory -``` - -重启 Codex 后生效。Codex 不自动从 manifest 读 hooks,需手动把 `hooks/hooks.codex.json` 合并到 `~/.codex/hooks.json`(修改路径为绝对路径),并在 `~/.codex/config.toml` 启用: - -```toml -[features] -codex_hooks = true -``` - -hook 配置使用 `${CODEX_PLUGIN_ROOT}` 变量。 - -### OpenCode - -1. 拷贝插件文件和命令到 OpenCode 配置目录: - -```bash -mkdir -p ~/.config/opencode/plugins ~/.config/opencode/commands -cp opencode/agentarts-memory-capture.ts ~/.config/opencode/plugins/ -cp opencode/commands/recall.md ~/.config/opencode/commands/ -cp opencode/commands/remember.md ~/.config/opencode/commands/ -``` - -2. 在 `~/.config/opencode/opencode.json` 启用插件: - -```json -{ - "plugin": ["./plugins/agentarts-memory-capture.ts"] -} -``` - -## user_id 解析优先级 - -user_id 用于记忆隔离,解析优先级如下: - -``` -1. payload.user_id / payload.userId (hook 请求携带) - ↓ 未提供或为空 -2. AGENTARTS_MEMORY_USER_ID 环境变量 - ↓ 未设置 -3. 平台默认值(基于环境变量检测) -``` - -### 平台检测与默认 user_id - -| 平台 | 环境变量 | 默认 user_id | -|------|----------|--------------| -| Claude Code | `CLAUDE_PLUGIN_ROOT` | `cc-user` | -| Codex | `CODEX_PLUGIN_ROOT` | `codex-user` | -| OpenCode | `OPENCODE_PLUGIN_ROOT` | `opencode-user` | -| 未知 | — | `__default__` | - -当插件通过 marketplace 正确安装时,各平台会自动设置对应的环境变量,无需手动配置。 - -## hooks → 端点映射 - -### Claude Code hooks(12 个) - -| hook | server 端点 | 写入记忆? | stdout 注入? | -|---|---|---|---| -| SessionStart | `/health` only | ❌ | ❌ | -| UserPromptSubmit | `/add_messages/` + `/search_memory/` + `/search_summary/` | ✅(仅用户 query) | ✅ | -| PreToolUse | no-op placeholder | ❌ | ❌ | -| PostToolUse / PostToolUseFailure | no-op | ❌ | ❌ | -| PreCompact | `/search_memory/` + `/search_summary/` | ❌ | ✅ | -| SubagentStart/Stop, Notification, TaskCompleted, Stop, SessionEnd | no-op | ❌ | ❌ | - -### Codex hooks(6 个) - -| hook | server 端点 | 写入记忆? | stdout 注入? | -|---|---|---|---| -| SessionStart | `/health` only | ❌ | ❌ | -| UserPromptSubmit | `/add_messages/` + `/search_memory/` + `/search_summary/` | ✅(仅用户 query) | ✅ | -| PreToolUse / PostToolUse | no-op placeholder | ❌ | ❌ | -| PreCompact | `/search_memory/` + `/search_summary/` | ❌ | ✅ | -| Stop | no-op | ❌ | ❌ | - -### OpenCode 插件机制 - -| 钩子 | 作用 | 记忆写入? | 注入? | -|---|---|---|---| -| `session.created` | 探测 `/health`,初始化 per-session 状态,解析 user_id | ❌ | ❌ | -| `session.deleted` | 清理 per-session 缓存 | ❌ | ❌ | -| `message.updated`(assistant) | AI 回复结束后写入暂存的用户 query | ✅(延后写入) | ❌ | -| `chat.message` | 存用户 query、标记 pending、阻塞执行一次 search 并缓存 | ❌(延后写入) | ❌ | -| `experimental.chat.system.transform` | 读取缓存 search 结果注入 `output.system[]` | ❌ | ✅ system prompt | -| `experimental.session.compacting` | 压缩前注入 `output.context[]`(命中缓存,否则 fallback 搜索) | ❌ | ✅ context | - -搜索只在 `chat.message` 阻塞执行一次并缓存,`system.transform`/`compacting` 全程只读缓存、不重复搜索。 - -## 环境变量(可选覆盖) - -| 变量 | 默认 | 说明 | -|---|---|---| -| `AGENTARTS_MEMORY_SERVER_URL` | `http://127.0.0.1:8719` | 本地 server 地址(hook/插件端) | -| `AGENTARTS_MEMORY_USER_ID` | 平台默认值 | 记忆隔离 user_id | -| `AGENTARTS_MEMORY_DEBUG` | `0` | 开调试日志 (1=开启) | -| `AGENTARTS_MEMORY_LOG_LEVEL` | `info` | Server 日志级别 (debug/info/warning/error) | -| `AGENTARTS_MEMORY_PROJECT_NAME` | git toplevel basename | scope_id 覆盖 | - -## server API - -| 端点 | 方法 | 入参 | 说明 | -|---|---|---|---| -| `/health` | GET | — | 配置就绪探测(无网络) | -| `/add_messages/` | POST | `{messages, user_id, scope_id}` | 按 scope 创建/复用 session 写入 | -| `/search_memory/` | POST | `{query, num, user_id, scope_id, threshold}` | 语义搜索 | -| `/list_memories/` | POST | `{limit, offset, user_id, scope_id}` | 列出记忆 | -| `/search_summary/` | POST | `{query, num, user_id, scope_id, threshold}` | 摘要类记忆检索 | - -`scope_id` → AgentArts `session_id`(首次自动创建并缓存),`user_id` → `actor_id`。 - -## 测试 - -```bash -# Python server 测试 -pytest tests/agentarts-memory-code_agent/ -q - -# Node hook 脚本测试 -node --test tests/agentarts-memory-code_agent/test_scripts.mjs - -# 验证平台检测 -CLAUDE_PLUGIN_ROOT=/test node -e ' -import("./scripts/_shared.mjs").then(m => console.log(m.detectPlatform(), m.resolveUserId({}))); -' -# 输出: claude-code cc-user - -CODEX_PLUGIN_ROOT=/test node -e ' -import("./scripts/_shared.mjs").then(m => console.log(m.detectPlatform(), m.resolveUserId({}))); -' -# 输出: codex codex-user - -OPENCODE_PLUGIN_ROOT=/test node -e ' -import("./scripts/_shared.mjs").then(m => console.log(m.detectPlatform(), m.resolveUserId({}))); -' -# 输出: opencode opencode-user -``` - -## 写入策略 - -只记录**用户 query**(`UserPromptSubmit` / OpenCode `message.updated`),不写 agent 回答/工具结果。`add_messages` fire-and-forget,不阻塞主循环。 - -## License - -Apache-2.0 \ No newline at end of file diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/plugin.json b/agentarts-memory-plugins/agentarts-memory-code_agent/plugin.json deleted file mode 100644 index e6ef2bf7..00000000 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/plugin.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "agentarts_memory", - "name": "agentarts_memory", - "version": "1.0.0", - "description": "Huawei Cloud AgentArts Memory as long-term memory backend for Claude Code (12 hooks), Codex (6 hooks), and OpenCode (TypeScript plugin).", - "author": { "name": "AgentArts" }, - "homepage": "https://github.com/huaweicloud/agentarts-sdk-python", - "repository": "https://github.com/huaweicloud/agentarts-sdk-python", - "license": "Apache-2.0", - "keywords": ["memory", "agentarts", "huaweicloud", "persistence"], - "contextFileName": "AGENTS.md" -} diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/pyproject.toml b/agentarts-memory-plugins/agentarts-memory-code_agent/pyproject.toml deleted file mode 100644 index 1dec1326..00000000 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "agentarts-memory-code_agent" -version = "1.0.0" -description = "AgentArts Memory adapter for Claude Code / Codex / OpenCode" -requires-python = ">=3.10" -license = "Apache-2.0" -dependencies = [ - "fastapi>=0.110", - "uvicorn>=0.29", -] - -[project.optional-dependencies] -cloud = ["agentarts-sdk>=0.1.0"] -dev = ["pytest>=7", "pytest-mock>=3", "httpx>=0.27", "black", "isort", "ruff", "mypy"] - -[project.scripts] -agentarts-memory-server = "server.run:main" - -[tool.hatch.build.targets.wheel] -packages = ["server"] - -[tool.black] -line-length = 100 -target-version = ["py310"] - -[tool.isort] -profile = "black" - -[tool.ruff] -line-length = 100 -target-version = "py310" diff --git a/agentarts-memory-plugins/agentarts-memory-installer/README.md b/agentarts-memory-plugins/agentarts-memory-installer/README.md deleted file mode 100644 index 7283aa56..00000000 --- a/agentarts-memory-plugins/agentarts-memory-installer/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# agentarts-memory-installer - -Unified Python installer for AgentArts Memory plugins. Deploys memory plugins to supported AI agent platforms via **direct file writing** (no marketplace). - -## 安装 - -```bash -cd agentarts-memory-plugins/agentarts-memory-installer -pip install -e . -``` - -## 用法 - -``` -agentarts-memory install [hermes|claude|codex|opencode|openclaw] [--global] [--yes] -agentarts-memory uninstall [hermes|claude|codex|opencode|openclaw] [--global] [--yes] -``` - -- 不带 target 时:自动检测已安装的平台,交互式选择。 -- `--global`:安装到用户级配置(默认项目级;hermes 固定用户级)。 -- `--yes`:跳过所有交互提示(CI 友好)。 -- `openclaw`:暂未实现,执行时提示并正常退出。 - -### 示例 - -```bash -# 交互式安装(自动检测平台) -agentarts-memory install - -# 安装 hermes(用户级,自动确认) -agentarts-memory install hermes --global --yes - -# 卸载 claude(用户级,自动确认) -agentarts-memory uninstall claude --global --yes -``` - -## 各平台落点速查 - -| 平台 | 项目级 | 用户级 | 部署内容 | 注册方式 | -|---|---|---|---|---| -| hermes | —(固定用户级) | `~/.hermes/hermes-agent/plugins/memory/agentarts/` | `provider.py` + `plugin.yaml` + `__init__.py` | `.env` 写 API Key;`agentarts.json` 写 space_id/region | -| claude | `.claude/` | `~/.claude/` | 13 个 `.mjs` 脚本 → `agentarts-memory/scripts/` | `settings.json` 的 `hooks`(绝对路径,12 个 hook) | -| codex | `.codex/` | `~/.codex/` | 同上 13 个 `.mjs` 脚本 | `hooks.json` 合并(6 个 hook)+ `config.toml` 的 `[features] codex_hooks = true` | -| opencode | `.opencode/` | `~/.config/opencode/` | TS 插件 → `plugins/`、命令 → `commands/` | `opencode.json` 的 `plugin` 数组 | -| openclaw | — | — | 无(占位) | 无 | - -> claude/codex/opencode 三平台共享 hook 脚本,运行时都调用本地适配 server(`127.0.0.1:8719`)。 - -## 凭证 / 环境变量 - -| 变量 | 必填 | 默认 | 说明 | -|---|---|---|---| -| `AGENTARTS_MEMORY_SPACE_ID` | 是 | — | AgentArts 记忆库 ID | -| `HUAWEICLOUD_SDK_MEMORY_API_KEY` | 是 | — | AgentArts 记忆库 API Key | -| `HUAWEICLOUD_SDK_REGION` | 否 | `cn-southwest-2` | 区域 | - -安装时检测这些环境变量,缺失则交互式提示输入(带校验)。 - -## 安装记录 - -安装记录保存在 `~/.agentarts-memory/installed.json`,支撑干净卸载:按清单精确移除我们部署的文件和注入的 hook 条目,不误删用户自有配置。 - -## 包结构 - -``` -agentarts-memory-installer/ -├── pyproject.toml # 入口 agentarts-memory = "cli:main" -├── __init__.py -├── cli.py # argparse 主入口(install/uninstall) -├── config.py # 凭证检测/交互补全 -├── manifest.py # installed.json 读写 -├── assets.py # 定位素材源目录 -├── utils.py # 路径/JSON 合并摘除/TOML 合并/.env/状态输出/交互 -└── platforms/ - ├── __init__.py # PLATFORMS 注册表 - ├── base.py # Platform 抽象基类 + InstallResult - ├── hermes.py - ├── claude.py - ├── codex.py - ├── opencode.py - └── openclaw.py # 占位 -``` - -## 测试 - -```bash -pytest tests/agentarts-memory-installer/ -v -``` - -测试使用 tmp HOME 隔离,不碰真实 `~`。核心场景:各平台 install→uninstall 往返、幂等性(连续安装不重复)、JSON/TOML 合并摘除往返。 diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/cli.py b/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/cli.py deleted file mode 100644 index efba0eb0..00000000 --- a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/cli.py +++ /dev/null @@ -1,337 +0,0 @@ -"""CLI entry point for agentarts-memory install/uninstall. - -Usage: - agentarts-memory install [hermes|claude|codex|opencode|openclaw] [--global] [--yes] - agentarts-memory uninstall [hermes|claude|codex|opencode|openclaw] [--global] [--yes] - agentarts-memory server start|stop|status [--yes] -""" - -from __future__ import annotations - -import argparse -import os -import sys - -from .platforms import detect_all, get_platform -from .server_manager import start, status, stop -from .utils import ( - EscapeInterrupt, - add, - confirm, - ensure_credentials, - expand, - find, - list_all, - remove, - select_one, - set_yes, -) - -VALID_TARGETS = ("hermes", "claude", "codex", "opencode", "openclaw") - -# Platforms that depend on the local adapter server. -SERVER_DEPENDENT = {"claude", "codex", "opencode"} - - -def build_parser() -> argparse.ArgumentParser: - """Build the argparse parser with install/uninstall subcommands.""" - parser = argparse.ArgumentParser( - prog="agentarts-memory", - description="Install/uninstall AgentArts Memory plugins for supported AI agents.", - ) - sub = parser.add_subparsers(dest="command", required=True) - - for cmd in ("install", "uninstall"): - sp = sub.add_parser( - cmd, - help=f"{cmd} a platform plugin", - description=f"{cmd.capitalize()} AgentArts Memory for the given platform.", - ) - sp.add_argument( - "target", - nargs="?", - default=None, - help=f"Target platform ({', '.join(VALID_TARGETS)}). " - "If omitted, detects interactively.", - ) - sp.add_argument( - "--global", - dest="global_scope", - action="store_true", - help="Install to user-level config instead of project-level.", - ) - sp.add_argument( - "--yes", - "-y", - action="store_true", - help="Auto-confirm all prompts (CI-friendly).", - ) - - # Server subcommand - sp = sub.add_parser( - "server", - help="manage the local adapter server", - description="Start, stop, or check status of the agentarts-memory-server.", - ) - sp.add_argument( - "action", - choices=("start", "stop", "status"), - help="Server action to perform.", - ) - sp.add_argument( - "--yes", - "-y", - action="store_true", - help="Auto-confirm all prompts (CI-friendly).", - ) - - return parser - - -def _select_scope(platform_name: str, yes: bool) -> str: - """Determine install scope (project or global).""" - platform = get_platform(platform_name) - if platform and platform.fixed_user_level: - return "global" - if yes: - return "project" - idx = select_one( - "Install scope", - [ - "Project — this project only", - "Global — all projects", - ], - 0, - ) - return "project" if idx == 0 else "global" - - -def _check_server_dependency(yes: bool) -> None: - """Print server dependency hint for claude/codex/opencode.""" - print("\nNote: This platform requires the local adapter server (127.0.0.1:8719).") - print(" Start it with: agentarts-memory-server") - print(" Or use: agentarts-memory server start") - print(" Install: pip install -e agentarts-memory-plugins/agentarts-memory-code_agent") - - -def cmd_install(args: argparse.Namespace) -> int: - """Handle the install subcommand.""" - set_yes(args.yes) - - target = args.target - - # Validate explicit target. - if target is not None and target not in VALID_TARGETS: - print( - f"Error: invalid target '{target}'. " f"Choose from: {', '.join(VALID_TARGETS)}", - file=sys.stderr, - ) - return 2 - - # openclaw placeholder. - if target == "openclaw": - print("openclaw 暂未实现,敬请期待") - return 0 - - # If no target, detect and select. - if target is None: - detected = detect_all(args.global_scope) - if not detected: - print("\nNo supported platforms detected.") - print( - "Install Claude Code, Codex, OpenCode, or Hermes Agent, " - "then run 'agentarts-memory install' again." - ) - return 1 - print("Detecting platforms...") - for name, p in detected: - print(f" \u2713 {p.display}") - options = [p.display for _, p in detected] - idx = select_one("\nSelect platform", options, 0) - target = detected[idx][0] - - platform = get_platform(target) - if platform is None: - print(f"Error: unknown platform '{target}'", file=sys.stderr) - return 2 - - # Credentials. - print("\nChecking credentials...") - creds = ensure_credentials(args.yes) - - # Determine scope. - scope = "global" if args.global_scope else _select_scope(target, args.yes) - - # Install. - print(f"\nInstalling {platform.display} ({scope})...") - result = platform.install(scope, creds, args.yes) - - # Record in manifest. - add( - { - "platform": target, - "scope": scope, - "config_dir": result.config_dir, - "scripts_dir": result.scripts_dir, - "files": result.files, - "config_files": result.config_files, - } - ) - - # Summary. - print(f"\n\U0001f389 Install complete: {platform.display} ({scope})") - print(f" Config dir: {result.config_dir}") - if result.scripts_dir: - print(f" Scripts: {result.scripts_dir}") - print(f" Files: {len(result.files)} deployed") - if result.config_files: - print(f" Config: {', '.join(result.config_files)}") - - if target in SERVER_DEPENDENT: - _check_server_dependency(args.yes) - - print("\nRestart the platform to activate.") - return 0 - - -def cmd_uninstall(args: argparse.Namespace) -> int: - """Handle the uninstall subcommand.""" - set_yes(args.yes) - - target = args.target - - # Validate explicit target. - if target is not None and target not in VALID_TARGETS: - print( - f"Error: invalid target '{target}'. " f"Choose from: {', '.join(VALID_TARGETS)}", - file=sys.stderr, - ) - return 2 - - # openclaw placeholder. - if target == "openclaw": - print("openclaw 暂未实现,敬请期待") - return 0 - - scope = "global" if args.global_scope else None - - # Find installation to remove. - entry = None - if target is not None: - entry = find(target, scope, None) - if entry is None: - print(f"\nNo {target} installation found in manifest.") - print("Attempting degraded scan...") - _degraded_scan(target) - return 1 - else: - all_installs = list_all() - if not all_installs: - print("\nNo installations found.") - return 1 - print("\nInstalled platforms:") - options = [ - f"{i['platform']} ({i.get('scope', '?')}) — {i.get('config_dir', '?')}" - for i in all_installs - ] - idx = select_one("Select installation to remove", options, 0) - entry = all_installs[idx] - target = entry["platform"] - - platform = get_platform(target) - if platform is None: - print(f"Error: unknown platform '{target}'", file=sys.stderr) - return 2 - - # Confirm. - if not args.yes and not confirm( - f"Remove {platform.display} from {entry.get('config_dir', '?')}?", - default=True, - ): - print("Cancelled.") - return 0 - - # Uninstall. - print(f"\nUninstalling {platform.display}...") - platform.uninstall(entry) - - # Remove from manifest. - remove( - target, - entry.get("scope", ""), - entry.get("config_dir", ""), - ) - - print(f"\n\u2705 Uninstall complete: {platform.display}") - print("Restart the platform to apply changes.") - return 0 - - -def cmd_server(args: argparse.Namespace) -> int: - """Handle the server subcommand.""" - set_yes(args.yes) - - if args.action == "start": - return start() - elif args.action == "stop": - return stop() - elif args.action == "status": - return status() - return 1 - - -def _degraded_scan(target: str) -> None: - """Attempt to find and clean up files when manifest is missing.""" - # Scan known platform directories for agentarts-memory markers. - - candidates = { - "hermes": [expand("~/.hermes/hermes-agent/plugins/memory/agentarts")], - "claude": [ - expand("~/.claude/agentarts-memory"), - os.path.join(os.getcwd(), ".claude", "agentarts-memory"), - ], - "codex": [ - expand("~/.codex/agentarts-memory"), - os.path.join(os.getcwd(), ".codex", "agentarts-memory"), - ], - "opencode": [expand("~/.config/opencode/plugins/agentarts-memory-capture.ts")], - } - - found = candidates.get(target, []) - any_found = False - for path in found: - if os.path.exists(path): - any_found = True - print(f" Found leftover: {path}") - print(f" Remove manually: rm -rf {path}") - - if not any_found: - print(f" No leftover {target} files found.") - - -def main(argv: list[str] | None = None) -> int: - """Main entry point. - - Args: - argv: Optional argument list (defaults to sys.argv[1:]). - """ - parser = build_parser() - args = parser.parse_args(argv) - - try: - if args.command == "install": - return cmd_install(args) - elif args.command == "uninstall": - return cmd_uninstall(args) - elif args.command == "server": - return cmd_server(args) - except EscapeInterrupt: - print("\nCancelled.") - return 0 - - parser.print_help() - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/agentarts-memory-plugins/agentarts-memory-installer/pyproject.toml b/agentarts-memory-plugins/agentarts-memory-installer/pyproject.toml deleted file mode 100644 index db885717..00000000 --- a/agentarts-memory-plugins/agentarts-memory-installer/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "agentarts-memory-installer" -version = "1.0.0" -description = "Unified installer for AgentArts Memory plugins (hermes, claude, codex, opencode, openclaw)" -requires-python = ">=3.10" -license = "Apache-2.0" - -[project.scripts] -agentarts-memory = "agentarts_memory_installer.cli:main" - -[tool.hatch.build.targets.wheel] -packages = ["agentarts_memory_installer"] - -[tool.black] -line-length = 100 -target-version = ["py310"] - -[tool.isort] -profile = "black" -line_length = 100 - -[tool.ruff] -line-length = 100 -target-version = "py310" diff --git a/pyproject.toml b/pyproject.toml index 4c54cab1..baf01e11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "huaweicloudsdkswr>=3.1.0", "starlette>=0.46.2", "uvicorn[standard]>=0.24.0", + "fastapi>=0.104.0", "pydantic>=2.0.0,<3.0.0", "pydantic-settings>=2.0.0,<3.0.0", "python-dotenv>=1.0.0", diff --git a/src/agentarts/toolkit/cli/memory/commands.py b/src/agentarts/toolkit/cli/memory/commands.py index eae69505..d0823289 100644 --- a/src/agentarts/toolkit/cli/memory/commands.py +++ b/src/agentarts/toolkit/cli/memory/commands.py @@ -15,6 +15,11 @@ update_space, ) from agentarts.toolkit.utils.common import echo_error, echo_success +from agentarts.toolkit.plugins.memory.installer.cli import ( + install_cmd, + server_app, + uninstall_cmd, +) console = Console() @@ -514,3 +519,10 @@ def space_status_cmd( region_info = result.space.get("region") if region_info: console.print(f" Region: [cyan]{region_info}[/cyan]") + + +# Register the installer's install/uninstall/server commands onto the shared +# memory app: `agentarts memory install|uninstall|server`. +memory_app.command(name="install")(install_cmd) +memory_app.command(name="uninstall")(uninstall_cmd) +memory_app.add_typer(server_app, name="server") diff --git a/src/agentarts/toolkit/plugins/memory/__init__.py b/src/agentarts/toolkit/plugins/memory/__init__.py new file mode 100644 index 00000000..c1a236d5 --- /dev/null +++ b/src/agentarts/toolkit/plugins/memory/__init__.py @@ -0,0 +1,6 @@ +"""AgentArts memory plugins package. + +Sub-packages: ``ai_agent`` (platform-specific plugin assets), ``server`` +(local adapter server), ``resources`` (docs/manifests + shared hook +scripts), ``installer`` (unified installer CLI). +""" diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/.claude-plugin/plugin.json b/src/agentarts/toolkit/plugins/memory/ai_agent/claude_code/.claude-plugin/plugin.json similarity index 96% rename from agentarts-memory-plugins/agentarts-memory-code_agent/.claude-plugin/plugin.json rename to src/agentarts/toolkit/plugins/memory/ai_agent/claude_code/.claude-plugin/plugin.json index 8e406404..74f07bb9 100644 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/.claude-plugin/plugin.json +++ b/src/agentarts/toolkit/plugins/memory/ai_agent/claude_code/.claude-plugin/plugin.json @@ -9,7 +9,7 @@ "repository": "https://github.com/huaweicloud/agentarts-sdk-python", "license": "Apache-2.0", "keywords": ["memory", "agentarts", "huaweicloud", "persistence"], - "hooks": "./hooks/hooks.json", + "hooks": "./hooks.json", "userConfig": { "memory_server_url": { "type": "string", diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.json b/src/agentarts/toolkit/plugins/memory/ai_agent/claude_code/hooks.json similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.json rename to src/agentarts/toolkit/plugins/memory/ai_agent/claude_code/hooks.json diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/.codex-plugin/plugin.json b/src/agentarts/toolkit/plugins/memory/ai_agent/codex/.codex-plugin/plugin.json similarity index 77% rename from agentarts-memory-plugins/agentarts-memory-code_agent/.codex-plugin/plugin.json rename to src/agentarts/toolkit/plugins/memory/ai_agent/codex/.codex-plugin/plugin.json index 63ebf956..e4ecfd3b 100644 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/.codex-plugin/plugin.json +++ b/src/agentarts/toolkit/plugins/memory/ai_agent/codex/.codex-plugin/plugin.json @@ -17,8 +17,8 @@ ], "interface": { "displayName": "AgentArts Memory", - "shortDescription": "Huawei Cloud AgentArts long-term memory for Codex", - "longDescription": "AgentArts Memory adds cross-session long-term memory to Codex via Huawei Cloud AgentArts Memory. Conversation prompts are recorded and relevant memories are injected before each turn.", + "shortDescription": "Huawei Cloud AgentArts memory for Codex", + "longDescription": "AgentArts Memory adds cross-session memory to Codex via Huawei Cloud AgentArts Memory. Conversation prompts are recorded and relevant memories are injected before each turn.", "developerName": "AgentArts", "category": "Productivity", "capabilities": [ diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.codex.json b/src/agentarts/toolkit/plugins/memory/ai_agent/codex/hooks.codex.json similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.codex.json rename to src/agentarts/toolkit/plugins/memory/ai_agent/codex/hooks.codex.json diff --git a/agentarts-memory-plugins/agentarts-memory-hermes/README.en.md b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/README.en.md similarity index 99% rename from agentarts-memory-plugins/agentarts-memory-hermes/README.en.md rename to src/agentarts/toolkit/plugins/memory/ai_agent/hermes/README.en.md index db93815e..24797e6e 100644 --- a/agentarts-memory-plugins/agentarts-memory-hermes/README.en.md +++ b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/README.en.md @@ -100,7 +100,7 @@ Check: ```bash pip install -e ".[dev]" -pytest tests/agentarts-memory-hermes/ -v +pytest tests/unit/toolkit/plugins/memory/hermes/ -v ``` ## About AgentArts Memory diff --git a/agentarts-memory-plugins/agentarts-memory-hermes/README.md b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/README.md similarity index 97% rename from agentarts-memory-plugins/agentarts-memory-hermes/README.md rename to src/agentarts/toolkit/plugins/memory/ai_agent/hermes/README.md index 5d82ea6d..6e092d34 100644 --- a/agentarts-memory-plugins/agentarts-memory-hermes/README.md +++ b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/README.md @@ -103,7 +103,7 @@ AgentArts Memory 从对话消息生成记忆需要时间(约 30 秒)。`sync ```bash pip install -e ".[dev]" -pytest tests/agentarts-memory-hermes/ -v +pytest tests/unit/toolkit/plugins/memory/hermes/ -v ``` ## 关于AgentArts Memory @@ -120,4 +120,4 @@ pytest tests/agentarts-memory-hermes/ -v 4、全托管免运维:云上全托管:无需管理数据库等基础设施和记忆处理引擎,实现业务快速上线,降低运维成本和复杂度。 -> 官方文档:[记忆库概述](https://support.huaweicloud.com/highcode-agentarts/agentarts_10_015.html) **** \ No newline at end of file +> 官方文档:[记忆库概述](https://support.huaweicloud.com/highcode-agentarts/agentarts_10_015.html) **** diff --git a/agentarts-memory-plugins/agentarts-memory-hermes/__init__.py b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/__init__.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-hermes/__init__.py rename to src/agentarts/toolkit/plugins/memory/ai_agent/hermes/__init__.py diff --git a/agentarts-memory-plugins/agentarts-memory-hermes/plugin.yaml b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/plugin.yaml similarity index 61% rename from agentarts-memory-plugins/agentarts-memory-hermes/plugin.yaml rename to src/agentarts/toolkit/plugins/memory/ai_agent/hermes/plugin.yaml index c792d380..62624b57 100644 --- a/agentarts-memory-plugins/agentarts-memory-hermes/plugin.yaml +++ b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/plugin.yaml @@ -1,6 +1,6 @@ name: agentarts_memory version: 1.0.0 -description: "Huawei Cloud's AgentArts Memory is a long-term memory provider, offering cross-session memory persistence and retrieval." +description: "Huawei Cloud's AgentArts Memory is a cloud memory provider, offering cross-session memory persistence and retrieval." pip_dependencies: - "agentarts-sdk>=0.1.4" hooks: diff --git a/agentarts-memory-plugins/agentarts-memory-hermes/provider.py b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/provider.py similarity index 96% rename from agentarts-memory-plugins/agentarts-memory-hermes/provider.py rename to src/agentarts/toolkit/plugins/memory/ai_agent/hermes/provider.py index 88dbd278..8505148a 100644 --- a/agentarts-memory-plugins/agentarts-memory-hermes/provider.py +++ b/src/agentarts/toolkit/plugins/memory/ai_agent/hermes/provider.py @@ -1,7 +1,7 @@ """AgentArts Memory Provider for Hermes Agent. This module implements a Hermes MemoryProvider backed by Huawei Cloud -AgentArts Memory. It provides cross-session long-term memory persistence +AgentArts Memory. It provides cross-session cloud memory persistence and retrieval (ltm_search / ltm_search_summary tools). """ @@ -30,11 +30,11 @@ # ── system_prompt_block text ── SYSTEM_PROMPT_BLOCK = ( "## Long-term Memory (AgentArts Memory)\n" - "This session provides cross-session long-term memory via Huawei Cloud AgentArts Memory.\n" + "This session provides cross-session cloud memory via Huawei Cloud AgentArts Memory.\n" "- Conversation content is automatically written to memory after each turn (non-blocking)\n" "- Relevant memories are injected before each LLM call" " (user profile / episodic / semantic + history summary)\n" - "- Use the ltm_search tool to actively retrieve long-term memories\n" + "- Use the ltm_search tool to actively retrieve cloud memories\n" "- Use the ltm_search_summary tool to view memory summaries\n" ) @@ -80,7 +80,7 @@ def save_config(values: dict[str, Any], hermes_home: str) -> None: { "name": "ltm_search", "description": ( - "Search AgentArts long-term memory and return memory entries relevant to the query" + "Search AgentArts memory and return memory entries relevant to the query" " (user profile / episodic / semantic + history summary)." ), "parameters": { @@ -220,7 +220,7 @@ def handle_tool_call(self, name: str, args: dict[str, Any]) -> str: return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) def _ltm_search(self, args: dict[str, Any] | None) -> str: - """Execute ltm_search: search AgentArts long-term memories.""" + """Execute ltm_search: search AgentArts memories.""" if not self._client: return json.dumps({"error": "Memory provider not initialized"}, ensure_ascii=False) @@ -311,7 +311,7 @@ def system_prompt_block(self) -> str: return SYSTEM_PROMPT_BLOCK def prefetch(self, query: str) -> str: - """Inject relevant long-term memories before each LLM call.""" + """Inject relevant emories before each LLM call.""" if not self._client or not query: return "" try: diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/agentarts-memory-capture.ts b/src/agentarts/toolkit/plugins/memory/ai_agent/opencode/agentarts-memory-capture.ts similarity index 96% rename from agentarts-memory-plugins/agentarts-memory-code_agent/opencode/agentarts-memory-capture.ts rename to src/agentarts/toolkit/plugins/memory/ai_agent/opencode/agentarts-memory-capture.ts index acdf85fc..1f168ef2 100644 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/agentarts-memory-capture.ts +++ b/src/agentarts/toolkit/plugins/memory/ai_agent/opencode/agentarts-memory-capture.ts @@ -170,7 +170,7 @@ const AGENTARTS_INSTRUCTIONS = ` You have access to Huawei Cloud AgentArts Memory for persistent cross-session memory. Relevant memories are automatically injected before each turn. The conversation prompt -is recorded to long-term memory after each user turn. +is recorded to cloud memory after each user turn. Use /recall [query] to search past memories, and /remember [content] to explicitly save. Never fabricate memory results — only present what the tools return. @@ -256,7 +256,13 @@ export const AgentArtsMemoryCapturePlugin: Plugin = async (ctx) => { const pendingQuery = sessionPendingAdd.get(sid); if (!pendingQuery) return; sessionPendingAdd.delete(sid); - await addMessages([{ role: "user", content: pendingQuery }], projectScopeId, getUserId()); + // Include the OpenCode session ID in scope_id so each conversation + // gets its own memory session (not shared across conversations). + await addMessages( + [{ role: "user", content: pendingQuery }], + `${projectScopeId}:${sid}`, + getUserId(), + ); } } }, diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/recall.md b/src/agentarts/toolkit/plugins/memory/ai_agent/opencode/commands/recall.md similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/recall.md rename to src/agentarts/toolkit/plugins/memory/ai_agent/opencode/commands/recall.md diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/remember.md b/src/agentarts/toolkit/plugins/memory/ai_agent/opencode/commands/remember.md similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/remember.md rename to src/agentarts/toolkit/plugins/memory/ai_agent/opencode/commands/remember.md diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/plugin.json b/src/agentarts/toolkit/plugins/memory/ai_agent/opencode/plugin.json similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/opencode/plugin.json rename to src/agentarts/toolkit/plugins/memory/ai_agent/opencode/plugin.json diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/__init__.py b/src/agentarts/toolkit/plugins/memory/installer/__init__.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/__init__.py rename to src/agentarts/toolkit/plugins/memory/installer/__init__.py diff --git a/src/agentarts/toolkit/plugins/memory/installer/cli.py b/src/agentarts/toolkit/plugins/memory/installer/cli.py new file mode 100644 index 00000000..4163ee4a --- /dev/null +++ b/src/agentarts/toolkit/plugins/memory/installer/cli.py @@ -0,0 +1,292 @@ +"""Typer callbacks for ``agentarts memory install|uninstall|server``. + +These callbacks are registered onto the shared ``memory_app`` in +``agentarts.toolkit.cli.memory.commands``. The business logic is ported from +the original argparse-based ``agentarts-memory`` installer. +""" + +from __future__ import annotations + +import os +from typing import Annotated + +import typer + +from rich.console import Console + +from agentarts.toolkit.utils.common import echo_error, echo_key_value, echo_success, echo_warning + +from .platforms import detect_all, get_platform +from .server_manager import start as _server_start +from .server_manager import status as _server_status +from .server_manager import stop as _server_stop +from .utils import ( + EscapeInterrupt, + add, + confirm, + ensure_credentials, + expand, + find, + list_all, + remove, + select_one, + set_yes, +) + +console = Console() + +VALID_TARGETS = ("hermes", "claude", "codex", "opencode", "openclaw") + +# Platforms that depend on the local adapter server. +SERVER_DEPENDENT = {"claude", "codex", "opencode"} + + +def _select_scope(platform_name: str, yes: bool) -> str: + """Determine install scope (project or global).""" + platform = get_platform(platform_name) + if platform and platform.fixed_user_level: + return "global" + if yes: + return "project" + idx = select_one( + "Install scope", + ["Project \u2014 this project only", "Global \u2014 all projects"], + 0, + ) + return "project" if idx == 0 else "global" + + +def _check_server_dependency(yes: bool) -> None: + """Print server dependency hint for claude/codex/opencode.""" + echo_warning("This platform requires the local adapter server (127.0.0.1:8719)") + console.print(" Start it with: [cyan]agentarts memory server start[/cyan]") + console.print(" Configure: HUAWEICLOUD_SDK_MEMORY_API_KEY + AGENTARTS_MEMORY_SPACE_ID") + + +def _degraded_scan(target: str) -> None: + """Attempt to find and clean up files when manifest is missing.""" + candidates = { + "hermes": [expand("~/.hermes/hermes-agent/plugins/memory/agentarts")], + "claude": [ + expand("~/.claude/agentarts-memory"), + os.path.join(os.getcwd(), ".claude", "agentarts-memory"), + ], + "codex": [ + expand("~/.codex/agentarts-memory"), + os.path.join(os.getcwd(), ".codex", "agentarts-memory"), + ], + "opencode": [expand("~/.config/opencode/plugins/agentarts-memory-capture.ts")], + } + + found = candidates.get(target, []) + any_found = False + for path in found: + if os.path.exists(path): + any_found = True + console.print(f" Found leftover: {path}") + console.print(f" Remove manually: [yellow]rm -rf {path}[/yellow]") + + if not any_found: + console.print(f" No leftover {target} files found.") + + +def _do_install(target: str | None, global_scope: bool, yes: bool) -> int: + """Handle the install flow. Returns process exit code.""" + if target is not None and target not in VALID_TARGETS: + echo_error(f"Invalid target '{target}'. Choose from: {', '.join(VALID_TARGETS)}") + return 2 + + if target == "openclaw": + echo_warning("openclaw not yet implemented") + return 0 + + if target is None: + detected = detect_all(global_scope) + if not detected: + console.print("\nNo supported platforms detected.") + console.print( + "Install Claude Code, Codex, OpenCode, or Hermes Agent, " + "then run [cyan]agentarts memory install[/cyan] again." + ) + return 1 + console.print("Detecting platforms...") + for _, p in detected: + console.print(f" [green]\u221a[/green] {p.display}") + options = [p.display for _, p in detected] + idx = select_one("\nSelect platform", options, 0) + target = detected[idx][0] + + platform = get_platform(target) + if platform is None: + echo_error(f"Unknown platform '{target}'") + return 2 + + console.print("\nChecking credentials...") + creds = ensure_credentials(yes) + + scope = "global" if global_scope else _select_scope(target, yes) + + console.print(f"\nInstalling {platform.display} ({scope})...") + result = platform.install(scope, creds, yes) + + add( + { + "platform": target, + "scope": scope, + "config_dir": result.config_dir, + "scripts_dir": result.scripts_dir, + "files": result.files, + "config_files": result.config_files, + } + ) + + echo_success(f"Install complete: {platform.display} ({scope})") + echo_key_value("Config dir", result.config_dir) + if result.scripts_dir: + echo_key_value("Scripts", result.scripts_dir) + echo_key_value("Files", f"{len(result.files)} deployed") + if result.config_files: + echo_key_value("Config", ", ".join(result.config_files)) + + if target in SERVER_DEPENDENT: + _check_server_dependency(yes) + + console.print("\nRestart the platform to activate.") + return 0 + + +def _do_uninstall(target: str | None, global_scope: bool, yes: bool) -> int: + """Handle the uninstall flow. Returns process exit code.""" + if target is not None and target not in VALID_TARGETS: + echo_error(f"Invalid target '{target}'. Choose from: {', '.join(VALID_TARGETS)}") + return 2 + + if target == "openclaw": + echo_warning("openclaw not yet implemented") + return 0 + + scope = "global" if global_scope else None + + entry = None + if target is not None: + entry = find(target, scope, None) + if entry is None: + console.print(f"\nNo {target} installation found in manifest.") + console.print("Attempting degraded scan...") + _degraded_scan(target) + return 1 + else: + all_installs = list_all() + if not all_installs: + console.print("\nNo installations found.") + return 1 + console.print("\nInstalled platforms:") + options = [ + f"{i['platform']} ({i.get('scope', '?')}) \u2014 {i.get('config_dir', '?')}" + for i in all_installs + ] + idx = select_one("Select installation to remove", options, 0) + entry = all_installs[idx] + target = entry["platform"] + + platform = get_platform(target) + if platform is None: + echo_error(f"Unknown platform '{target}'") + return 2 + + if not yes and not confirm( + f"Remove {platform.display} from {entry.get('config_dir', '?')}?", + default=True, + ): + console.print("[yellow]Cancelled.[/yellow]") + return 0 + + console.print(f"\nUninstalling {platform.display}...") + platform.uninstall(entry) + + remove( + target, + entry.get("scope", ""), + entry.get("config_dir", ""), + ) + + echo_success(f"Uninstall complete: {platform.display}") + console.print("Restart the platform to apply changes.") + return 0 + + +def install_cmd( + target: Annotated[ + str | None, + typer.Argument(help=f"Platform ({', '.join(VALID_TARGETS)}). Omit to detect."), + ] = None, + global_scope: Annotated[ + bool, typer.Option("--global", help="Install to user-level config.") + ] = False, + yes: Annotated[bool, typer.Option("--yes", "-y", help="Auto-confirm all prompts.")] = False, +) -> None: + """Install the AgentArts Memory plugin for a supported AI agent.""" + set_yes(yes) + try: + code = _do_install(target, global_scope, yes) + except EscapeInterrupt: + console.print("\n[yellow]Cancelled.[/yellow]") + code = 0 + if code: + raise typer.Exit(code) + + +def uninstall_cmd( + target: Annotated[ + str | None, + typer.Argument(help=f"Platform ({', '.join(VALID_TARGETS)}). Omit to select."), + ] = None, + global_scope: Annotated[ + bool, typer.Option("--global", help="Limit to user-level installs.") + ] = False, + yes: Annotated[bool, typer.Option("--yes", "-y", help="Auto-confirm all prompts.")] = False, +) -> None: + """Uninstall an AgentArts Memory plugin.""" + set_yes(yes) + try: + code = _do_uninstall(target, global_scope, yes) + except EscapeInterrupt: + console.print("\n[yellow]Cancelled.[/yellow]") + code = 0 + if code: + raise typer.Exit(code) + + +server_app = typer.Typer( + name="server", + help="Manage the local AgentArts Memory adapter server (127.0.0.1:8719).", + add_completion=False, + no_args_is_help=True, +) + + +@server_app.command("start") +def server_start_cmd( + yes: Annotated[bool, typer.Option("--yes", "-y", help="Auto-confirm all prompts.")] = False, +) -> None: + """Start the local adapter server.""" + set_yes(yes) + code = _server_start() + if code: + raise typer.Exit(code) + + +@server_app.command("stop") +def server_stop_cmd() -> None: + """Stop the local adapter server.""" + code = _server_stop() + if code: + raise typer.Exit(code) + + +@server_app.command("status") +def server_status_cmd() -> None: + """Check the local adapter server status.""" + code = _server_status() + if code: + raise typer.Exit(code) diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/__init__.py b/src/agentarts/toolkit/plugins/memory/installer/platforms/__init__.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/__init__.py rename to src/agentarts/toolkit/plugins/memory/installer/platforms/__init__.py diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/base.py b/src/agentarts/toolkit/plugins/memory/installer/platforms/base.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/base.py rename to src/agentarts/toolkit/plugins/memory/installer/platforms/base.py diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/claude.py b/src/agentarts/toolkit/plugins/memory/installer/platforms/claude.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/claude.py rename to src/agentarts/toolkit/plugins/memory/installer/platforms/claude.py diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/codex.py b/src/agentarts/toolkit/plugins/memory/installer/platforms/codex.py similarity index 92% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/codex.py rename to src/agentarts/toolkit/plugins/memory/installer/platforms/codex.py index e52672e3..3b315e47 100644 --- a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/codex.py +++ b/src/agentarts/toolkit/plugins/memory/installer/platforms/codex.py @@ -4,7 +4,7 @@ and registers 6 hooks in ``/hooks.json`` using absolute paths (the ``${CODEX_PLUGIN_ROOT}`` placeholder is replaced). -Also updates ``/config.toml`` to enable ``codex_hooks = true`` +Also updates ``/config.toml`` to enable ``hooks = true`` under ``[features]`` (text-level merge, no toml library dependency). Config dir: @@ -38,7 +38,7 @@ from .base import InstallResult, Platform CODEX_PLACEHOLDER = "${CODEX_PLUGIN_ROOT}" -TOML_KEY = "codex_hooks" +TOML_KEY = "hooks" class CodexPlatform(Platform): @@ -94,11 +94,13 @@ def install(self, scope: str, creds: dict, yes: bool) -> InstallResult: write_json_atomic(hooks_path, merged) status_updated("hooks.json", hooks_path) - # Phase 3: Update config.toml — enable codex_hooks. + # Phase 3: Update config.toml — enable hooks. toml_text = "" if os.path.isfile(toml_path): toml_text = Path(toml_path).read_text(encoding="utf-8") - updated_toml = merge_toml_features(toml_text, TOML_KEY, "true") + updated_toml = merge_toml_features( + toml_text, TOML_KEY, "true", deprecated_keys=["codex_hooks"] + ) Path(toml_path).parent.mkdir(parents=True, exist_ok=True) Path(toml_path).write_text(updated_toml, encoding="utf-8") status_updated("config.toml", toml_path) @@ -127,13 +129,13 @@ def uninstall(self, entry: dict) -> None: os.unlink(hooks_path) status_ok("Removed hooks.json", hooks_path) - # Phase 2: Strip codex_hooks from config.toml. + # Phase 2: Strip hooks from config.toml. if toml_path and os.path.isfile(toml_path): toml_text = Path(toml_path).read_text(encoding="utf-8") updated = strip_toml_feature(toml_text, TOML_KEY) if updated.strip(): Path(toml_path).write_text(updated, encoding="utf-8") - status_ok("Stripped codex_hooks from config.toml", toml_path) + status_ok("Stripped hooks from config.toml", toml_path) else: os.unlink(toml_path) status_ok("Removed config.toml", toml_path) diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/hermes.py b/src/agentarts/toolkit/plugins/memory/installer/platforms/hermes.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/hermes.py rename to src/agentarts/toolkit/plugins/memory/installer/platforms/hermes.py diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/openclaw.py b/src/agentarts/toolkit/plugins/memory/installer/platforms/openclaw.py similarity index 81% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/openclaw.py rename to src/agentarts/toolkit/plugins/memory/installer/platforms/openclaw.py index ebf898f6..099c0784 100644 --- a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/openclaw.py +++ b/src/agentarts/toolkit/plugins/memory/installer/platforms/openclaw.py @@ -6,6 +6,8 @@ from __future__ import annotations +from agentarts.toolkit.utils.common import echo_warning + from .base import InstallResult, Platform @@ -22,8 +24,8 @@ def config_dir(self, scope: str) -> str: return "" def install(self, scope: str, creds: dict, yes: bool) -> InstallResult: - print("openclaw 暂未实现,敬请期待") + echo_warning("openclaw not yet implemented") return InstallResult(config_dir="", scripts_dir="", files=[], config_files=[]) def uninstall(self, entry: dict) -> None: - print("openclaw 暂未实现,敬请期待") + echo_warning("openclaw not yet implemented") diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/opencode.py b/src/agentarts/toolkit/plugins/memory/installer/platforms/opencode.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/platforms/opencode.py rename to src/agentarts/toolkit/plugins/memory/installer/platforms/opencode.py diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/server_manager.py b/src/agentarts/toolkit/plugins/memory/installer/server_manager.py similarity index 73% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/server_manager.py rename to src/agentarts/toolkit/plugins/memory/installer/server_manager.py index 874a4964..0c8ef600 100644 --- a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/server_manager.py +++ b/src/agentarts/toolkit/plugins/memory/installer/server_manager.py @@ -1,4 +1,4 @@ -"""Server lifecycle management for agentarts-memory-server. +"""Server lifecycle management for the AgentArts Memory adapter server. Provides start, stop, and status operations for the local adapter server that serves Claude Code / Codex / OpenCode hook scripts over HTTP on @@ -11,7 +11,6 @@ from __future__ import annotations import os -import shutil import signal import subprocess import sys @@ -19,7 +18,10 @@ import urllib.request from pathlib import Path -from .utils import code_agent_source, confirm, expand, status_err, status_ok +from .utils import expand, status_err, status_ok + +# Launch the in-package adapter server as a module (no standalone console script). +SERVER_MODULE = "agentarts.toolkit.plugins.memory.server.run" PID_FILE = "~/.agentarts-memory/server.pid" LOG_FILE = "~/.agentarts-memory/server.log" @@ -95,69 +97,23 @@ def _check_health() -> bool: return False -def _is_installed() -> bool: - """Check if agentarts-memory-server console script is on PATH.""" - return shutil.which("agentarts-memory-server") is not None - - -def _install_server() -> bool: - """Install agentarts-memory-code_agent via pip from local source.""" - source = code_agent_source() - if not os.path.isdir(source): - status_err("Install server", f"source not found: {source}") - return False - - status_ok("Installing agentarts-memory-code_agent", source) - try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", source], - capture_output=True, - text=True, - ) - except FileNotFoundError: - status_err("Install server", "pip not available") - return False - - if result.returncode != 0: - status_err("Install server", result.stderr.strip() or "pip install failed") - return False - - if not _is_installed(): - status_err( - "Install server", "installed but agentarts-memory-server not on PATH; try 'hash -r'" - ) - return False - - status_ok("Install server", "agentarts-memory-server ready") - return True - - def start() -> int: """Start the server in the background. - If the server is not installed, installs agentarts-memory-code_agent first. - Returns 0 on success, 1 on failure. + Launches the in-package adapter server (FastAPI/uvicorn) as a detached + subprocess. Returns 0 on success, 1 on failure. """ if _is_running(): pid = _read_pid() status_ok("Server", f"already running (PID {pid})") return 0 - if not _is_installed(): - if not confirm( - "Install agentarts-memory-server (agentarts-memory-code_agent)?", default=True - ): - print("Cancelled.") - return 1 - if not _install_server(): - return 1 - log_path = _log_path() log_path.parent.mkdir(parents=True, exist_ok=True) log_fp = open(log_path, "a", encoding="utf-8") proc = subprocess.Popen( - ["agentarts-memory-server"], + [sys.executable, "-m", SERVER_MODULE], stdin=subprocess.DEVNULL, stdout=log_fp, stderr=log_fp, @@ -167,6 +123,8 @@ def start() -> int: _write_pid(proc.pid) status_ok("Start server", f"PID {proc.pid}") + status_ok("PID file", str(_pid_path())) + status_ok("Log file", str(log_path)) time.sleep(STARTUP_WAIT_SECONDS) if proc.poll() is not None: diff --git a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/utils.py b/src/agentarts/toolkit/plugins/memory/installer/utils.py similarity index 91% rename from agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/utils.py rename to src/agentarts/toolkit/plugins/memory/installer/utils.py index 603d0758..6a1da975 100644 --- a/agentarts-memory-plugins/agentarts-memory-installer/agentarts_memory_installer/utils.py +++ b/src/agentarts/toolkit/plugins/memory/installer/utils.py @@ -17,6 +17,10 @@ from pathlib import Path from typing import cast +from rich.console import Console + +console = Console() + _YES: bool = False @@ -203,11 +207,14 @@ def remove_hooks_key(settings: dict, scripts_dir: str) -> dict: # ── TOML text-level merge ──────────────────────────────────────────── -def merge_toml_features(text: str, key: str, value: str) -> str: +def merge_toml_features( + text: str, key: str, value: str, *, deprecated_keys: list[str] | None = None +) -> str: """Ensure ``[features]`` section contains ``key = value``. Works at the text level (no toml library dependency). Preserves - existing keys and sections. + existing keys and sections. If *deprecated_keys* is provided, those + keys are removed from the section first. """ lines = text.splitlines() @@ -227,6 +234,15 @@ def merge_toml_features(text: str, key: str, value: str) -> str: # Check if key already exists in the section. section_lines = lines[features_start + 1 : features_end] key_line = f"{key} = {value}" + + # Remove deprecated keys. + if deprecated_keys: + section_lines = [ + line + for line in section_lines + if not any(line.strip().startswith(f"{dk} ") for dk in deprecated_keys) + ] + found = False for i, line in enumerate(section_lines): if line.strip().startswith(f"{key} "): @@ -421,15 +437,15 @@ def remove_if_empty(path: str) -> None: def status_ok(label: str, path: str) -> None: - print(f" \u2713 {label} \u2192 {path}") + console.print(f" [green]\u221a[/green] {label} \u2192 {path}") def status_err(label: str, err: str) -> None: - print(f" \u2717 {label} \u2014 {err}") + console.print(f" [red]\u00d7[/red] {label} \u2014 {err}") def status_updated(label: str, path: str) -> None: - print(f" \u21bb {label} (updated) \u2192 {path}") + console.print(f" [yellow]\u21bb[/yellow] {label} (updated) \u2192 {path}") # ── Interactive prompts ────────────────────────────────────────────── @@ -517,10 +533,10 @@ def select_one(prompt: str, options: list[str], default_idx: int = 0) -> int: """Single-choice select. Returns *default_idx* when ``--yes``.""" if _YES or not sys.stdin.isatty(): return default_idx - print(prompt) + console.print(prompt) for i, opt in enumerate(options): marker = "*" if i == default_idx else " " - print(f" {i + 1}) {opt} {marker}") + console.print(f" {i + 1}) {opt} {marker}") try: raw = _input_with_esc(f"Choice (1-{len(options)}) [{default_idx + 1}]: ").strip() except (EOFError, KeyboardInterrupt): @@ -562,28 +578,38 @@ def installer_root() -> str: def repo_root() -> str: - """Return the absolute path to the repository root.""" - return str(Path(plugins_root()).parent) - + """Return the absolute path to the repository root. -def plugins_root() -> str: - """Return the absolute path to ``agentarts-memory-plugins/``.""" - p = Path(installer_root()) + Walks upward from this package looking for the directory containing + ``pyproject.toml``. In a source/editable checkout this resolves to the + repo root; falls back to the installer package directory otherwise. + """ + p = Path(__file__).resolve().parent while p.parent != p: - if p.name == "agentarts-memory-plugins": + if (p / "pyproject.toml").is_file(): return str(p) p = p.parent - return str(p) + return str(Path(__file__).resolve().parent) + + +def plugins_root() -> str: + """Return the absolute path to the memory plugins package directory. + + This is ``agentarts/toolkit/plugins/memory`` — the directory that holds + the ``hermes`` and ``code_agent`` plugin assets as siblings of the + ``installer`` package. + """ + return str(Path(__file__).resolve().parent.parent) def hermes_source() -> str: """Return the hermes plugin source directory.""" - return str(Path(plugins_root()) / "agentarts-memory-hermes") + return str(Path(plugins_root()) / "ai_agent" / "hermes") def code_agent_source() -> str: """Return the code_agent plugin source directory.""" - return str(Path(plugins_root()) / "agentarts-memory-code_agent") + return str(Path(plugins_root()) / "resources") # ── Hermes source files ── @@ -600,18 +626,18 @@ def hermes_files() -> list[str]: def code_agent_scripts() -> list[str]: """Return absolute paths of the 13 .mjs hook scripts.""" - scripts_dir = str(Path(code_agent_source()) / "scripts") + scripts_dir = str(Path(plugins_root()) / "resources" / "scripts") return [str(Path(scripts_dir) / f) for f in CODE_AGENT_SCRIPTS] def claude_hooks_template() -> str: - """Return the path to hooks/hooks.json (Claude template, 12 hooks).""" - return str(Path(code_agent_source()) / "hooks" / "hooks.json") + """Return the path to the Claude hooks template (claude_code/hooks.json).""" + return str(Path(plugins_root()) / "ai_agent" / "claude_code" / "hooks.json") def codex_hooks_template() -> str: - """Return the path to hooks/hooks.codex.json (Codex template, 6 hooks).""" - return str(Path(code_agent_source()) / "hooks" / "hooks.codex.json") + """Return the path to the Codex hooks template (codex/hooks.codex.json).""" + return str(Path(plugins_root()) / "ai_agent" / "codex" / "hooks.codex.json") # ── OpenCode source files ── @@ -625,7 +651,7 @@ def opencode_files() -> dict[str, str]: ``commands/recall.md`` ``commands/remember.md`` """ - oc_dir = str(Path(code_agent_source()) / "opencode") + oc_dir = str(Path(plugins_root()) / "ai_agent" / "opencode") return { "plugins/agentarts-memory-capture.ts": str(Path(oc_dir) / "agentarts-memory-capture.ts"), "commands/recall.md": str(Path(oc_dir) / "commands" / "recall.md"), @@ -882,16 +908,16 @@ def interactive_fill(missing: list[str], yes: bool) -> dict[str, str]: if validator: ok, result = validator(raw) if not ok: - print(f" \u2717 {result}") + console.print(f" [red]\u00d7[/red] {result}") continue raw = result if not raw and not is_optional: - print(" \u2717 Value cannot be empty") + console.print(" [red]\u00d7[/red] Value cannot be empty") continue display = _mask(raw, var) - print(f" \u2713 Configured: {display}") + console.print(f" [green]\u221a[/green] Configured: {display}") filled[var] = raw break @@ -914,18 +940,18 @@ def ensure_credentials(yes: bool) -> dict[str, str]: all_ok = all(v in config and config[v] for v in REQUIRED_VARS) if not all_ok: - print("\n\u26a0\ufe0f Missing required credentials. Please set:") + console.print("\n[yellow]\u26a0[/yellow] Missing required credentials. Please set:") for var in REQUIRED_VARS: if not config.get(var): - print(f" {var}") + console.print(f" {var}") return config # Optional: write to shell rc. if not yes: if confirm("Save configuration to shell rc for persistence?", default=True): write_shell_rc(config) - print(" \u2713 Configuration saved to shell rc") - print(" Run 'source' or restart terminal to apply.") + console.print(" [green]\u221a[/green] Configuration saved to shell rc") + console.print(" Run 'source' or restart terminal to apply.") elif all_ok: # In --yes mode, persist if we filled anything interactively (no-op if # everything came from env). diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/_shared.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/_shared.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/_shared.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/_shared.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/notification.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/notification.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/notification.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/notification.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-failure.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/post-tool-failure.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-failure.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/post-tool-failure.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-use.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/post-tool-use.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-use.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/post-tool-use.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-compact.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/pre-compact.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-compact.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/pre-compact.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-tool-use.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/pre-tool-use.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-tool-use.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/pre-tool-use.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/prompt-submit.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/prompt-submit.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/prompt-submit.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/prompt-submit.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-end.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/session-end.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-end.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/session-end.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-start.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/session-start.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-start.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/session-start.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/stop.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/stop.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/stop.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/stop.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-start.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/subagent-start.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-start.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/subagent-start.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-stop.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/subagent-stop.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-stop.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/subagent-stop.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/task-completed.mjs b/src/agentarts/toolkit/plugins/memory/resources/scripts/task-completed.mjs similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/scripts/task-completed.mjs rename to src/agentarts/toolkit/plugins/memory/resources/scripts/task-completed.mjs diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/__init__.py b/src/agentarts/toolkit/plugins/memory/server/__init__.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/server/__init__.py rename to src/agentarts/toolkit/plugins/memory/server/__init__.py diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/agentarts_client.py b/src/agentarts/toolkit/plugins/memory/server/agentarts_client.py similarity index 83% rename from agentarts-memory-plugins/agentarts-memory-code_agent/server/agentarts_client.py rename to src/agentarts/toolkit/plugins/memory/server/agentarts_client.py index 9544b57d..5f2ce243 100644 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/server/agentarts_client.py +++ b/src/agentarts/toolkit/plugins/memory/server/agentarts_client.py @@ -1,7 +1,7 @@ """Adapter wrapping agentarts.sdk.memory.MemoryClient. Provides: - - scope_id -> session_id caching (auto create_memory_session on first use) + - scope_id:actor_id -> session_id caching (auto create_memory_session on first use) - user_id -> actor_id mapping - normalized result dicts for the HTTP layer """ @@ -68,7 +68,7 @@ def __init__( self._sdk = sdk or import_memory_sdk() self._client: Any = None self._lock = threading.Lock() - # scope_id -> session_id cache + # scope_id:actor_id -> session_id cache self._sessions: dict[str, str] = {} # ── availability ── @@ -89,17 +89,27 @@ def _ensure_client(self) -> Any: return self._client def _get_or_create_session(self, scope_id: str, actor_id: str) -> str: - """Return cached session_id for scope, creating one on first use.""" + """Return cached session_id for scope+actor, creating one on first use.""" with self._lock: - sid = self._sessions.get(scope_id) + cache_key = f"{scope_id}:{actor_id}" + sid = self._sessions.get(cache_key) if sid: if DEBUG: - logger.debug("[SDK] session cache hit | scope_id=%s, session_id=%s", scope_id, sid) + logger.debug( + "[SDK] session cache hit | scope_id=%s, actor_id=%s, session_id=%s", + scope_id, + actor_id, + sid, + ) return sid client = self._ensure_client() if DEBUG: - logger.debug("[SDK] creating session | scope_id=%s, user_id=%s, space_id=%s", - scope_id, actor_id, self._space_id[:8] + "...") + logger.debug( + "[SDK] creating session | scope_id=%s, actor_id=%s, space_id=%s", + scope_id, + actor_id, + self._space_id[:8] + "...", + ) session = client.create_memory_session( space_id=self._space_id, actor_id=actor_id, @@ -108,9 +118,14 @@ def _get_or_create_session(self, scope_id: str, actor_id: str) -> str: sid = getattr(session, "id", None) or getattr(session, "session_id", "") if not sid: raise RuntimeError("create_memory_session returned empty session id") - self._sessions[scope_id] = sid + self._sessions[cache_key] = sid if DEBUG: - logger.debug("[SDK] session created | scope_id=%s, session_id=%s", scope_id, sid) + logger.debug( + "[SDK] session created | scope_id=%s, actor_id=%s, session_id=%s", + scope_id, + actor_id, + sid, + ) return sid # ── operations ── @@ -137,8 +152,13 @@ def add_messages( for m in messages ] if DEBUG: - logger.debug("[SDK] add_messages | user_id=%s, scope_id=%s, session_id=%s, count=%d", - user_id, scope_id, sid, len(sdk_msgs)) + logger.debug( + "[SDK] add_messages | user_id=%s, scope_id=%s, session_id=%s, count=%d", + user_id, + scope_id, + sid, + len(sdk_msgs), + ) resp = client.add_messages( space_id=self._space_id, session_id=sid, @@ -158,8 +178,13 @@ def search_memories( """Semantic search; returns normalized list of {content, score, type}.""" client = self._ensure_client() if DEBUG: - logger.debug("[SDK] search_memories | user_id=%s, scope_id=%s, query='%s...', num=%d", - user_id, scope_id, query[:50] if query else "", num) + logger.debug( + "[SDK] search_memories | user_id=%s, scope_id=%s, query='%s...', num=%d", + user_id, + scope_id, + query[:50] if query else "", + num, + ) filters = self._sdk.MemorySearchFilter( query=query, top_k=num, @@ -183,8 +208,12 @@ def list_memories( """List memory records; returns normalized list of {content, type, created_at}.""" client = self._ensure_client() if DEBUG: - logger.debug("[SDK] list_memories | user_id=%s, scope_id=%s, limit=%d", - user_id or "default", scope_id or "default", limit) + logger.debug( + "[SDK] list_memories | user_id=%s, scope_id=%s, limit=%d", + user_id or "default", + scope_id or "default", + limit, + ) resp = client.list_memories( space_id=self._space_id, limit=limit, diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/app.py b/src/agentarts/toolkit/plugins/memory/server/app.py similarity index 100% rename from agentarts-memory-plugins/agentarts-memory-code_agent/server/app.py rename to src/agentarts/toolkit/plugins/memory/server/app.py diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/run.py b/src/agentarts/toolkit/plugins/memory/server/run.py similarity index 99% rename from agentarts-memory-plugins/agentarts-memory-code_agent/server/run.py rename to src/agentarts/toolkit/plugins/memory/server/run.py index 3e545c90..123f15aa 100644 --- a/agentarts-memory-plugins/agentarts-memory-code_agent/server/run.py +++ b/src/agentarts/toolkit/plugins/memory/server/run.py @@ -318,7 +318,7 @@ def run_server(log_level: str) -> None: port = int(os.getenv("AGENTARTS_MEMORY_SERVER_PORT", "8719")) uvicorn.run( - "server.app:app", + "agentarts.toolkit.plugins.memory.server.app:app", host=host, port=port, log_level=log_level, @@ -373,4 +373,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/agentarts-memory-code_agent/__init__.py b/tests/agentarts-memory-code_agent/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/agentarts-memory-code_agent/conftest.py b/tests/agentarts-memory-code_agent/conftest.py deleted file mode 100644 index eeab4f6e..00000000 --- a/tests/agentarts-memory-code_agent/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Conftest for agentarts-memory-code_agent tests. - -Makes the plugin's `server` package importable without installing it, -by inserting the plugin root onto sys.path. -""" - -import os -import sys - -_PLUGIN_ROOT = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "agentarts-memory-plugins", "agentarts-memory-code_agent") -) -if _PLUGIN_ROOT not in sys.path: - sys.path.insert(0, _PLUGIN_ROOT) diff --git a/tests/agentarts-memory-hermes/conftest.py b/tests/agentarts-memory-hermes/conftest.py deleted file mode 100644 index a6b3ad56..00000000 --- a/tests/agentarts-memory-hermes/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Pytest configuration: add plugin root to sys.path for top-level modules.""" - -import sys -from pathlib import Path - -# From tests/agentarts-memory-hermes/ → ../../agentarts-memory-plugins/agentarts-memory-hermes -plugin_root = ( - Path(__file__).resolve().parent.parent.parent - / "agentarts-memory-plugins" - / "agentarts-memory-hermes" -) -sys.path.insert(0, str(plugin_root)) diff --git a/tests/agentarts-memory-installer/conftest.py b/tests/agentarts-memory-installer/conftest.py deleted file mode 100644 index 5cf17fa2..00000000 --- a/tests/agentarts-memory-installer/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Pytest configuration for agentarts-memory-installer tests. - -Makes the installer's `agentarts_memory_installer` package importable -without installing it, by inserting the plugin root onto sys.path. -""" - -import os -import sys - -_PLUGIN_ROOT = os.path.abspath( - os.path.join( - os.path.dirname(__file__), "..", "..", "agentarts-memory-plugins", "agentarts-memory-installer" - ) -) -if _PLUGIN_ROOT not in sys.path: - sys.path.insert(0, _PLUGIN_ROOT) diff --git a/tests/agentarts-memory-installer/test_installer_cli.py b/tests/agentarts-memory-installer/test_installer_cli.py deleted file mode 100644 index 4a7b057e..00000000 --- a/tests/agentarts-memory-installer/test_installer_cli.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Tests for CLI entry point (P0 + P6).""" - -import os -from unittest.mock import patch - -import pytest -from agentarts_memory_installer import cli -from agentarts_memory_installer.utils import ( - ENV_API_KEY, - ENV_REGION, - ENV_SPACE_ID, - EscapeInterrupt, - expand, - find, -) - -VALID_TARGETS = cli.VALID_TARGETS -build_parser = cli.build_parser -main = cli.main - - -def _set_home_and_creds(monkeypatch, tmp_path): - """Redirect HOME to tmp and set valid credentials.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv(ENV_SPACE_ID, "test-space-12345") - monkeypatch.setenv(ENV_API_KEY, "test-api-key-abcdef-123456") - monkeypatch.setenv(ENV_REGION, "cn-north-4") - - -# ── Parser tests ──────────────────────────────────────────────────── - - -class TestParser: - def test_install_help_exits_clean(self): - with pytest.raises(SystemExit) as exc: - build_parser().parse_args(["install", "--help"]) - assert exc.value.code == 0 - - def test_uninstall_help_exits_clean(self): - with pytest.raises(SystemExit) as exc: - build_parser().parse_args(["uninstall", "--help"]) - assert exc.value.code == 0 - - def test_no_subcommand_errors(self): - with pytest.raises(SystemExit): - build_parser().parse_args([]) - - def test_install_parses_target(self): - args = build_parser().parse_args(["install", "hermes"]) - assert args.command == "install" - assert args.target == "hermes" - assert args.global_scope is False - assert args.yes is False - - def test_install_global_flag(self): - args = build_parser().parse_args(["install", "claude", "--global"]) - assert args.global_scope is True - - def test_install_yes_flag(self): - args = build_parser().parse_args(["install", "codex", "--yes"]) - assert args.yes is True - args2 = build_parser().parse_args(["install", "codex", "-y"]) - assert args2.yes is True - - def test_install_no_target_ok(self): - args = build_parser().parse_args(["install"]) - assert args.target is None - - def test_valid_targets(self): - assert VALID_TARGETS == ("hermes", "claude", "codex", "opencode", "openclaw") - - -# ── openclaw placeholder ──────────────────────────────────────────── - - -class TestOpenClawPlaceholder: - def test_install_openclaw(self, capsys): - rc = main(["install", "openclaw"]) - assert rc == 0 - assert "暂未实现" in capsys.readouterr().out - - def test_uninstall_openclaw(self, capsys): - rc = main(["uninstall", "openclaw"]) - assert rc == 0 - assert "暂未实现" in capsys.readouterr().out - - -# ── Invalid target ────────────────────────────────────────────────── - - -class TestInvalidTarget: - def test_install_invalid(self, capsys): - rc = main(["install", "bogus"]) - assert rc == 2 - - def test_uninstall_invalid(self, capsys): - rc = main(["uninstall", "bogus"]) - assert rc == 2 - - -# ── No detection / no installs ────────────────────────────────────── - - -class TestNoTargets: - def test_install_no_target_no_detection(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - # No ~/.claude, ~/.codex, ~/.hermes, ~/.config/opencode - rc = main(["install", "--yes"]) - assert rc == 1 - assert "No supported platforms detected" in capsys.readouterr().out - - def test_uninstall_no_target_no_installs(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - rc = main(["uninstall", "--yes"]) - assert rc == 1 - assert "No installations found" in capsys.readouterr().out - - def test_uninstall_target_not_found(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - rc = main(["uninstall", "hermes", "--yes"]) - assert rc == 1 - assert "No hermes installation found" in capsys.readouterr().out - - -# ── End-to-end: install + uninstall hermes ──────────────────────────── - - -class TestEndToEndHermes: - def test_install_hermes_yes(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - rc = main(["install", "hermes", "--yes"]) - assert rc == 0 - - # Verify files deployed. - plugin_dir = expand("~/.hermes/hermes-agent/plugins/memory/agentarts") - assert os.path.isdir(plugin_dir) - assert os.path.isfile(os.path.join(plugin_dir, "provider.py")) - - # Verify manifest has record. - found = find("hermes") - assert found is not None - assert found["scope"] == "global" # hermes is fixed_user_level - - def test_install_hermes_global_yes(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - rc = main(["install", "hermes", "--global", "--yes"]) - assert rc == 0 - - # hermes is fixed_user_level, so scope should be "global" regardless. - found = find("hermes") - assert found is not None - assert found["scope"] == "global" - - def test_uninstall_hermes_after_install(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - - # Install first. - main(["install", "hermes", "--global", "--yes"]) - - # Uninstall. - rc = main(["uninstall", "hermes", "--global", "--yes"]) - assert rc == 0 - - # Verify cleaned. - plugin_dir = expand("~/.hermes/hermes-agent/plugins/memory/agentarts") - assert not os.path.exists(plugin_dir) - assert find("hermes") is None - - -# ── End-to-end: install + uninstall claude ─────────────────────────── - - -class TestEndToEndClaude: - def test_install_claude_global_yes(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - rc = main(["install", "claude", "--global", "--yes"]) - assert rc == 0 - - scripts_dir = os.path.join(expand("~/.claude"), "agentarts-memory", "scripts") - assert os.path.isdir(scripts_dir) - - settings_path = os.path.join(expand("~/.claude"), "settings.json") - assert os.path.isfile(settings_path) - - found = find("claude") - assert found is not None - assert found["scope"] == "global" - - def test_uninstall_claude_after_install(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - - main(["install", "claude", "--global", "--yes"]) - rc = main(["uninstall", "claude", "--global", "--yes"]) - assert rc == 0 - - scripts_dir = os.path.join(expand("~/.claude"), "agentarts-memory", "scripts") - assert not os.path.exists(scripts_dir) - assert find("claude") is None - - def test_server_dependency_hint_printed(self, monkeypatch, tmp_path, capsys): - _set_home_and_creds(monkeypatch, tmp_path) - main(["install", "claude", "--global", "--yes"]) - out = capsys.readouterr().out - assert "127.0.0.1:8719" in out - - -# ── Server subcommand ────────────────────────────────────────────── - - -class TestServerParser: - def test_parse_start(self): - args = build_parser().parse_args(["server", "start"]) - assert args.command == "server" - assert args.action == "start" - assert args.yes is False - - def test_parse_stop(self): - args = build_parser().parse_args(["server", "stop"]) - assert args.action == "stop" - - def test_parse_status(self): - args = build_parser().parse_args(["server", "status"]) - assert args.action == "status" - - def test_parse_yes_flag(self): - args = build_parser().parse_args(["server", "start", "--yes"]) - assert args.yes is True - args2 = build_parser().parse_args(["server", "start", "-y"]) - assert args2.yes is True - - def test_invalid_action_rejected(self): - with pytest.raises(SystemExit): - build_parser().parse_args(["server", "bogus"]) - - -class TestServerDispatch: - def test_start_dispatches(self): - with patch("agentarts_memory_installer.cli.start", return_value=0) as mock_start: - assert main(["server", "start", "--yes"]) == 0 - mock_start.assert_called_once() - - def test_stop_dispatches(self): - with patch("agentarts_memory_installer.cli.stop", return_value=0) as mock_stop: - assert main(["server", "stop"]) == 0 - mock_stop.assert_called_once() - - def test_status_dispatches(self): - with patch("agentarts_memory_installer.cli.status", return_value=0) as mock_status: - assert main(["server", "status"]) == 0 - mock_status.assert_called_once() - - def test_start_yes_sets_global(self): - with patch("agentarts_memory_installer.cli.start", return_value=0): - with patch("agentarts_memory_installer.cli.set_yes") as mock_set_yes: - main(["server", "start", "--yes"]) - mock_set_yes.assert_called_once_with(True) - - def test_start_without_yes_sets_false(self): - with patch("agentarts_memory_installer.cli.start", return_value=0): - with patch("agentarts_memory_installer.cli.set_yes") as mock_set_yes: - main(["server", "start"]) - mock_set_yes.assert_called_once_with(False) - - -class TestEscapeInterrupt: - def test_main_catches_escape(self, capsys): - """main() should catch EscapeInterrupt and return 0.""" - with patch("agentarts_memory_installer.cli.cmd_install", side_effect=EscapeInterrupt()): - rc = main(["install", "hermes", "--yes"]) - assert rc == 0 - assert "Cancelled" in capsys.readouterr().out diff --git a/tests/unit/toolkit/plugins/__init__.py b/tests/unit/toolkit/plugins/__init__.py new file mode 100644 index 00000000..b12ddcb1 --- /dev/null +++ b/tests/unit/toolkit/plugins/__init__.py @@ -0,0 +1 @@ +"""Unit tests for toolkit plugins module""" diff --git a/tests/unit/toolkit/plugins/memory/__init__.py b/tests/unit/toolkit/plugins/memory/__init__.py new file mode 100644 index 00000000..010cbaf1 --- /dev/null +++ b/tests/unit/toolkit/plugins/memory/__init__.py @@ -0,0 +1 @@ +"""Unit tests for toolkit memory plugins""" diff --git a/tests/unit/toolkit/plugins/memory/code_agent/conftest.py b/tests/unit/toolkit/plugins/memory/code_agent/conftest.py new file mode 100644 index 00000000..07997176 --- /dev/null +++ b/tests/unit/toolkit/plugins/memory/code_agent/conftest.py @@ -0,0 +1,5 @@ +"""Pytest configuration for the agentarts memory code_agent tests. + +The adapter server lives in ``agentarts.toolkit.plugins.memory.server`` +and is importable once the SDK is installed (editable). No sys.path hacks needed. +""" diff --git a/tests/agentarts-memory-code_agent/test_agentarts_client.py b/tests/unit/toolkit/plugins/memory/code_agent/test_agentarts_client.py similarity index 86% rename from tests/agentarts-memory-code_agent/test_agentarts_client.py rename to tests/unit/toolkit/plugins/memory/code_agent/test_agentarts_client.py index 80f887f3..7a6e9b2d 100644 --- a/tests/agentarts-memory-code_agent/test_agentarts_client.py +++ b/tests/unit/toolkit/plugins/memory/code_agent/test_agentarts_client.py @@ -11,8 +11,8 @@ import pytest -from server import agentarts_client as ac -from server.agentarts_client import AgentArtsMemoryClient +from agentarts.toolkit.plugins.memory.server import agentarts_client as ac +from agentarts.toolkit.plugins.memory.server.agentarts_client import AgentArtsMemoryClient # ── fake SDK ─────────────────────────────────────────────────────── @@ -88,6 +88,25 @@ def test_session_cached_per_scope(monkeypatch): assert c._client.create_memory_session.call_count == 2 +def test_session_not_shared_across_users(monkeypatch): + """Same scope but different actor_id must create separate sessions.""" + c = _make_client(monkeypatch) + c._client = MagicMock() + c._client.create_memory_session.return_value = SimpleNamespace(id="sess-1") + sid1 = c._get_or_create_session("proj-a", "codex-user") + # same scope, different user -> new session + c._client.create_memory_session.return_value = SimpleNamespace(id="sess-2") + sid2 = c._get_or_create_session("proj-a", "cc-user") + assert sid1 != sid2 + assert sid1 == "sess-1" + assert sid2 == "sess-2" + assert c._client.create_memory_session.call_count == 2 + # same scope + same user still cached + sid3 = c._get_or_create_session("proj-a", "codex-user") + assert sid3 == sid1 + assert c._client.create_memory_session.call_count == 2 + + def test_session_uses_actor_and_assistant(monkeypatch): c = _make_client(monkeypatch, space_id="sp") c._client = MagicMock() diff --git a/tests/agentarts-memory-code_agent/test_scripts.mjs b/tests/unit/toolkit/plugins/memory/code_agent/test_scripts.mjs similarity index 97% rename from tests/agentarts-memory-code_agent/test_scripts.mjs rename to tests/unit/toolkit/plugins/memory/code_agent/test_scripts.mjs index e98a7ce4..8236ebdb 100644 --- a/tests/agentarts-memory-code_agent/test_scripts.mjs +++ b/tests/unit/toolkit/plugins/memory/code_agent/test_scripts.mjs @@ -9,7 +9,8 @@ import assert from "node:assert/strict"; const here = fileURLToPath(import.meta.url); const path = await import("node:path"); -const PLUGIN_ROOT = path.resolve(here, "..", "..", "..", "agentarts-memory-plugins", "agentarts-memory-code_agent"); +// tests/unit/toolkit/plugins/memory/code_agent/ → 7× .. → repo root → src/.../resources +const PLUGIN_ROOT = path.resolve(here, "..", "..", "..", "..", "..", "..", "..", "src", "agentarts", "toolkit", "plugins", "memory", "resources"); const SCRIPTS = path.join(PLUGIN_ROOT, "scripts"); const join = path.join; diff --git a/tests/agentarts-memory-code_agent/test_server.py b/tests/unit/toolkit/plugins/memory/code_agent/test_server.py similarity index 96% rename from tests/agentarts-memory-code_agent/test_server.py rename to tests/unit/toolkit/plugins/memory/code_agent/test_server.py index f60a9712..17416fa2 100644 --- a/tests/agentarts-memory-code_agent/test_server.py +++ b/tests/unit/toolkit/plugins/memory/code_agent/test_server.py @@ -12,8 +12,8 @@ import pytest from fastapi.testclient import TestClient -from server import app as app_module -from server.app import app +from agentarts.toolkit.plugins.memory.server import app as app_module +from agentarts.toolkit.plugins.memory.server.app import app @pytest.fixture diff --git a/tests/unit/toolkit/plugins/memory/hermes/conftest.py b/tests/unit/toolkit/plugins/memory/hermes/conftest.py new file mode 100644 index 00000000..8ac452fa --- /dev/null +++ b/tests/unit/toolkit/plugins/memory/hermes/conftest.py @@ -0,0 +1,17 @@ +"""Pytest configuration: add plugin root to sys.path for top-level modules.""" + +import sys +from pathlib import Path + +# From tests/unit/toolkit/plugins/memory/hermes/ → repo root → src/.../ai_agent/hermes +plugin_root = ( + Path(__file__).resolve().parents[6] + / "src" + / "agentarts" + / "toolkit" + / "plugins" + / "memory" + / "ai_agent" + / "hermes" +) +sys.path.insert(0, str(plugin_root)) diff --git a/tests/agentarts-memory-hermes/test_provider.py b/tests/unit/toolkit/plugins/memory/hermes/test_provider.py similarity index 100% rename from tests/agentarts-memory-hermes/test_provider.py rename to tests/unit/toolkit/plugins/memory/hermes/test_provider.py diff --git a/tests/unit/toolkit/plugins/memory/installer/conftest.py b/tests/unit/toolkit/plugins/memory/installer/conftest.py new file mode 100644 index 00000000..742367e4 --- /dev/null +++ b/tests/unit/toolkit/plugins/memory/installer/conftest.py @@ -0,0 +1,5 @@ +"""Pytest configuration for the agentarts memory installer tests. + +The installer lives in ``agentarts.toolkit.plugins.memory.installer`` and is +importable once the SDK is installed (editable). No sys.path hacks needed. +""" diff --git a/tests/unit/toolkit/plugins/memory/installer/test_installer_cli.py b/tests/unit/toolkit/plugins/memory/installer/test_installer_cli.py new file mode 100644 index 00000000..3c9c36a6 --- /dev/null +++ b/tests/unit/toolkit/plugins/memory/installer/test_installer_cli.py @@ -0,0 +1,208 @@ +"""Tests for the agentarts memory install/uninstall/server CLI callbacks. + +The original installer exposed an argparse ``main``; it now ships Typer +callbacks (``install_cmd``/``uninstall_cmd``/``server_app``) registered onto +``agentarts memory``. These tests exercise the business logic +(``_do_install``/``_do_uninstall``) and the server dispatch directly. +""" + +import os +from unittest.mock import patch + +from agentarts.toolkit.plugins.memory.installer import cli +from agentarts.toolkit.plugins.memory.installer.utils import ( + ENV_API_KEY, + ENV_REGION, + ENV_SPACE_ID, + expand, + find, +) + +VALID_TARGETS = cli.VALID_TARGETS +_do_install = cli._do_install +_do_uninstall = cli._do_uninstall + + +def _set_home_and_creds(monkeypatch, tmp_path): + """Redirect HOME to tmp and set valid credentials.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv(ENV_SPACE_ID, "test-space-12345") + monkeypatch.setenv(ENV_API_KEY, "test-api-key-abcdef-123456") + monkeypatch.setenv(ENV_REGION, "cn-north-4") + + +# ── targets ────────────────────────────────────────────────────────── + + +class TestTargets: + def test_valid_targets(self): + assert VALID_TARGETS == ("hermes", "claude", "codex", "opencode", "openclaw") + + +# ── openclaw placeholder ──────────────────────────────────────────── + + +class TestOpenClawPlaceholder: + def test_install_openclaw(self, capsys): + assert _do_install("openclaw", False, False) == 0 + assert "not yet implemented" in capsys.readouterr().out + + def test_uninstall_openclaw(self, capsys): + assert _do_uninstall("openclaw", False, False) == 0 + assert "not yet implemented" in capsys.readouterr().out + + +# ── invalid target ────────────────────────────────────────────────── + + +class TestInvalidTarget: + def test_install_invalid(self, capsys): + assert _do_install("bogus", False, False) == 2 + + def test_uninstall_invalid(self, capsys): + assert _do_uninstall("bogus", False, False) == 2 + + +# ── no detection / no installs ───────────────────────────────────── + + +class TestNoTargets: + def test_install_no_target_no_detection(self, monkeypatch, tmp_path, capsys): + _set_home_and_creds(monkeypatch, tmp_path) + # No ~/.claude, ~/.codex, ~/.hermes, ~/.config/opencode + assert _do_install(None, False, True) == 1 + assert "No supported platforms detected" in capsys.readouterr().out + + def test_uninstall_no_target_no_installs(self, monkeypatch, tmp_path, capsys): + _set_home_and_creds(monkeypatch, tmp_path) + assert _do_uninstall(None, False, True) == 1 + assert "No installations found" in capsys.readouterr().out + + def test_uninstall_target_not_found(self, monkeypatch, tmp_path, capsys): + _set_home_and_creds(monkeypatch, tmp_path) + assert _do_uninstall("hermes", False, True) == 1 + assert "No hermes installation found" in capsys.readouterr().out + + +# ── end-to-end: install + uninstall hermes ─────────────────────────── + + +class TestEndToEndHermes: + def test_install_hermes_yes(self, monkeypatch, tmp_path): + _set_home_and_creds(monkeypatch, tmp_path) + assert _do_install("hermes", False, True) == 0 + + plugin_dir = expand("~/.hermes/hermes-agent/plugins/memory/agentarts") + assert os.path.isdir(plugin_dir) + assert os.path.isfile(os.path.join(plugin_dir, "provider.py")) + + found = find("hermes") + assert found is not None + assert found["scope"] == "global" # hermes is fixed_user_level + + def test_install_hermes_global_yes(self, monkeypatch, tmp_path): + _set_home_and_creds(monkeypatch, tmp_path) + assert _do_install("hermes", True, True) == 0 + + # hermes is fixed_user_level, so scope should be "global" regardless. + found = find("hermes") + assert found is not None + assert found["scope"] == "global" + + def test_uninstall_hermes_after_install(self, monkeypatch, tmp_path): + _set_home_and_creds(monkeypatch, tmp_path) + + # Install first. + _do_install("hermes", True, True) + + # Uninstall. + assert _do_uninstall("hermes", True, True) == 0 + + # Verify cleaned. + plugin_dir = expand("~/.hermes/hermes-agent/plugins/memory/agentarts") + assert not os.path.exists(plugin_dir) + assert find("hermes") is None + + +# ── end-to-end: install + uninstall claude ────────────────────────── + + +class TestEndToEndClaude: + def test_install_claude_global_yes(self, monkeypatch, tmp_path): + _set_home_and_creds(monkeypatch, tmp_path) + assert _do_install("claude", True, True) == 0 + + scripts_dir = os.path.join(expand("~/.claude"), "agentarts-memory", "scripts") + assert os.path.isdir(scripts_dir) + + settings_path = os.path.join(expand("~/.claude"), "settings.json") + assert os.path.isfile(settings_path) + + found = find("claude") + assert found is not None + assert found["scope"] == "global" + + def test_uninstall_claude_after_install(self, monkeypatch, tmp_path): + _set_home_and_creds(monkeypatch, tmp_path) + + _do_install("claude", True, True) + assert _do_uninstall("claude", True, True) == 0 + + scripts_dir = os.path.join(expand("~/.claude"), "agentarts-memory", "scripts") + assert not os.path.exists(scripts_dir) + assert find("claude") is None + + def test_server_dependency_hint_printed(self, monkeypatch, tmp_path, capsys): + _set_home_and_creds(monkeypatch, tmp_path) + _do_install("claude", True, True) + out = capsys.readouterr().out + assert "127.0.0.1:8719" in out + + +# ── server subcommand dispatch ────────────────────────────────────── + + +class TestServerCallbacks: + def test_start_dispatches(self): + with patch( + "agentarts.toolkit.plugins.memory.installer.cli._server_start", return_value=0 + ) as mock_start: + cli.server_start_cmd(yes=True) + mock_start.assert_called_once() + + def test_stop_dispatches(self): + with patch( + "agentarts.toolkit.plugins.memory.installer.cli._server_stop", return_value=0 + ) as mock_stop: + cli.server_stop_cmd() + mock_stop.assert_called_once() + + def test_status_dispatches(self): + with patch( + "agentarts.toolkit.plugins.memory.installer.cli._server_status", return_value=0 + ) as mock_status: + cli.server_status_cmd() + mock_status.assert_called_once() + + def test_start_yes_sets_global(self): + with patch("agentarts.toolkit.plugins.memory.installer.cli._server_start", return_value=0): + with patch("agentarts.toolkit.plugins.memory.installer.cli.set_yes") as mock_set_yes: + cli.server_start_cmd(yes=True) + mock_set_yes.assert_called_once_with(True) + + def test_start_without_yes_sets_false(self): + with patch("agentarts.toolkit.plugins.memory.installer.cli._server_start", return_value=0): + with patch("agentarts.toolkit.plugins.memory.installer.cli.set_yes") as mock_set_yes: + cli.server_start_cmd(yes=False) + mock_set_yes.assert_called_once_with(False) + + +# ── escape interrupt ─────────────────────────────────────────────── + + +class TestEscapeInterrupt: + def test_install_catches_escape(self, capsys): + """install_cmd should catch EscapeInterrupt and not error out.""" + with patch.object(cli, "_do_install", side_effect=cli.EscapeInterrupt()): + cli.install_cmd("hermes", False, True) + assert "Cancelled" in capsys.readouterr().out diff --git a/tests/agentarts-memory-installer/test_platforms.py b/tests/unit/toolkit/plugins/memory/installer/test_platforms.py similarity index 97% rename from tests/agentarts-memory-installer/test_platforms.py rename to tests/unit/toolkit/plugins/memory/installer/test_platforms.py index b99d7829..d0cd2ac4 100644 --- a/tests/agentarts-memory-installer/test_platforms.py +++ b/tests/unit/toolkit/plugins/memory/installer/test_platforms.py @@ -5,10 +5,10 @@ import json import os -from agentarts_memory_installer import platforms -from agentarts_memory_installer.platforms import PLATFORMS, detect_all, get_platform -from agentarts_memory_installer.platforms.hermes import HermesPlatform -from agentarts_memory_installer.utils import ( +from agentarts.toolkit.plugins.memory.installer import platforms +from agentarts.toolkit.plugins.memory.installer.platforms import PLATFORMS, detect_all, get_platform +from agentarts.toolkit.plugins.memory.installer.platforms.hermes import HermesPlatform +from agentarts.toolkit.plugins.memory.installer.utils import ( ENV_API_KEY, ENV_REGION, ENV_SPACE_ID, @@ -376,11 +376,11 @@ def test_install_deploys_scripts_hooks_and_toml(self, monkeypatch, tmp_path): assert "${CODEX_PLUGIN_ROOT}" not in cmd assert scripts_dir in cmd - # config.toml has codex_hooks = true. + # config.toml has hooks = true. toml_path = os.path.join(expand("~/.codex"), "config.toml") assert os.path.isfile(toml_path) toml_content = open(toml_path).read() - assert "codex_hooks = true" in toml_content + assert "hooks = true" in toml_content assert "[features]" in toml_content def test_uninstall_cleans_hooks_toml_and_scripts(self, monkeypatch, tmp_path): @@ -425,9 +425,9 @@ def test_install_idempotent_no_duplicate_hooks(self, monkeypatch, tmp_path): total += len(group.get("hooks", [])) assert total == 6 # not 12 - # config.toml should have one codex_hooks line. + # config.toml should have one hooks line. toml_content = open(os.path.join(expand("~/.codex"), "config.toml")).read() - assert toml_content.count("codex_hooks") == 1 + assert toml_content.count("hooks = true") == 1 def test_uninstall_preserves_user_hooks_and_other_toml_keys(self, monkeypatch, tmp_path): _set_home(monkeypatch, tmp_path) @@ -466,7 +466,7 @@ def test_uninstall_preserves_user_hooks_and_other_toml_keys(self, monkeypatch, t assert os.path.isfile(toml_path) toml = open(toml_path).read() assert "other_feature = true" in toml - assert "codex_hooks" not in toml + assert "hooks = true" not in toml def test_detect_returns_false_when_missing(self, monkeypatch, tmp_path): _set_home(monkeypatch, tmp_path) diff --git a/tests/agentarts-memory-installer/test_server_manager.py b/tests/unit/toolkit/plugins/memory/installer/test_server_manager.py similarity index 81% rename from tests/agentarts-memory-installer/test_server_manager.py rename to tests/unit/toolkit/plugins/memory/installer/test_server_manager.py index 36f3bd32..0946edab 100644 --- a/tests/agentarts-memory-installer/test_server_manager.py +++ b/tests/unit/toolkit/plugins/memory/installer/test_server_manager.py @@ -2,12 +2,16 @@ from __future__ import annotations -import os +import sys from pathlib import Path from unittest.mock import MagicMock -import agentarts_memory_installer.server_manager as sm -from agentarts_memory_installer.server_manager import start, status, stop +import agentarts.toolkit.plugins.memory.installer.server_manager as sm +from agentarts.toolkit.plugins.memory.installer.server_manager import ( + start, + status, + stop, +) def _set_home(monkeypatch, tmp_path): @@ -36,44 +40,9 @@ def test_already_running(self, monkeypatch, tmp_path): assert start() == 0 - def test_not_installed_installs_then_starts(self, monkeypatch, tmp_path): - _set_home(monkeypatch, tmp_path) - monkeypatch.setattr(sm, "_is_running", lambda: False) - monkeypatch.setattr(sm, "_is_installed", lambda: False) - monkeypatch.setattr(sm, "confirm", lambda prompt, default=True: True) - monkeypatch.setattr(sm, "_install_server", lambda: True) - monkeypatch.setattr(sm.time, "sleep", lambda x: None) - - mock_proc = MagicMock() - mock_proc.pid = 12345 - mock_proc.poll.return_value = None - monkeypatch.setattr(sm.subprocess, "Popen", lambda *a, **kw: mock_proc) - monkeypatch.setattr(sm, "_check_health", lambda: True) - - assert start() == 0 - assert _pid_file(tmp_path).read_text() == "12345" - - def test_install_fails(self, monkeypatch, tmp_path): - _set_home(monkeypatch, tmp_path) - monkeypatch.setattr(sm, "_is_running", lambda: False) - monkeypatch.setattr(sm, "_is_installed", lambda: False) - monkeypatch.setattr(sm, "confirm", lambda prompt, default=True: True) - monkeypatch.setattr(sm, "_install_server", lambda: False) - - assert start() == 1 - - def test_install_declined(self, monkeypatch, tmp_path): - _set_home(monkeypatch, tmp_path) - monkeypatch.setattr(sm, "_is_running", lambda: False) - monkeypatch.setattr(sm, "_is_installed", lambda: False) - monkeypatch.setattr(sm, "confirm", lambda prompt, default=True: False) - - assert start() == 1 - def test_process_dies_immediately(self, monkeypatch, tmp_path): _set_home(monkeypatch, tmp_path) monkeypatch.setattr(sm, "_is_running", lambda: False) - monkeypatch.setattr(sm, "_is_installed", lambda: True) monkeypatch.setattr(sm.time, "sleep", lambda x: None) mock_proc = MagicMock() @@ -87,7 +56,6 @@ def test_process_dies_immediately(self, monkeypatch, tmp_path): def test_starts_and_health_check_ok(self, monkeypatch, tmp_path): _set_home(monkeypatch, tmp_path) monkeypatch.setattr(sm, "_is_running", lambda: False) - monkeypatch.setattr(sm, "_is_installed", lambda: True) monkeypatch.setattr(sm.time, "sleep", lambda x: None) mock_proc = MagicMock() @@ -102,7 +70,6 @@ def test_starts_and_health_check_ok(self, monkeypatch, tmp_path): def test_starts_but_health_check_fails(self, monkeypatch, tmp_path): _set_home(monkeypatch, tmp_path) monkeypatch.setattr(sm, "_is_running", lambda: False) - monkeypatch.setattr(sm, "_is_installed", lambda: True) monkeypatch.setattr(sm.time, "sleep", lambda x: None) mock_proc = MagicMock() @@ -115,6 +82,26 @@ def test_starts_but_health_check_fails(self, monkeypatch, tmp_path): assert start() == 0 assert _pid_file(tmp_path).read_text() == "33333" + def test_start_invokes_in_package_module(self, monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + monkeypatch.setattr(sm, "_is_running", lambda: False) + monkeypatch.setattr(sm.time, "sleep", lambda x: None) + + captured = {} + + def fake_popen(cmd, *a, **kw): + captured["cmd"] = cmd + mock_proc = MagicMock() + mock_proc.pid = 44444 + mock_proc.poll.return_value = None + return mock_proc + + monkeypatch.setattr(sm.subprocess, "Popen", fake_popen) + monkeypatch.setattr(sm, "_check_health", lambda: True) + + assert start() == 0 + assert captured["cmd"] == [sys.executable, "-m", sm.SERVER_MODULE] + # ── stop ──────────────────────────────────────────────────────────── diff --git a/tests/agentarts-memory-installer/test_utils.py b/tests/unit/toolkit/plugins/memory/installer/test_utils.py similarity index 94% rename from tests/agentarts-memory-installer/test_utils.py rename to tests/unit/toolkit/plugins/memory/installer/test_utils.py index 05ad93fc..4857d86f 100644 --- a/tests/agentarts-memory-installer/test_utils.py +++ b/tests/unit/toolkit/plugins/memory/installer/test_utils.py @@ -4,8 +4,8 @@ import os import pytest -from agentarts_memory_installer import utils -from agentarts_memory_installer.utils import ( +from agentarts.toolkit.plugins.memory.installer import utils +from agentarts.toolkit.plugins.memory.installer.utils import ( DEFAULT_REGION, ENV_API_KEY, ENV_REGION, @@ -667,51 +667,65 @@ def test_strip_removes_empty_hook_group(self): class TestTomlMerge: def test_add_to_empty(self): - result = merge_toml_features("", "codex_hooks", "true") + result = merge_toml_features("", "hooks", "true") assert "[features]" in result - assert "codex_hooks = true" in result + assert "hooks = true" in result def test_add_to_existing_no_features(self): text = '[other]\nkey = "val"\n' - result = merge_toml_features(text, "codex_hooks", "true") + result = merge_toml_features(text, "hooks", "true") assert "[features]" in result - assert "codex_hooks = true" in result + assert "hooks = true" in result assert "[other]" in result assert 'key = "val"' in result def test_add_to_existing_features(self): text = "[features]\nother_key = false\n" - result = merge_toml_features(text, "codex_hooks", "true") + result = merge_toml_features(text, "hooks", "true") assert "other_key = false" in result - assert "codex_hooks = true" in result + assert "hooks = true" in result def test_update_existing_key(self): - text = "[features]\ncodex_hooks = false\n" - result = merge_toml_features(text, "codex_hooks", "true") - assert "codex_hooks = true" in result - assert "codex_hooks = false" not in result + text = "[features]\nhooks = false\n" + result = merge_toml_features(text, "hooks", "true") + assert "hooks = true" in result + assert "hooks = false" not in result - def test_strip_removes_key(self): + def test_removes_deprecated_keys(self): text = "[features]\ncodex_hooks = true\nother_key = false\n" - result = strip_toml_feature(text, "codex_hooks") + result = merge_toml_features(text, "hooks", "true", deprecated_keys=["codex_hooks"]) + assert "codex_hooks" not in result + assert "hooks = true" in result + assert "other_key = false" in result + + def test_removes_deprecated_and_updates_existing(self): + text = "[features]\ncodex_hooks = true\nhooks = false\n" + result = merge_toml_features(text, "hooks", "true", deprecated_keys=["codex_hooks"]) assert "codex_hooks" not in result + assert "hooks = true" in result + assert "hooks = false" not in result + + def test_strip_removes_key(self): + text = "[features]\nhooks = true\nother_key = false\n" + result = strip_toml_feature(text, "hooks") + assert "hooks" not in result assert "other_key = false" in result def test_strip_removes_empty_section(self): - text = "[features]\ncodex_hooks = true\n" - result = strip_toml_feature(text, "codex_hooks") + text = "[features]\nhooks = true\n" + result = strip_toml_feature(text, "hooks") assert "[features]" not in result def test_roundtrip(self): text = "[other]\nfoo = 1\n" - merged = merge_toml_features(text, "codex_hooks", "true") - stripped = strip_toml_feature(merged, "codex_hooks") - assert "codex_hooks" not in stripped + merged = merge_toml_features(text, "hooks", "true") + stripped = strip_toml_feature(merged, "hooks") + assert "hooks" not in stripped assert "foo = 1" in stripped def test_preserves_other_sections_after(self): - text = "[features]\ncodex_hooks = true\n\n[other]\nbar = 2\n" - result = strip_toml_feature(text, "codex_hooks") + text = "[features]\nhooks = true\n\n[other]\nbar = 2\n" + result = strip_toml_feature(text, "hooks") assert "[other]" in result assert "bar = 2" in result assert "[features]" not in result