Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/aft/src/commands/delete_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ fn delete_one_or_dir(
}

let path = match ctx.validate_path(&req.id, requested_path) {
Ok(path) => path,
Ok(path) => {
ctx.session_history()
.borrow_mut()
.record(req.session(), path.clone(), crate::session_history::FileOp::Delete);
path
}
Err(resp) => return Err(resp),
};

Expand Down
7 changes: 6 additions & 1 deletion crates/aft/src/commands/edit_match.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ fn handle_append(req: &RawRequest, ctx: &AppContext, op_id: &str) -> Response {
.unwrap_or(true);

let path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Ok(path) => {
ctx.session_history()
.borrow_mut()
.record(req.session(), path.clone(), crate::session_history::FileOp::Edit);
path
}
Err(resp) => return resp,
};

Expand Down
7 changes: 6 additions & 1 deletion crates/aft/src/commands/edit_symbol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ pub fn handle_edit_symbol(req: &RawRequest, ctx: &AppContext) -> Response {
}

let path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Ok(path) => {
ctx.session_history()
.borrow_mut()
.record(req.session(), path.clone(), crate::session_history::FileOp::Edit);
path
}
Err(resp) => return resp,
};
if !path.exists() {
Expand Down
7 changes: 6 additions & 1 deletion crates/aft/src/commands/move_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ pub fn handle_move_file(req: &RawRequest, ctx: &AppContext) -> Response {
};

let src_path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Ok(path) => {
ctx.session_history()
.borrow_mut()
.record(req.session(), path.clone(), crate::session_history::FileOp::Move);
path
}
Err(resp) => return resp,
};
let dst_path = match ctx.validate_path(&req.id, Path::new(destination)) {
Expand Down
7 changes: 6 additions & 1 deletion crates/aft/src/commands/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ pub fn handle_read(req: &RawRequest, ctx: &AppContext) -> Response {
};

let path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Ok(path) => {
ctx.session_history()
.borrow_mut()
.record(req.session(), path.clone(), crate::session_history::FileOp::Read);
path
}
Err(resp) => return resp,
};

