diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 145d0181..f942a4cb 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -74,7 +74,7 @@ Additional modules: screenshot.py ─ Terminal text → PNG rendering (ANSI color, font fallback) transcribe.py ─ Voice-to-text transcription via whisper.cpp / Apple Speech i18n.py ─ Per-user UI strings (en / ru / zh) - naming.py ─ Haiku-generated session names + readable summaries + naming.py ─ lightweight-model-generated session names usage.py ─ Token usage aggregator + per-session token alerts main.py ─ CLI entry point (ccbot / ccbot hook / ccbot send-file) config.py ─ Env-var loader (singleton `config`), .env priority @@ -125,13 +125,13 @@ bot/ package (was bot.py before A1, split per CLAUDE.md size budget): commands/auth.py ─ /login re-auth flow (+ maybe_consume_code, notify_auth_expired) callbacks/__init__.py ─ Top-level dispatcher; tries each handler in order - callbacks/dir_browser.py ─ CB_DIR_*, CB_SESSION_* (+ Haiku summary cache) + callbacks/dir_browser.py ─ CB_DIR_*, CB_SESSION_* callbacks/window_picker.py ─ CB_WIN_* callbacks/switcher.py ─ CB_SW_* callbacks/archive.py ─ CB_ARC_* callbacks/footer.py ─ CB_FT_STOP/KILL/CLEAR/MORE callbacks/more_menu.py ─ CB_MM_LIST/STATUS/SHOT/NEW/ARCHIVE/SETTINGS/BACK - callbacks/settings.py ─ CB_ST_GRP + CB_ST_PREV/LAG/VOICE/LANG/WDAY/APPROVE + callbacks/settings.py ─ CB_ST_GRP + CB_ST_LAG/VOICE/LANG/WDAY/APPROVE callbacks/confirm.py ─ CB_CONF_KILL/DONE/DEL × YES/NO callbacks/history_pagination.py ─ CB_HISTORY_PREV/NEXT callbacks/interactive_ui.py ─ CB_ASK_* (Up/Down/Left/Right/Esc/Enter/...) @@ -184,7 +184,7 @@ Handler modules (handlers/): State files (~/.ccbot/ or $CCBOT_DIR/): state.json ─ window states + display names + read offsets + user - settings (previews / live_lag / voice / card_history / + settings (live_lag / voice / card_history / card_page_lines / card_inline_screenshots / bg_notify_finished / bg_notify_error / bg_notify_needs_action / language / weekly_reset_day / diff --git a/.env.example b/.env.example index 0422e897..77ad24d0 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ CCBOT_AGENT_BACKEND=claude # screenshots, and remote key control reliable. CODEX_COMMAND=codex CODEX_FLAGS=--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --enable hooks --no-alt-screen -# Lightweight model used for automatic names and readable session previews. +# Lightweight model used for automatic session names. CODEX_NAMING_MODEL=gpt-5.6-luna # CODEX_HOME=~/.codex # CCBOT_CODEX_SESSIONS_PATH=~/.codex/sessions diff --git a/README.md b/README.md index 38618090..bb82e23e 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Most-frequently-tweaked optionals: | `CLAUDE_FLAGS` | `--dangerously-skip-permissions` | flags appended to `claude` | | `CODEX_COMMAND` | `codex` | Codex CLI binary (an absolute Termux path is accepted) | | `CODEX_FLAGS` | bypass + hook trust + hooks + `--no-alt-screen` | flags appended to `codex` | -| `CODEX_NAMING_MODEL` | `gpt-5.6-luna` | lightweight Codex model for automatic names and readable previews | +| `CODEX_NAMING_MODEL` | `gpt-5.6-luna` | lightweight Codex model for automatic session names | | `SESSION_IDLE_TTL` | `4h` | active → archived after this much idleness | | `ARCHIVE_PURGE_AFTER` | `14d` | archived sessions purged from state after this | | `QUOTA_ALERT_POLL_INTERVAL` | `10m` | how often the live `/usage` modal is sampled | @@ -276,9 +276,10 @@ Reply-quoting a bot message belonging to a non-active session routes that single reply there without changing the active session. *Menu → Archive* shows a numbered list of past sessions, two buttons -per row. Each row carries a short blurb (Claude's own `type=summary` -entry, or the first user message) so it's obvious at a glance what a -session was about. Tap a session — the carrier paints the actual +per row. Each row carries a short blurb made only from the user's first +messages, so it's obvious at a glance what a session was about. A +model-generated summary never replaces that text. Tap a session — the +carrier paints the actual transcript read straight from the JSONL on disk; *Restore* / *Delete* stay in the footer. @@ -327,7 +328,6 @@ Card knobs live under *Settings → 🃏 Card / view*: | `Card history` | `20` | end-of-turn boundaries seeded into a fresh card from the JSONL (survives bot restarts) | | `Page size` | `20` lines | max lines per card page; longer bodies chunk across pages on paragraph/sentence boundaries | | `Inline screenshots` | `off` | card becomes photo + caption — the photo is the live pane render (caption limit is 1024 chars, so shrink page size to compensate) | -| `Previews` | `economical` | local labels or cached summaries via Haiku / `CODEX_NAMING_MODEL` | | `Live lag` | `4s` | coalescing window for preview updates | Telegram's chat-header **`typing…` indicator** is driven by real diff --git a/README_CN.md b/README_CN.md index 3fda5c09..2cc274bf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -107,7 +107,7 @@ ccbot # 前台;生产环境用 systemd 单元 | `CLAUDE_FLAGS` | `--dangerously-skip-permissions` | 附加给 `claude` 的 flag | | `CODEX_COMMAND` | `codex` | Codex CLI 二进制 | | `CODEX_FLAGS` | bypass + hooks + `--no-alt-screen` | 附加给 `codex` 的 flag | -| `CODEX_NAMING_MODEL` | `gpt-5.6-luna` | 用于 Codex 自动命名和可读预览的轻量模型 | +| `CODEX_NAMING_MODEL` | `gpt-5.6-luna` | 用于 Codex 自动命名会话的轻量模型 | | `SESSION_IDLE_TTL` | `4h` | 闲置多久后 active → archived | | `ARCHIVE_PURGE_AFTER` | `14d` | 归档会话从 state 中清除的时长 | | `QUOTA_ALERT_POLL_INTERVAL` | `10m` | 实时 `/usage` 弹窗的采样间隔 | @@ -237,8 +237,8 @@ transcript 表面触手可及。多数用户一旦发现菜单,就再也不打 s 路由到该会话,但不更改活动会话。 *菜单 → Archive* 显示带编号的历史会话列表,每行两个按钮。每行 -携带一段简短描述(Claude 自己的 `type=summary` 条目,或第一条 -用户消息),这样一眼就能看出会话是关于什么的。点击会话,carrier +携带一段只取自用户最初消息的简短描述,这样一眼就能看出会话是 +关于什么的;模型生成的摘要不会替换这些文字。点击会话,carrier 会画出直接从磁盘 JSONL 读取的真实转录;*Restore* / *Delete* 保留在底部。 @@ -282,7 +282,6 @@ Enter / Esc 键盘。 | `卡片历史` | `20` | 从 JSONL 预加载进新卡片的 end-of-turn 边界数(机器人重启后仍在) | | `页面大小` | `20` 行 | 每页最多行数;长正文按段落/句子边界跨页切分 | | `内联截图` | `off` | 卡片变为图片 + 说明文字,图片是实时面板渲染(说明限 1024 字符,需相应调小页面大小) | -| `预览` | `economical` | 切换器预览的详细程度 | | `实时延迟` | `4s` | 预览更新的合并窗口 | Telegram 聊天头部的 **`正在输入…`** 指示由真实的 claude 事件驱动。 diff --git a/README_RU.md b/README_RU.md index f4a2e64e..9d390e58 100644 --- a/README_RU.md +++ b/README_RU.md @@ -118,7 +118,7 @@ ccbot # foreground; для prod — systemd-юнит | `CLAUDE_FLAGS` | `--dangerously-skip-permissions` | флаги для `claude` | | `CODEX_COMMAND` | `codex` | бинарь Codex CLI (можно указать абсолютный Termux-путь) | | `CODEX_FLAGS` | bypass + hook trust + hooks + `--no-alt-screen` | флаги для `codex` | -| `CODEX_NAMING_MODEL` | `gpt-5.6-luna` | легковесная модель Codex для автоматических имён и readable-превью | +| `CODEX_NAMING_MODEL` | `gpt-5.6-luna` | легковесная модель Codex для автоматических имён сессий | | `SESSION_IDLE_TTL` | `4h` | active → archived через столько простоя | | `ARCHIVE_PURGE_AFTER` | `14d` | архивные сессии удаляются из state через столько | | `QUOTA_ALERT_POLL_INTERVAL` | `10m` | как часто опрашивается живой `/usage` | @@ -273,9 +273,10 @@ Reply-цитата на сообщение бота из неактивной с текст туда разово, без смены активной. *Меню → Archive* показывает пронумерованный список прошлых сессий -по две кнопки в ряд. У каждой строки — короткое описание (Claude'овый -`type=summary` или первое сообщение пользователя), чтобы сразу было -понятно, о чём была сессия. Тап по сессии — carrier рисует реальный +по две кнопки в ряд. У каждой строки - короткое описание только из +первых сообщений пользователя, чтобы сразу было понятно, о чём была +сессия. Модельное саммари этот текст не подменяет. Тап по сессии - +carrier рисует реальный transcript прямо с диска (JSONL); *Restore* / *Delete* остаются в футере. @@ -323,7 +324,6 @@ foreground-промпте. | `История в карточке` | `20` | сколько end-of-turn-границ подгружается в свежую карточку из JSONL (переживает рестарт бота) | | `Размер страницы` | `20` строк | максимум строк на страницу; длинное тело режется по границам абзацев/предложений | | `Скрины в карточке` | `off` | карточка становится фото + подпись, фото — рендер живой панели (лимит подписи 1024 символа, уменьшай размер страницы) | -| `Превью` | `economical` | локальные подписи или кэшируемые саммари через Haiku / `CODEX_NAMING_MODEL` | | `Лаг карточки` | `4s` | окно коалесцинга обновлений превью | Индикатор Telegram **`печатает…`** в шапке чата управляется diff --git a/src/ccbot/bot/callbacks/dir_browser.py b/src/ccbot/bot/callbacks/dir_browser.py index a3222e23..c85a0e20 100644 --- a/src/ccbot/bot/callbacks/dir_browser.py +++ b/src/ccbot/bot/callbacks/dir_browser.py @@ -1,13 +1,7 @@ -"""Directory browser + session picker callbacks (CB_DIR_*, CB_SESSION_*). - -Also owns the readable-session-summary cache machinery used when the -user has Settings → Previews set to ``readable``. -""" +"""Directory browser + session picker callbacks (CB_DIR_*, CB_SESSION_*).""" from __future__ import annotations -import asyncio -import logging from pathlib import Path from typing import Any @@ -41,71 +35,17 @@ clear_session_picker_state, ) from ...handlers.message_sender import safe_edit -from ...naming import generate_name from ...session import session_manager from .._common import open_more_in_place from ..messages import create_and_activate_session -logger = logging.getLogger(__name__) - async def resolve_session_summaries( sessions: list[Any], *, user_id: int ) -> dict[str, str]: - """Agent session id → display summary, honoring user Previews setting. - - ``readable`` uses the active backend's lightweight naming model: - Haiku for Claude, ``CODEX_NAMING_MODEL`` for Codex. The picker only - contains sessions discovered for the globally selected backend, so one - backend value applies to the whole batch. - """ - settings = session_manager.get_user_settings(user_id) - mode = settings.get("previews", "economical") - backend = ( - session_manager.agent_backend - if session_manager.agent_backend in ("claude", "codex") - else "claude" - ) - out: dict[str, str] = {} - if mode != "readable": - for s in sessions: - out[s.session_id] = s.summary or "untitled" - return out - - pending: list[tuple[str, str, float]] = [] # (sid, seed, mtime) - for s in sessions: - try: - mtime = Path(s.file_path).stat().st_mtime - except OSError: - mtime = 0.0 - cached = session_manager.get_cached_summary(s.session_id, mtime) - if cached: - out[s.session_id] = cached - else: - out[s.session_id] = s.summary or "untitled" - seed = (s.summary or "")[:200] - if seed: - pending.append((s.session_id, seed, mtime)) - - if pending: - - async def _bg() -> None: - for sid, seed, mtime in pending: - try: - name = await generate_name(seed, backend=backend) - if name: - readable = name.replace("-", " ") - session_manager.set_cached_summary(sid, readable, mtime) - except Exception as e: - logger.debug( - "%s readable-preview resolve failed for %s: %s", - backend, - sid, - e, - ) - - asyncio.create_task(_bg()) - return out + """Agent session id → description taken from the user's messages.""" + del user_id # Kept in the API because callers already have it available. + return {s.session_id: s.summary or "untitled" for s in sessions} async def emit_session_picker( diff --git a/src/ccbot/bot/callbacks/settings.py b/src/ccbot/bot/callbacks/settings.py index 1072873a..d3bf1982 100644 --- a/src/ccbot/bot/callbacks/settings.py +++ b/src/ccbot/bot/callbacks/settings.py @@ -26,7 +26,6 @@ CB_ST_LCLAUDE, CB_ST_LOCAL, CB_ST_LTERM, - CB_ST_PREV, CB_ST_VOICE, CB_ST_VOICE_INSTALL_GO, CB_ST_VOICE_INSTALL_NO, @@ -203,7 +202,6 @@ async def progress(text: str) -> None: _GROUP_TO_SCREEN: dict[str, Screen] = { "agent_backend": "settings_agent", "language": "settings_language", - "previews": "settings_previews", "live_lag": "settings_lag", "voice": "settings_voice", "weekly_reset_day": "settings_weeklyday", @@ -286,7 +284,6 @@ async def handle( setter_prefixes = ( CB_ST_AGENT, - CB_ST_PREV, CB_ST_LAG, CB_ST_VOICE, CB_ST_LANG, @@ -313,11 +310,6 @@ async def handle( await query.answer(t(user.id, "toast.agent_live"), show_alert=True) return True screen_name = "settings_agent" - elif data.startswith(CB_ST_PREV): - value = data[len(CB_ST_PREV) :] - if value in ("economical", "readable"): - session_manager.update_user_setting(user.id, "previews", value) - screen_name = "settings_previews" elif data.startswith(CB_ST_LAG): try: lag = int(data[len(CB_ST_LAG) :]) diff --git a/src/ccbot/config.py b/src/ccbot/config.py index c22d61ed..2c521fed 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -90,9 +90,8 @@ def __init__(self) -> None: if self.agent_backend not in ("claude", "codex"): raise ValueError("CCBOT_AGENT_BACKEND must be 'claude' or 'codex'") self.codex_command = os.getenv("CODEX_COMMAND", "codex") - # Cheap, fast model used for one-shot session auto-naming and - # ``readable`` picker previews. Keep this separate from the - # interactive session model. + # Cheap, fast model used for one-shot session auto-naming. Keep this + # separate from the interactive session model. self.codex_naming_model = os.getenv( "CODEX_NAMING_MODEL", "gpt-5.6-luna" ).strip() diff --git a/src/ccbot/handlers/archive.py b/src/ccbot/handlers/archive.py index d7f4eb2f..8edb37b5 100644 --- a/src/ccbot/handlers/archive.py +++ b/src/ccbot/handlers/archive.py @@ -40,10 +40,6 @@ # soft length budget kicks in. Archived JSONLs are append-frozen so a # single scan covers the session's lifetime in archive. _BLURB_CACHE: dict[str, str] = {} -# Session ids with a background readable-preview request in flight. Archive -# rendering must stay local and instant; model output is persisted in the -# shared summary cache and appears on the next paint. -_BLURB_SUMMARY_INFLIGHT: set[str] = set() # Hard character cap on the combined blurb (all included messages # plus their hard-break separators). When the first message alone # exceeds this, it gets truncated with ``…`` on a word boundary; @@ -256,64 +252,22 @@ async def _collect_user_messages(sess: Session) -> str: return _format_blurb(messages) -async def _archive_blurb(sess: Session, user_id: int | None = None) -> str: +async def _archive_blurb(sess: Session) -> str: """Return the "what was this session about" line for an archived row. - Economical mode uses the user's own first 1-3 messages from the JSONL. - Readable mode first checks the persistent model-summary cache and, on a - miss, starts a non-blocking lightweight request: Haiku for Claude or - ``CODEX_NAMING_MODEL`` for Codex. The local blurb is returned immediately, - so opening Archive never waits for a model; the generated summary appears - on the next paint. + Source: the user's own first 1-3 messages from the JSONL transcript. + No model-generated summary may replace those words. The result is cached + per agent session id because archived transcripts are append-frozen. """ sid = sess.claude_session_id if not sid: return "" cached = _BLURB_CACHE.get(sid) - if cached is None: - cached = await _collect_user_messages(sess) - _BLURB_CACHE[sid] = cached - - if user_id is None: - return cached - settings = session_manager.get_user_settings(user_id) - if settings.get("previews", "economical") != "readable": - return cached - - # Archived transcripts are append-frozen. ``archived_at`` is therefore a - # stable, zero-I/O cache version; when a restored session is archived - # again the timestamp changes and naturally invalidates the old summary. - mtime = sess.archived_at or sess.last_event_at or 0.0 - readable = session_manager.get_cached_summary(sid, mtime) - if readable: - return readable - if not cached or sid in _BLURB_SUMMARY_INFLIGHT: + if cached is not None: return cached - - backend = sess.backend if sess.backend in ("claude", "codex") else "claude" - _BLURB_SUMMARY_INFLIGHT.add(sid) - - async def generate_readable() -> None: - try: - from ..naming import generate_name - - name = await generate_name(cached, backend=backend) - if name: - session_manager.set_cached_summary(sid, name, mtime) - except Exception as e: - logger.debug( - "%s archive readable-preview failed for %s: %s", - backend, - sid, - e, - ) - finally: - _BLURB_SUMMARY_INFLIGHT.discard(sid) - - import asyncio - - asyncio.create_task(generate_readable()) - return cached + blurb = await _collect_user_messages(sess) + _BLURB_CACHE[sid] = blurb + return blurb def _display_name(sess: Session) -> str: @@ -386,7 +340,7 @@ async def build_archive_page( blurbs: dict[str, str] = {} for sess in chunk: try: - blurbs[sess.id] = await _archive_blurb(sess, user_id) + blurbs[sess.id] = await _archive_blurb(sess) except Exception as e: logger.debug("archive blurb fetch failed for %s: %s", sess.id, e) blurbs[sess.id] = "" diff --git a/src/ccbot/handlers/callback_data.py b/src/ccbot/handlers/callback_data.py index cb29e212..fb1be4f3 100644 --- a/src/ccbot/handlers/callback_data.py +++ b/src/ccbot/handlers/callback_data.py @@ -97,7 +97,6 @@ # Settings (toggle screens) CB_ST_GRP = "st:grp:" # st:grp: open a per-group settings screen CB_ST_LANG = "st:lng:" # st:lng: -CB_ST_PREV = "st:prev:" # st:prev: CB_ST_LAG = "st:lag:" # st:lag: CB_ST_VOICE = "st:voice:" # st:voice: CB_ST_WDAY = "st:wday:" # st:wday: diff --git a/src/ccbot/handlers/menu.py b/src/ccbot/handlers/menu.py index cfdf6a14..d10462c7 100644 --- a/src/ccbot/handlers/menu.py +++ b/src/ccbot/handlers/menu.py @@ -55,7 +55,6 @@ CB_ST_LCLAUDE, CB_ST_LOCAL, CB_ST_LTERM, - CB_ST_PREV, CB_ST_VOICE, CB_ST_WDAY, CB_SW_NEW, @@ -74,7 +73,6 @@ "settings_cat_terminal", "settings_cat_behavior", # Individual setting sub-screens. - "settings_previews", "settings_lag", "settings_voice", "settings_language", @@ -95,7 +93,6 @@ _SETTINGS_GROUPS: tuple[tuple[str, str, str, str], ...] = ( ("agent_backend", "settings.group.agent", "settings_agent", "agent_backend"), ("language", "settings.group.language", "settings_language", "language"), - ("previews", "settings.group.previews", "settings_previews", "previews"), ("live_lag", "settings.group.live_lag", "settings_lag", "live_lag"), ("voice", "settings.group.voice", "settings_voice", "voice"), ( @@ -171,7 +168,6 @@ "settings.cat.card", "settings_cat_card", ( - "previews", "live_lag", "card_history", "card_page_lines", @@ -485,27 +481,6 @@ def _settings_category_grid( return rows -def _settings_previews_grid(user_id: int) -> list[list[InlineKeyboardButton]]: - cur = session_manager.get_user_settings(user_id).get("previews", "economical") - return [ - [ - InlineKeyboardButton( - _highlight("economical", cur == "economical"), - callback_data=f"{CB_ST_PREV}economical", - ), - InlineKeyboardButton( - _highlight("readable", cur == "readable"), - callback_data=f"{CB_ST_PREV}readable", - ), - ], - [ - InlineKeyboardButton( - t(user_id, "btn.back"), callback_data=_parent_cat_cb("previews") - ) - ], - ] - - def _settings_lag_grid(user_id: int) -> list[list[InlineKeyboardButton]]: cur = int(session_manager.get_user_settings(user_id).get("live_lag", 4)) return [ @@ -860,8 +835,6 @@ def build_footer_keyboard( rows.extend(_more_grid(user_id, exclude=exclude_more)) elif screen == "settings": rows.extend(_settings_main_grid(user_id)) - elif screen == "settings_previews": - rows.extend(_settings_previews_grid(user_id)) elif screen == "settings_lag": rows.extend(_settings_lag_grid(user_id)) elif screen == "settings_voice": @@ -974,7 +947,6 @@ def render_settings_text(user_id: int) -> str: "settings.body", agent=session_manager.agent_backend.capitalize(), language=s.get("language", "en"), - previews=s.get("previews", "economical"), live_lag=int(s.get("live_lag", 4)), voice=s.get("voice", "auto"), ) @@ -982,7 +954,6 @@ def render_settings_text(user_id: int) -> str: _GROUP_TEXT_KEYS: dict[str, str] = { "settings_agent": "settings.agent.body", - "settings_previews": "settings.previews.body", "settings_lag": "settings.lag.body", "settings_voice": "settings.voice.body", "settings_language": "settings.lang.body", diff --git a/src/ccbot/i18n.py b/src/ccbot/i18n.py index 45b28d5a..9ed3dcb0 100644 --- a/src/ccbot/i18n.py +++ b/src/ccbot/i18n.py @@ -141,7 +141,6 @@ "*Settings*\n\n" "Agent: `{agent}`\n" "Language: `{language}`\n" - "Previews: `{previews}`\n" "Live lag: `{live_lag}s`\n" "Voice: `{voice}`\n\n" "_Tap a group to change._" @@ -149,17 +148,9 @@ # Settings — group labels (in the main grid) "settings.group.agent": "Agent", "settings.group.language": "Language", - "settings.group.previews": "Previews", "settings.group.live_lag": "Live lag", "settings.group.voice": "Voice", # Settings — group sub-screen descriptions - "settings.previews.body": ( - "*Previews*\n\n" - "How session names are rendered in the picker:\n" - "• `economical` — local fallback, no extra model calls\n" - "• `readable` — cached summaries via Haiku (Claude) or the " - "lightweight Codex model" - ), "settings.lag.body": ( "*Live preview lag*\n\n" "Coalescing window for live-card edits.\n" @@ -635,23 +626,14 @@ "*Настройки*\n\n" "Агент: `{agent}`\n" "Язык: `{language}`\n" - "Превью: `{previews}`\n" "Лаг карточки: `{live_lag}с`\n" "Голос: `{voice}`\n\n" "_Тапни группу, чтобы изменить._" ), "settings.group.agent": "Агент", "settings.group.language": "Язык", - "settings.group.previews": "Превью", "settings.group.live_lag": "Лаг карточки", "settings.group.voice": "Голос", - "settings.previews.body": ( - "*Превью*\n\n" - "Как именуются сессии в пикере:\n" - "• `economical` — локальный fallback, без обращений к модели\n" - "• `readable` — кэшируемые саммари через Haiku (Claude) или " - "лёгкую модель Codex" - ), "settings.lag.body": ( "*Лаг карточки*\n\n" "Окно сглаживания правок live-карточки.\n" @@ -1091,22 +1073,14 @@ "*设置*\n\n" "代理: `{agent}`\n" "语言: `{language}`\n" - "预览: `{previews}`\n" "卡片延迟: `{live_lag}秒`\n" "语音: `{voice}`\n\n" "_点击分组进行更改。_" ), "settings.group.agent": "代理", "settings.group.language": "语言", - "settings.group.previews": "预览", "settings.group.live_lag": "卡片延迟", "settings.group.voice": "语音", - "settings.previews.body": ( - "*预览*\n\n" - "选择器中如何呈现会话名:\n" - "• `economical` — 本地回退,不额外调用模型\n" - "• `readable` — 通过 Haiku (Claude) 或轻量 Codex 模型生成并缓存摘要" - ), "settings.lag.body": ( "*实时预览延迟*\n\n" "实时卡片编辑的合并窗口。\n" diff --git a/src/ccbot/session.py b/src/ccbot/session.py index ac1598fc..79a1349d 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -832,8 +832,6 @@ def delete_session(self, session_id: str) -> bool: DEFAULT_USER_SETTINGS: ClassVar[dict[str, Any]] = { "language": "en", # "en" | "ru" | "zh" — UI strings - # ``readable`` uses Haiku for Claude or CODEX_NAMING_MODEL for Codex. - "previews": "economical", "live_lag": 4, # seconds, see PREVIEW_LIVE_LAG "voice": "auto", # "auto" | "whisper" | "apple" | "off" # Day-of-week the Anthropic weekly window resets on. Drives the %/d diff --git a/src/ccbot/session_claude_io.py b/src/ccbot/session_claude_io.py index 131d50fd..12061e6c 100644 --- a/src/ccbot/session_claude_io.py +++ b/src/ccbot/session_claude_io.py @@ -26,6 +26,20 @@ logger = logging.getLogger(__name__) +_RE_INJECTED_USER_MSG = re.compile( + r"<(bash-input|bash-stdout|bash-stderr|local-command-caveat|system-reminder)" +) +_RE_SYSTEM_UI_TEXT = re.compile( + r"^\s*(?:" + r"\[[^\]\n]+\]\s*$" + r"|Set (?:model|effort|thinking) to\b" + r"|Compact(?:ed|ing)\b" + r"|Cleared\b" + r"|Memory (?:updated|file)\b" + r")", + re.IGNORECASE, +) + def encode_cwd(cwd: str) -> str: """Encode a cwd path to match Claude Code's project-directory naming. @@ -49,14 +63,13 @@ def build_session_file_path(session_id: str, cwd: str) -> Path | None: def _parse_session_file(file_path: Path, session_id: str) -> ClaudeSession | None: - """Synchronously walk a JSONL once → summary, last user msg, tokens, count. + """Synchronously walk a JSONL once → last user msg, tokens, count. Kept sync (called via ``asyncio.to_thread``) because a single bulk ``read_text`` + per-line ``json.loads`` is several times faster than ``aiofiles``' ``async for line`` executor round-trips on large transcripts (multi-MB session files dominate the dir-picker path). """ - summary = "" last_user_msg = "" message_count = 0 token_total = 0 @@ -73,21 +86,24 @@ def _parse_session_file(file_path: Path, session_id: str) -> ClaudeSession | Non data = json.loads(line) except json.JSONDecodeError: continue - if data.get("type") == "summary": - s = data.get("summary", "") - if s: - summary = s - elif data.get("type") == "assistant": + if data.get("type") == "assistant": usage = (data.get("message") or {}).get("usage") or {} token_total += int(usage.get("input_tokens", 0) or 0) token_total += int(usage.get("output_tokens", 0) or 0) elif TranscriptParser.is_user_message(data): parsed = TranscriptParser.parse_message(data) - if parsed and parsed.text.strip(): - last_user_msg = parsed.text.strip() - - if not summary: - summary = last_user_msg[:50] if last_user_msg else "Untitled" + if not parsed or parsed.message_type != "user": + continue + text = parsed.text.strip() + if ( + not text + or _RE_INJECTED_USER_MSG.search(text) + or _RE_SYSTEM_UI_TEXT.match(text) + ): + continue + last_user_msg = " ".join(text.split()) + + summary = last_user_msg[:50] if last_user_msg else "Untitled" return ClaudeSession( session_id=session_id, @@ -101,8 +117,9 @@ def _parse_session_file(file_path: Path, session_id: str) -> ClaudeSession | Non async def get_session_direct(session_id: str, cwd: str) -> ClaudeSession | None: """Load a ``ClaudeSession`` from session_id + cwd, with glob fallback. - Walks the JSONL once (in a worker thread) to extract the latest - summary, last user message, cumulative token usage, and message count. + Walks the JSONL once (in a worker thread) to extract the last user + message, cumulative token usage, and message count. Model-generated + ``summary`` rows are intentionally ignored for the picker description. """ file_path = build_session_file_path(session_id, cwd) if not file_path or not file_path.exists(): diff --git a/tests/ccbot/handlers/test_archive_render.py b/tests/ccbot/handlers/test_archive_render.py index 8d2fe835..57b6cdc1 100644 --- a/tests/ccbot/handlers/test_archive_render.py +++ b/tests/ccbot/handlers/test_archive_render.py @@ -22,9 +22,7 @@ from __future__ import annotations -import asyncio import time -from unittest.mock import AsyncMock from unittest.mock import patch import pytest @@ -255,10 +253,8 @@ def reset_blurb_cache(self): from ccbot.handlers import archive archive._BLURB_CACHE.clear() - archive._BLURB_SUMMARY_INFLIGHT.clear() yield archive._BLURB_CACHE.clear() - archive._BLURB_SUMMARY_INFLIGHT.clear() @pytest.mark.asyncio async def test_three_short_messages_all_included( @@ -360,12 +356,10 @@ async def test_long_first_message_included_whole( assert out == long_msg @pytest.mark.asyncio - async def test_readable_codex_blurb_generates_cached_summary_in_background( + async def test_archive_blurb_always_uses_user_messages( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """Archive opens on the local blurb, while Codex Luna fills the - persistent readable-preview cache without blocking the page.""" - from ccbot import naming + """A stale model-summary cache cannot replace the user's words.""" from ccbot.handlers import archive sess = Session( @@ -382,37 +376,12 @@ async def test_readable_codex_blurb_generates_cached_summary_in_background( "_collect_user_messages", _fake_collect(local), ) - monkeypatch.setattr( - archive.session_manager, - "get_user_settings", - lambda _uid: {"previews": "readable"}, - ) - monkeypatch.setattr( - archive.session_manager, - "get_cached_summary", - lambda *_args: None, - ) - generated = AsyncMock(return_value="archive latency") - monkeypatch.setattr(naming, "generate_name", generated) - cache_written = asyncio.Event() - cached: list[tuple[str, str, float]] = [] - - def set_cached(sid: str, summary: str, mtime: float) -> None: - cached.append((sid, summary, mtime)) - cache_written.set() - - monkeypatch.setattr( - archive.session_manager, - "set_cached_summary", - set_cached, - ) - - initial = await archive._archive_blurb(sess, user_id=42) - await asyncio.wait_for(cache_written.wait(), timeout=1.0) + archive.session_manager.summary_cache["codex-5"] = { + "summary": "archive latency", + "mtime": 0.0, + } - assert initial == local - generated.assert_awaited_once_with(local, backend="codex") - assert cached[0][:2] == ("codex-5", "archive latency") + assert await archive._archive_blurb(sess) == local @pytest.mark.asyncio async def test_codex_blurb_reads_user_messages_from_rollout( diff --git a/tests/ccbot/test_codex_backend.py b/tests/ccbot/test_codex_backend.py index 9993e0bc..314bb7fa 100644 --- a/tests/ccbot/test_codex_backend.py +++ b/tests/ccbot/test_codex_backend.py @@ -37,7 +37,6 @@ def test_agent_backend_is_exposed_in_settings( "get_user_settings", lambda _uid: { "language": "en", - "previews": "economical", "live_lag": 4, "voice": "auto", }, @@ -320,9 +319,7 @@ async def test_restore_codex_archive_does_not_wait_for_hook( @pytest.mark.asyncio -async def test_codex_readable_previews_use_lightweight_codex_model( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_codex_picker_description_uses_user_message(tmp_path: Path) -> None: rollout = tmp_path / "rollout.jsonl" rollout.write_text("{}\n") session = SimpleNamespace( @@ -330,31 +327,9 @@ async def test_codex_readable_previews_use_lightweight_codex_model( summary="investigate archive latency", file_path=str(rollout), ) - monkeypatch.setattr(session_manager, "agent_backend", "codex") - monkeypatch.setattr( - session_manager, - "get_user_settings", - lambda _uid: {"previews": "readable"}, - ) - monkeypatch.setattr(session_manager, "get_cached_summary", lambda *_args: None) - - generated = AsyncMock(return_value="archive latency") - monkeypatch.setattr(dir_browser_cb, "generate_name", generated) - cached: list[tuple[str, str, float]] = [] - cache_written = asyncio.Event() - - def set_cached(sid: str, summary: str, mtime: float) -> None: - cached.append((sid, summary, mtime)) - cache_written.set() - - monkeypatch.setattr(session_manager, "set_cached_summary", set_cached) - initial = await dir_browser_cb.resolve_session_summaries([session], user_id=42) - await asyncio.wait_for(cache_written.wait(), timeout=1.0) assert initial == {"codex-session": "investigate archive latency"} - generated.assert_awaited_once_with("investigate archive latency", backend="codex") - assert cached[0][:2] == ("codex-session", "archive latency") def test_codex_rollout_normalizes_text_and_tools() -> None: diff --git a/tests/ccbot/test_session_claude_io.py b/tests/ccbot/test_session_claude_io.py index 09fd6fdf..541b9b46 100644 --- a/tests/ccbot/test_session_claude_io.py +++ b/tests/ccbot/test_session_claude_io.py @@ -1,6 +1,7 @@ """Tests for session_claude_io — encode_cwd + path-builder pure logic.""" from pathlib import Path +import json import pytest @@ -36,6 +37,33 @@ def test_uses_encoded_cwd(self) -> None: assert "-x-y-z" in str(p) +class TestParseSessionFile: + def test_picker_description_uses_user_message_not_model_summary( + self, tmp_path: Path + ) -> None: + transcript = tmp_path / "session.jsonl" + rows = [ + {"type": "summary", "summary": "model generated description"}, + { + "type": "user", + "message": {"content": "Верни описание из моего сообщения"}, + }, + { + "type": "user", + "message": {"content": "internal"}, + }, + ] + transcript.write_text( + "\n".join(json.dumps(row, ensure_ascii=False) for row in rows), + encoding="utf-8", + ) + + parsed = session_claude_io._parse_session_file(transcript, "sid") + + assert parsed is not None + assert parsed.summary == "Верни описание из моего сообщения" + + @pytest.mark.asyncio class TestGetSessionDirect: async def test_missing_file_returns_none(self, tmp_path: Path) -> None: