← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.15.1"+version = "0.16.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.15.1"+version = "0.16.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-0.15.1+0.16.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.15.1",+  "version": "0.16.0",   "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.15.1",+  "version": "0.16.0",   "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.",
src/html/index.html+34
@@ -308,6 +308,11 @@ header {   background: rgba(0,184,176,.08); border: 1px solid rgba(0,184,176,.25);   font-family: 'JetBrains Mono', monospace; font-size: 10.5px; line-height: 1.4;   color: var(--accent-bright); }+.num-input { -webkit-appearance: none; appearance: none; width: 88px; height: 30px;+  background: var(--overlay); border: 1px solid var(--border); border-radius: 7px;+  color: var(--text); font: 600 13px 'JetBrains Mono', monospace; text-align: center;+  outline: none; }+.num-input:focus { border-color: rgba(0,184,176,.6); } .ai-chip .lbl { color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: 9px;   letter-spacing: .04em; text-transform: uppercase; margin-right: 6px; } @@ -400,6 +405,24 @@ header {         <button data-v="pup">Pup</button>       </div>     </div>+    <div class="pref-row">+      <div class="pref-info">+        <div class="pref-name">Keep per channel</div>+        <div class="pref-desc">The janitor keeps only the newest N shots per channel on disk; older files are deleted as new ones arrive. Auto-capture channels like read-captures never grow unbounded.</div>+        <div class="ai-chip"><span class="lbl">or say to your AI</span>keep only the newest 100 shots per shotlog channel</div>+      </div>+      <input class="num-input" id="prefMaxShots" type="number" min="10" max="5000" step="10"+        data-tooltip="Newest shots kept per channel (10-5000). Applies as new shots arrive.">+    </div>+    <div class="pref-row">+      <div class="pref-info">+        <div class="pref-name">Prune idle channels after (days)</div>+        <div class="pref-desc">Channels with no new shots for this many days are deleted entirely (checked at startup and every 6 hours). 0 means never prune.</div>+        <div class="ai-chip"><span class="lbl">or say to your AI</span>never auto-delete idle shotlog channels</div>+      </div>+      <input class="num-input" id="prefRetention" type="number" min="0" max="3650" step="1"+        data-tooltip="Days of inactivity before a whole channel is pruned. 0 = never.">+    </div>   </div> </div> <div class="toast" id="toast"></div>@@ -857,6 +880,8 @@ header {     function renderPrefs(p){       hook.setAttribute('aria-checked', String(!!p.read_hook));       seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas));+      $('prefMaxShots').value = p.max_shots ?? 200;+      $('prefRetention').value = p.retention_days ?? 30;     }     async function patch(body, okMsg){       try{@@ -873,6 +898,15 @@ header {       const b=e.target.closest('button[data-v]'); if(!b) return;       patch({default_canvas: b.dataset.v}, 'channels now default to '+b.dataset.v+'.');     });+    function wireNum(id, key, msg){+      const el=$(id);+      const commit=()=>{ const v=parseInt(el.value,10); if(Number.isNaN(v)) return;+        patch({[key]: v}, msg(v)); };+      el.addEventListener('change', commit);+      el.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); commit(); el.blur(); } });+    }+    wireNum('prefMaxShots','max_shots', v=>'keeping the newest '+v+' shots per channel.');+    wireNum('prefRetention','retention_days', v=> v===0?'idle channels are never pruned.':'idle channels prune after '+v+' days.');     $('prefsBtn').addEventListener('click', async ()=>{       try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{}       dlg.classList.add('open');
src/server.rs+14
@@ -281,6 +281,8 @@ async fn get_prefs(State(state): State<Arc<AppState>>) -> Json<crate::store::App struct PrefsPatch {     default_canvas: Option<String>,     read_hook: Option<bool>,+    max_shots: Option<usize>,+    retention_days: Option<u64>, }  /// Patch global preferences from the viewer's Preferences dialog.@@ -298,6 +300,18 @@ async fn set_prefs(     if let Some(h) = patch.read_hook {         p.read_hook = h;     }+    if let Some(n) = patch.max_shots {+        if !(10..=5000).contains(&n) {+            return Json(serde_json::json!({"ok": false, "error": "max_shots must be 10-5000"}));+        }+        p.max_shots = n;+    }+    if let Some(d) = patch.retention_days {+        if d > 3650 {+            return Json(serde_json::json!({"ok": false, "error": "retention_days must be 0-3650 (0 = never)"}));+        }+        p.retention_days = d;+    }     state.store.set_app_prefs(p.clone()).await;     Json(serde_json::json!({"ok": true, "prefs": p})) }
src/store.rs+31−4
@@ -46,12 +46,14 @@ struct Channel { /// 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).+#[allow(dead_code)] 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).+#[allow(dead_code)] fn retention_days() -> u64 {     std::env::var("SHOTLOG_RETENTION_DAYS").ok().and_then(|v| v.parse().ok()).unwrap_or(30) }@@ -87,11 +89,22 @@ pub struct AppPrefs {     /// becomes a no-op without touching hook registration.     #[serde(default = "default_true")]     pub read_hook: bool,+    /// Janitor: newest N shots kept per channel on disk (oldest deleted as new+    /// ones arrive). Env SHOTLOG_MAX_SHOTS overrides when set.+    #[serde(default = "default_max_shots")]+    pub max_shots: usize,+    /// Janitor: prune whole channels idle this many days (0 = never).+    /// Env SHOTLOG_RETENTION_DAYS overrides when set.+    #[serde(default = "default_retention_days")]+    pub retention_days: u64, } fn default_canvas() -> String { "webview".into() } fn default_true() -> bool { true }+fn default_max_shots() -> usize { 200 }+fn default_retention_days() -> u64 { 30 } impl Default for AppPrefs {-    fn default() -> Self { Self { default_canvas: default_canvas(), read_hook: true } }+    fn default() -> Self { Self { default_canvas: default_canvas(), read_hook: true,+        max_shots: default_max_shots(), retention_days: default_retention_days() } } }  /// The flag file the read-hook script checks; present = hook disabled.@@ -250,7 +263,7 @@ impl Store {              // Keep only the newest max_shots() - and DELETE the older shots'             // files, which previously lingered on disk forever.-            let cap = max_shots();+            let cap = std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok()).unwrap_or(200);             if entries.len() > cap {                 let trimmed: Vec<Entry> = entries.drain(..entries.len() - cap).collect();                 for e in &trimmed { delete_entry_files(e); }@@ -342,7 +355,11 @@ impl Store {          ch.next_id += 1;         ch.entries.push(entry.clone());-        while ch.entries.len() > max_shots() {+        let cap = std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok())+            .unwrap_or_else(|| { // prefs read is async; channels lock is held - use try_read+                self.app_prefs.try_read().map(|p| p.max_shots).unwrap_or(200)+            });+        while ch.entries.len() > cap {             let old = ch.entries.remove(0);             delete_entry_files(&old);         }@@ -527,6 +544,16 @@ impl Store {         self.app_prefs.read().await.clone()     } +    /// Janitor knobs: env vars override, otherwise the UI-editable prefs.+    pub async fn effective_max_shots(&self) -> usize {+        std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok())+            .unwrap_or(self.app_prefs.read().await.max_shots)+    }+    pub async fn effective_retention_days(&self) -> u64 {+        std::env::var("SHOTLOG_RETENTION_DAYS").ok().and_then(|v| v.parse().ok())+            .unwrap_or(self.app_prefs.read().await.retention_days)+    }+     /// Update prefs, persist, and sync the read-hook flag file.     pub async fn set_app_prefs(&self, p: AppPrefs) {         let flag = read_hook_off_flag();@@ -545,7 +572,7 @@ impl Store {     /// 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();+        let days = self.effective_retention_days().await;         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();