1use 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
16const 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#[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
120pub 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 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 let git_branch = current_git_branch();
155 let git_status = get_git_status();
156
157 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 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 let memory_items = storage.list_memory(&project_path, None)?;
176
177 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
275fn 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 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 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 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
361fn encode_project_path(path: &str) -> String {
366 path.replace('/', "-")
367}
368
369fn 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
418fn 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#[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 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 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 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 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 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 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 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 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 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 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#[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 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
716fn 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
730fn truncate(s: &str, max_len: usize) -> String {
732 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}