1use super::path::ContainedPath;
2use super::transcript::append_note;
3use super::AppService;
4
5impl AppService {
6 pub async fn add_note(
8 &self,
9 session_id: &str,
10 content: &str,
11 title: Option<&str>,
12 ) -> Result<String, String> {
13 let count = append_note(self.require_log_dir()?, session_id, content, title)?;
14 Ok(serde_json::json!({
15 "session_id": session_id,
16 "notes_count": count,
17 })
18 .to_string())
19 }
20
21 const DEFAULT_MAX_CHARS: usize = 100_000;
23
24 pub async fn log_view(
26 &self,
27 session_id: Option<&str>,
28 limit: Option<usize>,
29 max_chars: Option<usize>,
30 ) -> Result<String, String> {
31 match session_id {
32 Some(sid) => self.log_read(sid, max_chars.unwrap_or(Self::DEFAULT_MAX_CHARS)),
33 None => self.log_list(limit.unwrap_or(50)),
34 }
35 }
36
37 fn log_read(&self, session_id: &str, max_chars: usize) -> Result<String, String> {
38 let log_dir = self.require_log_dir()?;
39 let path = ContainedPath::child(log_dir, &format!("{session_id}.json"))?;
40 if !path.as_ref().exists() {
41 return Err(format!("Log file not found for session '{session_id}'"));
42 }
43 let raw = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))?;
44
45 if max_chars == 0 || raw.len() <= max_chars {
47 return Ok(raw);
48 }
49
50 let mut doc: serde_json::Value =
52 serde_json::from_str(&raw).map_err(|e| format!("Failed to parse log: {e}"))?;
53
54 let original_rounds = doc
55 .get("transcript")
56 .and_then(|t| t.as_array())
57 .map(|a| a.len())
58 .unwrap_or(0);
59
60 if original_rounds == 0 {
61 return Ok(raw);
63 }
64
65 let transcript = doc
67 .get("transcript")
68 .and_then(|t| t.as_array())
69 .cloned()
70 .unwrap_or_default();
71
72 let mut kept = original_rounds;
73 loop {
74 if kept == 0 {
75 doc["transcript"] = serde_json::json!([]);
77 break;
78 }
79 let slice = &transcript[original_rounds - kept..];
81 doc["transcript"] = serde_json::Value::Array(slice.to_vec());
82 let serialized =
83 serde_json::to_string(&doc).map_err(|e| format!("Failed to serialize: {e}"))?;
84 if serialized.len() <= max_chars {
85 break;
86 }
87 if kept > 8 {
89 kept /= 2;
90 } else {
91 kept -= 1;
92 }
93 }
94
95 let returned_rounds = doc
96 .get("transcript")
97 .and_then(|t| t.as_array())
98 .map(|a| a.len())
99 .unwrap_or(0);
100
101 doc["truncated"] = serde_json::json!(true);
102 doc["original_rounds"] = serde_json::json!(original_rounds);
103 doc["returned_rounds"] = serde_json::json!(returned_rounds);
104
105 serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize: {e}"))
106 }
107
108 pub(super) fn log_list(&self, limit: usize) -> Result<String, String> {
109 let dir = match self.log_config.log_dir.as_deref() {
110 Some(d) if d.is_dir() => d,
111 _ => return Ok(serde_json::json!({ "sessions": [] }).to_string()),
112 };
113
114 let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
115
116 let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
118 .flatten()
119 .filter_map(|entry| {
120 let path = entry.path();
121 let name = path.file_name()?.to_str()?;
122 if !name.ends_with(".json") || name.ends_with(".meta.json") {
124 return None;
125 }
126 let mtime = entry.metadata().ok()?.modified().ok()?;
127 Some((path, mtime))
128 })
129 .collect();
130
131 files.sort_by(|a, b| b.1.cmp(&a.1));
133 files.truncate(limit);
134
135 let mut sessions = Vec::new();
136 for (path, _) in &files {
137 let meta_path = path.with_extension("meta.json");
139 let doc: serde_json::Value = if meta_path.exists() {
140 match std::fs::read_to_string(&meta_path)
142 .ok()
143 .and_then(|r| serde_json::from_str(&r).ok())
144 {
145 Some(d) => d,
146 None => continue,
147 }
148 } else {
149 let raw = match std::fs::read_to_string(path) {
151 Ok(r) => r,
152 Err(_) => continue,
153 };
154 match serde_json::from_str::<serde_json::Value>(&raw) {
155 Ok(d) => {
156 let stats = d.get("stats");
157 serde_json::json!({
158 "session_id": d.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
159 "task_hint": d.get("task_hint").and_then(|v| v.as_str()),
160 "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
161 "rounds": stats.and_then(|s| s.get("rounds")),
162 "llm_calls": stats.and_then(|s| s.get("llm_calls")),
163 "notes_count": d.get("notes").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
164 })
165 }
166 Err(_) => continue,
167 }
168 };
169
170 sessions.push(doc);
171 }
172
173 Ok(serde_json::json!({ "sessions": sessions }).to_string())
174 }
175
176 pub fn info(&self) -> String {
180 let mut info = serde_json::json!({
181 "version": env!("CARGO_PKG_VERSION"),
182 "log_dir": {
183 "resolved": self.log_config.log_dir.as_ref().map(|p| p.display().to_string()),
184 "source": self.log_config.log_dir_source.to_string(),
185 },
186 "log_enabled": self.log_config.log_enabled,
187 "tracing": if self.log_config.log_dir.is_some() { "file + stderr" } else { "stderr only" },
188 });
189
190 let search_paths_json: Vec<serde_json::Value> = self
192 .search_paths
193 .iter()
194 .map(|sp| {
195 serde_json::json!({
196 "path": sp.path.display().to_string(),
197 "source": sp.source.to_string(),
198 })
199 })
200 .collect();
201 info["search_paths"] = serde_json::json!(search_paths_json);
202
203 if let Some(home) = dirs::home_dir() {
205 let packages = home.join(".algocline").join("packages");
206 if packages.is_dir() {
207 info["packages_dir"] = serde_json::json!(packages.display().to_string());
208 }
209 }
210
211 serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())
212 }
213
214 pub fn stats(
219 &self,
220 strategy_filter: Option<&str>,
221 days: Option<u64>,
222 ) -> Result<String, String> {
223 let dir = match self.log_config.log_dir.as_deref() {
224 Some(d) if d.is_dir() => d,
225 _ => {
226 return Ok(serde_json::json!({
227 "total_sessions": 0,
228 "strategies": {},
229 })
230 .to_string());
231 }
232 };
233
234 let cutoff = days.map(|d| {
235 std::time::SystemTime::now()
236 .duration_since(std::time::UNIX_EPOCH)
237 .unwrap_or_default()
238 .as_millis() as u64
239 - d * 86_400_000
240 });
241
242 let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
243
244 #[derive(Default)]
245 struct StrategyAcc {
246 count: u64,
247 sum_elapsed_ms: u64,
248 sum_llm_calls: u64,
249 sum_rounds: u64,
250 sum_prompt_chars: u64,
251 sum_response_chars: u64,
252 }
253
254 let mut acc: std::collections::HashMap<String, StrategyAcc> =
255 std::collections::HashMap::new();
256 let mut total: u64 = 0;
257
258 for entry in entries.flatten() {
259 let path = entry.path();
260 let name = match path.file_name().and_then(|n| n.to_str()) {
261 Some(n) => n.to_string(),
262 None => continue,
263 };
264
265 let doc: serde_json::Value = if name.ends_with(".meta.json") {
267 match std::fs::read_to_string(&path)
268 .ok()
269 .and_then(|r| serde_json::from_str(&r).ok())
270 {
271 Some(d) => d,
272 None => continue,
273 }
274 } else if name.ends_with(".json") && !name.ends_with(".meta.json") {
275 let meta_name =
277 format!("{}.meta.json", name.strip_suffix(".json").unwrap_or(&name));
278 let meta_path = dir.join(meta_name);
279 if meta_path.exists() {
280 continue;
281 }
282 match std::fs::read_to_string(&path)
284 .ok()
285 .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
286 {
287 Some(d) => {
288 let stats = d.get("stats");
289 serde_json::json!({
290 "strategy": d.get("strategy").and_then(|v| v.as_str()),
291 "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
292 "llm_calls": stats.and_then(|s| s.get("llm_calls")),
293 "rounds": stats.and_then(|s| s.get("rounds")),
294 "total_prompt_chars": stats.and_then(|s| s.get("total_prompt_chars")),
295 "total_response_chars": stats.and_then(|s| s.get("total_response_chars")),
296 })
297 }
298 None => continue,
299 }
300 } else {
301 continue;
302 };
303
304 if let Some(cutoff_ms) = cutoff {
307 let mtime = entry
308 .metadata()
309 .ok()
310 .and_then(|m| m.modified().ok())
311 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
312 .map(|d| d.as_millis() as u64)
313 .unwrap_or(0);
314 if mtime < cutoff_ms {
315 continue;
316 }
317 }
318
319 let strat = doc
320 .get("strategy")
321 .and_then(|v| v.as_str())
322 .unwrap_or("unknown")
323 .to_string();
324
325 if let Some(filter) = strategy_filter {
327 if strat != filter {
328 continue;
329 }
330 }
331
332 let elapsed = doc.get("elapsed_ms").and_then(|v| v.as_u64()).unwrap_or(0);
333 let llm = doc.get("llm_calls").and_then(|v| v.as_u64()).unwrap_or(0);
334 let rounds = doc.get("rounds").and_then(|v| v.as_u64()).unwrap_or(0);
335 let prompt_chars = doc
336 .get("total_prompt_chars")
337 .and_then(|v| v.as_u64())
338 .unwrap_or(0);
339 let response_chars = doc
340 .get("total_response_chars")
341 .and_then(|v| v.as_u64())
342 .unwrap_or(0);
343
344 let a = acc.entry(strat).or_default();
345 a.count += 1;
346 a.sum_elapsed_ms += elapsed;
347 a.sum_llm_calls += llm;
348 a.sum_rounds += rounds;
349 a.sum_prompt_chars += prompt_chars;
350 a.sum_response_chars += response_chars;
351 total += 1;
352 }
353
354 let mut strategies = serde_json::Map::new();
356 for (strat, a) in &acc {
357 let c = a.count.max(1); strategies.insert(
359 strat.clone(),
360 serde_json::json!({
361 "count": a.count,
362 "avg_elapsed_ms": (a.sum_elapsed_ms + c / 2) / c,
363 "avg_llm_calls": (a.sum_llm_calls + c / 2) / c,
364 "avg_rounds": (a.sum_rounds + c / 2) / c,
365 "total_prompt_chars": a.sum_prompt_chars,
366 "total_response_chars": a.sum_response_chars,
367 }),
368 );
369 }
370
371 Ok(serde_json::json!({
372 "total_sessions": total,
373 "strategies": strategies,
374 })
375 .to_string())
376 }
377}