← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.9.0"+version = "0.9.1" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.9.0"+version = "0.9.1" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+2
@@ -18,6 +18,8 @@ There is **one shotlog server per container**, and the same binary is both the s  Every AI thread in the container simply runs `adom-shotlog inject`, which posts to that one local server. So the server is a **central broker**: it owns every channel, tracks every viewer (which surface, and whether it is actually on-screen), and manages all the viewer surfaces. Many threads can be sending you shots at once, each into its own channel, and the broker keeps a live view/tab for each. Run `adom-shotlog channels` to see the whole picture: every channel, its shot count, when it last updated, and who is watching right now. +Disk stays bounded automatically: each channel keeps its newest **200 shots** on disk (`SHOTLOG_MAX_SHOTS`), older files are deleted as new ones arrive, and channels idle for **30 days** are pruned entirely (`SHOTLOG_RETENTION_DAYS`, `0` disables).+ ## It adapts to your surface  shotlog notices which surface is watching a channel and behaves accordingly. It never steals your OS foreground.
VERSION+1−1
@@ -1 +1 @@-0.9.0+0.9.1
bin/adom-shotlog
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.9.0",+  "version": "0.9.1",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.9.0",+  "version": "0.9.1",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
skills/adom-shotlog-surfacing/SKILL.md+2
@@ -129,6 +129,8 @@ target - no foreground, no cursor move.  | Var | Effect | |-----|--------|+| `SHOTLOG_MAX_SHOTS` | Per-channel disk cap (default 200): oldest shot's files are deleted as new ones arrive. |+| `SHOTLOG_RETENTION_DAYS` | Prune channels idle this many days (default 30, 0 = off), at startup + every 6h. | | `SHOTLOG_NO_FORCE_OPEN=1` | Never auto-open a viewer, even when nobody is watching. |  ## The one rule, restated
src/main.rs+17
@@ -391,6 +391,23 @@ async fn run_server(port: u16, data_dir: PathBuf) {     let channel_count = store.channel_names().await.len();     ok(&format!("Recovered {channel_count} channel(s) from disk")); +    // Retention: cap shots per channel on disk (SHOTLOG_MAX_SHOTS, default 200,+    // enforced on every inject + at recovery) and prune idle channels+    // (SHOTLOG_RETENTION_DAYS, default 30, 0 disables) at startup + every 6h.+    {+        let sweeper = store.clone();+        tokio::spawn(async move {+            loop {+                let pruned = sweeper.retention_sweep().await;+                if !pruned.is_empty() {+                    ok(&format!("Retention: pruned {} idle channel(s): {}", pruned.len(), pruned.join(", ")));+                }+                tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;+            }+        });+        ok("Retention active: max 200 shots/channel on disk (SHOTLOG_MAX_SHOTS), idle channels pruned after 30d (SHOTLOG_RETENTION_DAYS, 0=off)");+    }+     let state = Arc::new(server::AppState { store, port, next_viewer_id: std::sync::atomic::AtomicU64::new(1) });     let app = server::create_router(state); 
src/store.rs+73−6
@@ -43,7 +43,36 @@ struct Channel {     tx: broadcast::Sender<Entry>, } -const MAX_ENTRIES: usize = 200;+/// In-memory AND on-disk cap of shots per channel (SHOTLOG_MAX_SHOTS, default 200).+/// When a channel exceeds it, the oldest shot is dropped AND its files deleted -+/// without this the disk grew unbounded (3.4 GB before retention existed).+fn max_shots() -> usize {+    std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok()).unwrap_or(200)+}++/// Days of inactivity before a whole channel is pruned (SHOTLOG_RETENTION_DAYS,+/// default 30; 0 disables the sweep).+fn retention_days() -> u64 {+    std::env::var("SHOTLOG_RETENTION_DAYS").ok().and_then(|v| v.parse().ok()).unwrap_or(30)+}++/// Delete a shot's files: the PNG, its JSON sidecar, and any child-window PNGs.+fn delete_entry_files(entry: &Entry) {+    let p = std::path::Path::new(&entry.path);+    let _ = fs::remove_file(p);+    let _ = fs::remove_file(p.with_extension("json"));+    if let Some(arr) = entry.popups.as_ref().and_then(|v| v.as_array()) {+        if let Some(dir) = p.parent() {+            for c in arr {+                if let Some(f) = c.get("filename").and_then(|x| x.as_str()) {+                    let _ = fs::remove_file(dir.join(f));+                }+            }+        }+    }+}++const MAX_ENTRIES: usize = 200; // legacy const; runtime cap comes from max_shots()  /// One connected viewer: which surface it's on, and whether that surface is /// actually on-screen (Page Visibility) vs a background tab / minimized window.@@ -172,9 +201,12 @@ impl Store {                 next_id += 1;             } -            // Keep only the last MAX_ENTRIES-            if entries.len() > MAX_ENTRIES {-                entries = entries.split_off(entries.len() - MAX_ENTRIES);+            // Keep only the newest max_shots() - and DELETE the older shots'+            // files, which previously lingered on disk forever.+            let cap = max_shots();+            if entries.len() > cap {+                let trimmed: Vec<Entry> = entries.drain(..entries.len() - cap).collect();+                for e in &trimmed { delete_entry_files(e); }             }              let mut channels = self.channels.write().await;@@ -263,8 +295,9 @@ impl Store {          ch.next_id += 1;         ch.entries.push(entry.clone());-        if ch.entries.len() > MAX_ENTRIES {-            ch.entries.remove(0);+        while ch.entries.len() > max_shots() {+            let old = ch.entries.remove(0);+            delete_entry_files(&old);         }          // Broadcast to SSE listeners (ignore errors — no receivers is fine)@@ -422,4 +455,38 @@ impl Store {     pub fn base_dir(&self) -> &Path {         &self.base_dir     }++    /// Prune whole channels idle longer than SHOTLOG_RETENTION_DAYS (default 30):+    /// delete the channel directory and drop it from the in-memory store.+    /// Returns the pruned channel names. Runs at startup and every 6 hours.+    pub async fn retention_sweep(&self) -> Vec<String> {+        let days = retention_days();+        if days == 0 { return Vec::new(); }+        let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(days * 86_400);+        let mut pruned = Vec::new();+        let Ok(read_dir) = fs::read_dir(&self.base_dir) else { return pruned };+        for d in read_dir.flatten() {+            let path = d.path();+            if !path.is_dir() { continue; }+            // newest mtime of any file inside (or the dir itself when empty)+            let newest = fs::read_dir(&path).ok()+                .map(|it| it.flatten()+                    .filter_map(|f| f.metadata().ok().and_then(|m| m.modified().ok()))+                    .max())+                .flatten()+                .or_else(|| fs::metadata(&path).ok().and_then(|m| m.modified().ok()));+            if let Some(t) = newest {+                if t < cutoff {+                    let name = d.file_name().to_string_lossy().to_string();+                    // Never prune a channel someone is actively viewing.+                    if !self.viewer_infos(&name).await.is_empty() { continue; }+                    if fs::remove_dir_all(&path).is_ok() {+                        self.channels.write().await.remove(&name);+                        pruned.push(name);+                    }+                }+            }+        }+        pruned+    } }