1use chrono::{DateTime, Utc};
14use orcs_auth::CommandGrant;
15use orcs_component::ComponentSnapshot;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::PathBuf;
19use uuid::Uuid;
20
21pub const SESSION_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SessionAsset {
43 pub id: String,
45
46 pub version: u32,
48
49 pub created_at_ms: u64,
51
52 pub updated_at_ms: u64,
54
55 pub history: ConversationHistory,
57
58 pub preferences: UserPreferences,
60
61 pub project_context: ProjectContext,
63
64 pub skill_configs: HashMap<String, SkillConfig>,
66
67 #[serde(default)]
69 pub component_snapshots: HashMap<String, ComponentSnapshot>,
70
71 #[serde(default)]
77 pub granted_commands: Vec<CommandGrant>,
78
79 pub metadata: HashMap<String, serde_json::Value>,
81}
82
83impl SessionAsset {
84 #[must_use]
86 pub fn new() -> Self {
87 let now = current_time_ms();
88 Self {
89 id: Uuid::new_v4().to_string(),
90 version: SESSION_VERSION,
91 created_at_ms: now,
92 updated_at_ms: now,
93 history: ConversationHistory::new(),
94 preferences: UserPreferences::default(),
95 project_context: ProjectContext::default(),
96 skill_configs: HashMap::new(),
97 component_snapshots: HashMap::new(),
98 granted_commands: Vec::new(),
99 metadata: HashMap::new(),
100 }
101 }
102
103 #[must_use]
105 pub fn for_project(path: impl Into<PathBuf>) -> Self {
106 let mut asset = Self::new();
107 asset.project_context.root_path = Some(path.into());
108 asset
109 }
110
111 pub fn touch(&mut self) {
113 self.updated_at_ms = current_time_ms();
114 }
115
116 pub fn add_turn(&mut self, turn: ConversationTurn) {
118 self.history.turns.push(turn);
119 self.touch();
120 }
121
122 pub fn set_skill_config(&mut self, skill_id: impl Into<String>, config: SkillConfig) {
124 self.skill_configs.insert(skill_id.into(), config);
125 self.touch();
126 }
127
128 pub fn save_snapshot(&mut self, snapshot: ComponentSnapshot) {
130 self.component_snapshots
131 .insert(snapshot.component_fqn.clone(), snapshot);
132 self.touch();
133 }
134
135 #[must_use]
137 pub fn get_snapshot(&self, fqn: &str) -> Option<&ComponentSnapshot> {
138 self.component_snapshots.get(fqn)
139 }
140
141 pub fn remove_snapshot(&mut self, fqn: &str) -> Option<ComponentSnapshot> {
143 let snapshot = self.component_snapshots.remove(fqn);
144 if snapshot.is_some() {
145 self.touch();
146 }
147 snapshot
148 }
149
150 pub fn clear_snapshots(&mut self) {
152 if !self.component_snapshots.is_empty() {
153 self.component_snapshots.clear();
154 self.touch();
155 }
156 }
157
158 #[must_use]
160 pub fn snapshot_count(&self) -> usize {
161 self.component_snapshots.len()
162 }
163
164 pub fn save_grants(&mut self, grants: Vec<CommandGrant>) {
171 if self.granted_commands != grants {
172 self.granted_commands = grants;
173 self.touch();
174 }
175 }
176
177 #[must_use]
179 pub fn granted_commands(&self) -> &[CommandGrant] {
180 &self.granted_commands
181 }
182
183 #[must_use]
185 pub fn grant_count(&self) -> usize {
186 self.granted_commands.len()
187 }
188
189 #[must_use]
191 pub fn created_at(&self) -> DateTime<Utc> {
192 DateTime::from_timestamp_millis(self.created_at_ms as i64).unwrap_or_else(Utc::now)
193 }
194
195 #[must_use]
197 pub fn updated_at(&self) -> DateTime<Utc> {
198 DateTime::from_timestamp_millis(self.updated_at_ms as i64).unwrap_or_else(Utc::now)
199 }
200
201 pub fn to_json(&self) -> Result<String, serde_json::Error> {
203 serde_json::to_string_pretty(self)
204 }
205
206 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
208 serde_json::from_str(json)
209 }
210
211 #[must_use]
213 pub fn checksum(&self) -> String {
214 use std::hash::{Hash, Hasher};
215 let mut hasher = std::collections::hash_map::DefaultHasher::new();
216 self.id.hash(&mut hasher);
217 self.updated_at_ms.hash(&mut hasher);
218 self.history.turns.len().hash(&mut hasher);
219 format!("{:016x}", hasher.finish())
220 }
221}
222
223impl Default for SessionAsset {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
231pub struct ConversationHistory {
232 pub turns: Vec<ConversationTurn>,
234
235 pub compacted: Vec<CompactedTurn>,
237
238 #[serde(default = "default_max_turns")]
240 pub max_turns: usize,
241}
242
243fn default_max_turns() -> usize {
244 1000
245}
246
247impl ConversationHistory {
248 #[must_use]
250 pub fn new() -> Self {
251 Self {
252 turns: Vec::new(),
253 compacted: Vec::new(),
254 max_turns: 1000,
255 }
256 }
257
258 #[must_use]
260 pub fn len(&self) -> usize {
261 self.turns.len()
262 }
263
264 #[must_use]
266 pub fn is_empty(&self) -> bool {
267 self.turns.is_empty()
268 }
269
270 #[must_use]
272 pub fn recent(&self, n: usize) -> &[ConversationTurn] {
273 let start = self.turns.len().saturating_sub(n);
274 &self.turns[start..]
275 }
276
277 #[must_use]
279 pub fn search(&self, keyword: &str) -> Vec<&ConversationTurn> {
280 let keyword_lower = keyword.to_lowercase();
281 self.turns
282 .iter()
283 .filter(|turn| {
284 turn.content.to_lowercase().contains(&keyword_lower)
285 || turn
286 .tags
287 .iter()
288 .any(|t| t.to_lowercase().contains(&keyword_lower))
289 })
290 .collect()
291 }
292
293 #[must_use]
295 pub fn in_range(&self, start_ms: u64, end_ms: u64) -> Vec<&ConversationTurn> {
296 self.turns
297 .iter()
298 .filter(|turn| turn.timestamp_ms >= start_ms && turn.timestamp_ms <= end_ms)
299 .collect()
300 }
301
302 pub fn compact(&mut self, summarizer: impl Fn(&[ConversationTurn]) -> String) {
307 if self.turns.len() > self.max_turns {
308 let to_compact = self.turns.len() - self.max_turns;
309 let old_turns: Vec<_> = self.turns.drain(..to_compact).collect();
310
311 let summary = summarizer(&old_turns);
312 self.compacted.push(CompactedTurn {
313 summary,
314 turn_count: old_turns.len(),
315 start_time_ms: old_turns.first().map(|t| t.timestamp_ms).unwrap_or(0),
316 end_time_ms: old_turns.last().map(|t| t.timestamp_ms).unwrap_or(0),
317 });
318 }
319 }
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ConversationTurn {
325 pub id: String,
327
328 pub timestamp_ms: u64,
330
331 pub role: String,
333
334 pub content: String,
336
337 pub tool_calls: Vec<ToolCallRecord>,
339
340 pub tags: Vec<String>,
342
343 pub metadata: HashMap<String, serde_json::Value>,
345}
346
347impl ConversationTurn {
348 #[must_use]
350 pub fn user(content: impl Into<String>) -> Self {
351 Self {
352 id: Uuid::new_v4().to_string(),
353 timestamp_ms: current_time_ms(),
354 role: "user".into(),
355 content: content.into(),
356 tool_calls: Vec::new(),
357 tags: Vec::new(),
358 metadata: HashMap::new(),
359 }
360 }
361
362 #[must_use]
364 pub fn assistant(content: impl Into<String>) -> Self {
365 Self {
366 id: Uuid::new_v4().to_string(),
367 timestamp_ms: current_time_ms(),
368 role: "assistant".into(),
369 content: content.into(),
370 tool_calls: Vec::new(),
371 tags: Vec::new(),
372 metadata: HashMap::new(),
373 }
374 }
375
376 #[must_use]
378 pub fn system(content: impl Into<String>) -> Self {
379 Self {
380 id: Uuid::new_v4().to_string(),
381 timestamp_ms: current_time_ms(),
382 role: "system".into(),
383 content: content.into(),
384 tool_calls: Vec::new(),
385 tags: Vec::new(),
386 metadata: HashMap::new(),
387 }
388 }
389
390 #[must_use]
392 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
393 self.tags = tags;
394 self
395 }
396
397 #[must_use]
399 pub fn with_tool_calls(mut self, calls: Vec<ToolCallRecord>) -> Self {
400 self.tool_calls = calls;
401 self
402 }
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ToolCallRecord {
408 pub tool_name: String,
410
411 pub args: serde_json::Value,
413
414 pub result: Option<serde_json::Value>,
416
417 pub success: bool,
419
420 pub duration_ms: u64,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct CompactedTurn {
427 pub summary: String,
429
430 pub turn_count: usize,
432
433 pub start_time_ms: u64,
435
436 pub end_time_ms: u64,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, Default)]
442pub struct UserPreferences {
443 pub coding_style: CodingStylePrefs,
445
446 pub communication: CommunicationPrefs,
448
449 pub frequent_tools: Vec<String>,
451
452 pub custom: HashMap<String, serde_json::Value>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize, Default)]
458pub struct CodingStylePrefs {
459 pub preferred_languages: Vec<String>,
461
462 pub indent: Option<String>,
464
465 pub indent_size: Option<u8>,
467
468 pub naming_convention: Option<String>,
470
471 pub comment_style: Option<String>,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize, Default)]
477pub struct CommunicationPrefs {
478 pub verbosity: Option<String>,
480
481 pub language: Option<String>,
483
484 pub formality: Option<String>,
486
487 #[serde(default)]
489 pub include_examples: bool,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, Default)]
494pub struct ProjectContext {
495 pub root_path: Option<PathBuf>,
497
498 pub name: Option<String>,
500
501 pub technologies: Vec<String>,
503
504 pub key_files: Vec<String>,
506
507 pub architecture_notes: Vec<String>,
509
510 pub learned_facts: Vec<LearnedFact>,
512
513 pub persistent_contexts: Vec<ContextInjection>,
515}
516
517impl ProjectContext {
518 pub fn learn(&mut self, fact: LearnedFact) {
520 if !self.learned_facts.iter().any(|f| f.key == fact.key) {
521 self.learned_facts.push(fact);
522 }
523 }
524
525 pub fn add_persistent_context(&mut self, ctx: ContextInjection) {
527 self.persistent_contexts.push(ctx);
528 }
529
530 #[must_use]
532 pub fn find_facts(&self, keyword: &str) -> Vec<&LearnedFact> {
533 let keyword_lower = keyword.to_lowercase();
534 self.learned_facts
535 .iter()
536 .filter(|f| {
537 f.key.to_lowercase().contains(&keyword_lower)
538 || f.value.to_lowercase().contains(&keyword_lower)
539 })
540 .collect()
541 }
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct LearnedFact {
547 pub key: String,
549
550 pub value: String,
552
553 pub source: String,
555
556 pub confidence: f32,
558
559 pub learned_at_ms: u64,
561}
562
563impl LearnedFact {
564 #[must_use]
566 pub fn new(
567 key: impl Into<String>,
568 value: impl Into<String>,
569 source: impl Into<String>,
570 ) -> Self {
571 Self {
572 key: key.into(),
573 value: value.into(),
574 source: source.into(),
575 confidence: 1.0,
576 learned_at_ms: current_time_ms(),
577 }
578 }
579
580 #[must_use]
582 pub fn with_confidence(mut self, confidence: f32) -> Self {
583 self.confidence = confidence.clamp(0.0, 1.0);
584 self
585 }
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct ContextInjection {
591 pub id: String,
593
594 pub timing: String,
596
597 pub content: String,
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct SkillConfig {
604 pub skill_id: String,
606
607 pub enabled: bool,
609
610 pub auto_trigger: AutoTriggerConfig,
612
613 pub params: HashMap<String, serde_json::Value>,
615}
616
617impl SkillConfig {
618 #[must_use]
620 pub fn new(skill_id: impl Into<String>) -> Self {
621 Self {
622 skill_id: skill_id.into(),
623 enabled: true,
624 auto_trigger: AutoTriggerConfig::default(),
625 params: HashMap::new(),
626 }
627 }
628
629 #[must_use]
631 pub fn disabled(skill_id: impl Into<String>) -> Self {
632 Self {
633 enabled: false,
634 ..Self::new(skill_id)
635 }
636 }
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, Default)]
641pub struct AutoTriggerConfig {
642 pub enabled: bool,
644
645 pub triggers: Vec<TriggerCondition>,
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize)]
651pub enum TriggerCondition {
652 OnFileEdit { glob: String },
654
655 OnLLMPreRequest,
657
658 OnLLMPostResponse,
660
661 OnEvent { kind: String },
663
664 Interval { seconds: u64 },
666}
667
668fn current_time_ms() -> u64 {
671 use std::time::{SystemTime, UNIX_EPOCH};
672 SystemTime::now()
673 .duration_since(UNIX_EPOCH)
674 .map(|d| d.as_millis() as u64)
675 .unwrap_or(0)
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn session_asset_new() {
684 let asset = SessionAsset::new();
685 assert!(!asset.id.is_empty());
686 assert_eq!(asset.version, SESSION_VERSION);
687 assert!(asset.history.is_empty());
688 }
689
690 #[test]
691 fn session_asset_for_project() {
692 let asset = SessionAsset::for_project("/path/to/project");
693 assert_eq!(
694 asset.project_context.root_path,
695 Some(PathBuf::from("/path/to/project"))
696 );
697 }
698
699 #[test]
700 fn session_asset_touch() {
701 let mut asset = SessionAsset::new();
702 let original = asset.updated_at_ms;
703 std::thread::sleep(std::time::Duration::from_millis(10));
704 asset.touch();
705 assert!(asset.updated_at_ms >= original);
706 }
707
708 #[test]
709 fn conversation_history_search() {
710 let mut history = ConversationHistory::new();
711 history.turns.push(ConversationTurn::user("Hello Rust"));
712 history
713 .turns
714 .push(ConversationTurn::user("Python is great"));
715 history
716 .turns
717 .push(ConversationTurn::user("rust programming"));
718
719 let results = history.search("rust");
720 assert_eq!(results.len(), 2);
721 }
722
723 #[test]
724 fn conversation_history_recent() {
725 let mut history = ConversationHistory::new();
726 for i in 0..10 {
727 history
728 .turns
729 .push(ConversationTurn::user(format!("Turn {i}")));
730 }
731
732 let recent = history.recent(3);
733 assert_eq!(recent.len(), 3);
734 assert!(recent[0].content.contains('7'));
735 }
736
737 #[test]
738 fn project_context_learn() {
739 let mut ctx = ProjectContext::default();
740 ctx.learn(LearnedFact::new("db", "PostgreSQL", "user"));
741 ctx.learn(LearnedFact::new("framework", "Actix", "codebase"));
742
743 let facts = ctx.find_facts("postgre");
744 assert_eq!(facts.len(), 1);
745 assert_eq!(facts[0].value, "PostgreSQL");
746 }
747
748 #[test]
749 fn session_asset_json_roundtrip() {
750 let mut asset = SessionAsset::new();
751 asset.add_turn(ConversationTurn::user("test"));
752 asset.preferences.coding_style.preferred_languages = vec!["Rust".into()];
753
754 let json = asset.to_json().expect("serialize session asset");
755 let restored = SessionAsset::from_json(&json).expect("deserialize session asset");
756
757 assert_eq!(asset.id, restored.id);
758 assert_eq!(restored.history.len(), 1);
759 assert_eq!(
760 restored.preferences.coding_style.preferred_languages,
761 vec!["Rust".to_string()]
762 );
763 }
764
765 #[test]
766 fn skill_config_new() {
767 let config = SkillConfig::new("lint");
768 assert!(config.enabled);
769 assert_eq!(config.skill_id, "lint");
770 }
771
772 #[test]
773 fn checksum_changes_on_update() {
774 let mut asset = SessionAsset::new();
775 let checksum1 = asset.checksum();
776
777 asset.add_turn(ConversationTurn::user("test"));
778 let checksum2 = asset.checksum();
779
780 assert_ne!(checksum1, checksum2);
781 }
782
783 #[test]
786 fn grants_default_empty() {
787 let asset = SessionAsset::new();
788 assert!(asset.granted_commands().is_empty());
789 assert_eq!(asset.grant_count(), 0);
790 }
791
792 #[test]
793 fn save_and_get_grants() {
794 use orcs_auth::CommandGrant;
795
796 let mut asset = SessionAsset::new();
797 let grants = vec![
798 CommandGrant::persistent("ls"),
799 CommandGrant::persistent("cargo"),
800 ];
801 asset.save_grants(grants);
802
803 assert_eq!(asset.grant_count(), 2);
804 assert_eq!(asset.granted_commands()[0].pattern, "ls");
805 assert_eq!(asset.granted_commands()[1].pattern, "cargo");
806 }
807
808 #[test]
809 fn grants_json_roundtrip() {
810 use orcs_auth::{CommandGrant, GrantKind};
811
812 let mut asset = SessionAsset::new();
813 asset.save_grants(vec![
814 CommandGrant::persistent("ls"),
815 CommandGrant::one_time("rm -rf"),
816 ]);
817
818 let json = asset.to_json().expect("serialize grants asset");
819 let restored = SessionAsset::from_json(&json).expect("deserialize grants asset");
820
821 assert_eq!(restored.grant_count(), 2);
822 assert_eq!(restored.granted_commands()[0].pattern, "ls");
823 assert_eq!(restored.granted_commands()[0].kind, GrantKind::Persistent);
824 assert_eq!(restored.granted_commands()[1].pattern, "rm -rf");
825 assert_eq!(restored.granted_commands()[1].kind, GrantKind::OneTime);
826 }
827
828 #[test]
829 fn grants_backward_compat_missing_field() {
830 let json = r#"{
832 "id": "test-id",
833 "version": 1,
834 "created_at_ms": 0,
835 "updated_at_ms": 0,
836 "history": { "turns": [], "compacted": [], "max_turns": 1000 },
837 "preferences": {
838 "coding_style": { "preferred_languages": [] },
839 "communication": {},
840 "frequent_tools": [],
841 "custom": {}
842 },
843 "project_context": {
844 "technologies": [],
845 "key_files": [],
846 "architecture_notes": [],
847 "learned_facts": [],
848 "persistent_contexts": []
849 },
850 "skill_configs": {},
851 "component_snapshots": {},
852 "metadata": {}
853 }"#;
854
855 let asset = SessionAsset::from_json(json).expect("backward compat deserialization");
856 assert!(asset.granted_commands().is_empty());
857 }
858}