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