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