Skip to content

Commit 6c42718

Browse files
committed
✨ Add Iris Companion for ambient session awareness
Introduce a new companion module that transforms Studio into an always-aware development assistant. The companion tracks: - Branch memory: Persists context across sessions including last focus, notes, and commit counts. Shows welcome messages when returning. - Session state: Files touched, commits made, and session duration. - Live file watching: Monitors repository changes with debouncing and gitignore filtering using the notify crate. Data persists to ~/.iris/repos/{repo-hash}/ with atomic writes for safety. Companion events integrate with Studio's event system and update the UI in real-time. Dependencies: notify, notify-debouncer-full, ignore
1 parent 6bfc3be commit 6c42718

File tree

13 files changed

+1514
-28
lines changed

13 files changed

+1514
-28
lines changed

Cargo.lock

Lines changed: 178 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ crossterm = "0.28.1"
3434
dirs = "6.0.0"
3535
futures = "0.3.30"
3636
git2 = "0.20.1"
37+
ignore = "0.4"
3738
indicatif = "0.17.8"
3839
log = "0.4.27"
40+
notify = "8.0"
41+
notify-debouncer-full = "0.5"
3942
lru = "0.12"
4043
once_cell = "1.21.3"
4144
parking_lot = "0.12.1"

src/companion/branch_memory.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//! Branch memory for Iris Companion
2+
//!
3+
//! Remembers context per branch across sessions.
4+
5+
use chrono::{DateTime, Utc};
6+
use serde::{Deserialize, Serialize};
7+
use std::path::PathBuf;
8+
9+
/// Focus state - where the user was last working
10+
#[derive(Debug, Clone, Serialize, Deserialize)]
11+
pub struct FileFocus {
12+
/// Path to the focused file
13+
pub path: PathBuf,
14+
/// Line number in the file
15+
pub line: usize,
16+
/// When this focus was recorded
17+
pub timestamp: DateTime<Utc>,
18+
}
19+
20+
impl FileFocus {
21+
/// Create a new file focus
22+
pub fn new(path: PathBuf, line: usize) -> Self {
23+
Self {
24+
path,
25+
line,
26+
timestamp: Utc::now(),
27+
}
28+
}
29+
}
30+
31+
/// Per-branch persistent memory
32+
#[derive(Debug, Clone, Serialize, Deserialize)]
33+
pub struct BranchMemory {
34+
/// Branch name
35+
pub branch_name: String,
36+
/// When this branch was first visited
37+
pub created_at: DateTime<Utc>,
38+
/// When this branch was last visited
39+
pub last_visited: DateTime<Utc>,
40+
/// Last focused file and line
41+
pub last_focus: Option<FileFocus>,
42+
/// User notes for this branch
43+
pub notes: Vec<String>,
44+
/// Number of sessions on this branch
45+
pub session_count: u32,
46+
/// Number of commits made on this branch (across sessions)
47+
pub total_commits: u32,
48+
}
49+
50+
impl BranchMemory {
51+
/// Create new branch memory
52+
pub fn new(branch_name: String) -> Self {
53+
let now = Utc::now();
54+
Self {
55+
branch_name,
56+
created_at: now,
57+
last_visited: now,
58+
last_focus: None,
59+
notes: Vec::new(),
60+
session_count: 1,
61+
total_commits: 0,
62+
}
63+
}
64+
65+
/// Record a new session visit
66+
pub fn record_visit(&mut self) {
67+
self.last_visited = Utc::now();
68+
self.session_count += 1;
69+
}
70+
71+
/// Update last focus
72+
pub fn set_focus(&mut self, path: PathBuf, line: usize) {
73+
self.last_focus = Some(FileFocus::new(path, line));
74+
}
75+
76+
/// Clear focus
77+
pub fn clear_focus(&mut self) {
78+
self.last_focus = None;
79+
}
80+
81+
/// Add a note
82+
pub fn add_note(&mut self, note: String) {
83+
self.notes.push(note);
84+
}
85+
86+
/// Record a commit
87+
pub fn record_commit(&mut self) {
88+
self.total_commits += 1;
89+
}
90+
91+
/// Time since last visit
92+
pub fn time_since_last_visit(&self) -> chrono::Duration {
93+
Utc::now() - self.last_visited
94+
}
95+
96+
/// Check if this is a returning visit (visited before more than 5 minutes ago)
97+
pub fn is_returning_visit(&self) -> bool {
98+
self.session_count > 1
99+
&& self.time_since_last_visit() > chrono::Duration::minutes(5)
100+
}
101+
102+
/// Generate a welcome message if returning
103+
pub fn welcome_message(&self) -> Option<String> {
104+
if !self.is_returning_visit() {
105+
return None;
106+
}
107+
108+
let duration = self.time_since_last_visit();
109+
let time_str = if duration.num_days() > 0 {
110+
format!("{} days ago", duration.num_days())
111+
} else if duration.num_hours() > 0 {
112+
format!("{} hours ago", duration.num_hours())
113+
} else {
114+
format!("{} minutes ago", duration.num_minutes())
115+
};
116+
117+
Some(format!("Welcome back to {}! Last here {}", self.branch_name, time_str))
118+
}
119+
}

src/companion/mod.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//! Iris Companion - Ambient awareness for Git workflows
2+
//!
3+
//! Provides session tracking, branch memory, and live file watching
4+
//! to transform Studio into an always-aware development companion.
5+
6+
mod branch_memory;
7+
mod session;
8+
mod storage;
9+
mod watcher;
10+
11+
pub use branch_memory::{BranchMemory, FileFocus};
12+
pub use session::{FileActivity, SessionState};
13+
pub use storage::CompanionStorage;
14+
pub use watcher::{CompanionEvent, FileWatcherService};
15+
16+
use anyhow::Result;
17+
use std::path::PathBuf;
18+
use std::sync::Arc;
19+
use tokio::sync::mpsc;
20+
21+
/// Main companion service that coordinates all subsystems
22+
pub struct CompanionService {
23+
/// Repository path being watched
24+
repo_path: PathBuf,
25+
/// Current session state
26+
session: Arc<parking_lot::RwLock<SessionState>>,
27+
/// Storage backend for persistence
28+
storage: CompanionStorage,
29+
/// File watcher service (optional - may fail to start)
30+
watcher: Option<FileWatcherService>,
31+
/// Channel for receiving companion events
32+
event_rx: mpsc::UnboundedReceiver<CompanionEvent>,
33+
/// Channel sender (held to keep channel alive)
34+
_event_tx: mpsc::UnboundedSender<CompanionEvent>,
35+
}
36+
37+
impl CompanionService {
38+
/// Create a new companion service for the given repository
39+
pub fn new(repo_path: PathBuf, branch: &str) -> Result<Self> {
40+
let (event_tx, event_rx) = mpsc::unbounded_channel();
41+
42+
// Initialize storage
43+
let storage = CompanionStorage::new(&repo_path)?;
44+
45+
// Try to load existing session or create new one
46+
let session = storage
47+
.load_session()?
48+
.filter(|s| s.branch == branch) // Only restore if same branch
49+
.unwrap_or_else(|| SessionState::new(repo_path.clone(), branch.to_owned()));
50+
51+
let session = Arc::new(parking_lot::RwLock::new(session));
52+
53+
// Try to start file watcher (non-fatal if it fails)
54+
let watcher = match FileWatcherService::new(&repo_path, event_tx.clone()) {
55+
Ok(w) => {
56+
tracing::info!("Companion file watcher started");
57+
Some(w)
58+
}
59+
Err(e) => {
60+
tracing::warn!("Failed to start file watcher: {}. Companion will run without live updates.", e);
61+
None
62+
}
63+
};
64+
65+
Ok(Self {
66+
repo_path,
67+
session,
68+
storage,
69+
watcher,
70+
event_rx,
71+
_event_tx: event_tx,
72+
})
73+
}
74+
75+
/// Get the current session state
76+
pub fn session(&self) -> &Arc<parking_lot::RwLock<SessionState>> {
77+
&self.session
78+
}
79+
80+
/// Load branch memory for the given branch
81+
pub fn load_branch_memory(&self, branch: &str) -> Result<Option<BranchMemory>> {
82+
self.storage.load_branch_memory(branch)
83+
}
84+
85+
/// Save branch memory
86+
pub fn save_branch_memory(&self, memory: &BranchMemory) -> Result<()> {
87+
self.storage.save_branch_memory(memory)
88+
}
89+
90+
/// Save current session state
91+
pub fn save_session(&self) -> Result<()> {
92+
let session = self.session.read();
93+
self.storage.save_session(&session)
94+
}
95+
96+
/// Record a file touch (opened/modified)
97+
pub fn touch_file(&self, path: PathBuf) {
98+
let mut session = self.session.write();
99+
session.touch_file(path);
100+
}
101+
102+
/// Record a commit was made
103+
pub fn record_commit(&self, hash: String) {
104+
let mut session = self.session.write();
105+
session.record_commit(hash);
106+
}
107+
108+
/// Try to receive the next companion event (non-blocking)
109+
pub fn try_recv_event(&mut self) -> Option<CompanionEvent> {
110+
self.event_rx.try_recv().ok()
111+
}
112+
113+
/// Check if file watcher is active
114+
pub fn has_watcher(&self) -> bool {
115+
self.watcher.is_some()
116+
}
117+
118+
/// Get repository path
119+
pub fn repo_path(&self) -> &PathBuf {
120+
&self.repo_path
121+
}
122+
}
123+
124+
impl Drop for CompanionService {
125+
fn drop(&mut self) {
126+
// Try to save session on shutdown
127+
if let Err(e) = self.save_session() {
128+
tracing::warn!("Failed to save session on shutdown: {}", e);
129+
}
130+
}
131+
}

