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.20.0
12 files changed
+53−16
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.19.1"+version = "0.20.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.19.1"+version = "0.20.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
SKILL.md+1@@ -177,6 +177,7 @@ metadata are additive. | `--orig-file` | for pre-shrunk images | Path to the original full-res image; measured | | `--orig-w` / `--orig-h` | for pre-shrunk images | Original dims (alternative to `--orig-file`) | | `--native` | for unresized small images | Assert FILE is at original resolution |+| `--thread` | no | Which AI thread produced this shot (human-readable). The read-hook fills it automatically from the session transcript; pass your thread's name when injecting deliberately so the human sees WHO. | | `--meta` | optional | Adom Desktop screenshot-response JSON: child windows + coordMap + orig dims | | `FILE` | yes | Primary image path (use `-` for stdin) |
VERSION+1−1@@ -1 +1 @@-0.19.1+0.20.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
hooks/shotlog-on-read.sh+14−2@@ -15,7 +15,8 @@ # Preferences dialog (broker writes this flag file). No-op when disabled. [ -f "$HOME/.claude/hooks/.shotlog-read-hook-off" ] && exit 0 -f="$(jq -r 'select(.tool_name=="Read") | (.tool_input.file_path // empty)' 2>/dev/null)"+INPUT="$(cat)"+f="$(echo "$INPUT" | jq -r 'select(.tool_name=="Read") | (.tool_input.file_path // empty)' 2>/dev/null)" [ -z "$f" ] && exit 0 case "$f" in@@ -37,6 +38,17 @@ shotlog health >/dev/null 2>&1 || { (cd "${SHOTLOG_HOME:-$HOME/project}" && shot base="$(basename "$f")" +# Attribute the capture to the AI THREAD that read the image: the hook payload+# carries session_id + transcript_path; the transcript's ai-title records hold+# the thread's human-readable title.+SID="$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)"+TP="$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)"+THREAD=""+if [ -n "$TP" ] && [ -f "$TP" ]; then+ THREAD="$(grep '"type":"ai-title"' "$TP" 2>/dev/null | tail -1 | jq -r '.aiTitle // empty' 2>/dev/null)"+fi+[ -z "$THREAD" ] && [ -n "$SID" ] && THREAD="thread ${SID:0:8}"+ # Satisfy shotlog's original-resolution contract (a blind pre-shrunk inject is # REJECTED). AD saves resized captures as *.safe.png beside the *.full.png # original - pass the original when it exists; otherwise the file Claude read@@ -51,6 +63,6 @@ esac shotlog inject -c read-captures \ -d "Auto-captured screenshot Claude read for analysis: ${base}" \- -s read-hook $ORIG_ARGS "$f" >/dev/null 2>&1+ -s read-hook ${THREAD:+--thread "$THREAD"} $ORIG_ARGS "$f" >/dev/null 2>&1 exit 0
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.19.1",+ "version": "0.20.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.19.1",+ "version": "0.20.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+5@@ -154,6 +154,7 @@ header { .shot-card:hover, .shot-card:active { border-color: rgba(0,184,176,.5); } .shot-card-desc { padding: 10px 13px 5px; font-family: 'Satoshi', sans-serif; font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.4; max-height: 76px; overflow: hidden; flex-shrink: 0; }+.shot-card-meta .thr { color: var(--purple); font-style: italic; } .shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; } .shot-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 3px 13px 7px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }@@ -591,6 +592,9 @@ header { const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); } if (e.source && e.source!=='cli'){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); const sr=document.createElement('span'); sr.className='src'; sr.textContent=e.source; meta.appendChild(sr); }+ if (e.thread){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+ const th=document.createElement('span'); th.className='thr'; th.textContent=e.thread;+ th.setAttribute('data-tooltip','The AI thread that produced this shot.'); meta.appendChild(th); } const pops=popList(e); if (pops.length){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' child window'+(pops.length>1?'s':'');@@ -641,6 +645,7 @@ header { 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.source&&e.source!=='cli'){ sep(); add('',e.source); }+ if(e.thread){ sep(); add('thr',e.thread,'The AI thread that produced this shot.'); } // Child-window thumbnails — each opens full-size in its own modal entry. const pops=popList(e);
src/main.rs+10−3@@ -163,6 +163,10 @@ enum Commands { /// the coordMap/screen metadata a hover panel. #[arg(long)] meta: Option<String>,+ /// Which AI thread produced this shot (human-readable title). The+ /// read-hook fills this automatically from the session transcript.+ #[arg(long)]+ thread: Option<String>, /// PNG file path (use - for stdin) file: String, },@@ -286,7 +290,8 @@ async fn main() { run_server(port, data_dir).await; } Commands::Inject {- channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, file,+ channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, thread,+ file, } => { let desc = match desc { Some(d) if d.len() > 5 => d,@@ -412,7 +417,7 @@ async fn main() { } }; - run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups);+ run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref()); } Commands::Open { channel, name, icon, port, skinny, ratio, pup, swap, webview,@@ -468,7 +473,7 @@ async fn main() { std::process::exit(1); } };- run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None);+ run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None); } Commands::Resize { file, output, width } => { run_resize(&file, output.as_deref(), width);@@ -569,6 +574,7 @@ fn run_inject( orig_h: Option<u32>, cap_meta: Option<serde_json::Value>, popups: Option<Vec<serde_json::Value>>,+ thread: Option<&str>, ) { let bytes = if file == "-" { use std::io::Read;@@ -599,6 +605,7 @@ fn run_inject( "image": data_url, "description": desc, "source": source,+ "thread": thread, }); if let (Some(ow), Some(oh)) = (orig_w, orig_h) { b["orig_w"] = serde_json::json!(ow);
src/server.rs+5−2@@ -766,6 +766,9 @@ struct InjectRequest { /// metadata (hwnd, title, kind, coordMap, shotId). The `image` is saved as its /// own PNG and replaced with `filename` in the stored array. popups: Option<Vec<serde_json::Value>>,+ /// Which AI thread produced this shot (human-readable title). The read-hook+ /// fills this from the session transcript; any injector may pass it.+ thread: Option<String>, } /// Images larger than this on any edge are auto-resized (Claude crashes >1500px).@@ -862,7 +865,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)+ .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone()) .await; // Presence at inject time — the caller uses this to know if the human is@@ -999,7 +1002,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, ) .await;
src/store.rs+13−4@@ -35,6 +35,10 @@ pub struct Entry { /// coordMap. Additive — absent for plain single-image injects. #[serde(skip_serializing_if = "Option::is_none")] pub popups: Option<serde_json::Value>,+ /// Which AI THREAD produced this shot (from the read-hook's session+ /// metadata, or any injector passing --thread). Human-readable title.+ #[serde(skip_serializing_if = "Option::is_none")]+ pub thread: Option<String>, } struct Channel {@@ -231,7 +235,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) = if let Ok(json_str) = fs::read_to_string(&meta_path) {+ let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread) = 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();@@ -242,12 +246,13 @@ impl Store { let oh = v["orig_h"].as_u64().map(|n| n as u32); let cm = if v.get("meta").map(|m| !m.is_null()).unwrap_or(false) { Some(v["meta"].clone()) } else { None }; let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };- (desc, src, ts, ow, oh, cm, pp)+ let th = v.get("thread").and_then(|t| t.as_str()).map(String::from);+ (desc, src, ts, ow, oh, cm, pp, th) } else {- (String::new(), "recovered".into(), modified, None, None, None, None)+ (String::new(), "recovered".into(), modified, None, None, None, None, None) } } else {- (String::new(), "recovered".into(), modified, None, None, None, None)+ (String::new(), "recovered".into(), modified, None, None, None, None, None) }; let png_meta = read_png_meta(&bytes_raw);@@ -267,6 +272,7 @@ impl Store { png_meta, meta: cap_meta, popups,+ thread, }); next_id += 1; }@@ -306,6 +312,7 @@ impl Store { orig_h: Option<u32>, meta: Option<serde_json::Value>, popups: Option<serde_json::Value>,+ thread: Option<String>, ) -> Entry { let channel_dir = self.base_dir.join(channel_name); fs::create_dir_all(&channel_dir).ok();@@ -331,6 +338,7 @@ impl Store { } if let Some(m) = &meta { meta_json["meta"] = m.clone(); } if let Some(p) = &popups { meta_json["popups"] = p.clone(); }+ if let Some(t) = &thread { meta_json["thread"] = serde_json::json!(t); } fs::write(&meta_path, meta_json.to_string()).ok(); let mut channels = self.channels.write().await;@@ -361,6 +369,7 @@ impl Store { png_meta, meta, popups,+ thread, }; ch.next_id += 1;