Files
mini-openclaw-v2/backend/graph/session_manager.py
T
qiaoqichaoandClaude Code d56c699ce6 chore(v2): 从兴邦预审v2白名单抽取独立可运行基座
按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 12:58:26 +08:00

243 lines
8.5 KiB
Python

"""Session Manager — JSON file-based conversation persistence with metadata."""
import json
import time
from pathlib import Path
from typing import Any
class SessionManager:
"""Manages conversation history as JSON files in sessions/ directory.
Storage format (v2):
{
"title": "会话标题",
"created_at": 1706000000,
"updated_at": 1706000100,
"messages": [{"role": "user", "content": "..."}, ...]
}
"""
def __init__(self) -> None:
self._sessions_dir: Path | None = None
def initialize(self, base_dir: Path) -> None:
self._sessions_dir = base_dir / "sessions"
self._sessions_dir.mkdir(exist_ok=True)
def _session_path(self, session_id: str) -> Path:
assert self._sessions_dir is not None
safe_id = "".join(c for c in session_id if c.isalnum() or c in "-_")
return self._sessions_dir / f"{safe_id}.json"
def _read_file(self, session_id: str) -> dict[str, Any]:
"""Read session file and normalize to v2 format."""
path = self._session_path(session_id)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
# Migrate v1 (plain list) to v2 (object with metadata)
if isinstance(data, list):
now = time.time()
return {
"title": session_id,
"created_at": path.stat().st_ctime,
"updated_at": now,
"messages": data,
}
return data
except (json.JSONDecodeError, Exception):
return {}
def _write_file(self, session_id: str, data: dict[str, Any]) -> None:
"""Write session data to file."""
data["updated_at"] = time.time()
path = self._session_path(session_id)
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def create_session(self, session_id: str) -> dict[str, Any]:
"""Create a new empty session. Returns metadata."""
now = time.time()
data: dict[str, Any] = {
"title": "New Chat",
"created_at": now,
"updated_at": now,
"messages": [],
}
self._write_file(session_id, data)
return {"id": session_id, "title": data["title"], "created_at": now, "updated_at": now}
def load_session(self, session_id: str) -> list[dict[str, Any]]:
"""Load conversation history for a session."""
data = self._read_file(session_id)
if not data:
return []
return data.get("messages", [])
def save_message(
self,
session_id: str,
role: str,
content: str,
tool_calls: list[dict[str, Any]] | None = None,
) -> None:
"""Append a message to the session history."""
data = self._read_file(session_id)
if not data:
now = time.time()
data = {
"title": "New Chat",
"created_at": now,
"updated_at": now,
"messages": [],
}
msg: dict[str, Any] = {"role": role, "content": content}
if tool_calls:
msg["tool_calls"] = tool_calls
data["messages"].append(msg)
self._write_file(session_id, data)
def rename_session(self, session_id: str, title: str) -> None:
"""Rename a session."""
data = self._read_file(session_id)
if not data:
raise FileNotFoundError(f"Session {session_id} not found")
data["title"] = title
self._write_file(session_id, data)
def update_title(self, session_id: str, title: str) -> None:
"""Update session title (alias for rename_session)."""
self.rename_session(session_id, title)
def delete_session(self, session_id: str) -> None:
"""Delete a session file."""
path = self._session_path(session_id)
if path.exists():
path.unlink()
def get_raw_messages(self, session_id: str) -> dict[str, Any]:
"""Return the complete session data including all messages."""
data = self._read_file(session_id)
if not data:
return {"title": "", "messages": []}
return data
def list_sessions(self) -> list[dict[str, Any]]:
"""List all sessions with metadata."""
assert self._sessions_dir is not None
sessions: list[dict[str, Any]] = []
for f in sorted(self._sessions_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
raw = json.loads(f.read_text(encoding="utf-8"))
if isinstance(raw, dict):
title = raw.get("title", f.stem)
updated_at = raw.get("updated_at", f.stat().st_mtime)
else:
title = f.stem
updated_at = f.stat().st_mtime
except Exception:
title = f.stem
updated_at = f.stat().st_mtime
sessions.append({
"id": f.stem,
"title": title,
"updated_at": updated_at,
})
return sessions
def compress_history(
self, session_id: str, summary: str, num_to_remove: int
) -> None:
"""Archive first N messages and store summary as compressed_context."""
assert self._sessions_dir is not None
data = self._read_file(session_id)
if not data:
return
messages = data.get("messages", [])
archived_messages = messages[:num_to_remove]
# Archive to sessions/archive/
archive_dir = self._sessions_dir / "archive"
archive_dir.mkdir(exist_ok=True)
archive_data = {
"session_id": session_id,
"archived_at": time.time(),
"messages": archived_messages,
}
archive_path = archive_dir / f"{session_id}_{int(time.time())}.json"
archive_path.write_text(
json.dumps(archive_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Remove archived messages from session
data["messages"] = messages[num_to_remove:]
# Append summary to compressed_context (support multiple compressions)
existing_context = data.get("compressed_context", "")
if existing_context:
data["compressed_context"] = existing_context + "\n---\n" + summary
else:
data["compressed_context"] = summary
self._write_file(session_id, data)
def get_compressed_context(self, session_id: str) -> str | None:
"""Return compressed context if any."""
data = self._read_file(session_id)
if not data:
return None
return data.get("compressed_context")
def load_session_for_agent(self, session_id: str) -> list[dict[str, Any]]:
"""Load session history merged for LLM context.
Since we now save multiple consecutive assistant messages per turn,
this method combines them back into single assistant messages to
maintain proper user/assistant alternation for the LLM.
If compressed_context exists, inserts it at the head as an assistant
message so the LLM retains prior context.
"""
data = self._read_file(session_id)
messages = data.get("messages", []) if data else []
merged: list[dict[str, Any]] = []
# Inject compressed context as the first message if available
compressed = data.get("compressed_context", "") if data else ""
if compressed:
merged.append({
"role": "assistant",
"content": f"[以下是之前对话的摘要]\n{compressed}",
})
for msg in messages:
if (
merged
and merged[-1]["role"] == "assistant"
and msg["role"] == "assistant"
):
# Combine consecutive assistant messages
merged[-1]["content"] += "\n" + msg["content"]
else:
# Strip tool_calls for agent context (not needed by LLM)
merged.append({"role": msg["role"], "content": msg["content"]})
return merged
def get_message_count(self, session_id: str) -> int:
"""Return the number of messages in a session."""
data = self._read_file(session_id)
if not data:
return 0
return len(data.get("messages", []))
session_manager = SessionManager()