← Files Portable ResumeARCHIVED FILE

skills/.portable-resume/runtime/portable_resume/adapters/openhands.py

25.6 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

"""Read OpenHands CLI local conversation event stores as inert context.

Pinned format: ``openhands-cli-events-v1`` — one directory per conversation with
ordered ``events/event-*.json`` records (OpenHands CLI LocalFileStore, 2026-07).

Default roots (env precedence):

    OPENHANDS_CONVERSATIONS_DIR
    or OPENHANDS_PERSISTENCE_DIR/conversations
    or ~/.openhands/conversations

Authority: event files under the selected conversation. Never invokes OpenHands
CLI/SDK/ACP/cloud, never registers SDK tools, never imports SDK event classes.
"""

from __future__ import annotations

import json
import os
import re
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 stable_read_bytes
from .base import CapabilityReport, ResolvedRef
from .common import within_age

FORMAT_ID = "openhands-cli-events-v1"
_CONV_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$")
_EVENT_NAME_RE = re.compile(r"^event-(\d{8})\.json$")
# Public message sources we normalize; everything else is control/private.
_PUBLIC_MESSAGE_SOURCES = frozenset({"user", "agent", "assistant"})
# Known control / private kinds: omit without failing closed.
_OMIT_KINDS = frozenset(
    {
        "SystemPromptEvent",
        "TokenEvent",
        "StreamingDeltaEvent",
        "ConversationStateUpdateEvent",
        "Condensation",
        "CondensationRequest",
        "CondensationSummaryEvent",
        "HookExecutionEvent",
        "LLMCompletionLogEvent",
        "InterruptEvent",
        "PauseEvent",
        "UserRejectObservation",
        "AgentErrorEvent",
        "ActionEvent",
        "ObservationEvent",
        "ACPToolCallEvent",
    }
)
# Content-bearing field names that must not hide unknown public turns.
_CONTENT_BEARING_KEYS = frozenset(
    {
        "message",
        "content",
        "prompt",
        "output",
        "text",
        "llm_message",
        "observation",
        "action",
    }
)


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_dir(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_ISDIR(mode):
        return False
    try:
        return is_within(path, root)
    except DiagnosticError:
        return False


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


def _safe_listdir(path: str) -> list[str]:
    try:
        return os.listdir(path)
    except OSError:
        return []


def _default_conversations_dir() -> str:
    env_conv = os.environ.get("OPENHANDS_CONVERSATIONS_DIR")
    if env_conv and env_conv.strip():
        path = env_conv.strip()
        if not os.path.isabs(path):
            raise DiagnosticError("E_UNSAFE_PATH", source="openhands", provider=FORMAT_ID)
        return path
    env_persist = os.environ.get("OPENHANDS_PERSISTENCE_DIR")
    if env_persist and env_persist.strip():
        path = env_persist.strip()
        if not os.path.isabs(path):
            raise DiagnosticError("E_UNSAFE_PATH", source="openhands", provider=FORMAT_ID)
        return os.path.join(path, "conversations")
    return os.path.expanduser("~/.openhands/conversations")


def _layout_from_root(candidate: str) -> tuple[str, str] | None:
    """Return (conversations_dir, containment_root) or None."""

    try:
        if os.path.isfile(candidate):
            base = os.path.basename(candidate)
            if not _EVENT_NAME_RE.fullmatch(base):
                return None
            events_dir = os.path.dirname(os.path.abspath(candidate))
            if os.path.basename(events_dir) != "events":
                return None
            conv_dir = os.path.dirname(events_dir)
            conversations = os.path.dirname(conv_dir)
            root = canonical_root(conversations)
            if not _regular_file(os.path.abspath(candidate), root):
                return None
            return conversations, root
        if not os.path.isdir(candidate):
            return None
        root = canonical_root(candidate)
    except DiagnosticError:
        return None

    # Exact conversation directory: .../<id> with events/ (before root scan).
    events = os.path.join(root, "events")
    if _regular_dir(events, root):
        parent = os.path.dirname(root)
        try:
            parent_root = canonical_root(parent)
        except DiagnosticError:
            parent_root = root
        return parent_root if _regular_dir(parent, parent_root) else root, parent_root

    # Direct conversations root (contains <id>/events/)
    names = _safe_listdir(root)
    if any(
        _regular_dir(os.path.join(root, name, "events"), root)
        for name in names
        if _CONV_ID_RE.fullmatch(name)
    ):
        return root, root

    # Persistence root: ~/.openhands
    conversations = os.path.join(root, "conversations")
    if _regular_dir(conversations, root):
        return conversations, root

    return None


def _resolve_layout(query: Query) -> tuple[str, str] | None:
    if query.source_root:
        return _layout_from_root(query.source_root)
    return _layout_from_root(_default_conversations_dir())


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 _CONV_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 _event_names(events_dir: str, *, scan_limit: int) -> list[str]:
    names = [
        name
        for name in _safe_listdir(events_dir)
        if _EVENT_NAME_RE.fullmatch(name)
    ]
    if len(names) > scan_limit:
        raise DiagnosticError.limit_exceeded()
    names.sort()
    return names


def _file_identity(path: str) -> tuple[int, int, int, int]:
    st = os.lstat(path)
    return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)


