Skip to main content

chub_core/team/
session_journal.rs

1//! JSONL event journal for full session transcripts.
2//!
3//! Stored in `.git/chub-sessions/<session-id>.jsonl` (local-only, never pushed).
4
5use std::fs;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use super::sessions::{git_sessions_dir, TokenUsage};
11use crate::util::now_iso8601;
12
13// ---------------------------------------------------------------------------
14// Event types
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "type")]
19pub enum SessionEvent {
20    #[serde(rename = "session_start")]
21    SessionStart {
22        ts: String,
23        session_id: String,
24        agent: String,
25        #[serde(default, skip_serializing_if = "Option::is_none")]
26        model: Option<String>,
27    },
28    #[serde(rename = "prompt")]
29    Prompt {
30        ts: String,
31        #[serde(default, skip_serializing_if = "Option::is_none")]
32        text: Option<String>,
33    },
34    #[serde(rename = "tool_call")]
35    ToolCall {
36        ts: String,
37        tool: String,
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        input_summary: Option<String>,
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        duration_ms: Option<u64>,
42    },
43    #[serde(rename = "tool_result")]
44    ToolResult {
45        ts: String,
46        tool: String,
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        output_size: Option<u64>,
49    },
50    #[serde(rename = "response")]
51    Response {
52        ts: String,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        tokens: Option<TokenUsage>,
55    },
56    #[serde(rename = "thinking")]
57    Thinking {
58        ts: String,
59        /// Truncated thinking/reasoning content (first 500 chars).
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        content: Option<String>,
62        /// Size of the full thinking block in bytes.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        content_size: Option<u64>,
65        /// Reasoning tokens consumed (from API usage).
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        reasoning_tokens: Option<u64>,
68    },
69    #[serde(rename = "model_update")]
70    ModelUpdate { ts: String, model: String },
71    #[serde(rename = "file_change")]
72    FileChange {
73        ts: String,
74        path: String,
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        action: Option<String>, // "create", "edit", "delete"
77    },
78    #[serde(rename = "session_end")]
79    SessionEnd {
80        ts: String,
81        #[serde(default, skip_serializing_if = "Option::is_none")]
82        duration_s: Option<u64>,
83        #[serde(default)]
84        turns: u32,
85    },
86}
87
88// ---------------------------------------------------------------------------
89// Journal operations
90// ---------------------------------------------------------------------------
91
92fn journal_path(session_id: &str) -> Option<PathBuf> {
93    git_sessions_dir().map(|d| d.join(format!("{}.jsonl", session_id)))
94}
95
96/// Append an event to the session journal.
97pub fn append_event(session_id: &str, event: &SessionEvent) {
98    let path = match journal_path(session_id) {
99        Some(p) => p,
100        None => return,
101    };
102    let _ = fs::create_dir_all(path.parent().unwrap_or(&PathBuf::from(".")));
103
104    let line = serde_json::to_string(event).unwrap_or_default();
105    let mut file = fs::OpenOptions::new().create(true).append(true).open(&path);
106
107    if let Ok(ref mut f) = file {
108        use std::io::Write;
109        let _ = writeln!(f, "{}", line);
110    }
111}
112
113/// Load all events for a session.
114pub fn load_events(session_id: &str) -> Vec<SessionEvent> {
115    let path = match journal_path(session_id) {
116        Some(p) => p,
117        None => return vec![],
118    };
119    if !path.exists() {
120        return vec![];
121    }
122
123    let content = match fs::read_to_string(&path) {
124        Ok(c) => c,
125        Err(_) => return vec![],
126    };
127
128    content
129        .lines()
130        .filter(|l| !l.trim().is_empty())
131        .filter_map(|l| serde_json::from_str(l).ok())
132        .collect()
133}
134
135/// Get journal file size for a session.
136pub fn journal_size(session_id: &str) -> u64 {
137    journal_path(session_id)
138        .and_then(|p| fs::metadata(&p).ok())
139        .map(|m| m.len())
140        .unwrap_or(0)
141}
142
143/// List all journal files in `.git/chub-sessions/`.
144pub fn list_journal_files() -> Vec<String> {
145    let dir = match git_sessions_dir() {
146        Some(d) => d,
147        None => return vec![],
148    };
149    if !dir.is_dir() {
150        return vec![];
151    }
152    fs::read_dir(&dir)
153        .ok()
154        .into_iter()
155        .flatten()
156        .filter_map(|e| e.ok())
157        .filter(|e| {
158            e.path()
159                .extension()
160                .map(|ext| ext == "jsonl")
161                .unwrap_or(false)
162        })
163        .filter_map(|e| {
164            e.path()
165                .file_stem()
166                .map(|s| s.to_string_lossy().to_string())
167        })
168        .collect()
169}
170
171/// Clear all local journal files.
172pub fn clear_journals() -> usize {
173    let dir = match git_sessions_dir() {
174        Some(d) => d,
175        None => return 0,
176    };
177    if !dir.is_dir() {
178        return 0;
179    }
180    let mut count = 0;
181    if let Ok(entries) = fs::read_dir(&dir) {
182        for entry in entries.flatten() {
183            let path = entry.path();
184            if path.extension().map(|e| e == "jsonl").unwrap_or(false)
185                && fs::remove_file(&path).is_ok()
186            {
187                count += 1;
188            }
189        }
190    }
191    count
192}
193
194// ---------------------------------------------------------------------------
195// Convenience: record events with auto-timestamp
196// ---------------------------------------------------------------------------
197
198pub fn record_session_start(session_id: &str, agent: &str, model: Option<&str>) {
199    append_event(
200        session_id,
201        &SessionEvent::SessionStart {
202            ts: now_iso8601(),
203            session_id: session_id.to_string(),
204            agent: agent.to_string(),
205            model: model.map(|s| s.to_string()),
206        },
207    );
208}
209
210pub fn record_prompt(session_id: &str, text: Option<&str>) {
211    append_event(
212        session_id,
213        &SessionEvent::Prompt {
214            ts: now_iso8601(),
215            text: text.map(|s| s.to_string()),
216        },
217    );
218}
219
220pub fn record_tool_call(session_id: &str, tool: &str, input_summary: Option<&str>) {
221    append_event(
222        session_id,
223        &SessionEvent::ToolCall {
224            ts: now_iso8601(),
225            tool: tool.to_string(),
226            input_summary: input_summary.map(|s| s.to_string()),
227            duration_ms: None,
228        },
229    );
230}
231
232pub fn record_tool_result(session_id: &str, tool: &str, output_size: Option<u64>) {
233    append_event(
234        session_id,
235        &SessionEvent::ToolResult {
236            ts: now_iso8601(),
237            tool: tool.to_string(),
238            output_size,
239        },
240    );
241}
242
243pub fn record_response(session_id: &str, tokens: Option<TokenUsage>) {
244    append_event(
245        session_id,
246        &SessionEvent::Response {
247            ts: now_iso8601(),
248            tokens,
249        },
250    );
251}
252
253pub fn record_file_change(session_id: &str, path: &str, action: Option<&str>) {
254    append_event(
255        session_id,
256        &SessionEvent::FileChange {
257            ts: now_iso8601(),
258            path: path.to_string(),
259            action: action.map(|s| s.to_string()),
260        },
261    );
262}
263
264pub fn record_thinking(
265    session_id: &str,
266    content: Option<&str>,
267    content_size: Option<u64>,
268    reasoning_tokens: Option<u64>,
269) {
270    // Truncate content to 500 chars for journal storage
271    let truncated = content.map(|c| {
272        if c.len() <= 500 {
273            c.to_string()
274        } else {
275            format!("{}...", &c[..497])
276        }
277    });
278    append_event(
279        session_id,
280        &SessionEvent::Thinking {
281            ts: now_iso8601(),
282            content: truncated,
283            content_size,
284            reasoning_tokens,
285        },
286    );
287}
288
289pub fn record_session_end(session_id: &str, duration_s: Option<u64>, turns: u32) {
290    append_event(
291        session_id,
292        &SessionEvent::SessionEnd {
293            ts: now_iso8601(),
294            duration_s,
295            turns,
296        },
297    );
298}