src/companion/session.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//! Session state tracking for Iris Companion
2+
//!
3+
//! Tracks files touched, time elapsed, and commits made during a session.
4+
5+
use chrono::{DateTime, Utc};
6+
use serde::{Deserialize, Serialize};
7+
use std::collections::HashMap;
8+
use std::path::PathBuf;
9+
use uuid::Uuid;
10+
11+
/// Activity tracking for a single file
12+
#[derive(Debug, Clone, Serialize, Deserialize)]
13+
pub struct FileActivity {
14+
/// Path to the file
15+
pub path: PathBuf,
16+
/// When this file was first touched in the session
17+
pub first_touched: DateTime<Utc>,
18+
/// When this file was last touched
19+
pub last_touched: DateTime<Utc>,
20+
/// Number of times this file was touched
21+
pub touch_count: u32,
22+
}
23+
24+
impl FileActivity {
25+
/// Create a new file activity record
26+
pub fn new(path: PathBuf) -> Self {
27+
let now = Utc::now();
28+
Self {
29+
path,
30+
first_touched: now,
31+
last_touched: now,
32+
touch_count: 1,
33+
}
34+
}
35+
36+
/// Record another touch
37+
pub fn touch(&mut self) {
38+
self.last_touched = Utc::now();
39+
self.touch_count += 1;
40+
}
41+
}
42+
43+
/// Session state for the current Studio session
44+
#[derive(Debug, Clone, Serialize, Deserialize)]
45+
pub struct SessionState {
46+
/// Unique session identifier
47+
pub session_id: Uuid,
48+
/// Repository path
49+
pub repo_path: PathBuf,
50+
/// Current branch name
51+
pub branch: String,
52+
/// When the session started
53+
pub started_at: DateTime<Utc>,
54+
/// Last activity timestamp
55+
pub last_activity: DateTime<Utc>,
56+
/// Files touched during this session
57+
pub files_touched: HashMap<PathBuf, FileActivity>,
58+
/// Commits made during this session (hashes)
59+
pub commits_made: Vec<String>,
60+
}
61+
62+
impl SessionState {
63+
/// Create a new session
64+
pub fn new(repo_path: PathBuf, branch: String) -> Self {
65+
let now = Utc::now();
66+
Self {
67+
session_id: Uuid::new_v4(),
68+
repo_path,
69+
branch,
70+
started_at: now,
71+
last_activity: now,
72+
files_touched: HashMap::new(),
73+
commits_made: Vec::new(),
74+
}
75+
}
76+
77+
/// Record a file touch
78+
pub fn touch_file(&mut self, path: PathBuf) {
79+
self.last_activity = Utc::now();
80+
self.files_touched
81+
.entry(path.clone())
82+
.and_modify(FileActivity::touch)
83+
.or_insert_with(|| FileActivity::new(path));
84+
}
85+
86+
/// Record a commit
87+
pub fn record_commit(&mut self, hash: String) {
88+
self.last_activity = Utc::now();
89+
self.commits_made.push(hash);
90+
}
91+
92+
/// Get session duration
93+
pub fn duration(&self) -> chrono::Duration {
94+
Utc::now() - self.started_at
95+
}
96+
97+
/// Get number of files touched
98+
pub fn files_count(&self) -> usize {
99+
self.files_touched.len()
100+
}
101+
102+
/// Get files ordered by most recently touched
103+
pub fn recent_files(&self) -> Vec<&FileActivity> {
104+
let mut files: Vec<_> = self.files_touched.values().collect();
105+
files.sort_by(|a, b| b.last_touched.cmp(&a.last_touched));
106+
files
107+
}
108+
109+
/// Get time since last commit (if any)
110+
pub fn time_since_last_commit(&self) -> Option<chrono::Duration> {
111+
if self.commits_made.is_empty() {
112+
None
113+
} else {
114+
Some(Utc::now() - self.last_activity)
115+
}
116+
}
117+
118+
/// Update branch (for branch switches)
119+
pub fn set_branch(&mut self, branch: String) {
120+
self.branch = branch;
121+
self.last_activity = Utc::now();
122+
}
123+
}

0 commit comments

Comments
 (0)