Skip to main content

thndrs_lib/core/session/
mod.rs

1//! Append-only JSONL session persistence.
2//!
3//! Records session metadata, transcript events, tool audits, and context
4//! control actions for audit and resume without storing full raw provider
5//! payloads.
6//!
7//! Each [`SessionRecord`] is one append-only JSONL line tagged with
8//! `schema_version`, a monotonic `seq`, `time`, and `type`.
9
10mod contracts;
11#[cfg(test)]
12mod tests;
13
14use std::collections::VecDeque;
15use std::fs::File;
16use std::io::{BufRead, BufReader};
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21use crate::acp::permissions::PendingPermission;
22use crate::app::{Entry, ToolStatus};
23use crate::artifacts::{self, ArtifactMetadata};
24use crate::context::ContextSource;
25use crate::prompt::{EnvironmentMetadata, HistoryReuse, PromptBundle};
26use crate::skills::{SkillActivation, SkillReferenceMeta};
27use crate::tools::{WriteOp, shell};
28use crate::{datetime, internals, tools};
29use thndrs_agent::ProviderRequestAccounting;
30use thndrs_agent::context::{ContextItem, ContextLedger};
31
32pub use contracts::{
33    AcpPermissionOptionRecord, AcpSessionMetadata, ContextDiagnosticMeta, ContextItemMeta, ContextLedgerMeta,
34    ContextSourceMeta, McpToolSessionMeta, SessionConfigFile, SessionConfigMeta,
35};
36
37/// Current JSONL schema version.
38pub const SCHEMA_VERSION: u32 = 1;
39
40/// Maximum amount of redacted log text returned by a reader.
41pub const MAX_LOG_OUTPUT_BYTES: usize = 32 * 1024;
42
43/// A single line in a session JSONL file.
44///
45/// Every record carries `schema_version`, `seq` (monotonic within the session),
46/// `time` (ISO 8601 UTC), and a `type` tag. Records are never rewritten;
47/// appends are the only mutation.
48#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
49#[serde(tag = "type")]
50pub enum SessionRecord {
51    /// First line of a session: identity and environment.
52    #[serde(rename = "session_meta")]
53    SessionMeta {
54        schema_version: u32,
55        seq: u64,
56        time: String,
57        session_id: String,
58        cwd: String,
59        title: String,
60        provider: String,
61        model: String,
62        websearch: String,
63        app_version: String,
64        /// Effective config metadata: loaded config files, key origins, and
65        /// diagnostics. `None` when config metadata was not captured.
66        #[serde(skip_serializing_if = "Option::is_none")]
67        config: Option<SessionConfigMeta>,
68    },
69    /// Loaded context source metadata (AGENTS.md etc.).
70    #[serde(rename = "context")]
71    Context {
72        schema_version: u32,
73        seq: u64,
74        time: String,
75        sources: Vec<ContextSourceMeta>,
76    },
77    /// Content-free context working-set snapshot for one prompt turn.
78    #[serde(rename = "context_ledger")]
79    ContextLedger {
80        schema_version: u32,
81        seq: u64,
82        time: String,
83        turn_id: String,
84        ledger: ContextLedgerMeta,
85    },
86    /// A user pin action for a context item.
87    #[serde(rename = "context_pin")]
88    ContextPin {
89        schema_version: u32,
90        seq: u64,
91        time: String,
92        item: ContextItemMeta,
93        reason: String,
94    },
95    /// A user drop action for a context item.
96    #[serde(rename = "context_drop")]
97    ContextDrop {
98        schema_version: u32,
99        seq: u64,
100        time: String,
101        item: ContextItemMeta,
102        reason: String,
103    },
104    /// A user recovery action for a context item.
105    #[serde(rename = "context_recovery")]
106    ContextRecovery {
107        schema_version: u32,
108        seq: u64,
109        time: String,
110        item: ContextItemMeta,
111        reason: String,
112    },
113    /// A manual or automatic compaction audit record.
114    #[serde(rename = "compaction")]
115    Compaction {
116        schema_version: u32,
117        seq: u64,
118        time: String,
119        audit: CompactionAudit,
120    },
121    /// The user's decision on a compaction summary that required review.
122    #[serde(rename = "compaction_review")]
123    CompactionReview {
124        schema_version: u32,
125        seq: u64,
126        time: String,
127        recovery_handle: String,
128        review: CompactionReviewResult,
129    },
130    /// A user-submitted prompt.
131    #[serde(rename = "user")]
132    User {
133        schema_version: u32,
134        seq: u64,
135        time: String,
136        turn_id: String,
137        text: String,
138    },
139    /// Prompt assembly metadata for one user turn.
140    #[serde(rename = "prompt_metadata")]
141    PromptMetadata {
142        schema_version: u32,
143        seq: u64,
144        time: String,
145        turn_id: String,
146        metadata: PromptMetadata,
147    },
148    /// Final replayable assistant text.
149    #[serde(rename = "assistant_finished")]
150    AssistantFinished {
151        schema_version: u32,
152        seq: u64,
153        time: String,
154        turn_id: String,
155        text: String,
156    },
157    /// Final replayable reasoning text.
158    #[serde(rename = "reasoning_finished")]
159    ReasoningFinished {
160        schema_version: u32,
161        seq: u64,
162        time: String,
163        turn_id: String,
164        text: String,
165    },
166    /// Provider token usage increment.
167    #[serde(rename = "usage")]
168    Usage {
169        schema_version: u32,
170        seq: u64,
171        time: String,
172        input_tokens: u64,
173        output_tokens: u64,
174    },
175    /// Exact request-size accounting and provider usage for one request.
176    #[serde(rename = "request_accounting")]
177    RequestAccounting {
178        schema_version: u32,
179        seq: u64,
180        time: String,
181        turn_id: String,
182        accounting: ProviderRequestAccounting,
183    },
184    /// A tool call started.
185    #[serde(rename = "tool_started")]
186    ToolStarted {
187        schema_version: u32,
188        seq: u64,
189        time: String,
190        turn_id: String,
191        call_id: String,
192        name: String,
193        arguments: String,
194        /// MCP metadata when this tool came from an MCP server.
195        #[serde(default, skip_serializing_if = "Option::is_none")]
196        mcp: Option<McpToolSessionMeta>,
197    },
198    /// A tool call finished.
199    #[serde(rename = "tool_finished")]
200    ToolFinished {
201        schema_version: u32,
202        seq: u64,
203        time: String,
204        turn_id: String,
205        call_id: String,
206        status: ToolStatus,
207        output: Vec<String>,
208        /// Metadata and handle for bounded redacted recoverable evidence.
209        #[serde(default, skip_serializing_if = "Option::is_none")]
210        artifact: Option<ArtifactMetadata>,
211        /// MCP metadata when this tool came from an MCP server.
212        #[serde(default, skip_serializing_if = "Option::is_none")]
213        mcp: Option<McpToolSessionMeta>,
214    },
215    /// The agent run was cancelled.
216    #[serde(rename = "cancelled")]
217    Cancelled {
218        schema_version: u32,
219        seq: u64,
220        time: String,
221        turn_id: String,
222        reason: String,
223    },
224    /// The agent run failed.
225    #[serde(rename = "failed")]
226    Failed {
227        schema_version: u32,
228        seq: u64,
229        time: String,
230        turn_id: String,
231        error: String,
232    },
233    /// ACP session metadata.
234    #[serde(rename = "acp_session")]
235    AcpSession {
236        schema_version: u32,
237        seq: u64,
238        time: String,
239        /// Local append-only `thndrs` session id.
240        local_session_id: String,
241        /// Configured ACP agent name.
242        agent_name: String,
243        /// Opaque external ACP session id returned by the agent.
244        acp_session_id: String,
245        /// Redacted command display used to start the agent.
246        command: String,
247        /// Selected ACP protocol version.
248        protocol_version: String,
249        /// Optional ACP agent info name.
250        #[serde(skip_serializing_if = "Option::is_none")]
251        agent_info_name: Option<String>,
252        /// Optional ACP agent info version.
253        #[serde(skip_serializing_if = "Option::is_none")]
254        agent_info_version: Option<String>,
255        /// Optional ACP client info name.
256        #[serde(skip_serializing_if = "Option::is_none")]
257        client_info_name: Option<String>,
258        /// Optional ACP client info version.
259        #[serde(skip_serializing_if = "Option::is_none")]
260        client_info_version: Option<String>,
261    },
262    /// Session title was renamed (latest wins).
263    #[serde(rename = "session_renamed")]
264    SessionRenamed {
265        schema_version: u32,
266        seq: u64,
267        time: String,
268        title: String,
269    },
270    /// A file write operation completed.
271    ///
272    /// Records the operation type, target path, and before/after metadata
273    /// for session audit. Only hashes and byte counts are persisted but
274    /// not file content.
275    #[serde(rename = "file_write")]
276    FileWrite {
277        schema_version: u32,
278        seq: u64,
279        time: String,
280        turn_id: String,
281        op: WriteOp,
282        path: String,
283        before_hash: Option<u64>,
284        before_bytes: Option<usize>,
285        after_hash: u64,
286        after_bytes: usize,
287        status: ToolStatus,
288    },
289    /// MCP configuration changed after the session was created.
290    ///
291    /// Records only file paths, sources, hashes, and loader diagnostics; raw
292    /// MCP server command, env, and header values are not persisted here.
293    #[serde(rename = "mcp_config_changed")]
294    McpConfigChanged {
295        schema_version: u32,
296        seq: u64,
297        time: String,
298        turn_id: String,
299        previous_files: Vec<SessionConfigFile>,
300        current_files: Vec<SessionConfigFile>,
301        #[serde(default, skip_serializing_if = "Vec::is_empty")]
302        diagnostics: Vec<String>,
303    },
304    /// A shell command lifecycle event.
305    ///
306    /// One-shot commands produce a terminal record. Background commands
307    /// produce a `running` start record followed by a terminal record when
308    /// they exit, time out, or are cancelled. stdout/stderr are not stored
309    /// directly — they are captured in redacted, capped output records.
310    #[serde(rename = "shell_exec")]
311    ShellExec {
312        schema_version: u32,
313        seq: u64,
314        time: String,
315        turn_id: String,
316        /// Registry id for a background process, when applicable.
317        #[serde(default, skip_serializing_if = "Option::is_none")]
318        process_id: Option<u64>,
319        /// Full argv (program + args) joined with spaces.
320        command: String,
321        /// Working directory the command ran in.
322        cwd: String,
323        /// Lifecycle status: ok, failed, timeout, cancelled.
324        process_status: String,
325        /// Exit code if the process exited normally, else `None`.
326        exit_code: Option<i32>,
327        /// Elapsed time in milliseconds.
328        elapsed_ms: u64,
329        /// "one-shot" or "background".
330        kind: String,
331    },
332    /// A skill was explicitly opened/activated in the session.
333    #[serde(rename = "skill_activated")]
334    SkillActivated {
335        schema_version: u32,
336        seq: u64,
337        time: String,
338        name: String,
339        path: String,
340        /// Hash of the raw `SKILL.md` file at `path`.
341        content_hash: u64,
342        /// Byte count of the raw `SKILL.md` file at `path`.
343        byte_count: usize,
344        /// Hash of the model-visible activation text after references are appended.
345        #[serde(default)]
346        rendered_content_hash: u64,
347        /// Byte count of the model-visible activation text after references are appended.
348        #[serde(default)]
349        rendered_byte_count: usize,
350        loaded_references: Vec<SkillReferenceRecord>,
351    },
352    /// Queued input recorded for audit. Resume does not rebuild pending queues.
353    #[serde(rename = "queued_input")]
354    QueuedInput {
355        schema_version: u32,
356        seq: u64,
357        time: String,
358        /// "steering" or "follow-up".
359        kind: String,
360        text: String,
361    },
362    /// ACP permission request metadata.
363    #[serde(rename = "acp_permission_request")]
364    AcpPermissionRequest {
365        schema_version: u32,
366        seq: u64,
367        time: String,
368        turn_id: String,
369        tool_call_id: String,
370        title: String,
371        options: Vec<AcpPermissionOptionRecord>,
372    },
373    /// ACP permission request outcome.
374    #[serde(rename = "acp_permission_outcome")]
375    AcpPermissionOutcome {
376        schema_version: u32,
377        seq: u64,
378        time: String,
379        turn_id: String,
380        tool_call_id: String,
381        outcome: String,
382    },
383}
384
385impl SessionRecord {
386    /// The sequence number of this record.
387    pub fn seq(&self) -> u64 {
388        match self {
389            SessionRecord::SessionMeta { seq, .. }
390            | SessionRecord::Context { seq, .. }
391            | SessionRecord::ContextLedger { seq, .. }
392            | SessionRecord::ContextPin { seq, .. }
393            | SessionRecord::ContextDrop { seq, .. }
394            | SessionRecord::ContextRecovery { seq, .. }
395            | SessionRecord::Compaction { seq, .. }
396            | SessionRecord::CompactionReview { seq, .. }
397            | SessionRecord::User { seq, .. }
398            | SessionRecord::PromptMetadata { seq, .. }
399            | SessionRecord::AssistantFinished { seq, .. }
400            | SessionRecord::ReasoningFinished { seq, .. }
401            | SessionRecord::Usage { seq, .. }
402            | SessionRecord::RequestAccounting { seq, .. }
403            | SessionRecord::ToolStarted { seq, .. }
404            | SessionRecord::ToolFinished { seq, .. }
405            | SessionRecord::Cancelled { seq, .. }
406            | SessionRecord::Failed { seq, .. }
407            | SessionRecord::AcpSession { seq, .. }
408            | SessionRecord::SessionRenamed { seq, .. }
409            | SessionRecord::FileWrite { seq, .. }
410            | SessionRecord::McpConfigChanged { seq, .. }
411            | SessionRecord::ShellExec { seq, .. }
412            | SessionRecord::SkillActivated { seq, .. }
413            | SessionRecord::QueuedInput { seq, .. }
414            | SessionRecord::AcpPermissionRequest { seq, .. }
415            | SessionRecord::AcpPermissionOutcome { seq, .. } => *seq,
416        }
417    }
418
419    /// Serialize to a JSON string for JSONL append.
420    pub fn to_json(&self) -> Result<String, serde_json::Error> {
421        serde_json::to_string(self)
422    }
423
424    /// Deserialize from a JSON string.
425    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
426        serde_json::from_str(json)
427    }
428
429    /// Convert a transcript [`Entry`] into a [`SessionRecord`].
430    ///
431    /// Only finalized entries are converted — streaming entries (assistant/
432    /// reasoning still streaming, tools still running) are skipped because
433    /// they represent incomplete live state.
434    pub fn from_entry(entry: &Entry, seq: u64, time: &str, turn_id: &str) -> Option<SessionRecord> {
435        Self::from_entry_with_artifact(entry, seq, time, turn_id, None)
436    }
437
438    /// Convert a transcript entry while attaching bounded artifact metadata.
439    pub fn from_entry_with_artifact(
440        entry: &Entry, seq: u64, time: &str, turn_id: &str, artifact: Option<ArtifactMetadata>,
441    ) -> Option<SessionRecord> {
442        match entry {
443            Entry::User { text } => Some(SessionRecord::User {
444                schema_version: SCHEMA_VERSION,
445                seq,
446                time: time.to_string(),
447                turn_id: turn_id.to_string(),
448                text: text.clone(),
449            }),
450            Entry::Agent { text, streaming: false } => Some(SessionRecord::AssistantFinished {
451                schema_version: SCHEMA_VERSION,
452                seq,
453                time: time.to_string(),
454                turn_id: turn_id.to_string(),
455                text: text.clone(),
456            }),
457            Entry::Reasoning { text, streaming: false } => Some(SessionRecord::ReasoningFinished {
458                schema_version: SCHEMA_VERSION,
459                seq,
460                time: time.to_string(),
461                turn_id: turn_id.to_string(),
462                text: text.clone(),
463            }),
464            Entry::Error { text } => Some(SessionRecord::Failed {
465                schema_version: SCHEMA_VERSION,
466                seq,
467                time: time.to_string(),
468                turn_id: turn_id.to_string(),
469                error: text.clone(),
470            }),
471            Entry::Tool { name, arguments: _, status, output } if *status != ToolStatus::Running => {
472                let (tool_name, call_id) = split_tool_name_id(name);
473                Some(SessionRecord::ToolFinished {
474                    schema_version: SCHEMA_VERSION,
475                    seq,
476                    time: time.to_string(),
477                    turn_id: turn_id.to_string(),
478                    call_id,
479                    status: *status,
480                    output: artifacts::bounded_redacted_lines(output, artifacts::DEFAULT_MAX_ARTIFACT_BYTES),
481                    artifact,
482                    mcp: mcp_tool_session_meta(&tool_name),
483                })
484                .map(|r| (r, tool_name))
485                .map(|(r, _)| r)
486            }
487            _ => None,
488        }
489    }
490
491    /// Reconstruct a transcript [`Entry`] from this record.
492    ///
493    /// Returns `None` for records that don't map to a transcript row
494    /// (session_meta, context, session_renamed).
495    pub fn to_entry(&self) -> Option<Entry> {
496        match self {
497            SessionRecord::User { text, .. } => Some(Entry::User { text: text.clone() }),
498            SessionRecord::PromptMetadata { .. } => None,
499            SessionRecord::AssistantFinished { text, .. } => {
500                Some(Entry::Agent { text: text.clone(), streaming: false })
501            }
502            SessionRecord::ReasoningFinished { text, .. } => {
503                Some(Entry::Reasoning { text: text.clone(), streaming: false })
504            }
505
506            SessionRecord::ToolFinished { call_id, status, output, .. } => Some(Entry::Tool {
507                name: format!("#{call_id}"),
508                arguments: String::new(),
509                status: *status,
510                output: output.clone(),
511            }),
512            SessionRecord::Cancelled { reason, .. } => Some(Entry::Status { text: reason.clone() }),
513            SessionRecord::Failed { error, .. } => Some(Entry::Error { text: error.clone() }),
514            SessionRecord::AcpSession { agent_name, acp_session_id, client_info_name, .. } => {
515                let client = client_info_name
516                    .as_ref()
517                    .map(|name| format!(" client {name}"))
518                    .unwrap_or_default();
519                Some(Entry::Status { text: format!("acp session {agent_name}: {acp_session_id}{client}") })
520            }
521            SessionRecord::FileWrite { op, path, status, .. } => {
522                Some(Entry::Status { text: format!("{} {}: {path}", status.icon(), op.label()) })
523            }
524            SessionRecord::McpConfigChanged { .. } => None,
525            SessionRecord::ShellExec { command, process_status, process_id, elapsed_ms, .. } => {
526                let id = process_id.map_or_else(String::new, |id| format!(" [{id}]"));
527                Some(Entry::Status { text: format!("shell{id} {process_status}: {command} ({elapsed_ms}ms)") })
528            }
529            SessionRecord::SkillActivated { name, path, .. } => {
530                Some(Entry::Status { text: format!("skill activated: {name} ({path})") })
531            }
532            SessionRecord::AcpPermissionRequest { tool_call_id, title, .. } => {
533                Some(Entry::Status { text: format!("acp permission requested: {title} ({tool_call_id})") })
534            }
535            SessionRecord::AcpPermissionOutcome { tool_call_id, outcome, .. } => {
536                Some(Entry::Status { text: format!("acp permission {tool_call_id}: {outcome}") })
537            }
538            _ => None,
539        }
540    }
541}
542
543/// Metadata for a single prompt turn, suitable for append-only JSONL storage.
544///
545/// This is the audit record: enough to reconstruct *what* was sent without
546/// storing *the content itself*. Full raw provider payloads are deliberately
547/// excluded because they can contain prompt text, repo content, and secrets.
548#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
549pub struct PromptMetadata {
550    /// Selected model name.
551    pub model: String,
552    /// Selected provider label.
553    #[serde(default)]
554    pub provider: String,
555    /// Application-owned web-search backend label.
556    pub search_mode: String,
557    /// Renderer mode label.
558    #[serde(default)]
559    pub renderer_mode: String,
560    /// Rounded date (YYYY-MM-DD) used for cache stability.
561    pub date: String,
562    /// Workspace root path.
563    pub cwd: String,
564    /// Ordered prompt fragment names included in the system prompt.
565    #[serde(default)]
566    pub prompt_fragments: Vec<String>,
567    /// Model-visible documentation entry point paths.
568    #[serde(default)]
569    pub docs_map: Vec<String>,
570    /// Metadata for each loaded AGENTS.md source (no content).
571    pub context_sources: Vec<ContextSourceMeta>,
572    /// Number of tools in the catalog sent this turn.
573    pub tool_catalog_size: usize,
574    /// Tool names in the catalog sent this turn.
575    #[serde(default)]
576    pub tool_names: Vec<String>,
577    /// Number of available Agent Skills exposed as metadata this turn.
578    pub skill_catalog_size: usize,
579    /// Available skill names exposed as metadata this turn.
580    #[serde(default)]
581    pub skill_names: Vec<String>,
582    /// Self-knowledge diagnostics visible for this turn.
583    #[serde(default)]
584    pub diagnostics: Vec<String>,
585    /// Whether history reuse was active for this turn.
586    pub history_reuse: bool,
587    /// Content hash of the root AGENTS.md from the previous turn, if any.
588    pub prev_context_hash: Option<u64>,
589    /// Number of transcript entries included in the projected tail.
590    pub transcript_tail_size: usize,
591    /// Whether the user turn was non-empty.
592    pub has_user_turn: bool,
593}
594
595impl PromptMetadata {
596    /// Extract prompt metadata from a [`PromptBundle`] for session storage.
597    ///
598    /// This captures the structural metadata of the turn — model, search backend,
599    /// context sources (hashes and truncation, not content), tool count, and
600    /// transcript tail size. It does not store prompt text, AGENTS.md content,
601    /// or provider request/response bodies.
602    pub fn from_bundle(bundle: &PromptBundle) -> Self {
603        let environment: &EnvironmentMetadata = &bundle.environment;
604        let snapshot: internals::SelfKnowledgeSnapshot = bundle.into();
605        PromptMetadata {
606            model: environment.model.clone(),
607            provider: snapshot.runtime.provider.provider,
608            search_mode: environment.search_mode.label().to_string(),
609            renderer_mode: snapshot.runtime.renderer_mode,
610            date: environment.date.clone(),
611            cwd: environment.cwd.clone(),
612            prompt_fragments: snapshot.inventory.prompt_context.prompt_fragments,
613            docs_map: snapshot
614                .inventory
615                .references
616                .docs
617                .iter()
618                .map(|doc| doc.path.to_string())
619                .collect(),
620            context_sources: bundle
621                .project_context
622                .iter()
623                .map(ContextSourceMeta::from_source)
624                .collect(),
625            tool_catalog_size: bundle.tool_catalog.len(),
626            tool_names: snapshot.runtime.tools,
627            skill_catalog_size: bundle.available_skills.len(),
628            skill_names: snapshot
629                .inventory
630                .references
631                .skills
632                .into_iter()
633                .map(|skill| skill.name)
634                .collect(),
635            diagnostics: snapshot.diagnostics,
636            history_reuse: bundle.history_reuse == HistoryReuse::Available,
637            prev_context_hash: bundle.prev_context_hash,
638            transcript_tail_size: bundle.transcript_tail.len(),
639            has_user_turn: !bundle.user_turn.is_empty(),
640        }
641    }
642}
643
644/// How compaction was initiated.
645#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
646#[serde(rename_all = "snake_case")]
647pub enum CompactionTrigger {
648    /// The user explicitly requested compaction.
649    Manual,
650    /// Context pressure triggered compaction before a provider request.
651    Automatic,
652}
653
654/// Risk classification for the compacted range.
655#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
656#[serde(rename_all = "snake_case")]
657pub enum CompactionRisk {
658    /// No known high-risk content was included in the covered range.
659    Low,
660    /// The covered range contains details that require explicit review policy.
661    High,
662}
663
664/// Review state recorded for a compaction summary.
665#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
666#[serde(rename_all = "snake_case")]
667pub enum CompactionReviewResult {
668    /// Review was not required by the active policy.
669    NotRequired,
670    /// Review is required but not yet resolved.
671    Pending,
672    /// A reviewer accepted the proposed summary.
673    Approved,
674    /// A reviewer rejected the proposed summary.
675    Rejected,
676}
677
678impl CompactionReviewResult {
679    pub fn label(self) -> &'static str {
680        match self {
681            CompactionReviewResult::NotRequired => "not-required",
682            CompactionReviewResult::Pending => "pending",
683            CompactionReviewResult::Approved => "approved",
684            CompactionReviewResult::Rejected => "rejected",
685        }
686    }
687}
688
689/// Source hash associated with a compacted input item.
690#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
691pub struct CompactionSourceHash {
692    /// Stable source item id or session-range handle.
693    pub id: String,
694    /// Hash of the source content, when one is available.
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub content_hash: Option<u64>,
697}
698
699/// Provider token usage for the compaction request, when reported.
700#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
701pub struct CompactionTokenUsage {
702    /// Tokens supplied to the configured model.
703    pub input_tokens: u64,
704    /// Tokens returned in the compaction summary.
705    pub output_tokens: u64,
706}
707
708/// Complete durable audit payload for one compaction.
709///
710/// The summary is intentionally retained because it becomes model-visible
711/// working context. The covered source is represented only by ranges, stable
712/// handles, and hashes; full transcript, file, and provider payload
713/// content never belongs in this record.
714#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
715pub struct CompactionAudit {
716    /// Summary that replaces the covered range in the active working set.
717    pub summary: String,
718    /// Inclusive first session sequence replaced by this summary.
719    pub covered_start_seq: u64,
720    /// Inclusive final session sequence replaced by this summary.
721    pub covered_end_seq: u64,
722    /// Content hashes for covered sources, where known.
723    #[serde(default, skip_serializing_if = "Vec::is_empty")]
724    pub source_hashes: Vec<CompactionSourceHash>,
725    /// Manual or automatic initiation.
726    pub trigger: CompactionTrigger,
727    /// Risk classification evaluated before applying the summary.
728    pub risk: CompactionRisk,
729    /// Review result, when a review policy applied.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub review: Option<CompactionReviewResult>,
732    /// Stable handles used to recover covered detail from the original session.
733    #[serde(default, skip_serializing_if = "Vec::is_empty")]
734    pub recovery_handles: Vec<String>,
735    /// Configured model that generated the summary.
736    pub model: String,
737    /// Provider-reported compaction token usage, when available.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub usage: Option<CompactionTokenUsage>,
740}
741
742impl CompactionAudit {
743    /// Return a copy with known secret-shaped fragments redacted.
744    fn redacted(&self) -> Self {
745        CompactionAudit {
746            summary: tools::shell::redact_secrets(&self.summary),
747            covered_start_seq: self.covered_start_seq,
748            covered_end_seq: self.covered_end_seq,
749            source_hashes: self.source_hashes.clone(),
750            trigger: self.trigger,
751            risk: self.risk,
752            review: self.review,
753            recovery_handles: self.recovery_handles.clone(),
754            model: self.model.clone(),
755            usage: self.usage,
756        }
757    }
758}
759
760/// Persisted metadata for a loaded skill reference.
761#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
762pub struct SkillReferenceRecord {
763    pub path: String,
764    pub content_hash: u64,
765    pub byte_count: usize,
766    pub truncated: bool,
767}
768
769impl From<&SkillReferenceMeta> for SkillReferenceRecord {
770    fn from(reference: &SkillReferenceMeta) -> Self {
771        SkillReferenceRecord {
772            path: reference.path.display().to_string(),
773            content_hash: reference.content_hash,
774            byte_count: reference.byte_count,
775            truncated: reference.truncated,
776        }
777    }
778}
779
780/// Action variants supported by [`SessionWriter::append_context_action`].
781#[derive(Clone, Copy, Debug, Eq, PartialEq)]
782enum ContextActionKind {
783    Pin,
784    Drop,
785    Recovery,
786}
787
788/// Append-only JSONL session writer.
789///
790/// Each session is a single `.jsonl` file. Records are appended one per line
791/// and never rewritten. The writer tracks a monotonic `seq` counter.
792#[derive(Debug)]
793pub struct SessionWriter {
794    path: PathBuf,
795    seq: u64,
796    session_id: String,
797    lock_path: PathBuf,
798    _lock: File,
799}
800
801impl SessionWriter {
802    /// Create a new session file in `dir` with the given session id.
803    ///
804    /// Writes the initial `session_meta` record as the first line.
805    #[expect(
806        clippy::too_many_arguments,
807        reason = "session metadata is written as a flat JSONL record"
808    )]
809    pub fn create(
810        dir: &Path, session_id: &str, cwd: &str, title: &str, provider: &str, model: &str, websearch: &str,
811        app_version: &str, config: Option<SessionConfigMeta>,
812    ) -> std::io::Result<Self> {
813        std::fs::create_dir_all(dir)?;
814        let path = dir.join(format!("{session_id}.jsonl"));
815        let (lock_path, lock) = acquire_writer_lock(&path)?;
816
817        let record = SessionRecord::SessionMeta {
818            schema_version: SCHEMA_VERSION,
819            seq: 0,
820            time: datetime::now_iso8601(),
821            session_id: session_id.to_string(),
822            cwd: cwd.to_string(),
823            title: title.to_string(),
824            provider: provider.to_string(),
825            model: model.to_string(),
826            websearch: websearch.to_string(),
827            app_version: app_version.to_string(),
828            config,
829        };
830
831        let write_result = (|| -> std::io::Result<()> {
832            use std::io::Write;
833            let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
834            writeln!(file, "{}", record.to_json().map_err(io_err)?)
835        })();
836        if let Err(error) = write_result {
837            let _ = std::fs::remove_file(&lock_path);
838            return Err(error);
839        }
840
841        Ok(SessionWriter { path, seq: 1, session_id: session_id.to_string(), lock_path, _lock: lock })
842    }
843
844    /// Sequence number that will be assigned to the next appended record.
845    pub fn next_sequence(&self) -> u64 {
846        self.seq
847    }
848
849    /// Reopen an existing session file for append-only continuation.
850    ///
851    /// The next sequence number is derived from the highest readable record
852    /// sequence. Corrupt trailing lines are ignored consistently with
853    /// [`SessionReader`].
854    pub fn resume(path: &Path, session_id: &str) -> std::io::Result<Self> {
855        let (lock_path, lock) = acquire_writer_lock(path)?;
856        let records = SessionReader::read_records(path);
857        let seq = records
858            .iter()
859            .map(SessionRecord::seq)
860            .max()
861            .map_or(0, |max_seq| max_seq.saturating_add(1));
862
863        if let Err(error) = std::fs::OpenOptions::new().append(true).open(path) {
864            let _ = std::fs::remove_file(&lock_path);
865            return Err(error);
866        }
867
868        Ok(SessionWriter { path: path.to_path_buf(), seq, session_id: session_id.to_string(), lock_path, _lock: lock })
869    }
870
871    /// Append a record to the session file.
872    pub fn append(&mut self, mut record: SessionRecord) -> std::io::Result<()> {
873        let seq = self.seq;
874        self.seq += 1;
875        set_seq(&mut record, seq);
876
877        let line = record.to_json().map_err(io_err)?;
878        use std::io::Write;
879        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
880        writeln!(file, "{line}")?;
881        Ok(())
882    }
883
884    /// Append a transcript entry as a record, if it maps to one.
885    ///
886    /// Streaming/live entries are skipped — only finalized entries are
887    /// persisted for replay.
888    pub fn append_entry(&mut self, entry: &Entry, turn_id: &str) -> std::io::Result<()> {
889        if let Some(record) = SessionRecord::from_entry(entry, self.seq, &datetime::now_iso8601(), turn_id) {
890            self.seq += 1;
891            let line = record.to_json().map_err(io_err)?;
892            use std::io::Write;
893            let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
894            writeln!(file, "{line}")?;
895        }
896        Ok(())
897    }
898
899    /// Append a finalized transcript entry with bounded artifact metadata.
900    pub fn append_entry_with_artifact(
901        &mut self, entry: &Entry, turn_id: &str, artifact: Option<ArtifactMetadata>,
902    ) -> std::io::Result<()> {
903        if let Some(record) =
904            SessionRecord::from_entry_with_artifact(entry, self.seq, &datetime::now_iso8601(), turn_id, artifact)
905        {
906            self.seq += 1;
907            let line = record.to_json().map_err(io_err)?;
908            use std::io::Write;
909            let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
910            writeln!(file, "{line}")?;
911        }
912        Ok(())
913    }
914
915    /// Append a `tool_started` record for a tool call that has begun.
916    ///
917    /// This records the command start: tool name, call id, and arguments.
918    /// The matching `tool_finished` (via [`Self::append_entry`]) records the
919    /// output, status, and summary. For `run_shell`, an additional
920    /// [`Self::append_shell_exec`] record captures exit code, elapsed time, and
921    /// process kind.
922    pub fn append_tool_started(&mut self, turn_id: &str, call_id: &str, name: &str, args: &str) -> std::io::Result<()> {
923        let record = SessionRecord::ToolStarted {
924            schema_version: SCHEMA_VERSION,
925            seq: self.seq,
926            time: datetime::now_iso8601(),
927            turn_id: turn_id.to_string(),
928            call_id: call_id.to_string(),
929            name: name.to_string(),
930            arguments: args.to_string(),
931            mcp: mcp_tool_session_meta(name),
932        };
933        self.seq += 1;
934        let line = record.to_json().map_err(io_err)?;
935        use std::io::Write;
936        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
937        writeln!(file, "{line}")?;
938        Ok(())
939    }
940
941    /// Append a context record with loaded AGENTS.md source metadata.
942    pub fn append_context(&mut self, sources: &[ContextSource]) -> std::io::Result<()> {
943        let metas: Vec<ContextSourceMeta> = sources.iter().map(ContextSourceMeta::from_source).collect();
944        let record = SessionRecord::Context {
945            schema_version: SCHEMA_VERSION,
946            seq: self.seq,
947            time: datetime::now_iso8601(),
948            sources: metas,
949        };
950        self.seq += 1;
951        let line = record.to_json().map_err(io_err)?;
952        use std::io::Write;
953        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
954        writeln!(file, "{line}")?;
955        Ok(())
956    }
957
958    /// Append a content-free context ledger snapshot for a prompt turn.
959    pub fn append_context_ledger(&mut self, turn_id: &str, ledger: &ContextLedger) -> std::io::Result<()> {
960        self.append(SessionRecord::ContextLedger {
961            schema_version: SCHEMA_VERSION,
962            seq: 0,
963            time: datetime::now_iso8601(),
964            turn_id: turn_id.to_string(),
965            ledger: ContextLedgerMeta::from(ledger),
966        })
967    }
968
969    /// Append a content-free user pin action.
970    pub fn append_context_pin(&mut self, item: &ContextItem, reason: &str) -> std::io::Result<()> {
971        self.append_context_action(item, reason, ContextActionKind::Pin)
972    }
973
974    /// Append a content-free user drop action.
975    pub fn append_context_drop(&mut self, item: &ContextItem, reason: &str) -> std::io::Result<()> {
976        self.append_context_action(item, reason, ContextActionKind::Drop)
977    }
978
979    /// Append a content-free user recovery action.
980    pub fn append_context_recovery(&mut self, item: &ContextItem, reason: &str) -> std::io::Result<()> {
981        self.append_context_action(item, reason, ContextActionKind::Recovery)
982    }
983
984    /// Append a compaction audit record without source payloads.
985    pub fn append_compaction(&mut self, audit: &CompactionAudit) -> std::io::Result<()> {
986        self.append(SessionRecord::Compaction {
987            schema_version: SCHEMA_VERSION,
988            seq: 0,
989            time: datetime::now_iso8601(),
990            audit: audit.redacted(),
991        })
992    }
993
994    /// Append the review decision for a previously pending compaction.
995    pub fn append_compaction_review(
996        &mut self, recovery_handle: &str, review: CompactionReviewResult,
997    ) -> std::io::Result<()> {
998        self.append(SessionRecord::CompactionReview {
999            schema_version: SCHEMA_VERSION,
1000            seq: 0,
1001            time: datetime::now_iso8601(),
1002            recovery_handle: tools::shell::redact_secrets(recovery_handle),
1003            review,
1004        })
1005    }
1006
1007    /// Append one typed context action without exposing content fields.
1008    fn append_context_action(
1009        &mut self, item: &ContextItem, reason: &str, action: ContextActionKind,
1010    ) -> std::io::Result<()> {
1011        let item = ContextItemMeta::from(item);
1012        let reason = tools::shell::redact_secrets(reason);
1013        let record = match action {
1014            ContextActionKind::Pin => SessionRecord::ContextPin {
1015                schema_version: SCHEMA_VERSION,
1016                seq: 0,
1017                time: datetime::now_iso8601(),
1018                item,
1019                reason,
1020            },
1021            ContextActionKind::Drop => SessionRecord::ContextDrop {
1022                schema_version: SCHEMA_VERSION,
1023                seq: 0,
1024                time: datetime::now_iso8601(),
1025                item,
1026                reason,
1027            },
1028            ContextActionKind::Recovery => SessionRecord::ContextRecovery {
1029                schema_version: SCHEMA_VERSION,
1030                seq: 0,
1031                time: datetime::now_iso8601(),
1032                item,
1033                reason,
1034            },
1035        };
1036        self.append(record)
1037    }
1038
1039    /// Append prompt assembly provenance for a user turn.
1040    pub fn append_prompt_metadata(&mut self, turn_id: &str, metadata: &PromptMetadata) -> std::io::Result<()> {
1041        let record = SessionRecord::PromptMetadata {
1042            schema_version: SCHEMA_VERSION,
1043            seq: self.seq,
1044            time: datetime::now_iso8601(),
1045            turn_id: turn_id.to_string(),
1046            metadata: metadata.clone(),
1047        };
1048        self.seq += 1;
1049        let line = record.to_json().map_err(io_err)?;
1050        use std::io::Write;
1051        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1052        writeln!(file, "{line}")?;
1053        Ok(())
1054    }
1055
1056    /// Append provider token usage for the session.
1057    pub fn append_usage(&mut self, input_tokens: u64, output_tokens: u64) -> std::io::Result<()> {
1058        if input_tokens == 0 && output_tokens == 0 {
1059            return Ok(());
1060        }
1061
1062        let record = SessionRecord::Usage {
1063            schema_version: SCHEMA_VERSION,
1064            seq: self.seq,
1065            time: datetime::now_iso8601(),
1066            input_tokens,
1067            output_tokens,
1068        };
1069        self.seq += 1;
1070        let line = record.to_json().map_err(io_err)?;
1071        use std::io::Write;
1072        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1073        writeln!(file, "{line}")?;
1074        Ok(())
1075    }
1076
1077    /// Append accounting for one successful provider request.
1078    pub fn append_request_accounting(
1079        &mut self, turn_id: &str, accounting: &ProviderRequestAccounting,
1080    ) -> std::io::Result<()> {
1081        self.append(SessionRecord::RequestAccounting {
1082            schema_version: SCHEMA_VERSION,
1083            seq: 0,
1084            time: datetime::now_iso8601(),
1085            turn_id: turn_id.to_string(),
1086            accounting: accounting.clone(),
1087        })
1088    }
1089
1090    /// Append a file-write audit record.
1091    ///
1092    /// Records the operation type, path, before/after hashes and byte counts,
1093    /// and status. File content is never stored — only hashes and byte counts,
1094    /// so secrets and large files are not persisted.
1095    pub fn append_file_write(
1096        &mut self, turn_id: &str, result: &tools::WriteResult, status: ToolStatus,
1097    ) -> std::io::Result<()> {
1098        let record = SessionRecord::FileWrite {
1099            schema_version: SCHEMA_VERSION,
1100            seq: self.seq,
1101            time: datetime::now_iso8601(),
1102            turn_id: turn_id.to_string(),
1103            op: result.op,
1104            path: result.path.display().to_string(),
1105            before_hash: result.before_hash,
1106            before_bytes: result.before_bytes,
1107            after_hash: result.after_hash,
1108            after_bytes: result.after_bytes,
1109            status,
1110        };
1111        self.seq += 1;
1112        let line = record.to_json().map_err(io_err)?;
1113        use std::io::Write;
1114        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1115        writeln!(file, "{line}")?;
1116        Ok(())
1117    }
1118
1119    /// Append an audit record when MCP config file hashes change mid-session.
1120    pub fn append_mcp_config_changed(
1121        &mut self, turn_id: &str, previous_files: Vec<SessionConfigFile>, current_files: Vec<SessionConfigFile>,
1122        diagnostics: Vec<String>,
1123    ) -> std::io::Result<()> {
1124        let record = SessionRecord::McpConfigChanged {
1125            schema_version: SCHEMA_VERSION,
1126            seq: self.seq,
1127            time: datetime::now_iso8601(),
1128            turn_id: turn_id.to_string(),
1129            previous_files,
1130            current_files,
1131            diagnostics,
1132        };
1133        self.seq += 1;
1134        let line = record.to_json().map_err(io_err)?;
1135        use std::io::Write;
1136        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1137        writeln!(file, "{line}")?;
1138        Ok(())
1139    }
1140
1141    /// Append a shell-execution audit record.
1142    ///
1143    /// Records the command argv, working directory, registry id, lifecycle
1144    /// status, exit code, elapsed time, and process kind. stdout/stderr are
1145    /// not stored here — they are captured in redacted and capped output
1146    /// records.
1147    pub fn append_shell_exec(&mut self, turn_id: &str, result: &shell::ProcessResult) -> std::io::Result<()> {
1148        let record = SessionRecord::ShellExec {
1149            schema_version: SCHEMA_VERSION,
1150            seq: self.seq,
1151            time: datetime::now_iso8601(),
1152            turn_id: turn_id.to_string(),
1153            process_id: result.process_id,
1154            command: shell::redact_secrets(&result.command.join(" ")),
1155            cwd: result.cwd.display().to_string(),
1156            process_status: result.status.label().to_string(),
1157            exit_code: result.exit_code,
1158            elapsed_ms: result.elapsed.as_millis() as u64,
1159            kind: result.kind.label().to_string(),
1160        };
1161        self.seq += 1;
1162        let line = record.to_json().map_err(io_err)?;
1163        use std::io::Write;
1164        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1165        writeln!(file, "{line}")?;
1166        Ok(())
1167    }
1168
1169    /// Append a skill activation metadata record.
1170    pub fn append_skill_activation(&mut self, activation: &SkillActivation) -> std::io::Result<()> {
1171        let record = SessionRecord::SkillActivated {
1172            schema_version: SCHEMA_VERSION,
1173            seq: self.seq,
1174            time: datetime::now_iso8601(),
1175            name: activation.name.clone(),
1176            path: activation.path.display().to_string(),
1177            content_hash: activation.content_hash,
1178            byte_count: activation.byte_count,
1179            rendered_content_hash: activation.rendered_content_hash,
1180            rendered_byte_count: activation.rendered_byte_count,
1181            loaded_references: activation
1182                .loaded_references
1183                .iter()
1184                .map(SkillReferenceRecord::from)
1185                .collect(),
1186        };
1187        self.seq += 1;
1188        let line = record.to_json().map_err(io_err)?;
1189        use std::io::Write;
1190        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1191        writeln!(file, "{line}")?;
1192        Ok(())
1193    }
1194
1195    /// Append a queued input audit record.
1196    pub fn append_queued(&mut self, kind: &str, text: &str) -> std::io::Result<()> {
1197        let record = SessionRecord::QueuedInput {
1198            schema_version: SCHEMA_VERSION,
1199            seq: self.seq,
1200            time: datetime::now_iso8601(),
1201            kind: kind.to_string(),
1202            text: text.to_string(),
1203        };
1204        self.seq += 1;
1205        let line = record.to_json().map_err(io_err)?;
1206        use std::io::Write;
1207        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1208        writeln!(file, "{line}")?;
1209        Ok(())
1210    }
1211
1212    /// Append ACP permission request metadata.
1213    pub fn append_acp_permission_request(&mut self, turn_id: &str, request: &PendingPermission) -> std::io::Result<()> {
1214        let record = SessionRecord::AcpPermissionRequest {
1215            schema_version: SCHEMA_VERSION,
1216            seq: self.seq,
1217            time: datetime::now_iso8601(),
1218            turn_id: turn_id.to_string(),
1219            tool_call_id: request.tool_call_id.clone(),
1220            title: request.title.clone(),
1221            options: request
1222                .options
1223                .iter()
1224                .map(|option| AcpPermissionOptionRecord {
1225                    id: option.id.clone(),
1226                    name: option.name.clone(),
1227                    kind: option.kind.label().to_string(),
1228                })
1229                .collect(),
1230        };
1231        self.seq += 1;
1232        let line = record.to_json().map_err(io_err)?;
1233        use std::io::Write;
1234        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1235        writeln!(file, "{line}")?;
1236        Ok(())
1237    }
1238
1239    /// Append ACP session metadata.
1240    pub fn append_acp_session(&mut self, metadata: &AcpSessionMetadata) -> std::io::Result<()> {
1241        let record = SessionRecord::AcpSession {
1242            schema_version: SCHEMA_VERSION,
1243            seq: self.seq,
1244            time: datetime::now_iso8601(),
1245            local_session_id: self.session_id.clone(),
1246            agent_name: metadata.agent_name.clone(),
1247            acp_session_id: metadata.acp_session_id.clone(),
1248            command: metadata.command.clone(),
1249            protocol_version: metadata.protocol_version.clone(),
1250            agent_info_name: metadata.agent_info_name.clone(),
1251            agent_info_version: metadata.agent_info_version.clone(),
1252            client_info_name: metadata.client_info_name.clone(),
1253            client_info_version: metadata.client_info_version.clone(),
1254        };
1255        self.seq += 1;
1256        let line = record.to_json().map_err(io_err)?;
1257        use std::io::Write;
1258        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1259        writeln!(file, "{line}")?;
1260        Ok(())
1261    }
1262
1263    /// Append ACP permission outcome metadata.
1264    pub fn append_acp_permission_outcome(
1265        &mut self, turn_id: &str, tool_call_id: &str, outcome: &str,
1266    ) -> std::io::Result<()> {
1267        let record = SessionRecord::AcpPermissionOutcome {
1268            schema_version: SCHEMA_VERSION,
1269            seq: self.seq,
1270            time: datetime::now_iso8601(),
1271            turn_id: turn_id.to_string(),
1272            tool_call_id: tool_call_id.to_string(),
1273            outcome: outcome.to_string(),
1274        };
1275        self.seq += 1;
1276        let line = record.to_json().map_err(io_err)?;
1277        use std::io::Write;
1278        let mut file = std::fs::OpenOptions::new().append(true).open(&self.path)?;
1279        writeln!(file, "{line}")?;
1280        Ok(())
1281    }
1282
1283    /// The session file path.
1284    pub fn path(&self) -> &Path {
1285        &self.path
1286    }
1287
1288    /// The session id.
1289    pub fn session_id(&self) -> &str {
1290        &self.session_id
1291    }
1292}
1293
1294impl Drop for SessionWriter {
1295    fn drop(&mut self) {
1296        let _ = std::fs::remove_file(&self.lock_path);
1297    }
1298}
1299
1300/// Reads a session JSONL file and reconstructs transcript entries.
1301///
1302/// Corrupt lines are skipped silently — the rest of the file is still
1303/// readable. This makes resume resilient to partial writes.
1304pub struct SessionReader;
1305
1306impl SessionReader {
1307    /// Read a session file and return all records, in order.
1308    ///
1309    /// Corrupt lines are skipped. Returns an empty vec if the file does
1310    /// not exist.
1311    pub fn read_records(path: &Path) -> Vec<SessionRecord> {
1312        let Ok(content) = std::fs::read_to_string(path) else {
1313            return Vec::new();
1314        };
1315        content
1316            .lines()
1317            .filter(|line| !line.is_empty())
1318            .filter_map(|line| SessionRecord::from_json(line).ok())
1319            .collect()
1320    }
1321
1322    /// Read only the trailing portion of a session file.
1323    ///
1324    /// If the bounded read starts in the middle of a JSONL record, that first
1325    /// partial record is discarded. This is suitable for bounded input recall,
1326    /// where recent turns matter more than a full historical reconstruction.
1327    pub fn read_records_from_tail(path: &Path, max_bytes: usize) -> Vec<SessionRecord> {
1328        use std::io::{Read, Seek};
1329
1330        if max_bytes == 0 {
1331            return Vec::new();
1332        }
1333
1334        let Ok(mut file) = std::fs::File::open(path) else {
1335            return Vec::new();
1336        };
1337        let Ok(file_len) = file.metadata().map(|metadata| metadata.len()) else {
1338            return Vec::new();
1339        };
1340        let start = file_len.saturating_sub(max_bytes as u64);
1341        let read_start = start.saturating_sub(1);
1342        if file.seek(std::io::SeekFrom::Start(read_start)).is_err() {
1343            return Vec::new();
1344        }
1345
1346        let mut bytes = Vec::new();
1347        if file.read_to_end(&mut bytes).is_err() {
1348            return Vec::new();
1349        }
1350        let content = String::from_utf8_lossy(&bytes);
1351        let complete_records = if start == 0 {
1352            content.as_ref()
1353        } else {
1354            content.split_once('\n').map_or("", |(_, records)| records)
1355        };
1356
1357        complete_records
1358            .lines()
1359            .filter(|line| !line.is_empty())
1360            .filter_map(|line| SessionRecord::from_json(line).ok())
1361            .collect()
1362    }
1363
1364    /// Read a session file and reconstruct the transcript.
1365    ///
1366    /// Only records that map to [`Entry`] values are included. Metadata
1367    /// records (session_meta, context, session_renamed) are skipped.
1368    pub fn read_transcript(path: &Path) -> Vec<Entry> {
1369        Self::read_records(path)
1370            .into_iter()
1371            .filter_map(|r| r.to_entry())
1372            .collect()
1373    }
1374
1375    /// Read the session title from a session file.
1376    ///
1377    /// Returns the latest `session_renamed` title, or the initial
1378    /// `session_meta` title, or the file stem as a fallback.
1379    pub fn read_title(path: &Path) -> String {
1380        let records = Self::read_records(path);
1381        for r in records.iter().rev() {
1382            if let SessionRecord::SessionRenamed { title, .. } = r {
1383                return title.clone();
1384            }
1385        }
1386
1387        for r in &records {
1388            if let SessionRecord::SessionMeta { title, .. } = r {
1389                return title.clone();
1390            }
1391        }
1392
1393        path.file_stem()
1394            .and_then(|s| s.to_str())
1395            .unwrap_or("session")
1396            .to_string()
1397    }
1398
1399    /// Read compact sidebar metadata from a session file.
1400    pub fn read_summary(path: &Path) -> SessionSummary {
1401        let records = Self::read_records(path);
1402        let mut title = path
1403            .file_stem()
1404            .and_then(|s| s.to_str())
1405            .unwrap_or("session")
1406            .to_string();
1407        let mut model = String::from("unknown");
1408        let mut input_tokens = 0u64;
1409        let mut output_tokens = 0u64;
1410        let mut accounted_input_tokens = 0u64;
1411        let mut accounted_output_tokens = 0u64;
1412        let mut has_request_accounting = false;
1413
1414        for record in records {
1415            match record {
1416                SessionRecord::SessionMeta { title: t, model: m, .. } => {
1417                    title = t;
1418                    model = m;
1419                }
1420                SessionRecord::SessionRenamed { title: t, .. } => title = t,
1421                SessionRecord::Usage { input_tokens: i, output_tokens: o, .. } => {
1422                    input_tokens = input_tokens.saturating_add(i);
1423                    output_tokens = output_tokens.saturating_add(o);
1424                }
1425                SessionRecord::RequestAccounting { accounting, .. } => {
1426                    has_request_accounting = true;
1427                    if let Some(usage) = accounting.provider_usage {
1428                        accounted_input_tokens =
1429                            accounted_input_tokens.saturating_add(usage.components.input_tokens.unwrap_or(0));
1430                        accounted_output_tokens =
1431                            accounted_output_tokens.saturating_add(usage.components.output_tokens.unwrap_or(0));
1432                    }
1433                }
1434                _ => {}
1435            }
1436        }
1437
1438        if has_request_accounting {
1439            input_tokens = accounted_input_tokens;
1440            output_tokens = accounted_output_tokens;
1441        }
1442
1443        SessionSummary { title, model, input_tokens, output_tokens }
1444    }
1445
1446    /// Read a renderer-independent, redacted JSON projection of every valid
1447    /// record in sequence order. Malformed lines remain omitted just as they
1448    /// are for transcript recovery.
1449    pub fn read_redacted_records(path: &Path) -> Vec<serde_json::Value> {
1450        Self::read_records(path)
1451            .into_iter()
1452            .filter_map(|record| serde_json::to_value(record).ok())
1453            .map(redact_json_value)
1454            .collect()
1455    }
1456}
1457
1458/// Compact session metadata for sidebar display.
1459#[derive(Clone, Debug, Eq, PartialEq)]
1460pub struct SessionSummary {
1461    pub title: String,
1462    pub model: String,
1463    pub input_tokens: u64,
1464    pub output_tokens: u64,
1465}
1466
1467/// An error resolving a user-supplied local session identifier.
1468#[derive(Clone, Debug, Eq, PartialEq)]
1469pub enum SessionLookupError {
1470    /// No session has the supplied exact id or prefix.
1471    NotFound { query: String },
1472    /// More than one session has the supplied prefix.
1473    Ambiguous { query: String, matches: Vec<String> },
1474}
1475
1476impl std::fmt::Display for SessionLookupError {
1477    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1478        match self {
1479            Self::NotFound { query } => write!(formatter, "session `{query}` is not found"),
1480            Self::Ambiguous { query, matches } => {
1481                write!(
1482                    formatter,
1483                    "session prefix `{query}` is ambiguous; matches: {}",
1484                    matches.join(", ")
1485                )
1486            }
1487        }
1488    }
1489}
1490
1491impl std::error::Error for SessionLookupError {}
1492
1493impl SessionSummary {
1494    pub fn sidebar_label(&self) -> String {
1495        format!("{}\nin {} out {}", self.model, self.input_tokens, self.output_tokens)
1496    }
1497}
1498
1499/// The sessions directory under a workspace root: `{root}/.thndrs/sessions/`.
1500pub fn sessions_dir(workspace_root: &Path) -> PathBuf {
1501    workspace_root.join(".thndrs").join("sessions")
1502}
1503
1504/// List session files in a directory, sorted newest-first by modification time.
1505pub fn list_session_files(dir: &Path) -> Vec<PathBuf> {
1506    let Ok(entries) = std::fs::read_dir(dir) else {
1507        return Vec::new();
1508    };
1509    let mut files: Vec<_> = entries
1510        .filter_map(|e| e.ok())
1511        .filter_map(|e| {
1512            let path = e.path();
1513            if path.extension().is_some_and(|ext| ext == "jsonl") { Some(path) } else { None }
1514        })
1515        .collect();
1516
1517    files.sort_by(|a, b| {
1518        let mtime_a = std::fs::metadata(a).and_then(|m| m.modified()).ok();
1519        let mtime_b = std::fs::metadata(b).and_then(|m| m.modified()).ok();
1520        mtime_b.cmp(&mtime_a).then_with(|| b.cmp(a))
1521    });
1522    files
1523}
1524
1525/// Resolve an exact session id or a unique id prefix.
1526///
1527/// Matching only considers `.jsonl` files in `dir`; a missing or corrupt
1528/// session file therefore cannot prevent other valid files from being found.
1529pub fn resolve_session_file(dir: &Path, query: &str) -> Result<PathBuf, SessionLookupError> {
1530    let files = list_session_files(dir);
1531    if let Some(path) = files.iter().find(|path| session_id_from_path(path) == Some(query)) {
1532        return Ok(path.clone());
1533    }
1534
1535    let matches: Vec<PathBuf> = files
1536        .into_iter()
1537        .filter(|path| session_id_from_path(path).is_some_and(|id| id.starts_with(query)))
1538        .collect();
1539    match matches.as_slice() {
1540        [] => Err(SessionLookupError::NotFound { query: query.to_string() }),
1541        [path] => Ok(path.clone()),
1542        _ => Err(SessionLookupError::Ambiguous {
1543            query: query.to_string(),
1544            matches: matches
1545                .iter()
1546                .filter_map(|path| session_id_from_path(path).map(ToString::to_string))
1547                .collect(),
1548        }),
1549    }
1550}
1551
1552/// List session titles from a directory, newest-first.
1553///
1554/// Each title is read from the session file (latest rename or session_meta).
1555/// Falls back to the file stem if the file cannot be parsed.
1556pub fn list_session_titles(dir: &Path) -> Vec<String> {
1557    list_session_files(dir)
1558        .into_iter()
1559        .map(|p| SessionReader::read_title(&p))
1560        .collect()
1561}
1562
1563/// List session sidebar summaries, newest-first.
1564pub fn list_session_summaries(dir: &Path) -> Vec<SessionSummary> {
1565    list_session_files(dir)
1566        .into_iter()
1567        .map(|p| SessionReader::read_summary(&p))
1568        .collect()
1569}
1570
1571/// Find the most recently modified session file in a directory.
1572///
1573/// Returns `None` if the directory does not exist or has no `.jsonl` files.
1574pub fn latest_session_file(dir: &Path) -> Option<PathBuf> {
1575    list_session_files(dir).into_iter().next()
1576}
1577
1578/// Generate a session id from a timestamp (format: `session-YYYYMMDD-HHMMSS`).
1579pub fn generate_session_id() -> String {
1580    let secs = std::time::SystemTime::now()
1581        .duration_since(std::time::UNIX_EPOCH)
1582        .unwrap_or_default()
1583        .as_secs();
1584    let days = secs / 86_400;
1585    let remainder = secs % 86_400;
1586    let hour = remainder / 3600;
1587    let minute = (remainder % 3600) / 60;
1588    let second = remainder % 60;
1589    let date = datetime::date_from_days(days);
1590    let date_compact = date.replace('-', "");
1591    format!("session-{date_compact}-{hour:02}{minute:02}{second:02}")
1592}
1593
1594/// Convert a serde_json error into an io::Error.
1595fn io_err(e: serde_json::Error) -> std::io::Error {
1596    std::io::Error::new(std::io::ErrorKind::InvalidData, e)
1597}
1598
1599fn acquire_writer_lock(path: &Path) -> std::io::Result<(PathBuf, File)> {
1600    let file_name = path
1601        .file_name()
1602        .and_then(|name| name.to_str())
1603        .unwrap_or("session.jsonl");
1604    let lock_path = path.with_file_name(format!("{file_name}.lock"));
1605    let lock = std::fs::OpenOptions::new()
1606        .write(true)
1607        .create_new(true)
1608        .open(&lock_path)
1609        .map_err(|error| {
1610            if error.kind() == std::io::ErrorKind::AlreadyExists {
1611                std::io::Error::new(
1612                    std::io::ErrorKind::WouldBlock,
1613                    format!("session `{}` already has an active writer", path.display()),
1614                )
1615            } else {
1616                error
1617            }
1618        })?;
1619    Ok((lock_path, lock))
1620}
1621
1622fn session_id_from_path(path: &Path) -> Option<&str> {
1623    path.file_stem().and_then(|stem| stem.to_str())
1624}
1625
1626fn redact_json_value(value: serde_json::Value) -> serde_json::Value {
1627    match value {
1628        serde_json::Value::String(text) => serde_json::Value::String(shell::redact_secrets(&text)),
1629        serde_json::Value::Array(items) => serde_json::Value::Array(items.into_iter().map(redact_json_value).collect()),
1630        serde_json::Value::Object(items) => serde_json::Value::Object(
1631            items
1632                .into_iter()
1633                .map(|(key, value)| (key, redact_json_value(value)))
1634                .collect(),
1635        ),
1636        value => value,
1637    }
1638}
1639
1640/// Read the last `max_lines` of a text log, redacting values and bounding the
1641/// returned payload. Missing files produce an empty result.
1642pub fn read_redacted_log_tail(path: &Path, max_lines: usize) -> Vec<String> {
1643    if max_lines == 0 {
1644        return Vec::new();
1645    }
1646    let Ok(file) = File::open(path) else {
1647        return Vec::new();
1648    };
1649
1650    let mut lines = VecDeque::with_capacity(max_lines);
1651    for line in BufReader::new(file).lines().map_while(Result::ok) {
1652        if lines.len() == max_lines {
1653            lines.pop_front();
1654        }
1655        lines.push_back(shell::redact_secrets(&line));
1656    }
1657
1658    let mut bytes = 0usize;
1659    let mut output = Vec::new();
1660    for line in lines.into_iter().rev() {
1661        let line_bytes = line.len().saturating_add(1);
1662        if bytes.saturating_add(line_bytes) > MAX_LOG_OUTPUT_BYTES {
1663            break;
1664        }
1665        bytes = bytes.saturating_add(line_bytes);
1666        output.push(line);
1667    }
1668    output.reverse();
1669    output
1670}
1671
1672/// Split a tool entry name like `"search_text#0"` into `("search_text", "0")`.
1673///
1674/// If there is no `#`, the whole string is the name and the id defaults to `"?"`.
1675fn split_tool_name_id(name: &str) -> (String, String) {
1676    match name.rsplit_once('#') {
1677        Some((n, id)) => (n.to_string(), id.to_string()),
1678        None => (name.to_string(), "?".to_string()),
1679    }
1680}
1681
1682fn mcp_tool_session_meta(name: &str) -> Option<McpToolSessionMeta> {
1683    let rest = name.strip_prefix("mcp__")?;
1684    let (server_name, original_tool_name) = rest.split_once("__")?;
1685    if server_name.is_empty() || original_tool_name.is_empty() {
1686        return None;
1687    }
1688    Some(McpToolSessionMeta {
1689        server_name: server_name.to_string(),
1690        original_tool_name: original_tool_name.to_string(),
1691    })
1692}
1693
1694/// Set the seq field on a record (used by `SessionWriter::append`).
1695fn set_seq(record: &mut SessionRecord, seq: u64) {
1696    match record {
1697        SessionRecord::SessionMeta { seq: s, .. }
1698        | SessionRecord::Context { seq: s, .. }
1699        | SessionRecord::ContextLedger { seq: s, .. }
1700        | SessionRecord::ContextPin { seq: s, .. }
1701        | SessionRecord::ContextDrop { seq: s, .. }
1702        | SessionRecord::ContextRecovery { seq: s, .. }
1703        | SessionRecord::Compaction { seq: s, .. }
1704        | SessionRecord::CompactionReview { seq: s, .. }
1705        | SessionRecord::User { seq: s, .. }
1706        | SessionRecord::PromptMetadata { seq: s, .. }
1707        | SessionRecord::AssistantFinished { seq: s, .. }
1708        | SessionRecord::ReasoningFinished { seq: s, .. }
1709        | SessionRecord::Usage { seq: s, .. }
1710        | SessionRecord::RequestAccounting { seq: s, .. }
1711        | SessionRecord::ToolStarted { seq: s, .. }
1712        | SessionRecord::ToolFinished { seq: s, .. }
1713        | SessionRecord::Cancelled { seq: s, .. }
1714        | SessionRecord::Failed { seq: s, .. }
1715        | SessionRecord::AcpSession { seq: s, .. }
1716        | SessionRecord::SessionRenamed { seq: s, .. }
1717        | SessionRecord::FileWrite { seq: s, .. }
1718        | SessionRecord::McpConfigChanged { seq: s, .. }
1719        | SessionRecord::ShellExec { seq: s, .. }
1720        | SessionRecord::SkillActivated { seq: s, .. }
1721        | SessionRecord::QueuedInput { seq: s, .. }
1722        | SessionRecord::AcpPermissionRequest { seq: s, .. }
1723        | SessionRecord::AcpPermissionOutcome { seq: s, .. } => *s = seq,
1724    }
1725}