def _preferred_session_from_root(query: Query) -> str | None:
    """When source_root is an exact conversation directory, pin that id."""

    if not query.source_root:
        return None
    candidate = os.path.abspath(query.source_root)
    if not os.path.isdir(candidate):
        return None
    events = os.path.join(candidate, "events")
    if not os.path.isdir(events):
        return None
    name = os.path.basename(candidate.rstrip(os.sep))
    if _CONV_ID_RE.fullmatch(name):
        return name
    return None


def _load_event_json(
    path: str,
    root: str,
    budget: ReadBudget,
) -> Mapping[str, Any]:
    try:
        read = stable_read_bytes(
            path,
            root=root,
            max_bytes=min(budget.limits.record_bytes, DEFAULT_BOUNDS.record_bytes),
            budget=budget,
        )
    except DiagnosticError:
        raise
    except OSError as error:
        raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
    try:
        payload = json.loads(read.data, object_pairs_hook=_object)
    except (
        json.JSONDecodeError,
        _DuplicateKey,
        RecursionError,
        UnicodeDecodeError,
    ) as error:
        raise DiagnosticError("E_CORRUPT_RECORD", source="openhands", provider=FORMAT_ID) from error
    if not isinstance(payload, Mapping):
        raise DiagnosticError("E_CORRUPT_RECORD", source="openhands", provider=FORMAT_ID)
    return payload


def _text_from_content(content: object) -> str | None:
    if isinstance(content, str) and content.strip():
        return content.strip()
    if not isinstance(content, list):
        return None
    chunks: list[str] = []
    for item in content:
        if isinstance(item, str) and item.strip():
            chunks.append(item.strip())
            continue
        if not isinstance(item, Mapping):
            continue
        kind = item.get("type")
        if kind in {None, "text", "input_text", "output_text"}:
            text = item.get("text")
            if isinstance(text, str) and text.strip():
                chunks.append(text.strip())
    if not chunks:
        return None
    return "\n".join(chunks)


def _message_turn(payload: Mapping[str, Any]) -> tuple[str, str] | None:
    """Return (role, text) for a public MessageEvent, else None."""

    kind = payload.get("kind")
    if kind != "MessageEvent":
        return None
    source = payload.get("source")
    if not isinstance(source, str) or source not in _PUBLIC_MESSAGE_SOURCES:
        return None
    llm_message = payload.get("llm_message")
    if not isinstance(llm_message, Mapping):
        return None
    role_raw = llm_message.get("role")
    if isinstance(role_raw, str) and role_raw in {"user", "assistant"}:
        role = role_raw
    elif source == "user":
        role = "user"
    else:
        role = "assistant"
    text = _text_from_content(llm_message.get("content"))
    if text is None:
        return None
    return role, text


