1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5 time::SystemTime,
6};
7
8use serde::Serialize;
9use time::{OffsetDateTime, format_description::well_known::Rfc3339};
10#[cfg(target_arch = "wasm32")]
11use zed_extension_api as zed;
12
13use crate::{
14 git_signal::{ChangeSummary, CommitSummary, GitSignals},
15 time_windows::{NOW_WINDOW, SESSION_WINDOW, WEEK_WINDOW},
16 usage_signal::UsageSnapshot,
17};
18#[cfg(target_arch = "wasm32")]
19use crate::{git_signal, usage_signal};
20
21#[derive(Clone, Debug, Serialize)]
22pub struct TouchedFile {
23 pub path: String,
24 pub modified_at: String,
25}
26
27#[derive(Clone, Debug, Serialize)]
28pub struct WindowSummary {
29 pub label: String,
30 pub top_files: Vec<String>,
31 pub focus_areas: Vec<String>,
32 pub themes: Vec<String>,
33 pub resume_hint: String,
34 pub commit_refs: Vec<CommitSummary>,
35 pub change_summary: Vec<ChangeSummary>,
36}
37
38#[derive(Clone, Debug, Serialize)]
39pub struct AssistantMemory {
40 pub latest_summary: String,
41 pub thread_refs: Vec<String>,
42}
43
44#[derive(Clone, Debug, Serialize)]
45pub struct ContextSnapshot {
46 pub worktree_root: String,
47 pub branch: String,
48 pub updated_at: String,
49 pub touched_files: Vec<TouchedFile>,
50 pub now: WindowSummary,
51 pub session: WindowSummary,
52 pub week: WindowSummary,
53 pub assistant_memory: AssistantMemory,
54 #[serde(default)]
55 pub usage: UsageSnapshot,
56}
57
58#[derive(Clone, Debug)]
59pub struct FileObservation {
60 pub relative_path: String,
61 pub modified_at: SystemTime,
62}
63
64pub struct ContextEngine;
65
66impl ContextEngine {
67 #[cfg(target_arch = "wasm32")]
72 pub fn generate(worktree: &zed::Worktree) -> Result<ContextSnapshot, String> {
73 let root = PathBuf::from(worktree.root_path());
74 let git = git_signal::collect(worktree)?;
75 let touched_files = collect_file_observations(&root)?;
76 let usage = usage_signal::collect(worktree);
77 let mut snapshot = assemble(root, git, touched_files)?;
78 snapshot.usage = usage;
79 Ok(snapshot)
80 }
81}
82
83pub fn assemble(
86 root: PathBuf,
87 git: GitSignals,
88 touched_files: Vec<FileObservation>,
89) -> Result<ContextSnapshot, String> {
90 {
91 let updated_at = timestamp(SystemTime::now())?;
92
93 let now_files = filter_files_for_window(&touched_files, NOW_WINDOW.duration);
94 let session_files = filter_files_for_window(&touched_files, SESSION_WINDOW.duration);
95 let week_files = filter_files_for_window(&touched_files, WEEK_WINDOW.duration);
96
97 let snapshot = ContextSnapshot {
98 worktree_root: root.display().to_string(),
99 branch: git.branch.clone(),
100 updated_at,
101 touched_files: touched_files
102 .iter()
103 .take(25)
104 .map(|file| {
105 Ok(TouchedFile {
106 path: file.relative_path.clone(),
107 modified_at: timestamp(file.modified_at)?,
108 })
109 })
110 .collect::<Result<Vec<_>, String>>()?,
111 now: summarize_window(NOW_WINDOW.label, &git, &now_files, NOW_WINDOW.duration, true),
112 session: summarize_window(SESSION_WINDOW.label, &git, &session_files, SESSION_WINDOW.duration, false),
113 week: summarize_window(WEEK_WINDOW.label, &git, &week_files, WEEK_WINDOW.duration, false),
114 assistant_memory: AssistantMemory {
115 latest_summary: build_assistant_memory(&git, &session_files),
116 thread_refs: Vec::new(),
117 },
118 usage: UsageSnapshot::default(),
119 };
120
121 Ok(snapshot)
122 }
123}
124
125pub fn collect_files(root: &Path) -> Result<Vec<FileObservation>, String> {
126 collect_file_observations(root)
127}
128
129pub fn render_window_markdown(snapshot: &ContextSnapshot, window: &str) -> String {
130 let summary = match window {
131 "now" => &snapshot.now,
132 "session" => &snapshot.session,
133 "week" => &snapshot.week,
134 _ => &snapshot.now,
135 };
136
137 let mut lines = vec![
138 format!("# {} brief", summary.label),
139 String::new(),
140 format!("- worktree: {}", snapshot.worktree_root),
141 format!("- branch: {}", snapshot.branch),
142 format!("- updated_at: {}", snapshot.updated_at),
143 String::new(),
144 "## Resume hint".to_string(),
145 summary.resume_hint.clone(),
146 String::new(),
147 ];
148
149 if !summary.top_files.is_empty() {
150 lines.push("## Top files".to_string());
151 lines.extend(summary.top_files.iter().map(|path| format!("- {path}")));
152 lines.push(String::new());
153 }
154
155 if !summary.focus_areas.is_empty() {
156 lines.push("## Focus areas".to_string());
157 lines.extend(summary.focus_areas.iter().map(|path| format!("- {path}")));
158 lines.push(String::new());
159 }
160
161 if !summary.themes.is_empty() {
162 lines.push("## Themes".to_string());
163 lines.extend(summary.themes.iter().map(|theme| format!("- {theme}")));
164 lines.push(String::new());
165 }
166
167 if !summary.commit_refs.is_empty() {
168 lines.push("## Recent commits".to_string());
169 lines.extend(
170 summary
171 .commit_refs
172 .iter()
173 .map(|commit| format!("- {} {}", commit.sha, commit.subject)),
174 );
175 lines.push(String::new());
176 }
177
178 if !summary.change_summary.is_empty() {
179 lines.push("## Local changes".to_string());
180 lines.extend(
181 summary
182 .change_summary
183 .iter()
184 .map(|change| format!("- {} {}", change.code, change.path)),
185 );
186 }
187
188 lines.join("\n").trim().to_string() + "\n"
189}
190
191fn summarize_window(
192 label: &str,
193 git: &GitSignals,
194 files: &[FileObservation],
195 window: std::time::Duration,
196 include_local_changes: bool,
197) -> WindowSummary {
198 let top_files = files
199 .iter()
200 .take(6)
201 .map(|file| file.relative_path.clone())
202 .collect::<Vec<_>>();
203
204 let windowed_commits = filter_commits_for_window(&git.recent_commits, window);
205 let focus_areas = top_directories(files, 4);
206 let themes = derive_themes(files, &windowed_commits, 4);
207 let change_summary = if include_local_changes {
208 combine_change_summary(git, 8)
209 } else {
210 Vec::new()
211 };
212
213 let resume_hint = match label {
214 "now" => {
215 if top_files.is_empty() {
216 format!("No files changed in the last {} minutes.", NOW_WINDOW.duration.as_secs() / 60)
217 } else {
218 format!(
219 "Continue in {} on branch {}. Local changes are concentrated in {}.",
220 top_files[0],
221 git.branch,
222 focus_areas
223 .first()
224 .cloned()
225 .unwrap_or_else(|| "the worktree root".to_string())
226 )
227 }
228 }
229 "session" => format!(
230 "Session focus is {} with {} recent file touches.",
231 focus_areas
232 .first()
233 .cloned()
234 .unwrap_or_else(|| "mixed project areas".to_string()),
235 files.len()
236 ),
237 _ => format!(
238 "Weekly pattern points to {} and {} recent commits on {}.",
239 focus_areas
240 .first()
241 .cloned()
242 .unwrap_or_else(|| "mixed project areas".to_string()),
243 windowed_commits.len(),
244 git.branch
245 ),
246 };
247
248 WindowSummary {
249 label: label.to_string(),
250 top_files,
251 focus_areas,
252 themes,
253 resume_hint,
254 commit_refs: windowed_commits.into_iter().take(6).collect(),
255 change_summary,
256 }
257}
258
259fn filter_commits_for_window(
260 commits: &[CommitSummary],
261 window: std::time::Duration,
262) -> Vec<CommitSummary> {
263 let now = SystemTime::now();
264 commits
265 .iter()
266 .filter(|commit| match commit.committed_at_system {
267 Some(time) => now
268 .duration_since(time)
269 .map(|age| age <= window)
270 .unwrap_or(true),
271 None => window >= WEEK_WINDOW.duration,
274 })
275 .cloned()
276 .collect()
277}
278
279fn build_assistant_memory(git: &GitSignals, session_files: &[FileObservation]) -> String {
280 let top_file = session_files
281 .first()
282 .map(|file| file.relative_path.as_str())
283 .unwrap_or("no recent files");
284 let commit = git
285 .recent_commits
286 .first()
287 .map(|commit| commit.subject.as_str())
288 .unwrap_or("no recent commits");
289
290 format!(
291 "Current branch is {}. Session focus is {}. Latest visible commit theme: {}.",
292 git.branch, top_file, commit
293 )
294}
295
296fn combine_change_summary(git: &GitSignals, limit: usize) -> Vec<ChangeSummary> {
297 git.staged_changes
298 .iter()
299 .chain(git.unstaged_changes.iter())
300 .take(limit)
301 .cloned()
302 .collect()
303}
304
305fn derive_themes(
306 files: &[FileObservation],
307 commits: &[CommitSummary],
308 limit: usize,
309) -> Vec<String> {
310 let mut themes = Vec::new();
311
312 for area in top_directories(files, limit) {
313 if area != "." {
314 themes.push(format!("focus on {area}"));
315 }
316 }
317
318 for commit in commits.iter().take(limit) {
319 if themes.len() >= limit {
320 break;
321 }
322 themes.push(commit.subject.clone());
323 }
324
325 themes
326}
327
328fn top_directories(files: &[FileObservation], limit: usize) -> Vec<String> {
329 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
330
331 for file in files {
332 let area = Path::new(&file.relative_path)
333 .parent()
334 .map(|path| {
335 let value = path.to_string_lossy().to_string();
336 if value.is_empty() { ".".to_string() } else { value }
337 })
338 .unwrap_or_else(|| ".".to_string());
339 if is_noise_area(&area) {
340 continue;
341 }
342 *counts.entry(area).or_default() += 1;
343 }
344
345 let mut entries = counts.into_iter().collect::<Vec<_>>();
346 entries.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
347
348 entries.into_iter().take(limit).map(|(path, _)| path).collect()
349}
350
351fn is_noise_area(area: &str) -> bool {
352 matches!(
353 area,
354 "." | ".git" | ".tmp" | ".context-bar" | "target" | "node_modules"
355 )
356}
357
358fn filter_files_for_window(
359 files: &[FileObservation],
360 duration: std::time::Duration,
361) -> Vec<FileObservation> {
362 let now = SystemTime::now();
363 files.iter()
364 .filter(|file| {
365 now.duration_since(file.modified_at)
366 .map(|age| age <= duration)
367 .unwrap_or(false)
368 })
369 .cloned()
370 .collect()
371}
372
373const MAX_COLLECT_DEPTH: usize = 12;
377
378fn collect_file_observations(root: &Path) -> Result<Vec<FileObservation>, String> {
379 let mut observations = Vec::new();
380 collect_dir(root, root, &mut observations, 0)?;
381 observations.sort_by(|left, right| right.modified_at.cmp(&left.modified_at));
382 Ok(observations)
383}
384
385fn collect_dir(
386 root: &Path,
387 current: &Path,
388 observations: &mut Vec<FileObservation>,
389 depth: usize,
390) -> Result<(), String> {
391 if depth >= MAX_COLLECT_DEPTH {
392 return Ok(());
393 }
394 for entry in fs::read_dir(current)
395 .map_err(|error| format!("failed to read directory {}: {error}", current.display()))?
396 {
397 let entry = entry.map_err(|error| format!("failed to inspect directory entry: {error}"))?;
398 let path = entry.path();
399 let file_type = entry
400 .file_type()
401 .map_err(|error| format!("failed to read file type for {}: {error}", path.display()))?;
402
403 if file_type.is_symlink() {
404 continue;
405 }
406
407 if file_type.is_dir() {
408 if should_skip_dir(&path) {
409 continue;
410 }
411 collect_dir(root, &path, observations, depth + 1)?;
412 continue;
413 }
414
415 if !file_type.is_file() {
416 continue;
417 }
418
419 if should_skip_file(&path) {
420 continue;
421 }
422
423 let metadata = entry
424 .metadata()
425 .map_err(|error| format!("failed to read metadata for {}: {error}", path.display()))?;
426 let modified_at = match metadata.modified() {
427 Ok(modified_at) => modified_at,
428 Err(_) => continue,
429 };
430
431 let relative_path = path
432 .strip_prefix(root)
433 .map_err(|error| format!("failed to compute relative path for {}: {error}", path.display()))?
434 .to_string_lossy()
435 .to_string();
436
437 observations.push(FileObservation {
438 relative_path,
439 modified_at,
440 });
441 }
442
443 Ok(())
444}
445
446fn should_skip_dir(path: &Path) -> bool {
447 matches!(
448 path.file_name().and_then(|value| value.to_str()),
449 Some(".git" | "target" | ".context-bar" | "node_modules" | ".tmp")
450 )
451}
452
453fn should_skip_file(path: &Path) -> bool {
454 matches!(
455 path.file_name().and_then(|value| value.to_str()),
456 Some("extension.wasm" | "Cargo.lock")
457 )
458}
459
460fn timestamp(time: SystemTime) -> Result<String, String> {
461 let datetime = OffsetDateTime::from(time);
462 datetime
463 .format(&Rfc3339)
464 .map_err(|error| format!("failed to format timestamp: {error}"))
465}
466
467#[cfg(test)]
468mod tests {
469 use super::{CommitSummary, FileObservation, derive_themes, top_directories};
470 use std::time::SystemTime;
471
472 #[test]
473 fn picks_top_directories_from_recent_files() {
474 let files = vec![
475 FileObservation {
476 relative_path: "src/lib.rs".to_string(),
477 modified_at: SystemTime::now(),
478 },
479 FileObservation {
480 relative_path: "src/context_engine.rs".to_string(),
481 modified_at: SystemTime::now(),
482 },
483 FileObservation {
484 relative_path: "docs/02-architecture.md".to_string(),
485 modified_at: SystemTime::now(),
486 },
487 ];
488
489 let areas = top_directories(&files, 2);
490 assert_eq!(areas, vec!["src".to_string(), "docs".to_string()]);
491 }
492
493 #[test]
494 fn derives_themes_from_files_and_commits() {
495 let files = vec![FileObservation {
496 relative_path: "src/lib.rs".to_string(),
497 modified_at: SystemTime::now(),
498 }];
499 let commits = vec![CommitSummary {
500 sha: "abc1234".to_string(),
501 subject: "Add context engine".to_string(),
502 committed_at: None,
503 committed_at_system: None,
504 }];
505
506 let themes = derive_themes(&files, &commits, 4);
507 assert!(themes.iter().any(|theme| theme.contains("src")));
508 assert!(themes.iter().any(|theme| theme.contains("Add context engine")));
509 }
510}