1use crate::config::{current_git_branch, resolve_db_path, resolve_project_path, resolve_session_or_suggest};
10use crate::embeddings::{is_embeddings_enabled, EmbeddingProvider, Model2VecProvider};
11use crate::error::{Error, Result};
12use crate::storage::{ContextItem, SqliteStorage};
13use serde::Serialize;
14use std::fs;
15use std::path::PathBuf;
16use std::sync::OnceLock;
17use tracing::{debug, warn};
18
19const HIGH_PRIORITY_LIMIT: u32 = 10;
21const DECISION_LIMIT: u32 = 10;
22const REMINDER_LIMIT: u32 = 10;
23const PROGRESS_LIMIT: u32 = 5;
24const READY_ISSUES_LIMIT: u32 = 10;
25const MEMORY_DISPLAY_LIMIT: usize = 20;
26
27const MMR_LAMBDA: f64 = 0.7;
29const HEADER_TOKEN_RESERVE: usize = 200;
30
31#[derive(Serialize)]
36struct PrimeOutput {
37 session: SessionInfo,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 git: Option<GitInfo>,
40 context: ContextBlock,
41 issues: IssueBlock,
42 memory: Vec<MemoryEntry>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 transcript: Option<TranscriptBlock>,
45 command_reference: Vec<CmdRef>,
46}
47
48#[derive(Serialize)]
49struct SessionInfo {
50 id: String,
51 name: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 description: Option<String>,
54 status: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 branch: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 project_path: Option<String>,
59}
60
61#[derive(Serialize)]
62struct GitInfo {
63 branch: String,
64 changed_files: Vec<String>,
65}
66
67#[derive(Serialize)]
68struct ContextBlock {
69 high_priority: Vec<ContextEntry>,
70 decisions: Vec<ContextEntry>,
71 reminders: Vec<ContextEntry>,
72 recent_progress: Vec<ContextEntry>,
73 total_items: usize,
74}
75
76#[derive(Serialize)]
77struct ContextEntry {
78 key: String,
79 value: String,
80 category: String,
81 priority: String,
82}
83
84#[derive(Serialize)]
85struct IssueBlock {
86 active: Vec<IssueSummary>,
87 ready: Vec<IssueSummary>,
88 total_open: usize,
89}
90
91#[derive(Serialize)]
92struct IssueSummary {
93 #[serde(skip_serializing_if = "Option::is_none")]
94 short_id: Option<String>,
95 title: String,
96 status: String,
97 priority: i32,
98 issue_type: String,
99}
100
101#[derive(Serialize)]
102struct MemoryEntry {
103 key: String,
104 value: String,
105 category: String,
106}
107
108#[derive(Serialize, Clone)]
109struct TranscriptBlock {
110 source: String,
111 entries: Vec<TranscriptEntry>,
112}
113
114#[derive(Serialize, Clone)]
115struct TranscriptEntry {
116 summary: String,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 timestamp: Option<String>,
119}
120
121#[derive(Serialize, Clone)]
122struct CmdRef {
123 cmd: String,
124 desc: String,
125}
126
127struct ScoredItem {
132 item: ContextItem,
133 score: f64,
134 token_estimate: usize,
135 embedding: Option<Vec<f32>>,
136}
137
138struct SmartConfig {
139 budget: usize,
140 decay_half_life_days: f64,
141 query_embedding: Option<Vec<f32>>,
142 mmr_lambda: f64,
143}
144
145#[derive(Serialize)]
146struct SmartPrimeOutput {
147 stats: SmartPrimeStats,
148 scored_context: Vec<ScoredContextEntry>,
149 issues: IssueBlock,
150 memory: Vec<MemoryEntry>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 transcript: Option<TranscriptBlock>,
153 command_reference: Vec<CmdRef>,
154}
155
156#[derive(Serialize)]
157struct SmartPrimeStats {
158 total_items: usize,
159 selected_items: usize,
160 tokens_used: usize,
161 tokens_budget: usize,
162 embeddings_available: bool,
163 mmr_applied: bool,
164 query_boosted: bool,
165}
166
167#[derive(Serialize)]
168struct ScoredContextEntry {
169 key: String,
170 value: String,
171 category: String,
172 priority: String,
173 score: f64,
174 token_estimate: usize,
175}
176
177static FAST_PROVIDER: OnceLock<Option<Model2VecProvider>> = OnceLock::new();
179
180fn get_fast_provider() -> Option<&'static Model2VecProvider> {
181 FAST_PROVIDER
182 .get_or_init(|| {
183 if !is_embeddings_enabled() {
184 return None;
185 }
186 Model2VecProvider::try_new()
187 })
188 .as_ref()
189}
190
191#[allow(clippy::too_many_arguments)]
197pub fn execute(
198 db_path: Option<&PathBuf>,
199 session_id: Option<&str>,
200 json: bool,
201 include_transcript: bool,
202 transcript_limit: usize,
203 compact: bool,
204 smart: bool,
205 budget: usize,
206 query: Option<&str>,
207 decay_days: u32,
208) -> Result<()> {
209 let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
210
211 if !db_path.exists() {
212 return Err(Error::NotInitialized);
213 }
214
215 let storage = SqliteStorage::open(&db_path)?;
216
217 let sid = resolve_session_or_suggest(session_id, &storage)?;
219 let session = storage
220 .get_session(&sid)?
221 .ok_or_else(|| Error::SessionNotFound { id: sid })?;
222
223 let project_path = session
224 .project_path
225 .clone()
226 .or_else(|| resolve_project_path(&storage, None).ok())
227 .unwrap_or_else(|| ".".to_string());
228
229 let git_branch = current_git_branch();
231 let git_status = get_git_status();
232
233 if smart {
235 return execute_smart(
236 &storage, &session, &project_path, &git_branch, &git_status,
237 json, compact, include_transcript, transcript_limit,
238 budget, query, decay_days,
239 );
240 }
241
242 let all_items = storage.get_context_items(&session.id, None, None, Some(1000))?;
244 let high_priority =
245 storage.get_context_items(&session.id, None, Some("high"), Some(HIGH_PRIORITY_LIMIT))?;
246 let decisions =
247 storage.get_context_items(&session.id, Some("decision"), None, Some(DECISION_LIMIT))?;
248 let reminders =
249 storage.get_context_items(&session.id, Some("reminder"), None, Some(REMINDER_LIMIT))?;
250 let progress =
251 storage.get_context_items(&session.id, Some("progress"), None, Some(PROGRESS_LIMIT))?;
252
253 let active_issues =
255 storage.list_issues(&project_path, Some("in_progress"), None, Some(READY_ISSUES_LIMIT))?;
256 let ready_issues = storage.get_ready_issues(&project_path, READY_ISSUES_LIMIT)?;
257 let all_open_issues = storage.list_issues(&project_path, None, None, Some(1000))?;
258
259 let memory_items = storage.list_memory(&project_path, None)?;
261
262 let transcript = if include_transcript {
264 parse_claude_transcripts(&project_path, transcript_limit)
265 } else {
266 None
267 };
268
269 let cmd_ref = build_command_reference();
270
271 if json {
272 let output = PrimeOutput {
273 session: SessionInfo {
274 id: session.id.clone(),
275 name: session.name.clone(),
276 description: session.description.clone(),
277 status: session.status.clone(),
278 branch: session.branch.clone(),
279 project_path: session.project_path.clone(),
280 },
281 git: git_branch.as_ref().map(|branch| {
282 let files: Vec<String> = git_status
283 .as_ref()
284 .map(|s| {
285 s.lines()
286 .take(20)
287 .map(|l| l.trim().to_string())
288 .collect()
289 })
290 .unwrap_or_default();
291 GitInfo {
292 branch: branch.clone(),
293 changed_files: files,
294 }
295 }),
296 context: ContextBlock {
297 high_priority: high_priority.iter().map(to_context_entry).collect(),
298 decisions: decisions.iter().map(to_context_entry).collect(),
299 reminders: reminders.iter().map(to_context_entry).collect(),
300 recent_progress: progress.iter().map(to_context_entry).collect(),
301 total_items: all_items.len(),
302 },
303 issues: IssueBlock {
304 active: active_issues.iter().map(to_issue_summary).collect(),
305 ready: ready_issues.iter().map(to_issue_summary).collect(),
306 total_open: all_open_issues.len(),
307 },
308 memory: memory_items
309 .iter()
310 .take(MEMORY_DISPLAY_LIMIT)
311 .map(|m| MemoryEntry {
312 key: m.key.clone(),
313 value: m.value.clone(),
314 category: m.category.clone(),
315 })
316 .collect(),
317 transcript,
318 command_reference: cmd_ref,
319 };
320 println!("{}", serde_json::to_string_pretty(&output)?);
321 } else if compact {
322 print_compact(
323 &session,
324 &git_branch,
325 &git_status,
326 &high_priority,
327 &decisions,
328 &reminders,
329 &progress,
330 &active_issues,
331 &ready_issues,
332 &all_open_issues,
333 &memory_items,
334 &transcript,
335 all_items.len(),
336 &cmd_ref,
337 );
338 } else {
339 print_full(
340 &session,
341 &git_branch,
342 &git_status,
343 &high_priority,
344 &decisions,
345 &reminders,
346 &progress,
347 &active_issues,
348 &ready_issues,
349 &all_open_issues,
350 &memory_items,
351 &transcript,
352 all_items.len(),
353 &cmd_ref,
354 );
355 }
356
357 Ok(())
358}
359
360#[allow(clippy::too_many_arguments)]
365fn execute_smart(
366 storage: &SqliteStorage,
367 session: &crate::storage::Session,
368 project_path: &str,
369 git_branch: &Option<String>,
370 git_status: &Option<String>,
371 json: bool,
372 compact: bool,
373 include_transcript: bool,
374 transcript_limit: usize,
375 budget: usize,
376 query: Option<&str>,
377 decay_days: u32,
378) -> Result<()> {
379 let now_ms = chrono::Utc::now().timestamp_millis();
380 let half_life = decay_days as f64;
381
382 let items_with_embeddings = storage.get_items_with_fast_embeddings(&session.id)?;
384 let total_items = items_with_embeddings.len();
385 let embeddings_available = items_with_embeddings.iter().any(|(_, e)| e.is_some());
386
387 let query_embedding = query.and_then(|q| generate_query_embedding(q));
389 let query_boosted = query_embedding.is_some();
390
391 let config = SmartConfig {
392 budget,
393 decay_half_life_days: half_life,
394 query_embedding,
395 mmr_lambda: MMR_LAMBDA,
396 };
397
398 let mut scored: Vec<ScoredItem> = items_with_embeddings
400 .into_iter()
401 .map(|(item, embedding)| {
402 let td = temporal_decay(item.updated_at, now_ms, config.decay_half_life_days);
403 let pw = priority_weight(&item.priority);
404 let cw = category_weight(&item.category);
405 let sb = semantic_boost(
406 embedding.as_deref(),
407 config.query_embedding.as_deref(),
408 );
409 let score = td * pw * cw * sb;
410 let token_estimate = estimate_tokens(&item.key, &item.value);
411
412 ScoredItem { item, score, token_estimate, embedding }
413 })
414 .collect();
415
416 scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
418
419 let mmr_applied = embeddings_available;
421 if mmr_applied {
422 scored = apply_mmr(scored, config.mmr_lambda);
423 }
424
425 let packed = pack_to_budget(scored, config.budget);
427 let selected_items = packed.len();
428 let tokens_used: usize = packed.iter().map(|s| s.token_estimate).sum::<usize>() + HEADER_TOKEN_RESERVE;
429
430 let stats = SmartPrimeStats {
431 total_items,
432 selected_items,
433 tokens_used,
434 tokens_budget: config.budget,
435 embeddings_available,
436 mmr_applied,
437 query_boosted,
438 };
439
440 let active_issues =
442 storage.list_issues(project_path, Some("in_progress"), None, Some(READY_ISSUES_LIMIT))?;
443 let ready_issues = storage.get_ready_issues(project_path, READY_ISSUES_LIMIT)?;
444 let all_open_issues = storage.list_issues(project_path, None, None, Some(1000))?;
445 let memory_items = storage.list_memory(project_path, None)?;
446 let transcript = if include_transcript {
447 parse_claude_transcripts(project_path, transcript_limit)
448 } else {
449 None
450 };
451 let cmd_ref = build_command_reference();
452
453 if json {
454 output_smart_json(&stats, &packed, &active_issues, &ready_issues, &all_open_issues, &memory_items, &transcript, &cmd_ref)?;
455 } else if compact {
456 output_smart_compact(session, git_branch, &stats, &packed, &active_issues, &ready_issues, &all_open_issues, &memory_items, &transcript, &cmd_ref);
457 } else {
458 output_smart_terminal(session, git_branch, git_status, &stats, &packed, &active_issues, &ready_issues, &all_open_issues, &memory_items, &transcript, &cmd_ref);
459 }
460
461 Ok(())
462}
463
464fn temporal_decay(updated_at_ms: i64, now_ms: i64, half_life_days: f64) -> f64 {
472 let age_days = (now_ms - updated_at_ms) as f64 / 86_400_000.0;
473 if age_days <= 0.0 {
474 return 1.0;
475 }
476 let lambda = 2.0_f64.ln() / half_life_days;
477 (-lambda * age_days).exp()
478}
479
480fn priority_weight(priority: &str) -> f64 {
482 match priority {
483 "high" => 3.0,
484 "normal" => 1.0,
485 "low" => 0.5,
486 _ => 1.0,
487 }
488}
489
490fn category_weight(category: &str) -> f64 {
492 match category {
493 "decision" => 2.0,
494 "reminder" => 1.5,
495 "progress" => 1.0,
496 "note" => 0.5,
497 _ => 1.0,
498 }
499}
500
501fn semantic_boost(item_emb: Option<&[f32]>, query_emb: Option<&[f32]>) -> f64 {
505 match (item_emb, query_emb) {
506 (Some(a), Some(b)) => (1.0 + cosine_similarity_f64(a, b) * 1.5).max(0.5),
507 _ => 1.0,
508 }
509}
510
511fn estimate_tokens(key: &str, value: &str) -> usize {
513 (key.len() + value.len() + 20) / 4
514}
515
516fn cosine_similarity_f64(a: &[f32], b: &[f32]) -> f64 {
518 if a.len() != b.len() || a.is_empty() {
519 return 0.0;
520 }
521 let mut dot = 0.0_f64;
522 let mut norm_a = 0.0_f64;
523 let mut norm_b = 0.0_f64;
524 for (x, y) in a.iter().zip(b.iter()) {
525 let xf = *x as f64;
526 let yf = *y as f64;
527 dot += xf * yf;
528 norm_a += xf * xf;
529 norm_b += yf * yf;
530 }
531 let denom = norm_a.sqrt() * norm_b.sqrt();
532 if denom < 1e-10 {
533 0.0
534 } else {
535 dot / denom
536 }
537}
538
539fn generate_query_embedding(query: &str) -> Option<Vec<f32>> {
541 let provider = get_fast_provider()?;
542 let rt = tokio::runtime::Runtime::new().ok()?;
543 match rt.block_on(provider.generate_embedding(query)) {
544 Ok(emb) => {
545 debug!(query, dim = emb.len(), "Generated query embedding for smart prime");
546 Some(emb)
547 }
548 Err(e) => {
549 warn!(query, error = %e, "Failed to generate query embedding");
550 None
551 }
552 }
553}
554
555fn apply_mmr(items: Vec<ScoredItem>, lambda: f64) -> Vec<ScoredItem> {
563 let mut with_emb: Vec<ScoredItem> = Vec::new();
565 let mut without_emb: Vec<ScoredItem> = Vec::new();
566
567 for item in items {
568 if item.embedding.is_some() {
569 with_emb.push(item);
570 } else {
571 without_emb.push(item);
572 }
573 }
574
575 if with_emb.is_empty() {
576 without_emb.extend(with_emb);
578 return without_emb;
579 }
580
581 let max_score = with_emb.iter().map(|s| s.score).fold(f64::NEG_INFINITY, f64::max);
583 let min_score = with_emb.iter().map(|s| s.score).fold(f64::INFINITY, f64::min);
584 let score_range = (max_score - min_score).max(1e-10);
585
586 let mut selected: Vec<ScoredItem> = Vec::new();
587 let mut candidates = with_emb;
588
589 while !candidates.is_empty() {
590 let mut best_idx = 0;
591 let mut best_mmr = f64::NEG_INFINITY;
592
593 for (i, candidate) in candidates.iter().enumerate() {
594 let relevance = (candidate.score - min_score) / score_range;
595
596 let max_sim = if selected.is_empty() {
598 0.0
599 } else {
600 selected
601 .iter()
602 .filter_map(|s| {
603 let c_emb = candidate.embedding.as_deref()?;
604 let s_emb = s.embedding.as_deref()?;
605 Some(cosine_similarity_f64(c_emb, s_emb))
606 })
607 .fold(f64::NEG_INFINITY, f64::max)
608 .max(0.0) };
610
611 let mmr = lambda * relevance - (1.0 - lambda) * max_sim;
612 if mmr > best_mmr {
613 best_mmr = mmr;
614 best_idx = i;
615 }
616 }
617
618 selected.push(candidates.remove(best_idx));
619 }
620
621 selected.extend(without_emb);
623 selected
624}
625
626fn pack_to_budget(items: Vec<ScoredItem>, budget: usize) -> Vec<ScoredItem> {
634 let available = budget.saturating_sub(HEADER_TOKEN_RESERVE);
635 let mut used = 0usize;
636 let mut packed = Vec::new();
637
638 for item in items {
639 if used + item.token_estimate <= available {
640 used += item.token_estimate;
641 packed.push(item);
642 }
643 }
645
646 packed
647}
648
649fn output_smart_json(
654 stats: &SmartPrimeStats,
655 items: &[ScoredItem],
656 active_issues: &[crate::storage::Issue],
657 ready_issues: &[crate::storage::Issue],
658 all_open: &[crate::storage::Issue],
659 memory: &[crate::storage::Memory],
660 transcript: &Option<TranscriptBlock>,
661 cmd_ref: &[CmdRef],
662) -> Result<()> {
663 let output = SmartPrimeOutput {
664 stats: SmartPrimeStats {
665 total_items: stats.total_items,
666 selected_items: stats.selected_items,
667 tokens_used: stats.tokens_used,
668 tokens_budget: stats.tokens_budget,
669 embeddings_available: stats.embeddings_available,
670 mmr_applied: stats.mmr_applied,
671 query_boosted: stats.query_boosted,
672 },
673 scored_context: items
674 .iter()
675 .map(|s| ScoredContextEntry {
676 key: s.item.key.clone(),
677 value: s.item.value.clone(),
678 category: s.item.category.clone(),
679 priority: s.item.priority.clone(),
680 score: (s.score * 100.0).round() / 100.0, token_estimate: s.token_estimate,
682 })
683 .collect(),
684 issues: IssueBlock {
685 active: active_issues.iter().map(to_issue_summary).collect(),
686 ready: ready_issues.iter().map(to_issue_summary).collect(),
687 total_open: all_open.len(),
688 },
689 memory: memory
690 .iter()
691 .take(MEMORY_DISPLAY_LIMIT)
692 .map(|m| MemoryEntry {
693 key: m.key.clone(),
694 value: m.value.clone(),
695 category: m.category.clone(),
696 })
697 .collect(),
698 transcript: transcript.clone(),
699 command_reference: cmd_ref.to_vec(),
700 };
701 println!("{}", serde_json::to_string_pretty(&output)?);
702 Ok(())
703}
704
705#[allow(clippy::too_many_arguments)]
706fn output_smart_compact(
707 session: &crate::storage::Session,
708 git_branch: &Option<String>,
709 stats: &SmartPrimeStats,
710 items: &[ScoredItem],
711 active_issues: &[crate::storage::Issue],
712 ready_issues: &[crate::storage::Issue],
713 all_open: &[crate::storage::Issue],
714 memory: &[crate::storage::Memory],
715 transcript: &Option<TranscriptBlock>,
716 cmd_ref: &[CmdRef],
717) {
718 println!("# SaveContext Smart Prime");
719 print!("Session: \"{}\" ({})", session.name, session.status);
720 if let Some(branch) = git_branch {
721 print!(" | Branch: {branch}");
722 }
723 println!(" | {} items", stats.total_items);
724 println!(
725 "Budget: {}/{} tokens | {} selected | MMR: {}",
726 stats.tokens_used,
727 stats.tokens_budget,
728 stats.selected_items,
729 if stats.mmr_applied { "yes" } else { "no" }
730 );
731 println!();
732
733 if !items.is_empty() {
734 println!("## Context (ranked by relevance)");
735 for s in items {
736 println!(
737 "- [{:.2}] {}: {} [{}/{}]",
738 s.score,
739 s.item.key,
740 truncate(&s.item.value, 100),
741 s.item.category,
742 s.item.priority
743 );
744 }
745 println!();
746 }
747
748 if !active_issues.is_empty() || !ready_issues.is_empty() {
749 println!("## Issues ({} open)", all_open.len());
750 for issue in active_issues {
751 let id = issue.short_id.as_deref().unwrap_or("??");
752 println!(
753 "- [{}] {} ({}/P{})",
754 id, issue.title, issue.status, issue.priority
755 );
756 }
757 for issue in ready_issues.iter().take(5) {
758 let id = issue.short_id.as_deref().unwrap_or("??");
759 println!("- [{}] {} (ready/P{})", id, issue.title, issue.priority);
760 }
761 println!();
762 }
763
764 if !memory.is_empty() {
765 println!("## Memory");
766 for item in memory.iter().take(10) {
767 println!("- {} [{}]: {}", item.key, item.category, truncate(&item.value, 80));
768 }
769 println!();
770 }
771
772 if let Some(t) = transcript {
773 println!("## Recent Transcripts");
774 for entry in &t.entries {
775 println!("- {}", truncate(&entry.summary, 120));
776 }
777 println!();
778 }
779
780 println!("## Quick Reference");
781 for c in cmd_ref {
782 println!("- `{}` -- {}", c.cmd, c.desc);
783 }
784}
785
786#[allow(clippy::too_many_arguments)]
787fn output_smart_terminal(
788 session: &crate::storage::Session,
789 git_branch: &Option<String>,
790 git_status: &Option<String>,
791 stats: &SmartPrimeStats,
792 items: &[ScoredItem],
793 active_issues: &[crate::storage::Issue],
794 ready_issues: &[crate::storage::Issue],
795 all_open: &[crate::storage::Issue],
796 memory: &[crate::storage::Memory],
797 transcript: &Option<TranscriptBlock>,
798 cmd_ref: &[CmdRef],
799) {
800 use colored::Colorize;
801
802 println!();
803 println!(
804 "{}",
805 "━━━ SaveContext Smart Prime ━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta().bold()
806 );
807 println!();
808
809 println!("{}", "Session".cyan().bold());
811 println!(" Name: {}", session.name);
812 println!(" Status: {}", session.status);
813 if let Some(branch) = git_branch {
814 println!(" Branch: {}", branch);
815 }
816 println!();
817
818 println!("{}", "Smart Stats".cyan().bold());
820 println!(
821 " Budget: {}/{} tokens",
822 stats.tokens_used, stats.tokens_budget
823 );
824 println!(
825 " Selected: {}/{} items",
826 stats.selected_items, stats.total_items
827 );
828 println!(
829 " Embeddings: {}",
830 if stats.embeddings_available { "yes" } else { "no" }
831 );
832 println!(" MMR: {}", if stats.mmr_applied { "yes" } else { "no" });
833 println!(
834 " Query: {}",
835 if stats.query_boosted { "boosted" } else { "none" }
836 );
837 println!();
838
839 if let Some(status) = git_status {
841 let lines: Vec<&str> = status.lines().take(10).collect();
842 if !lines.is_empty() {
843 println!("{}", "Git Changes".cyan().bold());
844 for line in &lines {
845 println!(" {line}");
846 }
847 println!();
848 }
849 }
850
851 if !items.is_empty() {
853 println!("{}", "Context (ranked)".yellow().bold());
854 for s in items {
855 let score_str = format!("[{:.2}]", s.score);
856 let meta = format!("[{}/{}]", s.item.category, s.item.priority);
857 println!(
858 " {} {} {} {}",
859 score_str.yellow(),
860 s.item.key.bold(),
861 meta.dimmed(),
862 truncate(&s.item.value, 60)
863 );
864 }
865 println!();
866 }
867
868 if !active_issues.is_empty() || !ready_issues.is_empty() {
870 println!(
871 "{} ({} open)",
872 "Issues".cyan().bold(),
873 all_open.len()
874 );
875 for issue in active_issues {
876 let id = issue.short_id.as_deref().unwrap_or("??");
877 println!(
878 " {} {} {} {}",
879 id.cyan(),
880 issue.title,
881 format!("[{}]", issue.issue_type).dimmed(),
882 format!("P{}", issue.priority).dimmed()
883 );
884 }
885 for issue in ready_issues.iter().take(5) {
886 let id = issue.short_id.as_deref().unwrap_or("??");
887 println!(
888 " {} {} {} {}",
889 id.dimmed(),
890 issue.title,
891 format!("[{}]", issue.issue_type).dimmed(),
892 format!("P{}", issue.priority).dimmed()
893 );
894 }
895 println!();
896 }
897
898 if !memory.is_empty() {
900 println!("{}", "Project Memory".cyan().bold());
901 for item in memory.iter().take(10) {
902 println!(
903 " {} {} {}",
904 item.key.bold(),
905 format!("[{}]", item.category).dimmed(),
906 truncate(&item.value, 60)
907 );
908 }
909 println!();
910 }
911
912 if let Some(t) = transcript {
914 println!("{}", "Recent Transcripts".magenta().bold());
915 for entry in &t.entries {
916 if let Some(ts) = &entry.timestamp {
917 println!(" {} {}", ts.dimmed(), truncate(&entry.summary, 100));
918 } else {
919 println!(" {}", truncate(&entry.summary, 100));
920 }
921 }
922 println!();
923 }
924
925 println!("{}", "Quick Reference".dimmed().bold());
927 for c in cmd_ref {
928 println!(" {} {}", c.cmd.cyan(), format!("# {}", c.desc).dimmed());
929 }
930 println!();
931 println!(
932 "{}",
933 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta()
934 );
935 println!();
936}
937
938fn parse_claude_transcripts(project_path: &str, limit: usize) -> Option<TranscriptBlock> {
951 let home = directories::BaseDirs::new()?.home_dir().to_path_buf();
952 let encoded_path = encode_project_path(project_path);
953 let transcript_dir = home.join(".claude").join("projects").join(&encoded_path);
954
955 if !transcript_dir.exists() {
956 return None;
957 }
958
959 let mut jsonl_files: Vec<_> = fs::read_dir(&transcript_dir)
961 .ok()?
962 .filter_map(|entry| {
963 let entry = entry.ok()?;
964 let path = entry.path();
965 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
966 let modified = entry.metadata().ok()?.modified().ok()?;
967 Some((path, modified))
968 } else {
969 None
970 }
971 })
972 .collect();
973
974 jsonl_files.sort_by(|a, b| b.1.cmp(&a.1));
975
976 let mut entries = Vec::new();
977
978 for (path, _) in &jsonl_files {
980 if entries.len() >= limit {
981 break;
982 }
983
984 let content = match fs::read_to_string(path) {
985 Ok(c) => c,
986 Err(_) => continue,
987 };
988
989 for line in content.lines().rev() {
990 if entries.len() >= limit {
991 break;
992 }
993
994 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
995 continue;
996 };
997
998 if val.get("type").and_then(|t| t.as_str()) == Some("summary") {
1000 if let Some(summary) = val.get("summary").and_then(|s| s.as_str()) {
1001 let timestamp = val
1002 .get("timestamp")
1003 .and_then(|t| t.as_str())
1004 .map(ToString::to_string);
1005 entries.push(TranscriptEntry {
1006 summary: truncate(summary, 500),
1007 timestamp,
1008 });
1009 }
1010 }
1011 }
1012 }
1013
1014 if entries.is_empty() {
1015 return None;
1016 }
1017
1018 Some(TranscriptBlock {
1019 source: transcript_dir.to_string_lossy().to_string(),
1020 entries,
1021 })
1022}
1023
1024fn encode_project_path(path: &str) -> String {
1029 path.replace('/', "-")
1030}
1031
1032fn build_command_reference() -> Vec<CmdRef> {
1037 vec![
1038 CmdRef {
1039 cmd: "sc save <key> <value> -c <cat> -p <pri>".into(),
1040 desc: "Save context item".into(),
1041 },
1042 CmdRef {
1043 cmd: "sc get -s <query>".into(),
1044 desc: "Search context items".into(),
1045 },
1046 CmdRef {
1047 cmd: "sc issue create <title> -t <type> -p <pri>".into(),
1048 desc: "Create issue".into(),
1049 },
1050 CmdRef {
1051 cmd: "sc issue list -s <status>".into(),
1052 desc: "List issues".into(),
1053 },
1054 CmdRef {
1055 cmd: "sc issue complete <id>".into(),
1056 desc: "Complete issue".into(),
1057 },
1058 CmdRef {
1059 cmd: "sc issue claim <id>".into(),
1060 desc: "Claim issue".into(),
1061 },
1062 CmdRef {
1063 cmd: "sc status".into(),
1064 desc: "Show session status".into(),
1065 },
1066 CmdRef {
1067 cmd: "sc checkpoint create <name>".into(),
1068 desc: "Create checkpoint".into(),
1069 },
1070 CmdRef {
1071 cmd: "sc memory save <key> <value>".into(),
1072 desc: "Save project memory".into(),
1073 },
1074 CmdRef {
1075 cmd: "sc compaction".into(),
1076 desc: "Prepare for context compaction".into(),
1077 },
1078 ]
1079}
1080
1081fn to_context_entry(item: &crate::storage::ContextItem) -> ContextEntry {
1086 ContextEntry {
1087 key: item.key.clone(),
1088 value: item.value.clone(),
1089 category: item.category.clone(),
1090 priority: item.priority.clone(),
1091 }
1092}
1093
1094fn to_issue_summary(issue: &crate::storage::Issue) -> IssueSummary {
1095 IssueSummary {
1096 short_id: issue.short_id.clone(),
1097 title: issue.title.clone(),
1098 status: issue.status.clone(),
1099 priority: issue.priority,
1100 issue_type: issue.issue_type.clone(),
1101 }
1102}
1103
1104#[allow(clippy::too_many_arguments)]
1109fn print_full(
1110 session: &crate::storage::Session,
1111 git_branch: &Option<String>,
1112 git_status: &Option<String>,
1113 high_priority: &[crate::storage::ContextItem],
1114 decisions: &[crate::storage::ContextItem],
1115 reminders: &[crate::storage::ContextItem],
1116 progress: &[crate::storage::ContextItem],
1117 active_issues: &[crate::storage::Issue],
1118 ready_issues: &[crate::storage::Issue],
1119 all_open: &[crate::storage::Issue],
1120 memory: &[crate::storage::Memory],
1121 transcript: &Option<TranscriptBlock>,
1122 total_items: usize,
1123 cmd_ref: &[CmdRef],
1124) {
1125 use colored::Colorize;
1126
1127 println!();
1128 println!(
1129 "{}",
1130 "━━━ SaveContext Prime ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta().bold()
1131 );
1132 println!();
1133
1134 println!("{}", "Session".cyan().bold());
1136 println!(" Name: {}", session.name);
1137 if let Some(desc) = &session.description {
1138 println!(" Desc: {}", desc);
1139 }
1140 println!(" Status: {}", session.status);
1141 if let Some(branch) = git_branch {
1142 println!(" Branch: {}", branch);
1143 }
1144 println!(" Items: {total_items}");
1145 println!();
1146
1147 if let Some(status) = git_status {
1149 let lines: Vec<&str> = status.lines().take(10).collect();
1150 if !lines.is_empty() {
1151 println!("{}", "Git Changes".cyan().bold());
1152 for line in &lines {
1153 println!(" {line}");
1154 }
1155 println!();
1156 }
1157 }
1158
1159 if !high_priority.is_empty() {
1161 println!("{}", "High Priority".red().bold());
1162 for item in high_priority.iter().take(5) {
1163 println!(
1164 " {} {} {}",
1165 "•".red(),
1166 item.key,
1167 format!("[{}]", item.category).dimmed()
1168 );
1169 println!(" {}", truncate(&item.value, 80));
1170 }
1171 println!();
1172 }
1173
1174 if !decisions.is_empty() {
1176 println!("{}", "Key Decisions".yellow().bold());
1177 for item in decisions.iter().take(5) {
1178 println!(" {} {}", "•".yellow(), item.key);
1179 println!(" {}", truncate(&item.value, 80));
1180 }
1181 println!();
1182 }
1183
1184 if !reminders.is_empty() {
1186 println!("{}", "Reminders".blue().bold());
1187 for item in reminders.iter().take(5) {
1188 println!(" {} {}", "•".blue(), item.key);
1189 println!(" {}", truncate(&item.value, 80));
1190 }
1191 println!();
1192 }
1193
1194 if !progress.is_empty() {
1196 println!("{}", "Recent Progress".green().bold());
1197 for item in progress {
1198 println!(" {} {}", "✓".green(), item.key);
1199 println!(" {}", truncate(&item.value, 80));
1200 }
1201 println!();
1202 }
1203
1204 if !active_issues.is_empty() || !ready_issues.is_empty() {
1206 println!(
1207 "{} ({} open)",
1208 "Issues".cyan().bold(),
1209 all_open.len()
1210 );
1211
1212 if !active_issues.is_empty() {
1213 println!(" {}", "In Progress:".bold());
1214 for issue in active_issues {
1215 let id = issue.short_id.as_deref().unwrap_or("??");
1216 println!(
1217 " {} {} {} {}",
1218 id.cyan(),
1219 issue.title,
1220 format!("[{}]", issue.issue_type).dimmed(),
1221 format!("P{}", issue.priority).dimmed()
1222 );
1223 }
1224 }
1225
1226 if !ready_issues.is_empty() {
1227 println!(" {}", "Ready:".bold());
1228 for issue in ready_issues.iter().take(5) {
1229 let id = issue.short_id.as_deref().unwrap_or("??");
1230 println!(
1231 " {} {} {} {}",
1232 id.dimmed(),
1233 issue.title,
1234 format!("[{}]", issue.issue_type).dimmed(),
1235 format!("P{}", issue.priority).dimmed()
1236 );
1237 }
1238 }
1239 println!();
1240 }
1241
1242 if !memory.is_empty() {
1244 println!("{}", "Project Memory".cyan().bold());
1245 for item in memory.iter().take(10) {
1246 println!(
1247 " {} {} {}",
1248 item.key.bold(),
1249 format!("[{}]", item.category).dimmed(),
1250 truncate(&item.value, 60)
1251 );
1252 }
1253 println!();
1254 }
1255
1256 if let Some(t) = transcript {
1258 println!("{}", "Recent Transcripts".magenta().bold());
1259 for entry in &t.entries {
1260 if let Some(ts) = &entry.timestamp {
1261 println!(" {} {}", ts.dimmed(), truncate(&entry.summary, 100));
1262 } else {
1263 println!(" {}", truncate(&entry.summary, 100));
1264 }
1265 }
1266 println!();
1267 }
1268
1269 println!("{}", "Quick Reference".dimmed().bold());
1271 for c in cmd_ref {
1272 println!(" {} {}", c.cmd.cyan(), format!("# {}", c.desc).dimmed());
1273 }
1274 println!();
1275 println!(
1276 "{}",
1277 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".magenta()
1278 );
1279 println!();
1280}
1281
1282#[allow(clippy::too_many_arguments)]
1287fn print_compact(
1288 session: &crate::storage::Session,
1289 git_branch: &Option<String>,
1290 _git_status: &Option<String>,
1291 high_priority: &[crate::storage::ContextItem],
1292 decisions: &[crate::storage::ContextItem],
1293 reminders: &[crate::storage::ContextItem],
1294 _progress: &[crate::storage::ContextItem],
1295 active_issues: &[crate::storage::Issue],
1296 ready_issues: &[crate::storage::Issue],
1297 all_open: &[crate::storage::Issue],
1298 memory: &[crate::storage::Memory],
1299 transcript: &Option<TranscriptBlock>,
1300 total_items: usize,
1301 cmd_ref: &[CmdRef],
1302) {
1303 println!("# SaveContext Prime");
1305 print!("Session: \"{}\" ({})", session.name, session.status);
1306 if let Some(branch) = git_branch {
1307 print!(" | Branch: {branch}");
1308 }
1309 println!(" | {total_items} context items");
1310 println!();
1311
1312 if !high_priority.is_empty() {
1313 println!("## High Priority");
1314 for item in high_priority.iter().take(5) {
1315 println!(
1316 "- {}: {} [{}]",
1317 item.key,
1318 truncate(&item.value, 100),
1319 item.category
1320 );
1321 }
1322 println!();
1323 }
1324
1325 if !decisions.is_empty() {
1326 println!("## Decisions");
1327 for item in decisions.iter().take(5) {
1328 println!("- {}: {}", item.key, truncate(&item.value, 100));
1329 }
1330 println!();
1331 }
1332
1333 if !reminders.is_empty() {
1334 println!("## Reminders");
1335 for item in reminders.iter().take(5) {
1336 println!("- {}: {}", item.key, truncate(&item.value, 100));
1337 }
1338 println!();
1339 }
1340
1341 if !active_issues.is_empty() || !ready_issues.is_empty() {
1342 println!("## Issues ({} open)", all_open.len());
1343 for issue in active_issues {
1344 let id = issue.short_id.as_deref().unwrap_or("??");
1345 println!(
1346 "- [{}] {} ({}/P{})",
1347 id, issue.title, issue.status, issue.priority
1348 );
1349 }
1350 for issue in ready_issues.iter().take(5) {
1351 let id = issue.short_id.as_deref().unwrap_or("??");
1352 println!("- [{}] {} (ready/P{})", id, issue.title, issue.priority);
1353 }
1354 println!();
1355 }
1356
1357 if !memory.is_empty() {
1358 println!("## Memory");
1359 for item in memory.iter().take(10) {
1360 println!("- {} [{}]: {}", item.key, item.category, truncate(&item.value, 80));
1361 }
1362 println!();
1363 }
1364
1365 if let Some(t) = transcript {
1366 println!("## Recent Transcripts");
1367 for entry in &t.entries {
1368 println!("- {}", truncate(&entry.summary, 120));
1369 }
1370 println!();
1371 }
1372
1373 println!("## Quick Reference");
1374 for c in cmd_ref {
1375 println!("- `{}` — {}", c.cmd, c.desc);
1376 }
1377}
1378
1379fn get_git_status() -> Option<String> {
1385 std::process::Command::new("git")
1386 .args(["status", "--porcelain"])
1387 .output()
1388 .ok()
1389 .filter(|output| output.status.success())
1390 .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1391}
1392
1393fn truncate(s: &str, max_len: usize) -> String {
1395 let first_line = s.lines().next().unwrap_or(s);
1397 if first_line.len() <= max_len {
1398 first_line.to_string()
1399 } else {
1400 format!("{}...", &first_line[..max_len.saturating_sub(3)])
1401 }
1402}
1403
1404#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 #[test]
1413 fn test_temporal_decay_now() {
1414 let now = 1_700_000_000_000i64;
1415 assert!((temporal_decay(now, now, 14.0) - 1.0).abs() < 1e-10);
1416 }
1417
1418 #[test]
1419 fn test_temporal_decay_half_life() {
1420 let now = 1_700_000_000_000i64;
1421 let fourteen_days_ago = now - 14 * 86_400_000;
1422 let decay = temporal_decay(fourteen_days_ago, now, 14.0);
1423 assert!((decay - 0.5).abs() < 0.01, "Expected ~0.5, got {decay}");
1424 }
1425
1426 #[test]
1427 fn test_temporal_decay_double_half_life() {
1428 let now = 1_700_000_000_000i64;
1429 let twenty_eight_days_ago = now - 28 * 86_400_000;
1430 let decay = temporal_decay(twenty_eight_days_ago, now, 14.0);
1431 assert!((decay - 0.25).abs() < 0.01, "Expected ~0.25, got {decay}");
1432 }
1433
1434 #[test]
1435 fn test_temporal_decay_future_item() {
1436 let now = 1_700_000_000_000i64;
1437 assert!((temporal_decay(now + 1000, now, 14.0) - 1.0).abs() < 1e-10);
1439 }
1440
1441 #[test]
1442 fn test_priority_weight_values() {
1443 assert!((priority_weight("high") - 3.0).abs() < 1e-10);
1444 assert!((priority_weight("normal") - 1.0).abs() < 1e-10);
1445 assert!((priority_weight("low") - 0.5).abs() < 1e-10);
1446 assert!((priority_weight("unknown") - 1.0).abs() < 1e-10);
1447 }
1448
1449 #[test]
1450 fn test_category_weight_values() {
1451 assert!((category_weight("decision") - 2.0).abs() < 1e-10);
1452 assert!((category_weight("reminder") - 1.5).abs() < 1e-10);
1453 assert!((category_weight("progress") - 1.0).abs() < 1e-10);
1454 assert!((category_weight("note") - 0.5).abs() < 1e-10);
1455 assert!((category_weight("other") - 1.0).abs() < 1e-10);
1456 }
1457
1458 #[test]
1459 fn test_semantic_boost_no_embeddings() {
1460 assert!((semantic_boost(None, None) - 1.0).abs() < 1e-10);
1461 assert!((semantic_boost(Some(&[1.0, 0.0]), None) - 1.0).abs() < 1e-10);
1462 assert!((semantic_boost(None, Some(&[1.0, 0.0])) - 1.0).abs() < 1e-10);
1463 }
1464
1465 #[test]
1466 fn test_semantic_boost_identical() {
1467 let emb = vec![1.0, 0.0, 0.0];
1468 let boost = semantic_boost(Some(&emb), Some(&emb));
1469 assert!((boost - 2.5).abs() < 0.01, "Expected 2.5, got {boost}");
1471 }
1472
1473 #[test]
1474 fn test_semantic_boost_orthogonal() {
1475 let a = vec![1.0, 0.0, 0.0];
1476 let b = vec![0.0, 1.0, 0.0];
1477 let boost = semantic_boost(Some(&a), Some(&b));
1478 assert!((boost - 1.0).abs() < 0.01, "Expected 1.0, got {boost}");
1480 }
1481
1482 #[test]
1483 fn test_cosine_similarity_identical() {
1484 let v = vec![1.0f32, 2.0, 3.0];
1485 let sim = cosine_similarity_f64(&v, &v);
1486 assert!((sim - 1.0).abs() < 1e-6, "Expected 1.0, got {sim}");
1487 }
1488
1489 #[test]
1490 fn test_cosine_similarity_orthogonal() {
1491 let a = vec![1.0f32, 0.0];
1492 let b = vec![0.0f32, 1.0];
1493 let sim = cosine_similarity_f64(&a, &b);
1494 assert!(sim.abs() < 1e-6, "Expected 0.0, got {sim}");
1495 }
1496
1497 #[test]
1498 fn test_cosine_similarity_opposite() {
1499 let a = vec![1.0f32, 0.0];
1500 let b = vec![-1.0f32, 0.0];
1501 let sim = cosine_similarity_f64(&a, &b);
1502 assert!((sim - (-1.0)).abs() < 1e-6, "Expected -1.0, got {sim}");
1503 }
1504
1505 #[test]
1506 fn test_cosine_similarity_empty() {
1507 let sim = cosine_similarity_f64(&[], &[]);
1508 assert!((sim - 0.0).abs() < 1e-10);
1509 }
1510
1511 #[test]
1512 fn test_cosine_similarity_mismatched_length() {
1513 let a = vec![1.0f32, 2.0];
1514 let b = vec![1.0f32];
1515 assert!((cosine_similarity_f64(&a, &b) - 0.0).abs() < 1e-10);
1516 }
1517
1518 #[test]
1519 fn test_estimate_tokens() {
1520 assert_eq!(estimate_tokens("key", "value"), 7);
1522 assert_eq!(estimate_tokens("", ""), 5);
1524 }
1525
1526 fn make_scored_item(key: &str, value: &str, score: f64, embedding: Option<Vec<f32>>) -> ScoredItem {
1527 ScoredItem {
1528 item: ContextItem {
1529 id: format!("id_{key}"),
1530 session_id: "sess_test".to_string(),
1531 key: key.to_string(),
1532 value: value.to_string(),
1533 category: "note".to_string(),
1534 priority: "normal".to_string(),
1535 channel: None,
1536 tags: None,
1537 size: value.len() as i64,
1538 created_at: 0,
1539 updated_at: 0,
1540 },
1541 score,
1542 token_estimate: estimate_tokens(key, value),
1543 embedding,
1544 }
1545 }
1546
1547 #[test]
1548 fn test_pack_to_budget_all_fit() {
1549 let items = vec![
1550 make_scored_item("a", "short", 3.0, None),
1551 make_scored_item("b", "also short", 2.0, None),
1552 ];
1553 let packed = pack_to_budget(items, 4000);
1554 assert_eq!(packed.len(), 2);
1555 }
1556
1557 #[test]
1558 fn test_pack_to_budget_overflow() {
1559 let big_value = "x".repeat(4000);
1561 let items = vec![
1562 make_scored_item("a", &big_value, 3.0, None),
1563 make_scored_item("b", "fits", 2.0, None),
1564 ];
1565 let packed = pack_to_budget(items, 500);
1566 assert_eq!(packed.len(), 1);
1568 assert_eq!(packed[0].item.key, "b");
1569 }
1570
1571 #[test]
1572 fn test_pack_to_budget_empty() {
1573 let packed = pack_to_budget(vec![], 4000);
1574 assert!(packed.is_empty());
1575 }
1576
1577 #[test]
1578 fn test_mmr_no_embeddings() {
1579 let items = vec![
1580 make_scored_item("a", "one", 3.0, None),
1581 make_scored_item("b", "two", 2.0, None),
1582 ];
1583 let result = apply_mmr(items, 0.7);
1584 assert_eq!(result.len(), 2);
1586 assert_eq!(result[0].item.key, "a");
1587 assert_eq!(result[1].item.key, "b");
1588 }
1589
1590 #[test]
1591 fn test_mmr_with_embeddings_preserves_count() {
1592 let items = vec![
1593 make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0, 0.0])),
1594 make_scored_item("b", "two", 2.0, Some(vec![0.0, 1.0, 0.0])),
1595 make_scored_item("c", "three", 1.0, Some(vec![0.0, 0.0, 1.0])),
1596 ];
1597 let result = apply_mmr(items, 0.7);
1598 assert_eq!(result.len(), 3);
1599 }
1600
1601 #[test]
1602 fn test_mmr_diverse_items_keep_order() {
1603 let items = vec![
1605 make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0, 0.0])),
1606 make_scored_item("b", "two", 2.0, Some(vec![0.0, 1.0, 0.0])),
1607 make_scored_item("c", "three", 1.0, Some(vec![0.0, 0.0, 1.0])),
1608 ];
1609 let result = apply_mmr(items, 0.7);
1610 assert_eq!(result[0].item.key, "a");
1611 }
1612
1613 #[test]
1614 fn test_mmr_penalizes_duplicates() {
1615 let items = vec![
1618 make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0])),
1619 make_scored_item("b", "two", 2.5, Some(vec![1.0, 0.0])), make_scored_item("c", "three", 2.5, Some(vec![0.0, 1.0])), ];
1622 let result = apply_mmr(items, 0.7);
1623 assert_eq!(result[0].item.key, "a");
1626 assert_eq!(result[1].item.key, "c", "Diverse item should rank above near-duplicate");
1627 }
1628
1629 #[test]
1630 fn test_mmr_mixed_embeddings() {
1631 let items = vec![
1633 make_scored_item("a", "one", 3.0, Some(vec![1.0, 0.0])),
1634 make_scored_item("b", "two", 2.0, None), make_scored_item("c", "three", 1.0, Some(vec![0.0, 1.0])),
1636 ];
1637 let result = apply_mmr(items, 0.7);
1638 assert_eq!(result.len(), 3);
1639 assert_eq!(result[2].item.key, "b");
1641 }
1642}