app
Shotlog - Screenshot Log
Public Made by Adomby adom
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 Bridge child-window arrays via --meta.
← Commit history
Publish 0.9.1
10 files changed
+99−11
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+ } }