1pub mod claims;
24pub mod cross_channel;
25pub mod git;
26pub mod matching;
27pub mod overlap;
28pub mod overlap_notes;
29pub mod paths;
30
31use std::path::Path;
32
33use serde::{Deserialize, Serialize};
34
35use crate::backend::blockcontroller::{cmd_env_of, get_block_controller_status, META_KEY_CMD_CWD, STATUS_RUNNING};
36use crate::backend::obj::{self, Block, MetaMapType};
37use crate::backend::reactive::progress_watcher::{watched_work, TodoItem};
38use crate::backend::reactive::types::AgentRegistration;
39use crate::backend::storage::store::Store;
40
41const META_LAST_PROMPT: &str = "term:last_prompt";
44
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
47pub struct FactFile {
48 pub path: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub rel: Option<String>,
53 pub tool: String,
54 pub ts_ms: u64,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct WorkFacts {
60 pub agent: String,
61 pub block_id: String,
62 pub channel: String,
64 pub status: String,
66 pub goal: Option<String>,
67 pub working_folder: Option<String>,
68 pub repo: Option<String>,
69 pub repo_root: Option<String>,
70 pub branch: Option<String>,
71 pub dirty_files: Vec<String>,
73 pub dirty_count: usize,
74 pub recent_files: Vec<FactFile>,
76 pub todos: Vec<TodoItem>,
77 pub current_tool: Option<String>,
78 pub git_checked_ms: Option<u64>,
81}
82
83impl WorkFacts {
84 pub fn repo_key(&self) -> Option<String> {
87 self.repo.clone().or_else(|| self.repo_root.as_ref().map(|r| format!("local:{r}")))
88 }
89}
90
91pub fn goal_of(meta: &MetaMapType) -> Option<String> {
93 let title = obj::meta_get_string(meta, crate::ambient::title::META_TITLE, "");
94 if crate::ambient::validate::is_usable_title(&title) {
95 return Some(title.trim().to_string());
96 }
97 let prompt = obj::meta_get_string(meta, META_LAST_PROMPT, "");
98 let prompt = prompt.trim();
99 (!prompt.is_empty()).then(|| prompt.to_string())
100}
101
102pub fn folder_of(store: &Store, home: Option<&Path>, reg: &AgentRegistration) -> Option<String> {
105 let block = store.get::<Block>(®.block_id).ok()??;
106 folder_of_meta(&block.meta, home, ®.agent_id)
107}
108
109fn folder_of_meta(meta: &MetaMapType, home: Option<&Path>, agent_id: &str) -> Option<String> {
110 let env = cmd_env_of(meta);
111 let start = obj::meta_get_string(meta, META_KEY_CMD_CWD, "");
112 paths::working_folder(home, &env, agent_id, &start)
113}
114
115fn status_of(block_id: &str) -> &'static str {
116 match get_block_controller_status(block_id) {
117 Some(s) if s.shellprocstatus == STATUS_RUNNING => {
118 if s.turn_active {
119 "busy"
120 } else {
121 "idle"
122 }
123 }
124 _ => "stopped",
125 }
126}
127
128pub fn collect_local(store: &Store, regs: &[AgentRegistration], channel: &str) -> Vec<WorkFacts> {
131 let home = dirs::home_dir();
132 regs.iter()
133 .map(|reg| {
134 let meta = store.get::<Block>(®.block_id).ok().flatten().map(|b| b.meta).unwrap_or_default();
135 let folder = folder_of_meta(&meta, home.as_deref(), ®.agent_id);
136 let git = folder.as_deref().and_then(|d| git::cached(®.block_id, d));
137 let watched = watched_work(®.block_id).unwrap_or_default();
138 build(
139 reg,
140 channel,
141 status_of(®.block_id),
142 goal_of(&meta),
143 folder,
144 git,
145 watched,
146 )
147 })
148 .collect()
149}
150
151pub fn build(
153 reg: &AgentRegistration,
154 channel: &str,
155 status: &str,
156 goal: Option<String>,
157 working_folder: Option<String>,
158 git: Option<git::GitFacts>,
159 watched: crate::backend::reactive::progress_watcher::WatchedWork,
160) -> WorkFacts {
161 let root = git.as_ref().map(|g| g.root.clone());
162 let recent_files = watched
163 .recent_files
164 .into_iter()
165 .map(|f| {
166 let path = paths::normalize_path(&f.path);
167 let rel = root.as_deref().and_then(|r| paths::relative_to(&path, r)).filter(|r| !r.is_empty());
168 FactFile { path, rel, tool: f.tool, ts_ms: f.ts_ms }
169 })
170 .collect();
171 let git = git.unwrap_or_default();
172 WorkFacts {
173 agent: reg.agent_id.clone(),
174 block_id: reg.block_id.clone(),
175 channel: channel.to_string(),
176 status: status.to_string(),
177 goal,
178 working_folder,
179 repo: git.repo,
180 repo_root: root,
181 branch: git.branch,
182 dirty_files: git.dirty_files,
183 dirty_count: git.dirty_count,
184 recent_files,
185 todos: watched.progress.todos,
186 current_tool: watched.progress.current_tool,
187 git_checked_ms: (git.checked_ms > 0).then_some(git.checked_ms),
188 }
189}
190
191const SUMMARY_DIRTY: usize = 10;
193const SUMMARY_RECENT: usize = 5;
194
195pub fn conversation_summary(f: &WorkFacts) -> serde_json::Value {
198 let done = f.todos.iter().filter(|t| t.status == "completed").count();
199 let in_progress: Vec<&str> =
200 f.todos.iter().filter(|t| t.status == "in_progress").map(|t| t.text.as_str()).take(3).collect();
201 serde_json::json!({
202 "goal": f.goal,
203 "working_folder": f.working_folder,
204 "repo": f.repo,
205 "branch": f.branch,
206 "dirty_count": f.dirty_count,
207 "dirty_files": f.dirty_files.iter().take(SUMMARY_DIRTY).collect::<Vec<_>>(),
208 "recent_files": f.recent_files.iter().take(SUMMARY_RECENT).map(|r| serde_json::json!({
209 "path": r.rel.as_deref().unwrap_or(&r.path),
210 "tool": r.tool,
211 "ts_ms": r.ts_ms,
212 })).collect::<Vec<_>>(),
213 "todos": { "total": f.todos.len(), "completed": done, "in_progress": in_progress },
214 "current_tool": f.current_tool,
215 "git_checked_ms": f.git_checked_ms,
216 })
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::backend::reactive::progress_watcher::{AgentProgress, RecentFile, WatchedWork};
223
224 fn reg(agent: &str, block: &str) -> AgentRegistration {
225 AgentRegistration {
226 agent_id: agent.to_string(),
227 block_id: block.to_string(),
228 tab_id: None,
229 uid: None,
230 registered_at: 0,
231 last_seen: 0,
232 registration_nonce: 0,
233 }
234 }
235
236 fn meta(pairs: &[(&str, &str)]) -> MetaMapType {
237 pairs.iter().map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string()))).collect()
238 }
239
240 #[test]
241 fn the_goal_is_the_title_else_the_last_prompt() {
242 assert_eq!(
243 goal_of(&meta(&[("term:ambient_summary", "Fix the login race"), ("term:last_prompt", "hi")])).as_deref(),
244 Some("Fix the login race")
245 );
246 assert_eq!(
247 goal_of(&meta(&[("term:ambient_summary", ""), ("term:last_prompt", " add the allowlist ")])).as_deref(),
248 Some("add the allowlist")
249 );
250 assert_eq!(goal_of(&meta(&[])), None);
251 }
252
253 #[test]
254 fn build_maps_recent_files_into_the_repository_and_keeps_watcher_state() {
255 let git = git::GitFacts {
256 root: "C:/w/clone".into(),
257 repo: Some("o/r".into()),
258 branch: Some("feat".into()),
259 dirty_files: vec!["a.rs".into()],
260 dirty_count: 1,
261 error: None,
262 checked_ms: 7,
263 };
264 let watched = WatchedWork {
265 progress: AgentProgress {
266 todos: vec![TodoItem { text: "Ship".into(), status: "in_progress".into() }],
267 current_tool: Some("Edit".into()),
268 ..Default::default()
269 },
270 recent_files: vec![
271 RecentFile { path: r"C:\w\clone\src\b.rs".into(), tool: "Edit".into(), ts_ms: 5 },
272 RecentFile { path: "D:/elsewhere/c.rs".into(), tool: "Write".into(), ts_ms: 4 },
273 ],
274 };
275 let f = build(®("A", "b1"), "stable", "idle", None, Some("C:/w/clone".into()), Some(git), watched);
276 assert_eq!(f.repo.as_deref(), Some("o/r"));
277 assert_eq!(f.recent_files[0].rel.as_deref(), Some("src/b.rs"));
278 assert_eq!(f.recent_files[0].path, "C:/w/clone/src/b.rs");
279 assert_eq!(f.recent_files[1].rel, None, "outside the repository");
280 assert_eq!(f.current_tool.as_deref(), Some("Edit"));
281 assert_eq!(f.git_checked_ms, Some(7));
282 assert_eq!(f.repo_key().as_deref(), Some("o/r"));
283
284 let summary = conversation_summary(&f);
285 assert_eq!(summary["dirty_count"], 1);
286 assert_eq!(summary["recent_files"][0]["path"], "src/b.rs");
287 assert_eq!(summary["todos"]["in_progress"][0], "Ship");
288 }
289
290 #[test]
291 fn a_repository_without_a_remote_is_keyed_by_its_folder() {
292 let f = WorkFacts { repo_root: Some("C:/w/x".into()), ..Default::default() };
293 assert_eq!(f.repo_key().as_deref(), Some("local:C:/w/x"));
294 assert_eq!(WorkFacts::default().repo_key(), None);
295 }
296
297 #[test]
298 fn facts_round_trip_for_the_cross_channel_endpoint() {
299 let f = WorkFacts {
300 agent: "A".into(),
301 todos: vec![TodoItem { text: "t".into(), status: "pending".into() }],
302 recent_files: vec![FactFile { path: "/x".into(), rel: None, tool: "Edit".into(), ts_ms: 1 }],
303 ..Default::default()
304 };
305 let back: WorkFacts = serde_json::from_value(serde_json::to_value(&f).unwrap()).unwrap();
306 assert_eq!(back, f);
307 }
308}