diff --git a/AGENTS.md b/AGENTS.md index db0b2b0..4fa3086 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,10 @@ 行动时必须以运行时注入的环境信息为准,特别是平台、shell、工作目录和可用工具列表。 +REPL 内的会话管理命令优先使用 slash command。 + +`/history`、`/resume`、`/new` 由程序直接处理,不进入 agent loop。 + 未明确说明时,使用以下默认值: - 工作目录:当前进程目录 diff --git a/SKILLS/cli-core.md b/SKILLS/cli-core.md index de2856a..225935d 100644 --- a/SKILLS/cli-core.md +++ b/SKILLS/cli-core.md @@ -23,6 +23,7 @@ - 搜索文件内容时,优先使用 `Grep`,而不是 `Bash` - 修改已有文件时,优先使用 `Edit`,而不是 `Bash` 或 `Write` - 需要创建或覆盖文件时,优先使用 `Write` 工具,而不是 `Bash` +- 会话管理优先使用 slash command,而不是自然语言或 `Bash` 探查 session 文件 - Windows 下先验证 shell 兼容性,再选择命令写法 ## 沟通风格 diff --git a/src/cc_slim/engine.py b/src/cc_slim/engine.py index 20c6212..d6d22b0 100644 --- a/src/cc_slim/engine.py +++ b/src/cc_slim/engine.py @@ -13,6 +13,7 @@ from typing import Any, Iterator from anthropic import Anthropic from openai import OpenAI +from cc_slim.session import SessionStore from cc_slim.tools import Tool @@ -57,10 +58,20 @@ def resolve_config(workspace: Path, cli: dict[str, Any]) -> Config: class Agent: - def __init__(self, config: Config, tools: list[Tool], workspace: Path) -> None: + def __init__( + self, + config: Config, + tools: list[Tool], + workspace: Path, + session_store: SessionStore | None = None, + session_id: str | None = None, + history: list[dict[str, Any]] | None = None, + ) -> None: self.config = config self.tools = {tool.name: tool for tool in tools} - self.history: list[dict[str, Any]] = [] + self.history: list[dict[str, Any]] = list(history or []) + self.session_store = session_store + self.session_id = session_id self.system_prompt = self._build_system_prompt(workspace) self.client = self._build_client() @@ -74,7 +85,9 @@ class Agent: return "".join(parts).strip() or "(empty response)" def stream_reply(self, user_input: str) -> Iterator[dict[str, Any]]: - self.history.append({"role": "user", "content": user_input}) + user_message = {"role": "user", "content": user_input} + self.history.append(user_message) + self._save_message(user_message) for _ in range(self.config.max_turns): try: @@ -87,6 +100,7 @@ class Agent: return self.history.append(result["assistant"]) + self._save_message(result["assistant"]) if not result["tool_calls"]: yield {"type": "done"} @@ -104,6 +118,7 @@ class Agent: "content": tool_output, } ) + self._save_message(self.history[-1]) yield {"type": "error", "message": "已达到最大工具循环轮数,停止执行。"} @@ -345,6 +360,10 @@ class Agent: "input": payload, } + def _save_message(self, message: dict[str, Any]) -> None: + if self.session_store and self.session_id: + self.session_store.append_message(self.session_id, message) + def _load_file_config(path: Path) -> dict[str, Any]: if not path.exists(): diff --git a/src/cc_slim/main.py b/src/cc_slim/main.py index d83adcf..99f165c 100644 --- a/src/cc_slim/main.py +++ b/src/cc_slim/main.py @@ -5,8 +5,10 @@ from typing import Optional import typer from rich.console import Console +from rich.table import Table from cc_slim.engine import Agent, resolve_config +from cc_slim.session import SessionStore from cc_slim.tools import build_default_tools app = typer.Typer(add_completion=False, no_args_is_help=False) @@ -36,6 +38,77 @@ def render_stream(agent: Agent, user_input: str) -> None: return +def render_history(store: SessionStore) -> None: + sessions = store.list_sessions() + if not sessions: + console.print("当前工作目录没有历史 session。") + return + + table = Table(show_header=True) + table.add_column("#") + table.add_column("session_id") + table.add_column("title") + table.add_column("updated_at") + table.add_column("count") + for index, item in enumerate(sessions, start=1): + table.add_row( + str(index), + str(item.get("session_id", "")), + str(item.get("title", "")), + str(item.get("updated_at", "")), + str(item.get("message_count", 0)), + ) + console.print(table) + + +def build_agent( + root: Path, + config: object, + store: SessionStore, + session_meta: dict[str, object], + restored_history: list[dict[str, object]] | None = None, +) -> Agent: + return Agent( + config=config, + tools=build_default_tools(root), + workspace=root, + session_store=store, + session_id=str(session_meta["session_id"]), + history=restored_history or [], + ) + + +def handle_repl_command( + user_input: str, + store: SessionStore, + config: object, + root: Path, + agent: Agent, +) -> Agent: + command = user_input.strip() + if command in {"--history", "/history"}: + render_history(store) + return agent + + if command == "/new": + session_meta = store.create_session(config.model) + console.print(f"已创建新 session: {session_meta.get('session_id')}") + return build_agent(root, config, store, session_meta, []) + + if command.startswith("--resume ") or command.startswith("/resume "): + target = command.split(maxsplit=1)[1].strip() + if not target: + console.print("[red]error:[/red] 缺少 resume 目标") + return agent + session_id = store.resolve_session_id(target) + session_meta = store.load_meta(session_id) + restored_history = store.load_messages(session_id) + console.print(f"已恢复 session: {session_meta.get('session_id')}") + return build_agent(root, config, store, session_meta, restored_history) + + return agent + + @app.command() def run( prompt: Optional[str] = typer.Argument(None, help="单次执行的用户输入"), @@ -45,8 +118,15 @@ def run( base_url: Optional[str] = typer.Option(None, help="可选的 API Base URL"), cwd: Path = typer.Option(Path("."), help="工作区根目录"), max_turns: Optional[int] = typer.Option(None, help="最大工具循环轮数"), + history: bool = typer.Option(False, "--history", help="列出当前工作目录的历史 session"), + resume: Optional[str] = typer.Option(None, "--resume", help="按 session id 或序号恢复历史 session"), ) -> None: root = cwd.resolve() + store = SessionStore(root) + if history: + render_history(store) + return + config = resolve_config( root, { @@ -57,7 +137,17 @@ def run( "max_turns": max_turns, }, ) - agent = Agent(config=config, tools=build_default_tools(root), workspace=root) + session_meta: dict[str, object] + restored_history: list[dict[str, object]] = [] + if resume: + session_id = store.resolve_session_id(resume) + session_meta = store.load_meta(session_id) + restored_history = store.load_messages(session_id) + console.print(f"已恢复 session: {session_meta.get('session_id')}") + else: + session_meta = store.create_session(config.model) + + agent = build_agent(root, config, store, session_meta, restored_history) if prompt: render_stream(agent, prompt) @@ -76,6 +166,13 @@ def run( if not user_input.strip(): continue + if user_input.strip().startswith("/") or user_input.strip().startswith("--history") or user_input.strip().startswith("--resume"): + try: + agent = handle_repl_command(user_input, store, config, root, agent) + except Exception as exc: # pragma: no cover + console.print(f"[red]error:[/red] {exc}") + continue + try: render_stream(agent, user_input) except Exception as exc: # pragma: no cover diff --git a/src/cc_slim/session.py b/src/cc_slim/session.py new file mode 100644 index 0000000..6fe0050 --- /dev/null +++ b/src/cc_slim/session.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime +from pathlib import Path +from typing import Any + + +class SessionStore: + def __init__(self, cwd: Path) -> None: + self.cwd = cwd.resolve() + self.root = Path.home() / ".config" / "cc-slim" / "sessions" / self._sanitize_cwd(self.cwd) + self.root.mkdir(parents=True, exist_ok=True) + + def create_session(self, model: str) -> dict[str, Any]: + now = datetime.now().strftime("%Y%m%d-%H%M%S") + cwd_hash = hashlib.sha1(str(self.cwd).encode("utf-8")).hexdigest()[:8] + session_id = f"{now}-{cwd_hash}" + meta = { + "session_id": session_id, + "title": "", + "cwd": str(self.cwd), + "model": model, + "created_at": datetime.now().isoformat(timespec="seconds"), + "updated_at": datetime.now().isoformat(timespec="seconds"), + "message_count": 0, + } + self._write_meta(session_id, meta) + self._messages_path(session_id).write_text("", encoding="utf-8") + return meta + + def append_message(self, session_id: str, message: dict[str, Any]) -> None: + with self._messages_path(session_id).open("a", encoding="utf-8") as fh: + fh.write(json.dumps(message, ensure_ascii=False) + "\n") + + meta = self.load_meta(session_id) + meta["message_count"] = int(meta.get("message_count", 0)) + 1 + meta["updated_at"] = datetime.now().isoformat(timespec="seconds") + if not meta.get("title") and message.get("role") == "user": + meta["title"] = self._make_title(str(message.get("content", ""))) + self._write_meta(session_id, meta) + + def list_sessions(self) -> list[dict[str, Any]]: + sessions: list[dict[str, Any]] = [] + for path in self.root.glob("*.meta.json"): + sessions.append(json.loads(path.read_text(encoding="utf-8"))) + sessions.sort(key=lambda item: item.get("updated_at", ""), reverse=True) + return sessions + + def load_messages(self, session_id: str) -> list[dict[str, Any]]: + path = self._messages_path(session_id) + if not path.exists(): + return [] + messages: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if line.strip(): + messages.append(json.loads(line)) + return messages + + def load_meta(self, session_id: str) -> dict[str, Any]: + return json.loads(self._meta_path(session_id).read_text(encoding="utf-8")) + + def resolve_session_id(self, value: str) -> str: + sessions = self.list_sessions() + if value.isdigit(): + index = int(value) - 1 + if 0 <= index < len(sessions): + return str(sessions[index]["session_id"]) + raise ValueError(f"session index 不存在: {value}") + + for item in sessions: + session_id = str(item.get("session_id", "")) + if session_id == value or session_id.startswith(value): + return session_id + raise ValueError(f"session 不存在: {value}") + + def _messages_path(self, session_id: str) -> Path: + return self.root / f"{session_id}.jsonl" + + def _meta_path(self, session_id: str) -> Path: + return self.root / f"{session_id}.meta.json" + + def _write_meta(self, session_id: str, meta: dict[str, Any]) -> None: + self._meta_path(session_id).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + + def _sanitize_cwd(self, cwd: Path) -> str: + text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(cwd)) + digest = hashlib.sha1(str(cwd).encode("utf-8")).hexdigest()[:8] + return f"{text[:48]}-{digest}" + + def _make_title(self, text: str) -> str: + compact = " ".join(text.strip().split()) or "新会话" + return compact[:48] diff --git a/src/cc_slim/tools.py b/src/cc_slim/tools.py index a436dea..e328827 100644 --- a/src/cc_slim/tools.py +++ b/src/cc_slim/tools.py @@ -188,6 +188,8 @@ def bash_tool(workspace: Path, data: dict[str, Any]) -> str: shell=True, capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=60, ) payload = {