diff --git a/apps/claude-code-plugin/SETUP.md b/apps/claude-code-plugin/SETUP.md index 2f5868716..efb95566d 100644 --- a/apps/claude-code-plugin/SETUP.md +++ b/apps/claude-code-plugin/SETUP.md @@ -40,7 +40,7 @@ Installed-plugin init is idempotent and uses plugin-local state: $HOME/.powermem/ .env runtime.env - server.pid + powermem.pid powermem-server.log venv/ ``` @@ -69,19 +69,35 @@ sh "$CLAUDE_PLUGIN_ROOT/scripts/..." sh "$CLAUDE_PLUGIN_ROOT/scripts/init.sh" ``` - The script reads `~/.claude/settings.json` and attempts to derive: - `LLM_PROVIDER`, `LLM_MODEL`, `LLM_API_KEY`, and provider base URL. It writes only - the plugin-local `.env`. + The script reads the current process environment first and attempts to derive + the supported Anthropic configuration. It uses `ANTHROPIC_API_KEY` first; only + when that is absent does it use `ANTHROPIC_AUTH_TOKEN` together with + `ANTHROPIC_BASE_URL`. It also reads `ANTHROPIC_MODEL` from the environment. + If the environment does not provide a complete config, it falls back to + `~/.claude/settings.json` using the same Anthropic keys. It writes the + plugin-local `.env` with the full PowerMem backend defaults: embedded + OceanBase/seekdb storage, local default embedding, server settings, and logging + settings. 4. If init reports missing values, ask the user only for those missing values. Do not invent credentials. Re-run init with the matching environment variables: ```bash POWERMEM_INIT_LLM_PROVIDER=anthropic \ - POWERMEM_INIT_LLM_MODEL=claude-sonnet-4-6 \ + POWERMEM_INIT_LLM_MODEL=anthropic/claude-sonnet-4.6 \ POWERMEM_INIT_LLM_API_KEY=... \ sh "$CLAUDE_PLUGIN_ROOT/scripts/init.sh" ``` + For a bearer-token gateway, use: + + ```bash + POWERMEM_INIT_LLM_PROVIDER=anthropic \ + POWERMEM_INIT_LLM_MODEL=anthropic/claude-sonnet-4.6 \ + POWERMEM_INIT_LLM_AUTH_TOKEN=... \ + POWERMEM_INIT_LLM_BASE_URL=https://your-gateway.example.com \ + sh "$CLAUDE_PLUGIN_ROOT/scripts/init.sh" + ``` + Optional variables: - `POWERMEM_INIT_LLM_BASE_URL` for a custom provider gateway. - `POWERMEM_INIT_PACKAGE` to test unpublished backend code instead of PyPI @@ -91,7 +107,8 @@ sh "$CLAUDE_PLUGIN_ROOT/scripts/..." - `POWERMEM_INIT_PORT` to force the managed server port. - `POWERMEM_INIT_PRELOAD_MODEL=1` to pre-download the default local `all-MiniLM-L6-v2` embedding model before starting the server. -5. Never print API keys. Mask any secret in summaries. +5. Never print API keys, auth tokens, or other credentials. Mask any secret in + summaries. 6. After init succeeds, run `sh "$CLAUDE_PLUGIN_ROOT/scripts/status.sh"` again and report the base URL. 7. The hook launcher reads `runtime.env`, so once init writes a base URL, prompt @@ -124,7 +141,8 @@ integration autonomously and ask me for any secret you need — never invent cre **🔒 DATA SAFETY — API Key Masking (MANDATORY):** When displaying ANY `.env` content — current values, proposed changes, confirmation -summaries, or any other output — you MUST mask `LLM_API_KEY` and any other secret +summaries, or any other output — you MUST mask `LLM_API_KEY`, `LLM_AUTH_TOKEN`, +and any other secret values (passwords, tokens, keys): - **Key ≥ 10 chars:** show only first 4 + last 4 characters (e.g. `sk-a…b12x`) - **Key < 10 chars:** show `***` @@ -136,7 +154,8 @@ the user and Claude Code cannot retroactively redact it. Follow these rules: `echo "${VAR:0:4}...${VAR: -4}"` to show first 4 + last 4, or `[ -n "$VAR" ] && echo "set" || echo "empty"` just to check existence. - When you need to `cat .env` or `grep` for secrets, pipe through sed to mask before printing: `cat .env | sed -E 's/(API_KEY=).*/\1***REDACTED***/'` -- Never run `echo $LLM_API_KEY`, `env | grep KEY`, `cat .env` (unmasked), or any command +- Never run `echo $LLM_API_KEY`, `echo $LLM_AUTH_TOKEN`, `env | grep KEY`, + `cat .env` (unmasked), or any command that would print a secret value directly to stdout. - Use `read` with `-s` (silent) when prompting for secrets interactively. @@ -210,7 +229,8 @@ writing. Never silently patch `.env`.** 4. `$POWERMEM_PYTHON -m pip install -q huggingface_hub` (non-CN model download dep) 2. COLLECT CONFIG (idempotent). If a .env already exists in the working directory - with LLM_PROVIDER / LLM_API_KEY / LLM_MODEL set to real values (not placeholders + with LLM_PROVIDER / LLM_API_KEY or LLM_AUTH_TOKEN / LLM_MODEL set to real + values (not placeholders like `your_api_key_here`), REUSE it — skip directly to step 3a/3b. Only collect what is missing. Use zero-config defaults for everything else (storage = embedded seekdb, embedder = local all-MiniLM-L6-v2) unless I say otherwise. @@ -219,58 +239,69 @@ writing. Never silently patch `.env`.** | Option | Description | |--------|-------------| - | Yes, auto-detect | Auto-detect LLM config (priority: OS env → `~/.claude/settings.json` → manual) | + | Yes, auto-detect | Auto-detect LLM config from the current process environment, then `~/.claude/settings.json`, then ask only for missing fields | If the user selects "Yes, auto-detect" (or "Other" and types "yes"/"auto"): - **Auto-detection priority chain** — same order Claude Code uses internally: - 1. **OS environment variables** (highest priority) — check these first: - - `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `OPENAI_API_KEY` / `DEEPSEEK_API_KEY` - - `ANTHROPIC_MODEL` / `OPENAI_MODEL` / `DEEPSEEK_MODEL` - - `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` / `DEEPSEEK_BASE_URL` - 2. **`~/.claude/settings.json`** — fall back if env vars are missing + **Auto-detection priority chain**: + 1. **Current process environment variables** — check these first: + - `ANTHROPIC_API_KEY` + - `ANTHROPIC_AUTH_TOKEN` + - `ANTHROPIC_BASE_URL` + - `ANTHROPIC_MODEL` + 2. **`~/.claude/settings.json`** — use `env.ANTHROPIC_*`, `env.LLM_*`, and + top-level `model` as fallback sources 3. **Manual input** — ask only for fields that are still missing + PowerMem supports Claude Code's Anthropic API-key path and bearer-token gateway + path. Do not treat `ANTHROPIC_AUTH_TOKEN` as `LLM_API_KEY`; copy it to + `LLM_AUTH_TOKEN` and require `ANTHROPIC_BASE_URL`. A token without a base URL + is incomplete; fall back to API-key mode or ask for the base URL instead of + sending the token to Anthropic's default endpoint. Do not migrate + `CLAUDE_CODE_OAUTH_TOKEN`, `/login` credentials, `apiKeyHelper`, Bedrock, + Vertex, or Foundry as either `LLM_API_KEY` or `LLM_AUTH_TOKEN`. + **Step 1 — Check OS environment variables.** Run these checks silently (do not print the values, only note whether each field was found): | Field | Check | |-------|-------| - | LLM_PROVIDER | If `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` is set → `anthropic`; elif `OPENAI_API_KEY` → `openai`; elif `DEEPSEEK_API_KEY` → `deepseek` | - | LLM_MODEL | `$ANTHROPIC_MODEL` or `$OPENAI_MODEL` or `$DEEPSEEK_MODEL` (provider-specific) | - | LLM_API_KEY | `$ANTHROPIC_API_KEY` or `$ANTHROPIC_AUTH_TOKEN` or `$OPENAI_API_KEY` or `$DEEPSEEK_API_KEY` | - | LLM_BASE_URL | `$ANTHROPIC_BASE_URL` or `$OPENAI_BASE_URL` or `$DEEPSEEK_BASE_URL` | - - **Step 2 — If any field is still missing, read `~/.claude/settings.json`.** - - **Model and provider** — read `env.ANTHROPIC_MODEL` (Claude Code's standard model - key); fall back to the top-level `model` field if absent. Both use the format - `/` — split on the first `/`: - - `"deepseek/deepseek-v4-pro"` → `LLM_PROVIDER=deepseek`, `LLM_MODEL=deepseek-v4-pro` - - `"anthropic/claude-sonnet-4-6"` → `LLM_PROVIDER=anthropic`, `LLM_MODEL=claude-sonnet-4-6` - - If neither field is present or has no `/`, leave the field unset — it will - be collected manually in Step 3. - - ⚠️ **Anthropic model name normalization**: Claude Code's `settings.json` uses - **dots** for version numbers (e.g. `claude-sonnet-4.6`), but the Anthropic API - requires **dashes** (e.g. `claude-sonnet-4-6`). After splitting on `/`, if - `LLM_PROVIDER=anthropic`, replace every `.` with `-` in the version suffix of - `LLM_MODEL`. Rule: `claude--.` → `claude---`. - Example: `claude-sonnet-4.6` → `claude-sonnet-4-6`, `claude-haiku-4.5` → `claude-haiku-4-5`. - - **API key** — Claude Code always stores its credentials under `ANTHROPIC_*` keys - regardless of the actual model or provider. Read directly: - - `env.ANTHROPIC_AUTH_TOKEN` (preferred) or `env.ANTHROPIC_API_KEY` + | LLM_PROVIDER | If `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` is set → `anthropic`; otherwise infer from `ANTHROPIC_MODEL` prefix if present | + | LLM_MODEL | `$ANTHROPIC_MODEL` | + | LLM_API_KEY | `$ANTHROPIC_API_KEY` | + | LLM_AUTH_TOKEN | `$ANTHROPIC_AUTH_TOKEN`, only when `ANTHROPIC_API_KEY` is absent | + | LLM_BASE_URL | `$ANTHROPIC_BASE_URL` | + + If environment model detection fails, read `env.ANTHROPIC_MODEL`, + `env.LLM_MODEL`, or top-level `model` from `~/.claude/settings.json`. Keep + the model exactly as configured. Do not strip a `/` prefix and do + not rewrite dotted versions: + - `"anthropic/claude-opus-4.6"` → `LLM_PROVIDER=anthropic`, `LLM_MODEL=anthropic/claude-opus-4.6` + - `"anthropic/claude-sonnet-4.6"` → `LLM_PROVIDER=anthropic`, `LLM_MODEL=anthropic/claude-sonnet-4.6` + + **Credentials** — preserve PowerMem's environment precedence: API key first, + bearer-token gateway second. If environment credentials are incomplete, fall + back to the old `~/.claude/settings.json` credential flow. Read: + - `ANTHROPIC_API_KEY` + - `ANTHROPIC_AUTH_TOKEN`, only if `ANTHROPIC_API_KEY` is absent + - fallback `env.ANTHROPIC_AUTH_TOKEN` / `env.ANTHROPIC_API_KEY` from + `~/.claude/settings.json` **Base URL** — read directly: - - `env.ANTHROPIC_BASE_URL` (if absent, leave blank — PowerMem will use the - provider's default endpoint) - - **Step 3 — For any fields still missing after Steps 1-2,** ask as a plain chat - question (one at a time, per 2b–2e below). Only ask for what is actually missing. - - After all three steps, show a **masked** summary of the merged result (per - 🔒 DATA SAFETY rules), noting the source of each field (env / settings.json / manual). + - `ANTHROPIC_BASE_URL` + - fallback `env.ANTHROPIC_BASE_URL` or `env.LLM_BASE_URL` from + `~/.claude/settings.json` + - If `ANTHROPIC_AUTH_TOKEN` is used, `ANTHROPIC_BASE_URL` is required. + - If `ANTHROPIC_API_KEY` is used and the base URL is absent, leave it blank — + PowerMem will use the provider's default endpoint. + + **Step 3 — For any fields still missing after environment and settings + detection,** ask as a plain chat question (one at a time, per 2b–2e below). + Only ask for what is actually missing. + + After detection/manual input, show a **masked** summary of the merged result + (per 🔒 DATA SAFETY rules), noting the source of each field + (env / settings.json / manual). Then jump to **2f**. If the user does NOT select auto-detect, fall back to the manual flow: @@ -279,7 +310,8 @@ writing. Never silently patch `.env`.** **2c.** Ask: "What provider id? (e.g. openai, anthropic, qwen, deepseek, ollama)" - **2d.** Ask: "Please paste your API key." — skip if provider is `ollama` or `vllm`. + **2d.** Ask for the credential: API key for direct provider access, or auth + token for an Anthropic-compatible gateway. Skip if provider is `ollama` or `vllm`. **2e.** Ask: "Which model? (e.g. gpt-4o-mini, claude-sonnet-4-6, qwen-plus)" @@ -288,7 +320,8 @@ writing. Never silently patch `.env`.** **2f. Confirm and write.** Show a **masked** summary of what will be written (per 🔒 DATA SAFETY rules above), then WAIT for explicit "yes" before writing. Copy - `.env.example` if `.env` does not exist, then fill `LLM_PROVIDER` / `LLM_API_KEY` + `.env.example` if `.env` does not exist, then fill `LLM_PROVIDER` / + `LLM_API_KEY` or `LLM_AUTH_TOKEN` / `LLM_MODEL`. If a base URL was given, write it to the provider-prefixed `*_LLM_BASE_URL` (e.g. `OPENAI_LLM_BASE_URL`) — verify spelling against `.env.example.full`; a typo is silently ignored. @@ -524,7 +557,7 @@ writing. Never silently patch `.env`.** sed -n '1,40p' /tmp/powermem-mcp.stdout Interpret the output: - stderr has import errors: install/repair the package or virtual environment. - - stderr has missing LLM/API key/config errors: fix `.env` after asking the + - stderr has missing LLM/API key/auth token/config errors: fix `.env` after asking the user for approval; never silently patch `.env`. - stderr has model download or network timeouts: pre-download the model or switch to a configured remote embedder. @@ -613,9 +646,12 @@ make build-claude-hook #### [E005] Storage Backend Initialization **Problem**: 503 errors on API calls despite server health -**Fix**: Use SQLite alternative +**Fix**: the Claude Code plugin defaults to embedded OceanBase/seekdb. Stop the +managed server, remove stale seekdb data, and restart init: ```bash -STORAGE_TYPE=sqlite SQLITE_DB_PATH=sqlite_data/powermem.db powermem-server --host 0.0.0.0 --port 8848 & +sh "$CLAUDE_PLUGIN_ROOT/scripts/stop.sh" +rm -rf "$HOME/.powermem/seekdb_data" +sh "$CLAUDE_PLUGIN_ROOT/scripts/init.sh" ``` #### [E006] Model Download Timeout diff --git a/apps/claude-code-plugin/UNINSTALL.md b/apps/claude-code-plugin/UNINSTALL.md index 62b491d5b..d6790c632 100644 --- a/apps/claude-code-plugin/UNINSTALL.md +++ b/apps/claude-code-plugin/UNINSTALL.md @@ -42,7 +42,7 @@ confirmation — those steps are gated below. PID=$(lsof -t -i:8848 2>/dev/null); [ -n "$PID" ] && { kill "$PID" 2>/dev/null; sleep 2; kill -9 "$PID" 2>/dev/null; }; true Then confirm nothing answers (either branch is fine, never errors): curl -s -m 3 http://localhost:8848/api/v1/system/health >/dev/null 2>&1 && echo "still up" || echo "server down" - Also remove a stale PID file if present: rm -f .server.pid 2>/dev/null || true + Also remove stale PID files if present: rm -f ~/.powermem/powermem.pid ~/.powermem/server.pid .server.pid 2>/dev/null || true 3a. SOURCE path — remove the global plugin + marketplace (idempotent): - Disable then uninstall the plugin (skip silently if not installed): diff --git a/apps/claude-code-plugin/hooks/run-hook.sh b/apps/claude-code-plugin/hooks/run-hook.sh index 253935d7c..b4f002fea 100644 --- a/apps/claude-code-plugin/hooks/run-hook.sh +++ b/apps/claude-code-plugin/hooks/run-hook.sh @@ -2,7 +2,7 @@ # Select the correct native binary for macOS / Linux. Pass-through args (e.g. "poll" for file watcher). ROOT=$(CDPATH= cd -- "$(dirname "$0")" && pwd) PLUGIN_ROOT=$(CDPATH= cd -- "$ROOT/.." && pwd) -DATA_DIR="$HOME/.powermem" +DATA_DIR="${POWERMEM_DATA_DIR:-$HOME/.powermem}" if [ -f "$DATA_DIR/runtime.env" ]; then # shellcheck disable=SC1090 . "$DATA_DIR/runtime.env" diff --git a/apps/claude-code-plugin/init-flow.png b/apps/claude-code-plugin/init-flow.png new file mode 100644 index 000000000..d81aae94b Binary files /dev/null and b/apps/claude-code-plugin/init-flow.png differ diff --git a/apps/claude-code-plugin/init-flow.svg b/apps/claude-code-plugin/init-flow.svg new file mode 100644 index 000000000..025511671 --- /dev/null +++ b/apps/claude-code-plugin/init-flow.svg @@ -0,0 +1,300 @@ + + PowerMem Claude Code 插件 init 与 hook 流程 + init.sh 负责确保可用的 PowerMem HTTP 服务,并把服务地址写入 runtime.env。hook 只读取 POWERMEM_BASE_URL 并调用服务。 + + + + + + + + + + init.sh:确保 PowerMem 服务可用,并配置 POWERMEM_BASE_URL + 主线是 init 显式执行;hook 不会启动 server,只读取地址并调用 HTTP API。 + + + init.sh:准备、校验、复用或启动 PowerMem 后端服务 + + + 显式运行 init.sh + 手动执行,或通过 /memory-powermem:init 执行 + + + 注意 + hook 不触发 init.sh + hook 也不会自动启动 server + + + + 健康服务地址汇流 + + + + 子流程 A:准备 ~/.powermem/.env + + + 检查 ~/.powermem/.env 是否存在 + 默认数据目录是 ~/.powermem,可用 POWERMEM_DATA_DIR 覆盖 + + + 已存在:复用 + init 不覆盖已有配置 + + + 不存在:生成 + 先读当前进程环境变量 + 失败后用 ~/.claude/settings.json 兜底 + + + + 子流程 B:已有 .env 时,先检查是否有可复用的 managed server + + + 1. 读取 managed PID + ~/.powermem/powermem.pid,兼容旧 server.pid + + + 2. 校验 PID 指向的进程 + 进程存在,命令包含 powermem-server + + + 3. 校验对应当前 .env + Linux 且 /proc 可读时,检查 POWERMEM_ENV_FILE + + + managed 服务健康? + GET /api/v1/system/health + + + 健康:复用 + 从进程参数恢复 host / port + + + 不健康或不匹配 + 停止 unhealthy managed server + 清理 managed PID 后继续 + + + + 子流程 C:校验 LLM 配置 + + + 1. 读取 ~/.powermem/.env + LLM_PROVIDER / LLM_MODEL + LLM_API_KEY 或 LLM_AUTH_TOKEN + + + 2. 决定是否远程试探 + ollama、vllm 或无凭证时跳过请求 + + + 3. 对支持的 provider 发起最小请求 + 失败则 init 退出,不继续启动 server + + + 校验失败 + 提示 provider / model + API key、auth token 或 base URL 后退出 + + + + 子流程 D:准备本机运行环境 + + + 1. 检查 ~/.powermem/venv + 缺少 venv 时用 bootstrap Python 创建 + + + 2. 确保 venv 里有 powermem-server + 已有可执行文件则复用,不重装 + 缺少时安装 powermem[server,seekdb] + + + 3. pip install 镜像选择 + 公网 IP 检测为 CN 时追加 + -i https://pypi.tuna.tsinghua.edu.cn/simple + + + 4. 可选预下载 embedding 模型 + POWERMEM_INIT_PRELOAD_MODEL=1 + preload-model.sh 安装 modelscope 时沿用镜像 + + + 输出:本机已有可执行的 powermem-server,可用于启动 managed 服务 + + + + 子流程 E:选择要给 hook 使用的健康服务 + + + 1. 再次检查 managed server + 安装期间如果服务已恢复,可直接复用 + + + 2. 检查外部候选 base URL + POWERMEM_BASE_URL > runtime.env > 默认 8848 + 健康则复用,不启动 managed server + + + 找到健康服务? + managed 或外部 backend + + + 是:复用服务地址 + 不启动新的 server + + + 否:启动新的 + 进入子流程 F + + + + 子流程 F:启动新的 managed powermem-server + + + 1. 选择端口 + POWERMEM_INIT_PORT -> 8848 -> 8849-8899 + + + 2. 使用当前 .env 启动 + POWERMEM_ENV_FILE=~/.powermem/.env + nohup powermem-server --host 127.0.0.1 --port <port> + + + 3. 记录 managed PID + 写 ~/.powermem/powermem.pid + + + 120 秒内 health 通过? + GET /api/v1/system/health + + + 失败 + 停止刚启动的 server + 清理 PID,不写 runtime.env + + + + 子流程 G:配置 POWERMEM_BASE_URL + + + 写 ~/.powermem/runtime.env + POWERMEM_BASE_URL=http://host:port + 同时记录 POWERMEM_ENV_FILE=~/.powermem/.env + + + + hook:读取 POWERMEM_BASE_URL 并调用服务 + + + 1. Claude Code 触发 hook + UserPromptSubmit / SessionEnd / PostCompact + + + 2. run-hook.sh 读取地址配置 + $POWERMEM_DATA_DIR/runtime.env + 然后读取 plugin config/runtime.env + + + 3. hook binary 决定 base URL + 优先用 POWERMEM_BASE_URL + 未设置则默认 http://localhost:8848 + + + 4. 调用 powermem-server + 搜索上下文或写入记忆 + + + + + + + + + + + + + 存在 + + 不存在 + + + + + + + + + + 健康 + + 失败 + + + + + + 失败 + + + + + + + + + + + + + + + + + + + + + + + + 通过 + + 失败 + + + + + + + runtime.env 提供 POWERMEM_BASE_URL + + + + + + diff --git a/apps/claude-code-plugin/scripts/common.sh b/apps/claude-code-plugin/scripts/common.sh index 1f3d30c44..0cff70f64 100755 --- a/apps/claude-code-plugin/scripts/common.sh +++ b/apps/claude-code-plugin/scripts/common.sh @@ -8,12 +8,13 @@ powermem_data_dir() { printf '%s\n' "$HOME/.powermem" } -DATA_DIR=$(powermem_data_dir) -ENV_FILE="$DATA_DIR/.env" -RUNTIME_FILE="$DATA_DIR/runtime.env" -PID_FILE="$DATA_DIR/server.pid" -LOG_FILE="$DATA_DIR/powermem-server.log" -VENV_DIR="$DATA_DIR/venv" +DATA_DIR="${POWERMEM_DATA_DIR:-$(powermem_data_dir)}" +ENV_FILE="${POWERMEM_ENV_FILE:-$DATA_DIR/.env}" +RUNTIME_FILE="${POWERMEM_RUNTIME_FILE:-$DATA_DIR/runtime.env}" +PID_FILE="${POWERMEM_PID_FILE:-$DATA_DIR/powermem.pid}" +LEGACY_PID_FILE="$DATA_DIR/server.pid" +LOG_FILE="${POWERMEM_LOG_FILE:-$DATA_DIR/powermem-server.log}" +VENV_DIR="${POWERMEM_VENV_DIR:-$DATA_DIR/venv}" mkdir -p "$DATA_DIR" @@ -82,10 +83,101 @@ is_healthy() { } pid_alive() { - [ -f "$PID_FILE" ] || return 1 - pid=$(cat "$PID_FILE" 2>/dev/null || true) - [ -n "$pid" ] || return 1 - kill -0 "$pid" 2>/dev/null + pid=$(managed_pid) || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + pid_is_powermem_server "$pid" || return 1 + pid_uses_env_file "$pid" || return 1 +} + +managed_pid_file() { + if [ -f "$PID_FILE" ]; then + printf '%s\n' "$PID_FILE" + return + fi + if [ -z "${POWERMEM_PID_FILE:-}" ] && [ -f "$LEGACY_PID_FILE" ]; then + printf '%s\n' "$LEGACY_PID_FILE" + return + fi + printf '%s\n' "$PID_FILE" +} + +managed_pid() { + file=$(managed_pid_file) + [ -f "$file" ] || return 1 + pid=$(cat "$file" 2>/dev/null | tr -d '[:space:]' || true) + case "$pid" in + ""|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$pid" +} + +write_managed_pid() { + printf '%s\n' "$1" > "$PID_FILE" + if [ -z "${POWERMEM_PID_FILE:-}" ] && [ "$LEGACY_PID_FILE" != "$PID_FILE" ]; then + rm -f "$LEGACY_PID_FILE" 2>/dev/null || true + fi +} + +remove_managed_pid_files() { + file=$(managed_pid_file) + rm -f "$file" 2>/dev/null || true + if [ -z "${POWERMEM_PID_FILE:-}" ]; then + rm -f "$PID_FILE" "$LEGACY_PID_FILE" 2>/dev/null || true + fi +} + +pid_is_powermem_server() { + pid=$1 + args=$(process_args "$pid") + [ -n "$args" ] || return 0 + printf '%s\n' "$args" | grep -q 'powermem-server' +} + +process_args() { + ps -p "$1" -o args= 2>/dev/null || ps -p "$1" -o command= 2>/dev/null || true +} + +managed_base_url() { + pid=$(managed_pid) || return 1 + args=$(process_args "$pid") + [ -n "$args" ] || return 1 + + host=127.0.0.1 + port= + next= + for arg in $args; do + if [ "$next" = "host" ]; then + host=$arg + next= + continue + fi + if [ "$next" = "port" ]; then + port=$arg + next= + continue + fi + case "$arg" in + --host=*) host=${arg#--host=} ;; + --host) next=host ;; + --port=*) port=${arg#--port=} ;; + --port) next=port ;; + esac + done + + case "$port" in + ""|*[!0-9]*) return 1 ;; + esac + case "$host" in + ""|"0.0.0.0"|"::"|"[::]") host=localhost ;; + esac + printf 'http://%s:%s\n' "$host" "$port" +} + +pid_uses_env_file() { + pid=$1 + environ="/proc/$pid/environ" + [ -r "$environ" ] || return 0 + tr '\000' '\n' < "$environ" | grep -Fx "POWERMEM_ENV_FILE=$ENV_FILE" >/dev/null } venv_python() { diff --git a/apps/claude-code-plugin/scripts/init.sh b/apps/claude-code-plugin/scripts/init.sh index a49f82d9a..4e1c7e3c2 100755 --- a/apps/claude-code-plugin/scripts/init.sh +++ b/apps/claude-code-plugin/scripts/init.sh @@ -18,101 +18,387 @@ export POWERMEM_BOOTSTRAP_PYTHON=$BOOTSTRAP_PYTHON echo "Bootstrap Python: $BOOTSTRAP_PYTHON ($(python_version "$BOOTSTRAP_PYTHON"))" create_env_file() { - python3 - "$ENV_FILE" <<'PY' + "$BOOTSTRAP_PYTHON" - "$ENV_FILE" "$DATA_DIR" <<'PY' import json import os -import re import sys from pathlib import Path env_path = Path(sys.argv[1]) +data_dir = Path(sys.argv[2]).expanduser() def read_claude_settings(): path = Path.home() / ".claude" / "settings.json" if not path.is_file(): return {} try: - return json.loads(path.read_text()) - except Exception: + loaded = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + print( + f"Warning: failed to read Claude settings {path}: {exc}; " + "ignoring settings fallback.", + file=sys.stderr, + ) return {} - -def normalize_model(provider, model): - if provider == "anthropic": - return re.sub(r"(claude-[A-Za-z]+-\d+)\.(\d+)", r"\1-\2", model) - return model + return loaded if isinstance(loaded, dict) else {} + +def env_first(*names): + value, _ = env_first_with_source(*names) + return value + +def env_first_with_source(*names): + for name in names: + value = os.environ.get(name) + if value is not None and value.strip(): + return value.strip(), f"env:{name}" + return "", "" + +def settings_first(settings_env, *names): + value, _ = settings_first_with_source(settings_env, *names) + return value + +def settings_first_with_source(settings_env, *names): + for name in names: + value = settings_env.get(name) + if isinstance(value, str) and value.strip(): + return value.strip(), f"settings.env:{name}" + return "", "" + +def first_value(*items): + for value, source in items: + if value: + return value, source + return "", "" + +def path_value(*parts): + return str(data_dir.joinpath(*parts)) + +def provider_base_url_from_env(provider): + value, _ = provider_base_url_from_env_source(provider) + return value + +def provider_base_url_from_env_source(provider): + provider_base_envs = { + "anthropic": ("ANTHROPIC_BASE_URL",), + "openai": ("OPENAI_BASE_URL",), + "qwen": ("QWEN_BASE_URL",), + "siliconflow": ("SILICONFLOW_BASE_URL",), + "deepseek": ("DEEPSEEK_BASE_URL",), + } + return env_first_with_source(*provider_base_envs.get(provider, ())) + +def provider_base_url_from_settings(settings_env, provider): + value, _ = provider_base_url_from_settings_source(settings_env, provider) + return value + +def provider_base_url_from_settings_source(settings_env, provider): + provider_base_keys = { + "anthropic": ("ANTHROPIC_BASE_URL",), + "openai": ("OPENAI_BASE_URL",), + "qwen": ("QWEN_BASE_URL",), + "siliconflow": ("SILICONFLOW_BASE_URL",), + "deepseek": ("DEEPSEEK_BASE_URL",), + } + return settings_first_with_source(settings_env, *(provider_base_keys.get(provider, ()) + ("LLM_BASE_URL",))) settings = read_claude_settings() settings_env = settings.get("env") if isinstance(settings.get("env"), dict) else {} -raw_model = ( - os.environ.get("POWERMEM_INIT_LLM_MODEL") - or settings_env.get("ANTHROPIC_MODEL") - or settings.get("model") - or "" -).strip() - -# API key detection drives provider selection. First match wins. -KEY_SOURCES = [ - ("POWERMEM_INIT_LLM_API_KEY", None), - ("ANTHROPIC_AUTH_TOKEN", "anthropic"), - ("ANTHROPIC_API_KEY", "anthropic"), -] +settings_model = settings.get("model") if isinstance(settings.get("model"), str) else "" +raw_model, model_source = first_value( + env_first_with_source("POWERMEM_INIT_LLM_MODEL", "LLM_MODEL", "ANTHROPIC_MODEL"), + settings_first_with_source(settings_env, "ANTHROPIC_MODEL", "LLM_MODEL"), + (settings_model.strip(), "settings.model" if settings_model.strip() else ""), +) +raw_model = raw_model.strip() + +explicit_base_url, explicit_base_url_source = env_first_with_source("POWERMEM_INIT_LLM_BASE_URL", "LLM_BASE_URL") +env_api_key, env_api_key_source = first_value( + env_first_with_source("POWERMEM_INIT_LLM_API_KEY", "LLM_API_KEY"), + env_first_with_source("ANTHROPIC_API_KEY"), +) +env_auth_token, env_auth_token_source = first_value( + env_first_with_source("POWERMEM_INIT_LLM_AUTH_TOKEN", "LLM_AUTH_TOKEN"), + env_first_with_source("ANTHROPIC_AUTH_TOKEN"), +) +env_anthropic_base_url, env_anthropic_base_url_source = env_first_with_source("ANTHROPIC_BASE_URL") +env_auth_base_url, env_auth_base_url_source = first_value( + (explicit_base_url, explicit_base_url_source), + (env_anthropic_base_url, env_anthropic_base_url_source), +) +settings_api_key, settings_api_key_source = settings_first_with_source(settings_env, "ANTHROPIC_API_KEY") +settings_auth_token, settings_auth_token_source = settings_first_with_source(settings_env, "ANTHROPIC_AUTH_TOKEN") +settings_auth_base_value, settings_auth_base_source = settings_first_with_source( + settings_env, + "ANTHROPIC_BASE_URL", + "LLM_BASE_URL", +) +settings_auth_base_url, settings_auth_base_url_source = first_value( + (explicit_base_url, explicit_base_url_source), + (settings_auth_base_value, settings_auth_base_source), +) + api_key = "" -key_provider = "" -for env_var, implied_provider in KEY_SOURCES: - val = (os.environ.get(env_var) or settings_env.get(env_var) or "").strip() - if val: - api_key = val - if implied_provider: - key_provider = implied_provider - break +api_key_source = "" +auth_token = "" +auth_token_source = "" +base_url = "" +base_url_source = "" +credential_source_group = "" +if env_api_key: + api_key = env_api_key + api_key_source = env_api_key_source + credential_source_group = "env" +elif env_auth_token and env_auth_base_url: + auth_token = env_auth_token + auth_token_source = env_auth_token_source + base_url = env_auth_base_url + base_url_source = env_auth_base_url_source + credential_source_group = "env" +elif settings_api_key: + api_key = settings_api_key + api_key_source = settings_api_key_source + credential_source_group = "settings" +elif settings_auth_token and settings_auth_base_url: + auth_token = settings_auth_token + auth_token_source = settings_auth_token_source + base_url = settings_auth_base_url + base_url_source = settings_auth_base_url_source + credential_source_group = "settings" +elif env_auth_token: + auth_token = env_auth_token + auth_token_source = env_auth_token_source + credential_source_group = "env" +elif settings_auth_token: + auth_token = settings_auth_token + auth_token_source = settings_auth_token_source + credential_source_group = "settings" + +key_provider = "anthropic" if auth_token or api_key else "" model_prefix = raw_model.split("/", 1)[0].strip().lower() if "/" in raw_model else "" -provider = ( - os.environ.get("POWERMEM_INIT_LLM_PROVIDER", "").strip().lower() - or key_provider - or model_prefix +explicit_provider, explicit_provider_source = env_first_with_source("POWERMEM_INIT_LLM_PROVIDER", "LLM_PROVIDER") +settings_provider, settings_provider_source = settings_first_with_source(settings_env, "LLM_PROVIDER") +provider_candidates = [ + (explicit_provider, explicit_provider_source), +] +if credential_source_group != "env": + provider_candidates.append((settings_provider, settings_provider_source)) +provider_candidates.extend( + [ + (key_provider, "inferred:anthropic credential" if key_provider else ""), + (model_prefix, "inferred:LLM model prefix" if model_prefix else ""), + ] ) -# Strip provider prefix from raw model name (e.g. "anthropic/claude-sonnet-4-6" -> "claude-sonnet-4-6") -raw_model_clean = raw_model.split("/", 1)[1].strip() if "/" in raw_model else raw_model -model = os.environ.get("POWERMEM_INIT_LLM_MODEL", raw_model_clean).strip() -if provider and model: - model = normalize_model(provider, model) - -base_url = ( - os.environ.get("POWERMEM_INIT_LLM_BASE_URL") - or settings_env.get("ANTHROPIC_BASE_URL") - or "" -).strip() +provider, provider_source = first_value(*provider_candidates) +provider = provider.lower() + +model = raw_model + +if not base_url and not auth_token: + base_url = explicit_base_url + base_url_source = explicit_base_url_source +if not base_url and not auth_token: + if credential_source_group == "env": + base_url, base_url_source = provider_base_url_from_env_source(provider) + elif credential_source_group == "settings": + base_url, base_url_source = provider_base_url_from_settings_source(settings_env, provider) + else: + base_url, base_url_source = provider_base_url_from_env_source(provider) + if not base_url: + base_url, base_url_source = provider_base_url_from_settings_source(settings_env, provider) +base_url = base_url.strip() missing = [] if not provider: missing.append("POWERMEM_INIT_LLM_PROVIDER") if not model: missing.append("POWERMEM_INIT_LLM_MODEL") -if provider not in {"ollama", "vllm"} and not api_key: - missing.append("POWERMEM_INIT_LLM_API_KEY") +if provider not in {"ollama", "vllm"}: + if provider == "anthropic": + if not auth_token and not api_key: + missing.append( + "ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL, " + "ANTHROPIC_API_KEY, or POWERMEM_INIT_LLM_API_KEY" + ) + if auth_token and not base_url: + missing.append("ANTHROPIC_BASE_URL or POWERMEM_INIT_LLM_BASE_URL") + elif not api_key: + missing.append("POWERMEM_INIT_LLM_API_KEY or LLM_API_KEY") if missing: print("Missing configuration: " + ", ".join(missing), file=sys.stderr) print("Run init again with these environment variables set.", file=sys.stderr) sys.exit(2) +embedding_provider = env_first("POWERMEM_INIT_EMBEDDING_PROVIDER", "EMBEDDING_PROVIDER") or "default" +embedding_provider = embedding_provider.lower() + +embedding_model_defaults = { + "default": "all-MiniLM-L6-v2", + "qwen": "text-embedding-v4", + "openai": "text-embedding-3-small", + "siliconflow": "BAAI/bge-m3", + "ollama": "nomic-embed-text", + "lmstudio": "text-embedding-nomic-embed-text-v1.5", +} +embedding_dim_defaults = { + "default": "384", + "qwen": "1536", + "openai": "1536", + "siliconflow": "1024", + "ollama": "768", + "lmstudio": "768", +} +embedding_model = ( + env_first("POWERMEM_INIT_EMBEDDING_MODEL", "EMBEDDING_MODEL") + or embedding_model_defaults.get(embedding_provider, embedding_model_defaults["default"]) +) +embedding_dims = ( + env_first("POWERMEM_INIT_EMBEDDING_DIMS", "EMBEDDING_DIMS") + or embedding_dim_defaults.get(embedding_provider, embedding_dim_defaults["default"]) +) +embedding_api_key = env_first("POWERMEM_INIT_EMBEDDING_API_KEY", "EMBEDDING_API_KEY") +if not embedding_api_key: + if embedding_provider == "qwen": + embedding_api_key = env_first("QWEN_API_KEY", "DASHSCOPE_API_KEY") or settings_first(settings_env, "QWEN_API_KEY", "DASHSCOPE_API_KEY") + elif embedding_provider == "openai": + embedding_api_key = env_first("OPENAI_API_KEY") or settings_first(settings_env, "OPENAI_API_KEY") + elif embedding_provider == "siliconflow": + embedding_api_key = env_first("SILICONFLOW_API_KEY") or settings_first(settings_env, "SILICONFLOW_API_KEY") + +if embedding_provider not in {"default", "ollama", "lmstudio"} and not embedding_api_key: + print( + "Missing configuration: POWERMEM_INIT_EMBEDDING_API_KEY " + f"for EMBEDDING_PROVIDER={embedding_provider}", + file=sys.stderr, + ) + sys.exit(2) + +server_port = env_first("POWERMEM_SERVER_PORT", "POWERMEM_INIT_PORT") or "8848" +server_host = env_first("POWERMEM_SERVER_HOST") or "127.0.0.1" +server_workers = env_first("POWERMEM_SERVER_WORKERS") or "1" +server_log_file = env_first("POWERMEM_SERVER_LOG_FILE") or path_value("powermem-server.log") +logging_level = env_first("LOGGING_LEVEL") or "INFO" + lines = [ "# Generated by the PowerMem Claude Code plugin.", + "", + "# Core paths", + f"POWERMEM_DATA_DIR={data_dir}", + "", + "# Database: embedded seekdb through the OceanBase provider", + "DATABASE_PROVIDER=oceanbase", + "OCEANBASE_HOST=", + f"OCEANBASE_PATH={path_value('seekdb_data')}", + "OCEANBASE_PORT=2881", + "OCEANBASE_USER=root@sys", + "OCEANBASE_PASSWORD=", + "OCEANBASE_DATABASE=powermem", + "OCEANBASE_COLLECTION=memories", + "OCEANBASE_INDEX_TYPE=HNSW", + "OCEANBASE_VECTOR_METRIC_TYPE=cosine", + f"OCEANBASE_EMBEDDING_MODEL_DIMS={embedding_dims}", + "OCEANBASE_TEXT_FIELD=document", + "OCEANBASE_VECTOR_FIELD=embedding", + "OCEANBASE_PRIMARY_FIELD=id", + "OCEANBASE_METADATA_FIELD=metadata", + "OCEANBASE_VIDX_NAME=memories_vidx", + "OCEANBASE_INCLUDE_SPARSE=false", + "OCEANBASE_ENABLE_NATIVE_HYBRID=false", + "", + "# LLM", f"LLM_PROVIDER={provider}", f"LLM_MODEL={model}", ] if api_key: lines.append(f"LLM_API_KEY={api_key}") +if auth_token: + lines.append(f"LLM_AUTH_TOKEN={auth_token}") if base_url: key = f"{provider.upper()}_LLM_BASE_URL" if provider == "qwen": key = "QWEN_LLM_BASE_URL" lines.append(f"{key}={base_url}") +else: + key = "" + +lines.extend( + [ + "", + "# Embedding", + f"EMBEDDING_PROVIDER={embedding_provider}", + f"EMBEDDING_MODEL={embedding_model}", + f"EMBEDDING_DIMS={embedding_dims}", + ] +) +if embedding_api_key: + lines.append(f"EMBEDDING_API_KEY={embedding_api_key}") + +embedding_base_override = env_first("POWERMEM_INIT_EMBEDDING_BASE_URL", "EMBEDDING_BASE_URL") +embedding_base_keys = { + "qwen": "QWEN_EMBEDDING_BASE_URL", + "openai": "OPENAI_EMBEDDING_BASE_URL", + "siliconflow": "SILICONFLOW_EMBEDDING_BASE_URL", + "ollama": "OLLAMA_EMBEDDING_BASE_URL", + "lmstudio": "LMSTUDIO_EMBEDDING_BASE_URL", +} +if embedding_base_override and embedding_provider in embedding_base_keys: + lines.append(f"{embedding_base_keys[embedding_provider]}={embedding_base_override}") + +lines.extend( + [ + "# Server", + f"POWERMEM_SERVER_HOST={server_host}", + f"POWERMEM_SERVER_PORT={server_port}", + f"POWERMEM_SERVER_WORKERS={server_workers}", + "POWERMEM_SERVER_RELOAD=false", + "POWERMEM_SERVER_AUTH_ENABLED=false", + "POWERMEM_SERVER_API_KEYS=", + "POWERMEM_SERVER_RATE_LIMIT_ENABLED=true", + "POWERMEM_SERVER_RATE_LIMIT_PER_MINUTE=100", + f"POWERMEM_SERVER_LOG_FILE={server_log_file}", + "POWERMEM_SERVER_LOG_LEVEL=INFO", + "POWERMEM_SERVER_LOG_FORMAT=json", + "POWERMEM_SERVER_API_TITLE=PowerMem API", + "POWERMEM_SERVER_API_VERSION=v1", + "POWERMEM_SERVER_CORS_ENABLED=true", + "POWERMEM_SERVER_CORS_ORIGINS=*", + "", + "# Logging", + f"LOGGING_LEVEL={logging_level}", + "LOGGING_FORMAT=json", + "", + "# Vector store tuning", + "VECTOR_STORE_BATCH_SIZE=50", + "VECTOR_STORE_CACHE_SIZE=500", + "VECTOR_STORE_INDEX_REBUILD_INTERVAL=86400", + "", + "# Optional retrieval features", + "SPARSE_VECTOR_ENABLE=false", + "", + ] +) env_path.parent.mkdir(parents=True, exist_ok=True) -env_path.write_text("\n".join(lines) + "\n") -print(f"Wrote {env_path} with provider={provider}, model={model}, api_key={'set' if api_key else 'not-required'}") +env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") +print( + f"Wrote {env_path} with llm_provider={provider}, llm_model={model}, " + f"embedding_provider={embedding_provider}, embedding_model={embedding_model}, " + f"embedding_dims={embedding_dims}, server_port={server_port}" +) +print("LLM config sources:") +print(f" LLM_PROVIDER: {provider_source or 'not set'}") +print(f" LLM_MODEL: {model_source or 'not set'}") +if api_key: + print(f" LLM_API_KEY: {api_key_source or 'unknown'} (value hidden)") +elif auth_token: + print(f" LLM_AUTH_TOKEN: {auth_token_source or 'unknown'} (value hidden)") +else: + print(" LLM_API_KEY/LLM_AUTH_TOKEN: not required") +if key and base_url: + print(f" {key}: {base_url_source or 'unknown'} (value hidden)") +else: + print(" *_LLM_BASE_URL: not set") PY } @@ -128,40 +414,84 @@ for line in Path(sys.argv[1]).read_text().splitlines(): k, v = line.split('=', 1) env[k.strip()] = v.strip() +def env_value_with_source(*names): + for name in names: + value = env.get(name, '') + if value: + return value, f".env:{name}" + return '', '' + provider = env.get('LLM_PROVIDER', '') model = env.get('LLM_MODEL', '') -api_key = env.get('LLM_API_KEY', '') -base_url = env.get(f'{provider.upper()}_LLM_BASE_URL', '') if provider else '' - -if provider in {'ollama', 'vllm'} or not api_key: +api_key, api_key_source = env_value_with_source('LLM_API_KEY') +auth_token, auth_token_source = env_value_with_source('LLM_AUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN') +base_url_name = f'{provider.upper()}_LLM_BASE_URL' if provider else '' +base_url, base_url_source = env_value_with_source(base_url_name) if base_url_name else ('', '') +if provider == 'anthropic': + if not base_url: + base_url, base_url_source = env_value_with_source('ANTHROPIC_BASE_URL') + if api_key: + auth_token = '' + auth_token_source = '' + elif auth_token and not base_url: + print( + 'LLM validation failed: ANTHROPIC_AUTH_TOKEN/LLM_AUTH_TOKEN requires ' + 'ANTHROPIC_LLM_BASE_URL or ANTHROPIC_BASE_URL.', + file=sys.stderr, + ) + sys.exit(1) + +if provider in {'ollama', 'vllm'} or not (api_key or auth_token): sys.exit(0) +print("LLM validation sources:") +print(" LLM_PROVIDER: .env:LLM_PROVIDER") +print(" LLM_MODEL: .env:LLM_MODEL") +if api_key: + print(f" LLM_API_KEY: {api_key_source} (value hidden)") +elif auth_token: + print(f" {auth_token_source.rsplit(':', 1)[-1]}: {auth_token_source} (value hidden)") +if base_url: + print(f" {base_url_source.rsplit(':', 1)[-1]}: {base_url_source} (value hidden)") +else: + print(" *_LLM_BASE_URL: not set") + +def anthropic_headers(api_key, auth_token): + headers = { + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + } + if auth_token: + headers['authorization'] = f'Bearer {auth_token}' + else: + headers['x-api-key'] = api_key + return headers + CONFIGS = { 'anthropic': { 'url': lambda b: f"{b or 'https://api.anthropic.com'}/v1/messages", - 'headers': lambda k: {'x-api-key': k, 'anthropic-version': '2023-06-01', - 'content-type': 'application/json'}, + 'headers': lambda k, t: anthropic_headers(k, t), 'body': lambda m: {'model': m, 'max_tokens': 1, 'messages': [{'role': 'user', 'content': 'hi'}]}, }, 'openai': { 'url': lambda b: f"{b or 'https://api.openai.com'}/v1/chat/completions", - 'headers': lambda k: {'authorization': f'Bearer {k}', - 'content-type': 'application/json'}, + 'headers': lambda k, t: {'authorization': f'Bearer {k}', + 'content-type': 'application/json'}, 'body': lambda m: {'model': m, 'max_tokens': 1, 'messages': [{'role': 'user', 'content': 'hi'}]}, }, 'deepseek': { 'url': lambda b: f"{b or 'https://api.deepseek.com'}/v1/chat/completions", - 'headers': lambda k: {'authorization': f'Bearer {k}', - 'content-type': 'application/json'}, + 'headers': lambda k, t: {'authorization': f'Bearer {k}', + 'content-type': 'application/json'}, 'body': lambda m: {'model': m, 'max_tokens': 1, 'messages': [{'role': 'user', 'content': 'hi'}]}, }, 'qwen': { 'url': lambda b: f"{b or 'https://dashscope.aliyuncs.com/compatible-mode'}/v1/chat/completions", - 'headers': lambda k: {'authorization': f'Bearer {k}', - 'content-type': 'application/json'}, + 'headers': lambda k, t: {'authorization': f'Bearer {k}', + 'content-type': 'application/json'}, 'body': lambda m: {'model': m, 'max_tokens': 1, 'messages': [{'role': 'user', 'content': 'hi'}]}, }, @@ -173,7 +503,7 @@ if not cfg: sys.exit(0) url = cfg['url'](base_url) -headers = cfg['headers'](api_key) +headers = cfg['headers'](api_key, auth_token) body = json.dumps(cfg['body'](model)).encode() try: @@ -191,16 +521,115 @@ except Exception as e: PY } +detect_public_ip_country() { + "$BOOTSTRAP_PYTHON" - <<'PY' +import urllib.request + +endpoints = [ + "https://ipapi.co/country/", + "https://ifconfig.co/country-iso", + "https://ipinfo.io/country", +] + +for url in endpoints: + try: + req = urllib.request.Request(url, headers={"User-Agent": "powermem-init/1.0"}) + with urllib.request.urlopen(req, timeout=3) as resp: + value = resp.read(64).decode(errors="replace").strip().upper() + if len(value) == 2 and value.isalpha(): + print(value) + raise SystemExit(0) + except Exception: + pass + +raise SystemExit(1) +PY +} + +configure_pip_index() { + if [ "${POWERMEM_PIP_INDEX_CONFIGURED:-0}" = "1" ]; then + return + fi + POWERMEM_PIP_INDEX_CONFIGURED=1 + export POWERMEM_PIP_INDEX_CONFIGURED + + country=$(detect_public_ip_country || true) + case "$country" in + CN) + POWERMEM_PIP_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple" + export POWERMEM_PIP_INDEX_URL + echo "Detected public IP country: CN; pip install will append -i $POWERMEM_PIP_INDEX_URL" + ;; + "") + echo "Public IP country detection failed; pip install will use the default PyPI index." + ;; + *) + echo "Detected public IP country: $country; pip install will use the default PyPI index." + ;; + esac +} + +pip_install() { + if [ -n "${POWERMEM_PIP_INDEX_URL:-}" ]; then + "$PYTHON" -m pip install "$@" -i "$POWERMEM_PIP_INDEX_URL" + else + "$PYTHON" -m pip install "$@" + fi +} + +stop_unhealthy_managed_server() { + pid=$(managed_pid 2>/dev/null || true) + if [ -n "$pid" ]; then + echo "Stopping unhealthy managed powermem-server PID $pid." + kill "$pid" 2>/dev/null || true + sleep 2 + if kill -0 "$pid" 2>/dev/null; then + echo "Managed server PID $pid is still running; sending SIGKILL." + kill -9 "$pid" 2>/dev/null || true + fi + fi + remove_managed_pid_files +} + +announce_dashboard_url() { + case "$1" in + */) dashboard_url="${1}dashboard/" ;; + *) dashboard_url="${1}/dashboard/" ;; + esac + echo "Memory dashboard: $dashboard_url" +} + if [ ! -f "$ENV_FILE" ]; then - echo "Creating plugin .env from Claude settings or POWERMEM_INIT_* variables." + echo "Creating plugin .env from environment variables or Claude settings fallback." create_env_file else echo "Using existing plugin .env: $ENV_FILE" + echo "Checking managed powermem-server recorded for this env: $(managed_pid_file)" + if pid_alive; then + echo "Managed powermem-server is recorded and matches $ENV_FILE: $(managed_pid)" + managed_url=$(managed_base_url || true) + if [ -n "$managed_url" ]; then + base_url=$managed_url + echo "Managed powermem-server URL: $base_url" + fi + if is_healthy "$base_url"; then + write_runtime_base_url "$base_url" + echo "Managed PowerMem backend is healthy: $base_url" + announce_dashboard_url "$base_url" + echo "Hook will use this backend through $RUNTIME_FILE." + exit 0 + fi + echo "Managed powermem-server exists, but health check failed at $base_url; init will stop it and continue." + stop_unhealthy_managed_server + else + echo "No matching managed powermem-server is recorded for $ENV_FILE; init will start one if no healthy backend is found." + remove_managed_pid_files + fi fi echo "Validating LLM config..." if ! validate_llm_config; then - echo "LLM validation failed. The model name may not match the provider." >&2 + echo "LLM validation failed. Check the provider, model, API key, and base URL." >&2 echo "Re-run with POWERMEM_INIT_LLM_MODEL= (and optionally POWERMEM_INIT_LLM_PROVIDER=) to override." >&2 exit 1 fi @@ -212,10 +641,11 @@ if [ ! -x "$(venv_powermem_server)" ]; then fi PYTHON=$(venv_python) echo "Venv Python: $PYTHON ($(python_version "$PYTHON"))" - "$PYTHON" -m pip install -U pip setuptools wheel + configure_pip_index + pip_install -U pip setuptools wheel PACKAGE=${POWERMEM_INIT_PACKAGE:-powermem[server,seekdb]} echo "Installing $PACKAGE" - "$PYTHON" -m pip install "$PACKAGE" + pip_install "$PACKAGE" else echo "Using existing plugin virtualenv: $VENV_DIR" PYTHON=$(venv_python) @@ -224,24 +654,32 @@ fi if [ "${POWERMEM_INIT_PRELOAD_MODEL:-0}" = "1" ] || [ "${POWERMEM_INIT_PRELOAD_MODEL:-}" = "true" ]; then echo "Preloading default local embedding model." + configure_pip_index sh "$SCRIPT_DIR/preload-model.sh" "$PYTHON" else echo "Skipping model preload. Set POWERMEM_INIT_PRELOAD_MODEL=1 to download via ModelScope and bridge to HuggingFace cache." fi if pid_alive; then - echo "Managed PowerMem server process is running: $(cat "$PID_FILE")" + echo "Managed PowerMem server process is running: $(managed_pid)" + managed_url=$(managed_base_url || true) + if [ -n "$managed_url" ]; then + base_url=$managed_url + fi if is_healthy "$base_url"; then write_runtime_base_url "$base_url" echo "Managed PowerMem backend is healthy: $base_url" + announce_dashboard_url "$base_url" exit 0 fi - echo "Managed server PID exists but health check failed; starting a fresh managed server." + echo "Managed server PID exists but health check failed; stopping it before continuing." + stop_unhealthy_managed_server fi if is_healthy "$base_url"; then write_runtime_base_url "$base_url" echo "External PowerMem backend is healthy: $base_url" + announce_dashboard_url "$base_url" echo "Plugin config and venv are ready. Not starting a managed server." exit 0 fi @@ -269,16 +707,19 @@ server=$(venv_powermem_server) echo "Starting PowerMem server on port $port" POWERMEM_ENV_FILE="$ENV_FILE" nohup "$server" --host 127.0.0.1 --port "$port" >> "$LOG_FILE" 2>&1 & pid=$! -echo "$pid" > "$PID_FILE" +write_managed_pid "$pid" +echo "Recorded managed server PID $pid in $PID_FILE" base_url="http://localhost:$port" -write_runtime_base_url "$base_url" echo "Waiting for backend health: $base_url" i=0 while [ "$i" -lt 60 ]; do if is_healthy "$base_url"; then + write_runtime_base_url "$base_url" echo "PowerMem backend is healthy: $base_url" + announce_dashboard_url "$base_url" + echo "Hook will use this backend through $RUNTIME_FILE." echo "Log: $LOG_FILE" exit 0 fi @@ -288,5 +729,6 @@ done echo "PowerMem server did not become healthy within 120 seconds." >&2 echo "Check log: $LOG_FILE" >&2 +stop_unhealthy_managed_server describe_port "$port" >&2 exit 1 diff --git a/apps/claude-code-plugin/scripts/preload-model.sh b/apps/claude-code-plugin/scripts/preload-model.sh index ae71e0d6b..d6785da24 100755 --- a/apps/claude-code-plugin/scripts/preload-model.sh +++ b/apps/claude-code-plugin/scripts/preload-model.sh @@ -16,7 +16,11 @@ fi echo "Preloading all-MiniLM-L6-v2 with Python: $PYTHON ($(python_version "$PYTHON"))" -"$PYTHON" -m pip install -q modelscope +if [ -n "${POWERMEM_PIP_INDEX_URL:-}" ]; then + "$PYTHON" -m pip install -q modelscope -i "$POWERMEM_PIP_INDEX_URL" +else + "$PYTHON" -m pip install -q modelscope +fi "$PYTHON" - <<'PY' import json @@ -63,4 +67,3 @@ for name in os.listdir(src): print("ModelScope download and HuggingFace cache bridge complete.") print(f"HF cache snapshot: {snap}") PY - diff --git a/apps/claude-code-plugin/scripts/status.sh b/apps/claude-code-plugin/scripts/status.sh index 56a6da97f..fced43f1e 100755 --- a/apps/claude-code-plugin/scripts/status.sh +++ b/apps/claude-code-plugin/scripts/status.sh @@ -11,6 +11,7 @@ echo "PowerMem Claude Code plugin status" echo "Data dir: $DATA_DIR" echo "Runtime file: $RUNTIME_FILE" echo "Env file: $ENV_FILE" +echo "PID file: $(managed_pid_file)" echo "Base URL: $base_url" if BOOTSTRAP_PYTHON=$(choose_python 2>/dev/null); then @@ -26,7 +27,7 @@ else fi if pid_alive; then - echo "Managed server PID: $(cat "$PID_FILE")" + echo "Managed server PID: $(managed_pid)" else echo "Managed server PID: not running" fi diff --git a/apps/claude-code-plugin/scripts/stop.sh b/apps/claude-code-plugin/scripts/stop.sh index 2195c3f5f..de86956a6 100755 --- a/apps/claude-code-plugin/scripts/stop.sh +++ b/apps/claude-code-plugin/scripts/stop.sh @@ -6,7 +6,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) . "$SCRIPT_DIR/common.sh" if pid_alive; then - pid=$(cat "$PID_FILE") + pid=$(managed_pid) echo "Stopping PowerMem server PID $pid" kill "$pid" 2>/dev/null || true sleep 2 @@ -14,8 +14,11 @@ if pid_alive; then echo "Server still running; sending SIGKILL" kill -9 "$pid" 2>/dev/null || true fi - rm -f "$PID_FILE" + remove_managed_pid_files else + if [ -f "$(managed_pid_file)" ]; then + echo "Removing stale managed server PID file: $(managed_pid_file)" + remove_managed_pid_files + fi echo "No managed PowerMem server is running." fi - diff --git a/docs/integrations/claude_code.md b/docs/integrations/claude_code.md index bb96bd1d3..1b04ecc66 100644 --- a/docs/integrations/claude_code.md +++ b/docs/integrations/claude_code.md @@ -61,7 +61,11 @@ cd powermem ### Step 2 — Configure `.env` -Copy the template and set your **LLM API key** — the only required credential. Storage defaults to the embedded **seekdb** (no separate database), and the embedder to a local `all-MiniLM-L6-v2` model (no API key, auto-downloaded on first use). +Copy the template and set your Anthropic credential. For direct Anthropic API +access use `LLM_API_KEY`; for a Claude Code-style bearer-token gateway use +`LLM_AUTH_TOKEN` together with `ANTHROPIC_LLM_BASE_URL`. Storage defaults to the +embedded **seekdb** (no separate database), and the embedder to a local +`all-MiniLM-L6-v2` model (no API key, auto-downloaded on first use). ```bash cp .env.example .env @@ -69,6 +73,12 @@ cp .env.example .env # LLM_PROVIDER=anthropic # or openai / qwen / ... # LLM_API_KEY=sk-... # LLM_MODEL=claude-3-5-sonnet-latest +# +# or for an Anthropic-compatible gateway: +# LLM_PROVIDER=anthropic +# LLM_AUTH_TOKEN=... +# ANTHROPIC_LLM_BASE_URL=https://your-gateway.example.com +# LLM_MODEL=anthropic/claude-sonnet-4.6 ``` Every available setting is documented under [Configuration](#configuration); `pmem config init` can also generate `.env` interactively. diff --git a/src/powermem/integrations/llm/anthropic.py b/src/powermem/integrations/llm/anthropic.py index 88367d955..36378f471 100644 --- a/src/powermem/integrations/llm/anthropic.py +++ b/src/powermem/integrations/llm/anthropic.py @@ -38,8 +38,21 @@ def __init__(self, config: Optional[Union[BaseLLMConfig, AnthropicConfig, Dict]] self.config.model = "claude-3-5-sonnet-20240620" api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY") + auth_token = "" + if not api_key: + auth_token = getattr(self.config, "auth_token", None) or os.getenv("ANTHROPIC_AUTH_TOKEN") base_url = getattr(self.config, "anthropic_base_url", None) or os.getenv("ANTHROPIC_BASE_URL") - self.client = anthropic.Anthropic(api_key=api_key, base_url=base_url) + if auth_token and not base_url: + raise ValueError( + "ANTHROPIC_AUTH_TOKEN/LLM_AUTH_TOKEN requires " + "ANTHROPIC_BASE_URL or ANTHROPIC_LLM_BASE_URL." + ) + client_kwargs = {"base_url": base_url} + if auth_token: + client_kwargs["auth_token"] = auth_token + else: + client_kwargs["api_key"] = api_key + self.client = anthropic.Anthropic(**client_kwargs) def generate_response( self, diff --git a/src/powermem/integrations/llm/config/anthropic.py b/src/powermem/integrations/llm/config/anthropic.py index b0139aaad..44c74899d 100644 --- a/src/powermem/integrations/llm/config/anthropic.py +++ b/src/powermem/integrations/llm/config/anthropic.py @@ -28,12 +28,23 @@ class AnthropicConfig(BaseLLMConfig): description="Anthropic API key" ) + auth_token: Optional[str] = Field( + default=None, + validation_alias=AliasChoices( + "auth_token", + "LLM_AUTH_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + ), + description="Anthropic bearer token for gateway/proxy authentication" + ) + # Anthropic-specific fields anthropic_base_url: Optional[str] = Field( default=None, validation_alias=AliasChoices( "anthropic_base_url", "ANTHROPIC_LLM_BASE_URL", + "ANTHROPIC_BASE_URL", ), description="Anthropic API base URL" ) diff --git a/tests/unit/test_anthropic.py b/tests/unit/test_anthropic.py index 51ea31305..575a921e2 100644 --- a/tests/unit/test_anthropic.py +++ b/tests/unit/test_anthropic.py @@ -27,3 +27,52 @@ def test_anthropic_llm_base_url(monkeypatch): monkeypatch.setenv("ANTHROPIC_BASE_URL", compat_url) llm = AnthropicLLM(AnthropicConfig(api_key="fake")) assert str(llm.client.base_url) == compat_url + + +def test_anthropic_llm_api_key_auth_headers(monkeypatch): + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + llm = AnthropicLLM(AnthropicConfig(api_key="fake-key")) + + assert llm.client.auth_headers == {"X-Api-Key": "fake-key"} + + +def test_anthropic_llm_auth_token_headers(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + llm = AnthropicLLM( + AnthropicConfig( + auth_token="gateway-token", + anthropic_base_url="https://gateway.example.com", + ) + ) + + assert llm.client.auth_headers == {"Authorization": "Bearer gateway-token"} + assert str(llm.client.base_url) == "https://gateway.example.com" + + +def test_anthropic_llm_auth_token_requires_base_url(monkeypatch): + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.delenv("LLM_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_LLM_BASE_URL", raising=False) + + with pytest.raises(ValueError, match="ANTHROPIC_AUTH_TOKEN"): + AnthropicLLM(AnthropicConfig(auth_token="gateway-token")) + + +def test_anthropic_llm_api_key_from_env_takes_precedence(monkeypatch): + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "env-token") + monkeypatch.setenv("ANTHROPIC_API_KEY", "env-api-key") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env-gateway.example.com") + + llm = AnthropicLLM(AnthropicConfig()) + + assert llm.client.auth_headers == {"X-Api-Key": "env-api-key"} + assert str(llm.client.base_url) == "https://env-gateway.example.com"