1mod 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
37pub const SCHEMA_VERSION: u32 = 1;
39
40pub const MAX_LOG_OUTPUT_BYTES: usize = 32 * 1024;
42
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
49#[serde(tag = "type")]
50pub enum SessionRecord {
51 #[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 #[serde(skip_serializing_if = "Option::is_none")]
67 config: Option<SessionConfigMeta>,
68 },
69 #[serde(rename = "context")]
71 Context {
72 schema_version: u32,
73 seq: u64,
74 time: String,
75 sources: Vec<ContextSourceMeta>,
76 },
77 #[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 #[serde(rename = "context_pin")]
88 ContextPin {
89 schema_version: u32,
90 seq: u64,
91 time: String,
92 item: ContextItemMeta,
93 reason: String,
94 },
95 #[serde(rename = "context_drop")]
97 ContextDrop {
98 schema_version: u32,
99 seq: u64,
100 time: String,
101 item: ContextItemMeta,
102 reason: String,
103 },
104 #[serde(rename = "context_recovery")]
106 ContextRecovery {
107 schema_version: u32,
108 seq: u64,
109 time: String,
110 item: ContextItemMeta,
111 reason: String,
112 },
113 #[serde(rename = "compaction")]
115 Compaction {
116 schema_version: u32,
117 seq: u64,
118 time: String,
119 audit: CompactionAudit,
120 },
121 #[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 #[serde(rename = "user")]
132 User {
133 schema_version: u32,
134 seq: u64,
135 time: String,
136 turn_id: String,
137 text: String,
138 },
139 #[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 #[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 #[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 #[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 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
196 mcp: Option<McpToolSessionMeta>,
197 },
198 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
210 artifact: Option<ArtifactMetadata>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 mcp: Option<McpToolSessionMeta>,
214 },
215 #[serde(rename = "cancelled")]
217 Cancelled {
218 schema_version: u32,
219 seq: u64,
220 time: String,
221 turn_id: String,
222 reason: String,
223 },
224 #[serde(rename = "failed")]
226 Failed {
227 schema_version: u32,
228 seq: u64,
229 time: String,
230 turn_id: String,
231 error: String,
232 },
233 #[serde(rename = "acp_session")]
235 AcpSession {
236 schema_version: u32,
237 seq: u64,
238 time: String,
239 local_session_id: String,
241 agent_name: String,
243 acp_session_id: String,
245 command: String,
247 protocol_version: String,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 agent_info_name: Option<String>,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 agent_info_version: Option<String>,
255 #[serde(skip_serializing_if = "Option::is_none")]
257 client_info_name: Option<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
260 client_info_version: Option<String>,
261 },
262 #[serde(rename = "session_renamed")]
264 SessionRenamed {
265 schema_version: u32,
266 seq: u64,
267 time: String,
268 title: String,
269 },
270 #[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 #[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 #[serde(rename = "shell_exec")]
311 ShellExec {
312 schema_version: u32,
313 seq: u64,
314 time: String,
315 turn_id: String,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 process_id: Option<u64>,
319 command: String,
321 cwd: String,
323 process_status: String,
325 exit_code: Option<i32>,
327 elapsed_ms: u64,
329 kind: String,
331 },
332 #[serde(rename = "skill_activated")]
334 SkillActivated {
335 schema_version: u32,
336 seq: u64,
337 time: String,
338 name: String,
339 path: String,
340 content_hash: u64,
342 byte_count: usize,
344 #[serde(default)]
346 rendered_content_hash: u64,
347 #[serde(default)]
349 rendered_byte_count: usize,
350 loaded_references: Vec<SkillReferenceRecord>,
351 },
352 #[serde(rename = "queued_input")]
354 QueuedInput {
355 schema_version: u32,
356 seq: u64,
357 time: String,
358 kind: String,
360 text: String,
361 },
362 #[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 #[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 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 pub fn to_json(&self) -> Result<String, serde_json::Error> {
421 serde_json::to_string(self)
422 }
423
424 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
426 serde_json::from_str(json)
427 }
428
429 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 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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
549pub struct PromptMetadata {
550 pub model: String,
552 #[serde(default)]
554 pub provider: String,
555 pub search_mode: String,
557 #[serde(default)]
559 pub renderer_mode: String,
560 pub date: String,
562 pub cwd: String,
564 #[serde(default)]
566 pub prompt_fragments: Vec<String>,
567 #[serde(default)]
569 pub docs_map: Vec<String>,
570 pub context_sources: Vec<ContextSourceMeta>,
572 pub tool_catalog_size: usize,
574 #[serde(default)]
576 pub tool_names: Vec<String>,
577 pub skill_catalog_size: usize,
579 #[serde(default)]
581 pub skill_names: Vec<String>,
582 #[serde(default)]
584 pub diagnostics: Vec<String>,
585 pub history_reuse: bool,
587 pub prev_context_hash: Option<u64>,
589 pub transcript_tail_size: usize,
591 pub has_user_turn: bool,
593}
594
595impl PromptMetadata {
596 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
646#[serde(rename_all = "snake_case")]
647pub enum CompactionTrigger {
648 Manual,
650 Automatic,
652}
653
654#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
656#[serde(rename_all = "snake_case")]
657pub enum CompactionRisk {
658 Low,
660 High,
662}
663
664#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
666#[serde(rename_all = "snake_case")]
667pub enum CompactionReviewResult {
668 NotRequired,
670 Pending,
672 Approved,
674 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
691pub struct CompactionSourceHash {
692 pub id: String,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub content_hash: Option<u64>,
697}
698
699#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
701pub struct CompactionTokenUsage {
702 pub input_tokens: u64,
704 pub output_tokens: u64,
706}
707
708#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
715pub struct CompactionAudit {
716 pub summary: String,
718 pub covered_start_seq: u64,
720 pub covered_end_seq: u64,
722 #[serde(default, skip_serializing_if = "Vec::is_empty")]
724 pub source_hashes: Vec<CompactionSourceHash>,
725 pub trigger: CompactionTrigger,
727 pub risk: CompactionRisk,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub review: Option<CompactionReviewResult>,
732 #[serde(default, skip_serializing_if = "Vec::is_empty")]
734 pub recovery_handles: Vec<String>,
735 pub model: String,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub usage: Option<CompactionTokenUsage>,
740}
741
742impl CompactionAudit {
743 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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
782enum ContextActionKind {
783 Pin,
784 Drop,
785 Recovery,
786}
787
788#[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 #[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 pub fn next_sequence(&self) -> u64 {
846 self.seq
847 }
848
849 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn path(&self) -> &Path {
1285 &self.path
1286 }
1287
1288 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
1300pub struct SessionReader;
1305
1306impl SessionReader {
1307 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 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 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 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 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 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#[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#[derive(Clone, Debug, Eq, PartialEq)]
1469pub enum SessionLookupError {
1470 NotFound { query: String },
1472 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
1499pub fn sessions_dir(workspace_root: &Path) -> PathBuf {
1501 workspace_root.join(".thndrs").join("sessions")
1502}
1503
1504pub 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
1525pub 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
1552pub 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
1563pub 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
1571pub fn latest_session_file(dir: &Path) -> Option<PathBuf> {
1575 list_session_files(dir).into_iter().next()
1576}
1577
1578pub 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
1594fn 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
1640pub 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
1672fn 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
1694fn 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}