← Files Portable ResumeARCHIVED FILE
skills/.portable-resume/runtime/portable_resume/adapters/cline.py
39 KB · Oct 3, 2026 · 06:34 UTC
"""Read Cline local hub/session stores as inert context.
Pinned format: ``cline-session-json-v1`` (messages file version = 1) with optional
SQLite index ``sessions.db`` under the Cline data tree.
Default layout (upstream sdk/packages/shared storage paths, 2026-07):
~/.cline/data/db/sessions.db # index (never a transcript)
~/.cline/data/sessions/<id>/<id>.json # manifest
~/.cline/data/sessions/<id>/<id>.messages.json # authoritative turns
Authority: messages JSON is the transcript source of truth; SQLite is list/index
only. Never invokes Cline CLI/hub/SDK, connectors, or migrations.
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import stat
from dataclasses import replace
from datetime import datetime, timezone
from typing import Any, Mapping
from ..bounds import DEFAULT_BOUNDS, ReadBudget
from ..diagnostics import DiagnosticError
from ..model import Query, Session, SessionSummary, Turn
from ..paths import canonical_root, canonicalize_cwd, is_within, same_cwd
from ..sanitize import sanitize_turn_record
from ..snapshot import (
private_sqlite_connection,
query_only_live_sqlite,
stable_read_bytes,
stable_read_windows,
)
from .base import CapabilityReport, ResolvedRef
from .common import within_age
FORMAT_ID = "cline-session-json-v1"
INDEX_PROVIDER = "cline-session-index-sqlite-v1"
MESSAGES_VERSION = 1
# List eligibility must not full-decode multi-MB transcripts (Codex P1).
_LIST_ELIGIBILITY_BYTES = 256 * 1024
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$")
_REQUIRED_SESSION_COLUMNS = frozenset(
{
"session_id",
"source",
"started_at",
"status",
"cwd",
"workspace_root",
"parent_session_id",
"is_subagent",
"prompt",
"messages_path",
"updated_at",
}
)
_PUBLIC_ROLES = frozenset({"user", "assistant", "tool"})
_SYNTHETIC_USER_KINDS = frozenset(
{
"auto_compaction",
"compaction_budget_emergency",
"completion_reminder",
"loop_detection_notice",
"manual_compaction",
"mistake_stop_notice",
"recovery_notice",
}
)
class _DuplicateKey(ValueError):
pass
def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
value: dict[str, Any] = {}
for key, item in pairs:
if key in value:
raise _DuplicateKey(key)
value[key] = item
return value
def _regular_file(path: str, root: str) -> bool:
try:
mode = os.lstat(path).st_mode
except OSError:
return False
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
return False
try:
return is_within(path, root)
except DiagnosticError:
return False
_OPTIONAL_MANIFEST_DIAGNOSTICS = frozenset(
{"E_CORRUPT_RECORD", "E_UNSUPPORTED_FORMAT"}
)
def _optional_manifest_present(path: str, root: str) -> bool:
"""Distinguish an absent optional manifest from unsafe or busy state."""
try:
mode = os.lstat(path).st_mode
except FileNotFoundError:
return False
except OSError as error:
raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
raise DiagnosticError.unsafe_path()
try:
contained = is_within(path, root)
except DiagnosticError:
raise
except OSError as error:
raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
if not contained:
raise DiagnosticError.unsafe_path()
return True
def _load_optional_manifest(
path: str,
root: str,
budget: ReadBudget,
) -> Mapping[str, Any] | None:
"""Load optional metadata while propagating every non-format diagnostic."""
if not _optional_manifest_present(path, root):
return None
try:
read = stable_read_bytes(
path,
root=root,
max_bytes=min(budget.limits.record_bytes, DEFAULT_BOUNDS.record_bytes),
budget=budget,
)
except DiagnosticError as error:
if error.code in _OPTIONAL_MANIFEST_DIAGNOSTICS:
return None
if error.code == "E_UNSAFE_PATH":
try:
os.lstat(path)
except OSError as race:
raise DiagnosticError.source_busy(provider=FORMAT_ID) from race
raise
except OSError as error:
raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
try:
manifest = json.loads(read.data.decode("utf-8"), object_pairs_hook=_object)
except (
json.JSONDecodeError,
_DuplicateKey,
RecursionError,
UnicodeDecodeError,
):
return None
return manifest if isinstance(manifest, Mapping) else None
def _regular_dir(path: str) -> bool:
try:
mode = os.lstat(path).st_mode
except OSError:
return False
return not stat.S_ISLNK(mode) and stat.S_ISDIR(mode)
def _default_cline_dir() -> str:
env = os.environ.get("CLINE_DIR")
if env and env.strip():
return env.strip()
return os.path.expanduser("~/.cline")
def _layout_from_root(candidate: str) -> tuple[str | None, str | None, str] | None:
"""Return (sessions_db|None, sessions_dir|None, containment_root)."""
try:
if os.path.isfile(candidate):
base = os.path.basename(candidate)
parent = os.path.dirname(os.path.abspath(candidate))
if base == "sessions.db":
# When DB is under .../data/db/sessions.db, containment must be
# the data dir so sibling .../data/sessions/<id>/*.json is allowed.
if os.path.basename(parent) == "db":
data_dir = os.path.dirname(parent)
root = canonical_root(data_dir)
sessions_dir = None
sibling = os.path.join(data_dir, "sessions")
if _regular_dir(sibling):
sessions_dir = sibling
else:
root = canonical_root(parent)
sessions_dir = None
if not _regular_file(os.path.abspath(candidate), root):
return None
return os.path.abspath(candidate), sessions_dir, root
if base.endswith(".json") and not base.endswith(".messages.json"):
# Exact manifest path: .../<id>/<id>.json
session_dir = parent
root = canonical_root(os.path.dirname(session_dir) if os.path.basename(os.path.dirname(session_dir)) else session_dir)
path = os.path.abspath(candidate)
if not _regular_file(path, root):
return None
# sessions_dir is parent of session_dir
sessions_dir = os.path.dirname(session_dir)
db_guess = os.path.join(os.path.dirname(sessions_dir), "db", "sessions.db")
db = db_guess if _regular_file(db_guess, root) else None
return db, sessions_dir, root
return None
if not os.path.isdir(candidate):
return None
root = canonical_root(candidate)
except DiagnosticError:
return None
# ~/.cline
db = os.path.join(root, "data", "db", "sessions.db")
sessions = os.path.join(root, "data", "sessions")
if _regular_file(db, root) or _regular_dir(sessions):
return (
db if _regular_file(db, root) else None,
sessions if _regular_dir(sessions) else None,
root,
)
# ~/.cline/data
db = os.path.join(root, "db", "sessions.db")
sessions = os.path.join(root, "sessions")
if _regular_file(db, root) or _regular_dir(sessions):
return (
db if _regular_file(db, root) else None,
sessions if _regular_dir(sessions) else None,
root,
)
# ~/.cline/data/db — contain under parent data dir so sibling sessions/ is allowed.
if os.path.basename(root.rstrip(os.sep)) == "db":
try:
data_root = canonical_root(os.path.dirname(root))
except DiagnosticError:
return None
db_path = os.path.join(data_root, "db", "sessions.db")
sessions = os.path.join(data_root, "sessions")
if _regular_file(db_path, data_root) or _regular_dir(sessions):
return (
db_path if _regular_file(db_path, data_root) else None,
sessions if _regular_dir(sessions) else None,
data_root,
)
# ~/.cline/data/sessions
if any(
name.endswith(".json") or _regular_dir(os.path.join(root, name))
for name in _safe_listdir(root)
):
db = os.path.join(os.path.dirname(root), "db", "sessions.db")
return (
db if _regular_file(db, root) else None,
root,
root,
)
return None
def _safe_listdir(path: str) -> list[str]:
try:
return os.listdir(path)
except OSError:
return []
def _resolve_layout(query: Query) -> tuple[str | None, str | None, str] | None:
if query.source_root:
return _layout_from_root(query.source_root)
return _layout_from_root(_default_cline_dir())
def _open_connection(database: str, root: str, budget: ReadBudget | None = None):
limits = budget.limits if budget is not None else DEFAULT_BOUNDS
try:
size = os.path.getsize(database)
except OSError as error:
raise DiagnosticError.source_busy(provider=INDEX_PROVIDER) from error
if size > limits.sqlite_snapshot_bytes:
return query_only_live_sqlite(database, root=root, provider=INDEX_PROVIDER)
return private_sqlite_connection(
database, root=root, bounds=limits, provider=INDEX_PROVIDER
)
def _require_index_schema(connection: sqlite3.Connection) -> None:
try:
tables = {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'"
)
}
if "sessions" not in tables:
raise DiagnosticError(
"E_UNSUPPORTED_FORMAT", source="cline", provider=INDEX_PROVIDER
)
cols = {
str(row[1]) for row in connection.execute("PRAGMA table_info(sessions)")
}
if not _REQUIRED_SESSION_COLUMNS.issubset(cols):
raise DiagnosticError(
"E_UNSUPPORTED_FORMAT", source="cline", provider=INDEX_PROVIDER
)
except sqlite3.DatabaseError as error:
raise DiagnosticError(
"E_UNSUPPORTED_FORMAT", source="cline", provider=INDEX_PROVIDER
) from error
def _exact_ref(value: str | None) -> str | None:
if not value:
return None
text = value.strip()
if not text or text == "latest":
return None
if _SESSION_ID_RE.fullmatch(text):
return text
return None
def _stamp_iso(value: object) -> str | None:
if not isinstance(value, str) or not value.strip():
return None
text = value.strip()
try:
return (
datetime.fromisoformat(text.replace("Z", "+00:00"))
.astimezone(timezone.utc)
.isoformat(timespec="microseconds")
.replace("+00:00", "Z")
)
except ValueError:
return None
def _messages_path_for(
*,
session_id: str,
messages_path: object,
sessions_dir: str | None,
root: str,
) -> str | None:
# Prefer the closed layout path; only accept explicit paths that stay under root
# and whose basename matches the selected session (prevents cross-session swaps).
if sessions_dir is not None:
candidate = os.path.join(sessions_dir, session_id, f"{session_id}.messages.json")
if _regular_file(candidate, root):
return candidate
if isinstance(messages_path, str) and messages_path.strip():
path = messages_path.strip()
if not os.path.isabs(path) and sessions_dir is not None:
path = os.path.join(sessions_dir, session_id, path)
if (
_regular_file(path, root)
and os.path.basename(path) == f"{session_id}.messages.json"
):
return path
return None
def _content_chunks(content: object) -> list[str]:
if isinstance(content, str) and content.strip():
return [content]
if not isinstance(content, list):
return []
chunks: list[str] = []
for item in content:
if not isinstance(item, Mapping):
continue
kind = item.get("type")
if kind == "text":
text = item.get("text")
if isinstance(text, str) and text.strip():
chunks.append(text)
elif kind in {"tool_result", "tool-result"}:
text = item.get("content")
if isinstance(text, str) and text.strip():
chunks.append(text)
elif isinstance(text, list):
chunks.extend(_content_chunks(text))
elif kind in {"tool_use", "tool-use", "tool_call"}:
name = item.get("name")
if isinstance(name, str) and name.strip():
chunks.append(name)
return chunks
def _turn_from_message(message: Mapping[str, Any]) -> tuple[str, str] | None:
role = message.get("role")
if not isinstance(role, str) or role not in _PUBLIC_ROLES:
return None
meta = message.get("metadata")
if isinstance(meta, Mapping):
kind = meta.get("kind")
if isinstance(kind, str) and kind in _SYNTHETIC_USER_KINDS:
return None
display = meta.get("displayRole")
if isinstance(display, str) and display.strip().lower() in {
"system",
"status",
"error",
}:
return None
chunks = _content_chunks(message.get("content"))
if not chunks:
return None
text = "\n".join(chunks).strip()
if not text:
return None
return role, text
def _load_messages_payload(
path: str,
root: str,
budget: ReadBudget,
*,
expected_session_id: str | None = None,
max_bytes: int | None = None,
) -> list[Mapping[str, Any]]:
ceiling = min(budget.limits.source_read_bytes, DEFAULT_BOUNDS.source_read_bytes)
limit = ceiling if max_bytes is None else min(max_bytes, ceiling)
# stable_read_bytes rejects max_bytes above sqlite_snapshot_bytes.
limit = min(limit, DEFAULT_BOUNDS.sqlite_snapshot_bytes)
try:
# Fail closed on oversize before allocating a full buffer when possible.
size = os.lstat(path).st_size
except OSError as error:
raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
if size > limit:
raise DiagnosticError.limit_exceeded()
try:
read = stable_read_bytes(
path,
root=root,
max_bytes=limit,
budget=budget,
)
except DiagnosticError:
raise
except OSError as error:
raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
raw = read.data
if len(raw) > budget.limits.source_read_bytes:
raise DiagnosticError.limit_exceeded()
try:
# json.loads accepts bytes — avoid an extra full-string decode copy.
payload = json.loads(raw, object_pairs_hook=_object)
except (
json.JSONDecodeError,
_DuplicateKey,
RecursionError,
UnicodeDecodeError,
) as error:
raise DiagnosticError("E_CORRUPT_RECORD", source="cline", provider=FORMAT_ID) from error
if not isinstance(payload, Mapping):
raise DiagnosticError("E_CORRUPT_RECORD", source="cline", provider=FORMAT_ID)
version = payload.get("version")
if version != MESSAGES_VERSION:
raise DiagnosticError(
"E_UNSUPPORTED_FORMAT", source="cline", provider=FORMAT_ID
)
if expected_session_id is not None:
payload_id = payload.get("sessionId")
if not isinstance(payload_id, str) or payload_id != expected_session_id:
raise DiagnosticError("E_CORRUPT_RECORD", source="cline", provider=FORMAT_ID)
messages = payload.get("messages")
if not isinstance(messages, list):
raise DiagnosticError("E_CORRUPT_RECORD", source="cline", provider=FORMAT_ID)
return [m for m in messages if isinstance(m, Mapping)]
def _window_text(data: bytes) -> str:
"""Decode a byte window; incomplete boundary code points become U+FFFD."""
if not data:
return ""
return data.decode("utf-8", errors="replace")
def _list_messages_soft_ok(
path: str,
root: str,
budget: ReadBudget,
*,
expected_session_id: str,
) -> bool:
"""Bounded list eligibility for oversized messages (no full decode).
Path already binds ``session_id`` via ``{id}/{id}.messages.json``. Windows only
reject clear envelope mismatches (wrong version / wrong sessionId). Public
roles may live in the unsampled middle — show validates turns fully. Unsafe,
busy, exhausted-budget, and unknown diagnostics always propagate.
"""
head_limit = min(_LIST_ELIGIBILITY_BYTES, 4 * 1024 * 1024)
tail_limit = min(64 * 1024, head_limit)
try:
windows = stable_read_windows(
path,
root=root,
head_bytes=head_limit,
tail_bytes=tail_limit,
max_bytes=min(budget.limits.source_read_bytes, DEFAULT_BOUNDS.source_read_bytes),
budget=budget,
require_size_within_max=False,
)
except DiagnosticError as error:
if error.code in {"E_UNSAFE_PATH", "E_SOURCE_BUSY", "E_LIMIT_EXCEEDED"}:
raise
if error.code in {"E_CORRUPT_RECORD", "E_UNSUPPORTED_FORMAT"}:
return False
raise
text = _window_text(windows.head)
if windows.tail and windows.tail != windows.head:
text = text + "\n" + _window_text(windows.tail)
if not text.strip():
return False
# Reject only unambiguous envelope mismatches visible in the windows.
if re.search(r'"version"\s*:\s*(\d+)', text):
if not re.search(r'"version"\s*:\s*1\b', text):
return False
if re.search(r'"sessionId"\s*:\s*"', text):
if not re.search(
rf'"sessionId"\s*:\s*"{re.escape(expected_session_id)}"',
text,
):
return False
budget.consume_records()
return True
def _session_has_extractable(
*,
session_id: str,
messages_path: str | None,
root: str,
budget: ReadBudget,
raise_on_bad: bool = False,
) -> bool:
"""Require a safe authoritative messages payload (prompt alone is insufficient).
During ordinary listing (``raise_on_bad=False``):
- corrupt / unsupported candidates are skipped so older valid rows can win
- multi-MB files use a soft bounded check (no full JSON decode, no role-in-window)
Unsafe, busy, and exhausted-budget diagnostics always propagate. Explicit
selection full-loads and also raises for corrupt / unsupported candidates.
"""
if messages_path is None:
return False
try:
size = os.lstat(messages_path).st_size
except OSError as error:
raise DiagnosticError.source_busy() from error
if size <= 0:
return False
source_cap = min(budget.limits.source_read_bytes, DEFAULT_BOUNDS.source_read_bytes)
if size > source_cap:
if raise_on_bad:
raise DiagnosticError.limit_exceeded()
return False
list_cap = min(
budget.limits.record_bytes,
DEFAULT_BOUNDS.record_bytes,
_LIST_ELIGIBILITY_BYTES,
)
if not raise_on_bad and size > list_cap:
return _list_messages_soft_ok(
messages_path,
root,
budget,
expected_session_id=session_id,
)
try:
messages = _load_messages_payload(
messages_path,
root,
budget,
expected_session_id=session_id,
max_bytes=list_cap if not raise_on_bad else None,
)
except DiagnosticError as error:
if error.code in {"E_UNSAFE_PATH", "E_SOURCE_BUSY", "E_LIMIT_EXCEEDED"}:
raise
if error.code in {"E_CORRUPT_RECORD", "E_UNSUPPORTED_FORMAT"}:
if raise_on_bad:
raise
return False
raise
for message in messages[: min(64, budget.limits.transcript_records or 64)]:
budget.consume_records()
if _turn_from_message(message) is not None:
return True
return False
def _row_summary(
*,
session_id: str,
source_path: str,
prompt: object,
cwd_value: object,
workspace_root: object,
started_at: object,
updated_at: object,
query: Query,
require_age: bool,
) -> SessionSummary | None:
if not isinstance(session_id, str) or not session_id:
return None
cwd: str | None = None
for candidate in (cwd_value, workspace_root):
if isinstance(candidate, str) and candidate.strip():
try:
cwd = canonicalize_cwd(candidate)
break
except DiagnosticError:
continue
if query.cwd is not None:
if cwd is None or not same_cwd(cwd, query.cwd):
return None
stamp = _stamp_iso(updated_at) or _stamp_iso(started_at)
if require_age and not within_age(
stamp, query.within_min, default_minutes=DEFAULT_BOUNDS.listing_age_minutes
):
return None
title = None
if isinstance(prompt, str) and prompt.strip():
title = prompt.strip().splitlines()[0][: DEFAULT_BOUNDS.title_chars]
return SessionSummary(
source="cline",
session_id=session_id,
source_path=source_path,
title=title,
cwd=cwd,
branch=None,
created_at=_stamp_iso(started_at),
updated_at=stamp,
provider=FORMAT_ID,
warnings=(),
)
def _list_from_sessions_dir(
*,
sessions_dir: str,
root: str,
query: Query,
exact_id: str | None,
budget: ReadBudget,
) -> list[SessionSummary]:
"""Bounded JSON discovery when the SQLite index is absent (Codex P1)."""
scan_limit = min(budget.limits.scanned_records, DEFAULT_BOUNDS.scanned_records)
list_limit = min(budget.limits.listed_sessions, DEFAULT_BOUNDS.listed_sessions)
if exact_id is None and list_limit <= 0:
return []
require_age = exact_id is None
candidates: list[str]
if exact_id is not None:
candidates = [exact_id]
else:
names = sorted(_safe_listdir(sessions_dir))
if len(names) > scan_limit:
raise DiagnosticError.limit_exceeded()
candidates = [name for name in names if _SESSION_ID_RE.fullmatch(name)]
values: list[SessionSummary] = []
for session_id in candidates:
msg_path = _messages_path_for(
session_id=session_id,
messages_path=None,
sessions_dir=sessions_dir,
root=root,
)
if msg_path is None:
continue
if not _session_has_extractable(
session_id=session_id,
messages_path=msg_path,
root=root,
budget=budget,
raise_on_bad=exact_id is not None,
):
continue
# Optional manifest for metadata only (identity must match directory id).
prompt = None
cwd_value = None
workspace_root = None
started_at = None
updated_at = None
manifest_path = os.path.join(sessions_dir, session_id, f"{session_id}.json")
manifest = _load_optional_manifest(manifest_path, root, budget)
if manifest is not None:
mid = manifest.get("session_id")
if isinstance(mid, str) and mid and mid != session_id:
# Stale/copied manifest: ignore metadata, keep messages authority.
pass
else:
prompt = manifest.get("prompt")
cwd_value = manifest.get("cwd")
workspace_root = manifest.get("workspace_root")
started_at = manifest.get("started_at")
updated_at = manifest.get("updated_at") or manifest.get("started_at")
parent = manifest.get("parent_session_id")
is_sub = manifest.get("is_subagent")
if exact_id is None and (
(isinstance(parent, str) and parent.strip())
or is_sub in (1, True)
):
continue
item = _row_summary(
session_id=session_id,
source_path=msg_path,
prompt=prompt,
cwd_value=cwd_value,
workspace_root=workspace_root,
started_at=started_at,
updated_at=updated_at,
query=query,
require_age=require_age,
)
if item is not None:
# Do not truncate here: candidates are ID-sorted, not recency-sorted.
# Outer list() sorts by updated_at then applies listed_sessions (Codex P1).
values.append(item)
budget.consume_records()
if exact_id is None and len(values) >= scan_limit:
break
return values
def _list_from_index(
connection: sqlite3.Connection,
*,
database: str,
sessions_dir: str | None,
root: str,
query: Query,
exact_id: str | None,
budget: ReadBudget,
) -> list[SessionSummary]:
scan_limit = min(budget.limits.scanned_records, DEFAULT_BOUNDS.scanned_records)
list_limit = min(budget.limits.listed_sessions, DEFAULT_BOUNDS.listed_sessions)
if exact_id is None and list_limit <= 0:
return []
if exact_id is not None:
rows = connection.execute(
"""
SELECT session_id, parent_session_id, is_subagent, prompt, cwd, workspace_root,
messages_path, started_at, updated_at
FROM sessions
WHERE session_id = ? OR lower(session_id) = lower(?)
LIMIT 2
""",
(exact_id, exact_id),
).fetchall()
require_age = False
if not rows:
# Stale/lagging index: recover authoritative messages JSON by exact id
# before abandoning exact mode (Codex P1).
if sessions_dir is not None:
return _list_from_sessions_dir(
sessions_dir=sessions_dir,
root=root,
query=query,
exact_id=exact_id,
budget=budget,
)
return []
else:
if scan_limit <= 0:
return []
rows = connection.execute(
"""
SELECT session_id, parent_session_id, is_subagent, prompt, cwd, workspace_root,
messages_path, started_at, updated_at
FROM sessions
WHERE (parent_session_id IS NULL OR parent_session_id = '')
AND COALESCE(is_subagent, 0) = 0
ORDER BY updated_at DESC, started_at DESC, session_id ASC
LIMIT ?
""",
(scan_limit,),
).fetchall()
require_age = True
values: list[SessionSummary] = []
for row in rows:
(
session_id,
parent_session_id,
is_subagent,
prompt,
cwd_value,
workspace_root,
messages_path,
started_at,
updated_at,
) = row
msg_path = _messages_path_for(
session_id=str(session_id),
messages_path=messages_path,
sessions_dir=sessions_dir,
root=root,
)
if exact_id is None:
if isinstance(parent_session_id, str) and parent_session_id.strip():
continue
if is_subagent in (1, True):
continue
if not _session_has_extractable(
session_id=str(session_id),
messages_path=msg_path,
root=root,
budget=budget,
raise_on_bad=False,
):
continue
# Prefer authoritative messages path so exact-path selection works (Codex P2).
summary_path = msg_path if msg_path is not None else database
item = _row_summary(
session_id=str(session_id),
source_path=summary_path,
prompt=prompt,
cwd_value=cwd_value,
workspace_root=workspace_root,
started_at=started_at,
updated_at=updated_at,
query=query,
require_age=require_age,
)
if item is not None:
if exact_id is None and len(values) >= list_limit:
break
values.append(item)
budget.consume_records()
if exact_id is None and len(values) >= list_limit:
break
return values
def _show_from_messages(
*,
session_id: str,
source_path: str,
messages_path: str,
root: str,
query: Query,
budget: ReadBudget,
title: str | None,
cwd: str | None,
created_at: str | None,
updated_at: str | None,
) -> Session:
if query.cwd is not None and (cwd is None or not same_cwd(cwd, query.cwd)):
raise DiagnosticError("E_NO_MATCH", source="cline", provider=FORMAT_ID)
messages = _load_messages_payload(
messages_path, root, budget, expected_session_id=session_id
)
turns: list[Turn] = []
warnings: list[str] = []
turn_bounds = replace(DEFAULT_BOUNDS, tool_output_chars=query.max_tool_chars)
limit = budget.limits.transcript_records
count = 0
for message in messages:
count += 1
if count > limit:
raise DiagnosticError.limit_exceeded()
budget.consume_transcript_records()
parsed = _turn_from_message(message)
if parsed is None:
continue
role, text = parsed
turn, turn_warnings = sanitize_turn_record(
{"role": role, "content": text},
ordinal=len(turns),
bounds=turn_bounds,
)
warnings.extend(turn_warnings)
if turn is not None:
budget.consume_turns()
turns.append(turn)
last_user = next((t.content for t in reversed(turns) if t.role == "user"), None)
last_assistant = next(
(t.content for t in reversed(turns) if t.role == "assistant"), None
)
return Session(
source="cline",
session_id=session_id,
source_path=source_path,
title=title,
cwd=cwd,
branch=None,
created_at=created_at,
updated_at=updated_at or created_at,
last_user_request=last_user,
last_assistant_action=last_assistant,
turns=tuple(turns),
warnings=tuple(dict.fromkeys(warnings)),
)
class ClineAdapter:
key = "cline"
def approved_roots(self, query: Query) -> tuple[str, ...]:
layout = _resolve_layout(query)
return (layout[2],) if layout else ()
def probe(self, query: Query) -> CapabilityReport:
try:
layout = _resolve_layout(query)
if layout is None:
return CapabilityReport(self.key, FORMAT_ID, "unavailable")
database, sessions_dir, root = layout
json_ok = bool(sessions_dir and _regular_dir(sessions_dir))
index_ok = False
if database is not None:
try:
with _open_connection(database, root) as connection:
_require_index_schema(connection)
index_ok = True
except DiagnosticError as error:
if error.code in {"E_UNSAFE_PATH", "E_SOURCE_BUSY", "E_SQLITE_LIVE_WAL"}:
return CapabilityReport(self.key, FORMAT_ID, "unsafe", root=root)
if error.code not in {"E_UNSUPPORTED_FORMAT", "E_CORRUPT_RECORD"}:
raise
if index_ok and json_ok:
return CapabilityReport(
self.key,
FORMAT_ID,
"supported",
root=root,
evidence=(FORMAT_ID, INDEX_PROVIDER),
)
if json_ok or index_ok:
return CapabilityReport(
self.key,
FORMAT_ID,
"partial",
root=root,
evidence=(FORMAT_ID if json_ok else INDEX_PROVIDER,),
)
return CapabilityReport(self.key, FORMAT_ID, "unsupported", root=root)
except DiagnosticError as error:
state = (
"unsafe"
if error.code in {"E_UNSAFE_PATH", "E_SOURCE_BUSY", "E_SQLITE_LIVE_WAL"}
else "unsupported"
)
return CapabilityReport(self.key, FORMAT_ID, state)
def list(self, query: Query, budget: ReadBudget) -> list[SessionSummary]:
layout = _resolve_layout(query)
if layout is None:
raise DiagnosticError(
"E_CAPABILITY_UNAVAILABLE", source=self.key, provider=FORMAT_ID
)
database, sessions_dir, root = layout
exact = _exact_ref(query.ref)
list_query = query
if exact is not None and query.within_min is None:
list_query = Query(
source=query.source,
ref=query.ref,
cwd=query.cwd,
within_min=0,
source_root=query.source_root,
max_tool_chars=query.max_tool_chars,
)
values: list[SessionSummary] = []
if database is not None:
try:
with _open_connection(database, root, budget) as connection:
_require_index_schema(connection)
values = _list_from_index(
connection,
database=database,
sessions_dir=sessions_dir,
root=root,
query=list_query,
exact_id=exact,
budget=budget,
)
except DiagnosticError as error:
# Optional index: corrupt/unsupported schema must not block
# authoritative JSON recovery when sessions_dir is present.
if (
error.code in {"E_UNSUPPORTED_FORMAT", "E_CORRUPT_RECORD"}
and sessions_dir is not None
):
values = _list_from_sessions_dir(
sessions_dir=sessions_dir,
root=root,
query=list_query,
exact_id=exact,
budget=budget,
)
else:
raise
elif sessions_dir is not None:
values = _list_from_sessions_dir(
sessions_dir=sessions_dir,
root=root,
query=list_query,
exact_id=exact,
budget=budget,
)
else:
raise DiagnosticError(
"E_CAPABILITY_UNAVAILABLE", source=self.key, provider=FORMAT_ID
)
values.sort(key=lambda item: item.session_id)
values.sort(key=lambda item: item.updated_at or "", reverse=True)
values.sort(key=lambda item: item.updated_at is None)
if exact is not None:
return values
list_limit = min(budget.limits.listed_sessions, DEFAULT_BOUNDS.listed_sessions)
return values[:list_limit]
def show(self, ref: ResolvedRef, query: Query, budget: ReadBudget) -> Session:
layout = _resolve_layout(query)
if layout is None:
raise DiagnosticError(
"E_CAPABILITY_UNAVAILABLE", source=self.key, provider=FORMAT_ID
)
database, sessions_dir, root = layout
session_id = ref.session_id
if not session_id:
raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)
prompt = None
cwd_value = None
workspace_root = None
started_at = None
updated_at = None
messages_path_col = None
source_path = database or ""
if database is not None:
try:
with _open_connection(database, root, budget) as connection:
_require_index_schema(connection)
rows = connection.execute(
"""
SELECT prompt, cwd, workspace_root, messages_path, started_at, updated_at
FROM sessions WHERE session_id = ?
LIMIT 2
""",
(session_id,),
).fetchall()
if len(rows) == 1:
(
prompt,
cwd_value,
workspace_root,
messages_path_col,
started_at,
updated_at,
) = rows[0]
source_path = database
except DiagnosticError as error:
if error.code not in {"E_UNSUPPORTED_FORMAT", "E_CORRUPT_RECORD"}:
raise
# Fall through to sessions_dir / messages JSON authority.
# Index-absent path: recover metadata from the session manifest when present.
if (
sessions_dir is not None
and cwd_value is None
and workspace_root is None
):
manifest_path = os.path.join(sessions_dir, session_id, f"{session_id}.json")
manifest = _load_optional_manifest(manifest_path, root, budget)
if manifest is not None:
prompt = prompt or manifest.get("prompt")
cwd_value = manifest.get("cwd")
workspace_root = manifest.get("workspace_root")
started_at = started_at or manifest.get("started_at")
updated_at = (
updated_at
or manifest.get("updated_at")
or manifest.get("started_at")
)
msg_path = _messages_path_for(
session_id=session_id,
messages_path=messages_path_col,
sessions_dir=sessions_dir,
root=root,
)
if msg_path is None:
raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)
cwd: str | None = None
for candidate in (cwd_value, workspace_root):
if isinstance(candidate, str) and candidate.strip():
try:
cwd = canonicalize_cwd(candidate)
break
except DiagnosticError:
continue
title = None
if isinstance(prompt, str) and prompt.strip():
title = prompt.strip().splitlines()[0][: DEFAULT_BOUNDS.title_chars]
# Keep source_path aligned with list() (messages path preferred) so the
# reader invariant list.source_path == show.source_path holds.
return _show_from_messages(
session_id=session_id,
source_path=msg_path or source_path,
messages_path=msg_path,
root=root,
query=query,
budget=budget,
title=title,
cwd=cwd,
created_at=_stamp_iso(started_at),
updated_at=_stamp_iso(updated_at),
)
ADAPTER = ClineAdapter()
SHA-256: 3f9052903e6f2251a6d09199283b13e8e932c060ddd0c3fc608374f60540208b