def _handle_event(payload: Mapping[str, Any], *, strict_unknown: bool) -> tuple[str, str] | None:
    kind = payload.get("kind")
    if not isinstance(kind, str) or not kind:
        if strict_unknown:
            raise DiagnosticError("E_CORRUPT_RECORD", source="openhands", provider=FORMAT_ID)
        return None
    if kind == "MessageEvent":
        return _message_turn(payload)
    if kind in _OMIT_KINDS:
        return None
    # Unknown kind: fail closed if it looks content-bearing.
    if any(key in payload for key in _CONTENT_BEARING_KEYS):
        raise DiagnosticError(
            "E_UNSUPPORTED_FORMAT", source="openhands", provider=FORMAT_ID
        )
    if strict_unknown:
        # Non-content control unknown — omit.
        return None
    return None


def _conversation_paths(
    conversations: str,
    session_id: str,
    root: str,
) -> tuple[str, str] | None:
    conv_dir = os.path.join(conversations, session_id)
    events_dir = os.path.join(conv_dir, "events")
    if not _regular_dir(conv_dir, root) or not _regular_dir(events_dir, root):
        return None
    return conv_dir, events_dir


def _list_title_and_stamp(
    events_dir: str,
    root: str,
    budget: ReadBudget,
) -> tuple[str | None, str | None, str | None]:
    """Bounded early scan: first stamp + first user title + last stamp."""

    scan_limit = min(budget.limits.scanned_records, DEFAULT_BOUNDS.scanned_records)
    names = _event_names(events_dir, scan_limit=scan_limit)
    if not names:
        return None, None, None
    first_stamp: str | None = None
    last_stamp: str | None = None
    title: str | None = None
    # Only open a small prefix for list eligibility / title.
    prefix = names[: min(32, len(names))]
    for name in prefix:
        path = os.path.join(events_dir, name)
        if not _regular_file(path, root):
            continue
        try:
            payload = _load_event_json(path, root, budget)
        except DiagnosticError as error:
            if error.code in {
                "E_CORRUPT_RECORD",
                "E_UNSUPPORTED_FORMAT",
                "E_LIMIT_EXCEEDED",
                "E_SOURCE_BUSY",
                "E_UNSAFE_PATH",
            }:
                raise
            continue
        budget.consume_records()
        stamp = _stamp_iso(payload.get("timestamp"))
        if first_stamp is None and stamp is not None:
            first_stamp = stamp
        if stamp is not None:
            last_stamp = stamp
        if title is None:
            turn = _message_turn(payload)
            if turn is not None and turn[0] == "user":
                title = turn[1].splitlines()[0][: DEFAULT_BOUNDS.title_chars]
    # Cheap last-file stamp when prefix did not include the last event.
    if names and names[-1] not in prefix:
        path = os.path.join(events_dir, names[-1])
        if _regular_file(path, root):
            try:
                payload = _load_event_json(path, root, budget)
            except DiagnosticError as error:
                if error.code in {
                    "E_LIMIT_EXCEEDED",
                    "E_SOURCE_BUSY",
                    "E_UNSAFE_PATH",
                    "E_CORRUPT_RECORD",
                    "E_UNSUPPORTED_FORMAT",
                }:
                    raise
            else:
                stamp = _stamp_iso(payload.get("timestamp"))
                if stamp is not None:
                    last_stamp = stamp
    return title, first_stamp, last_stamp or first_stamp


def _has_public_turn(
    events_dir: str,
    root: str,
    budget: ReadBudget,
    *,
    raise_on_bad: bool,
) -> bool:
    scan_limit = min(budget.limits.scanned_records, DEFAULT_BOUNDS.scanned_records)
    try:
        names = _event_names(events_dir, scan_limit=scan_limit)
    except DiagnosticError:
        if raise_on_bad:
            raise
        return False
    # Bound list eligibility work.
    for name in names[: min(64, len(names))]:
        path = os.path.join(events_dir, name)
        if not _regular_file(path, root):
            continue
        try:
            payload = _load_event_json(path, root, budget)
            turn = _handle_event(payload, strict_unknown=True)
        except DiagnosticError as error:
            if error.code in {"E_LIMIT_EXCEEDED", "E_SOURCE_BUSY", "E_UNSAFE_PATH"}:
                # Propagate hard failures — do not skip as if corrupt (Codex P1).
                raise
            if error.code in {"E_CORRUPT_RECORD", "E_UNSUPPORTED_FORMAT"}:
                if raise_on_bad:
                    raise
                return False
            continue
        budget.consume_records()
        if turn is not None:
            return True
    return False