Expand Down
7 changes: 6 additions & 1 deletion crates/aft/src/commands/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ pub fn handle_write(req: &RawRequest, ctx: &AppContext) -> Response {
.unwrap_or(true);

let path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Ok(path) => {
ctx.session_history()
.borrow_mut()
.record(req.session(), path.clone(), crate::session_history::FileOp::Write);
path
}
Err(resp) => return resp,
};
let existed = path.exists();
Expand Down
7 changes: 6 additions & 1 deletion crates/aft/src/commands/zoom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,12 @@ pub fn handle_zoom(req: &RawRequest, ctx: &AppContext) -> Response {
.map(|v| v as usize);

let path = match resolve_file_or_url(req, ctx, file) {
Ok(path) => path,
Ok(path) => {
ctx.session_history()
.borrow_mut()
.record(req.session(), path.clone(), crate::session_history::FileOp::Zoom);
path
}
Err(resp) => return resp,
};
if !path.exists() {
Expand Down
54 changes: 54 additions & 0 deletions crates/aft/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use crate::parser::{SharedSymbolCache, SymbolCache};
use crate::protocol::{
ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
};
use crate::session_history::{FileOp, SessionHistory};

pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
Expand Down Expand Up @@ -379,6 +380,11 @@ pub struct AppContext {
/// root is configured or when the project has no gitignore files; in that
/// case the watcher falls back to a small hardcoded infra-directory skip.
gitignore: RefCell<Option<Arc<ignore::gitignore::Gitignore>>>,
/// Per-session, in-memory, chronologically-ordered file-access history.
/// Records every `read`, `zoom`, `edit`, `write`, `delete`, and `move`
/// operation so the agent (or user) can answer "what files was I just
/// working on?" within the current session.
session_history: RefCell<SessionHistory>,
}

impl AppContext {
Expand Down Expand Up @@ -432,6 +438,7 @@ impl AppContext {
filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
gitignore: RefCell::new(None),
session_history: RefCell::new(SessionHistory::new()),
}
}

Expand Down Expand Up @@ -465,6 +472,53 @@ impl AppContext {
*self.gitignore.borrow_mut() = None;
}

/// Access the per-session file-access history store.
pub fn session_history(&self) -> &RefCell<SessionHistory> {
&self.session_history
}

/// Record a file read operation in session history.
pub fn record_file_read(&self, session: &str, path: &std::path::Path) {
self.session_history
.borrow_mut()
.record(session, path.to_path_buf(), FileOp::Read);
}

/// Record a file zoom operation in session history.
pub fn record_file_zoom(&self, session: &str, path: &std::path::Path) {
self.session_history
.borrow_mut()
.record(session, path.to_path_buf(), FileOp::Zoom);
}

/// Record a file edit operation in session history.
pub fn record_file_edit(&self, session: &str, path: &std::path::Path) {
self.session_history
.borrow_mut()
.record(session, path.to_path_buf(), FileOp::Edit);
}

/// Record a file write operation in session history.
pub fn record_file_write(&self, session: &str, path: &std::path::Path) {
self.session_history
.borrow_mut()
.record(session, path.to_path_buf(), FileOp::Write);
}

/// Record a file delete operation in session history.
pub fn record_file_delete(&self, session: &str, path: &std::path::Path) {
self.session_history
.borrow_mut()
.record(session, path.to_path_buf(), FileOp::Delete);
}

/// Record a file move operation in session history.
pub fn record_file_move(&self, session: &str, path: &std::path::Path) {
self.session_history
.borrow_mut()
.record(session, path.to_path_buf(), FileOp::Move);
}

pub fn rebuild_gitignore(&self) {
use ignore::gitignore::GitignoreBuilder;
use std::path::Path;
Expand Down
1 change: 1 addition & 0 deletions crates/aft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ pub mod protocol;
pub mod query_shape;
pub mod search_index;
pub mod semantic_index;
pub mod session_history;
pub mod symbol_cache_disk;
pub mod symbols;
pub mod url_fetch;
Expand Down
19 changes: 19 additions & 0 deletions crates/aft/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ fn dispatch(req: RawRequest, ctx: &AppContext) -> Response {
"grep" => aft::commands::grep::handle_grep(&req, ctx),
"semantic_search" => aft::commands::semantic_search::handle_semantic_search(&req, ctx),
"status" => aft::commands::status::handle_status(&req, ctx),
"session_history" => handle_session_history(&req, ctx),
"list_filters" => aft::commands::list_filters::handle_list_filters(&req, ctx),
"trust_filter_project" => {
aft::commands::trust_filter_project::handle_trust_filter_project(&req, ctx)
Expand Down Expand Up @@ -541,6 +542,24 @@ fn handle_snapshot(req: &RawRequest, ctx: &AppContext) -> Response {
}
}

fn handle_session_history(req: &RawRequest, ctx: &AppContext) -> Response {
let limit = req
.params
.get("limit")
.and_then(|v| v.as_u64())
.unwrap_or(50)
.min(200) as usize;
let session = req.session();
let entries = ctx.session_history().borrow().recent(session, limit);
Response::success(
&req.id,
serde_json::json!({
"entries": entries,
"count": entries.len(),
}),
)
}

fn write_response(ctx: &AppContext, response: &Response) -> io::Result<()> {
let stdout_writer = ctx.stdout_writer();
let mut writer = stdout_writer
Expand Down
169 changes: 169 additions & 0 deletions crates/aft/src/session_history.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
use std::collections::VecDeque;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

const MAX_ENTRIES_PER_SESSION: usize = 50;

/// The kind of file operation recorded in session history.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileOp {
Read,
Zoom,
Edit,
Write,
Delete,
Move,
}

/// A single entry in the session file-access history.
#[derive(Debug, Clone, serde::Serialize)]
pub struct HistoryEntry {
pub path: PathBuf,
pub op: FileOp,
pub timestamp_millis: u64,
}

/// Per-session, in-memory, chronologically-ordered file-access history.
///
/// Entries are recorded by instrumenting command handlers and retained
/// for the lifetime of the bridge process. The cap is enforced per
/// session so one busy session cannot evict another's history.
///
/// This is purely in-memory — no disk persistence. The purpose is to
/// answer "what was I just working on?" within the current session.
#[derive(Debug, Clone)]
pub struct SessionHistory {
/// session_id -> VecDeque of entries (newest first)
sessions: std::collections::HashMap<String, VecDeque<HistoryEntry>>,
}

impl Default for SessionHistory {
fn default() -> Self {
Self::new()
}
}

impl SessionHistory {
pub fn new() -> Self {
Self {
sessions: std::collections::HashMap::new(),
}
}

/// Record a file access for the given session.
///
/// Entries are inserted at the front so iteration yields newest-first.
/// When the per-session cap is exceeded, the oldest entry is dropped.
pub fn record(&mut self, session: &str, path: PathBuf, op: FileOp) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;

let queue = self.sessions.entry(session.to_string()).or_default();
// Avoid duplicate consecutive entries for the same (path, op) — if
// the agent reads the same file twice in a row, just update the
// timestamp of the existing head entry.
if let Some(front) = queue.front_mut() {
if front.path == path && front.op == op {
front.timestamp_millis = now;
return;
}
}

queue.push_front(HistoryEntry {
path,
op,
timestamp_millis: now,
});

while queue.len() > MAX_ENTRIES_PER_SESSION {
queue.pop_back();
}
}

/// Return recent history for a session, newest first, up to `limit`.
pub fn recent(&self, session: &str, limit: usize) -> Vec<HistoryEntry> {
self.sessions
.get(session)
.map(|queue| queue.iter().take(limit).cloned().collect())
.unwrap_or_default()
}

/// Return ALL sessions that have recorded history (for diagnostics / status).
pub fn known_sessions(&self) -> Vec<String> {
self.sessions.keys().cloned().collect()
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;

#[test]
fn records_and_returns_recent() {
let mut hist = SessionHistory::new();
hist.record("s1", Path::new("/a.ts").to_path_buf(), FileOp::Read);
hist.record("s1", Path::new("/b.ts").to_path_buf(), FileOp::Edit);
hist.record("s2", Path::new("/c.ts").to_path_buf(), FileOp::Read);

let s1 = hist.recent("s1", 10);
assert_eq!(s1.len(), 2);
assert_eq!(s1[0].path, Path::new("/b.ts"));
assert_eq!(s1[0].op as FileOp, FileOp::Edit);
assert_eq!(s1[1].path, Path::new("/a.ts"));

let s2 = hist.recent("s2", 10);
assert_eq!(s2.len(), 1);
}

#[test]
fn deduplicates_consecutive_same_op() {
let mut hist = SessionHistory::new();
hist.record("s1", Path::new("/a.ts").to_path_buf(), FileOp::Read);
let ts1 = hist.recent("s1", 10)[0].timestamp_millis;

std::thread::sleep(std::time::Duration::from_millis(2));
hist.record("s1", Path::new("/a.ts").to_path_buf(), FileOp::Read);
let s1 = hist.recent("s1", 10);
assert_eq!(s1.len(), 1); // still 1 entry
assert!(s1[0].timestamp_millis > ts1); // timestamp updated
}

#[test]
fn caps_at_max_entries() {
let mut hist = SessionHistory::new();
for i in 0..MAX_ENTRIES_PER_SESSION + 10 {
hist.record(
"s1",
Path::new(&format!("/file-{}.ts", i)).to_path_buf(),
FileOp::Read,
);
}
let entries = hist.recent("s1", usize::MAX);
assert_eq!(entries.len(), MAX_ENTRIES_PER_SESSION);
// Newest entry should be the last one we added
assert_eq!(
entries[0].path,
Path::new(&format!("/file-{}.ts", MAX_ENTRIES_PER_SESSION + 9))
);
}

#[test]
fn empty_session_returns_empty() {
let hist = SessionHistory::new();
assert!(hist.recent("nonexistent", 10).is_empty());
}

#[test]
fn known_sessions() {
let mut hist = SessionHistory::new();
hist.record("s1", Path::new("/a.ts").to_path_buf(), FileOp::Read);
hist.record("s2", Path::new("/b.ts").to_path_buf(), FileOp::Write);
let mut sessions = hist.known_sessions();
sessions.sort();
assert_eq!(sessions, vec!["s1".to_string(), "s2".to_string()]);
}
}
Loading