1use opensession_runtime_config::{
2 SummaryOutputShape, SummaryProvider, SummaryResponseStyle, SummarySettings,
3};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::PathBuf;
7use std::process::{Command, Output, Stdio};
8use std::thread;
9use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10
11const DEFAULT_OLLAMA_ENDPOINT: &str = "http://127.0.0.1:11434";
12const DEFAULT_SUMMARY_CHAR_LIMIT: usize = 560;
13const DEFAULT_AUTH_SECURITY_CHAR_LIMIT: usize = 320;
14const DEFAULT_LAYER_SUMMARY_CHAR_LIMIT: usize = 260;
15const DEFAULT_MAX_LAYER_ITEMS: usize = 10;
16const DEFAULT_MAX_FILES_PER_LAYER: usize = 14;
17
18#[derive(Debug, Clone, Copy)]
19struct SummaryNormalizationLimits {
20 summary_chars: usize,
21 auth_security_chars: usize,
22 layer_summary_chars: usize,
23 max_layer_items: usize,
24 max_files_per_layer: usize,
25}
26
27fn summary_limits(settings: &SummarySettings) -> SummaryNormalizationLimits {
28 let (summary_chars, auth_security_chars, layer_summary_chars) = match settings.response.style {
29 SummaryResponseStyle::Compact => (280, 160, 120),
30 SummaryResponseStyle::Standard => (
31 DEFAULT_SUMMARY_CHAR_LIMIT,
32 DEFAULT_AUTH_SECURITY_CHAR_LIMIT,
33 DEFAULT_LAYER_SUMMARY_CHAR_LIMIT,
34 ),
35 SummaryResponseStyle::Detailed => (960, 520, 360),
36 };
37 let (max_layer_items, max_files_per_layer) = match settings.response.shape {
38 SummaryOutputShape::Layered => (DEFAULT_MAX_LAYER_ITEMS, DEFAULT_MAX_FILES_PER_LAYER),
39 SummaryOutputShape::FileList => (16, 20),
40 SummaryOutputShape::SecurityFirst => (12, 14),
41 };
42 SummaryNormalizationLimits {
43 summary_chars,
44 auth_security_chars,
45 layer_summary_chars,
46 max_layer_items,
47 max_files_per_layer,
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct LocalSummaryProfile {
53 pub provider: SummaryProvider,
54 pub endpoint: String,
55 pub model: String,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct SemanticSummary {
60 pub changes: String,
61 pub auth_security: String,
62 #[serde(default)]
63 pub layer_file_changes: Vec<LayerFileChange>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct LayerFileChange {
68 pub layer: String,
69 pub summary: String,
70 #[serde(default)]
71 pub files: Vec<String>,
72}
73
74impl SemanticSummary {
75 fn normalize(mut self, limits: SummaryNormalizationLimits) -> Self {
76 self.changes = normalize_summary_text_with_limit(&self.changes, limits.summary_chars);
77 self.auth_security =
78 normalize_summary_text_with_limit(&self.auth_security, limits.auth_security_chars);
79 if self.auth_security.is_empty() {
80 self.auth_security = "none detected".to_string();
81 }
82
83 self.layer_file_changes = self
84 .layer_file_changes
85 .into_iter()
86 .filter_map(|item| {
87 let layer = normalize_summary_text_with_limit(&item.layer, 40);
88 if layer.is_empty() {
89 return None;
90 }
91 let summary =
92 normalize_summary_text_with_limit(&item.summary, limits.layer_summary_chars);
93 let mut files = item
94 .files
95 .into_iter()
96 .map(|file| normalize_summary_text_with_limit(&file, 120))
97 .filter(|file| !file.is_empty())
98 .collect::<Vec<_>>();
99 files.sort();
100 files.dedup();
101 files.truncate(limits.max_files_per_layer);
102
103 Some(LayerFileChange {
104 layer,
105 summary,
106 files,
107 })
108 })
109 .collect();
110 self.layer_file_changes
111 .sort_by(|lhs, rhs| lhs.layer.cmp(&rhs.layer));
112 self.layer_file_changes.truncate(limits.max_layer_items);
113 self
114 }
115
116 fn from_plain_fallback(text: &str, limits: SummaryNormalizationLimits) -> Self {
117 Self {
118 changes: normalize_summary_text_with_limit(text, limits.summary_chars),
119 auth_security: "none detected".to_string(),
120 layer_file_changes: Vec::new(),
121 }
122 .normalize(limits)
123 }
124}
125
126pub fn detect_local_summary_profile() -> Option<LocalSummaryProfile> {
127 detect_ollama_profile()
128 .or_else(detect_codex_exec_profile)
129 .or_else(detect_claude_cli_profile)
130}
131
132fn detect_ollama_profile() -> Option<LocalSummaryProfile> {
133 let output = Command::new("ollama").arg("list").output().ok()?;
134 if !output.status.success() {
135 return None;
136 }
137 let stdout = String::from_utf8_lossy(&output.stdout);
138 let model = parse_ollama_list_output(&stdout).into_iter().next()?;
139 Some(LocalSummaryProfile {
140 provider: SummaryProvider::Ollama,
141 endpoint: DEFAULT_OLLAMA_ENDPOINT.to_string(),
142 model,
143 })
144}
145
146fn detect_codex_exec_profile() -> Option<LocalSummaryProfile> {
147 if !command_available("codex", &["exec", "--help"]) {
148 return None;
149 }
150 Some(LocalSummaryProfile {
151 provider: SummaryProvider::CodexExec,
152 endpoint: String::new(),
153 model: String::new(),
154 })
155}
156
157fn detect_claude_cli_profile() -> Option<LocalSummaryProfile> {
158 if !command_available("claude", &["--help"]) {
159 return None;
160 }
161 Some(LocalSummaryProfile {
162 provider: SummaryProvider::ClaudeCli,
163 endpoint: String::new(),
164 model: String::new(),
165 })
166}
167
168fn command_available(program: &str, args: &[&str]) -> bool {
169 Command::new(program)
170 .args(args)
171 .stdout(Stdio::null())
172 .stderr(Stdio::null())
173 .status()
174 .map(|status| status.success())
175 .unwrap_or(false)
176}
177
178fn parse_ollama_list_output(raw: &str) -> Vec<String> {
179 let mut models = Vec::new();
180 for line in raw.lines() {
181 let trimmed = line.trim();
182 if trimmed.is_empty() {
183 continue;
184 }
185 let lowered = trimmed.to_ascii_lowercase();
186 if lowered.starts_with("name ")
187 || lowered.starts_with("error")
188 || lowered.starts_with("failed")
189 {
190 continue;
191 }
192 let Some(token) = trimmed.split_whitespace().next() else {
193 continue;
194 };
195 let candidate = token.trim().to_string();
196 if candidate.is_empty() || models.contains(&candidate) {
197 continue;
198 }
199 models.push(candidate);
200 }
201 models
202}
203
204#[derive(Debug, Serialize)]
205struct OllamaGenerateRequest<'a> {
206 model: &'a str,
207 prompt: &'a str,
208 stream: bool,
209}
210
211#[derive(Debug, Deserialize)]
212struct OllamaGenerateResponse {
213 response: String,
214}
215
216pub async fn generate_summary(
217 settings: &SummarySettings,
218 prompt: &str,
219) -> Result<SemanticSummary, String> {
220 let raw = generate_text(settings, prompt).await?;
221 Ok(parse_semantic_summary_or_fallback(&raw, settings))
222}
223
224pub async fn generate_text(settings: &SummarySettings, prompt: &str) -> Result<String, String> {
225 if prompt.trim().is_empty() {
226 return Err("summary prompt is empty".to_string());
227 }
228 if !settings.is_configured() {
229 return Err("local summary provider is not configured".to_string());
230 }
231
232 match settings.provider.id {
233 SummaryProvider::Disabled => Err("local summary provider is disabled".to_string()),
234 SummaryProvider::Ollama => generate_text_with_ollama(settings, prompt).await,
235 SummaryProvider::CodexExec => generate_text_with_codex_exec(settings, prompt).await,
236 SummaryProvider::ClaudeCli => generate_text_with_claude_cli(settings, prompt).await,
237 }
238}
239
240async fn generate_text_with_ollama(
241 settings: &SummarySettings,
242 prompt: &str,
243) -> Result<String, String> {
244 let endpoint = if settings.provider.endpoint.trim().is_empty() {
245 DEFAULT_OLLAMA_ENDPOINT
246 } else {
247 settings.provider.endpoint.trim()
248 };
249 let url = format!("{}/api/generate", endpoint.trim_end_matches('/'));
250 let model = settings.provider.model.trim();
251 if model.is_empty() {
252 return Err("ollama model is empty".to_string());
253 }
254
255 let client = reqwest::Client::builder()
256 .connect_timeout(Duration::from_secs(2))
257 .timeout(Duration::from_secs(20))
258 .build()
259 .map_err(|err| format!("failed to build local summary HTTP client: {err}"))?;
260
261 let response = client
262 .post(url)
263 .json(&OllamaGenerateRequest {
264 model,
265 prompt,
266 stream: false,
267 })
268 .send()
269 .await
270 .map_err(|err| format!("failed to call ollama summary API: {err}"))?;
271
272 if !response.status().is_success() {
273 let status = response.status().as_u16();
274 let body = response.text().await.unwrap_or_default();
275 return Err(format!(
276 "ollama summary API returned {status}: {}",
277 body.trim()
278 ));
279 }
280
281 let payload: OllamaGenerateResponse = response
282 .json()
283 .await
284 .map_err(|err| format!("failed to decode ollama summary response: {err}"))?;
285 if payload.response.trim().is_empty() {
286 return Err("ollama summary response was empty".to_string());
287 }
288
289 Ok(payload.response)
290}
291
292async fn generate_text_with_codex_exec(
293 settings: &SummarySettings,
294 prompt: &str,
295) -> Result<String, String> {
296 let output_path = temp_cli_output_path("codex-summary");
297
298 let mut command = Command::new("codex");
299 command
300 .arg("exec")
301 .arg("--skip-git-repo-check")
302 .arg("--sandbox")
303 .arg("read-only")
304 .arg("--output-last-message")
305 .arg(output_path.to_string_lossy().to_string());
306 let model = settings.provider.model.trim();
307 if !model.is_empty() {
308 command.arg("--model").arg(model);
309 }
310 command.arg(prompt);
311
312 let output = run_command_with_timeout(command, Duration::from_secs(60))
313 .map_err(|err| format!("failed to run codex exec summary: {err}"))?;
314
315 let response = read_output_or_stdout(&output_path, &output);
316 if response.trim().is_empty() {
317 return Err("codex exec summary response was empty".to_string());
318 }
319 Ok(response)
320}
321
322async fn generate_text_with_claude_cli(
323 settings: &SummarySettings,
324 prompt: &str,
325) -> Result<String, String> {
326 let model = settings.provider.model.trim().to_string();
327 let timeout = Duration::from_secs(60);
328
329 let mut command = Command::new("claude");
330 command.arg("-c");
331 if !model.is_empty() {
332 command.arg("--model").arg(&model);
333 }
334 command.arg(prompt);
335
336 let output = match run_command_with_timeout(command, timeout) {
337 Ok(output) => output,
338 Err(primary_error) => {
339 let mut fallback = Command::new("claude");
340 fallback
341 .arg("--print")
342 .arg("--output-format")
343 .arg("text")
344 .arg("--no-session-persistence")
345 .arg("--tools")
346 .arg("");
347 if !model.is_empty() {
348 fallback.arg("--model").arg(&model);
349 }
350 fallback.arg(prompt);
351
352 run_command_with_timeout(fallback, timeout).map_err(|fallback_error| {
353 format!(
354 "failed to run claude summary (`claude -c` => {primary_error}; fallback => {fallback_error})"
355 )
356 })?
357 }
358 };
359
360 let response = String::from_utf8_lossy(&output.stdout).to_string();
361 if response.trim().is_empty() {
362 return Err("claude summary response was empty".to_string());
363 }
364 Ok(response)
365}
366
367fn parse_semantic_summary_or_fallback(raw: &str, settings: &SummarySettings) -> SemanticSummary {
368 let limits = summary_limits(settings);
369 match parse_semantic_summary(raw) {
370 Ok(summary) => summary.normalize(limits),
371 Err(_) => SemanticSummary::from_plain_fallback(raw, limits),
372 }
373}
374
375fn read_output_or_stdout(path: &PathBuf, output: &Output) -> String {
376 let file_text = fs::read_to_string(path).unwrap_or_default();
377 let _ = fs::remove_file(path);
378 if !file_text.trim().is_empty() {
379 return file_text;
380 }
381 String::from_utf8_lossy(&output.stdout).to_string()
382}
383
384fn temp_cli_output_path(prefix: &str) -> PathBuf {
385 let pid = std::process::id();
386 let timestamp = SystemTime::now()
387 .duration_since(UNIX_EPOCH)
388 .map(|duration| duration.as_nanos())
389 .unwrap_or(0);
390 std::env::temp_dir().join(format!("{prefix}-{pid}-{timestamp}.txt"))
391}
392
393fn run_command_with_timeout(mut command: Command, timeout: Duration) -> Result<Output, String> {
394 command.stdout(Stdio::piped()).stderr(Stdio::piped());
395
396 let program = command.get_program().to_string_lossy().to_string();
397 let mut child = command
398 .spawn()
399 .map_err(|err| format!("failed to spawn `{program}`: {err}"))?;
400 let started = Instant::now();
401
402 loop {
403 match child.try_wait() {
404 Ok(Some(_status)) => {
405 let output = child
406 .wait_with_output()
407 .map_err(|err| format!("failed to collect `{program}` output: {err}"))?;
408 if output.status.success() {
409 return Ok(output);
410 }
411 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
412 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
413 let detail = if !stderr.is_empty() {
414 stderr
415 } else if !stdout.is_empty() {
416 stdout
417 } else {
418 format!("exit status {}", output.status)
419 };
420 return Err(format!("`{program}` failed: {detail}"));
421 }
422 Ok(None) => {
423 if started.elapsed() >= timeout {
424 let _ = child.kill();
425 let _ = child.wait();
426 return Err(format!(
427 "`{program}` timed out after {}s",
428 timeout.as_secs()
429 ));
430 }
431 thread::sleep(Duration::from_millis(50));
432 }
433 Err(err) => {
434 return Err(format!("failed while waiting for `{program}`: {err}"));
435 }
436 }
437 }
438}
439
440#[cfg(test)]
441fn normalize_summary_text(raw: &str) -> String {
442 normalize_summary_text_with_limit(raw, DEFAULT_SUMMARY_CHAR_LIMIT)
443}
444
445fn normalize_summary_text_with_limit(raw: &str, limit: usize) -> String {
446 let compact = raw.split_whitespace().collect::<Vec<_>>().join(" ");
447 if compact.chars().count() <= limit {
448 return compact;
449 }
450 let mut out = String::new();
451 for ch in compact.chars().take(limit.saturating_sub(1)) {
452 out.push(ch);
453 }
454 out.push('…');
455 out
456}
457
458fn parse_semantic_summary(raw: &str) -> Result<SemanticSummary, String> {
459 let trimmed = raw.trim();
460 if trimmed.is_empty() {
461 return Err("empty summary payload".to_string());
462 }
463
464 if let Ok(parsed) = serde_json::from_str::<SemanticSummary>(trimmed) {
465 return Ok(parsed);
466 }
467
468 if let Some(json_block) = strip_markdown_json_fence(trimmed) {
469 if let Ok(parsed) = serde_json::from_str::<SemanticSummary>(&json_block) {
470 return Ok(parsed);
471 }
472 }
473
474 if let Some(object_slice) = find_json_object_slice(trimmed) {
475 if let Ok(parsed) = serde_json::from_str::<SemanticSummary>(object_slice) {
476 return Ok(parsed);
477 }
478 }
479
480 Err("failed to parse semantic summary JSON".to_string())
481}
482
483fn strip_markdown_json_fence(raw: &str) -> Option<String> {
484 let trimmed = raw.trim();
485 if !trimmed.starts_with("```") {
486 return None;
487 }
488 let mut lines = trimmed.lines();
489 let first = lines.next()?.trim().to_ascii_lowercase();
490 if !(first == "```json" || first == "```") {
491 return None;
492 }
493 let remaining = lines.collect::<Vec<_>>().join("\n");
494 let end_idx = remaining.rfind("```")?;
495 Some(remaining[..end_idx].trim().to_string())
496}
497
498fn find_json_object_slice(raw: &str) -> Option<&str> {
499 let start = raw.find('{')?;
500 let end = raw.rfind('}')?;
501 if end <= start {
502 return None;
503 }
504 Some(raw[start..=end].trim())
505}
506
507#[cfg(test)]
508mod tests {
509 use super::{
510 normalize_summary_text, parse_ollama_list_output, parse_semantic_summary,
511 parse_semantic_summary_or_fallback, SemanticSummary,
512 };
513 use opensession_runtime_config::{SummaryOutputShape, SummaryResponseStyle, SummarySettings};
514
515 #[test]
516 fn parse_ollama_list_output_extracts_model_names() {
517 let output = r#"
518NAME ID SIZE MODIFIED
519llama3.2:3b a80c4f17acd5 2.0 GB 3 hours ago
520qwen2.5-coder:7b 2b0496514337 4.7 GB 1 day ago
521"#;
522
523 let models = parse_ollama_list_output(output);
524 assert_eq!(
525 models,
526 vec!["llama3.2:3b".to_string(), "qwen2.5-coder:7b".to_string()]
527 );
528 }
529
530 #[test]
531 fn parse_ollama_list_output_ignores_errors_and_empty_lines() {
532 let output = "\nError: could not connect to ollama\n";
533 assert!(parse_ollama_list_output(output).is_empty());
534 }
535
536 #[test]
537 fn normalize_summary_text_collapses_whitespace_and_limits_length() {
538 let raw = " fixed setup flow\nand added summary cache ";
539 assert_eq!(
540 normalize_summary_text(raw),
541 "fixed setup flow and added summary cache"
542 );
543 }
544
545 #[test]
546 fn parse_semantic_summary_accepts_plain_json() {
547 let raw = r#"{
548 "changes": "Updated session summary pipeline",
549 "auth_security": "none detected",
550 "layer_file_changes": [
551 {"layer":"application","summary":"Added queue handling","files":["crates/summary/src/lib.rs"]}
552 ]
553}"#;
554
555 let parsed = parse_semantic_summary(raw).expect("parse semantic summary");
556 assert_eq!(parsed.changes, "Updated session summary pipeline");
557 assert_eq!(parsed.auth_security, "none detected");
558 assert_eq!(parsed.layer_file_changes.len(), 1);
559 assert_eq!(parsed.layer_file_changes[0].layer, "application");
560 }
561
562 #[test]
563 fn parse_semantic_summary_accepts_markdown_code_fence() {
564 let raw = r#"```json
565{"changes":"c","auth_security":"none","layer_file_changes":[]}
566```"#;
567 let parsed = parse_semantic_summary(raw).expect("parse fenced semantic summary");
568 assert_eq!(
569 parsed,
570 SemanticSummary {
571 changes: "c".to_string(),
572 auth_security: "none".to_string(),
573 layer_file_changes: Vec::new()
574 }
575 );
576 }
577
578 #[test]
579 fn parse_semantic_summary_fallback_preserves_plain_text_changes() {
580 let parsed = parse_semantic_summary_or_fallback(
581 "updated auth token handling",
582 &SummarySettings::default(),
583 );
584 assert_eq!(parsed.changes, "updated auth token handling");
585 assert_eq!(parsed.auth_security, "none detected");
586 assert!(parsed.layer_file_changes.is_empty());
587 }
588
589 #[test]
590 fn parse_semantic_summary_fallback_applies_compact_style_limits() {
591 let mut settings = SummarySettings::default();
592 settings.response.style = SummaryResponseStyle::Compact;
593 let parsed = parse_semantic_summary_or_fallback(&"x".repeat(400), &settings);
594 assert!(parsed.changes.chars().count() <= 280);
595 }
596
597 #[test]
598 fn parse_semantic_summary_fallback_applies_file_list_shape_limits() {
599 let mut settings = SummarySettings::default();
600 settings.response.shape = SummaryOutputShape::FileList;
601 let payload = r#"{
602 "changes":"summary",
603 "auth_security":"none detected",
604 "layer_file_changes":[
605 {"layer":"application","summary":"changed","files":[
606 "f01","f02","f03","f04","f05","f06","f07","f08","f09","f10",
607 "f11","f12","f13","f14","f15","f16","f17","f18","f19","f20","f21"
608 ]}
609 ]
610}"#;
611 let parsed = parse_semantic_summary_or_fallback(payload, &settings);
612 assert_eq!(parsed.layer_file_changes.len(), 1);
613 assert_eq!(parsed.layer_file_changes[0].files.len(), 20);
614 }
615}