diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/.claude-plugin/plugin.json b/agentarts-memory-plugins/agentarts-memory-code_agent/.claude-plugin/plugin.json new file mode 100644 index 0000000..ce81729 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/.claude-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "agentarts_memory", + "version": "1.0.0", + "description": "Huawei Cloud AgentArts Memory as long-term memory backend for Claude Code. 12 hooks.", + "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"], + "hooks": "./hooks/hooks.json", + "userConfig": { + "memory_server_url": { + "type": "string", + "title": "Memory Server URL", + "description": "Local AgentArts memory adapter server URL (default: http://127.0.0.1:8719)", + "required": false + }, + "user_id": { + "type": "string", + "title": "User ID", + "description": "User ID for memory scope isolation (default: cc-user)", + "required": false + } + } +} diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/.codex-plugin/plugin.json b/agentarts-memory-plugins/agentarts-memory-code_agent/.codex-plugin/plugin.json new file mode 100644 index 0000000..710b2a8 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/.codex-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "agentarts_memory", + "version": "1.0.0", + "description": "Huawei Cloud AgentArts Memory as long-term memory backend for Codex. 6 hooks.", + "author": { + "name": "AgentArts", + "url": "https://github.com/huaweicloud/agentarts-sdk-python" + }, + "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" + ], + "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.", + "developerName": "AgentArts", + "category": "Productivity", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/huaweicloud/agentarts-sdk-python", + "defaultPrompt": [ + "Search my memories for recent project decisions", + "Remember that I prefer TypeScript over JavaScript", + "What do you know about my coding preferences?" + ] + } +} diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/AGENTS.md b/agentarts-memory-plugins/agentarts-memory-code_agent/AGENTS.md new file mode 100644 index 0000000..730e69b --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/AGENTS.md @@ -0,0 +1,29 @@ +# 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 new file mode 100644 index 0000000..f7ae2cd --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/README.md @@ -0,0 +1,256 @@ +# agentarts-memory-code_agent + +**Huawei Cloud AgentArts Memory** 作为 **Claude Code / Codex / OpenCode** 三平台 AI 编程助手的长期记忆后端。 + +一个插件目录同时覆盖三平台,共享同一个本地 HTTP 适配 server + hook 脚本逻辑。 + +## 它做什么 + +| 平台 | 接入方式 | 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 +# 注册 marketplace 后安装 +/plugin install agentarts_memory +``` + +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/hooks/hooks.codex.json b/agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.codex.json new file mode 100644 index 0000000..d896bc3 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.codex.json @@ -0,0 +1,68 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/session-start.mjs\"", + "statusMessage": "agentarts-memory: checking server health" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"", + "statusMessage": "agentarts-memory: recording prompt + searching memories" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|Read|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/pre-compact.mjs\"", + "statusMessage": "agentarts-memory: injecting context before compaction" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/stop.mjs\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.json b/agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.json new file mode 100644 index 0000000..b3efae8 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/hooks/hooks.json @@ -0,0 +1,128 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"", + "statusMessage": "agentarts-memory: checking server health" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"", + "statusMessage": "agentarts-memory: recording prompt + searching memories" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|Read|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-failure.mjs\"" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs\"", + "statusMessage": "agentarts-memory: injecting context before compaction" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.mjs\"" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-stop.mjs\"" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.mjs\"" + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/task-completed.mjs\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\"" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-end.mjs\"" + } + ] + } + ] + } +} diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/agentarts-memory-capture.ts b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/agentarts-memory-capture.ts new file mode 100644 index 0000000..acdf85f --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/agentarts-memory-capture.ts @@ -0,0 +1,330 @@ +// agentarts-memory-code_agent OpenCode plugin — TypeScript Plugin SDK implementation. +// +// Drop this file into ~/.config/opencode/plugins/ and reference it in +// ~/.config/opencode/opencode.json: +// { "plugin": ["./plugins/agentarts-memory-capture.ts"] } +// +// Requires: @opencode-ai/plugin types (provided by OpenCode at runtime). + +import type { Plugin } from "@opencode-ai/plugin"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const REST_URL = + process.env.AGENTARTS_MEMORY_SERVER_URL || "http://127.0.0.1:8719"; +const DEBUG = process.env.AGENTARTS_MEMORY_DEBUG === "1"; + +// Platform detection for OpenCode — default to opencode since this plugin only runs in OpenCode +function detectOpenCodePlatform(): string { + if (process.env.OPENCODE_PLUGIN_ROOT) return "opencode"; + // Default to opencode for this plugin since it's OpenCode-specific + return "opencode"; +} + +const PLATFORM_USER_ID: Record = { + "opencode": "opencode-user", + "unknown": "__default__", +}; + +// Lazy-resolved default user_id, computed at runtime when first needed +let _cachedDefaultUserId: string | null = null; +function getDefaultUserId(): string { + if (_cachedDefaultUserId === null) { + _cachedDefaultUserId = process.env.AGENTARTS_MEMORY_USER_ID || PLATFORM_USER_ID[detectOpenCodePlatform()]; + } + return _cachedDefaultUserId; +} + +const SEARCH_MEM_NUM = 5; +const SEARCH_SUMMARY_NUM = 3; +const DEFAULT_THRESHOLD = 0.3; + +/** + * Resolve user_id with priority: + * 1. payload.user_id / payload.userId (from hook request) + * 2. AGENTARTS_MEMORY_USER_ID env var + * 3. OPENCODE_PLUGIN_ROOT detected -> "opencode-user" + * 4. Default: "__default__" + */ +function resolveUserId(payload: unknown): string { + if (payload && typeof payload === "object") { + const explicit = (payload as any).user_id || (payload as any).userId; + if (explicit && typeof explicit === "string" && explicit.trim()) { + return explicit.trim(); + } + } + return getDefaultUserId(); +} + +function authHeaders(): Record { + return { "Content-Type": "application/json" }; +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- +async function post( + path: string, + body: Record, + timeoutMs = 3000, +): Promise { + try { + const res = await fetch(`${REST_URL}/${path}`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + if (DEBUG && !res.ok) + console.error(`[agentarts] POST /${path} returned ${res.status}`); + } catch (e) { + if (DEBUG) console.error(`[agentarts] POST /${path} failed:`, (e as Error).message); + } +} + +async function postJson( + path: string, + body: Record, + timeoutMs = 0, +): Promise { + try { + const opts: RequestInit = { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + }; + if (timeoutMs > 0) opts.signal = AbortSignal.timeout(timeoutMs); + const res = await fetch(`${REST_URL}/${path}`, opts); + if (res.ok) return await res.json(); + if (DEBUG) console.error(`[agentarts] POST /${path} returned ${res.status}`); + } catch (e) { + if (DEBUG) + console.error(`[agentarts] POST /${path} (json) failed:`, (e as Error).message); + } + return null; +} + +// --------------------------------------------------------------------------- +// High-level operations +// --------------------------------------------------------------------------- +async function addMessages( + messages: Array<{ role: string; content: string }>, + scopeId: string, + userId = getDefaultUserId(), +): Promise { + await post("add_messages/", { messages, user_id: userId, scope_id: scopeId }); +} + +async function searchAndFormat( + query: string, + scopeId: string, + userId = getDefaultUserId(), +): Promise { + const [memResult, summaryResult] = await Promise.all([ + postJson("search_memory/", { + query, + num: SEARCH_MEM_NUM, + user_id: userId, + scope_id: scopeId, + threshold: DEFAULT_THRESHOLD, + }), + postJson("search_summary/", { + query, + num: SEARCH_SUMMARY_NUM, + user_id: userId, + scope_id: scopeId, + threshold: DEFAULT_THRESHOLD, + }), + ]); + + const memItems = ((memResult as any)?.results || []) as Array>; + const summaryItems = ((summaryResult as any)?.results || []) as Array>; + const lines: string[] = []; + + if (memItems.length) { + lines.push("## Related Memories"); + for (const r of memItems) { + const label = r.type ? `[${r.type}]` : ""; + lines.push( + `- ${label} ${String(r.content || "").slice(0, 300)} (score: ${Number(r.score || 0).toFixed(2)})`, + ); + } + } + if (summaryItems.length) { + if (lines.length) lines.push(""); + lines.push("## Related History Summaries"); + for (const r of summaryItems) { + lines.push( + `- ${String(r.content || "").slice(0, 300)} (score: ${Number(r.score || 0).toFixed(2)})`, + ); + } + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// System prompt instructions +// --------------------------------------------------------------------------- +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. + +Use /recall [query] to search past memories, and /remember [content] to explicitly save. +Never fabricate memory results — only present what the tools return. +`; + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- +let activeSessionId: string | null = null; +let sessionUserId: string | null = null; // Per-session user_id from hooks +const DEFAULT_SCOPE_ID = process.env.AGENTARTS_MEMORY_PROJECT_NAME || "opencode-default"; +let projectScopeId: string = DEFAULT_SCOPE_ID; +const contextInjectedSessions = new Set(); +const sessionLastUserQuery = new Map(); +const sessionPendingAdd = new Map(); +const sessionSearchResult = new Map(); + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- +export const AgentArtsMemoryCapturePlugin: Plugin = async (ctx) => { + const cwd = ctx.worktree || ctx.project?.id || ""; + if (cwd) { + const raw = cwd.replace(/[\\/]+$/, ""); + const derived = raw.split(/[\\/]/).pop()?.trim(); + if (derived) projectScopeId = derived; + } + + // Resolve user_id from context (hooks request) with fallback + const getUserId = () => sessionUserId || resolveUserId(ctx); + + return { + event: async ({ event }) => { + const type = event.type; + const props = (event as any).properties || {}; + + // ── session.created ── + if (type === "session.created") { + const info = props.info as Record | undefined; + activeSessionId = (info?.id as string) || props.sessionID || null; + if (!activeSessionId) return; + + // Resolve user_id from session creation props + sessionUserId = resolveUserId(props); + + contextInjectedSessions.delete(activeSessionId); + sessionLastUserQuery.delete(activeSessionId); + sessionPendingAdd.delete(activeSessionId); + sessionSearchResult.delete(activeSessionId); + // Probe health — best-effort, never fatal. + try { + await fetch(`${REST_URL}/health`, { + method: "GET", + headers: authHeaders(), + signal: AbortSignal.timeout(800), + }); + } catch {} + } + + // ── session.deleted ── + if (type === "session.deleted") { + const sid = (props.info as any)?.id || props.sessionID || activeSessionId; + if (sid) { + if (sid === activeSessionId) { + activeSessionId = null; + sessionUserId = null; + } + contextInjectedSessions.delete(sid); + sessionLastUserQuery.delete(sid); + sessionPendingAdd.delete(sid); + sessionSearchResult.delete(sid); + } + } + + // ── message.updated (assistant) ── + // AI 回复结束后,把之前存的用户 query 写入记忆(延后写入避免打断对话) + if (type === "message.updated") { + const info = props.info as Record | undefined; + if (!info) return; + if (info.role === "assistant") { + const sid = props.sessionID || (info.sessionID as string) || activeSessionId; + if (!sid) return; + const pendingQuery = sessionPendingAdd.get(sid); + if (!pendingQuery) return; + sessionPendingAdd.delete(sid); + await addMessages([{ role: "user", content: pendingQuery }], projectScopeId, getUserId()); + } + } + }, + + // ── chat.message ── + // Store the user query, mark it pending for later add, AND run the search. + "chat.message": async (input: any, output: any) => { + const sid = input.sessionID || activeSessionId; + if (!sid) return; + + const parts = output.parts || []; + const textParts = parts.filter( + (p: any) => p.type === "text" && !p.synthetic && !p.ignored, + ); + const userText = textParts.map((p: any) => p.text || "").join("\n"); + if (!userText) return; + + const query = userText.slice(0, 2000); + + sessionLastUserQuery.set(sid, query); + sessionPendingAdd.set(sid, userText.slice(0, 8000)); + + // Search once per user message, cache the result. + const searchResult = await searchAndFormat(query, projectScopeId, getUserId()); + if (searchResult) sessionSearchResult.set(sid, searchResult); + else sessionSearchResult.delete(sid); + }, + + // ── experimental.chat.system.transform ── + "experimental.chat.system.transform": async (input: any, output: any) => { + const sid = input.sessionID || activeSessionId; + if (!sid) return; + if (!Array.isArray(output.system)) return; + + // Inject usage instructions once per session. + if (!contextInjectedSessions.has(sid)) { + output.system.push(AGENTARTS_INSTRUCTIONS); + contextInjectedSessions.add(sid); + } + + // Inject cached search result (read-only, no re-search). + const cachedResult = sessionSearchResult.get(sid); + if (cachedResult) { + output.system.push(cachedResult); + } + }, + + // ── experimental.session.compacting ── + "experimental.session.compacting": async (input: any, output: any) => { + const sid = input.sessionID || activeSessionId; + if (!sid) return; + + const cachedResult = sessionSearchResult.get(sid); + const context = + cachedResult || + (sessionLastUserQuery.has(sid) + ? await searchAndFormat(sessionLastUserQuery.get(sid)!, projectScopeId, getUserId()) + : ""); + if (context && Array.isArray(output.context)) { + output.context.push(context); + } + }, + + // ── config ── + config: async (input: any) => { + if (DEBUG) { + console.error("[agentarts] config loaded:", { theme: input.theme, model: input.model }); + } + }, + }; +}; diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/recall.md b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/recall.md new file mode 100644 index 0000000..904b7d4 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/recall.md @@ -0,0 +1,26 @@ +Search past session memories and summaries for relevant context. + +## Usage + +``` +/recall [query] +``` + +## Instructions + +1. The local AgentArts memory server exposes `/search_memory/` and `/search_summary/` REST endpoints. +2. Since OpenCode slash commands can't call HTTP directly, run: + + ```bash + curl -s -X POST "$AGENTARTS_MEMORY_SERVER_URL/search_memory/" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"\", \"num\": 5, \"threshold\": 0.3, \"user_id\": \"opencode-user\", \"scope_id\": \"\"}" + ``` + + and similarly for `/search_summary/` with `num: 3`. +3. Combine and present results: + - Group memories by type (semantic, episodic, user_preference) + - Show history summaries separately + - Highlight high-score memories (score >= 0.7) +4. If no results, suggest 2-3 alternative search terms. +5. **Never hallucinate results.** Only present what the server returns. diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/remember.md b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/remember.md new file mode 100644 index 0000000..d0a83bd --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/commands/remember.md @@ -0,0 +1,21 @@ +Explicitly save an insight, decision, or learning to AgentArts Memory for future sessions. + +## Usage + +``` +/remember [what to remember] +``` + +## Instructions + +1. Analyze what needs to be remembered — extract the core insight, decision, or fact. +2. Save it by calling the local memory server: + + ```bash + curl -s -X POST "$AGENTARTS_MEMORY_SERVER_URL/add_messages/" \ + -H "Content-Type: application/json" \ + -d "{\"messages\": [{\"role\": \"user\", \"content\": \"\"}], \"user_id\": \"opencode-user\", \"scope_id\": \"\"}" + ``` + +3. Confirm the save and show a brief summary of what was stored. +4. Preserve the user's own phrasing — don't paraphrase. diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/plugin.json b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/plugin.json new file mode 100644 index 0000000..b4e36df --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/opencode/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "agentarts-memory-capture", + "version": "1.0.0", + "description": "OpenCode plugin for AgentArts Memory — session lifecycle capture, assistant reply tracking, system prompt injection.", + "author": { + "name": "AgentArts" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/huaweicloud/agentarts-sdk-python", + "repository": "https://github.com/huaweicloud/agentarts-sdk-python" +} diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/plugin.json b/agentarts-memory-plugins/agentarts-memory-code_agent/plugin.json new file mode 100644 index 0000000..e6ef2bf --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/plugin.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..1dec132 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/pyproject.toml @@ -0,0 +1,35 @@ +[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-code_agent/scripts/_shared.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/_shared.mjs new file mode 100644 index 0000000..b831bdd --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/_shared.mjs @@ -0,0 +1,250 @@ +// agentarts-memory-code_agent — shared utilities for hook scripts. +// +// All hook scripts import from this module for: +// - REST URL / timeout config +// - resolveProject(cwd) — git toplevel basename for scope isolation +// - addMessages() / searchAndFormat() / healthCheck() — HTTP calls +// - platform detection (Claude Code vs Codex vs Cursor) +// - output formatting + +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +// --------------------------------------------------------------------------- +// Version +// --------------------------------------------------------------------------- +export const PLUGIN_VERSION = "1.0.0"; + +// --------------------------------------------------------------------------- +// Platform detection +// +// Claude Code sets CLAUDE_PLUGIN_ROOT; Codex sets CODEX_PLUGIN_ROOT. +// OpenCode sets OPENCODE_PLUGIN_ROOT. +// Used to derive a per-platform default user_id. +// --------------------------------------------------------------------------- +export function detectPlatform() { + if (process.env.CLAUDE_PLUGIN_ROOT) return "claude-code"; + if (process.env.CODEX_PLUGIN_ROOT) return "codex"; + if (process.env.OPENCODE_PLUGIN_ROOT) return "opencode"; + return "unknown"; +} + +const PLATFORM_USER_ID = { + "claude-code": "cc-user", + "codex": "codex-user", + "opencode": "opencode-user", + "unknown": "__default__", +}; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +export const REST_URL = + process.env.AGENTARTS_MEMORY_SERVER_URL || "http://127.0.0.1:8719"; +export const DEBUG = process.env.AGENTARTS_MEMORY_DEBUG === "1"; + +// Default user_id from environment or platform detection +const ENV_DEFAULT_USER_ID = + process.env.AGENTARTS_MEMORY_USER_ID || PLATFORM_USER_ID[detectPlatform()]; + +/** + * Resolve user_id with priority: + * 1. payload.user_id / payload.userId (from hook request) + * 2. AGENTARTS_MEMORY_USER_ID env var + * 3. Platform-based default (cc-user / codex-user / __default__) + */ +export function resolveUserId(payload) { + if (payload && typeof payload === "object") { + const explicit = payload.user_id || payload.userId; + if (explicit && typeof explicit === "string" && explicit.trim()) { + return explicit.trim(); + } + } + return ENV_DEFAULT_USER_ID; +} + +export const SEARCH_MEM_NUM = 5; +export const SEARCH_SUMMARY_NUM = 3; +export const DEFAULT_THRESHOLD = 0.3; +export const MAX_TRUNCATE = 8000; + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- +export function authHeaders() { + return { "Content-Type": "application/json" }; +} + +export async function post(path, body, timeoutMs = 3000) { + try { + const res = await fetch(`${REST_URL}/${path}`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + if (DEBUG && !res.ok) console.error(`[agentarts] POST /${path} returned ${res.status}`); + } catch (e) { + if (DEBUG) console.error(`[agentarts] POST /${path} failed:`, e?.message || e); + } +} + +export async function postJson(path, body, timeoutMs = 0) { + try { + const opts = { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + }; + if (timeoutMs > 0) opts.signal = AbortSignal.timeout(timeoutMs); + const res = await fetch(`${REST_URL}/${path}`, opts); + if (res.ok) return await res.json(); + if (DEBUG) console.error(`[agentarts] POST /${path} returned ${res.status}`); + } catch (e) { + if (DEBUG) console.error(`[agentarts] POST /${path} (json) failed:`, e?.message || e); + } + return null; +} + +export async function getJson(path, timeoutMs = 800) { + try { + const res = await fetch(`${REST_URL}/${path}`, { + method: "GET", + headers: authHeaders(), + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.ok) return await res.json(); + } catch { + // ignore + } + return null; +} + +// --------------------------------------------------------------------------- +// Endpoints (trailing slash matches FastAPI route declarations) +// --------------------------------------------------------------------------- +const EP_ADD_MESSAGES = "add_messages/"; +const EP_SEARCH_MEMORY = "search_memory/"; +const EP_SEARCH_SUMMARY = "search_summary/"; +const EP_HEALTH = "health"; + +// --------------------------------------------------------------------------- +// Project resolution — scope_id = project basename for per-project isolation. +// --------------------------------------------------------------------------- +export function resolveProject(cwd) { + const explicit = process.env.AGENTARTS_MEMORY_PROJECT_NAME; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: ["ignore", "pipe", "ignore"], + timeout: 500, + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +// --------------------------------------------------------------------------- +// High-level operations +// --------------------------------------------------------------------------- +export async function addMessages(messages, scopeId, userId = DEFAULT_USER_ID) { + await post(EP_ADD_MESSAGES, { + messages, + user_id: userId, + scope_id: scopeId, + plugin_version: PLUGIN_VERSION, + }, 3000); +} + +/** + * Combined search — calls /search_memory/ and /search_summary/, merges into a + * formatted context string for stdout injection. + */ +export async function searchAndFormat(query, scopeId, userId = DEFAULT_USER_ID) { + const [memResult, summaryResult] = await Promise.all([ + postJson(EP_SEARCH_MEMORY, { + query, + num: SEARCH_MEM_NUM, + user_id: userId, + scope_id: scopeId, + threshold: DEFAULT_THRESHOLD, + plugin_version: PLUGIN_VERSION, + }), + postJson(EP_SEARCH_SUMMARY, { + query, + num: SEARCH_SUMMARY_NUM, + user_id: userId, + scope_id: scopeId, + threshold: DEFAULT_THRESHOLD, + plugin_version: PLUGIN_VERSION, + }), + ]); + + const memItems = memResult?.results || []; + const summaryItems = summaryResult?.results || []; + const lines = []; + + if (memItems.length) { + lines.push("## Related Memories"); + for (const r of memItems) { + const label = r.type ? `[${r.type}]` : ""; + const content = String(r.content || "").slice(0, 300); + const score = Number(r.score || 0).toFixed(2); + lines.push(`- ${label} ${content} (score: ${score})`); + } + } + if (summaryItems.length) { + if (lines.length) lines.push(""); + lines.push("## Related History Summaries"); + for (const r of summaryItems) { + const content = String(r.content || "").slice(0, 300); + const score = Number(r.score || 0).toFixed(2); + lines.push(`- ${content} (score: ${score})`); + } + } + return lines.join("\n"); +} + +export async function healthCheck() { + const r = await getJson(EP_HEALTH, 800); + return r && r.status === "healthy"; +} + +// --------------------------------------------------------------------------- +// SDK child guard — prevents sub-agents from double-capturing. +// --------------------------------------------------------------------------- +export function isSdkChildContext(payload) { + if (process.env.AGENTARTS_SDK_CHILD === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} + +// --------------------------------------------------------------------------- +// Output format — Claude Code / Codex: stdout is plain text. +// (Cursor JSON support omitted this round; kept extensible.) +// --------------------------------------------------------------------------- +export function formatOutput(text, eventType = "generic") { + return text || ""; +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- +export function truncate(value, max = MAX_TRUNCATE) { + if (typeof value === "string" && value.length > max) return value.slice(0, max) + "\n[...truncated]"; + return value; +} + +export function coerceText(content) { + if (!content) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((b) => (typeof b === "string" ? b : b?.text || b?.content || "")) + .filter(Boolean) + .join(" "); + } + return String(content); +} diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/notification.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/notification.mjs new file mode 100644 index 0000000..9e25ba7 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/notification.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// notification — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-failure.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-failure.mjs new file mode 100644 index 0000000..eaf96be --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-failure.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// post-tool-failure — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-use.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-use.mjs new file mode 100644 index 0000000..d606a9e --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/post-tool-use.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// post-tool-use — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-compact.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-compact.mjs new file mode 100644 index 0000000..3b1d83f --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-compact.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// PreCompact — inject relevant memories via stdout before context compression. +import { + resolveProject, + searchAndFormat, + isSdkChildContext, + resolveUserId, + coerceText, + formatOutput, +} from "./_shared.mjs"; + +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { data = JSON.parse(input); } catch { return; } + if (isSdkChildContext(data)) return; + + const cwd = data.cwd || process.cwd(); + const scopeId = resolveProject(cwd); + const userId = resolveUserId(data); + + // Extract query from conversation (last user message). + const messages = data.messages || []; + let query = ""; + for (const m of messages) { + if (m.role === "user") query = coerceText(m.content).slice(0, 500); + } + if (!query) query = scopeId; + + const context = await searchAndFormat(query, scopeId, userId); + if (context) process.stdout.write(formatOutput(context, "preCompact")); +} + +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-tool-use.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-tool-use.mjs new file mode 100644 index 0000000..2b48145 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/pre-tool-use.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// pre-tool-use — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/prompt-submit.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/prompt-submit.mjs new file mode 100644 index 0000000..b7e768b --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/prompt-submit.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// UserPromptSubmit — record user query + inject relevant memories via stdout. +import { + resolveProject, + addMessages, + searchAndFormat, + isSdkChildContext, + resolveUserId, + coerceText, + formatOutput, +} from "./_shared.mjs"; + +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { data = JSON.parse(input); } catch { return; } + if (isSdkChildContext(data)) return; + + const cwd = data.cwd || process.cwd(); + const scopeId = resolveProject(cwd); + const userId = resolveUserId(data); + const prompt = coerceText(data.prompt ?? data.userPrompt ?? ""); + if (!prompt) return; + + // Fire-and-forget background write — never block the agent loop. + addMessages([{ role: "user", content: prompt }], scopeId, userId).catch(() => {}); + + // Search for relevant context and inject via stdout. + const context = await searchAndFormat(prompt, scopeId, userId); + if (context) process.stdout.write(formatOutput(context, "userPromptSubmit")); + + setTimeout(() => process.exit(0), 300).unref(); +} + +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-end.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-end.mjs new file mode 100644 index 0000000..0947f43 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-end.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// session-end — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-start.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-start.mjs new file mode 100644 index 0000000..9593d90 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/session-start.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +// SessionStart — probe server health only. No memory search here. +import { healthCheck, isSdkChildContext } from "./_shared.mjs"; + +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { data = JSON.parse(input); } catch { return; } + if (isSdkChildContext(data)) return; + + await healthCheck(); + setTimeout(() => process.exit(0), 300).unref(); +} + +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/stop.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/stop.mjs new file mode 100644 index 0000000..7d39b75 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/stop.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// stop — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-start.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-start.mjs new file mode 100644 index 0000000..5a7a2b0 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-start.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// subagent-start — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-stop.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-stop.mjs new file mode 100644 index 0000000..2d61b79 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/subagent-stop.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// subagent-stop — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/task-completed.mjs b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/task-completed.mjs new file mode 100644 index 0000000..0b75349 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/scripts/task-completed.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// task-completed — no-op placeholder. Drains stdin, does nothing. +async function main() { + for await (const _ of process.stdin) { /* drain */ } +} +main(); diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/__init__.py b/agentarts-memory-plugins/agentarts-memory-code_agent/server/__init__.py new file mode 100644 index 0000000..6a7f15a --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/server/__init__.py @@ -0,0 +1,8 @@ +"""AgentArts Memory local HTTP adapter server. + +Exposes a thin FastAPI layer over the AgentArts MemoryClient so that +Claude Code / Codex / OpenCode hook scripts can record prompts and retrieve +memories via plain HTTP. +""" + +__all__ = ["app", "AgentArtsMemoryClient"] diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/agentarts_client.py b/agentarts-memory-plugins/agentarts-memory-code_agent/server/agentarts_client.py new file mode 100644 index 0000000..9544b57 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/server/agentarts_client.py @@ -0,0 +1,285 @@ +"""Adapter wrapping agentarts.sdk.memory.MemoryClient. + +Provides: + - scope_id -> session_id caching (auto create_memory_session on first use) + - user_id -> actor_id mapping + - normalized result dicts for the HTTP layer +""" + +from __future__ import annotations + +import logging +import os +import threading +from typing import Any + +logger = logging.getLogger("agentarts_memory_code_agent.server") + +# Debug mode from environment +DEBUG = os.getenv("AGENTARTS_MEMORY_LOG_LEVEL", "info").lower() == "debug" + +ENV_API_KEY = "HUAWEICLOUD_SDK_MEMORY_API_KEY" +ENV_REGION = "HUAWEICLOUD_SDK_REGION" +ENV_SPACE_ID = "AGENTARTS_MEMORY_SPACE_ID" + +DEFAULT_REGION = "cn-southwest-2" +DEFAULT_ASSISTANT_ID = "agentarts-memory-code_agent" +DEFAULT_TOP_K = 5 +DEFAULT_LIST_LIMIT = 10 +DEFAULT_MIN_SCORE = 0.3 + + +def import_memory_sdk() -> Any: + """Lazily import the AgentArts Memory SDK. + + Returns a namespace exposing MemoryClient, TextMessage and MemorySearchFilter. + This indirection lets tests monkeypatch the import without requiring the SDK. + """ + from agentarts.sdk.memory import MemoryClient + from agentarts.sdk.memory.inner.config import MemorySearchFilter, TextMessage + + return type( + "_Namespace", + (), + { + "MemoryClient": MemoryClient, + "TextMessage": TextMessage, + "MemorySearchFilter": MemorySearchFilter, + }, + ) + + +class AgentArtsMemoryClient: + """Thin wrapper over MemoryClient with scope->session caching.""" + + def __init__( + self, + *, + space_id: str | None = None, + region_name: str | None = None, + api_key: str | None = None, + assistant_id: str = DEFAULT_ASSISTANT_ID, + sdk: Any = None, + ) -> None: + self._space_id = space_id or os.getenv(ENV_SPACE_ID, "") + self._region = region_name or os.getenv(ENV_REGION, DEFAULT_REGION) + self._api_key = api_key or os.getenv(ENV_API_KEY) + self._assistant_id = assistant_id + self._sdk = sdk or import_memory_sdk() + self._client: Any = None + self._lock = threading.Lock() + # scope_id -> session_id cache + self._sessions: dict[str, str] = {} + + # ── availability ── + def is_configured(self) -> bool: + """Return True if the minimal env vars are present (no network).""" + return bool(self._space_id and self._api_key) + + @property + def space_id(self) -> str: + return self._space_id + + def _ensure_client(self) -> Any: + if self._client is None: + self._client = self._sdk.MemoryClient( + region_name=self._region, + api_key=self._api_key, + ) + 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.""" + with self._lock: + sid = self._sessions.get(scope_id) + if sid: + if DEBUG: + logger.debug("[SDK] session cache hit | scope_id=%s, session_id=%s", scope_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] + "...") + session = client.create_memory_session( + space_id=self._space_id, + actor_id=actor_id, + assistant_id=self._assistant_id, + ) + 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 + if DEBUG: + logger.debug("[SDK] session created | scope_id=%s, session_id=%s", scope_id, sid) + return sid + + # ── operations ── + def add_messages( + self, + messages: list[dict[str, str]], + *, + user_id: str, + scope_id: str, + ) -> dict[str, Any]: + """Record messages under the scope's session. + + ``messages`` is a list of ``{"role": str, "content": str}``. + """ + sid = self._get_or_create_session(scope_id, user_id) + client = self._ensure_client() + sdk_msgs = [ + self._sdk.TextMessage( + role=m["role"], + content=m["content"], + actor_id=user_id, + assistant_id=self._assistant_id, + ) + 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)) + resp = client.add_messages( + space_id=self._space_id, + session_id=sid, + messages=sdk_msgs, + ) + return {"session_id": sid, "count": len(sdk_msgs)} + + def search_memories( + self, + *, + query: str, + user_id: str, + scope_id: str, + num: int = DEFAULT_TOP_K, + threshold: float = DEFAULT_MIN_SCORE, + ) -> list[dict[str, Any]]: + """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) + filters = self._sdk.MemorySearchFilter( + query=query, + top_k=num, + min_score=threshold, + actor_id=user_id, + ) + resp = client.search_memories(space_id=self._space_id, filters=filters) + results = self._normalize_search_results(resp) + if DEBUG: + logger.debug("[SDK] search_memories | results=%d", len(results)) + return results + + def list_memories( + self, + *, + user_id: str | None = None, + scope_id: str | None = None, + limit: int = DEFAULT_LIST_LIMIT, + offset: int = 0, + ) -> list[dict[str, Any]]: + """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) + resp = client.list_memories( + space_id=self._space_id, + limit=limit, + offset=offset, + ) + return self._normalize_list_results(resp) + + def health(self) -> dict[str, Any]: + """Return config readiness (no network call).""" + return { + "status": "healthy" if self.is_configured() else "misconfigured", + "space_id": bool(self._space_id), + "api_key": bool(self._api_key), + } + + # ── normalization helpers ── + @staticmethod + def _extract_content(record: Any) -> str: + """Best-effort extract content string from a search result record.""" + if record is None: + return "" + if isinstance(record, str): + return record + if isinstance(record, dict): + for key in ("content", "text", "summary", "message"): + val = record.get(key) + if val: + return str(val) + # nested record + inner = record.get("record") + if inner: + return AgentArtsMemoryClient._extract_content(inner) + return str(record) + + @staticmethod + def _extract_type(record: Any) -> str: + if isinstance(record, dict): + for key in ("strategy_type", "memory_type", "type", "strategy"): + val = record.get(key) + if val: + return str(val) + inner = record.get("record") + if isinstance(inner, dict): + return AgentArtsMemoryClient._extract_type(inner) + return "" + + @classmethod + def _normalize_search_results(cls, resp: Any) -> list[dict[str, Any]]: + """Normalize MemorySearchResponse -> list of {content, score, type}.""" + raw_results = getattr(resp, "results", None) + if not raw_results: + return [] + out: list[dict[str, Any]] = [] + for item in raw_results: + if isinstance(item, dict): + record = item.get("record") + score = item.get("score") + content = cls._extract_content(record) + mtype = cls._extract_type(record) + else: + record = getattr(item, "record", None) + score = getattr(item, "score", None) + content = cls._extract_content(record) + mtype = cls._extract_type(record) + out.append( + { + "content": content, + "score": float(score) if score is not None else 0.0, + "type": mtype, + } + ) + return out + + @classmethod + def _normalize_list_results(cls, resp: Any) -> list[dict[str, Any]]: + """Normalize MemoryListResponse -> list of {content, type, created_at, id}.""" + items = getattr(resp, "items", None) or [] + out: list[dict[str, Any]] = [] + for mem in items: + if isinstance(mem, dict): + content = mem.get("content", "") + mtype = mem.get("strategy_type") or mem.get("memory_type", "") + mid = mem.get("id", "") + created = mem.get("created_at", "") + else: + content = getattr(mem, "content", "") + mtype = getattr(mem, "strategy_type", "") or getattr(mem, "memory_type", "") + mid = getattr(mem, "id", "") + created = getattr(mem, "created_at", "") + out.append( + { + "id": mid, + "content": content, + "type": mtype, + "created_at": created, + } + ) + return out diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/app.py b/agentarts-memory-plugins/agentarts-memory-code_agent/server/app.py new file mode 100644 index 0000000..656031d --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/server/app.py @@ -0,0 +1,204 @@ +"""FastAPI application exposing AgentArts Memory over local HTTP. + +Endpoints (trailing slash to match the convention used by hook scripts): + GET /health + POST /add_messages/ + POST /search_memory/ + POST /list_memories/ + POST /search_summary/ +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from .agentarts_client import ( + DEFAULT_LIST_LIMIT, + DEFAULT_MIN_SCORE, + DEFAULT_TOP_K, + AgentArtsMemoryClient, +) + +logger = logging.getLogger("agentarts_memory_code_agent.server") + +# Debug mode from environment +DEBUG = os.getenv("AGENTARTS_MEMORY_LOG_LEVEL", "info").lower() == "debug" + +# Server version +SERVER_VERSION = "1.0.0" + + +def _log_request(endpoint: str, user_id: str, scope_id: str, plugin_version: str = "", **extra: Any) -> None: + """Log request details in debug mode.""" + if DEBUG: + version_str = f", plugin=v{plugin_version}" if plugin_version else "" + extra_str = ", " + ", ".join(f"{k}={v}" for k, v in extra.items() if v) if extra else "" + logger.debug("[API] %s | user_id=%s, scope_id=%s%s%s", endpoint, user_id, scope_id, version_str, extra_str) + + +def _log_response(endpoint: str, result: Any) -> None: + """Log response details in debug mode.""" + if DEBUG: + if isinstance(result, dict): + count = result.get("total", len(result.get("results", []))) + logger.debug("[API] %s | response: %d items", endpoint, count) + else: + logger.debug("[API] %s | response: %s", endpoint, type(result).__name__) + + +# ── single shared client instance ── +_client: AgentArtsMemoryClient | None = None + + +def get_client() -> AgentArtsMemoryClient: + global _client + if _client is None: + _client = AgentArtsMemoryClient() + return _client + + +def reset_client(client: AgentArtsMemoryClient | None = None) -> None: + """Replace the shared client (used by tests).""" + global _client + _client = client + + +# ── request models ── +class MessageItem(BaseModel): + role: str + content: str + + +class AddMessagesRequest(BaseModel): + messages: list[MessageItem] + user_id: str = "cc-user" + scope_id: str = "default" + plugin_version: str = "" + + +class SearchRequest(BaseModel): + query: str + num: int = Field(default=DEFAULT_TOP_K, ge=1, le=100) + user_id: str = "cc-user" + scope_id: str = "default" + threshold: float = Field(default=DEFAULT_MIN_SCORE, ge=0.0, le=1.0) + plugin_version: str = "" + + +class ListRequest(BaseModel): + limit: int = Field(default=DEFAULT_LIST_LIMIT, ge=1, le=100) + offset: int = Field(default=0, ge=0) + user_id: str | None = None + scope_id: str | None = None + plugin_version: str = "" + + +app = FastAPI( + title="AgentArts Memory Agent Server", + version=SERVER_VERSION, + description="Local HTTP adapter over Huawei Cloud AgentArts Memory for Claude Code / Codex / OpenCode hooks.", +) + +# Add CORS middleware for cross-origin requests +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +def health() -> dict[str, Any]: + result = get_client().health() + if DEBUG: + logger.debug("[API] GET /health | status=%s", result.get("status", "unknown")) + return result + + +@app.post("/add_messages/") +def add_messages(req: AddMessagesRequest) -> dict[str, Any]: + _log_request("POST /add_messages/", req.user_id, req.scope_id, req.plugin_version, messages=len(req.messages)) + try: + result = get_client().add_messages( + [m.model_dump() for m in req.messages], + user_id=req.user_id, + scope_id=req.scope_id, + ) + if DEBUG and result: + logger.debug("[API] POST /add_messages/ | session_id=%s, count=%d", + result.get("session_id", "unknown"), result.get("count", 0)) + return result + except Exception as exc: # noqa: BLE001 + logger.warning("add_messages failed: %s", exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@app.post("/search_memory/") +def search_memory(req: SearchRequest) -> dict[str, Any]: + _log_request("POST /search_memory/", req.user_id, req.scope_id, req.plugin_version, query=req.query[:50], num=req.num) + try: + results = get_client().search_memories( + query=req.query, + user_id=req.user_id, + scope_id=req.scope_id, + num=req.num, + threshold=req.threshold, + ) + _log_response("POST /search_memory/", results) + return {"results": results, "total": len(results), "query": req.query} + except Exception as exc: # noqa: BLE001 + logger.warning("search_memory failed: %s", exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@app.post("/list_memories/") +def list_memories(req: ListRequest) -> dict[str, Any]: + _log_request("POST /list_memories/", req.user_id or "default", req.scope_id or "default", req.plugin_version, limit=req.limit) + try: + results = get_client().list_memories( + user_id=req.user_id, + scope_id=req.scope_id, + limit=req.limit, + offset=req.offset, + ) + _log_response("POST /list_memories/", results) + return {"results": results, "total": len(results)} + except Exception as exc: # noqa: BLE001 + logger.warning("list_memories failed: %s", exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@app.post("/search_summary/") +def search_summary(req: SearchRequest) -> dict[str, Any]: + """Summary search — reuse list_memories filtered to summary-like types. + + AgentArts has no dedicated summary endpoint, so we list memories and + return those whose strategy_type looks summary-like; falling back to the + full list when no summary-type memories exist. + """ + _log_request("POST /search_summary/", req.user_id, req.scope_id, req.plugin_version, query=req.query[:50], num=req.num) + try: + all_mem = get_client().list_memories( + user_id=req.user_id, + scope_id=req.scope_id, + limit=min(max(req.num * 5, DEFAULT_LIST_LIMIT), 20), + offset=0, + ) + summary_types = {"summary", "episodic", "user_preference"} + summaries = [m for m in all_mem if m.get("type") in summary_types] + if not summaries: + summaries = all_mem + summaries = summaries[: req.num] + _log_response("POST /search_summary/", summaries) + return {"results": summaries, "total": len(summaries), "query": req.query} + except Exception as exc: # noqa: BLE001 + logger.warning("search_summary failed: %s", exc) + raise HTTPException(status_code=502, detail=str(exc)) from exc \ No newline at end of file diff --git a/agentarts-memory-plugins/agentarts-memory-code_agent/server/run.py b/agentarts-memory-plugins/agentarts-memory-code_agent/server/run.py new file mode 100644 index 0000000..b174de8 --- /dev/null +++ b/agentarts-memory-plugins/agentarts-memory-code_agent/server/run.py @@ -0,0 +1,379 @@ +"""Entry point to launch the AgentArts Memory adapter server.""" + +from __future__ import annotations + +import logging +import os +import sys + +# Configure logger +logger = logging.getLogger("agentarts_memory_code_agent") + +# Server version +SERVER_VERSION = "1.0.0" + +# Required environment variables for AgentArts Memory +REQUIRED_ENV_VARS = { + "AGENTARTS_MEMORY_SPACE_ID": "Huawei Cloud AgentArts Memory Space ID", + "HUAWEICLOUD_SDK_MEMORY_API_KEY": "Huawei Cloud AgentArts Memory API Key", +} + +OPTIONAL_ENV_VARS = { + "HUAWEICLOUD_SDK_REGION": ("Huawei Cloud AgentArts Memory Region", "cn-southwest-2"), +} + + +def validate_space_id(value: str) -> tuple[bool, str]: + """Validate Space ID format.""" + if not value or not value.strip(): + return False, "Space ID cannot be empty" + value = value.strip() + if len(value) < 8: + return False, "Space ID must be at least 8 characters" + return True, value + + +def validate_api_key(value: str) -> tuple[bool, str]: + """Validate API Key format.""" + if not value or not value.strip(): + return False, "API Key cannot be empty" + value = value.strip() + if len(value) < 16: + return False, "API Key must be at least 16 characters" + return True, value + + +def validate_region(value: str) -> tuple[bool, str]: + """Validate region format.""" + if not value or not value.strip(): + return True, "cn-southwest-2" + value = value.strip() + parts = value.split("-") + if len(parts) != 3: + return False, "Region format should be like 'cn-southwest-2'" + return True, value + + +VALIDATORS = { + "AGENTARTS_MEMORY_SPACE_ID": validate_space_id, + "HUAWEICLOUD_SDK_MEMORY_API_KEY": validate_api_key, + "HUAWEICLOUD_SDK_REGION": validate_region, +} + + +def mask_sensitive(value: str, var_name: str) -> str: + """Mask sensitive values for display.""" + if not value: + return "" + if "API_KEY" in var_name or "SECRET" in var_name or "SK" in var_name: + return "*" * min(len(value), 8) + if len(value) <= 8: + return value + return f"{value[:4]}...{value[-4:]}" + + +def prompt_for_config(var_name: str, description: str, is_optional: bool = False, default: str = "") -> str: + """Prompt user for a configuration value with validation.""" + validator = VALIDATORS.get(var_name) + + while True: + prompt_text = f"\n{description}" + if is_optional and default: + prompt_text += f" (default: {default})" + prompt_text += ": " + + try: + value = input(prompt_text).strip() + except (EOFError, KeyboardInterrupt): + log_config("\nConfiguration cancelled.") + sys.exit(1) + + if is_optional and not value: + value = default + + if validator: + is_valid, result = validator(value) + if not is_valid: + log_config(" ✗ %s", result) + continue + value = result + + if not value and not is_optional: + log_config(" ✗ Value cannot be empty") + continue + + display_value = mask_sensitive(value, var_name) + log_config(" ✓ Configured: %s", display_value) + return value + + +def check_env_configured() -> tuple[bool, dict[str, str]]: + """Check if all required environment variables are configured.""" + config = {} + + for var_name in REQUIRED_ENV_VARS: + value = os.getenv(var_name) + if not value: + return False, {} + + validator = VALIDATORS.get(var_name) + if validator: + is_valid, result = validator(value) + if not is_valid: + return False, {} + config[var_name] = result + else: + config[var_name] = value + + for var_name, (_, default) in OPTIONAL_ENV_VARS.items(): + value = os.getenv(var_name) + if value: + validator = VALIDATORS.get(var_name) + if validator: + is_valid, result = validator(value) + config[var_name] = result if is_valid else default + else: + config[var_name] = value + else: + config[var_name] = default + + return True, config + + +def interactive_config() -> dict[str, str]: + """Interactive configuration prompt for missing values.""" + config = {} + missing_required = [] + + log_config("") + log_config("=" * 60) + log_config("AgentArts Memory Server Configuration") + log_config("=" * 60) + + for var_name, description in REQUIRED_ENV_VARS.items(): + value = os.getenv(var_name) + if value: + validator = VALIDATORS.get(var_name) + if validator: + is_valid, result = validator(value) + if is_valid: + config[var_name] = result + continue + log_config("Invalid %s: %s", description, result) + value = None + + if not value: + missing_required.append((var_name, description)) + + for var_name, (_, default) in OPTIONAL_ENV_VARS.items(): + value = os.getenv(var_name) + if value: + validator = VALIDATORS.get(var_name) + if validator: + is_valid, result = validator(value) + if is_valid: + config[var_name] = result + continue + + config[var_name] = value or default + + if not missing_required: + log_config("") + log_config("✓ All required environment variables are configured.") + return config + + log_config("") + log_config("Missing required configuration:") + for var_name, description in missing_required: + config[var_name] = prompt_for_config(var_name, description) + + for var_name, (description, default) in OPTIONAL_ENV_VARS.items(): + if not os.getenv(var_name): + log_config("") + log_config("ℹ Optional: %s", description) + try: + configure = input(f" Configure {description}? [y/N]: ").strip().lower() + if configure in ("y", "yes"): + config[var_name] = prompt_for_config(var_name, description, is_optional=True, default=default) + except (EOFError, KeyboardInterrupt): + log_config("") + config[var_name] = default + + return config + + +def apply_config(config: dict[str, str]) -> None: + """Apply configuration to environment variables.""" + for var_name, value in config.items(): + if value: + os.environ[var_name] = value + + +def save_config_to_shell_rc(config: dict[str, str]) -> None: + """Optionally save configuration to shell rc file.""" + if not config: + return + + log_config("") + log_config("-" * 60) + try: + save = input("Save configuration to ~/.zshrc for persistence? [y/N]: ").strip().lower() + if save not in ("y", "yes"): + return + except (EOFError, KeyboardInterrupt): + log_config("") + return + + rc_file = os.path.expanduser("~/.zshrc") + if not os.path.exists(rc_file): + log_config("%s not found, skipping save.", rc_file) + return + + config_lines = ["\n# AgentArts Memory Server Configuration"] + for var_name in REQUIRED_ENV_VARS: + if var_name in config: + config_lines.append(f'export {var_name}="{config[var_name]}"') + + for var_name in OPTIONAL_ENV_VARS: + if var_name in config and config[var_name]: + config_lines.append(f'export {var_name}="{config[var_name]}"') + + config_lines.append("") + + try: + with open(rc_file, "a") as f: + f.write("\n".join(config_lines)) + log_config("✓ Configuration saved to %s", rc_file) + log_config(" Run 'source ~/.zshrc' or restart terminal to apply.") + except Exception as e: + log_config("Failed to save: %s", e) + + +def setup_logging(log_level: str = "info") -> None: + """Configure logging for the application. + + Runtime logs (API requests, SDK calls) include timestamp with milliseconds and level. + Startup config logs remain plain for better readability. + """ + import time + + level = getattr(logging, log_level.upper(), logging.INFO) + + # Create a custom formatter for runtime logs with milliseconds + class MillisecondFormatter(logging.Formatter): + def formatTime(self, record, datefmt=None): + ct = self.converter(record.created) + if datefmt: + s = time.strftime(datefmt, ct) + else: + s = time.strftime("%Y-%m-%d %H:%M:%S", ct) + return "%s,%03d" % (s, int((record.created - int(record.created)) * 1000)) + + formatter = MillisecondFormatter( + fmt="%(asctime)s %(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # Configure root logger + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.setLevel(level) + root_logger.handlers = [handler] + + # Set our logger + logger.setLevel(level) + + if log_level.lower() != "debug": + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + + +def log_config(message: str, *args) -> None: + """Log startup configuration messages without timestamp (plain format).""" + if args: + message = message % args + print(message) + + +def log_startup_info(config: dict[str, str], log_level: str, host: str, port: int) -> None: + """Log server startup information (plain format, no timestamp).""" + log_config("") + log_config("=" * 60) + log_config("AgentArts Memory Server v%s", SERVER_VERSION) + log_config("=" * 60) + log_config(" Address: %s:%s", host, port) + log_config(" Space ID: %s", mask_sensitive(config.get("AGENTARTS_MEMORY_SPACE_ID", ""), "SPACE_ID")) + log_config(" Region: %s", config.get("HUAWEICLOUD_SDK_REGION", "cn-southwest-2")) + log_config(" Log Level: %s", log_level) + log_config("=" * 60) + log_config("") + + +def run_server(log_level: str) -> None: + """Run the uvicorn server.""" + import uvicorn + + host = os.getenv("AGENTARTS_MEMORY_SERVER_HOST", "127.0.0.1") + port = int(os.getenv("AGENTARTS_MEMORY_SERVER_PORT", "8719")) + + uvicorn.run( + "server.app:app", + host=host, + port=port, + log_level=log_level, + ) + + +def main() -> None: + """Main entry point with configuration check.""" + log_level = os.getenv("AGENTARTS_MEMORY_LOG_LEVEL", "info") + setup_logging(log_level) + + is_configured, config = check_env_configured() + + if is_configured: + log_config("") + log_config("=" * 60) + log_config("AgentArts Memory Server v%s", SERVER_VERSION) + log_config("=" * 60) + log_config("✓ Environment variables detected, starting server...") + log_config(" Space ID: %s", mask_sensitive(config.get("AGENTARTS_MEMORY_SPACE_ID", ""), "SPACE_ID")) + log_config(" Region: %s", config.get("HUAWEICLOUD_SDK_REGION", "cn-southwest-2")) + log_config(" Log Level: %s", log_level) + log_config("") + + apply_config(config) + + try: + run_server(log_level) + except Exception as e: + logger.error("Server failed to start: %s", e) + log_config("Entering interactive configuration...") + + config = interactive_config() + apply_config(config) + save_config_to_shell_rc(config) + + host = os.getenv("AGENTARTS_MEMORY_SERVER_HOST", "127.0.0.1") + port = int(os.getenv("AGENTARTS_MEMORY_SERVER_PORT", "8719")) + log_startup_info(config, log_level, host, port) + + run_server(log_level) + else: + config = interactive_config() + apply_config(config) + save_config_to_shell_rc(config) + + host = os.getenv("AGENTARTS_MEMORY_SERVER_HOST", "127.0.0.1") + port = int(os.getenv("AGENTARTS_MEMORY_SERVER_PORT", "8719")) + log_startup_info(config, log_level, host, port) + + run_server(log_level) + log_startup_info(config, log_level, host, port) + + run_server(log_level) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 75cf16d..acafa1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,10 @@ dev = [ "types-PyYAML>=6.0.0", "types-redis>=4.6.0", "ruff>=0.1.0", + "fastapi>=0.104.0", + "langgraph>=1.0.0", + "langchain>=1.0.0", + "langchain-core>=1.0.0", ] [project.optional-dependencies] diff --git a/tests/agentarts-memory-code_agent/__init__.py b/tests/agentarts-memory-code_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/agentarts-memory-code_agent/conftest.py b/tests/agentarts-memory-code_agent/conftest.py new file mode 100644 index 0000000..eeab4f6 --- /dev/null +++ b/tests/agentarts-memory-code_agent/conftest.py @@ -0,0 +1,14 @@ +"""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-code_agent/test_agentarts_client.py b/tests/agentarts-memory-code_agent/test_agentarts_client.py new file mode 100644 index 0000000..2b43120 --- /dev/null +++ b/tests/agentarts-memory-code_agent/test_agentarts_client.py @@ -0,0 +1,171 @@ +"""Unit tests for AgentArtsMemoryClient (server/agentarts_client.py). + +Uses a fake SDK namespace so no cloud SDK or network is required. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from server import agentarts_client as ac +from server.agentarts_client import AgentArtsMemoryClient + + +# ── fake SDK ─────────────────────────────────────────────────────── +class FakeTextMessage: + def __init__(self, role, content, actor_id=None, assistant_id=None): + self.role = role + self.content = content + self.actor_id = actor_id + self.assistant_id = assistant_id + + +class FakeSearchFilter: + def __init__(self, query=None, top_k=5, min_score=0.3, actor_id=None, **kw): + self.query = query + self.top_k = top_k + self.min_score = min_score + self.actor_id = actor_id + + +def _fake_sdk(): + return SimpleNamespace( + TextMessage=FakeTextMessage, + MemorySearchFilter=FakeSearchFilter, + ) + + +def _make_client(monkeypatch, space_id="space-1", ak="ak1", sk="sk1", api_key="key1"): + monkeypatch.setenv("HUAWEICLOUD_SDK_AK", ak) + monkeypatch.setenv("HUAWEICLOUD_SDK_SK", sk) + monkeypatch.setenv("AGENTARTS_MEMORY_SPACE_ID", space_id) + monkeypatch.setenv("HUAWEICLOUD_SDK_MEMORY_API_KEY", api_key) + sdk = _fake_sdk() + client = AgentArtsMemoryClient(sdk=sdk) + return client + + +# ── availability ────────────────────────────────────────────────── +def test_is_configured_true(monkeypatch): + c = _make_client(monkeypatch) + assert c.is_configured() is True + + +def test_is_configured_false_when_missing_space(monkeypatch): + monkeypatch.setenv("HUAWEICLOUD_SDK_AK", "ak") + monkeypatch.setenv("HUAWEICLOUD_SDK_SK", "sk") + monkeypatch.delenv("AGENTARTS_MEMORY_SPACE_ID", raising=False) + c = AgentArtsMemoryClient(sdk=_fake_sdk()) + assert c.is_configured() is False + + +def test_health_reports_flags(monkeypatch): + c = _make_client(monkeypatch) + h = c.health() + assert h["space_id"] is True + assert h["ak"] is True + assert h["sk"] is True + assert h["status"] == "healthy" + + +# ── session caching ─────────────────────────────────────────────── +def test_session_cached_per_scope(monkeypatch): + 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", "user-1") + sid2 = c._get_or_create_session("proj-a", "user-1") + assert sid1 == sid2 == "sess-1" + # create called only once for the same scope + assert c._client.create_memory_session.call_count == 1 + # different scope -> new session + c._client.create_memory_session.return_value = SimpleNamespace(id="sess-2") + sid3 = c._get_or_create_session("proj-b", "user-1") + assert sid3 == "sess-2" + 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() + c._client.create_memory_session.return_value = SimpleNamespace(id="s-x") + c._get_or_create_session("scope", "the-actor") + call = c._client.create_memory_session.call_args + assert call.kwargs["space_id"] == "sp" + assert call.kwargs["actor_id"] == "the-actor" + assert call.kwargs["assistant_id"] == ac.DEFAULT_ASSISTANT_ID + + +# ── add_messages ────────────────────────────────────────────────── +def test_add_messages_maps_role_content(monkeypatch): + c = _make_client(monkeypatch) + c._client = MagicMock() + c._client.create_memory_session.return_value = SimpleNamespace(id="sess-9") + c._client.add_messages.return_value = SimpleNamespace() + res = c.add_messages( + [{"role": "user", "content": "hello"}], + user_id="u1", + scope_id="proj", + ) + assert res["session_id"] == "sess-9" + assert res["count"] == 1 + sent = c._client.add_messages.call_args + msgs = sent.kwargs["messages"] + assert msgs[0].role == "user" + assert msgs[0].content == "hello" + assert msgs[0].actor_id == "u1" + assert sent.kwargs["space_id"] == "space-1" + assert sent.kwargs["session_id"] == "sess-9" + + +# ── search_memories ─────────────────────────────────────────────── +def test_search_memories_normalizes_results(monkeypatch): + c = _make_client(monkeypatch) + c._client = MagicMock() + # MemorySearchResponse.results = [{"record": , "score": 0.9}, ...] + c._client.search_memories.return_value = SimpleNamespace( + results=[ + {"record": {"content": "likes python", "strategy_type": "semantic"}, "score": 0.9}, + {"record": {"content": "episodic note", "strategy_type": "episodic"}, "score": 0.7}, + ], + total=2, + ) + out = c.search_memories(query="python", user_id="u1", scope_id="proj") + assert len(out) == 2 + assert out[0]["content"] == "likes python" + assert out[0]["score"] == pytest.approx(0.9) + assert out[0]["type"] == "semantic" + # filter passed through + f = c._client.search_memories.call_args.kwargs["filters"] + assert f.query == "python" + assert f.top_k == ac.DEFAULT_TOP_K + assert f.min_score == ac.DEFAULT_MIN_SCORE + assert f.actor_id == "u1" + + +def test_search_memories_empty(monkeypatch): + c = _make_client(monkeypatch) + c._client = MagicMock() + c._client.search_memories.return_value = SimpleNamespace(results=[], total=0) + assert c.search_memories(query="x", user_id="u", scope_id="s") == [] + + +# ── list_memories ───────────────────────────────────────────────── +def test_list_memories_normalizes(monkeypatch): + c = _make_client(monkeypatch) + c._client = MagicMock() + c._client.list_memories.return_value = SimpleNamespace( + items=[ + SimpleNamespace(id="m1", content="c1", strategy_type="semantic", created_at="t1"), + ], + total=1, + ) + out = c.list_memories(user_id="u", scope_id="s") + assert out[0]["id"] == "m1" + assert out[0]["content"] == "c1" + assert out[0]["type"] == "semantic" + assert out[0]["created_at"] == "t1" diff --git a/tests/agentarts-memory-code_agent/test_scripts.mjs b/tests/agentarts-memory-code_agent/test_scripts.mjs new file mode 100644 index 0000000..10fc13f --- /dev/null +++ b/tests/agentarts-memory-code_agent/test_scripts.mjs @@ -0,0 +1,172 @@ +// Node hook script tests — uses node:test + a file-based fetch stub preload. +import { test } from "node:test"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +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"); +const SCRIPTS = path.join(PLUGIN_ROOT, "scripts"); +const join = path.join; + +function runHook(scriptName, stdinObj, env = {}) { + const r = spawnSync(process.execPath, [join(SCRIPTS, scriptName)], { + input: stdinObj ? JSON.stringify(stdinObj) : "", + env: { ...process.env, ...env }, + encoding: "utf8", + timeout: 10000, + }); + return { stdout: r.stdout, stderr: r.stderr, code: r.status }; +} + +// Write a preload .mjs that patches globalThis.fetch with canned responses. +function writeFetchStubPreload(routes) { + const dir = mkdtempSync(join(tmpdir(), "fetch-stub-")); + const file = join(dir, "preload.mjs"); + const code = [ + "globalThis.__stubRoutes = " + JSON.stringify(routes) + ";", + "globalThis.fetch = async (urlStr) => {", + " const u = new URL(urlStr);", + " const key = u.pathname;", + " let body = globalThis.__stubRoutes[key];", + " if (!body) { for (const k of Object.keys(globalThis.__stubRoutes)) { if (key.startsWith(k)) { body = globalThis.__stubRoutes[k]; break; } } }", + " return { ok: true, json: async () => body || {} };", + "};", + ].join("\n") + "\n"; + writeFileSync(file, code); + return file; +} + +// ── _shared.mjs unit tests ──────────────────────────────────────── +test("_shared.resolveProject uses explicit env override", async () => { + const mod = await import(join(SCRIPTS, "_shared.mjs") + "?t=" + Date.now()); + process.env.AGENTARTS_MEMORY_PROJECT_NAME = "my-proj"; + assert.equal(mod.resolveProject("/some/cwd"), "my-proj"); + delete process.env.AGENTARTS_MEMORY_PROJECT_NAME; +}); + +test("_shared.formatOutput returns text for plain platform", async () => { + const mod = await import(join(SCRIPTS, "_shared.mjs") + "?t=" + (Date.now() + 1)); + assert.equal(mod.formatOutput("hello", "userPromptSubmit"), "hello"); + assert.equal(mod.formatOutput("", "x"), ""); +}); + +test("_shared.coerceText handles string and array", async () => { + const mod = await import(join(SCRIPTS, "_shared.mjs") + "?t=" + (Date.now() + 2)); + assert.equal(mod.coerceText("abc"), "abc"); + assert.equal(mod.coerceText([{ text: "a" }, "b"]), "a b"); + assert.equal(mod.coerceText(""), ""); +}); + +// ── session-start.mjs ───────────────────────────────────────────── +test("session-start drains stdin and exits 0 (no server)", () => { + const r = runHook("session-start.mjs", { cwd: "/tmp" }, { + AGENTARTS_MEMORY_SERVER_URL: "http://127.0.0.1:65535", + }); + assert.equal(r.code, 0, "stderr: " + r.stderr); + assert.equal(r.stdout, ""); +}); + +// ── prompt-submit.mjs ───────────────────────────────────────────── +test("prompt-submit with no server produces no stdout", () => { + const r = runHook( + "prompt-submit.mjs", + { cwd: "/tmp", prompt: "hello world" }, + { AGENTARTS_MEMORY_SERVER_URL: "http://127.0.0.1:65535" }, + ); + assert.equal(r.stdout, ""); +}); + +test("prompt-submit with invalid JSON exits cleanly", () => { + const r = spawnSync(process.execPath, [join(SCRIPTS, "prompt-submit.mjs")], { + input: "not json", + env: { ...process.env, AGENTARTS_MEMORY_SERVER_URL: "http://127.0.0.1:65535" }, + encoding: "utf8", + timeout: 10000, + }); + assert.equal(r.status, 0); + assert.equal(r.stdout, ""); +}); + +test("prompt-submit injects memory context with stubbed fetch", () => { + const preload = writeFetchStubPreload({ + "/health": { status: "healthy" }, + "/search_memory/": { + results: [{ content: "likes python", score: 0.9, type: "semantic" }], + }, + "/search_summary/": { results: [] }, + "/add_messages/": {}, + }); + const r = spawnSync(process.execPath, ["--import", preload, join(SCRIPTS, "prompt-submit.mjs")], { + input: JSON.stringify({ cwd: "/tmp", prompt: "python" }), + env: { ...process.env, AGENTARTS_MEMORY_SERVER_URL: "http://stub.local" }, + encoding: "utf8", + timeout: 10000, + }); + assert.ok(r.stdout.includes("Related Memories"), "stdout: " + r.stdout + " stderr: " + r.stderr); + assert.ok(r.stdout.includes("likes python")); + assert.ok(r.stdout.includes("semantic")); +}); + +// ── pre-compact.mjs ─────────────────────────────────────────────── +test("pre-compact with no server produces no stdout", () => { + const r = runHook( + "pre-compact.mjs", + { cwd: "/tmp", messages: [{ role: "user", content: "compress me" }] }, + { AGENTARTS_MEMORY_SERVER_URL: "http://127.0.0.1:65535" }, + ); + assert.equal(r.stdout, ""); +}); + +test("pre-compact exits 0 with valid input", () => { + const r = runHook( + "pre-compact.mjs", + { cwd: "/tmp", messages: [] }, + { AGENTARTS_MEMORY_SERVER_URL: "http://127.0.0.1:65535" }, + ); + assert.equal(r.code, 0); +}); + +test("pre-compact injects memory with stubbed fetch", () => { + const preload = writeFetchStubPreload({ + "/health": { status: "healthy" }, + "/search_memory/": { + results: [{ content: "past decision", score: 0.8, type: "episodic" }], + }, + "/search_summary/": { results: [] }, + }); + const r = spawnSync(process.execPath, ["--import", preload, join(SCRIPTS, "pre-compact.mjs")], { + input: JSON.stringify({ + cwd: "/tmp", + messages: [{ role: "user", content: "keep context" }], + }), + env: { ...process.env, AGENTARTS_MEMORY_SERVER_URL: "http://stub.local" }, + encoding: "utf8", + timeout: 10000, + }); + assert.ok(r.stdout.includes("Related Memories"), "stdout: " + r.stdout + " stderr: " + r.stderr); + assert.ok(r.stdout.includes("past decision")); +}); + +// ── no-op scripts ───────────────────────────────────────────────── +const noOps = [ + "post-tool-use.mjs", + "post-tool-failure.mjs", + "pre-tool-use.mjs", + "stop.mjs", + "session-end.mjs", + "subagent-start.mjs", + "subagent-stop.mjs", + "notification.mjs", + "task-completed.mjs", +]; +for (const name of noOps) { + test("no-op " + name + " drains stdin and exits 0", () => { + const r = runHook(name, { foo: "bar" }); + assert.equal(r.code, 0, "stderr: " + r.stderr); + assert.equal(r.stdout, ""); + }); +} diff --git a/tests/agentarts-memory-code_agent/test_server.py b/tests/agentarts-memory-code_agent/test_server.py new file mode 100644 index 0000000..f60a971 --- /dev/null +++ b/tests/agentarts-memory-code_agent/test_server.py @@ -0,0 +1,124 @@ +"""Unit tests for the FastAPI server routes (server/app.py). + +Uses FastAPI TestClient with a mock AgentArtsMemoryClient injected via +reset_client(). No network or cloud SDK required. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from server import app as app_module +from server.app import app + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("HUAWEICLOUD_SDK_AK", "ak") + monkeypatch.setenv("HUAWEICLOUD_SDK_SK", "sk") + monkeypatch.setenv("AGENTARTS_MEMORY_SPACE_ID", "sp") + monkeypatch.setenv("HUAWEICLOUD_SDK_MEMORY_API_KEY", "k") + mock = MagicMock() + mock.health.return_value = {"status": "healthy", "space_id": True, "ak": True, "sk": True, "api_key": True} + app_module.reset_client(mock) + yield mock + app_module.reset_client(None) + + +def test_health(client): + c = TestClient(app) + r = c.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "healthy" + + +def test_add_messages(client): + client.add_messages.return_value = {"session_id": "s1", "count": 2} + c = TestClient(app) + r = c.post("/add_messages/", json={ + "messages": [{"role": "user", "content": "hi"}], + "user_id": "u1", "scope_id": "proj", + }) + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "s1" + assert body["count"] == 2 + kw = client.add_messages.call_args.args[0] + assert kw[0] == {"role": "user", "content": "hi"} + assert client.add_messages.call_args.kwargs == {"user_id": "u1", "scope_id": "proj"} + + +def test_add_messages_error(client): + client.add_messages.side_effect = RuntimeError("boom") + c = TestClient(app) + r = c.post("/add_messages/", json={ + "messages": [{"role": "user", "content": "x"}], + "user_id": "u", "scope_id": "s", + }) + assert r.status_code == 502 + assert "boom" in r.json()["detail"] + + +def test_search_memory(client): + client.search_memories.return_value = [ + {"content": "c1", "score": 0.9, "type": "semantic"}, + ] + c = TestClient(app) + r = c.post("/search_memory/", json={ + "query": "py", "user_id": "u", "scope_id": "s", "num": 5, "threshold": 0.3, + }) + assert r.status_code == 200 + body = r.json() + assert body["results"][0]["content"] == "c1" + assert body["query"] == "py" + kw = client.search_memories.call_args.kwargs + assert kw["query"] == "py" + assert kw["num"] == 5 + assert kw["threshold"] == 0.3 + + +def test_list_memories(client): + client.list_memories.return_value = [ + {"id": "m1", "content": "c", "type": "semantic", "created_at": "t"}, + ] + c = TestClient(app) + r = c.post("/list_memories/", json={"limit": 10, "offset": 0, "user_id": "u", "scope_id": "s"}) + assert r.status_code == 200 + assert r.json()["results"][0]["id"] == "m1" + + +def test_search_summary(client): + client.list_memories.return_value = [ + {"id": "m1", "content": "sum", "type": "episodic", "created_at": "t"}, + {"id": "m2", "content": "other", "type": "semantic", "created_at": "t2"}, + ] + c = TestClient(app) + r = c.post("/search_summary/", json={ + "query": "x", "user_id": "u", "scope_id": "s", "num": 3, "threshold": 0.3, + }) + assert r.status_code == 200 + types = [m["type"] for m in r.json()["results"]] + assert "episodic" in types + + +def test_search_summary_fallback_to_all(client): + client.list_memories.return_value = [ + {"id": "m1", "content": "c", "type": "semantic", "created_at": "t"}, + ] + c = TestClient(app) + r = c.post("/search_summary/", json={ + "query": "x", "user_id": "u", "scope_id": "s", "num": 5, "threshold": 0.3, + }) + assert r.status_code == 200 + assert len(r.json()["results"]) == 1 + + +def test_validation_error(client): + c = TestClient(app) + # missing messages field + r = c.post("/add_messages/", json={"user_id": "u", "scope_id": "s"}) + assert r.status_code == 422