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.23.0
10 files changed
+152−18
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.22.1"+version = "0.23.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.22.1"+version = "0.23.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1@@ -1 +1 @@-0.22.1+0.23.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.22.1",+ "version": "0.23.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.22.1",+ "version": "0.23.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+109−4@@ -179,6 +179,14 @@ header { font-size: 9.5px; color: var(--text2); cursor: help; } .chip.ad { color: var(--accent); border-color: rgba(0,184,176,.3); } .chip.auto { color: var(--text3); }+.fchip { -webkit-appearance: none; appearance: none; padding: 5px 11px; border-radius: 999px;+ border: 1px solid var(--border); background: var(--overlay); color: var(--text2);+ cursor: pointer; font: 600 11px 'Familjen Grotesk', sans-serif; }+.fchip.on { color: var(--accent-bright); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.12); }+.frow { display: flex; flex-wrap: wrap; gap: 7px; padding: 8px 0 2px; }+.hdr-btn.filters-on { color: var(--accent-bright); border-color: rgba(0,184,176,.55); }+.m-full-toggle { color: var(--accent); cursor: pointer; font-weight: 600; }+.m-full-toggle:hover { text-decoration: underline; } .m-why { padding: 0 16px 8px; font: italic 12px/1.5 'Satoshi', sans-serif; color: var(--text2); } .m-why .k { color: var(--text3); font-style: normal; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; margin-right: 6px; } .shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; }@@ -385,6 +393,7 @@ header { <div class="actions"> <button class="hdr-btn" id="canvasBtn" style="display:none"></button> <button class="hdr-btn icon-only" id="toTopBtn" data-tooltip="Scroll to newest (top)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg></button>+ <button class="hdr-btn icon-only" id="filterBtn" data-tooltip="Filter shots: child-window arrays, capture endpoint, safe/full pairs, direct vs auto-captured."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg></button> <button class="hdr-btn icon-only" id="prefsBtn" data-tooltip="Preferences: the auto-capture read-hook, and whether channels default to webview or pup."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button> <button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button> <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live. While green the server knows you're watching and won't force-open a duplicate tab on new shots."></span>@@ -488,6 +497,35 @@ header { </div> </div> </div>+<div class="helper" id="filterDlg" role="dialog" aria-modal="true" aria-label="Filters">+ <div class="helper-card">+ <button class="h-close" id="filterClose">✕</button>+ <div class="h-title">Filters</div>+ <div class="pref-info"><div class="pref-name">What kind of shot</div></div>+ <div class="frow">+ <button class="fchip" data-f="arrays" data-tooltip="Only shots carrying an Adom Desktop CHILD-WINDOW ARRAY (parent hwnd + its owned dropdowns/dialogs).">child-window arrays</button>+ <button class="fchip" data-f="full" data-tooltip="Only shots stored as a SAFE/FULL pair (a full-resolution original is viewable).">safe/full pairs</button>+ <button class="fchip" data-f="direct" data-tooltip="Only shots an AI deliberately injected (not auto-captured by the read-hook).">direct injects</button>+ <button class="fchip" data-f="auto" data-tooltip="Only auto-captures from the read-hook (images AIs read).">auto-captured</button>+ </div>+ <div class="pref-info" style="margin-top:8px"><div class="pref-name">Capture endpoint</div></div>+ <div class="frow" id="provRow">+ <button class="fchip" data-p="ad-window" data-tooltip="Adom Desktop window screenshots (desktop_screenshot_window).">AD·window</button>+ <button class="fchip" data-p="ad-screen" data-tooltip="Adom Desktop full-screen screenshots.">AD·screen</button>+ <button class="fchip" data-p="pup" data-tooltip="pup (Puppeteer browser bridge) page captures.">pup</button>+ <button class="fchip" data-p="hydrogen" data-tooltip="Hydrogen workspace screenshots.">hydrogen</button>+ <button class="fchip" data-p="abe" data-tooltip="Adom Browser Extension captures (the user's real browser).">ABE</button>+ <button class="fchip" data-p="hd" data-tooltip="Hydrogen Desktop's built-in screenshot feature.">HD</button>+ <button class="fchip" data-p="crop" data-tooltip="Crops from larger originals.">crop</button>+ <button class="fchip" data-p="clipboard" data-tooltip="Human clipboard pastes (Ctrl+V).">paste</button>+ </div>+ <div class="h-actions">+ <button class="h-btn secondary" id="filterClear">Clear all</button>+ <span style="flex:1"></span>+ <span id="filterCount" style="color:var(--text3);font:11px 'JetBrains Mono',monospace"></span>+ </div>+ </div>+</div> <div class="toast" id="toast"></div> <div class="modal" id="modal"><div class="modal-card" id="modalCard"> <div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div><img id="mImg" alt="">@@ -645,7 +683,7 @@ header { // Inline nerd-metadata panel, rendered ON the card (revealed in place — no zoom). function buildNerdInline(e) { const pops=popList(e);- const raw=JSON.stringify({meta:e.meta||null, popups:pops.length?pops:undefined}, null, 2);+ const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows const panel=document.createElement('div'); panel.className='card-nerd'; const head=document.createElement('div'); head.className='nhead'; head.textContent='⌗ capture metadata'; const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';@@ -673,6 +711,10 @@ header { if (e.w&&e.h){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); dimNodes(e,meta); } if (e.kb){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }+ if(e.full){ const s2=document.createElement('span'); s2.className='sep'; s2.textContent='·'; meta.appendChild(s2);+ const fc=document.createElement('span'); fc.className='chip ad'; fc.textContent='full';+ fc.setAttribute('data-tooltip','Safe/full pair: the full-resolution original is stored alongside - open the zoom and click "view full".');+ meta.appendChild(fc); } srcChips(e).forEach(c=>{ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); const ch=document.createElement('span'); ch.className='chip '+(c.cls||''); ch.textContent=c.label; ch.setAttribute('data-tooltip', c.tip); meta.appendChild(ch); });@@ -732,6 +774,21 @@ header { if(e.w&&e.h){ sep(); add('dim-main',e.w+'×'+e.h); if(e.orig_w&&e.orig_h&&(e.orig_w!==e.w||e.orig_h!==e.h)){ sep(); add('','orig '+e.orig_w+'×'+e.orig_h); } } if(e.kb){ sep(); add('',e.kb+' KB'); }+ if(e.full){ sep();+ const t=document.createElement('span'); t.className='m-full-toggle';+ let showingFull=false;+ const label=()=> t.textContent = showingFull+ ? ('viewing FULL '+(e.orig_w||'?')+'×'+(e.orig_h||'?')+' - click for safe'+ )+ : ('view full '+(e.orig_w||'?')+'×'+(e.orig_h||'?'));+ label();+ t.setAttribute('data-tooltip','This shot is a safe/full pair: the resized (safe) copy is shown by default; click to swap in the stored full-resolution original.');+ t.addEventListener('click', ev=>{ ev.stopPropagation();+ showingFull=!showingFull;+ mImg.src = showingFull ? popUrl(e.full) : imgUrl(e);+ label(); });+ mMeta.appendChild(t);+ } if(e.source&&e.source!=='cli'){ sep(); add('',e.source); } if(e.thread){ sep(); add('thr',e.thread,'The AI thread that produced this shot.'); } @@ -762,8 +819,8 @@ header { // Nerd metadata panel — readable rows + copyable raw JSON. mNerd.innerHTML=''; const rows=metaRows(e);- if(e.meta || rows.length){- const raw=JSON.stringify({meta:e.meta||null, popups:pops.length?pops:undefined}, null, 2);+ if(true){ // full metadata is ALWAYS available now+ const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows const d=document.createElement('details'); d.className='nerd'; const sum=document.createElement('summary'); sum.textContent='⌗ capture metadata'; const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';@@ -797,7 +854,12 @@ header { shotCount=0; updateCount(); return; } shotCount=entries.length;- for (let i=entries.length-1;i>=0;i--) feed.appendChild(card(entries[i])); // newest first+ let shown=0;+ for (let i=entries.length-1;i>=0;i--){ const e=entries[i];+ if(!passFilters(e)) continue; shown++; feed.appendChild(card(e)); } // newest first+ if(!shown){+ feed.innerHTML='<div class="empty">No shots match the active filters ('+entries.length+' hidden).<br><br>Open the funnel in the header to adjust or clear them.</div>';+ } updateCount(); } function channelTooltipHtml(countText){@@ -823,6 +885,48 @@ header { $('channelName')._ttHtml = channelTooltipHtml(t); } // ── Load ───────────────────────────────────────────────────────────────+ // ── Filters: what kinds of shots to show in the feed ──────────────────────+ const FILTERS={arrays:false, full:false, direct:false, auto:false, prov:new Set()};+ function passFilters(e){+ if(FILTERS.arrays && !popList(e).length) return false;+ if(FILTERS.full && !e.full) return false;+ const isAuto=(e.source||'').startsWith('read-hook');+ if(FILTERS.direct && isAuto) return false;+ if(FILTERS.auto && !isAuto) return false;+ if(FILTERS.prov.size){+ const parts=(e.source||'').split('/');+ if(![...FILTERS.prov].some(x=>parts.includes(x))) return false;+ }+ return true;+ }+ function filtersActive(){ return FILTERS.arrays||FILTERS.full||FILTERS.direct||FILTERS.auto||FILTERS.prov.size>0; }+ (function wireFilters(){+ const dlg=$('filterDlg'), btn=$('filterBtn'); if(!dlg) return;+ const close=()=>dlg.classList.remove('open');+ $('filterClose').addEventListener('click',close);+ dlg.addEventListener('click',e=>{ if(e.target===dlg) close(); });+ document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&dlg.classList.contains('open')) close(); });+ function refresh(){+ dlg.querySelectorAll('.fchip[data-f]').forEach(c=>c.classList.toggle('on', !!FILTERS[c.dataset.f]));+ dlg.querySelectorAll('.fchip[data-p]').forEach(c=>c.classList.toggle('on', FILTERS.prov.has(c.dataset.p)));+ btn.classList.toggle('filters-on', filtersActive());+ renderAll();+ const shown=entries.filter(passFilters).length;+ $('filterCount').textContent = filtersActive()? shown+' of '+entries.length+' shown' : '';+ }+ dlg.addEventListener('click',e=>{+ const c=e.target.closest('.fchip'); if(!c) return;+ if(c.dataset.f){ FILTERS[c.dataset.f]=!FILTERS[c.dataset.f];+ // direct & auto are mutually exclusive+ if(c.dataset.f==='direct'&&FILTERS.direct) FILTERS.auto=false;+ if(c.dataset.f==='auto'&&FILTERS.auto) FILTERS.direct=false; }+ else if(c.dataset.p){ FILTERS.prov.has(c.dataset.p)?FILTERS.prov.delete(c.dataset.p):FILTERS.prov.add(c.dataset.p); }+ refresh();+ });+ $('filterClear').addEventListener('click',()=>{ FILTERS.arrays=FILTERS.full=FILTERS.direct=FILTERS.auto=false; FILTERS.prov.clear(); refresh(); });+ btn.addEventListener('click',()=>{ dlg.classList.add('open'); refresh(); });+ })();+ // ── Tab strip: shotlog-managed channel tabs ─────────────────────────────── const strip=$('chtabs'); function tabOrder(){@@ -1149,6 +1253,7 @@ header { if (existing) Object.assign(existing, e); else c.entries.push(e); } if (chName === channel) {+ if(!passFilters(e)){ renderTabs(); return; } const atTop = feed.scrollTop < 60; const old = feed.querySelector(`[data-id="${e.id}"]`); if (old) { old.replaceWith(card(e)); }
src/main.rs+5−2@@ -365,6 +365,7 @@ async fn main() { // Resolve the original dimensions. Two ways to supply them for a // pre-shrunk image: --orig-file (measure it) or --orig-w/--orig-h.+ let orig_path_keep = orig_file.clone(); let (orig_w, orig_h) = match (orig_file, orig_w, orig_h) { (Some(of), _, _) => { let bytes = std::fs::read(&of).unwrap_or_else(|e| {@@ -421,7 +422,7 @@ async fn main() { } }; - run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref());+ run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref(), orig_path_keep.as_deref()); } Commands::Open { channel, name, icon, port, skinny, ratio, pup, swap, webview,@@ -477,7 +478,7 @@ async fn main() { std::process::exit(1); } };- run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None);+ run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None, None); } Commands::Resize { file, output, width } => { run_resize(&file, output.as_deref(), width);@@ -580,6 +581,7 @@ fn run_inject( popups: Option<Vec<serde_json::Value>>, thread: Option<&str>, context: Option<&str>,+ orig_path: Option<&str>, ) { let bytes = if file == "-" { use std::io::Read;@@ -612,6 +614,7 @@ fn run_inject( "source": source, "thread": thread, "context": context,+ "orig_path": orig_path.map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string())), }); if let (Some(ow), Some(oh)) = (orig_w, orig_h) { b["orig_w"] = serde_json::json!(ow);
src/server.rs+5−2@@ -775,6 +775,9 @@ struct InjectRequest { /// WHY the shot exists: the user prompt the thread was serving (the /// read-hook extracts it programmatically from the transcript). context: Option<String>,+ /// Absolute path of the ORIGINAL full-res image (when the caller passed+ /// --orig-file): the broker stores a copy beside the shot as {stem}__full.png.+ orig_path: Option<String>, } /// Images larger than this on any edge are auto-resized (Claude crashes >1500px).@@ -871,7 +874,7 @@ async fn post_shots( let entry = state .store- .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone(), req.context.clone())+ .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone(), req.context.clone(), req.orig_path.clone()) .await; // Presence at inject time — the caller uses this to know if the human is@@ -1011,7 +1014,7 @@ async fn post_paste( &req.channel, bytes, filename, description, "clipboard".into(), w, h, if was_resized { Some(orig_w) } else { None }, if was_resized { Some(orig_h) } else { None },- None, None, None, None,+ None, None, None, None, None, ) .await;
src/store.rs+28−5@@ -44,6 +44,10 @@ pub struct Entry { /// transcript's last-prompt record - zero AI tokens). #[serde(skip_serializing_if = "Option::is_none")] pub context: Option<String>,+ /// Filename of the stored FULL-RESOLUTION copy ({stem}__full.png) when the+ /// injector provided the original (safe/full pairs). Viewable via the zoom.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub full: Option<String>, } struct Channel {@@ -72,6 +76,9 @@ 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(f), Some(dir)) = (&entry.full, p.parent()) {+ let _ = fs::remove_file(dir.join(f));+ } if let Some(arr) = entry.popups.as_ref().and_then(|v| v.as_array()) { if let Some(dir) = p.parent() { for c in arr {@@ -216,7 +223,7 @@ impl Store { if p.extension().and_then(|e| e.to_str()) == Some("png") { // Skip owned-popup attachment PNGs ({stem}__popupN.png) — they // belong to a primary shot's sidecar, not a standalone entry.- if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup")).unwrap_or(false) {+ if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup") || n.contains("__full")).unwrap_or(false) { continue; } if let Ok(meta) = fs::metadata(&p) {@@ -250,7 +257,7 @@ impl Store { // Read sidecar metadata if it exists let meta_path = path.with_extension("json");- let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread, context) = if let Ok(json_str) = fs::read_to_string(&meta_path) {+ let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread, context, full) = if let Ok(json_str) = fs::read_to_string(&meta_path) { if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) { let desc = v["description"].as_str().unwrap_or("").to_string(); let src = v["source"].as_str().unwrap_or("recovered").to_string();@@ -263,12 +270,13 @@ impl Store { let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None }; let th = v.get("thread").and_then(|t| t.as_str()).map(String::from); let cx = v.get("context").and_then(|t| t.as_str()).map(String::from);- (desc, src, ts, ow, oh, cm, pp, th, cx)+ let fl = v.get("full").and_then(|t| t.as_str()).map(String::from);+ (desc, src, ts, ow, oh, cm, pp, th, cx, fl) } else {- (String::new(), "recovered".into(), modified, None, None, None, None, None, None)+ (String::new(), "recovered".into(), modified, None, None, None, None, None, None, None) } } else {- (String::new(), "recovered".into(), modified, None, None, None, None, None, None)+ (String::new(), "recovered".into(), modified, None, None, None, None, None, None, None) }; let png_meta = read_png_meta(&bytes_raw);@@ -290,6 +298,7 @@ impl Store { popups, thread, context,+ full, }); next_id += 1; }@@ -331,6 +340,7 @@ impl Store { popups: Option<serde_json::Value>, thread: Option<String>, context: Option<String>,+ orig_path: Option<String>, ) -> Entry { let channel_dir = self.base_dir.join(channel_name); fs::create_dir_all(&channel_dir).ok();@@ -338,6 +348,17 @@ impl Store { let file_path = channel_dir.join(&filename); fs::write(&file_path, &image_bytes).ok(); + // Safe/full pairs: copy the ORIGINAL next to the stored (safe) shot so+ // the full resolution stays viewable even after the source cache is+ // janitored elsewhere. Skipped when the original IS the injected file.+ let full = orig_path.as_deref().and_then(|op| {+ let ob = fs::read(op).ok()?;+ if ob == image_bytes { return None; }+ let fname = format!("{}__full.png", filename.trim_end_matches(".png"));+ fs::write(channel_dir.join(&fname), &ob).ok()?;+ Some(fname)+ });+ let file_bytes = image_bytes.len() as u64; let png_meta = read_png_meta(&image_bytes); @@ -358,6 +379,7 @@ impl Store { if let Some(p) = &popups { meta_json["popups"] = p.clone(); } if let Some(t) = &thread { meta_json["thread"] = serde_json::json!(t); } if let Some(c) = &context { meta_json["context"] = serde_json::json!(c); }+ if let Some(f) = &full { meta_json["full"] = serde_json::json!(f); } fs::write(&meta_path, meta_json.to_string()).ok(); let mut channels = self.channels.write().await;@@ -390,6 +412,7 @@ impl Store { popups, thread, context,+ full, }; ch.next_id += 1;