Skip to main content

opensession_summary/
provider.rs

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}