Skip to main content

supercode_interchange/session/
mod.rs

1//! Natively load — and continue — real Claude Code and Codex sessions.
2//!
3//! Both tools persist their conversations as JSONL on disk:
4//!
5//! - **Claude Code**: `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,
6//!   one line per event in the Anthropic message format, linked by
7//!   `uuid`/`parentUuid`.
8//! - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, where each line
9//!   is a `{timestamp, type, payload}` envelope and the `response_item` lines
10//!   form the canonical conversation.
11//!
12//! [`Session::load`] auto-detects the format and normalizes either one into a
13//! provider-neutral [`Vec<ChatMessage>`] that can be handed straight back to a
14//! model (via OpenRouter or any OpenAI-compatible endpoint) to continue.
15//!
16//! Provider-internal artifacts that don't replay across vendors — Anthropic
17//! `thinking` blocks, Codex `reasoning` items — are dropped during
18//! normalization.
19//!
20//! # Where this sits in supercode's priorities
21//!
22//! This module is the home of **feature 1 (translate between session formats)**
23//! and half of **feature 2 (emulate-to-continue)** — the load/emit surface for
24//! each harness ([`SessionFormat`], `from_*_str` loaders, `to_*_jsonl`
25//! emitters). See [`AGENTS.md`](../../../AGENTS.md) for the three ranked
26//! feature-priorities and the glue-tool positioning; the top priority is
27//! **feature 3 (continue losslessly *with massive token reduction*)**, which
28//! this fidelity work exists to make trustworthy. `opencode` + `pi` loaders
29//! are built against the frozen `docs/interop/opencode-pi-spec.md` contract
30//! — OpenCode additionally reads its native SQLite store (`opencode*.db`,
31//! PARITY-3/PARITY-16) via `rusqlite`, reconstructing the same envelope form
32//! [`Session::from_opencode_str`] already parses for the JSON-tree surfaces.
33
34use serde::{Deserialize, Serialize};
35use std::collections::{BTreeMap, HashMap, HashSet};
36use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
37use std::path::{Path, PathBuf};
38
39use rusqlite::Connection;
40use serde_json::Value;
41
42use crate::{
43    ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
44};
45
46mod claude_code;
47pub(crate) use claude_code::ClaudeAppendState;
48#[doc(hidden)]
49pub use claude_code::ClaudeReadIndex;
50mod codex;
51mod detect;
52mod gemini;
53mod goose;
54mod grok;
55mod helpers;
56mod hermes;
57mod native;
58mod openclaw;
59mod opencode;
60mod pi;
61mod residue;
62
63// The per-harness files below are an internal file layout only: every item
64// keeps its original `crate::session::…` path through these re-exports, whose
65// visibility matches the most-visible item each module holds.
66pub(crate) use claude_code::*;
67use codex::*;
68pub use detect::*;
69use gemini::*;
70use grok::*;
71pub use helpers::*;
72pub use hermes::*;
73use native::*;
74pub use openclaw::*;
75pub use opencode::*;
76pub use pi::*;
77pub use residue::*;
78
79/// Which tool produced a session log.
80///
81/// This is **read-provenance**: a fact recovered when a log is loaded (stored
82/// in [`SessionMeta::source`], filled in by auto-detection in
83/// `detect_source`), describing which tool originally wrote the file on
84/// disk. It answers "where did this session come from?" — e.g. for
85/// `inspect`/`convert` display in the CLI.
86///
87/// It is deliberately distinct from [`SessionFormat`], even though the two
88/// enums' variant lists currently coincide: [`SessionFormat`] selects a
89/// serialization codec (what to parse/export *as*), while `SessionSource`
90/// records history (what wrote the file). The pair is intentionally kept
91/// separate rather than merged — a session loaded from one tool's log can
92/// still be exported in the other tool's format, and the two concepts could
93/// diverge further (e.g. a format that is readable but not attributable, or
94/// multiple versioned formats sharing one source).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum SessionSource {
97    /// A `~/.claude/projects/.../<id>.jsonl` transcript.
98    ClaudeCode,
99    /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
100    Codex,
101    /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
102    /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
103    /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
104    /// 1:1 per the frozen interop spec (§0).
105    OpenCode,
106    /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
107    /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
108    /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
109    /// round-trip property.
110    Pi,
111    /// A Grok session transcript stored as
112    /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
113    Grok,
114    /// A Gemini CLI transcript stored under
115    /// `~/.gemini/tmp/<project>/chats/session-*.jsonl`.
116    Gemini,
117    /// A Goose session exported through `_goose/unstable/session/export`, or
118    /// reconstructed from Goose's `sessions/sessions.db` native store.
119    Goose,
120    /// An OpenClaw agent session (`~/.openclaw/agents/<agentId>/sessions/
121    /// <uuid>.jsonl`, openclaw >= 2026.7): pi session-format v3 with
122    /// openclaw dialect divergences — `type:"leaf"` navigation-control
123    /// entries that REDIRECT the active leaf (pi's last-entry anchor rule is
124    /// wrong for them), `appendMode:"side"` entries that never anchor, and
125    /// vendor-namespaced `__openclaw` message metadata. READ-ONLY provenance
126    /// (UNI-16): there is deliberately no `SessionFormat::OpenClaw` — the
127    /// write tier is a permanently skipped direct-DB/store path; loaded
128    /// sessions translate OUT through the other formats.
129    OpenClaw,
130    /// A Hermes Agent session read from its single SQLite store
131    /// (`~/.hermes/state.db`, `SCHEMA_VERSION = 22` at the 0.19.0 pin).
132    /// READ-ONLY provenance (UNI-15): no `SessionFormat::Hermes` exists —
133    /// writing into a live, shared, WAL, single-writer store stays gated by
134    /// UNI-22 (not fired; the schema churned 19->22 in one release) — loaded
135    /// sessions translate OUT through the other formats.
136    Hermes,
137    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
138    /// cosmetic"): a session that was never imported from ANY foreign
139    /// tool's log at all — authored directly by supercode's own agent loop,
140    /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
141    /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
142    /// natively-spawned `spawn_subagent` children) uses this — before this
143    /// variant existed, that call site built its blank `Session` via
144    /// `Session::from_claude_code_str("")` purely as an "empty parser to
145    /// get a blank skeleton" trick, which left `meta.source ==
146    /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
147    /// was ever involved, mislabeling a native supercode spawn as an
148    /// imported CC session on disk (and in any `inspect`/`convert` reading
149    /// it back). Never produced by auto-detection (`detect_source`) or any
150    /// `from_<tool>_str` loader — only by code that explicitly constructs
151    /// a `SessionMeta` with this source, so no existing imported-session
152    /// path can ever observe this variant appearing where it didn't before.
153    Native,
154}
155
156/// An on-disk session format supercode can both read and write.
157///
158/// Like an image editor that opens and exports several file formats, supercode
159/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
160/// supported format on the edges.
161///
162/// This is a **write-target** / codec selector: a caller's request, passed to
163/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
164/// choosing which on-disk dialect to parse or emit. It answers "what format
165/// should I read/write?" — as opposed to [`SessionSource`], which records the
166/// provenance fact of what actually produced a loaded file. The two enums are
167/// intentionally kept separate (provenance fact vs. serialization choice) and
168/// should not be unified, even though their variants currently match
169/// one-to-one.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum SessionFormat {
172    /// Claude Code transcript JSONL.
173    ClaudeCode,
174    /// Codex rollout JSONL.
175    Codex,
176    /// OpenCode export-document / envelope JSONL (wave B; see
177    /// [`SessionSource::OpenCode`]).
178    OpenCode,
179    /// Pi session JSONL (see [`SessionSource::Pi`]).
180    Pi,
181    /// Grok `chat_history.jsonl` transcript.
182    Grok,
183    /// Gemini CLI session JSONL.
184    Gemini,
185    /// Goose native session-export JSON.
186    Goose,
187}
188
189impl SessionFormat {
190    /// The [`SessionSource`] a file of this format reports.
191    ///
192    /// This is the deliberate one-way bridge between the two concepts: a file
193    /// saved in this format will, when reloaded, report this provenance (see
194    /// `crates/harness/tests/session_saving.rs`), making the relationship
195    /// discoverable from the method itself.
196    pub fn source(self) -> SessionSource {
197        match self {
198            SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
199            SessionFormat::Codex => SessionSource::Codex,
200            SessionFormat::OpenCode => SessionSource::OpenCode,
201            SessionFormat::Pi => SessionSource::Pi,
202            SessionFormat::Grok => SessionSource::Grok,
203            SessionFormat::Gemini => SessionSource::Gemini,
204            SessionFormat::Goose => SessionSource::Goose,
205        }
206    }
207
208    /// The format a session from `source` is written in, when it has one of its own.
209    pub fn for_source(source: SessionSource) -> Option<Self> {
210        [
211            SessionFormat::ClaudeCode,
212            SessionFormat::Codex,
213            SessionFormat::OpenCode,
214            SessionFormat::Pi,
215            SessionFormat::Grok,
216            SessionFormat::Gemini,
217            SessionFormat::Goose,
218        ]
219        .into_iter()
220        .find(|format| format.source() == source)
221    }
222}
223
224/// Metadata recovered from a session log.
225pub use crate::ontology::surface::{
226    CrossSurface, Recurrence, SurfaceKey, Trigger, WorkspaceKind, WorkspaceRef,
227};
228
229/// How a Codex session started, from its `session_meta`: a subagent's (`source.subagent`) is its parent's;
230/// `codex exec` (`codex_exec`) and supercode's runtimes (`supercode`, `supercode_*`) are programs. A TUI
231/// start makes no claim (agents run the TUI in panes too), nor do other originators. `source` alone cannot
232/// tell programs apart: its `vscode` covers every app-server client, supercode's included.
233pub fn codex_start_trigger(payload: &serde_json::Value) -> Option<Trigger> {
234    if payload
235        .get("source")
236        .and_then(|source| source.get("subagent"))
237        .is_some()
238    {
239        return Some(Trigger::Parent);
240    }
241    match payload
242        .get("originator")
243        .and_then(serde_json::Value::as_str)?
244    {
245        "codex_exec" => Some(Trigger::Api),
246        other if other == "supercode" || other.starts_with("supercode_") => Some(Trigger::Api),
247        _ => None,
248    }
249}
250
251/// ORCH-6: the ORCH-3 conversation nouns as one additive wire block, carried
252/// by `harness.v1.sessions.discover` / `sessions.load` rows and by
253/// [`crate::catalog::SessionDescriptor`]. Every field is optional so an older
254/// client sees exactly the shape it already knows.
255#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
256pub struct OrchestrationNouns {
257    /// Why the session exists.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub trigger: Option<Trigger>,
260    /// Where the conversation is reached.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub surface: Option<SurfaceKey>,
263    /// Routed config home (Hermes profile / OpenClaw agent).
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub profile: Option<String>,
266    /// The job a recurring session belongs to.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub recurrence: Option<Recurrence>,
269    /// Moved-to-another-surface state.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub cross_surface: Option<CrossSurface>,
272    /// Typed workspace (the D2 precedence result).
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub workspace: Option<WorkspaceRef>,
275}
276
277impl OrchestrationNouns {
278    /// Read the nouns off a loaded session's metadata. `trigger` and
279    /// `workspace` always resolve — through [`SessionMeta::trigger_or_default`]
280    /// and [`SessionMeta::workspace`], never through a second derivation.
281    pub fn from_meta(meta: &SessionMeta) -> Self {
282        Self {
283            trigger: Some(meta.trigger_or_default()),
284            surface: meta.surface.clone(),
285            profile: meta.profile.clone(),
286            recurrence: meta.recurrence.clone(),
287            cross_surface: meta.cross_surface.clone(),
288            workspace: Some(meta.workspace_ref()),
289        }
290    }
291}
292
293#[derive(Debug, Clone)]
294#[non_exhaustive]
295pub struct SessionMeta {
296    /// The tool that wrote the log.
297    pub source: SessionSource,
298    /// The session/rollout id.
299    pub session_id: Option<String>,
300    /// Native recorded session end, in RFC 3339 form. Absence is unknown;
301    /// it must not be replaced by a transcript quiet-time estimate.
302    pub ended_at: Option<String>,
303    /// Source-native reason accompanying the recorded session end.
304    pub end_reason: Option<String>,
305    /// The model the session was running.
306    pub model: Option<String>,
307    /// The working directory the session ran in.
308    pub cwd: Option<PathBuf>,
309    /// The system / base-instructions prompt, when the log records it.
310    pub system_prompt: Option<String>,
311    /// Verbatim source-format header records (the Codex `session_meta` /
312    /// `turn_context` lines), preserved so re-export can replay the exact header
313    /// the original tool expects rather than guessing its required fields.
314    pub codex_headers: Vec<Value>,
315    /// Exact source lines for Codex execution/provenance records that affect
316    /// continuation semantics but must not be replayed as active events after
317    /// a foreign-format hop. Each entry records its original physical-line
318    /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
319    /// list in a namespaced extension; a later Codex export restores headers
320    /// from it while keeping compaction/rollback/review records non-operative,
321    /// avoiding a second rollback or compaction of the already-normalized view.
322    pub codex_provenance: Vec<Value>,
323    /// PARITY-23: generalized source-native residue records for NON-codex
324    /// sources — `{record_index, kind, raw}` entries captured at load time
325    /// (or restored from a portable v2 envelope) so cross-format hops can
326    /// return them exactly. Codex keeps its original dedicated store above.
327    pub native_residue: Vec<Value>,
328    /// The source format `native_residue` belongs to (e.g. `claude_code`).
329    pub native_residue_source: Option<String>,
330    /// For each message as loaded, the index in `raw` of the record that created it, when the
331    /// loader knows it — the provenance the portable residue cuts segments on
332    /// (`docs/plans/portable-residue.md`). Empty when the loader does not record it.
333    pub message_records: Vec<Option<usize>>,
334    /// The OpenCode analogue of [`Self::codex_headers`]
335    /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
336    /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
337    /// absent), plus any captured `session_diff`/`todo` side-records — each
338    /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
339    /// shape `raw` uses, so a consumer can tell which storage key a header
340    /// record belongs to. These replay only via the direct-write fallback
341    /// (`Session::to_opencode_direct_write`); `opencode import` has no
342    /// ingestion path for `session_diff`/`todo` (S5).
343    pub opencode_headers: Vec<Value>,
344    /// Goose's native session-export object with `conversation` removed.
345    /// Goose stores sessions in SQLite but defines this JSON object as its
346    /// official import/export boundary. Keeping the shell lets an unchanged
347    /// direct round-trip remain byte exact while appended turns are spliced
348    /// into a stock-importable artifact without guessing native metadata.
349    pub goose_header: Option<Value>,
350    /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
351    /// stem). `None` for top-level sessions.
352    pub agent_id: Option<String>,
353    /// For a subagent session: the `tool_use_id` of the parent `Task` call that
354    /// spawned it, recovered from the parent transcript's tool result. Best
355    /// effort — `None` if the link could not be established.
356    pub parent_tool_use_id: Option<String>,
357    /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
358    /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
359    /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
360    /// `depth`). Empty for a plain top-level session. Used by
361    /// [`Session::reconstruct_tree`] to nest children under their parents.
362    pub lineage: std::collections::BTreeMap<String, String>,
363    /// ORCH-3: why the session exists, when the source says.
364    pub trigger: Option<Trigger>,
365    /// ORCH-3: the conversation's surface identity, when it has one.
366    pub surface: Option<SurfaceKey>,
367    /// ORCH-3: routed config home (Hermes profile / OpenClaw agent / Codex profile).
368    pub profile: Option<String>,
369    /// ORCH-3: the job a recurring session belongs to.
370    pub recurrence: Option<Recurrence>,
371    /// ORCH-3: moved-to-another-surface state.
372    pub cross_surface: Option<CrossSurface>,
373}
374
375impl SessionMeta {
376    pub(crate) fn new(source: SessionSource) -> Self {
377        SessionMeta {
378            source,
379            session_id: None,
380            ended_at: None,
381            end_reason: None,
382            model: None,
383            cwd: None,
384            system_prompt: None,
385            codex_headers: Vec::new(),
386            codex_provenance: Vec::new(),
387            native_residue: Vec::new(),
388            native_residue_source: None,
389            message_records: Vec::new(),
390            opencode_headers: Vec::new(),
391            goose_header: None,
392            agent_id: None,
393            parent_tool_use_id: None,
394            lineage: std::collections::BTreeMap::new(),
395            trigger: None,
396            surface: None,
397            profile: None,
398            recurrence: None,
399            cross_surface: None,
400        }
401    }
402
403    /// The trigger, defaulting from what the loaders already know: a spawned
404    /// child (`agent_id` / a delegate lineage) is `Parent`; otherwise `Human`.
405    pub fn trigger_or_default(&self) -> Trigger {
406        if let Some(t) = self.trigger {
407            return t;
408        }
409        let delegate = self
410            .lineage
411            .get("hermes_lineage_kind")
412            .map(|k| k == "delegate")
413            .unwrap_or(false);
414        if self.agent_id.is_some() || self.parent_tool_use_id.is_some() || delegate {
415            Trigger::Parent
416        } else {
417            Trigger::Human
418        }
419    }
420
421    /// UNI-9 workspace with the D2 precedence: `repo` when a cwd exists, else
422    /// `channel` when the surface is a channel, else `none`. Derived, never stored.
423    pub fn workspace(&self) -> (WorkspaceKind, Option<String>) {
424        if let Some(cwd) = &self.cwd {
425            return (
426                WorkspaceKind::Repo,
427                Some(cwd.to_string_lossy().into_owned()),
428            );
429        }
430        if let Some(surface) = self.surface.as_ref().filter(|s| s.is_channel()) {
431            let label = match (&surface.platform, &surface.chat_id) {
432                (Some(p), Some(c)) => format!("{p}:{c}"),
433                (Some(p), None) => p.clone(),
434                _ => String::new(),
435            };
436            return (WorkspaceKind::Channel, Some(label));
437        }
438        (WorkspaceKind::None, None)
439    }
440
441    /// [`Self::workspace`] as the wire value. Naming only — the precedence
442    /// stays in `workspace()`.
443    pub fn workspace_ref(&self) -> WorkspaceRef {
444        let (kind, value) = self.workspace();
445        WorkspaceRef { kind, value }
446    }
447}
448
449/// A normalized, replayable conversation loaded from a tool's session log.
450#[derive(Debug, Clone)]
451pub struct Session {
452    /// Recovered metadata.
453    pub meta: SessionMeta,
454    /// The conversation, normalized to the OpenAI chat-completions shape.
455    pub messages: Vec<ChatMessage>,
456    /// Subagent (Task) sub-conversations. Claude Code stores these as separate
457    /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
458    /// discovers and attaches them here (each is a full [`Session`] whose
459    /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
460    pub subagents: Vec<Session>,
461    /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
462    /// captured via `split_lines_verbatim`, not the blank-skipping/trimming
463    /// `non_empty_lines` parse view, so a blank line, a CRLF (`\r\n`)
464    /// terminator, or trailing whitespace on a line all survive bit-for-bit
465    /// rather than being dropped/normalized away. Normalization into
466    /// `messages` is still lossy by design (it targets the OpenAI replay
467    /// shape), but these raw lines retain *everything* — including records
468    /// with no canonical representation (e.g. Claude `file-history-snapshot`)
469    /// — so a round-trip through the supercode-native format
470    /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
471    /// formats (Claude Code/Codex/Pi), for ANY input (see
472    /// [`Self::raw_trailing_newline`] for the one piece of information a line
473    /// list alone can't carry).
474    pub raw: Vec<String>,
475    /// Whether the source text `raw` was captured from ended with a trailing
476    /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
477    /// trailing newline from one that doesn't (both split into the same
478    /// lines) — this flag carries that fact out-of-band so
479    /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
480    /// original source bytes exactly, including the presence/absence of a
481    /// final newline. `true` for a `Session` whose `raw` isn't captured
482    /// verbatim from real source text (e.g. OpenCode's re-synthesized
483    /// export-document `raw`, or a `Session` assembled programmatically) —
484    /// matching the historical always-terminated-by-newline behavior for
485    /// those cases.
486    pub raw_trailing_newline: bool,
487    /// How many of `messages` (and, symmetrically, of `raw` — see below) came
488    /// from parsing the imported log, as opposed to being appended after
489    /// import. Set once, at the end of [`Self::from_claude_code_str`] /
490    /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
491    /// before [`Self::from_native_str`]'s subsequent loop reattaches any
492    /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
493    /// That loop pushes exactly one `raw` line and one message per appended
494    /// turn, so the two lists grow in lockstep from here on: the raw-prefix
495    /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
496    /// as `raw.len() - (messages.len() - imported_message_count)`, without a
497    /// second counter. `None` only when a `Session` is constructed some other
498    /// way than through those two loaders — splicing then has no boundary to
499    /// honor and treats every message as imported (equivalent to
500    /// `Some(messages.len())`).
501    /// A bounded display-history projection uses this field for the total
502    /// number of normalized messages observed before its in-memory window was
503    /// applied. Such a semantic view is never a continuation source, and all
504    /// splice callers clamp the value to `messages.len()`.
505    pub imported_message_count: Option<usize>,
506    /// Whether `raw` was captured strict-verbatim from real source text
507    /// (`true`) or re-synthesized by this crate (`false`) — the fact
508    /// [`Self::raw_verbatim`]'s callers need to know before claiming a
509    /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
510    /// `true` for every line-oriented loader (`from_claude_code_str`,
511    /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
512    /// surface (`from_opencode_str`'s per-line loop) — each of those splits
513    /// `raw` directly out of the source text via `split_lines_verbatim`, so
514    /// replaying it reproduces the original bytes exactly. `false` for
515    /// OpenCode's EXPORT-DOCUMENT read surface
516    /// (`Session::from_opencode_export_doc`): a pretty-printed
517    /// `{info, messages:[...]}` document has no per-line envelope structure
518    /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
519    /// record — faithful in value, but not the original document's bytes.
520    /// A `Session` assembled programmatically (not through a `from_*_str`
521    /// loader) also defaults to `false` — no real source text was captured
522    /// at all.
523    pub raw_is_verbatim: bool,
524    /// PARITY-15: how many non-empty lines of the source text FAILED to
525    /// deserialize at all (a genuinely malformed/truncated JSON line — not
526    /// a well-formed-but-unmodeled record type, which is a normal,
527    /// intentional "skip", tracked separately by `crate::audit`). Every
528    /// line-oriented loader tolerates a stray corrupt line rather than
529    /// hard-failing the whole load (a single bad line must not make an
530    /// otherwise-healthy multi-thousand-line session unloadable) — but that
531    /// tolerance used to be completely invisible: `Session::load` returned
532    /// `Ok` either way, with no signal that anything was skipped. This
533    /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
534    /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
535    /// and for a `Session` assembled programmatically.
536    pub parse_error_lines: usize,
537    /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
538    /// failing — the same "say exactly what was given up" residue list
539    /// `harness.v1.sessions.export` already reports for artifacts.
540    ///
541    /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
542    /// transcript it cannot reconstruct exactly, which is what keeps
543    /// continuation/transfer/export guarantees intact. A non-empty list means
544    /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
545    /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
546    pub load_residue: Vec<String>,
547}
548
549impl Session {
550    /// The fidelity this reconstruction actually achieved.
551    ///
552    /// Same rule the export path applies to an artifact: named residue means
553    /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
554    /// [`Fidelity::ByteLossless`] and a re-synthesized one is
555    /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
556    /// session's: the whole reconstruction is only as faithful as its least
557    /// faithful part, and each child still reports its own residue where it
558    /// was measured.
559    pub fn load_fidelity(&self) -> Fidelity {
560        let own = if !self.load_residue.is_empty() {
561            Fidelity::Semantic
562        } else if self.raw_is_verbatim {
563            Fidelity::ByteLossless
564        } else {
565            Fidelity::ValueLossless
566        };
567        if own != Fidelity::Semantic
568            && self
569                .subagents
570                .iter()
571                .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
572        {
573            return Fidelity::Semantic;
574        }
575        own
576    }
577
578    /// Assemble a session from supercode's own flat store transcript (one
579    /// [`ChatMessage`] per JSONL line). These files are the native working
580    /// format written by Supercode's native session store, not a foreign
581    /// harness log, so routing them through format auto-detection would
582    /// misclassify them as an empty Claude Code session.
583    pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
584        Session {
585            meta: SessionMeta::new(SessionSource::Native),
586            messages,
587            subagents: Vec::new(),
588            raw: Vec::new(),
589            raw_trailing_newline: true,
590            imported_message_count: None,
591            raw_is_verbatim: false,
592            parse_error_lines: 0,
593            load_residue: Vec::new(),
594        }
595    }
596
597    /// Load a session, auto-detecting whether it's a Claude Code or Codex log
598    /// — or, when `path` looks like a SQLite database, a real OpenCode
599    /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
600    /// UTF-8 text read, so a binary `.db` file is routed to
601    /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
602    /// did not contain valid UTF-8" error (the confirmed footgun these items
603    /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
604    ///
605    /// A DIRECTORY is also accepted directly: `path` is probed with
606    /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
607    /// checks below (both of which assume a file and would otherwise surface
608    /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
609    /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
610    /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
611    /// `audit --format opencode` already does. A resolved `Sqlite` surface
612    /// loads exactly like pointing `load` at that `opencode*.db` file
613    /// directly (most-recently-updated top-level session). The legacy
614    /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
615    /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
616    /// that case returns a clear error naming the `.db` file / `audit` as the
617    /// way in, rather than silently doing nothing or crashing.
618    pub fn load(path: impl AsRef<Path>) -> Result<Session> {
619        Self::load_with_fidelity(path, Fidelity::ByteLossless)
620    }
621
622    /// Load a session at a declared [`Fidelity`].
623    ///
624    /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
625    /// record graph cannot be reconstructed exactly (the everyday case for a
626    /// Claude Code session that has been compacted or resumed across files,
627    /// where a live record's `parentUuid` names a record that was pruned)
628    /// still loads, stitched best-effort in transcript order, and names what
629    /// it gave up in [`Session::load_residue`]. Every stricter level keeps
630    /// the historical behavior — refuse loudly — because a continuation,
631    /// transfer or export built on a guessed graph is exactly the loss
632    /// supercode exists to prevent. Callers that go on to RESUME a session
633    /// must therefore use [`Session::load`].
634    pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
635        Self::load_with_fidelity_and_subagents(path, fidelity, true)
636    }
637
638    /// Load only the selected session's own transcript at a declared fidelity.
639    ///
640    /// This is the read-only frontend path: Claude Code can place hundreds of
641    /// child transcripts beside a parent, but a chat viewport displaying the
642    /// parent must not eagerly parse and transport that entire child tree.
643    /// Translation, continuation, export, and the ordinary [`Self::load`]
644    /// path keep attaching every subagent unchanged.
645    #[doc(hidden)]
646    pub fn load_parent_with_fidelity(
647        path: impl AsRef<Path>,
648        fidelity: Fidelity,
649    ) -> Result<Session> {
650        Self::load_with_fidelity_and_subagents(path, fidelity, false)
651    }
652
653    /// Load a bounded, parent-only transcript for human display.
654    ///
655    /// Unlike the continuation loader, Codex compaction records do not erase
656    /// earlier visible assistant turns here: the native rollout still holds
657    /// those records, and a scrollback view should show what the human saw,
658    /// not only the compacted context the next model call will receive.
659    #[doc(hidden)]
660    pub fn load_display_view(
661        path: impl AsRef<Path>,
662        fidelity: Fidelity,
663        message_limit: usize,
664    ) -> Result<Session> {
665        let path = path.as_ref();
666        if path.is_dir() || looks_like_sqlite(path) {
667            let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
668            truncate_session_messages(&mut session, message_limit);
669            return Ok(session);
670        }
671        let mut read_limit = message_limit.max(1);
672        let mut previous_window_len = 0usize;
673        let (mut session, omitted_prefix) = loop {
674            let (source, text, omitted_prefix) = read_display_jsonl(path, read_limit)?;
675            let mut candidate = match source {
676                Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
677                Some(SessionSource::Gemini) => {
678                    let mut session = Self::from_gemini_str(&text)?;
679                    session.raw_is_verbatim = false;
680                    session.load_residue.push(
681                        "display history is a bounded native-record projection, not a complete Gemini artifact"
682                            .to_string(),
683                    );
684                    session
685                }
686                Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
687                Some(SessionSource::Grok) => {
688                    let mut session = Self::from_grok_str(&text)?;
689                    session.capture_grok_path_metadata(path);
690                    session
691                }
692                Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
693                _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
694            };
695            let observed_messages = candidate
696                .imported_message_count
697                .unwrap_or(candidate.messages.len())
698                .max(candidate.messages.len());
699            let human_turns = candidate
700                .messages
701                .iter()
702                .filter(|message| message.role == Role::User)
703                .count();
704            let window_len = text.len();
705            let sufficient =
706                !omitted_prefix || (observed_messages > message_limit.max(1) && human_turns >= 2);
707            // 16 KiB/message with a 64 MiB ceiling means 4096 is the first
708            // read limit that cannot grow the native byte window further.
709            // Smaller repeated lengths can be the intentional 4 MiB floor;
710            // keep doubling through that plateau instead of declaring a
711            // false pagination end.
712            let byte_window_exhausted = window_len <= previous_window_len && read_limit >= 4096;
713            if sufficient || byte_window_exhausted {
714                if omitted_prefix {
715                    // The prefix is known to contain more native history even
716                    // when this bounded window cannot cheaply normalize its
717                    // exact size. Never turn that into a false end-of-history.
718                    candidate.imported_message_count =
719                        Some(observed_messages.max(message_limit.max(1).saturating_add(1)));
720                }
721                break (candidate, omitted_prefix);
722            }
723            previous_window_len = window_len;
724            read_limit = read_limit.saturating_mul(2);
725        };
726        if omitted_prefix {
727            session.load_residue.push(
728                "older native records remain outside this bounded display window".to_string(),
729            );
730        }
731        truncate_session_messages(&mut session, message_limit);
732        Ok(session)
733    }
734
735    fn load_with_fidelity_and_subagents(
736        path: impl AsRef<Path>,
737        fidelity: Fidelity,
738        include_subagents: bool,
739    ) -> Result<Session> {
740        let path = path.as_ref();
741        if path.is_dir() {
742            return match detect_opencode_storage_surface(path) {
743                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
744                    Self::from_opencode_sqlite(&db_path, None)
745                }
746                Some((
747                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
748                    _,
749                )) => Err(crate::Error::Other(format!(
750                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
751                         supercode does not load directly — point `inspect`/`convert`/`resume` \
752                         at the store's `opencode*.db` SQLite file if this install has one, or \
753                         use `audit --format opencode {}` instead",
754                    path.display(),
755                    path.display()
756                ))),
757                None => Err(crate::Error::Other(format!(
758                    "{} is a directory, but no session file or OpenCode store was found in it \
759                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
760                     tree)",
761                    path.display()
762                ))),
763            };
764        }
765        if looks_like_sqlite(path) {
766            // Two SQLite-backed stores exist: OpenCode's (schema_meta-free
767            // key/value envelope db) and Hermes's `state.db` (UNI-15). The
768            // fingerprint check is cheap and read-only.
769            if let Ok(conn) = Connection::open_with_flags(
770                path,
771                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
772                    | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
773            ) {
774                if hermes_sqlite_fingerprint(&conn) {
775                    drop(conn);
776                    return Self::from_hermes_sqlite(path, None);
777                }
778            }
779            return Self::from_opencode_sqlite(path, None);
780        }
781        let text = read_utf8_or_diagnose(path)?;
782        match detect_source(&text) {
783            Some(SessionSource::Codex) => Self::from_codex_str(&text),
784            Some(SessionSource::Pi) => Self::from_pi_str(&text),
785            Some(SessionSource::OpenClaw) => {
786                let mut session = Self::from_openclaw_str(&text)?;
787                if session.meta.profile.is_none() {
788                    session.meta.profile = openclaw_agent_id_from_path(path);
789                }
790                Ok(session)
791            }
792            Some(SessionSource::Grok) => {
793                let mut session = Self::from_grok_str(&text)?;
794                session.capture_grok_path_metadata(path);
795                Ok(session)
796            }
797            Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
798            Some(SessionSource::Goose) => Self::from_goose_str(&text),
799            // IX-3: a detected OpenCode session must route to its own
800            // loader, not the Claude Code fallback below
801            // (`docs/interop/build-followups.md`).
802            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
803            _ => {
804                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
805                if include_subagents {
806                    session.attach_claude_subagents(path, &text, fidelity)?;
807                }
808                Ok(session)
809            }
810        }
811    }
812
813    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
814    ///
815    /// Codex stores subagents as separate rollout files linked to their parent
816    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
817    /// collection of sessions, this nests each child into its parent's
818    /// [`Session::subagents`] and returns only the roots. Children whose parent
819    /// isn't in the set are returned as roots themselves (best effort).
820    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
821        use std::collections::HashMap;
822        // Index each session's position by its session_id.
823        let mut idx: HashMap<String, usize> = HashMap::new();
824        for (i, s) in sessions.iter().enumerate() {
825            if let Some(id) = &s.meta.session_id {
826                idx.insert(id.clone(), i);
827            }
828        }
829        // Determine each session's parent (by index), if present in the set.
830        let parent_of: Vec<Option<usize>> = sessions
831            .iter()
832            .map(|s| {
833                s.meta
834                    .lineage
835                    .get("parent_thread_id")
836                    .and_then(|p| idx.get(p).copied())
837            })
838            .collect();
839
840        // Move children into parents, deepest-first so chains nest correctly.
841        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
842        let mut order: Vec<usize> = (0..slots.len()).collect();
843        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
844        for i in order {
845            if let Some(p) = parent_of[i] {
846                if p != i {
847                    if let Some(child) = slots[i].take() {
848                        if let Some(parent) = slots[p].as_mut() {
849                            parent.subagents.push(child);
850                        } else {
851                            slots[i] = Some(child); // parent already moved; keep as root
852                        }
853                    }
854                }
855            }
856        }
857        slots.into_iter().flatten().collect()
858    }
859
860    /// Parse a session of a known format from an in-memory JSONL string.
861    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
862        match format {
863            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
864            SessionFormat::Codex => Self::from_codex_str(jsonl),
865            SessionFormat::Pi => Self::from_pi_str(jsonl),
866            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
867            SessionFormat::Grok => Self::from_grok_str(jsonl),
868            SessionFormat::Gemini => Self::from_gemini_str(jsonl),
869            SessionFormat::Goose => Self::from_goose_str(jsonl),
870        }
871    }
872
873    /// Serialize this session to JSONL in the given format.
874    ///
875    /// The conversation is synthesized from the canonical messages, so this
876    /// works for sessions loaded from *either* tool as well as ones supercode
877    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
878    /// "export": format-specific framing that has no slot in the target may be
879    /// dropped, but the user/assistant/tool conversation is preserved.
880    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
881        match format {
882            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
883            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
884            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
885            SessionFormat::OpenCode => self.to_opencode_jsonl(),
886            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
887            SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
888            SessionFormat::Goose => Ok(self.to_goose_json()),
889        }
890    }
891
892    /// Export back to `format`, replaying the imported `raw` prefix
893    /// **verbatim** — original uuids/ids, real timestamps, and
894    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
895    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
896    /// is the session's own origin (`format.source() == self.meta.source`,
897    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
898    /// Only messages appended *after* import (tracked by
899    /// [`Self::imported_message_count`]) are synthesized, chained onto the
900    /// last original record found in the raw prefix.
901    ///
902    /// `session_id` of `Some(new)` rewrites the session id on every emitted
903    /// line, raw and synthesized alike (`sessionId` for Claude Code,
904    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
905    ///
906    /// Cross-format export (no verbatim prefix exists in the target dialect,
907    /// by definition) and a session with no `raw` lines both fall back
908    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
909    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
910    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
911    /// cross-format stays at the documented semantic tier.
912    pub fn to_jsonl_spliced(
913        &self,
914        format: SessionFormat,
915        session_id: Option<&str>,
916    ) -> Result<String> {
917        if self.parse_error_lines > 0
918            || self
919                .subagents
920                .iter()
921                .any(|subagent| subagent.parse_error_lines > 0)
922        {
923            return Err(Error::InvalidSession(
924                "refusing spliced export because the loaded session contains parse loss"
925                    .to_string(),
926            ));
927        }
928        if self.raw.is_empty() || format.source() != self.meta.source {
929            if let Some(session_id) = session_id {
930                let mut rewritten = self.clone();
931                rewritten.meta.session_id = Some(session_id.to_string());
932                return rewritten.to_jsonl(format);
933            }
934            return self.to_jsonl(format);
935        }
936        match format {
937            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
938            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
939            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
940            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
941            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
942            SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
943            SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
944        }
945    }
946
947    /// Write this session to `path` in the given format.
948    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
949        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
950        Ok(())
951    }
952
953    /// Reconstruct the exact source bytes this `Session` was loaded from,
954    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
955    /// inverse of the strict-verbatim capture those two fields record — see
956    /// `join_lines_verbatim`).
957    ///
958    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
959    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
960    /// original text, so this reproduces the original file byte-for-byte —
961    /// the P008/P009 diagonal-convert fix (`convert <file> --to
962    /// <same-format>` is byte-identical to `<file>`) is built on exactly
963    /// this. The one documented exception is an OpenCode **export-document**
964    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
965    /// is RE-SYNTHESIZED as one envelope line per record (see
966    /// `from_opencode_export_doc`'s contract), so this returns a
967    /// verbatim reproduction of THAT captured representation rather than the
968    /// original pretty-printed document — a known, narrow residue, not a
969    /// silent loss (the same records are all still present).
970    pub fn raw_verbatim(&self) -> String {
971        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
972    }
973
974    /// The session as it stood before its message `keep` (0-based): its first `keep` normalized
975    /// messages, for a fork at a turn (edit a sent message, retry a reply). The verbatim source lines
976    /// and the side records captured from them (Claude Code's file snapshots and last prompt, Codex's
977    /// provenance) describe the whole session and are keyed by source record, not by message, so they
978    /// are dropped: every export of the cut is regenerated from the kept messages. A subagent is kept
979    /// only when it is linked to a spawning tool call that is kept; an unlinked one cannot be placed
980    /// before or after the cut and is dropped. The source session is unchanged. `keep` past the end
981    /// keeps every message.
982    pub fn cut_before(&self, keep: usize) -> Session {
983        let messages: Vec<ChatMessage> = self.messages.iter().take(keep).cloned().collect();
984        let spawned: std::collections::HashSet<&str> = messages
985            .iter()
986            .flat_map(|message| message.tool_calls.iter().flatten())
987            .map(|call| call.id.as_str())
988            .collect();
989        let subagents = self
990            .subagents
991            .iter()
992            .filter(|subagent| {
993                subagent
994                    .meta
995                    .parent_tool_use_id
996                    .as_deref()
997                    .is_some_and(|id| spawned.contains(id))
998            })
999            .cloned()
1000            .collect();
1001        let mut meta = self.meta.clone();
1002        meta.native_residue = Vec::new();
1003        meta.native_residue_source = None;
1004        meta.codex_provenance = Vec::new();
1005        Session {
1006            meta,
1007            messages,
1008            subagents,
1009            raw: Vec::new(),
1010            raw_trailing_newline: true,
1011            imported_message_count: None,
1012            raw_is_verbatim: false,
1013            parse_error_lines: self.parse_error_lines,
1014            load_residue: self.load_residue.clone(),
1015        }
1016    }
1017
1018    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
1019    /// core.session(tree-addressable transcript)"): materialize this
1020    /// session's linear [`Self::messages`] into a native in-place
1021    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
1022    /// FIRST time it wants to run a tree operation (rewind/branch/label)
1023    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
1024    /// synthesized node (see
1025    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
1026    /// why a single timestamp is used: the source linear messages carry no
1027    /// per-turn timestamp of their own here).
1028    ///
1029    /// This does not mutate `self` or persist anything — see
1030    /// the composition layer's session-store tree writer for persistence, and
1031    /// [`Self::apply_session_tree`] for the inverse bridge.
1032    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
1033        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
1034    }
1035
1036    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
1037    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
1038    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
1039    /// existing linear consumer — the agent loop, exporters — working
1040    /// unchanged after a tree operation runs). Nothing else on `self`
1041    /// (`meta`, `raw`, ...) is touched.
1042    ///
1043    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
1044    /// `Err` rather than applying anything — a structurally-corrupt tree
1045    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
1046    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
1047    /// `self` is left untouched on `Err` (the assignment only happens after
1048    /// the projection has already succeeded).
1049    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
1050        self.messages = tree.linear_projection()?;
1051        Ok(())
1052    }
1053}
1054
1055/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
1056/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
1057/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
1058/// accept there. Binary SQLite input never reaches this function: callers
1059/// check [`looks_like_sqlite`] first and route to
1060/// [`Session::from_opencode_sqlite`] instead.
1061pub(super) fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
1062    let bytes = read_session_bytes(path)?;
1063    String::from_utf8(bytes).map_err(|_| {
1064        crate::Error::Other(format!(
1065            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
1066             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
1067             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
1068            path.display()
1069        ))
1070    })
1071}
1072
1073/// Whether `path` is a zstd-compressed transcript (`*.jsonl.zst`, Codex's cold-rollout form).
1074pub fn is_zstd_session_path(path: &Path) -> bool {
1075    path.extension().and_then(|e| e.to_str()) == Some("zst")
1076}
1077
1078/// Open a session file for reading, decompressing a `*.zst` transcript as it is read.
1079pub fn open_session_reader(path: &Path) -> std::io::Result<Box<dyn Read>> {
1080    let file = std::fs::File::open(path)?;
1081    if !is_zstd_session_path(path) {
1082        return Ok(Box::new(file));
1083    }
1084    let decoder = ruzstd::decoding::StreamingDecoder::new(file).map_err(|error| {
1085        std::io::Error::new(
1086            std::io::ErrorKind::InvalidData,
1087            format!("{} is not a readable zstd stream: {error}", path.display()),
1088        )
1089    })?;
1090    Ok(Box::new(decoder))
1091}
1092
1093/// Every byte of a session file, decompressed when it is `*.zst`.
1094pub fn read_session_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
1095    if !is_zstd_session_path(path) {
1096        return std::fs::read(path);
1097    }
1098    let mut bytes = Vec::new();
1099    open_session_reader(path)?.read_to_end(&mut bytes)?;
1100    Ok(bytes)
1101}
1102
1103/// Read only the portion of a JSONL transcript a bounded scrollback can use.
1104///
1105/// The first record carries durable session metadata (especially for Codex),
1106/// while the trailing window carries the messages the viewport will render.
1107/// Full lossless loaders intentionally continue to read every byte.
1108pub(super) fn read_display_jsonl(
1109    path: &Path,
1110    message_limit: usize,
1111) -> Result<(Option<SessionSource>, String, bool)> {
1112    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
1113    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
1114    const BYTES_PER_MESSAGE: u64 = 16 * 1024;
1115
1116    // A compressed transcript cannot be windowed by seeking; it is read whole.
1117    if is_zstd_session_path(path) {
1118        let text = read_utf8_or_diagnose(path)?;
1119        return Ok((detect_source(&text), text, false));
1120    }
1121    let mut first = String::new();
1122    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
1123    let source = detect_source(&first);
1124    if !matches!(
1125        source,
1126        Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
1127    ) {
1128        let text = read_utf8_or_diagnose(path)?;
1129        return Ok((detect_source(&text), text, false));
1130    }
1131
1132    let mut file = std::fs::File::open(path)?;
1133    let file_len = file.metadata()?.len();
1134    let requested = (message_limit.max(1) as u64)
1135        .saturating_mul(BYTES_PER_MESSAGE)
1136        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
1137    if file_len <= requested {
1138        let text = read_utf8_or_diagnose(path)?;
1139        return Ok((source, text, false));
1140    }
1141
1142    let start = file_len - requested;
1143    file.seek(SeekFrom::Start(start))?;
1144    let mut bytes = Vec::with_capacity(requested as usize);
1145    file.read_to_end(&mut bytes)?;
1146    // The window normally starts in the middle of a JSON record. Discard that
1147    // partial prefix so every line passed to the existing parsers is valid.
1148    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
1149        bytes.drain(..=newline);
1150    }
1151    let mut tail = String::from_utf8(bytes).map_err(|_| {
1152        crate::Error::Other(format!(
1153            "{} contains non-UTF-8 data in its display window",
1154            path.display()
1155        ))
1156    })?;
1157    if start > 0 {
1158        // Always recover the human boundary immediately before the byte
1159        // window, even when the window already contains newer prompts. A
1160        // long run of large tool records can otherwise make the numeric tail
1161        // begin in one old turn while its only retained users belong to much
1162        // newer turns. The display projector then (correctly) hides the
1163        // orphaned activity, making pagination appear inert.
1164        //
1165        // Search backward independently of the render window and retain only
1166        // two complete human JSONL records. The search grows geometrically but
1167        // never reads more than the same 64 MiB hard ceiling as the display
1168        // window, and none of the intervening tool bytes are normalized or
1169        // sent over RPC.
1170        let max_search_bytes = start.min(MAX_TAIL_BYTES);
1171        let mut search_bytes = requested.min(max_search_bytes);
1172        let anchors = loop {
1173            let search_start = start - search_bytes;
1174            file.seek(SeekFrom::Start(search_start))?;
1175            let mut search = Vec::with_capacity(search_bytes as usize);
1176            (&mut file).take(search_bytes).read_to_end(&mut search)?;
1177            if search_start > 0 {
1178                if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
1179                    search.drain(..=newline);
1180                } else {
1181                    search.clear();
1182                }
1183            }
1184            // `start` normally cuts the record whose remainder the tail
1185            // reader discarded. Exclude its incomplete prefix here too.
1186            if let Some(newline) = search.iter().rposition(|byte| *byte == b'\n') {
1187                search.truncate(newline + 1);
1188            } else {
1189                search.clear();
1190            }
1191            let anchors = std::str::from_utf8(&search)
1192                .ok()
1193                .map(|search| {
1194                    let mut found = search
1195                        .lines()
1196                        .rev()
1197                        .filter(|line| native_display_human_line(line, source))
1198                        .take(2)
1199                        .map(str::to_string)
1200                        .collect::<Vec<_>>();
1201                    found.reverse();
1202                    found
1203                })
1204                .unwrap_or_default();
1205            if anchors.len() >= 2 || search_start == 0 || search_bytes == max_search_bytes {
1206                break anchors;
1207            }
1208            search_bytes = search_bytes.saturating_mul(2).min(max_search_bytes);
1209        };
1210        if !anchors.is_empty() {
1211            tail = format!("{}\n{tail}", anchors.join("\n"));
1212        }
1213    }
1214    let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
1215        format!("{first}{tail}")
1216    } else {
1217        tail
1218    };
1219    Ok((source, text, true))
1220}