← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.28.7"+version = "0.29.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.28.7"+version = "0.29.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+2
@@ -26,6 +26,8 @@ shotlog ships a Claude Code `PostToolUse(Read)` hook (`hooks/shotlog-on-read.sh`  `adom-shotlog install` deploys the hook and registers it in `~/.claude/settings.json` (surgically, never touching your other hooks), and **every `serve` startup re-verifies both**, healing them if missing, so the pipeline cannot silently rot. The hook also self-heals the broker if it is not running. +**The send-hook: what the AI said about it.** A capture taken on read only knows the file name. When an AI then sends that image to you with a caption ("Domino with bottom pours: 0.3 mm gap, two vias per pad..."), the companion `PostToolUse(SendUserFile)` hook (`hooks/shotlog-on-send.sh`) makes the caption the shot's description, matched to the shot by the image's content, and the card updates live. The automatic description is kept as `auto_description`. Images sent without being read are added with their caption. `adom-shotlog annotate -d "..." <file>` does the same by hand.+ Related guarantees: the broker is **single-instance per port** (a second `serve` detects the healthy broker and defers gracefully, warning if versions differ), and `read-captures` stays disk-bounded by the retention policy.  ## It adapts to your surface
SKILL.md+17
@@ -72,6 +72,23 @@ broker (from `$SHOTLOG_HOME`, default `~/project`) if it is down. If you see `read-captures` filling up: that is this hook working as designed, and retention keeps it bounded. +## The send-hook: your caption becomes the shot's description++A read-hook capture only knows the file name ("Auto-captured screenshot Claude+read for analysis: x.png"). What you SAY about the image comes later, when you+send it to the human. shotlog also ships a `PostToolUse(SendUserFile)` hook:+when you send an image with a `caption`, that caption replaces the description+of the shot made from the same file (matched by content, not name), the+automatic text is kept as `auto_description`, and open viewers update that card+in place. An image you send without having read it is injected with the+caption. So: give image sends a real caption; it is what the human reads in+shotlog. Installed and re-verified with the read-hook; off when the read-hook+is off.++By hand: `adom-shotlog annotate -d "<what it shows>" <file>` re-describes the+newest shot made from `<file>` (exit 3 when there is none; `--inject-missing`+adds it instead).+ ## Preferences and canvas control (AI-drivable)  Everything the human can click, YOU can do - and the human is told the matching
VERSION+1−1
@@ -1 +1 @@-0.28.7+0.29.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+4
@@ -482,3 +482,7 @@ When you fix something Ray reported (issue or PR), DM him one simple line on Goo  The viewer's `<base>` is derived from the page's own path: everything in front of `/log/`. Never match a fixed `/proxy/N/` prefix: code-server serves `/proxy/8820/`, Mac Hydrogen serves `/__cs/proxy/8820/`, and a direct open has none. With the wrong base every relative fetch and the WebSocket go to the host app instead of the broker and the gallery sits empty with a gray LED. `open --webview` now polls `viewer-status` for 8s after the tab opens and prints WARN + the direct `localhost:<port>/log/` URL when nothing connects; `broker_viewer_count(port, channel)` is the helper. Filelog carries the identical derivation. ++## Send-hook and annotate (Ray, 2026-10-16)++A read-hook shot is taken BEFORE the AI writes anything about the image, so its only description is the file name. `hooks/shotlog-on-send.sh` (PostToolUse SendUserFile, registered by `ensure_hook` next to the read-hook) runs `annotate --inject-missing` with the send's caption for each image. Matching is by `image::fingerprint` (FNV-1a 64 + length over the bytes AS THE CALLER HAD THEM, before any broker resize), stored on the entry and its sidecar; never match by filename (shotlog renames files from the description). `Store::annotate` replaces `description`, keeps the first automatic one in `auto_description`, fills thread/context only when empty, rewrites the sidecar and re-broadcasts the entry; the viewer's existing same-id branch replaces the card. Shots from before 0.29.0 have no fingerprint and cannot be annotated (the hook then injects a new captioned shot).
hooks/shotlog-on-send.shadded+59
@@ -0,0 +1,59 @@+#!/bin/bash+# PostToolUse(SendUserFile) hook - SHIPPED AND MANAGED BY adom-shotlog.+# When an AI SENDS an image to the human with a caption, that caption becomes+# the description of the shot made from the same file (matched by content via+# `shotlog annotate`), so the shotlog card says what the AI said about it -+# not just "Auto-captured screenshot Claude read for analysis: <file>".+# An image that was sent but never Read is injected with the caption.+# Installed by `adom-shotlog install` and re-verified at every+# `adom-shotlog serve` startup; edits here are overwritten.+#+# Reads the hook JSON on stdin:+#   {"tool_name":"SendUserFile","tool_input":{"files":[...],"caption":"..."},"cwd":"..."}+# Always exits 0 so it can never block a send.++# Same switch as the read-hook: auto-capture off in the viewer's Preferences.+[ -f "$HOME/.claude/hooks/.shotlog-read-hook-off" ] && exit 0++INPUT="$(cat)"+[ "$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)" = "SendUserFile" ] || exit 0+CAPTION="$(echo "$INPUT" | jq -r '.tool_input.caption // empty' 2>/dev/null)"+# No caption: nothing to carry over (the file name is all the read-hook had too).+[ -z "$(printf '%s' "$CAPTION" | tr -d '[:space:]')" ] && exit 0+command -v shotlog >/dev/null 2>&1 || exit 0+CWD="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)"++# Thread + context exactly as the read-hook derives them (used only where the+# shot has none, or when the image was never read and is injected here).+SID="$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)"+TP="$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)"+THREAD=""; CONTEXT=""+if [ -n "$TP" ] && [ -f "$TP" ]; then+  THREAD="$(grep '"type":"custom-title"' "$TP" 2>/dev/null | tail -1 | jq -r '.customTitle // empty' 2>/dev/null)"+  [ -z "$THREAD" ] && THREAD="$(grep '"type":"ai-title"' "$TP" 2>/dev/null | tail -1 | jq -r '.aiTitle // empty' 2>/dev/null)"+  CONTEXT="$(grep '"type":"last-prompt"' "$TP" 2>/dev/null | tail -1 | jq -r '.lastPrompt // empty' 2>/dev/null | head -c 220)"+fi+[ -z "$THREAD" ] && [ -n "$SID" ] && THREAD="thread ${SID:0:8}"++shotlog health >/dev/null 2>&1 || { (cd "${SHOTLOG_HOME:-$HOME/project}" && shotlog serve >/dev/null 2>&1 &) ; sleep 1; }++echo "$INPUT" | jq -r '.tool_input.files[]? // empty' 2>/dev/null | while IFS= read -r f; do+  [ -z "$f" ] && continue+  case "$f" in /*) ;; *) [ -n "$CWD" ] && f="$CWD/$f" ;; esac+  case "$f" in+    *.png|*.jpg|*.jpeg|*.PNG|*.JPG|*.JPEG) ;;   # the same types the read-hook captures+    *) continue ;;+  esac+  [ -f "$f" ] || continue+  # A file already inside shotlog's own store is a stored COPY (possibly+  # resized): its fingerprint never matches the original, so --inject-missing+  # would add a duplicate card. Same skip as the read-hook.+  case "$f" in */screenshots/shotlog/*) continue ;; esac+  for attempt in 1 2 3; do+    shotlog annotate -c read-captures --inject-missing -d "$CAPTION" \+      ${THREAD:+--thread "$THREAD"} ${CONTEXT:+--context "$CONTEXT"} "$f" >/dev/null 2>&1 && break+    sleep 2+  done+done++exit 0
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.28.7",+  "version": "0.29.0",   "type": "app",   "title": "Shotlog - Screenshot Log",   "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 Bridge child-window arrays via --meta.",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.28.7",+  "version": "0.29.0",   "type": "app",   "title": "Shotlog - Screenshot Log",   "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 Bridge child-window arrays via --meta.",
src/html/index.html+36−1
@@ -269,6 +269,15 @@ body.compact .card-popups .ph { padding-top: 4px; } @media (hover: none) { .card-refbar .rb-copy { opacity: .8; } } .card-refbar .rb-tag { color: var(--accent-bright); background: rgba(0,184,176,.12); border: 1px solid rgba(0,184,176,.45);   border-radius: 5px; padding: 0 6px; max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+/* caption pill (gold): an annotated shot carries a longer caption from the+   AI that sent it. It starts the caption here, cut to fit the narrow+   sidebar, and hovering it reveals the full info overlay, like the ⓘ. */+.card-refbar .rb-cap { color: #e3b341; background: rgba(227,179,65,.10); border: 1px solid rgba(227,179,65,.45);+  border-radius: 5px; padding: 0 6px; flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis;+  white-space: nowrap; cursor: help; font-family: 'Satoshi', sans-serif; font-weight: 600; }+/* classic mode already shows the caption above the image */+body:not(.compact) .card-refbar .rb-cap { display: none; }+.card-refbar .rb-cap:hover { background: rgba(227,179,65,.18); border-color: rgba(227,179,65,.7); } .shot-card:not(.has-refbar) .card-refbar { } .chip { display: inline-block; padding: 1px 6px; border-radius: 5px; background: var(--overlay);   border: 1px solid var(--border-muted); font-family: 'JetBrains Mono', monospace;@@ -1175,7 +1184,25 @@ body.compact .card-popups .ph { padding-top: 4px; }     // was serving, and demote the generic "auto-captured: file.png" to a     // small mono sub-line. Deliberate injects keep their description first.     const isAuto=(e.source||'').startsWith('read-hook');-    if (isAuto && e.context) {+    if (e.auto_description) {+      // Annotated (send-hook / `annotate`): the caption the AI wrote when it+      // SENT this image is the headline; the prompt and the file name it was+      // auto-captured as become the small sub-lines.+      // .shot-card-why styling, not .shot-card-desc: captions run long and+      // the desc style clips at 76px, which in the narrow sidebar's ⓘ overlay+      // cut the caption to a few words. The overlay sizes to its content.+      const desc=document.createElement('div'); desc.className='shot-card-why shot-card-caption';+      desc.textContent=e.description;+      info.appendChild(desc);+      if (e.context) {+        const why=document.createElement('div'); why.className='shot-card-sub';+        why.textContent='prompt: '+e.context;+        info.appendChild(why);+      }+      const sub=document.createElement('div'); sub.className='shot-card-sub';+      sub.textContent='auto-captured: '+(e.filename||'').replace(/^auto-captured-screenshot-claude-read-for-analysis-/,'');+      info.appendChild(sub);+    } else if (isAuto && e.context) {       const why=document.createElement('div'); why.className='shot-card-why';       why.textContent=e.context;       why.setAttribute('data-tooltip','The prompt this AI thread was working on when it read the image - the REASON this capture exists.');@@ -1256,6 +1283,14 @@ body.compact .card-popups .ph { padding-top: 4px; }       if (e.tag){ const tg=document.createElement('span'); tg.className='rb-tag'; tg.textContent=e.tag;         tg.setAttribute('data-tooltip','Handle "'+e.tag+'" given by the AI that injected this shot (--tag). Say "'+e.tag+'" to mean exactly this shot.');         rb.appendChild(tg); }+      if (e.auto_description && e.description) {+        const cp=document.createElement('span'); cp.className='rb-cap'; cp.textContent=e.description;+        cp.setAttribute('aria-label','Caption: '+e.description);+        // Same reveal as the ⓘ: hover shows the full info overlay (caption,+        // prompt, file, chips); click toggles it for touch.+        cp.addEventListener('mouseenter', ()=>{ info.classList.add('show'); window._hideTip&&window._hideTip(); });+        cp.addEventListener('click', ev=>{ ev.stopPropagation(); info.classList.toggle('show'); });+        rb.appendChild(cp); }       // Copy the WHOLE reference, "#158 hero-final", not just one half (Ray,       // PR #5): the ref is globally unambiguous and the handle is what a human       // actually says, so pasting both lets the AI resolve it either way and
src/image.rs+13
@@ -118,3 +118,16 @@ pub fn parse_data_url(data_url: &str) -> Result<(String, Vec<u8>), String> {     Ok((mime, bytes)) } ++/// Content FINGERPRINT of an image file as the caller had it (before any+/// broker resize): FNV-1a 64 over the bytes, plus the length. Stable across+/// versions and hosts (no std hasher), so a later step that only has the FILE+/// (the send-hook, `shotlog annotate`) can find the shot made from it.+pub fn fingerprint(bytes: &[u8]) -> String {+    let mut h: u64 = 0xcbf29ce484222325;+    for b in bytes {+        h ^= *b as u64;+        h = h.wrapping_mul(0x100000001b3);+    }+    format!("{h:016x}-{}", bytes.len())+}
src/main.rs+118−17
@@ -29,19 +29,43 @@ fn surface_label(surfaces: &[String]) -> String { const SKILL_MD: &str = include_str!("../SKILL.md"); const READ_HOOK_SH: &str = include_str!("../hooks/shotlog-on-read.sh"); -/// Install/verify the shipped PostToolUse(Read) hook: the hook FILE and its+const SEND_HOOK_SH: &str = include_str!("../hooks/shotlog-on-send.sh");++/// Install/verify the shipped PostToolUse hooks: each hook FILE and its /// REGISTRATION in ~/.claude/settings.json. Runs at `install` and at every /// `serve` startup, so the auto-capture pipeline can never silently rot. /// Surgical + idempotent: never touches other hooks; backs up settings once /// per change to settings.json.shotlog-bak.+///   Read          -> shotlog-on-read.sh: every image an AI reads is captured+///   SendUserFile  -> shotlog-on-send.sh: the caption an AI writes when it+///                    sends an image to the human becomes that shot's description fn ensure_read_hook() {+    let read = ensure_hook("Read", "shotlog-on-read.sh", READ_HOOK_SH);+    let send = ensure_hook("SendUserFile", "shotlog-on-send.sh", SEND_HOOK_SH);+    match read {+        Some((true, _)) => ok("read-hook installed/updated: every image any AI thread Reads is auto-captured to the 'read-captures' channel (PostToolUse hook + settings registration)."),+        Some((false, true)) => ok("read-hook verified: auto-capture of every image any AI Reads is active."),+        Some((false, false)) => warn("read-hook file present but could not verify registration in settings.json."),+        None => {}+    }+    match send {+        Some((true, _)) => ok("send-hook installed/updated: the caption an AI writes when it sends an image to the human becomes that shot's description."),+        Some((false, true)) => ok("send-hook verified: captions of images sent to the human are carried into shotlog."),+        Some((false, false)) => warn("send-hook file present but could not verify registration in settings.json."),+        None => {}+    }+}++/// Write one hook file and register it under PostToolUse[matcher].+/// Returns (changed, registered), or None when settings.json is not valid JSON.+fn ensure_hook(matcher: &str, name: &str, body: &str) -> Option<(bool, bool)> {     let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());     let dir = format!("{home}/.claude/hooks");-    let path = format!("{dir}/shotlog-on-read.sh");+    let path = format!("{dir}/{name}");     let _ = std::fs::create_dir_all(&dir);     let mut file_changed = false;-    if std::fs::read_to_string(&path).unwrap_or_default() != READ_HOOK_SH {-        if std::fs::write(&path, READ_HOOK_SH).is_ok() {+    if std::fs::read_to_string(&path).unwrap_or_default() != body {+        if std::fs::write(&path, body).is_ok() {             use std::os::unix::fs::PermissionsExt;             let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));             file_changed = true;@@ -51,8 +75,8 @@ fn ensure_read_hook() {     let sp = format!("{home}/.claude/settings.json");     let raw = std::fs::read_to_string(&sp).unwrap_or_else(|_| "{}".to_string());     let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {-        warn("read-hook: ~/.claude/settings.json is not valid JSON - hook file installed but NOT registered.");-        return;+        warn(&format!("{name}: ~/.claude/settings.json is not valid JSON - hook file installed but NOT registered."));+        return None;     };     let mut reg_changed = false;     let mut registered = false;@@ -61,15 +85,15 @@ fn ensure_read_hook() {         if let Some(hobj) = hooks.as_object_mut() {             let ptu = hobj.entry("PostToolUse").or_insert_with(|| serde_json::json!([]));             if let Some(arr) = ptu.as_array_mut() {-                let mut found_read_group = false;+                let mut found_group = false;                 for g in arr.iter_mut() {-                    if g.get("matcher").and_then(|m| m.as_str()) == Some("Read") {-                        found_read_group = true;+                    if g.get("matcher").and_then(|m| m.as_str()) == Some(matcher) {+                        found_group = true;                         if let Some(gobj) = g.as_object_mut() {                             let hs = gobj.entry("hooks").or_insert_with(|| serde_json::json!([]));                             if let Some(list) = hs.as_array_mut() {                                 if list.iter().any(|h| h.get("command").and_then(|c| c.as_str())-                                    .map(|c| c.contains("shotlog-on-read.sh")).unwrap_or(false)) {+                                    .map(|c| c.contains(name)).unwrap_or(false)) {                                     registered = true;                                 } else {                                     list.push(serde_json::json!({"type": "command", "command": path}));@@ -81,8 +105,8 @@ fn ensure_read_hook() {                         break;                     }                 }-                if !found_read_group {-                    arr.push(serde_json::json!({"matcher": "Read",+                if !found_group {+                    arr.push(serde_json::json!({"matcher": matcher,                         "hooks": [{"type": "command", "command": path}]}));                     registered = true;                     reg_changed = true;@@ -96,11 +120,7 @@ fn ensure_read_hook() {             let _ = std::fs::write(&sp, pretty);         }     }-    match (file_changed || reg_changed, registered) {-        (true, _) => ok("read-hook installed/updated: every image any AI thread Reads is auto-captured to the 'read-captures' channel (PostToolUse hook + settings registration)."),-        (false, true) => ok("read-hook verified: auto-capture of every image any AI Reads is active."),-        (false, false) => warn("read-hook file present but could not verify registration in settings.json."),-    }+    Some((file_changed || reg_changed, registered)) }  #[derive(Parser)]@@ -281,6 +301,38 @@ enum Commands {         #[arg(long, default_value_t = 25)]         limit: usize,     },+    /// Re-describe the shot made from FILE (matched by content, not name): the+    /// newest shot whose source bytes equal FILE gets DESC as its description;+    /// the automatic one is kept as auto_description. Exit 0 = updated, 3 = no+    /// shot was made from FILE (use --inject-missing to add one instead).+    /// The send-hook runs this with the caption an AI wrote when it SENT an+    /// image to the human, so the shot carries that context, not just the file name.+    Annotate {+        /// The new description+        #[arg(short, long)]+        desc: String,+        /// Channel to search first (every channel is searched after it), and+        /// the channel --inject-missing injects into+        #[arg(short, long, default_value = "read-captures")]+        channel: String,+        /// Which AI thread this came from (set only if the shot has none)+        #[arg(long)]+        thread: Option<String>,+        /// The prompt being served (set only if the shot has none)+        #[arg(long)]+        context: Option<String>,+        /// No shot made from FILE yet: inject it (as never-resized, --native)+        #[arg(long)]+        inject_missing: bool,+        /// Source identifier used by --inject-missing+        #[arg(short, long, default_value = "send-hook")]+        source: String,+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+        /// Image file the shot was made from+        file: String,+    },     /// List the shots in a channel: ref, handle, age, description. `channels`     /// answers "what channels exist"; this answers "what is IN one", so an     /// agent can read back its own history and quote refs ("compare #41 and@@ -552,6 +604,9 @@ async fn main() {         Commands::Channels { port, since_min, limit } => {             run_channels(port, since_min, limit);         }+        Commands::Annotate { desc, channel, thread, context, inject_missing, source, port, file } => {+            run_annotate(&desc, &channel, thread.as_deref(), context.as_deref(), inject_missing, &source, port, &file);+        }         Commands::Shots { channel, port, limit, tagged, json } => {             run_shots(&channel, port, limit, tagged, json);         }@@ -809,6 +864,52 @@ fn run_inject(     } } +#[allow(clippy::too_many_arguments)]+fn run_annotate(desc: &str, channel: &str, thread: Option<&str>, context: Option<&str>,+                inject_missing: bool, source: &str, port: u16, file: &str) {+    let desc = desc.trim();+    if desc.is_empty() {+        err("--desc is empty.");+        std::process::exit(1);+    }+    let bytes = std::fs::read(file).unwrap_or_else(|e| {+        err(&format!("Failed to read {file}: {e}"));+        std::process::exit(1);+    });+    let body = serde_json::json!({+        "fingerprint": image::fingerprint(&bytes),+        "description": desc,+        "channel": channel,+        "thread": thread,+        "context": context,+    });+    match http_post(port, "/api/annotate", &body) {+        Ok(v) if v.get("found").and_then(|f| f.as_bool()) == Some(true) => {+            ok(&format!("Annotated #{} in '{}': {}",+                v["id"], v["channel"].as_str().unwrap_or("?"), v["description"].as_str().unwrap_or("")));+        }+        Ok(v) if v.get("found").and_then(|f| f.as_bool()) == Some(false) => {+            if inject_missing {+                let (w, h) = image::read_png_dimensions(&bytes);+                let (ow, oh) = if w == 0 { (None, None) } else { (Some(w), Some(h)) };+                run_inject(channel, desc, source, port, file, false, ow, oh, None, None, thread, context, None, None);+            } else {+                warn(&format!("No shot was made from {file}; nothing annotated (--inject-missing adds it)."));+                std::process::exit(3);+            }+        }+        Ok(v) => {+            err(&format!("annotate failed: {v}"));+            hint("A broker older than 0.29.0 has no /api/annotate: restart it on the new binary.");+            std::process::exit(1);+        }+        Err(e) => {+            err(&format!("Failed to reach shotlog on port {port}: {e}"));+            std::process::exit(1);+        }+    }+}+ fn run_resize(file: &str, output: Option<&str>, max_width: u32) {     let bytes = std::fs::read(file).unwrap_or_else(|e| {         err(&format!("Failed to read {file}: {e}"));
src/server.rs+36−2
@@ -196,6 +196,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {         .route("/api/clear-all", delete(clear_all))         .route("/api/clear-everything", delete(clear_everything))         .route("/api/delete", delete(delete_entry))+        .route("/api/annotate", post(post_annotate))         .route("/ws/{channel}", get(ws_handler))         .route("/shots/{channel}/{filename}", get(serve_image))         .route("/health", get(health))@@ -1177,6 +1178,35 @@ struct InjectResponse {     hints: Vec<String>, } +#[derive(Deserialize)]+struct AnnotateRequest {+    /// image::fingerprint of the file (the CLI computes it from FILE).+    fingerprint: String,+    /// The new description (e.g. the caption an AI wrote when it sent the image).+    description: String,+    /// Channel to search first; every channel is searched after it.+    channel: Option<String>,+    thread: Option<String>,+    context: Option<String>,+}++/// Re-describe an existing shot by the fingerprint of the file it was made+/// from. {ok:false, found:false} when no shot has it (the caller may inject).+async fn post_annotate(+    State(state): State<Arc<AppState>>,+    Json(req): Json<AnnotateRequest>,+) -> Json<serde_json::Value> {+    let desc = req.description.trim();+    if desc.is_empty() {+        return Json(serde_json::json!({"ok": false, "error": "empty description"}));+    }+    match state.store.annotate(&req.fingerprint, req.channel.as_deref(), desc, req.thread, req.context).await {+        Some((channel, e)) => Json(serde_json::json!({"ok": true, "found": true, "channel": channel,+            "id": e.id, "description": e.description, "auto_description": e.auto_description})),+        None => Json(serde_json::json!({"ok": false, "found": false})),+    }+}+ async fn post_shots(     State(state): State<Arc<AppState>>,     Json(req): Json<InjectRequest>,@@ -1185,6 +1215,9 @@ async fn post_shots(         parse_data_url(&req.image).map_err(|e| (StatusCode::BAD_REQUEST, e))?;      let (incoming_w, incoming_h) = read_png_dimensions(&raw_bytes);+    // Fingerprint the file AS THE CALLER HAD IT (before our resize), so a later+    // `annotate` that only has that file can find this shot.+    let fingerprint = crate::image::fingerprint(&raw_bytes);      // Auto-resize anything over the limit, recording the TRUE original. This is     // how orig dims get captured at the source: shotlog owns the resize, so it@@ -1234,7 +1267,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(), req.orig_path.clone(), req.tag.clone().map(|t| t.trim().chars().take(40).collect::<String>()).filter(|t| !t.is_empty()))+        .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(), req.tag.clone().map(|t| t.trim().chars().take(40).collect::<String>()).filter(|t| !t.is_empty()), Some(fingerprint))         .await;      // Presence at inject time — the caller uses this to know if the human is@@ -1371,6 +1404,7 @@ async fn post_paste(      let (orig_w, orig_h) = read_png_dimensions(&raw_bytes);     let orig_bytes = raw_bytes.len() as u64;+    let fingerprint = crate::image::fingerprint(&raw_bytes);      let (bytes, w, h) =         resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;@@ -1401,7 +1435,7 @@ async fn post_paste(             &req.channel, bytes, filename, description, source, w, h,             if was_resized { Some(orig_w) } else { None },             if was_resized { Some(orig_h) } else { None },-            None, None, req.thread.clone(), req.context.clone(), None, None,+            None, None, req.thread.clone(), req.context.clone(), None, None, Some(fingerprint),         )         .await; 
src/store.rs+72−3
@@ -53,6 +53,15 @@ pub struct Entry {     /// injector provided the original (safe/full pairs). Viewable via the zoom.     #[serde(skip_serializing_if = "Option::is_none")]     pub full: Option<String>,+    /// Content fingerprint of the injected file (image::fingerprint, taken+    /// before any resize). How `annotate` finds the shot made from a file.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub fingerprint: Option<String>,+    /// The automatic description the shot was captured with, kept when an+    /// annotation (e.g. the caption an AI wrote when it SENT the image to the+    /// human) replaced `description`.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub auto_description: Option<String>, }  struct Channel {@@ -329,10 +338,14 @@ impl Store {                 };                  let png_meta = read_png_meta(&bytes_raw);-                let tag = fs::read_to_string(&meta_path).ok()-                    .and_then(|j| serde_json::from_str::<serde_json::Value>(&j).ok())-                    .and_then(|v| v.get("tag").and_then(|t| t.as_str()).map(String::from))+                let side = fs::read_to_string(&meta_path).ok()+                    .and_then(|j| serde_json::from_str::<serde_json::Value>(&j).ok());+                let side_str = |k: &str| side.as_ref()+                    .and_then(|v| v.get(k).and_then(|t| t.as_str()).map(String::from))                     .filter(|t| !t.is_empty());+                let tag = side_str("tag");+                let fingerprint = side_str("fingerprint");+                let auto_description = side_str("auto_description");                 entries.push(Entry {                     id: next_id,                     timestamp,@@ -353,6 +366,8 @@ impl Store {                     context,                     tag,                     full,+                    fingerprint,+                    auto_description,                 });                 next_id += 1;             }@@ -397,6 +412,7 @@ impl Store {         context: Option<String>,         orig_path: Option<String>,         tag: Option<String>,+        fingerprint: Option<String>,     ) -> Entry {         let channel_dir = self.base_dir.join(channel_name);         fs::create_dir_all(&channel_dir).ok();@@ -439,6 +455,7 @@ impl Store {         // The handle must SURVIVE a broker restart (recovery rebuilds entries         // from these sidecars; 0.28.0-0.28.2 dropped every --tag on restart).         if let Some(t) = &tag { meta_json["tag"] = serde_json::json!(t); }+        if let Some(f) = &fingerprint { meta_json["fingerprint"] = serde_json::json!(f); }         fs::write(&meta_path, meta_json.to_string()).ok();          let mut channels = self.channels.write().await;@@ -473,6 +490,8 @@ impl Store {             context,             tag,             full,+            fingerprint,+            auto_description: None,         };          ch.next_id += 1;@@ -494,6 +513,56 @@ impl Store {         entry     } +    /// Replace the description of the NEWEST shot made from the file with this+    /// fingerprint (searching `channel` first, then every channel), keeping the+    /// automatic one as `auto_description`. Fills thread/context only where the+    /// shot has none. Persists to the sidecar (survives a broker restart) and+    /// re-broadcasts the entry, so open viewers update that card in place.+    /// None when no shot carries the fingerprint.+    pub async fn annotate(+        &self,+        fingerprint: &str,+        channel: Option<&str>,+        description: &str,+        thread: Option<String>,+        context: Option<String>,+    ) -> Option<(String, Entry)> {+        let mut channels = self.channels.write().await;+        let mut order: Vec<String> = channels.keys().cloned().collect();+        order.sort();+        if let Some(c) = channel {+            order.retain(|n| n != c);+            order.insert(0, c.to_string());+        }+        for name in order {+            let Some(ch) = channels.get_mut(&name) else { continue };+            let Some(e) = ch.entries.iter_mut().rev()+                .find(|e| e.fingerprint.as_deref() == Some(fingerprint)) else { continue };+            if e.description != description {+                if e.auto_description.is_none() {+                    e.auto_description = Some(e.description.clone());+                }+                e.description = description.to_string();+            }+            if e.thread.is_none() { e.thread = thread.clone(); }+            if e.context.is_none() { e.context = context.clone(); }+            let meta_path = Path::new(&e.path).with_extension("json");+            let mut side = fs::read_to_string(&meta_path).ok()+                .and_then(|j| serde_json::from_str::<serde_json::Value>(&j).ok())+                .unwrap_or_else(|| serde_json::json!({}));+            side["description"] = serde_json::json!(e.description);+            if let Some(a) = &e.auto_description { side["auto_description"] = serde_json::json!(a); }+            if let Some(t) = &e.thread { side["thread"] = serde_json::json!(t); }+            if let Some(c) = &e.context { side["context"] = serde_json::json!(c); }+            fs::write(&meta_path, side.to_string()).ok();+            let entry = e.clone();+            let _ = ch.tx.send(entry.clone());+            let _ = self.all_tx.send((name.clone(), entry.clone()));+            return Some((name, entry));+        }+        None+    }+     /// Get all entries for a channel.     pub async fn get_entries(&self, channel_name: &str) -> Vec<Entry> {         let channels = self.channels.read().await;