1use super::hub_dist_preset::PRESET_CATALOG_VERSION;
2use super::path::ContainedPath;
3use super::transcript::append_note;
4use super::AppService;
5
6impl AppService {
7 pub async fn add_note(
9 &self,
10 session_id: &str,
11 content: &str,
12 title: Option<&str>,
13 ) -> Result<String, String> {
14 let count = append_note(self.require_log_dir()?, session_id, content, title)?;
15 Ok(serde_json::json!({
16 "session_id": session_id,
17 "notes_count": count,
18 })
19 .to_string())
20 }
21
22 const DEFAULT_MAX_CHARS: usize = 100_000;
24
25 pub async fn log_view(
27 &self,
28 session_id: Option<&str>,
29 limit: Option<usize>,
30 max_chars: Option<usize>,
31 ) -> Result<String, String> {
32 match session_id {
33 Some(sid) => self.log_read(sid, max_chars.unwrap_or(Self::DEFAULT_MAX_CHARS)),
34 None => self.log_list(limit.unwrap_or(50)),
35 }
36 }
37
38 fn log_read(&self, session_id: &str, max_chars: usize) -> Result<String, String> {
39 let log_dir = self.require_log_dir()?;
40 let path = ContainedPath::child(log_dir, &format!("{session_id}.json"))?;
41 if !path.as_ref().exists() {
42 return Err(format!("Log file not found for session '{session_id}'"));
43 }
44 let raw = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))?;
45
46 if max_chars == 0 || raw.len() <= max_chars {
48 return Ok(raw);
49 }
50
51 let mut doc: serde_json::Value =
53 serde_json::from_str(&raw).map_err(|e| format!("Failed to parse log: {e}"))?;
54
55 let original_rounds = doc
56 .get("transcript")
57 .and_then(|t| t.as_array())
58 .map(|a| a.len())
59 .unwrap_or(0);
60
61 if original_rounds == 0 {
62 return Ok(raw);
64 }
65
66 let transcript = doc
68 .get("transcript")
69 .and_then(|t| t.as_array())
70 .cloned()
71 .unwrap_or_default();
72
73 let mut kept = original_rounds;
74 loop {
75 if kept == 0 {
76 doc["transcript"] = serde_json::json!([]);
78 break;
79 }
80 let slice = &transcript[original_rounds - kept..];
82 doc["transcript"] = serde_json::Value::Array(slice.to_vec());
83 let serialized =
84 serde_json::to_string(&doc).map_err(|e| format!("Failed to serialize: {e}"))?;
85 if serialized.len() <= max_chars {
86 break;
87 }
88 if kept > 8 {
90 kept /= 2;
91 } else {
92 kept -= 1;
93 }
94 }
95
96 let returned_rounds = doc
97 .get("transcript")
98 .and_then(|t| t.as_array())
99 .map(|a| a.len())
100 .unwrap_or(0);
101
102 doc["truncated"] = serde_json::json!(true);
103 doc["original_rounds"] = serde_json::json!(original_rounds);
104 doc["returned_rounds"] = serde_json::json!(returned_rounds);
105
106 serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize: {e}"))
107 }
108
109 pub(super) fn log_list(&self, limit: usize) -> Result<String, String> {
110 let dir = match self.log_config.log_dir.as_deref() {
111 Some(d) if d.is_dir() => d,
112 _ => return Ok(serde_json::json!({ "sessions": [] }).to_string()),
113 };
114
115 let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
116
117 let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
119 .flatten()
120 .filter_map(|entry| {
121 let path = entry.path();
122 let name = path.file_name()?.to_str()?;
123 if !name.ends_with(".json") || name.ends_with(".meta.json") {
125 return None;
126 }
127 let mtime = entry.metadata().ok()?.modified().ok()?;
128 Some((path, mtime))
129 })
130 .collect();
131
132 files.sort_by_key(|b| std::cmp::Reverse(b.1));
134 files.truncate(limit);
135
136 let mut sessions = Vec::new();
137 for (path, _) in &files {
138 let meta_path = path.with_extension("meta.json");
140 let doc: serde_json::Value = if meta_path.exists() {
141 match std::fs::read_to_string(&meta_path)
143 .ok()
144 .and_then(|r| serde_json::from_str(&r).ok())
145 {
146 Some(d) => d,
147 None => continue,
148 }
149 } else {
150 let raw = match std::fs::read_to_string(path) {
152 Ok(r) => r,
153 Err(_) => continue,
154 };
155 match serde_json::from_str::<serde_json::Value>(&raw) {
156 Ok(d) => {
157 let stats = d.get("stats");
158 serde_json::json!({
159 "session_id": d.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
160 "task_hint": d.get("task_hint").and_then(|v| v.as_str()),
161 "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
162 "rounds": stats.and_then(|s| s.get("rounds")),
163 "llm_calls": stats.and_then(|s| s.get("llm_calls")),
164 "notes_count": d.get("notes").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
165 })
166 }
167 Err(_) => continue,
168 }
169 };
170
171 sessions.push(doc);
172 }
173
174 Ok(serde_json::json!({ "sessions": sessions }).to_string())
175 }
176
177 pub fn info(&self) -> String {
181 let mut info = serde_json::json!({
182 "version": env!("CARGO_PKG_VERSION"),
183 "preset_catalog_version": PRESET_CATALOG_VERSION,
184 "log_dir": {
185 "resolved": self.log_config.log_dir.as_ref().map(|p| p.display().to_string()),
186 "source": self.log_config.log_dir_source.to_string(),
187 },
188 "log_enabled": self.log_config.log_enabled,
189 "tracing": if self.log_config.log_dir.is_some() { "file + stderr" } else { "stderr only" },
190 });
191
192 let search_paths_json: Vec<serde_json::Value> = self
194 .search_paths
195 .iter()
196 .map(|sp| {
197 serde_json::json!({
198 "path": sp.path.display().to_string(),
199 "source": sp.source.to_string(),
200 })
201 })
202 .collect();
203 info["search_paths"] = serde_json::json!(search_paths_json);
204
205 let packages = self.log_config.app_dir().packages_dir();
207 if packages.is_dir() {
208 info["packages_dir"] = serde_json::json!(packages.display().to_string());
209 }
210
211 let gh_report = crate::service::gh_credentials::diagnose(self.log_config.app_dir().root());
216 info["gh_credentials"] = serde_json::to_value(&gh_report)
217 .unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() }));
218
219 let app_dir = self.log_config.app_dir();
227 match crate::service::setting::resolve_setting(&app_dir, None, None) {
228 Ok(resolved) => match serde_json::to_value(&resolved) {
229 Ok(v) => {
230 info["settings"] = v;
231 }
232 Err(e) => {
233 info["settings_error"] = serde_json::json!(e.to_string());
234 }
235 },
236 Err(e) => {
237 info["settings_error"] = serde_json::json!(e.to_string());
238 }
239 }
240
241 serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())
242 }
243
244 pub fn stats(
258 &self,
259 strategy_filter: Option<&str>,
260 days: Option<u64>,
261 ) -> Result<String, String> {
262 let dir = match self.log_config.log_dir.as_deref() {
263 Some(d) if d.is_dir() => d,
264 _ => {
265 let card_sinks = algocline_engine::card::subscriber_stats_snapshot();
266 return Ok(serde_json::json!({
267 "total_sessions": 0,
268 "strategies": {},
269 "card_sinks": card_sinks,
270 })
271 .to_string());
272 }
273 };
274
275 let cutoff = days.map(|d| {
276 std::time::SystemTime::now()
277 .duration_since(std::time::UNIX_EPOCH)
278 .unwrap_or_default()
279 .as_millis() as u64
280 - d * 86_400_000
281 });
282
283 let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
284
285 #[derive(Default)]
286 struct StrategyAcc {
287 count: u64,
288 sum_elapsed_ms: u64,
289 sum_llm_calls: u64,
290 sum_rounds: u64,
291 sum_prompt_chars: u64,
292 sum_response_chars: u64,
293 sum_prompt_tokens: u64,
294 sum_response_tokens: u64,
295 }
296
297 let mut acc: std::collections::HashMap<String, StrategyAcc> =
298 std::collections::HashMap::new();
299 let mut total: u64 = 0;
300
301 for entry in entries.flatten() {
302 let path = entry.path();
303 let name = match path.file_name().and_then(|n| n.to_str()) {
304 Some(n) => n.to_string(),
305 None => continue,
306 };
307
308 let doc: serde_json::Value = if name.ends_with(".meta.json") {
310 match std::fs::read_to_string(&path)
311 .ok()
312 .and_then(|r| serde_json::from_str(&r).ok())
313 {
314 Some(d) => d,
315 None => continue,
316 }
317 } else if name.ends_with(".json") && !name.ends_with(".meta.json") {
318 let meta_name =
320 format!("{}.meta.json", name.strip_suffix(".json").unwrap_or(&name));
321 let meta_path = dir.join(meta_name);
322 if meta_path.exists() {
323 continue;
324 }
325 match std::fs::read_to_string(&path)
327 .ok()
328 .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
329 {
330 Some(d) => {
331 let stats = d.get("stats");
332 serde_json::json!({
333 "strategy": d.get("strategy").and_then(|v| v.as_str()),
334 "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
335 "llm_calls": stats.and_then(|s| s.get("llm_calls")),
336 "rounds": stats.and_then(|s| s.get("rounds")),
337 "total_prompt_chars": stats.and_then(|s| s.get("total_prompt_chars")),
338 "total_response_chars": stats.and_then(|s| s.get("total_response_chars")),
339 })
340 }
341 None => continue,
342 }
343 } else {
344 continue;
345 };
346
347 if let Some(cutoff_ms) = cutoff {
350 let mtime = entry
351 .metadata()
352 .ok()
353 .and_then(|m| m.modified().ok())
354 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
355 .map(|d| d.as_millis() as u64)
356 .unwrap_or(0);
357 if mtime < cutoff_ms {
358 continue;
359 }
360 }
361
362 let strat = doc
363 .get("strategy")
364 .and_then(|v| v.as_str())
365 .unwrap_or("unknown")
366 .to_string();
367
368 if let Some(filter) = strategy_filter {
370 if strat != filter {
371 continue;
372 }
373 }
374
375 let elapsed = doc.get("elapsed_ms").and_then(|v| v.as_u64()).unwrap_or(0);
376 let llm = doc.get("llm_calls").and_then(|v| v.as_u64()).unwrap_or(0);
377 let rounds = doc.get("rounds").and_then(|v| v.as_u64()).unwrap_or(0);
378 let prompt_chars = doc
379 .get("total_prompt_chars")
380 .and_then(|v| v.as_u64())
381 .unwrap_or(0);
382 let response_chars = doc
383 .get("total_response_chars")
384 .and_then(|v| v.as_u64())
385 .unwrap_or(0);
386
387 let prompt_tokens = doc
389 .get("prompt_tokens")
390 .and_then(|v| v.get("tokens"))
391 .and_then(|v| v.as_u64())
392 .unwrap_or(0);
393 let response_tokens = doc
394 .get("response_tokens")
395 .and_then(|v| v.get("tokens"))
396 .and_then(|v| v.as_u64())
397 .unwrap_or(0);
398
399 let a = acc.entry(strat).or_default();
400 a.count += 1;
401 a.sum_elapsed_ms += elapsed;
402 a.sum_llm_calls += llm;
403 a.sum_rounds += rounds;
404 a.sum_prompt_chars += prompt_chars;
405 a.sum_response_chars += response_chars;
406 a.sum_prompt_tokens += prompt_tokens;
407 a.sum_response_tokens += response_tokens;
408 total += 1;
409 }
410
411 let mut strategies = serde_json::Map::new();
413 for (strat, a) in &acc {
414 let c = a.count.max(1); strategies.insert(
416 strat.clone(),
417 serde_json::json!({
418 "count": a.count,
419 "avg_elapsed_ms": (a.sum_elapsed_ms + c / 2) / c,
420 "avg_llm_calls": (a.sum_llm_calls + c / 2) / c,
421 "avg_rounds": (a.sum_rounds + c / 2) / c,
422 "total_prompt_chars": a.sum_prompt_chars,
423 "total_response_chars": a.sum_response_chars,
424 "total_prompt_tokens": a.sum_prompt_tokens,
425 "total_response_tokens": a.sum_response_tokens,
426 "total_tokens": a.sum_prompt_tokens + a.sum_response_tokens,
427 }),
428 );
429 }
430
431 let card_sinks = algocline_engine::card::subscriber_stats_snapshot();
432 Ok(serde_json::json!({
433 "total_sessions": total,
434 "strategies": strategies,
435 "card_sinks": card_sinks,
436 })
437 .to_string())
438 }
439}