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.29.0
15 files changed
+362−28
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;