From 4557a81d2f70cd74d6b5cc1f15592fb3ab20f740 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 15:15:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20b5-doctor-cmd=20=E2=80=94=20batch=205?= =?UTF-8?q?=20wave=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/crates/runtime/src/conversation.rs | 4 + rust/crates/runtime/src/lib.rs | 2 +- rust/crates/runtime/src/session.rs | 108 +++++++++++++++++++++++ rust/crates/rusty-claude-cli/src/main.rs | 43 +++++++-- 4 files changed, 150 insertions(+), 7 deletions(-) diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index c3e3c03..b687687 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -504,6 +504,10 @@ where &self.session } + pub fn session_mut(&mut self) -> &mut Session { + &mut self.session + } + #[must_use] pub fn fork_session(&self, branch_name: Option) -> Session { self.session.fork(branch_name) diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index eb73f08..e193039 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -145,7 +145,7 @@ pub use sandbox::{ }; pub use session::{ ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError, - SessionFork, + SessionFork, SessionPromptEntry, }; pub use sse::{IncrementalSseParser, SseEvent}; pub use stale_branch::{ diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index 61c0e75..e70ceda 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -65,6 +65,13 @@ pub struct SessionFork { pub branch_name: Option, } +/// A single user prompt recorded with a timestamp for history tracking. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPromptEntry { + pub timestamp_ms: u64, + pub text: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionPersistence { path: PathBuf, @@ -88,6 +95,7 @@ pub struct Session { pub compaction: Option, pub fork: Option, pub workspace_root: Option, + pub prompt_history: Vec, persistence: Option, } @@ -101,6 +109,7 @@ impl PartialEq for Session { && self.compaction == other.compaction && self.fork == other.fork && self.workspace_root == other.workspace_root + && self.prompt_history == other.prompt_history } } @@ -151,6 +160,7 @@ impl Session { compaction: None, fork: None, workspace_root: None, + prompt_history: Vec::new(), persistence: None, } } @@ -252,6 +262,7 @@ impl Session { branch_name: normalize_optional_string(branch_name), }), workspace_root: self.workspace_root.clone(), + prompt_history: self.prompt_history.clone(), persistence: None, } } @@ -295,6 +306,17 @@ impl Session { JsonValue::String(workspace_root_to_string(workspace_root)?), ); } + if !self.prompt_history.is_empty() { + object.insert( + "prompt_history".to_string(), + JsonValue::Array( + self.prompt_history + .iter() + .map(SessionPromptEntry::to_jsonl_record) + .collect(), + ), + ); + } Ok(JsonValue::Object(object)) } @@ -339,6 +361,16 @@ impl Session { .get("workspace_root") .and_then(JsonValue::as_str) .map(PathBuf::from); + let prompt_history = object + .get("prompt_history") + .and_then(JsonValue::as_array) + .map(|entries| { + entries + .iter() + .filter_map(SessionPromptEntry::from_json_opt) + .collect() + }) + .unwrap_or_default(); Ok(Self { version, session_id, @@ -348,6 +380,7 @@ impl Session { compaction, fork, workspace_root, + prompt_history, persistence: None, }) } @@ -361,6 +394,7 @@ impl Session { let mut compaction = None; let mut fork = None; let mut workspace_root = None; + let mut prompt_history = Vec::new(); for (line_number, raw_line) in contents.lines().enumerate() { let line = raw_line.trim(); @@ -414,6 +448,13 @@ impl Session { object.clone(), ))?); } + "prompt_history" => { + if let Some(entry) = + SessionPromptEntry::from_json_opt(&JsonValue::Object(object.clone())) + { + prompt_history.push(entry); + } + } other => { return Err(SessionError::Format(format!( "unsupported JSONL record type at line {}: {other}", @@ -433,15 +474,36 @@ impl Session { compaction, fork, workspace_root, + prompt_history, persistence: None, }) } + /// Record a user prompt with the current wall-clock timestamp. + /// + /// The entry is appended to the in-memory history and, when a persistence + /// path is configured, incrementally written to the JSONL session file. + pub fn push_prompt_entry(&mut self, text: impl Into) -> Result<(), SessionError> { + let timestamp_ms = current_time_millis(); + let entry = SessionPromptEntry { + timestamp_ms, + text: text.into(), + }; + self.prompt_history.push(entry); + let entry_ref = self.prompt_history.last().expect("entry was just pushed"); + self.append_persisted_prompt_entry(entry_ref) + } + fn render_jsonl_snapshot(&self) -> Result { let mut lines = vec![self.meta_record()?.render()]; if let Some(compaction) = &self.compaction { lines.push(compaction.to_jsonl_record()?.render()); } + lines.extend( + self.prompt_history + .iter() + .map(|entry| entry.to_jsonl_record().render()), + ); lines.extend( self.messages .iter() @@ -468,6 +530,25 @@ impl Session { Ok(()) } + fn append_persisted_prompt_entry( + &self, + entry: &SessionPromptEntry, + ) -> Result<(), SessionError> { + let Some(path) = self.persistence_path() else { + return Ok(()); + }; + + let needs_bootstrap = !path.exists() || fs::metadata(path)?.len() == 0; + if needs_bootstrap { + self.save_to_path(path)?; + return Ok(()); + } + + let mut file = OpenOptions::new().append(true).open(path)?; + writeln!(file, "{}", entry.to_jsonl_record().render())?; + Ok(()) + } + fn meta_record(&self) -> Result { let mut object = BTreeMap::new(); object.insert( @@ -784,6 +865,33 @@ impl SessionFork { } } +impl SessionPromptEntry { + #[must_use] + pub fn to_jsonl_record(&self) -> JsonValue { + let mut object = BTreeMap::new(); + object.insert( + "type".to_string(), + JsonValue::String("prompt_history".to_string()), + ); + object.insert( + "timestamp_ms".to_string(), + JsonValue::Number(i64::try_from(self.timestamp_ms).unwrap_or(i64::MAX)), + ); + object.insert("text".to_string(), JsonValue::String(self.text.clone())); + JsonValue::Object(object) + } + + fn from_json_opt(value: &JsonValue) -> Option { + let object = value.as_object()?; + let timestamp_ms = object + .get("timestamp_ms") + .and_then(JsonValue::as_i64) + .and_then(|value| u64::try_from(value).ok())?; + let text = object.get("text").and_then(JsonValue::as_str)?.to_string(); + Some(Self { timestamp_ms, text }) + } +} + fn message_record(message: &ConversationMessage) -> JsonValue { let mut object = BTreeMap::new(); object.insert("type".to_string(), JsonValue::String("message".to_string())); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 447c45c..73fa23b 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3642,10 +3642,14 @@ impl LiveCli { .map_or(self.runtime.session().updated_at_ms, |duration| { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) }); - self.prompt_history.push(PromptHistoryEntry { + let entry = PromptHistoryEntry { timestamp_ms, text: prompt.to_string(), - }); + }; + self.prompt_history.push(entry); + if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { + eprintln!("warning: failed to persist prompt history: {error}"); + } } fn print_prompt_history(&self, count: Option<&str>) { @@ -3656,10 +3660,27 @@ impl LiveCli { return; } }; - let entries = if self.prompt_history.is_empty() { - collect_session_prompt_history(self.runtime.session()) + let session_entries = &self.runtime.session().prompt_history; + let entries = if session_entries.is_empty() { + if self.prompt_history.is_empty() { + collect_session_prompt_history(self.runtime.session()) + } else { + self.prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect() + } } else { - self.prompt_history.clone() + session_entries + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect() }; println!("{}", render_prompt_history_report(&entries, limit)); } @@ -5145,7 +5166,7 @@ fn write_temp_text_file( Ok(path) } -const DEFAULT_HISTORY_LIMIT: usize = 10; +const DEFAULT_HISTORY_LIMIT: usize = 20; fn parse_history_count(raw: Option<&str>) -> Result { let Some(raw) = raw else { @@ -5222,6 +5243,16 @@ fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> } fn collect_session_prompt_history(session: &Session) -> Vec { + if !session.prompt_history.is_empty() { + return session + .prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect(); + } let timestamp_ms = session.updated_at_ms; session .messages