Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:52:05.154Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

synthesize.rs

970 lines · 32.7 KB · rust
1use crate::{
2    anthropic_client::PlainLlmClient,
3    git::{ensure_repo_cloned, run_git},
4    paths::{FAILED_EXAMPLES_DIR, LATEST_FAILED_EXAMPLES_DIR, SYNTHESIZE_STATE_FILE},
5    progress::{InfoStyle, Progress, Step, StepProgress},
6};
7use anthropic::ResponseContent;
8use anyhow::{Context as _, Result};
9use chrono::Local;
10use collections::{HashMap, HashSet};
11use edit_prediction::{
12    data_collection::format_cursor_excerpt,
13    example_spec::ExampleSpec,
14    udiff::{apply_diff_to_string, edits_for_diff},
15};
16use futures::stream::{FuturesUnordered, StreamExt};
17use indoc::indoc;
18use serde::{Deserialize, Serialize};
19use std::{
20    path::{Path, PathBuf},
21    sync::Arc,
22};
23
24#[derive(Debug, Clone)]
25pub struct SynthesizeConfig {
26    pub repo_urls: Vec<String>,
27    /// Number of examples to generate per repository
28    pub count: usize,
29    pub max_commits: usize,
30    pub output_dir: PathBuf,
31    pub fresh: bool,
32}
33
34#[derive(Debug, Default, Serialize, Deserialize)]
35struct SynthesizeState {
36    repositories: HashMap<String, RepoState>,
37}
38
39#[derive(Debug, Default, Serialize, Deserialize)]
40struct RepoState {
41    processed_commits: HashSet<String>,
42    examples_generated: usize,
43}
44
45impl SynthesizeState {
46    fn load() -> Self {
47        if SYNTHESIZE_STATE_FILE.exists() {
48            std::fs::read_to_string(&*SYNTHESIZE_STATE_FILE)
49                .ok()
50                .and_then(|s| serde_json::from_str(&s).ok())
51                .unwrap_or_default()
52        } else {
53            Self::default()
54        }
55    }
56
57    fn save(&self) -> Result<()> {
58        let content = serde_json::to_string_pretty(self)?;
59        std::fs::write(&*SYNTHESIZE_STATE_FILE, content)?;
60        Ok(())
61    }
62
63    fn take_repo_state(&mut self, repo_url: &str) -> RepoState {
64        self.repositories.remove(repo_url).unwrap_or_default()
65    }
66
67    fn merge_repo_state(&mut self, repo_url: String, repo_state: RepoState) {
68        self.repositories.insert(repo_url, repo_state);
69    }
70}
71
72impl RepoState {
73    fn is_processed(&self, commit_sha: &str) -> bool {
74        self.processed_commits.contains(commit_sha)
75    }
76
77    fn mark_processed(&mut self, commit_sha: &str, examples_count: usize) {
78        self.processed_commits.insert(commit_sha.to_string());
79        self.examples_generated += examples_count;
80    }
81}
82
83#[derive(Debug)]
84struct CommitInfo {
85    sha: String,
86    parent_sha: String,
87    message: String,
88    diff: String,
89    expanded_diff: String,
90}
91
92/// Claude's response parsed into structured form
93#[derive(Debug)]
94struct ClaudeResponse {
95    name: String,
96    reasoning: String,
97    edit_history_hunks: Vec<String>,
98    expected_patch_hunks: Vec<String>,
99}
100
101pub async fn run_synthesize(config: SynthesizeConfig) -> Result<()> {
102    let mut state = if config.fresh {
103        SynthesizeState::default()
104    } else {
105        SynthesizeState::load()
106    };
107
108    std::fs::create_dir_all(&config.output_dir)?;
109    std::fs::create_dir_all(&*FAILED_EXAMPLES_DIR)?;
110
111    // Create "latest_failed" symlink pointing to this run's failed directory
112    if LATEST_FAILED_EXAMPLES_DIR.is_symlink() {
113        std::fs::remove_file(&*LATEST_FAILED_EXAMPLES_DIR)?;
114    }
115    #[cfg(unix)]
116    std::os::unix::fs::symlink(&*FAILED_EXAMPLES_DIR, &*LATEST_FAILED_EXAMPLES_DIR)?;
117    #[cfg(windows)]
118    std::os::windows::fs::symlink_dir(&*FAILED_EXAMPLES_DIR, &*LATEST_FAILED_EXAMPLES_DIR)?;
119
120    let progress = Progress::global();
121    let total_examples = config.count * config.repo_urls.len();
122    progress.set_total_examples(total_examples);
123
124    let client = Arc::new(PlainLlmClient::new()?);
125    let config = Arc::new(config);
126
127    let mut futures: FuturesUnordered<_> = config
128        .repo_urls
129        .iter()
130        .map(|repo_url| {
131            let client = client.clone();
132            let repo_state = state.take_repo_state(repo_url);
133            let config = config.clone();
134            let repo_url = repo_url.clone();
135            async move {
136                let result = synthesize_repo(&client, repo_state, &config, &repo_url).await;
137                (repo_url, result)
138            }
139        })
140        .collect();
141
142    let mut errors = Vec::new();
143    while let Some((repo_url, result)) = futures.next().await {
144        match result {
145            Ok(repo_state) => {
146                state.merge_repo_state(repo_url, repo_state);
147            }
148            Err(e) => {
149                errors.push(e);
150            }
151        }
152    }
153
154    state.save()?;
155
156    progress.finalize();
157
158    if let Some(first_error) = errors.into_iter().next() {
159        return Err(first_error);
160    }
161
162    Ok(())
163}
164
165async fn synthesize_repo(
166    client: &PlainLlmClient,
167    mut repo_state: RepoState,
168    config: &SynthesizeConfig,
169    repo_url: &str,
170) -> Result<RepoState> {
171    let progress = Progress::global();
172    let batch_size = config.max_commits;
173
174    let clone_progress = progress.start(Step::Synthesize, &format!("clone {}", repo_url));
175    let repo_path = ensure_repo_cloned(repo_url).await?;
176    drop(clone_progress);
177
178    let mut examples_generated = 0;
179    let mut commits_skipped = 0;
180
181    'outer: loop {
182        let list_progress = progress.start(
183            Step::Synthesize,
184            &format!("{}: list-commits", repo_name_from_url(repo_url)),
185        );
186        let commits = list_commits(&repo_path, batch_size, commits_skipped).await?;
187        drop(list_progress);
188
189        if commits.is_empty() {
190            break;
191        }
192
193        commits_skipped += commits.len();
194
195        for commit in commits {
196            if examples_generated >= config.count {
197                break 'outer;
198            }
199
200            if !config.fresh && repo_state.is_processed(&commit.sha) {
201                continue;
202            }
203
204            if should_skip_commit(&commit) {
205                continue;
206            }
207
208            let repo_name = repo_name_from_url(repo_url);
209            let commit_label = format!(
210                "{}: {} {}",
211                repo_name,
212                &commit.sha[..8],
213                truncate_message(&commit.message, 40)
214            );
215            let step_progress = Arc::new(progress.start(Step::Synthesize, &commit_label));
216
217            // Single Claude call to identify and copy hunks
218            step_progress.set_substatus("analyzing...");
219            let claude_response =
220                match analyze_commit(client, repo_url, &commit, step_progress.clone()).await {
221                    Ok(Some(response)) => response,
222                    Ok(None) => {
223                        step_progress.set_info("no pattern", InfoStyle::Normal);
224                        repo_state.mark_processed(&commit.sha, 0);
225                        continue;
226                    }
227                    Err(e) => {
228                        step_progress.set_info(format!("error: {:?}", e), InfoStyle::Warning);
229                        repo_state.mark_processed(&commit.sha, 0);
230                        continue;
231                    }
232                };
233
234            // Validate and build the example
235            step_progress.set_substatus("validating...");
236            match build_example(repo_url, &commit, &repo_path, &claude_response).await {
237                Ok(spec) => {
238                    let timestamp = Local::now().format("%Y-%m-%d--%H-%M-%S");
239                    let filename = format!("{}--{}.md", repo_name, timestamp);
240                    let path = config.output_dir.join(&filename);
241                    std::fs::write(&path, spec.to_markdown())?;
242                    examples_generated += 1;
243                    step_progress.set_info(filename, InfoStyle::Normal);
244                }
245                Err(rejection_reason) => {
246                    log::debug!("Example rejected: {}", rejection_reason);
247                    let timestamp = Local::now().format("%Y-%m-%d--%H-%M-%S%.3f");
248                    let filename = format!("{}--{}.md", repo_name, timestamp);
249                    let path = FAILED_EXAMPLES_DIR.join(&filename);
250                    let content = format_rejected_example(&claude_response, &rejection_reason);
251                    if let Err(e) = std::fs::write(&path, content) {
252                        log::warn!("Failed to write rejected example: {:?}", e);
253                    }
254                    step_progress.set_info(format!("rejected: {}", filename), InfoStyle::Warning);
255                }
256            }
257
258            repo_state.mark_processed(&commit.sha, 1);
259        }
260    }
261
262    Ok(repo_state)
263}
264
265fn repo_name_from_url(url: &str) -> String {
266    url.rsplit('/')
267        .next()
268        .unwrap_or(url)
269        .trim_end_matches(".git")
270        .to_string()
271}
272
273fn truncate_message(msg: &str, max_len: usize) -> String {
274    let first_line = msg.lines().next().unwrap_or("");
275    if first_line.len() <= max_len {
276        first_line.to_string()
277    } else {
278        format!("{}...", &first_line[..max_len - 3])
279    }
280}
281
282fn should_skip_commit(commit: &CommitInfo) -> bool {
283    let lines_changed = commit
284        .diff
285        .lines()
286        .filter(|l| l.starts_with('+') || l.starts_with('-'))
287        .count();
288    lines_changed < 30
289        || lines_changed > 1000
290        || is_non_code_commit(commit)
291        || is_rename_commit(commit)
292}
293
294fn is_non_code_commit(commit: &CommitInfo) -> bool {
295    let non_code_extensions = [
296        ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".lock", ".svg", ".png", ".jpg", ".gif",
297        ".ico", ".woff", ".ttf", ".eot",
298    ];
299
300    let diff_files: Vec<&str> = commit
301        .diff
302        .lines()
303        .filter(|l| l.starts_with("+++ b/") || l.starts_with("--- a/"))
304        .filter_map(|l| {
305            l.strip_prefix("+++ b/")
306                .or_else(|| l.strip_prefix("--- a/"))
307        })
308        .collect();
309
310    if diff_files.is_empty() {
311        return false;
312    }
313
314    diff_files
315        .iter()
316        .all(|f| non_code_extensions.iter().any(|ext| f.ends_with(ext)))
317}
318
319fn is_rename_commit(commit: &CommitInfo) -> bool {
320    commit.diff.contains("similarity index")
321        || commit.diff.contains("rename from")
322        || commit.diff.contains("rename to")
323}
324
325async fn list_commits(
326    repo_path: &Path,
327    max_commits: usize,
328    skip: usize,
329) -> Result<Vec<CommitInfo>> {
330    let output = run_git(
331        repo_path,
332        &[
333            "log",
334            "--no-merges",
335            &format!("--skip={}", skip),
336            &format!("-{}", max_commits),
337            "--format=%H|%P|%s",
338        ],
339    )
340    .await?;
341
342    let mut commits = Vec::new();
343    for line in output.lines() {
344        let parts: Vec<&str> = line.splitn(3, '|').collect();
345        if parts.len() < 3 {
346            continue;
347        }
348        let sha = parts[0].to_string();
349        let parent_sha = parts[1].split_whitespace().next().unwrap_or("").to_string();
350        if parent_sha.is_empty() {
351            continue;
352        }
353
354        // Get standard diff (for skip checks)
355        let diff = run_git(repo_path, &["show", "--format=", &sha])
356            .await
357            .unwrap_or_default();
358
359        // Get expanded diff with 30 lines of context
360        let expanded_diff = run_git(repo_path, &["show", "-U30", "--format=", &sha])
361            .await
362            .unwrap_or_default();
363
364        commits.push(CommitInfo {
365            sha,
366            parent_sha,
367            message: parts[2].to_string(),
368            diff,
369            expanded_diff,
370        });
371    }
372
373    Ok(commits)
374}
375
376fn build_prompt(repo_url: &str, commit: &CommitInfo) -> String {
377    format!(
378        indoc! {r#"
379            You are analyzing a git commit to construct a realistic edit prediction example.
380
381            Your goal is to tell the story of a programmer's editing session: what sequence
382            of changes did they make, and what change logically comes next? We use these examples
383            to train a model to predict edits, so the quality of the EDIT HISTORY is what matters most.
384
385            An edit prediction example consists of:
386            1. **Edit History**: 2-6 hunks showing what the programmer did BEFORE making the expected patch.
387               This is the most important part - it must tell a coherent story of the changes leading up to the prediction.
388            2. **Expected Patch**: One small hunk that logically follows from the edit history.
389
390            Both single-file and multi-file patterns are acceptable.
391
392            ## What Makes a Good Example
393
394            The edit history should read like a story: "First the programmer changed X, then Y, then Z, and now they need to change W."
395
396            GOOD examples (rich sequences with 3+ steps):
397            - Removing a parameter: docstring update → constructor change → field removal → (predict) usage site update
398            - Adding a feature: type definition → first usage → second usage → (predict) third usage
399            - Bug fix pattern: fix in file A → fix in file B → fix in file C → (predict) fix in file D
400
401            BAD examples (respond NO_PATTERN):
402            - Commits where all changes are independent (no narrative thread)
403            - Simple find-and-replace (renaming, version bumps)
404            - Documentation-only or config-only changes
405            - Changes where you can only find 1-2 hunks for the edit history
406
407            ## Commit Information
408
409            Repository: {repo_url}
410            Commit: {sha}
411            Message: {message}
412
413            ## Diff (30 lines context)
414
415            ```diff
416            {expanded_diff}
417            ```
418
419            ## Your Task
420
421            First, THINK through whether this commit can support a good example:
422
423            1. What is the high-level pattern in this commit?
424            2. Can you identify at least 3 related hunks (2 or more for edit history + 1 for expected patch)?
425            3. What would be the narrative? (First... then... then... finally predict...)
426            4. Which specific hunk should be the expected patch (the "punchline")?
427
428            If you cannot construct a coherent 3+ hunk story, respond with just:
429            NO_PATTERN: <brief reason>
430
431            If you CAN construct a good example, respond in this format:
432
433            ANALYSIS:
434            Pattern: <one sentence describing the pattern>
435            Steps:
436            1. <file:line-range> - <what this hunk does>
437            2. <file:line-range> - <what this hunk does>
438            3. <file:line-range> - <what this hunk does>
439            4. [EXPECTED PATCH] <file:line-range> - <what this hunk does>
440
441            NAME: <short description, like a commit message, under 60 chars>
442
443            EDIT_HISTORY:
444
445            Hunk 1:
446            ```diff
447            --- a/src/models/user.py
448            +++ b/src/models/user.py
449            @@ -15,7 +15,6 @@ class User:
450                 """A user in the system.
451
452                 Attributes:
453            -        email: The user's email address.
454                     name: The user's display name.
455                 """
456            ```
457
458            Hunk 2:
459            ```diff
460            --- a/src/models/user.py
461            +++ b/src/models/user.py
462            @@ -25,10 +24,9 @@ class User:
463                 def __init__(
464                     self,
465                     name: str,
466            -        email: str,
467                     created_at: datetime,
468                 ):
469                     self.name = name
470            -        self.email = email
471                     self.created_at = created_at
472            ```
473
474            Hunk 3:
475            ```diff
476            --- a/src/api/handlers.py
477            +++ b/src/api/handlers.py
478            @@ -42,7 +42,6 @@ def create_user(request):
479                 data = request.json()
480                 user = User(
481                     name=data["name"],
482            -        email=data["email"],
483                     created_at=datetime.now(),
484                 )
485                 return user.save()
486            ```
487
488            EXPECTED_PATCH:
489            ```diff
490            --- a/src/api/handlers.py
491            +++ b/src/api/handlers.py
492            @@ -58,7 +57,6 @@ def update_user(request, user_id):
493                 user = User.get(user_id)
494                 user.name = data.get("name", user.name)
495            -    user.email = data.get("email", user.email)
496                 user.save()
497                 return user
498            ```
499
500            ## Requirements for the diffs
501
502            Edit history:
503            - MUST have 3-6 hunks (if you cannot find 3+, respond NO_PATTERN instead)
504            - Each hunk needs file headers (--- a/path and +++ b/path)
505            - Hunks must be valid unified diffs that apply to the parent commit
506            - Order hunks as a programmer would naturally make the changes
507
508            Expected patch:
509            - Must be a SINGLE hunk from a SINGLE file
510            - Must be SMALL: 1-15 changed lines (not counting context)
511            - Must be clearly predictable from the edit history narrative
512        "#},
513        repo_url = repo_url,
514        sha = commit.sha,
515        message = commit.message,
516        expanded_diff = commit.expanded_diff,
517    )
518}
519
520async fn analyze_commit(
521    client: &PlainLlmClient,
522    repo_url: &str,
523    commit: &CommitInfo,
524    step_progress: Arc<StepProgress>,
525) -> Result<Option<ClaudeResponse>> {
526    use anthropic::{Message, RequestContent, Role};
527
528    let prompt = build_prompt(repo_url, commit);
529    let messages = vec![Message {
530        role: Role::User,
531        content: vec![RequestContent::Text {
532            text: prompt,
533            cache_control: None,
534        }],
535    }];
536
537    let response = client
538        .generate_streaming("claude-sonnet-4-5", 8192, messages, |chars, _text| {
539            step_progress.set_substatus(format!("analyzing: {:.1}K", chars as f64 / 1000.0));
540        })
541        .await?;
542
543    // Extract text content from response
544    let response_text: String = response
545        .content
546        .iter()
547        .filter_map(|block| {
548            if let ResponseContent::Text { text } = block {
549                Some(text.as_str())
550            } else {
551                None
552            }
553        })
554        .collect::<Vec<_>>()
555        .join("\n");
556
557    parse_claude_response(&response_text)
558}
559
560fn parse_claude_response(response: &str) -> Result<Option<ClaudeResponse>> {
561    // Check for NO_PATTERN
562    if response.contains("NO_PATTERN:") {
563        return Ok(None);
564    }
565
566    // Parse NAME
567    let name = response
568        .lines()
569        .find(|l| l.starts_with("NAME:"))
570        .map(|l| l.strip_prefix("NAME:").unwrap_or("").trim().to_string())
571        .unwrap_or_else(|| "unnamed example".to_string());
572
573    // Parse ANALYSIS section (Claude's planning) - this is the primary reasoning
574    let reasoning = extract_section(
575        response,
576        "ANALYSIS:",
577        &["NAME:", "REASONING:", "EDIT_HISTORY:", "EXPECTED_PATCH:"],
578    )
579    .unwrap_or_default();
580
581    // Parse EDIT_HISTORY diff block
582    let edit_history_hunks = extract_diff_block(response, "EDIT_HISTORY:")?;
583
584    // Parse EXPECTED_PATCH diff block
585    let expected_patch_hunks = extract_diff_block(response, "EXPECTED_PATCH:")?;
586
587    if edit_history_hunks.is_empty() {
588        anyhow::bail!("No edit history hunks found in response");
589    }
590    if expected_patch_hunks.is_empty() {
591        anyhow::bail!("No expected patch hunks found in response");
592    }
593
594    Ok(Some(ClaudeResponse {
595        name,
596        reasoning,
597        edit_history_hunks,
598        expected_patch_hunks,
599    }))
600}
601
602fn extract_section(text: &str, start_marker: &str, end_markers: &[&str]) -> Option<String> {
603    let start_idx = text.find(start_marker)?;
604    let content_start = start_idx + start_marker.len();
605
606    let end_idx = end_markers
607        .iter()
608        .filter_map(|marker| text[content_start..].find(marker))
609        .min()
610        .map(|idx| content_start + idx)
611        .unwrap_or(text.len());
612
613    Some(text[content_start..end_idx].trim().to_string())
614}
615
616fn extract_diff_block(text: &str, section_marker: &str) -> Result<Vec<String>> {
617    let section_start = text
618        .find(section_marker)
619        .context(format!("Section {} not found", section_marker))?;
620
621    let after_marker = &text[section_start + section_marker.len()..];
622
623    // Find where the next major section starts (to bound our search)
624    let section_end = ["EXPECTED_PATCH:", "## "]
625        .iter()
626        .filter(|&&m| m != section_marker)
627        .filter_map(|marker| after_marker.find(marker))
628        .min()
629        .unwrap_or(after_marker.len());
630
631    let section_content = &after_marker[..section_end];
632
633    // Collect all ```diff blocks in this section
634    let mut hunks = Vec::new();
635    let mut search_start = 0;
636
637    while let Some(diff_start) = section_content[search_start..].find("```diff") {
638        let abs_diff_start = search_start + diff_start;
639        let block_content_start = section_content[abs_diff_start..]
640            .find('\n')
641            .map(|i| abs_diff_start + i + 1)
642            .unwrap_or(abs_diff_start);
643
644        if let Some(block_end_rel) = section_content[block_content_start..].find("```") {
645            let block_end = block_content_start + block_end_rel;
646            let diff_content = section_content[block_content_start..block_end].trim();
647
648            // Split this block into hunks (in case multiple hunks in one block)
649            hunks.extend(split_into_hunks(diff_content));
650
651            search_start = block_end + 3;
652        } else {
653            break;
654        }
655    }
656
657    if hunks.is_empty() {
658        anyhow::bail!("No diff blocks found in section {}", section_marker);
659    }
660
661    Ok(hunks)
662}
663
664/// Split a diff block into individual hunks, preserving file headers
665fn split_into_hunks(diff: &str) -> Vec<String> {
666    let mut hunks = Vec::new();
667    let mut current_file_header: Option<String> = None;
668    let mut current_hunk: Vec<String> = Vec::new();
669    let mut in_hunk = false;
670
671    for line in diff.lines() {
672        if line.starts_with("--- a/") || line.starts_with("--- /") {
673            // Start of file header - flush previous hunk
674            if in_hunk && !current_hunk.is_empty() {
675                let mut hunk_text = String::new();
676                if let Some(ref header) = current_file_header {
677                    hunk_text.push_str(header);
678                    hunk_text.push('\n');
679                }
680                hunk_text.push_str(&current_hunk.join("\n"));
681                hunks.push(hunk_text);
682                current_hunk.clear();
683            }
684            current_file_header = Some(line.to_string());
685            in_hunk = false;
686        } else if line.starts_with("+++ b/") || line.starts_with("+++ /") {
687            if let Some(ref mut header) = current_file_header {
688                header.push('\n');
689                header.push_str(line);
690            }
691        } else if line.starts_with("@@ ") {
692            // New hunk - flush previous
693            if in_hunk && !current_hunk.is_empty() {
694                let mut hunk_text = String::new();
695                if let Some(ref header) = current_file_header {
696                    hunk_text.push_str(header);
697                    hunk_text.push('\n');
698                }
699                hunk_text.push_str(&current_hunk.join("\n"));
700                hunks.push(hunk_text);
701                current_hunk.clear();
702            }
703            current_hunk.push(line.to_string());
704            in_hunk = true;
705        } else if in_hunk {
706            current_hunk.push(line.to_string());
707        }
708    }
709
710    // Flush final hunk
711    if !current_hunk.is_empty() {
712        let mut hunk_text = String::new();
713        if let Some(ref header) = current_file_header {
714            hunk_text.push_str(header);
715            hunk_text.push('\n');
716        }
717        hunk_text.push_str(&current_hunk.join("\n"));
718        hunks.push(hunk_text);
719    }
720
721    hunks
722}
723
724/// Validate Claude's output by applying diffs and build the ExampleSpec
725async fn build_example(
726    repo_url: &str,
727    commit: &CommitInfo,
728    repo_path: &Path,
729    response: &ClaudeResponse,
730) -> Result<ExampleSpec, String> {
731    // Validate expected patch hunks
732    if response.expected_patch_hunks.len() != 1 {
733        return Err(format!(
734            "Expected exactly 1 expected patch hunk, got {}",
735            response.expected_patch_hunks.len()
736        ));
737    }
738
739    // Parse the expected patch to determine cursor file
740    let expected_patch = &response.expected_patch_hunks[0];
741    let cursor_file = extract_file_from_hunk(expected_patch)
742        .ok_or_else(|| "Could not determine file from expected patch".to_string())?;
743
744    // Get the file content before the commit
745    let before_content = run_git(
746        repo_path,
747        &["show", &format!("{}^:{}", commit.sha, cursor_file)],
748    )
749    .await
750    .map_err(|e| format!("Failed to get file content for {}: {}", cursor_file, e))?;
751
752    // Build edit history diff from Claude's hunks
753    let edit_history = response.edit_history_hunks.join("\n");
754
755    // Apply edit history to get intermediate state (validates edit history)
756    let intermediate_state =
757        apply_edit_history_to_content(&before_content, &edit_history, &cursor_file)?;
758
759    // Validate expected patch applies to intermediate state
760    let expected_patch_with_header = ensure_diff_header(expected_patch, &cursor_file);
761    apply_diff_to_string(&expected_patch_with_header, &intermediate_state)
762        .map_err(|e| format!("Expected patch failed to apply: {}", e))?;
763
764    // Find where the expected patch edits would apply in the intermediate state
765    let edits = edits_for_diff(&intermediate_state, &expected_patch_with_header)
766        .map_err(|e| format!("Failed to parse expected patch: {}", e))?;
767    if edits.is_empty() {
768        return Err(
769            "Could not locate expected patch in file (context not found or ambiguous)".to_string(),
770        );
771    }
772
773    // Use the start of the first edit for cursor positioning
774    let cursor_byte_offset = edits[0].0.start;
775
776    // Extract excerpt around the edit location
777    let (excerpt, cursor_offset) = extract_cursor_excerpt(&intermediate_state, cursor_byte_offset)?;
778
779    // Build the ExampleSpec and use set_cursor_excerpt to format with comment marker
780    let comment_prefix = line_comment_prefix(&cursor_file);
781    let reasoning_with_source = format!(
782        "Source commit: {} ({})\n\n{}",
783        commit.sha,
784        truncate_message(&commit.message, 60),
785        response.reasoning
786    );
787    let spec = ExampleSpec {
788        name: response.name.clone(),
789        repository_url: repo_url.to_string(),
790        revision: commit.parent_sha.clone(),
791        tags: Vec::new(),
792        reasoning: Some(reasoning_with_source),
793        uncommitted_diff: String::new(),
794        recently_opened_files: Vec::new(),
795        recently_viewed_files: Vec::new(),
796        uncommitted_diff_contains_edit_history: false,
797        cursor_path: Arc::from(Path::new(&cursor_file)),
798        cursor_position: format_cursor_excerpt(&excerpt, cursor_offset, comment_prefix),
799        edit_history,
800        expected_patches: vec![expected_patch_with_header],
801        rejected_patch: None,
802        telemetry: None,
803        human_feedback: Vec::new(),
804        rating: None,
805    };
806
807    Ok(spec)
808}
809
810/// Extract file path from a hunk (looks for --- a/path or +++ b/path)
811fn extract_file_from_hunk(hunk: &str) -> Option<String> {
812    for line in hunk.lines() {
813        if let Some(path) = line.strip_prefix("+++ b/") {
814            return Some(path.to_string());
815        }
816        if let Some(path) = line.strip_prefix("--- a/") {
817            return Some(path.to_string());
818        }
819    }
820    None
821}
822
823/// Ensure a hunk has proper file headers
824fn ensure_diff_header(hunk: &str, file_path: &str) -> String {
825    if hunk.contains("--- a/") || hunk.contains("+++ b/") {
826        return hunk.to_string();
827    }
828    format!("--- a/{}\n+++ b/{}\n{}", file_path, file_path, hunk)
829}
830
831/// Apply edit history to file content, only if hunks affect this file
832fn apply_edit_history_to_content(
833    content: &str,
834    edit_history: &str,
835    cursor_file: &str,
836) -> Result<String, String> {
837    // Extract just the hunks for this file from the edit history
838    let file_diff = extract_file_diff_from_combined(edit_history, cursor_file);
839
840    if file_diff.is_empty() {
841        return Ok(content.to_string());
842    }
843
844    apply_diff_to_string(&file_diff, content)
845        .map_err(|e| format!("Failed to apply edit history: {}", e))
846}
847
848/// Extract hunks for a specific file from a combined diff
849fn extract_file_diff_from_combined(combined_diff: &str, target_file: &str) -> String {
850    let mut result = String::new();
851    let mut in_target_file = false;
852    let mut found_header = false;
853
854    for line in combined_diff.lines() {
855        if line.starts_with("--- a/") {
856            let file = line.strip_prefix("--- a/").unwrap_or("");
857            in_target_file = file == target_file;
858            if in_target_file {
859                result.push_str(line);
860                result.push('\n');
861                found_header = false;
862            }
863        } else if line.starts_with("+++ b/") && in_target_file {
864            result.push_str(line);
865            result.push('\n');
866            found_header = true;
867        } else if in_target_file && found_header {
868            if line.starts_with("--- a/") {
869                break;
870            }
871            result.push_str(line);
872            result.push('\n');
873        }
874    }
875
876    result
877}
878
879/// Extract a cursor position excerpt from content around a byte offset.
880/// Returns the excerpt and the cursor offset within the excerpt.
881fn extract_cursor_excerpt(
882    content: &str,
883    cursor_byte_offset: usize,
884) -> Result<(String, usize), String> {
885    // Find the line containing the cursor
886    let line_start = content[..cursor_byte_offset]
887        .rfind('\n')
888        .map(|pos| pos + 1)
889        .unwrap_or(0);
890    let line_end = content[cursor_byte_offset..]
891        .find('\n')
892        .map(|pos| cursor_byte_offset + pos)
893        .unwrap_or(content.len());
894
895    // Get context lines before
896    let lines_before: Vec<&str> = content[..line_start].lines().collect();
897    let context_before: Vec<&str> = lines_before.iter().rev().take(3).rev().cloned().collect();
898
899    // Get context lines after
900    let after_line_end = if line_end < content.len() {
901        line_end + 1
902    } else {
903        line_end
904    };
905    let context_after: Vec<&str> = content[after_line_end..].lines().take(4).collect();
906
907    // The line containing the cursor
908    let cursor_line = &content[line_start..line_end];
909    let cursor_column = cursor_byte_offset - line_start;
910
911    // Build the excerpt
912    let mut excerpt = String::new();
913    for line in context_before {
914        excerpt.push_str(line);
915        excerpt.push('\n');
916    }
917    // Track where cursor will be in the excerpt
918    let cursor_offset_in_excerpt = excerpt.len() + cursor_column;
919    // Line containing cursor
920    excerpt.push_str(cursor_line);
921    excerpt.push('\n');
922    for line in context_after {
923        excerpt.push_str(line);
924        excerpt.push('\n');
925    }
926
927    // Trim trailing newline
928    if excerpt.ends_with('\n') {
929        excerpt.pop();
930    }
931
932    Ok((excerpt, cursor_offset_in_excerpt))
933}
934
935/// Get the line comment prefix for a file based on its extension
936fn line_comment_prefix(file_path: &str) -> &'static str {
937    let extension = file_path.rsplit('.').next().unwrap_or("");
938    match extension {
939        "rs" | "c" | "cpp" | "cc" | "h" | "hpp" | "js" | "ts" | "tsx" | "jsx" | "go" | "java"
940        | "swift" | "kt" | "kts" | "scala" | "cs" | "m" | "mm" | "zig" | "v" | "d" => "//",
941        "py" | "rb" | "sh" | "bash" | "zsh" | "pl" | "pm" | "r" | "jl" | "yaml" | "yml"
942        | "toml" | "coffee" | "cr" | "ex" | "exs" | "elixir" => "#",
943        "lua" | "hs" | "sql" => "--",
944        "lisp" | "clj" | "cljs" | "scm" | "rkt" | "el" => ";",
945        "erl" | "hrl" => "%",
946        _ => "//",
947    }
948}
949
950fn format_rejected_example(response: &ClaudeResponse, rejection_reason: &str) -> String {
951    let mut content = String::new();
952    content.push_str("# Rejected Example\n\n");
953    content.push_str(&format!("## Name\n\n{}\n\n", response.name));
954    content.push_str(&format!("## Reasoning\n\n{}\n\n", response.reasoning));
955    content.push_str("## Edit History Hunks\n\n```diff\n");
956    for hunk in &response.edit_history_hunks {
957        content.push_str(hunk);
958        content.push_str("\n\n");
959    }
960    content.push_str("```\n\n");
961    content.push_str("## Expected Patch Hunks\n\n```diff\n");
962    for hunk in &response.expected_patch_hunks {
963        content.push_str(hunk);
964        content.push_str("\n\n");
965    }
966    content.push_str("```\n\n");
967    content.push_str(&format!("## Rejection Reason\n\n{}\n", rejection_reason));
968    content
969}
970
Served at tenant.openagents/omega Member data and write actions are omitted.