diff --git a/EFFORT_MAPPING.md b/EFFORT_MAPPING.md index b5c4a27..26bfc19 100644 --- a/EFFORT_MAPPING.md +++ b/EFFORT_MAPPING.md @@ -83,8 +83,34 @@ MINIMAL → LOW → MEDIUM → HIGH | Kimi K2.6 | Moonshot API | На стороне сервера | | Gemini 3.x | antigravity-claude-proxy | npm пакет | +## Persistence effort между сессиями + +Каждый лаунчер (`ai-claude`, `ai-deepseek`, `ai-kimi`, `ai-openrouter`) запоминает свой +уровень effort отдельно. Логика гибридная: + +- **`low` / `medium` / `high` / `xhigh`** живут нативно в `settings.json` лаунчера. + `/effort` внутри сессии работает как обычно, уровень сохраняется между сессиями. +- **`max`** — единственный, который Claude Code **не сохраняет** в `settings.json` + (он session-only). Поэтому его восстанавливаем через `CLAUDE_CODE_EFFORT_LEVEL`. + Текущий уровень (включая `max`) статусбар пишет в `~/.cache/ai-setup/effort_`. + +**Важное следствие (только для `max`):** когда восстановлена `max`-сессия, выставлена +`CLAUDE_CODE_EFFORT_LEVEL=max`, и `/effort` внутри неё **не сменит** уровень +(env-переменная — жёсткий override Claude Code). На остальных уровнях `/effort` свободен. + +**Как выйти из `max` (или форсить любой уровень):** перезапусти лаунчер с `AI_EFFORT`: + +```bash +AI_EFFORT=max ai-deepseek # включить и запомнить max +AI_EFFORT=high ai-deepseek # вернуться на high (выйти из max) +ai-deepseek # без флага - восстанавливает последний уровень +``` + +Дефолты при пустом кэше: `xhigh` для `ai-claude`, `high` для остальных. + ## Рекомендации - **Для повседневной работы:** `high` или `xhigh` — работает одинаково хорошо у всех провайдеров - **`max` effort:** имеет реальный эффект только у **Anthropic** и **DeepSeek**. Для GPT маппится в `xhigh`, для Gemini и Kimi — в их максимальный уровень - **`low`/`medium`:** у DeepSeek и Kimi фактически не снижают reasoning — DeepSeek поднимет до `high`, Kimi просто включит thinking +- **Смена уровня:** на `low..xhigh` обычным `/effort`; из `max` — через `AI_EFFORT= ai-` (в max-сессии `/effort` залочен env-переменной, см. «Persistence effort») diff --git a/home-configs/claude/hooks/effort-save-hook.sh b/home-configs/claude/hooks/effort-save-hook.sh deleted file mode 100644 index 3d05366..0000000 --- a/home-configs/claude/hooks/effort-save-hook.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Сохраняет текущий effortLevel в кэш лаунчера при завершении сессии. -# /effort внутри Claude Code обновляет settings.json - мы читаем оттуда. -launcher="${AI_LAUNCHER:-}" -[ -z "$launcher" ] && exit 0 -cat /dev/stdin > /dev/null 2>&1 # drain stdin (Claude Code передаёт JSON) -mkdir -p "$HOME/.cache/ai-setup" -python3 - "$HOME/.claude/settings.json" "$HOME/.cache/ai-setup" "$launcher" <<'PYEOF' -import json, os, sys -settings_path, cache_dir, launcher = sys.argv[1], sys.argv[2], sys.argv[3] -if not os.path.exists(settings_path): - sys.exit(0) -try: - d = json.load(open(settings_path)) -except Exception: - sys.exit(0) -effort = d.get('effortLevel', '') -if effort: - open(os.path.join(cache_dir, f'effort_{launcher}'), 'w').write(effort) -model = d.get('model', '') -if model: - open(os.path.join(cache_dir, f'model_{launcher}'), 'w').write(model) -PYEOF -exit 0 diff --git a/home-configs/claude/statusline-command.sh b/home-configs/claude/statusline-command.sh index ff3416a..366ffd8 100755 --- a/home-configs/claude/statusline-command.sh +++ b/home-configs/claude/statusline-command.sh @@ -42,11 +42,12 @@ printf "\033[38;5;252m%s\033[00m" "$short_cwd" if [ -n "$model" ]; then brand_color=$(_brand_color "${AI_LAUNCHER:-}") effort=$(echo "$input" | jq -r ".effort.level // empty") - # Сохраняем effort для persistence между сессиями одного лаунчера - if [ -n "${AI_LAUNCHER:-}" ] && [ -n "$effort" ]; then + # Ловим выбранный уровень в кэш лаунчера (чтобы запомнить max между сессиями). + # Когда CLAUDE_CODE_EFFORT_LEVEL выставлена (восстановленная max-сессия) - уровень + # форсится env, кэш НЕ трогаем, чтобы дисплей-баг (.effort.level=xhigh) не затёр max. + if [ -n "${AI_LAUNCHER:-}" ] && [ -z "${CLAUDE_CODE_EFFORT_LEVEL:-}" ] && [ -n "$effort" ]; then effort_file="$HOME/.cache/ai-setup/effort_${AI_LAUNCHER}" - prev_effort=$(cat "$effort_file" 2>/dev/null) - if [ "$effort" != "$prev_effort" ]; then + if [ "$effort" != "$(cat "$effort_file" 2>/dev/null)" ]; then mkdir -p "$HOME/.cache/ai-setup" echo "$effort" > "$effort_file" fi diff --git a/scripts/ai-setup.sh b/scripts/ai-setup.sh index 398fd35..8ead554 100755 --- a/scripts/ai-setup.sh +++ b/scripts/ai-setup.sh @@ -683,39 +683,39 @@ else warn "Файл $STATUSLINE_SRC не найден, пропускаю" fi -# ── 6.7.0. Хук effort-save (сохраняет effort при завершении сессии) ── -info "Деплою хук effort-save..." -EFFORT_HOOK_SRC="$SCRIPT_DIR/home-configs/claude/hooks/effort-save-hook.sh" -EFFORT_HOOK_DST="$HOME/.claude/hooks/effort-save-hook.sh" -mkdir -p "$HOME/.claude/hooks" -if [ -f "$EFFORT_HOOK_SRC" ]; then - cp "$EFFORT_HOOK_SRC" "$EFFORT_HOOK_DST" - chmod +x "$EFFORT_HOOK_DST" - python3 - "$HOME/.claude/settings.json" "$EFFORT_HOOK_DST" <<'PYEOF' +# ── 6.7.0. Чистка устаревшего хука effort-save ────────────── +# effort/model теперь персистятся нативно в settings.json каждого CLAUDE_CONFIG_DIR +# (полная изоляция лаунчеров), самопальный кэш ~/.cache/ai-setup/{effort,model}_* не нужен. +# Удаляем старый хук с диска и из ~/.claude/settings.json (нотифаер в Stop сохраняем). +info "Удаляю устаревший хук effort-save..." +rm -f "$HOME/.claude/hooks/effort-save-hook.sh" +# Осиротевший кэш моделей (источник старой протечки между ai-*); effort_* НЕ трогаем - +# он снова используется для персиста effort (включая max) через CLAUDE_CODE_EFFORT_LEVEL. +rm -f "$HOME"/.cache/ai-setup/model_* +python3 - "$HOME/.claude/settings.json" <<'PYEOF' import sys, json, os -settings_path, hook_path = sys.argv[1], sys.argv[2] -data = {} -if os.path.exists(settings_path): +settings_path = sys.argv[1] +if not os.path.exists(settings_path): + sys.exit(0) +try: with open(settings_path) as f: - try: data = json.load(f) - except json.JSONDecodeError: pass -data.setdefault("hooks", {}).setdefault("Stop", [{"hooks": []}]) -hook_cmd = f'bash "{hook_path}"' -stop_hooks = data["hooks"]["Stop"] -already = any( - any(h.get("command", "") == hook_cmd for h in entry.get("hooks", [])) - for entry in stop_hooks -) -if not already: - stop_hooks[0]["hooks"].append({"type": "command", "command": hook_cmd}) -with open(settings_path, "w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write("\n") + data = json.load(f) +except Exception: + sys.exit(0) +stop = data.get("hooks", {}).get("Stop") +if isinstance(stop, list): + for entry in stop: + hooks = entry.get("hooks") + if isinstance(hooks, list): + entry["hooks"] = [h for h in hooks if "effort-save-hook" not in h.get("command", "")] + data["hooks"]["Stop"] = [e for e in stop if e.get("hooks")] + if not data["hooks"]["Stop"]: + del data["hooks"]["Stop"] + with open(settings_path, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") PYEOF - success "Хук effort-save установлен" -else - warn "Файл $EFFORT_HOOK_SRC не найден, пропускаю" -fi +success "Старый хук effort-save удалён" # ── 6.7.1. Хук switch-account ─────────────────────────────────── info "Деплою хук switch-account..." @@ -1077,71 +1077,95 @@ _open_browser() { else echo "Откройте вручную: $url"; fi } -# _restore_effort: читает сохранённый effort для текущего AI_LAUNCHER из кэша -# и записывает его в settings.json, чтобы Claude Code подхватил нужный уровень. -# Не передаём --effort через CLI, чтобы /effort внутри сессии работал без блокировки. -_restore_effort() { - local default_effort="${1:-high}" - local launcher="${AI_LAUNCHER:-}" - [ -z "$launcher" ] && return - local effort_file="$HOME/.cache/ai-setup/effort_${launcher}" - local effort - effort=$(cat "$effort_file" 2>/dev/null) - [ -z "$effort" ] && effort="$default_effort" - mkdir -p "$HOME/.cache/ai-setup" - python3 - "$HOME/.claude/settings.json" "$effort" <<'PYEOF' +# _setup_isolated_config: готовит изолированную папку CLAUDE_CONFIG_DIR для лаунчера. +# Каждый сторонний провайдер получает собственные settings.json и .claude.json, +# поэтому выбор модели и кэш кастом-моделей НЕ протекают между ai-* лаунчерами. +# Общие ресурсы (skills, CLAUDE.md) шарятся симлинком из ~/.claude. +# model и effortLevel сидируются как дефолты - выбор юзера через /model и /effort +# (для low/medium/high/xhigh) сохраняется нативно в этом же settings.json. +# Уровень max обрабатывается отдельно в _apply_effort (settings.json его не хранит). +# Использование: _setup_isolated_config +_setup_isolated_config() { + local launcher="$1" default_model="$2" default_effort="${3:-high}" avail="${4:-}" + local cfg="$HOME/.config/ai-setup/cfg/$launcher" + mkdir -p "$cfg" + # Общие ресурсы из ~/.claude (единый источник правды) + ln -sfn "$HOME/.claude/skills" "$cfg/skills" + ln -sfn "$HOME/.claude/CLAUDE.md" "$cfg/CLAUDE.md" + [ -e "$HOME/.claude/agents" ] && ln -sfn "$HOME/.claude/agents" "$cfg/agents" + # .claude.json в свежей папке: пропускаем онбординг + [ -f "$cfg/.claude.json" ] || echo '{"hasCompletedOnboarding": true}' > "$cfg/.claude.json" + python3 - "$cfg/settings.json" "$HOME/.claude/statusline-command.sh" \ + "$default_model" "$default_effort" "$avail" <<'PYEOF' import sys, json, os -settings_path, effort = sys.argv[1], sys.argv[2] +cfg_settings, statusline, model, effort, avail = sys.argv[1:6] data = {} -if os.path.exists(settings_path): +if os.path.exists(cfg_settings): try: - with open(settings_path) as f: + with open(cfg_settings) as f: data = json.load(f) except Exception: pass -data['effortLevel'] = effort -with open(settings_path, 'w') as f: +# Структурные настройки лаунчера (переустанавливаем всегда) +data["statusLine"] = {"type": "command", "command": f"bash {statusline}"} +data["skipDangerousModePermissionPrompt"] = True +data.setdefault("hooks", {})["SessionStart"] = [{"hooks": [{"type": "command", "command": "true"}]}] +# availableModels - белый список пикера (политика лаунчера) +if avail: + data["availableModels"] = json.loads(avail) +else: + data.pop("availableModels", None) +# model/effortLevel - сидируем дефолты, не перетирая выбор юзера (нативный persistence) +if model: + data.setdefault("model", model) +data.setdefault("effortLevel", effort) +with open(cfg_settings, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) - f.write('\n') + f.write("\n") PYEOF } -_restore_model() { - local default_model="${1:-}" - local launcher="${AI_LAUNCHER:-}" - [ -z "$launcher" ] && return - local model_file="$HOME/.cache/ai-setup/model_${launcher}" - local model - model=$(cat "$model_file" 2>/dev/null) - [ -z "$model" ] && model="$default_model" - [ -z "$model" ] && return - python3 - "$HOME/.claude/settings.json" "$model" <<'PYEOF' +# _apply_effort: per-launcher persistence уровня effort (гибрид). +# - low/medium/high/xhigh живут нативно в settings.json лаунчера -> /effort работает, +# уровень сохраняется между сессиями, env-переменная НЕ ставится. +# - max единственный нельзя сохранить в settings.json (он session-only), поэтому +# его восстанавливаем через CLAUDE_CODE_EFFORT_LEVEL. В такой max-сессии /effort +# залочен env-переменной (ограничение Claude Code). +# Текущий уровень (вкл. max) ловит статусбар в ~/.cache/ai-setup/effort_. +# Сменить уровень из max: AI_EFFORT= ai-. +# Использование: _apply_effort +_apply_effort() { + local launcher="$1" default_effort="${2:-high}" + local f="$HOME/.cache/ai-setup/effort_${launcher}" + local settings="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" + local eff + if [ -n "${AI_EFFORT:-}" ]; then + # Явный override: запоминаем и применяем + eff="$AI_EFFORT" + mkdir -p "$HOME/.cache/ai-setup" + echo "$eff" > "$f" + else + eff=$(cat "$f" 2>/dev/null) + fi + if [ "$eff" = "max" ]; then + # Единственный способ восстановить max между сессиями + export CLAUDE_CODE_EFFORT_LEVEL=max + elif [ -n "${AI_EFFORT:-}" ] && [ -n "$eff" ]; then + # Явный сброс на low/medium/high/xhigh - пишем нативно в settings.json лаунчера + python3 - "$settings" "$eff" <<'PYEOF' import sys, json, os -settings_path, model = sys.argv[1], sys.argv[2] -data = {} -if os.path.exists(settings_path): - try: - with open(settings_path) as f: - data = json.load(f) - except Exception: - pass -data['model'] = model -with open(settings_path, 'w') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write('\n') +p, eff = sys.argv[1], sys.argv[2] +d = {} +if os.path.exists(p): + try: d = json.load(open(p)) + except Exception: pass +d["effortLevel"] = eff +os.makedirs(os.path.dirname(p), exist_ok=True) +with open(p, "w") as fp: + json.dump(d, fp, indent=2, ensure_ascii=False); fp.write("\n") PYEOF -} - -# _restore_model_str: возвращает сохранённую модель строкой (для ANTHROPIC_MODEL env var) -_restore_model_str() { - local default_model="${1:-}" - local launcher="${AI_LAUNCHER:-}" - [ -z "$launcher" ] && echo "$default_model" && return - local model_file="$HOME/.cache/ai-setup/model_${launcher}" - local model - model=$(cat "$model_file" 2>/dev/null) - [ -z "$model" ] && model="$default_model" - echo "$model" + fi + # Иначе (≤xhigh без AI_EFFORT): ничего не делаем - effortLevel уже персистнут нативно. } _build_ai_sys_prompt() { @@ -1266,13 +1290,19 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=deepseek -_restore_effort high +export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/deepseek" +# Пикер: только DeepSeek V4 Pro (opus) и DeepSeek V4 Flash (haiku), дефолт - Pro +_setup_isolated_config deepseek opus high '["opus", "haiku"]' +_apply_effort deepseek high ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic \ ANTHROPIC_AUTH_TOKEN="$api_key" \ -ANTHROPIC_MODEL=deepseek-v4-pro \ ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-pro \ +ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="DeepSeek V4 Pro" \ +ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="DeepSeek V4 Pro - флагман для сложных задач" \ ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-pro \ ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash \ +ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="DeepSeek V4 Flash" \ +ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="DeepSeek V4 Flash - быстрый и дешёвый" \ CLAUDE_CODE_SUBAGENT_MODEL=deepseek-v4-flash \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" @@ -1340,11 +1370,15 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=kimi -_restore_effort high +export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/kimi" +# Пикер: единственная модель Kimi K2.6 (под алиасом opus) +_setup_isolated_config kimi opus high '["opus"]' +_apply_effort kimi high ANTHROPIC_BASE_URL=https://api.kimi.com/coding \ ANTHROPIC_AUTH_TOKEN="$api_key" \ -ANTHROPIC_MODEL=kimi-k2.6 \ ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.6 \ +ANTHROPIC_DEFAULT_OPUS_MODEL_NAME="Kimi K2.6" \ +ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION="Kimi K2.6 (Moonshot AI)" \ ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.6 \ ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.6 \ CLAUDE_CODE_SUBAGENT_MODEL=kimi-k2.6 \ @@ -1414,11 +1448,16 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=openrouter -_restore_effort high -_MODEL=$(_restore_model_str "openai/gpt-5.5") +export CLAUDE_CONFIG_DIR="$HOME/.config/ai-setup/cfg/openrouter" +# openrouter - гибкий лаунчер: пикер не ограничиваем (availableModels пустой), +# gpt-5.5 добавляем отдельным пунктом и делаем дефолтом +export ANTHROPIC_CUSTOM_MODEL_OPTION="openai/gpt-5.5" +export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="GPT-5.5" +export ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION="OpenRouter: openai/gpt-5.5" +_setup_isolated_config openrouter "openai/gpt-5.5" high '' +_apply_effort openrouter high ANTHROPIC_BASE_URL=https://openrouter.ai/api \ ANTHROPIC_AUTH_TOKEN="$api_key" \ -ANTHROPIC_MODEL=$_MODEL \ ANTHROPIC_DEFAULT_OPUS_MODEL=anthropic/claude-4.8-opus \ ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-4.6-sonnet \ ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-5.5 \ @@ -1469,8 +1508,11 @@ _PROMPT_FILE=$(mktemp /tmp/ai-sys-prompt.XXXXXX) trap 'rm -f "$_PROMPT_FILE"' EXIT INT TERM _build_ai_sys_prompt > "$_PROMPT_FILE" export AI_LAUNCHER=claude -_restore_effort xhigh -_restore_model "sonnet" +# ai-claude работает в дефолтном ~/.claude (нативный логин и аккаунты). +# Модель хранится нативно в ~/.claude/settings.json; другие ai-* лаунчеры теперь +# изолированы в своих CLAUDE_CONFIG_DIR, поэтому в пикер не протекают чужие модели - +# показываются только нативные модели Claude Code. +_apply_effort claude xhigh claude --dangerously-skip-permissions --system-prompt-file "$_PROMPT_FILE" "$@" CLAUDEEOF chmod +x "$BIN_DIR/ai-claude" diff --git a/tests/test_fixes.sh b/tests/test_fixes.sh index 1fbd67d..b349747 100755 --- a/tests/test_fixes.sh +++ b/tests/test_fixes.sh @@ -48,7 +48,7 @@ test_kimi_claude_launcher() { # ── ai-kimi: uses official Kimi API ────────────────────────────────────── test_kimi_official_api() { if echo "$KIMI_SECTION" | grep -q 'api.kimi.com/coding' \ - && echo "$KIMI_SECTION" | grep -q 'ANTHROPIC_MODEL=kimi-k2.6' \ + && echo "$KIMI_SECTION" | grep -q 'ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.6' \ && ! echo "$KIMI_SECTION" | grep -q 'artemox'; then ok "ai-kimi: uses official Kimi API and model" else