class OpenHandsAdapter:
    key = "openhands"

    def approved_roots(self, query: Query) -> tuple[str, ...]:
        layout = _resolve_layout(query)
        return (layout[1],) 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")
            conversations, root = layout
            # Empty store is partial (layout recognized).
            names = [
                name
                for name in _safe_listdir(conversations)
                if _CONV_ID_RE.fullmatch(name)
                and _regular_dir(os.path.join(conversations, name, "events"), root)
            ]
            if not names:
                return CapabilityReport(
                    self.key, FORMAT_ID, "partial", root=root, evidence=(FORMAT_ID,)
                )
            # Spot-check one event file header shape when present.
            sample = sorted(names)[0]
            events_dir = os.path.join(conversations, sample, "events")
            event_names = [
                n for n in _safe_listdir(events_dir) if _EVENT_NAME_RE.fullmatch(n)
            ]
            if not event_names:
                return CapabilityReport(
                    self.key, FORMAT_ID, "partial", root=root, evidence=(FORMAT_ID,)
                )
            path = os.path.join(events_dir, sorted(event_names)[0])
            budget = ReadBudget()
            try:
                payload = _load_event_json(path, root, budget)
            except DiagnosticError as error:
                if error.code in {"E_UNSAFE_PATH", "E_SOURCE_BUSY"}:
                    return CapabilityReport(self.key, FORMAT_ID, "unsafe", root=root)
                return CapabilityReport(self.key, FORMAT_ID, "unsupported", root=root)
            if not isinstance(payload.get("kind"), str) or "timestamp" not in payload:
                return CapabilityReport(self.key, FORMAT_ID, "unsupported", root=root)
            return CapabilityReport(
                self.key, FORMAT_ID, "supported", root=root, evidence=(FORMAT_ID,)
            )
        except DiagnosticError as error:
            state = (
                "unsafe"
                if error.code in {"E_UNSAFE_PATH", "E_SOURCE_BUSY"}
                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
            )
        conversations, root = layout
        exact = _exact_ref(query.ref) or _preferred_session_from_root(query)
        scan_limit = min(budget.limits.scanned_records, DEFAULT_BOUNDS.scanned_records)
        list_limit = min(budget.limits.listed_sessions, DEFAULT_BOUNDS.listed_sessions)
        if exact is None and list_limit <= 0:
            return []

        if exact is not None:
            candidates = [exact]
            require_age = False
        else:
            names = sorted(_safe_listdir(conversations))
            if len(names) > scan_limit:
                raise DiagnosticError.limit_exceeded()
            candidates = [n for n in names if _CONV_ID_RE.fullmatch(n)]
            require_age = True

        values: list[SessionSummary] = []
        for session_id in candidates:
            paths = _conversation_paths(conversations, session_id, root)
            if paths is None:
                continue
            _conv_dir, events_dir = paths
            try:
                if not _has_public_turn(
                    events_dir, root, budget, raise_on_bad=exact is not None
                ):
                    continue
            except DiagnosticError as error:
                if error.code in {"E_LIMIT_EXCEEDED", "E_SOURCE_BUSY", "E_UNSAFE_PATH"}:
                    raise
                if exact is not None:
                    raise
                if error.code in {"E_CORRUPT_RECORD", "E_UNSUPPORTED_FORMAT"}:
                    continue
                continue
            try:
                title, created_at, updated_at = _list_title_and_stamp(
                    events_dir, root, budget
                )
            except DiagnosticError as error:
                if error.code in {"E_LIMIT_EXCEEDED", "E_SOURCE_BUSY", "E_UNSAFE_PATH"}:
                    raise
                if exact is not None:
                    raise
                if error.code in {"E_CORRUPT_RECORD", "E_UNSUPPORTED_FORMAT"}:
                    continue
                continue
            # Event v1 has no durable cwd field; leave cwd unset and do not
            # exclude on query.cwd (no false negatives for cwd-only filters).
            if require_age and not within_age(
                updated_at, query.within_min, default_minutes=DEFAULT_BOUNDS.listing_age_minutes
            ):
                continue
            values.append(
                SessionSummary(
                    source="openhands",
                    session_id=session_id,
                    source_path=events_dir,
                    title=title,
                    cwd=None,
                    branch=None,
                    created_at=created_at,
                    updated_at=updated_at,
                    provider=FORMAT_ID,
                    warnings=(),
                )
            )
            budget.consume_records()
            if exact is None and len(values) >= scan_limit:
                break

        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
        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
            )
        conversations, root = layout
        session_id = ref.session_id
        if not session_id or not _CONV_ID_RE.fullmatch(session_id):
            raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)
        paths = _conversation_paths(conversations, session_id, root)
        if paths is None:
            raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)
        _conv_dir, events_dir = paths
        # No durable cwd in events v1 — ignore query.cwd rather than fail closed.

        # Show uses transcript_records for membership (control events are many files).
        membership_limit = min(
            budget.limits.transcript_records, DEFAULT_BOUNDS.transcript_records
        )
        names = _event_names(events_dir, scan_limit=membership_limit)
        if not names:
            raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)

        # Stable set: pin name order + per-file identity before/after reads.
        membership = tuple(names)
        pinned: list[tuple[str, tuple[int, int, int, int]]] = []
        for name in membership:
            path = os.path.join(events_dir, name)
            if not _regular_file(path, root):
                raise DiagnosticError(
                    "E_CORRUPT_RECORD", source=self.key, provider=FORMAT_ID
                )
            try:
                pinned.append((name, _file_identity(path)))
            except OSError as error:
                raise DiagnosticError.source_busy(provider=FORMAT_ID) from error

        turns: list[Turn] = []
        warnings: list[str] = []
        created_at: str | None = None
        updated_at: str | None = None
        title: str | None = None
        turn_bounds = replace(DEFAULT_BOUNDS, tool_output_chars=query.max_tool_chars)
        transcript_limit = membership_limit
        count = 0
        seen_ids: set[str] = set()
        for name, before_id in pinned:
            path = os.path.join(events_dir, name)
            if not _regular_file(path, root):
                raise DiagnosticError("E_CORRUPT_RECORD", source=self.key, provider=FORMAT_ID)
            try:
                if _file_identity(path) != before_id:
                    raise DiagnosticError.source_busy(provider=FORMAT_ID)
            except OSError as error:
                raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
            count += 1
            if count > transcript_limit:
                raise DiagnosticError.limit_exceeded()
            budget.consume_transcript_records()
            payload = _load_event_json(path, root, budget)
            event_id = payload.get("id")
            if isinstance(event_id, str) and event_id:
                if event_id in seen_ids:
                    raise DiagnosticError(
                        "E_CORRUPT_RECORD", source=self.key, provider=FORMAT_ID
                    )
                seen_ids.add(event_id)
            stamp = _stamp_iso(payload.get("timestamp"))
            if created_at is None and stamp is not None:
                created_at = stamp
            if stamp is not None:
                updated_at = stamp
            parsed = _handle_event(payload, strict_unknown=True)
            if parsed is None:
                continue
            role, text = parsed
            if title is None and role == "user":
                title = text.splitlines()[0][: DEFAULT_BOUNDS.title_chars]
            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)

        # Revalidate full membership + identities after scan (stable set).
        after = tuple(_event_names(events_dir, scan_limit=membership_limit))
        if after != membership:
            raise DiagnosticError.source_busy(provider=FORMAT_ID)
        for name, before_id in pinned:
            path = os.path.join(events_dir, name)
            try:
                if not _regular_file(path, root) or _file_identity(path) != before_id:
                    raise DiagnosticError.source_busy(provider=FORMAT_ID)
            except OSError as error:
                raise DiagnosticError.source_busy(provider=FORMAT_ID) from error

        if not turns:
            raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)

        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="openhands",
            session_id=session_id,
            source_path=events_dir,
            title=title,
            cwd=None,
            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)),
        )


ADAPTER = OpenHandsAdapter()

SHA-256: 087d6e472c9451164bcdf1efeb1ac26062392a651bd7f609e0b0c5d9c925edf7