Skip to main content

sc/cli/commands/
prime.rs

1//! Prime command implementation.
2//!
3//! Generates a context primer for AI coding agents by aggregating
4//! session state, issues, memory, and optionally Claude Code transcripts
5//! into a single injectable context block.
6//!
7//! This is a **read-only** command — it never mutates the database.
8
9use crate::config::{current_git_branch, current_project_path, resolve_db_path, resolve_session_or_suggest};
10use crate::error::{Error, Result};
11use crate::storage::SqliteStorage;
12use serde::Serialize;
13use std::fs;
14use std::path::PathBuf;
15
16/// Limits for prime context items
17const HIGH_PRIORITY_LIMIT: u32 = 10;
18const DECISION_LIMIT: u32 = 10;
19const REMINDER_LIMIT: u32 = 10;
20const PROGRESS_LIMIT: u32 = 5;
21const READY_ISSUES_LIMIT: u32 = 10;
22const MEMORY_DISPLAY_LIMIT: usize = 20;
23
24// ============================================================================
25// JSON Output Structures
26// ============================================================================
27
28#[derive(Serialize)]
29struct PrimeOutput {
30    session: SessionInfo,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    git: Option<GitInfo>,
33    context: ContextBlock,
34    issues: IssueBlock,
35    memory: Vec<MemoryEntry>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    transcript: Option<TranscriptBlock>,
38    command_reference: Vec<CmdRef>,
39}
40
41#[derive(Serialize)]
42struct SessionInfo {
43    id: String,
44    name: String,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    description: Option<String>,
47    status: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    branch: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    project_path: Option<String>,
52}
53
54#[derive(Serialize)]
55struct GitInfo {
56    branch: String,
57    changed_files: Vec<String>,
58}
59
60#[derive(Serialize)]
61struct ContextBlock {
62    high_priority: Vec<ContextEntry>,
63    decisions: Vec<ContextEntry>,
64    reminders: Vec<ContextEntry>,
65    recent_progress: Vec<ContextEntry>,
66    total_items: usize,
67}
68
69#[derive(Serialize)]
70struct ContextEntry {
71    key: String,
72    value: String,
73    category: String,
74    priority: String,
75}
76
77#[derive(Serialize)]
78struct IssueBlock {
79    active: Vec<IssueSummary>,
80    ready: Vec<IssueSummary>,
81    total_open: usize,
82}
83
84#[derive(Serialize)]
85struct IssueSummary {
86    #[serde(skip_serializing_if = "Option::is_none")]
87    short_id: Option<String>,
88    title: String,
89    status: String,
90    priority: i32,
91    issue_type: String,
92}
93
94#[derive(Serialize)]
95struct MemoryEntry {
96    key: String,
97    value: String,
98    category: String,
99}
100
101#[derive(Serialize)]
102struct TranscriptBlock {
103    source: String,
104    entries: Vec<TranscriptEntry>,
105}
106
107#[derive(Serialize)]
108struct TranscriptEntry {
109    summary: String,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    timestamp: Option<String>,
112}
113
114#[derive(Serialize)]
115struct CmdRef {
116    cmd: String,
117    desc: String,
118}
119
120// ============================================================================
121// Execute
122// ============================================================================
123
124/// Execute the prime command.
125pub fn execute(
126    db_path: Option<&PathBuf>,
127    session_id: Option<&str>,
128    json: bool,
129    include_transcript: bool,
130    transcript_limit: usize,
131    compact: bool,
132) -> Result<()> {
133    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
134
135    if !db_path.exists() {
136        return Err(Error::NotInitialized);
137    }
138
139    let storage = SqliteStorage::open(&db_path)?;
140
141    // Resolve session via TTY-keyed status cache
142    let sid = resolve_session_or_suggest(session_id, &storage)?;
143    let session = storage
144        .get_session(&sid)?
145        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
146
147    let project_path = session
148        .project_path
149        .clone()
150        .or_else(|| current_project_path().map(|p| p.to_string_lossy().to_string()))
151        .unwrap_or_else(|| ".".to_string());
152
153    // Git info
154    let git_branch = current_git_branch();
155    let git_status = get_git_status();
156
157    // Context items (read-only queries)
158    let all_items = storage.get_context_items(&session.id, None, None, Some(1000))?;
159    let high_priority =
160        storage.get_context_items(&session.id, None, Some("high"), Some(HIGH_PRIORITY_LIMIT))?;
161    let decisions =
162        storage.get_context_items(&session.id, Some("decision"), None, Some(DECISION_LIMIT))?;
163    let reminders =
164        storage.get_context_items(&session.id, Some("reminder"), None, Some(REMINDER_LIMIT))?;
165    let progress =
166        storage.get_context_items(&session.id, Some("progress"), None, Some(PROGRESS_LIMIT))?;
167
168    // Issues
169    let active_issues =
170        storage.list_issues(&project_path, Some("in_progress"), None, Some(READY_ISSUES_LIMIT))?;
171    let ready_issues = storage.get_ready_issues(&project_path, READY_ISSUES_LIMIT)?;
172    let all_open_issues = storage.list_issues(&project_path, None, None, Some(1000))?;
173
174    // Memory
175    let memory_items = storage.list_memory(&project_path, None)?;
176
177    // Transcript (optional, never fails the command)
178    let transcript = if include_transcript {
179        parse_claude_transcripts(&project_path, transcript_limit)
180    } else {
181        None
182    };
183
184    let cmd_ref = build_command_reference();
185
186    if json {
187        let output = PrimeOutput {
188            session: SessionInfo {
189                id: session.id.clone(),
190                name: session.name.clone(),
191                description: session.description.clone(),
192                status: session.status.clone(),
193                branch: session.branch.clone(),
194                project_path: session.project_path.clone(),
195            },
196            git: git_branch.as_ref().map(|branch| {
197                let files: Vec<String> = git_status
198                    .as_ref()
199                    .map(|s| {
200                        s.lines()
201                            .take(20)
202                            .map(|l| l.trim().to_string())
203                            .collect()
204                    })
205                    .unwrap_or_default();
206                GitInfo {
207                    branch: branch.clone(),
208                    changed_files: files,
209                }
210            }),
211            context: ContextBlock {
212                high_priority: high_priority.iter().map(to_context_entry).collect(),
213                decisions: decisions.iter().map(to_context_entry).collect(),
214                reminders: reminders.iter().map(to_context_entry).collect(),
215                recent_progress: progress.iter().map(to_context_entry).collect(),
216                total_items: all_items.len(),
217            },
218            issues: IssueBlock {
219                active: active_issues.iter().map(to_issue_summary).collect(),
220                ready: ready_issues.iter().map(to_issue_summary).collect(),
221                total_open: all_open_issues.len(),
222            },
223            memory: memory_items
224                .iter()
225                .take(MEMORY_DISPLAY_LIMIT)
226                .map(|m| MemoryEntry {
227                    key: m.key.clone(),
228                    value: m.value.clone(),
229                    category: m.category.clone(),
230                })
231                .collect(),
232            transcript,
233            command_reference: cmd_ref,
234        };
235        println!("{}", serde_json::to_string_pretty(&output)?);
236    } else if compact {
237        print_compact(
238            &session,
239            &git_branch,
240            &git_status,
241            &high_priority,
242            &decisions,
243            &reminders,
244            &progress,
245            &active_issues,
246            &ready_issues,
247            &all_open_issues,
248            &memory_items,
249            &transcript,
250            all_items.len(),
251            &cmd_ref,
252        );
253    } else {
254        print_full(
255            &session,
256            &git_branch,
257            &git_status,
258            &high_priority,
259            &decisions,
260            &reminders,
261            &progress,
262            &active_issues,
263            &ready_issues,
264            &all_open_issues,
265            &memory_items,
266            &transcript,
267            all_items.len(),
268            &cmd_ref,
269        );
270    }
271
272    Ok(())
273}
274
275// ============================================================================
276// Transcript Parsing
277// ============================================================================
278
279/// Parse Claude Code transcript files for conversation summaries.
280///
281/// Claude Code stores session transcripts at:
282///   `~/.claude/projects/<encoded-path>/<uuid>.jsonl`
283///
284/// Where `<encoded-path>` replaces `/` with `-` in the project path.
285/// Each line is a JSON object; lines with `"type": "summary"` contain
286/// conversation summaries from previous sessions.
287fn parse_claude_transcripts(project_path: &str, limit: usize) -> Option<TranscriptBlock> {
288    let home = directories::BaseDirs::new()?.home_dir().to_path_buf();
289    let encoded_path = encode_project_path(project_path);
290    let transcript_dir = home.join(".claude").join("projects").join(&encoded_path);
291
292    if !transcript_dir.exists() {
293        return None;
294    }
295
296    // Find .jsonl files, sorted by modification time (most recent first)
297    let mut jsonl_files: Vec<_> = fs::read_dir(&transcript_dir)
298        .ok()?
299        .filter_map(|entry| {
300            let entry = entry.ok()?;
301            let path = entry.path();
302            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
303                let modified = entry.metadata().ok()?.modified().ok()?;
304                Some((path, modified))
305            } else {
306                None
307            }
308        })
309        .collect();
310
311    jsonl_files.sort_by(|a, b| b.1.cmp(&a.1));
312
313    let mut entries = Vec::new();
314
315    // Scan files from most recent, collecting summary entries
316    for (path, _) in &jsonl_files {
317        if entries.len() >= limit {
318            break;
319        }
320
321        let content = match fs::read_to_string(path) {
322            Ok(c) => c,
323            Err(_) => continue,
324        };
325
326        for line in content.lines().rev() {
327            if entries.len() >= limit {
328                break;
329            }
330
331            let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
332                continue;
333            };
334
335            // Look for summary entries
336            if val.get("type").and_then(|t| t.as_str()) == Some("summary") {
337                if let Some(summary) = val.get("summary").and_then(|s| s.as_str()) {
338                    let timestamp = val
339                        .get("timestamp")
340                        .and_then(|t| t.as_str())
341                        .map(ToString::to_string);
342                    entries.push(TranscriptEntry {
343                        summary: truncate(summary, 500),
344                        timestamp,
345                    });
346                }
347            }
348        }
349    }
350
351    if entries.is_empty() {
352        return None;
353    }
354
355    Some(TranscriptBlock {
356        source: transcript_dir.to_string_lossy().to_string(),
357        entries,
358    })
359}
360
361/// Encode a project path for Claude Code's directory naming.
362///
363/// Replaces `/` with `-` to match Claude Code's convention:
364///   `/Users/shane/code/project` → `-Users-shane-code-project`
365fn encode_project_path(path: &str) -> String {
366    path.replace('/', "-")
367}
368
369// ============================================================================
370// Command Reference
371// ============================================================================
372
373fn build_command_reference() -> Vec<CmdRef> {
374    vec![
375        CmdRef {
376            cmd: "sc save <key> <value> -c <cat> -p <pri>".into(),
377            desc: "Save context item".into(),
378        },
379        CmdRef {
380            cmd: "sc get -s <query>".into(),
381            desc: "Search context items".into(),
382        },
383        CmdRef {
384            cmd: "sc issue create <title> -t <type> -p <pri>".into(),
385            desc: "Create issue".into(),
386        },
387        CmdRef {
388            cmd: "sc issue list -s <status>".into(),
389            desc: "List issues".into(),
390        },
391        CmdRef {
392            cmd: "sc issue complete <id>".into(),
393            desc: "Complete issue".into(),
394        },
395        CmdRef {
396            cmd: "sc issue claim <id>".into(),
397            desc: "Claim issue".into(),
398        },
399        CmdRef {
400            cmd: "sc status".into(),
401            desc: "Show session status".into(),
402        },
403        CmdRef {
404            cmd: "sc checkpoint create <name>".into(),
405            desc: "Create checkpoint".into(),
406        },
407        CmdRef {
408            cmd: "sc memory save <key> <value>".into(),
409            desc: "Save project memory".into(),
410        },
411        CmdRef {
412            cmd: "sc compaction".into(),
413            desc: "Prepare for context compaction".into(),
414        },
415    ]
416}
417
418// ============================================================================
419// Converters
420// ============================================================================
421
422fn to_context_entry(item: &crate::storage::ContextItem) -> ContextEntry {
423    ContextEntry {
424        key: item.key.clone(),
425        value: item.value.clone(),
426        category: item.category.clone(),
427        priority: item.priority.clone(),
428    }
429}
430
431fn to_issue_summary(issue: &crate::storage::Issue) -> IssueSummary {
432    IssueSummary {
433        short_id: issue.short_id.clone(),
434        title: issue.title.clone(),
435        status: issue.status.clone(),
436        priority: issue.priority,
437        issue_type: issue.issue_type.clone(),
438    }
439}
440
441// ============================================================================
442// Human-Readable Output (Full)
443// ============================================================================
444
445#[allow(clippy::too_many_arguments)]
446fn print_full(
447    session: &crate::storage::Session,
448    git_branch: &Option<String>,
449    git_status: &Option<String>,
450    high_priority: &[crate::storage::ContextItem],
451    decisions: &[crate::storage::ContextItem],
452    reminders: &[crate::storage::ContextItem],
453    progress: &[crate::storage::ContextItem],
454    active_issues: &[crate::storage::Issue],
455    ready_issues: &[crate::storage::Issue],
456    all_open: &[crate::storage::Issue],
457    memory: &[crate::storage::Memory],
458    transcript: &Option<TranscriptBlock>,
459    total_items: usize,
460    cmd_ref: &[CmdRef],
461) {
462    use colored::Colorize;
463
464    println!();
465    println!(
466        "{}",
467        "━━━ SaveContext Prime ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta().bold()
468    );
469    println!();
470
471    // Session
472    println!("{}", "Session".cyan().bold());
473    println!("  Name:    {}", session.name);
474    if let Some(desc) = &session.description {
475        println!("  Desc:    {}", desc);
476    }
477    println!("  Status:  {}", session.status);
478    if let Some(branch) = git_branch {
479        println!("  Branch:  {}", branch);
480    }
481    println!("  Items:   {total_items}");
482    println!();
483
484    // Git
485    if let Some(status) = git_status {
486        let lines: Vec<&str> = status.lines().take(10).collect();
487        if !lines.is_empty() {
488            println!("{}", "Git Changes".cyan().bold());
489            for line in &lines {
490                println!("  {line}");
491            }
492            println!();
493        }
494    }
495
496    // High priority
497    if !high_priority.is_empty() {
498        println!("{}", "High Priority".red().bold());
499        for item in high_priority.iter().take(5) {
500            println!(
501                "  {} {} {}",
502                "•".red(),
503                item.key,
504                format!("[{}]", item.category).dimmed()
505            );
506            println!("    {}", truncate(&item.value, 80));
507        }
508        println!();
509    }
510
511    // Decisions
512    if !decisions.is_empty() {
513        println!("{}", "Key Decisions".yellow().bold());
514        for item in decisions.iter().take(5) {
515            println!("  {} {}", "•".yellow(), item.key);
516            println!("    {}", truncate(&item.value, 80));
517        }
518        println!();
519    }
520
521    // Reminders
522    if !reminders.is_empty() {
523        println!("{}", "Reminders".blue().bold());
524        for item in reminders.iter().take(5) {
525            println!("  {} {}", "•".blue(), item.key);
526            println!("    {}", truncate(&item.value, 80));
527        }
528        println!();
529    }
530
531    // Progress
532    if !progress.is_empty() {
533        println!("{}", "Recent Progress".green().bold());
534        for item in progress {
535            println!("  {} {}", "✓".green(), item.key);
536            println!("    {}", truncate(&item.value, 80));
537        }
538        println!();
539    }
540
541    // Issues
542    if !active_issues.is_empty() || !ready_issues.is_empty() {
543        println!(
544            "{} ({} open)",
545            "Issues".cyan().bold(),
546            all_open.len()
547        );
548
549        if !active_issues.is_empty() {
550            println!("  {}", "In Progress:".bold());
551            for issue in active_issues {
552                let id = issue.short_id.as_deref().unwrap_or("??");
553                println!(
554                    "    {} {} {} {}",
555                    id.cyan(),
556                    issue.title,
557                    format!("[{}]", issue.issue_type).dimmed(),
558                    format!("P{}", issue.priority).dimmed()
559                );
560            }
561        }
562
563        if !ready_issues.is_empty() {
564            println!("  {}", "Ready:".bold());
565            for issue in ready_issues.iter().take(5) {
566                let id = issue.short_id.as_deref().unwrap_or("??");
567                println!(
568                    "    {} {} {} {}",
569                    id.dimmed(),
570                    issue.title,
571                    format!("[{}]", issue.issue_type).dimmed(),
572                    format!("P{}", issue.priority).dimmed()
573                );
574            }
575        }
576        println!();
577    }
578
579    // Memory
580    if !memory.is_empty() {
581        println!("{}", "Project Memory".cyan().bold());
582        for item in memory.iter().take(10) {
583            println!(
584                "  {} {} {}",
585                item.key.bold(),
586                format!("[{}]", item.category).dimmed(),
587                truncate(&item.value, 60)
588            );
589        }
590        println!();
591    }
592
593    // Transcript
594    if let Some(t) = transcript {
595        println!("{}", "Recent Transcripts".magenta().bold());
596        for entry in &t.entries {
597            if let Some(ts) = &entry.timestamp {
598                println!("  {} {}", ts.dimmed(), truncate(&entry.summary, 100));
599            } else {
600                println!("  {}", truncate(&entry.summary, 100));
601            }
602        }
603        println!();
604    }
605
606    // Command reference
607    println!("{}", "Quick Reference".dimmed().bold());
608    for c in cmd_ref {
609        println!("  {} {}", c.cmd.cyan(), format!("# {}", c.desc).dimmed());
610    }
611    println!();
612    println!(
613        "{}",
614        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta()
615    );
616    println!();
617}
618
619// ============================================================================
620// Human-Readable Output (Compact — for agent injection)
621// ============================================================================
622
623#[allow(clippy::too_many_arguments)]
624fn print_compact(
625    session: &crate::storage::Session,
626    git_branch: &Option<String>,
627    _git_status: &Option<String>,
628    high_priority: &[crate::storage::ContextItem],
629    decisions: &[crate::storage::ContextItem],
630    reminders: &[crate::storage::ContextItem],
631    _progress: &[crate::storage::ContextItem],
632    active_issues: &[crate::storage::Issue],
633    ready_issues: &[crate::storage::Issue],
634    all_open: &[crate::storage::Issue],
635    memory: &[crate::storage::Memory],
636    transcript: &Option<TranscriptBlock>,
637    total_items: usize,
638    cmd_ref: &[CmdRef],
639) {
640    // Compact markdown format for direct agent injection
641    println!("# SaveContext Prime");
642    print!("Session: \"{}\" ({})", session.name, session.status);
643    if let Some(branch) = git_branch {
644        print!(" | Branch: {branch}");
645    }
646    println!(" | {total_items} context items");
647    println!();
648
649    if !high_priority.is_empty() {
650        println!("## High Priority");
651        for item in high_priority.iter().take(5) {
652            println!(
653                "- {}: {} [{}]",
654                item.key,
655                truncate(&item.value, 100),
656                item.category
657            );
658        }
659        println!();
660    }
661
662    if !decisions.is_empty() {
663        println!("## Decisions");
664        for item in decisions.iter().take(5) {
665            println!("- {}: {}", item.key, truncate(&item.value, 100));
666        }
667        println!();
668    }
669
670    if !reminders.is_empty() {
671        println!("## Reminders");
672        for item in reminders.iter().take(5) {
673            println!("- {}: {}", item.key, truncate(&item.value, 100));
674        }
675        println!();
676    }
677
678    if !active_issues.is_empty() || !ready_issues.is_empty() {
679        println!("## Issues ({} open)", all_open.len());
680        for issue in active_issues {
681            let id = issue.short_id.as_deref().unwrap_or("??");
682            println!(
683                "- [{}] {} ({}/P{})",
684                id, issue.title, issue.status, issue.priority
685            );
686        }
687        for issue in ready_issues.iter().take(5) {
688            let id = issue.short_id.as_deref().unwrap_or("??");
689            println!("- [{}] {} (ready/P{})", id, issue.title, issue.priority);
690        }
691        println!();
692    }
693
694    if !memory.is_empty() {
695        println!("## Memory");
696        for item in memory.iter().take(10) {
697            println!("- {} [{}]: {}", item.key, item.category, truncate(&item.value, 80));
698        }
699        println!();
700    }
701
702    if let Some(t) = transcript {
703        println!("## Recent Transcripts");
704        for entry in &t.entries {
705            println!("- {}", truncate(&entry.summary, 120));
706        }
707        println!();
708    }
709
710    println!("## Quick Reference");
711    for c in cmd_ref {
712        println!("- `{}` — {}", c.cmd, c.desc);
713    }
714}
715
716// ============================================================================
717// Helpers
718// ============================================================================
719
720/// Get current git status output.
721fn get_git_status() -> Option<String> {
722    std::process::Command::new("git")
723        .args(["status", "--porcelain"])
724        .output()
725        .ok()
726        .filter(|output| output.status.success())
727        .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
728}
729
730/// Truncate a string to max length with ellipsis.
731fn truncate(s: &str, max_len: usize) -> String {
732    // Work on first line only to avoid multi-line blowup
733    let first_line = s.lines().next().unwrap_or(s);
734    if first_line.len() <= max_len {
735        first_line.to_string()
736    } else {
737        format!("{}...", &first_line[..max_len.saturating_sub(3)])
738    }
739}