Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .claude/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/...)
Expand Down Expand Up @@ -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 /
Expand Down
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 弹窗的采样间隔 |
Expand Down Expand Up @@ -237,8 +237,8 @@ transcript 表面触手可及。多数用户一旦发现菜单,就再也不打 s
路由到该会话,但不更改活动会话。

*菜单 → Archive* 显示带编号的历史会话列表,每行两个按钮。每行
携带一段简短描述(Claude 自己的 `type=summary` 条目,或第一条
用户消息),这样一眼就能看出会话是关于什么的。点击会话,carrier
携带一段只取自用户最初消息的简短描述,这样一眼就能看出会话是
关于什么的;模型生成的摘要不会替换这些文字。点击会话,carrier
会画出直接从磁盘 JSONL 读取的真实转录;*Restore* / *Delete*
保留在底部。

Expand Down Expand Up @@ -282,7 +282,6 @@ Enter / Esc 键盘。
| `卡片历史` | `20` | 从 JSONL 预加载进新卡片的 end-of-turn 边界数(机器人重启后仍在) |
| `页面大小` | `20` 行 | 每页最多行数;长正文按段落/句子边界跨页切分 |
| `内联截图` | `off` | 卡片变为图片 + 说明文字,图片是实时面板渲染(说明限 1024 字符,需相应调小页面大小) |
| `预览` | `economical` | 切换器预览的详细程度 |
| `实时延迟` | `4s` | 预览更新的合并窗口 |

Telegram 聊天头部的 **`正在输入…`** 指示由真实的 claude 事件驱动。
Expand Down
10 changes: 5 additions & 5 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -273,9 +273,10 @@ Reply-цитата на сообщение бота из неактивной с
текст туда разово, без смены активной.

*Меню → Archive* показывает пронумерованный список прошлых сессий
по две кнопки в ряд. У каждой строки — короткое описание (Claude'овый
`type=summary` или первое сообщение пользователя), чтобы сразу было
понятно, о чём была сессия. Тап по сессии — carrier рисует реальный
по две кнопки в ряд. У каждой строки - короткое описание только из
первых сообщений пользователя, чтобы сразу было понятно, о чём была
сессия. Модельное саммари этот текст не подменяет. Тап по сессии -
carrier рисует реальный
transcript прямо с диска (JSONL); *Restore* / *Delete* остаются в
футере.

Expand Down Expand Up @@ -323,7 +324,6 @@ foreground-промпте.
| `История в карточке` | `20` | сколько end-of-turn-границ подгружается в свежую карточку из JSONL (переживает рестарт бота) |
| `Размер страницы` | `20` строк | максимум строк на страницу; длинное тело режется по границам абзацев/предложений |
| `Скрины в карточке` | `off` | карточка становится фото + подпись, фото — рендер живой панели (лимит подписи 1024 символа, уменьшай размер страницы) |
| `Превью` | `economical` | локальные подписи или кэшируемые саммари через Haiku / `CODEX_NAMING_MODEL` |
| `Лаг карточки` | `4s` | окно коалесцинга обновлений превью |

Индикатор Telegram **`печатает…`** в шапке чата управляется
Expand Down
68 changes: 4 additions & 64 deletions src/ccbot/bot/callbacks/dir_browser.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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(
Expand Down
8 changes: 0 additions & 8 deletions src/ccbot/bot/callbacks/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -286,7 +284,6 @@ async def handle(

setter_prefixes = (
CB_ST_AGENT,
CB_ST_PREV,
CB_ST_LAG,
CB_ST_VOICE,
CB_ST_LANG,
Expand All @@ -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) :])
Expand Down
5 changes: 2 additions & 3 deletions src/ccbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
64 changes: 9 additions & 55 deletions src/ccbot/handlers/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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] = ""
Expand Down
1 change: 0 additions & 1 deletion src/ccbot/handlers/callback_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@
# Settings (toggle screens)
CB_ST_GRP = "st:grp:" # st:grp:<name> open a per-group settings screen
CB_ST_LANG = "st:lng:" # st:lng:<code>
CB_ST_PREV = "st:prev:" # st:prev:<value>
CB_ST_LAG = "st:lag:" # st:lag:<value>
CB_ST_VOICE = "st:voice:" # st:voice:<value>
CB_ST_WDAY = "st:wday:" # st:wday:<mon|tue|...|sun>
Expand Down
Loading
Loading