OpenSpace/openspace/services/session/restore.py
2026-07-17 11:43:42 +08:00

978 lines
33 KiB
Python

"""Session discovery and resume restoration.
OpenSpace keeps the project-scoped append-only transcript from
``session_storage.py`` as the resume source of truth. This module is the
runtime-facing layer: lightweight listing, cross-project decisions, transcript
deserialization, cost/metadata/worktree recovery, and TodoWrite state hydration.
"""
from __future__ import annotations
import dataclasses
import json
import os
import shlex
import subprocess
from collections.abc import Sequence as SequenceABC
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence
from openspace.services.memory.paths import find_project_root
from openspace.services.session.recovery import (
deserialize_for_resume as conversation_deserialize_for_resume,
)
from openspace.persistence.file_history import copy_file_history_for_resume
from openspace.services.session.storage import (
METADATA_FILENAME,
TRANSCRIPT_FILENAME,
SessionLoadResult,
SessionStorage,
get_projects_dir,
get_sessions_dir,
)
from openspace.tools.todo_tool import (
TODO_WRITE_TOOL_ALIAS,
TODO_WRITE_TOOL_NAME,
TodoItem,
normalize_todos,
validate_todo_payload,
)
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
DEFAULT_DISCOVERY_LIMIT = 50
DEFAULT_PAGE_SIZE = 20
TAIL_READ_BYTES = 64 * 1024
@dataclass(slots=True)
class CrossProjectResumeResult:
is_cross_project: bool
is_same_repo_worktree: bool = False
project_path: str | None = None
command: str | None = None
def to_dict(self) -> dict[str, Any]:
payload = {
"is_cross_project": self.is_cross_project,
"isCrossProject": self.is_cross_project,
"is_same_repo_worktree": self.is_same_repo_worktree,
"isSameRepoWorktree": self.is_same_repo_worktree,
}
if self.project_path:
payload["project_path"] = self.project_path
payload["projectPath"] = self.project_path
if self.command:
payload["command"] = self.command
return payload
@dataclass(slots=True)
class SessionSummary:
session_id: str
title: str
first_prompt: str
cwd: str
project_root: str
model: str | None
created_at: str
last_active_at: str
message_count: int
cost_usd: float | None
tag: str | None
mode: str
agent_name: str | None
agent_type: str | None
session_dir: str
transcript_path: str
same_project: bool
cross_project: bool
cross_project_result: CrossProjectResumeResult = field(
default_factory=lambda: CrossProjectResumeResult(False)
)
def to_dict(self) -> dict[str, Any]:
return {
"session_id": self.session_id,
"title": self.title,
"first_prompt": self.first_prompt,
"preview": self.first_prompt,
"cwd": self.cwd,
"project_root": self.project_root,
"project_path": self.project_root or self.cwd,
"worktree_path": _worktree_path_from_summary(self),
"model": self.model,
"created_at": self.created_at,
"updated_at": self.last_active_at,
"last_active_at": self.last_active_at,
"message_count": self.message_count,
"cost": self.cost_usd,
"cost_usd": self.cost_usd,
"tag": self.tag,
"mode": self.mode,
"agent_name": self.agent_name,
"agent_type": self.agent_type,
"session_dir": self.session_dir,
"transcript_path": self.transcript_path,
"same_project": self.same_project,
"cross_project": self.cross_project,
"cross_project_result": self.cross_project_result.to_dict(),
}
@dataclass(slots=True)
class SessionDiscoveryResult:
sessions: list[SessionSummary]
total: int
page: int
page_size: int
has_more: bool
def to_dict(self) -> dict[str, Any]:
return {
"sessions": [session.to_dict() for session in self.sessions],
"total": self.total,
"page": self.page,
"page_size": self.page_size,
"has_more": self.has_more,
}
@dataclass(slots=True)
class ResumeDeserializationResult:
messages: list[dict[str, Any]]
turn_interruption_state: dict[str, Any]
inserted_sentinel: bool = False
inserted_continuation: bool = False
@dataclass(slots=True)
class RestoredSession:
session_id: str
session_record: dict[str, Any]
title: str | None
mode: str | None
metadata: dict[str, Any]
runtime: dict[str, Any]
messages: list[dict[str, Any]]
cost: dict[str, Any] | None
cost_total: float | None
agent: dict[str, Any] | None
standalone_agent_context: dict[str, Any] | None
worktree: dict[str, Any] | None
file_history_snapshots: list[dict[str, Any]]
content_replacements: list[dict[str, Any]]
transcript_segments: list[dict[str, Any]]
todo_state: dict[str, list[dict[str, str]]]
turn_interruption_state: dict[str, Any]
session_dir: str
transcript_path: str
def to_dict(self) -> dict[str, Any]:
return dataclasses.asdict(self)
class CrossProjectResumeRequired(RuntimeError):
"""Raised when a session should be resumed from its original project."""
def __init__(self, result: CrossProjectResumeResult) -> None:
self.result = result
super().__init__(
result.command
or f"Session belongs to a different project: {result.project_path}"
)
async def discover_sessions(
cwd: str | Path,
*,
limit: int = DEFAULT_DISCOVERY_LIMIT,
page: int = 0,
page_size: int = DEFAULT_PAGE_SIZE,
all_projects: bool = False,
same_repo: bool = True,
config_home: str | Path | None = None,
) -> SessionDiscoveryResult:
"""Discover resumable sessions without loading full transcripts."""
current_cwd = _resolve_path(cwd)
current_project_root = str(find_project_root(current_cwd))
worktree_paths = _git_worktree_paths(current_project_root)
scan_all = bool(all_projects or same_repo)
summaries_by_id: dict[str, SessionSummary] = {}
for session_dir in _iter_session_dirs(
current_project_root,
config_home=config_home,
all_projects=scan_all,
):
summary = _summary_from_session_dir(
session_dir,
current_project_root=current_project_root,
current_cwd=str(current_cwd),
worktree_paths=worktree_paths,
)
if summary is None:
continue
include = all_projects or summary.same_project
if not include and same_repo:
include = summary.cross_project_result.is_same_repo_worktree
if not include:
continue
existing = summaries_by_id.get(summary.session_id)
if existing is None or _sort_timestamp(summary) >= _sort_timestamp(existing):
summaries_by_id[summary.session_id] = summary
summaries = sorted(
summaries_by_id.values(),
key=lambda item: (_sort_timestamp(item), item.session_id),
reverse=True,
)
normalized_limit = max(0, int(limit or 0))
if normalized_limit:
summaries = summaries[:normalized_limit]
normalized_page_size = max(1, int(page_size or DEFAULT_PAGE_SIZE))
normalized_page = max(0, int(page or 0))
start = normalized_page * normalized_page_size
page_items = summaries[start : start + normalized_page_size]
return SessionDiscoveryResult(
sessions=page_items,
total=len(summaries),
page=normalized_page,
page_size=normalized_page_size,
has_more=start + normalized_page_size < len(summaries),
)
async def restore_session(
session_id: str,
*,
cwd: str | Path | None = None,
fork: bool = False,
allow_cross_project: bool = False,
context: Any | None = None,
config_home: str | Path | None = None,
) -> RestoredSession:
"""Restore a session from SessionStorage and hydrate runtime state."""
current_cwd = _resolve_path(cwd or os.getcwd())
current_project_root = str(find_project_root(current_cwd))
if fork:
source = SessionStorage.for_session(
str(session_id),
cwd=current_cwd,
config_home=config_home,
create=False,
)
loaded = source.load()
metadata = dict(loaded.metadata)
metadata["forked_from"] = str(session_id)
metadata["cwd"] = str(current_cwd)
metadata["project_root"] = current_project_root
metadata["project_path"] = current_project_root
storage = SessionStorage.create_new(
cwd=current_cwd,
model=_none_or_str(metadata.get("model")),
config_home=config_home,
metadata=metadata,
)
await storage.replace_messages(loaded.messages, metadata_patch=metadata)
await copy_file_history_for_resume(source, storage, loaded.file_history_snapshots)
loaded = storage.load()
effective_session_id = storage.session_id
else:
storage = SessionStorage.for_session(
str(session_id),
cwd=current_cwd,
config_home=config_home,
create=False,
)
loaded = storage.load()
effective_session_id = str(session_id)
summary = _summary_from_session_dir(
loaded.session_dir,
current_project_root=current_project_root,
current_cwd=str(current_cwd),
worktree_paths=_git_worktree_paths(current_project_root),
)
cross_project = (
summary.cross_project_result
if summary is not None
else check_cross_project_resume(
_summary_from_loaded(loaded, current_project_root, str(current_cwd)),
str(current_cwd),
_git_worktree_paths(current_project_root),
)
)
if (
cross_project.is_cross_project
and not cross_project.is_same_repo_worktree
and not allow_cross_project
):
raise CrossProjectResumeRequired(cross_project)
deserialized = deserialize_messages_for_resume(loaded.messages)
metadata = dict(loaded.metadata)
metadata["session_id"] = effective_session_id
metadata.setdefault("session_dir", str(loaded.session_dir))
metadata.setdefault("transcript_path", str(loaded.transcript_path))
runtime = dict(metadata.get("runtime") or {})
runtime["session_id"] = effective_session_id
if metadata.get("model"):
runtime.setdefault("model", metadata.get("model"))
cost_snapshot = _select_cost_snapshot(metadata, loaded)
cost_total = _extract_cost_usd(cost_snapshot or runtime)
if cost_total is not None:
runtime["cost_usd"] = cost_total
worktree = _restore_worktree_metadata(storage, metadata)
worktree_data = _mapping(worktree)
file_history = (
_canonical_file_history_entries(metadata.get("file_history_snapshots"))
or _canonical_file_history_entries(worktree_data.get("file_history_snapshots"))
or _canonical_file_history_entries(loaded.file_history_snapshots)
)
content_replacements = (
_dict_list(metadata.get("content_replacements"))
or _dict_list(worktree_data.get("content_replacements"))
or list(loaded.content_replacements)
)
if worktree is not None:
if file_history and "file_history_snapshots" not in worktree:
worktree["file_history_snapshots"] = file_history
if content_replacements and "content_replacements" not in worktree:
worktree["content_replacements"] = content_replacements
todos = extract_todos_from_transcript(deserialized.messages)
todo_key = _todo_key(context, effective_session_id)
todo_state = {todo_key: todos}
if context is not None:
existing = getattr(context, "todo_state", None)
if isinstance(existing, dict):
existing[todo_key] = todos
else:
try:
setattr(context, "todo_state", dict(todo_state))
except Exception:
pass
runtime["todo_state"] = dict(todo_state)
runtime["turn_interruption_state"] = dict(deserialized.turn_interruption_state)
agent = _agent_payload(metadata)
standalone_agent_context = _standalone_agent_context(metadata)
record = _session_record(metadata, runtime, worktree, file_history, content_replacements)
return RestoredSession(
session_id=effective_session_id,
session_record=record,
title=_none_or_str(metadata.get("title")),
mode=_none_or_str(metadata.get("mode")),
metadata=dict(metadata.get("metadata") or {}),
runtime=runtime,
messages=deserialized.messages,
cost=cost_snapshot,
cost_total=cost_total,
agent=agent,
standalone_agent_context=standalone_agent_context,
worktree=worktree,
file_history_snapshots=file_history,
content_replacements=content_replacements,
transcript_segments=list(loaded.transcript_segments),
todo_state=todo_state,
turn_interruption_state=deserialized.turn_interruption_state,
session_dir=str(loaded.session_dir),
transcript_path=str(loaded.transcript_path),
)
async def rewind_session(
session_id: str,
messages: Sequence[Mapping[str, Any]],
*,
cwd: str | Path | None = None,
config_home: str | Path | None = None,
model: str | None = None,
metadata_patch: Mapping[str, Any] | None = None,
cost: Mapping[str, Any] | None = None,
allow_cross_project: bool = True,
context: Any | None = None,
) -> RestoredSession:
"""Replace a session transcript and restore the resulting canonical session."""
storage = SessionStorage.for_session(
str(session_id),
cwd=cwd,
config_home=config_home,
create=True,
)
patch = dict(metadata_patch or {})
patch["session_id"] = str(session_id)
patch["last_task_id"] = None
patch["last_status"] = "rewound"
runtime = dict(patch.get("runtime") or {})
runtime.pop("active_task_id", None)
runtime["phase"] = "rewound"
runtime["session_id"] = str(session_id)
patch["runtime"] = runtime
if cost is not None:
patch["cost"] = dict(cost)
normalized_messages = [
dict(message) for message in messages if isinstance(message, Mapping)
]
await storage.replace_messages(
normalized_messages,
model=model,
metadata_patch=patch,
)
return await restore_session(
str(session_id),
cwd=cwd,
allow_cross_project=allow_cross_project,
context=context,
config_home=config_home,
)
def deserialize_messages_for_resume(
messages: Sequence[Mapping[str, Any]],
) -> ResumeDeserializationResult:
"""Make persisted OpenAI-shaped messages safe for resume."""
recovered = conversation_deserialize_for_resume(messages)
return ResumeDeserializationResult(
messages=recovered.messages,
turn_interruption_state=recovered.turn_interruption_state,
inserted_sentinel=recovered.inserted_sentinel,
inserted_continuation=recovered.inserted_continuation,
)
def extract_todos_from_transcript(
messages: Sequence[Mapping[str, Any]],
) -> list[dict[str, str]]:
"""Return the last valid TodoWrite payload from a transcript."""
for message in reversed(list(messages)):
if not isinstance(message, Mapping) or message.get("role") != "assistant":
continue
for payload in _iter_assistant_tool_payloads(message):
name = _none_or_str(payload.get("name"))
if name not in {TODO_WRITE_TOOL_NAME, TODO_WRITE_TOOL_ALIAS}:
continue
args = payload.get("input")
if not isinstance(args, Mapping):
return []
validation_error = validate_todo_payload(args)
if validation_error is not None:
return []
todos = normalize_todos(args.get("todos") or [])
if todos and all(todo.status == "completed" for todo in todos):
return []
return [todo.to_dict() for todo in todos if isinstance(todo, TodoItem)]
return []
def check_cross_project_resume(
session: SessionSummary,
current_cwd: str | Path,
worktree_paths: Sequence[str] | None = None,
) -> CrossProjectResumeResult:
"""Return the cross-project resume decision for a session."""
current_root = str(find_project_root(current_cwd))
project_path = session.project_root or session.cwd
if not project_path or _same_path(project_path, current_root) or _same_path(project_path, current_cwd):
return CrossProjectResumeResult(False)
normalized_worktrees = [str(Path(path).expanduser().resolve()) for path in worktree_paths or ()]
try:
resolved_project = str(Path(project_path).expanduser().resolve())
except Exception:
resolved_project = project_path
for worktree in normalized_worktrees:
if resolved_project == worktree or resolved_project.startswith(worktree + os.sep):
return CrossProjectResumeResult(
True,
is_same_repo_worktree=True,
project_path=project_path,
)
return CrossProjectResumeResult(
True,
is_same_repo_worktree=False,
project_path=project_path,
command=f"cd {shlex.quote(session.cwd or project_path)} && openspace --resume {shlex.quote(session.session_id)}",
)
def _iter_session_dirs(
cwd: str | Path,
*,
config_home: str | Path | None,
all_projects: bool,
) -> list[Path]:
if all_projects:
projects_dir = get_projects_dir(config_home)
if not projects_dir.exists():
return []
roots = [path / "sessions" for path in projects_dir.iterdir() if path.is_dir()]
else:
roots = [get_sessions_dir(cwd, config_home)]
result: list[Path] = []
for root in roots:
if not root.exists():
continue
for candidate in root.iterdir():
if not candidate.is_dir():
continue
if (candidate / TRANSCRIPT_FILENAME).exists() or (candidate / METADATA_FILENAME).exists():
result.append(candidate)
return result
def _summary_from_session_dir(
session_dir: Path,
*,
current_project_root: str,
current_cwd: str,
worktree_paths: Sequence[str],
) -> SessionSummary | None:
metadata = _read_json_object(session_dir / METADATA_FILENAME) or {}
transcript = session_dir / TRANSCRIPT_FILENAME
if not metadata and not transcript.exists():
return None
session_id = str(metadata.get("session_id") or session_dir.name)
cwd = str(metadata.get("cwd") or metadata.get("project_path") or metadata.get("project_root") or "")
project_root = str(metadata.get("project_root") or metadata.get("project_path") or cwd)
first_prompt = _first_prompt(transcript)
last_active = str(
metadata.get("last_active_at")
or metadata.get("updated_at")
or _mtime_iso(transcript)
or ""
)
summary = SessionSummary(
session_id=session_id,
title=str(metadata.get("title") or metadata.get("name") or first_prompt or session_id),
first_prompt=first_prompt,
cwd=cwd,
project_root=project_root,
model=_none_or_str(metadata.get("model") or _mapping(metadata.get("runtime")).get("model")),
created_at=str(metadata.get("created_at") or ""),
last_active_at=last_active,
message_count=int(metadata.get("message_count") or _count_messages(transcript)),
cost_usd=_extract_cost_usd(metadata.get("cost") or metadata.get("runtime")),
tag=_none_or_str(metadata.get("tag")),
mode=str(metadata.get("mode") or "normal"),
agent_name=_none_or_str(metadata.get("agent_name") or _mapping(metadata.get("agent")).get("name")),
agent_type=_none_or_str(metadata.get("agent_type") or _mapping(metadata.get("agent")).get("type")),
session_dir=str(session_dir),
transcript_path=str(transcript),
same_project=_same_path(project_root, current_project_root) or _same_path(cwd, current_cwd),
cross_project=False,
)
cross = check_cross_project_resume(summary, current_cwd, worktree_paths)
summary.cross_project_result = cross
summary.cross_project = cross.is_cross_project
summary.same_project = not cross.is_cross_project
return summary
def _summary_from_loaded(
loaded: SessionLoadResult,
current_project_root: str,
current_cwd: str,
) -> SessionSummary:
metadata = dict(loaded.metadata)
return SessionSummary(
session_id=loaded.session_id,
title=str(metadata.get("title") or loaded.session_id),
first_prompt="",
cwd=str(metadata.get("cwd") or current_cwd),
project_root=str(metadata.get("project_root") or metadata.get("project_path") or ""),
model=_none_or_str(metadata.get("model")),
created_at=str(metadata.get("created_at") or ""),
last_active_at=str(metadata.get("last_active_at") or metadata.get("updated_at") or ""),
message_count=len(loaded.messages),
cost_usd=_extract_cost_usd(metadata.get("cost") or metadata.get("runtime")),
tag=_none_or_str(metadata.get("tag")),
mode=str(metadata.get("mode") or "normal"),
agent_name=_none_or_str(metadata.get("agent_name")),
agent_type=_none_or_str(metadata.get("agent_type")),
session_dir=str(loaded.session_dir),
transcript_path=str(loaded.transcript_path),
same_project=_same_path(metadata.get("project_root"), current_project_root),
cross_project=not _same_path(metadata.get("project_root"), current_project_root),
)
def _select_cost_snapshot(
metadata: Mapping[str, Any],
loaded: SessionLoadResult,
) -> dict[str, Any] | None:
cost = metadata.get("cost")
if isinstance(cost, Mapping):
return dict(cost)
runtime = metadata.get("runtime")
if isinstance(runtime, Mapping) and isinstance(runtime.get("cost"), Mapping):
return dict(runtime["cost"])
if not loaded.usage:
return None
return {
"usage": list(loaded.usage),
"total_cost": _extract_cost_usd(runtime) or 0.0,
}
def _restore_worktree_metadata(
storage: SessionStorage,
metadata: dict[str, Any],
) -> dict[str, Any] | None:
worktree = metadata.get("worktree")
if not isinstance(worktree, Mapping):
return None
restored = dict(worktree)
worktree_path = (
restored.get("worktree_path")
or restored.get("worktreePath")
or restored.get("workspace_dir")
or restored.get("path")
)
if isinstance(worktree_path, str) and worktree_path:
if Path(worktree_path).expanduser().exists():
restored.setdefault("workspace_dir", worktree_path)
else:
logger.warning("Resume worktree path no longer exists: %s", worktree_path)
try:
storage.update_metadata({"worktree": None}, append=True)
except Exception:
logger.debug("Failed to clear stale worktree metadata", exc_info=True)
return None
return restored or None
def _session_record(
metadata: Mapping[str, Any],
runtime: Mapping[str, Any],
worktree: Mapping[str, Any] | None,
file_history: list[dict[str, Any]],
content_replacements: list[dict[str, Any]],
) -> dict[str, Any]:
project_path = str(metadata.get("project_root") or metadata.get("project_path") or metadata.get("cwd") or "")
workspace_dir = (
_mapping(worktree).get("workspace_dir")
or _mapping(worktree).get("worktree_path")
or metadata.get("cwd")
or project_path
)
record = dict(metadata)
record.update(
{
"session_id": metadata.get("session_id"),
"project_path": project_path,
"worktree_path": _mapping(worktree).get("worktree_path") or workspace_dir,
"workspace_dir": workspace_dir,
"runtime": dict(runtime),
"worktree": dict(worktree or {}),
"file_history_snapshots": list(file_history),
"content_replacements": list(content_replacements),
}
)
return record
def _iter_assistant_tool_payloads(message: Mapping[str, Any]) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = []
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, SequenceABC) and not isinstance(tool_calls, (str, bytes)):
for call in reversed(list(tool_calls)):
if not isinstance(call, Mapping):
continue
name = _tool_call_name(call)
args = _tool_call_arguments(call)
payloads.append({"name": name, "input": args})
content = message.get("content")
if isinstance(content, SequenceABC) and not isinstance(content, (str, bytes)):
for block in reversed(list(content)):
if not isinstance(block, Mapping) or block.get("type") != "tool_use":
continue
payloads.append(
{
"name": _none_or_str(block.get("name")),
"input": block.get("input") if isinstance(block.get("input"), Mapping) else {},
}
)
return payloads
def _tool_call_name(call: Mapping[str, Any]) -> str | None:
function = call.get("function")
if isinstance(function, Mapping):
return _none_or_str(function.get("name"))
return _none_or_str(call.get("name"))
def _tool_call_arguments(call: Mapping[str, Any]) -> dict[str, Any]:
function = call.get("function")
raw: Any
if isinstance(function, Mapping):
raw = function.get("arguments")
else:
raw = call.get("arguments") or call.get("input")
if isinstance(raw, Mapping):
return dict(raw)
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return {}
return dict(parsed) if isinstance(parsed, Mapping) else {}
return {}
def _todo_key(context: Any | None, session_id: str) -> str:
if context is not None:
agent_id = getattr(context, "agent_id", None)
if agent_id:
return str(agent_id)
context_session = getattr(context, "session_id", None)
if context_session:
return str(context_session)
return str(session_id or "primary")
def _agent_payload(metadata: Mapping[str, Any]) -> dict[str, Any] | None:
agent = metadata.get("agent")
if isinstance(agent, Mapping) and agent:
return dict(agent)
agent_type = _none_or_str(metadata.get("agent_type"))
agent_name = _none_or_str(metadata.get("agent_name"))
if not agent_type and not agent_name:
return None
return {"type": agent_type, "name": agent_name}
def _standalone_agent_context(metadata: Mapping[str, Any]) -> dict[str, Any] | None:
context = metadata.get("standalone_agent_context")
if isinstance(context, Mapping) and context:
return dict(context)
name = _none_or_str(metadata.get("agent_name"))
color = _none_or_str(metadata.get("agent_color"))
if not name and not color:
return None
payload: dict[str, Any] = {"name": name or ""}
if color and color != "default":
payload["color"] = color
return payload
def _git_worktree_paths(project_root: str) -> list[str]:
root = Path(project_root).expanduser()
if not root.exists():
return [str(root)]
try:
proc = subprocess.run(
["git", "-C", str(root), "worktree", "list", "--porcelain"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=2,
)
except Exception:
return [str(root.resolve())]
if proc.returncode != 0:
return [str(root.resolve())]
paths: list[str] = []
for line in proc.stdout.splitlines():
if line.startswith("worktree "):
paths.append(str(Path(line[len("worktree ") :]).expanduser().resolve()))
return paths or [str(root.resolve())]
def _first_prompt(path: Path) -> str:
for message in _iter_transcript_messages(path, max_lines=300):
if message.get("role") != "user":
continue
content = message.get("content")
text = _content_text(content)
if text:
return text[:240]
return ""
def _iter_transcript_messages(path: Path, *, max_lines: int | None = None) -> list[dict[str, Any]]:
if not path.exists():
return []
messages: list[dict[str, Any]] = []
try:
with path.open("r", encoding="utf-8") as handle:
for index, line in enumerate(handle):
if max_lines is not None and index >= max_lines:
break
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
message = raw.get("message") if isinstance(raw, Mapping) else None
if isinstance(message, Mapping):
messages.append(dict(message))
elif isinstance(raw, Mapping) and "role" in raw:
messages.append(dict(raw))
except OSError:
return []
return messages
def _count_messages(path: Path) -> int:
if not path.exists():
return 0
count = 0
try:
with path.open("r", encoding="utf-8") as handle:
for line in handle:
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(raw, Mapping) and (
raw.get("type") == "message" or "role" in raw
):
count += 1
except OSError:
return 0
return count
def _content_text(content: Any) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, SequenceABC) and not isinstance(content, (str, bytes)):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, Mapping):
if isinstance(block.get("text"), str):
parts.append(block["text"])
elif isinstance(block.get("content"), str):
parts.append(block["content"])
return "\n".join(part.strip() for part in parts if part and part.strip())
return ""
def _read_json_object(path: Path) -> dict[str, Any] | None:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return dict(raw) if isinstance(raw, Mapping) else None
def _mtime_iso(path: Path) -> str:
try:
return datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat()
except OSError:
return ""
def _sort_timestamp(summary: SessionSummary) -> float:
try:
return datetime.fromisoformat(summary.last_active_at).timestamp()
except (TypeError, ValueError):
try:
return Path(summary.session_dir).stat().st_mtime
except OSError:
return 0.0
def _extract_cost_usd(raw: Any) -> float | None:
if not isinstance(raw, Mapping):
return None
for key in ("total_cost", "totalCostUSD", "cost_usd", "cost"):
value = raw.get(key)
if isinstance(value, (int, float)):
return float(value)
return None
def _same_path(left: Any, right: Any) -> bool:
if not left or not right:
return False
try:
return Path(str(left)).expanduser().resolve() == Path(str(right)).expanduser().resolve()
except Exception:
return str(left) == str(right)
def _resolve_path(path: str | Path) -> Path:
return Path(path).expanduser().resolve()
def _mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _dict_list(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, SequenceABC) or isinstance(value, (str, bytes)):
return []
return [dict(item) for item in value if isinstance(item, Mapping)]
def _canonical_file_history_entries(value: Any) -> list[dict[str, Any]]:
return [
item
for item in _dict_list(value)
if _is_canonical_file_history_snapshot(item)
]
def _is_canonical_file_history_snapshot(value: Mapping[str, Any]) -> bool:
raw = value.get("snapshot") if isinstance(value.get("snapshot"), Mapping) else value
if not isinstance(raw, Mapping):
return False
backups = raw.get("tracked_file_backups")
return isinstance(backups, Mapping)
def _none_or_str(value: Any) -> str | None:
if value is None:
return None
text = str(value)
return text if text else None
def _worktree_path_from_summary(summary: SessionSummary) -> str:
return summary.cwd or summary.project_root
__all__ = [
"CrossProjectResumeRequired",
"CrossProjectResumeResult",
"RestoredSession",
"ResumeDeserializationResult",
"SessionDiscoveryResult",
"SessionSummary",
"check_cross_project_resume",
"deserialize_messages_for_resume",
"discover_sessions",
"extract_todos_from_transcript",
"restore_session",
]