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.28.3
13 files changed
+119−11
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.28.2"+version = "0.28.3" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.28.2"+version = "0.28.3" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+2−1@@ -59,7 +59,7 @@ Every card shows its **ref** (`#42`) in a slim strip above the thumbnail (never adom-shotlog inject -c hero -d "hero variant G, tighter crop" --tag heroG variant-g.png ``` -The handle shows beside the ref (`#42 heroG`), and the inject reply hands the AI its ref back (`Ref: #42, handle "heroG"`), so "heroG is the one to use" is unambiguous on both sides.+The handle shows beside the ref (`#42 heroG`), and the inject reply hands the AI its ref back (`Ref: #42, handle "heroG"`), so "heroG is the one to use" is unambiguous on both sides. Auto-captures get a handle too when the file has a wordy name (`board_v3-front.png` becomes `board_v3-front`); timestamp-named captures keep just their ref, so the gallery is not littered with date chips. An AI reads a channel back with `adom-shotlog shots -c <channel>`; handles survive broker restarts. ### Phone @@ -131,6 +131,7 @@ The viewer has a **Settings** dialog: the outline cog at the right end of the he |---|---| | `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds child windows; `--tag heroG` names the shot; reports its ref and whether a human is watching) | | `adom-shotlog open` | Open the gallery as a Hydrogen webview tab (`--skinny` for the slim right panel; exit 2 = no pane found, exit 1 = adom-cli or bridge failure, with the reason printed) |+| `adom-shotlog shots` | Read a channel back: ref, handle, age and description of its newest shots (`--tagged` for handles only, `--json` for every field, `--limit N`) | | `adom-shotlog viewers` | Is a human watching a channel? Exit 0 / 4 / 3, plus connected surfaces | | `adom-shotlog channels` | Overview of every channel: shots, last activity, and who is watching | | `adom-shotlog serve` | Start the HTTP server / broker (port 8820) |
SKILL.md+2@@ -309,6 +309,8 @@ adom-shotlog inject -c hero -d "hero variant G, tighter crop" --tag heroG varian Then "heroG is the one" or "#42 is the one" is unambiguous. Refer to shots by their ref in your own replies too ("compare #41 and #42"). +Read a channel BACK before you talk about it: `adom-shotlog shots -c <channel>` lists ref, handle, age and description (newest last); `--tagged` shows only handled shots, `--json` gives every field. You cannot quote a ref you have not looked up. Read-hook captures get a handle automatically when the source file has a wordy name; timestamp-named files keep just their ref.+ ## Layout rules the viewer follows (so you do not fight them) - ONE click on a thumbnail fills the window; the next click, Esc, or the margin comes back. There is no modal step any more.
VERSION+1−1@@ -1 +1 @@-0.28.2+0.28.3
bin/adom-shotlog⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+8@@ -461,3 +461,11 @@ Content is bounded anyway (context truncates at capture), so no cap needed. - Header: `.subject` is `overflow:hidden` and the two-row layout kicks in below 760px (a ~570px ah pane overlapped before); below 300px (a 10% own column is ~170 CSS px) `.actions` wraps. - Images: `lazyImg` (IntersectionObservers ROOTED AT `.feed`, the real scroll container; a viewport-rooted observer is clipped by it and loads one image) loads in-view shots at high priority, within one screen at low, unloads after 20s beyond three screens, never the top 4. `img.style.aspectRatio` is reserved from `e.w/e.h` so unloading never shifts scroll. `window._lazyImg.stats()` for tests. - Ralph-test all of the above in pup via `pup_eval` (see the 2026-09-02 transcript for the exact expression): last toolbar child, backdrop color, four dismissals, card/row/hint counts, restore arm/disarm, image naturalWidth.++## Refs, handles and read-back (2026-09-02, Ray's wiki issues #3/#5 and PRs #1-#4)++- `tag` MUST be written to the shot's JSON sidecar and read back in recovery (store.rs). 0.28.0-0.28.2 kept it only in memory, so every handle vanished on the next broker restart. Test on a throwaway broker: inject `--tag`, kill, serve again, `shots --tagged` still shows it.+- `adom-shotlog shots -c <ch> [--tagged] [--json] [--limit N]` reads `/api/shots` back (Ray's PR #2, adapted: the footer says every shot is addressable by its ref; the `--tag` hint is advice, printed only when a channel of 3+ shots has no handle at all).+- The read-hook derives a handle from the source filename ONLY when the stem is wordy: at least 3 letters AND more letters than digits (Ray's PR #3, adapted). Timestamp names (`2026-09-02T12-59-09.647.png`, `IMG_1234.png`, `screenshot-2026-09-02-at-12.59.png`) get no handle; the ref (#id) already names them, and a date chip on every card is noise.+- Tooltips are suppressed while ANY deliberately opened surface is up: the hovered card's `.card-info.show` AND any `.hover-popover.show` / `.card-info.show` on the page (Ray's PR #4).+- Wiki PRs against this page are whole-file diffs from an older base; apply them locally (adapted where needed), release, then close the PR with a comment crediting the author. Never `pr merge` a whole-file diff over newer files.
hooks/shotlog-on-read.sh+14−1@@ -38,6 +38,19 @@ shotlog health >/dev/null 2>&1 || { (cd "${SHOTLOG_HOME:-$HOME/project}" && shot base="$(basename "$f")" +# HANDLE (Ray, wiki PR #3, adapted): derive a short handle from the source+# filename so an auto-captured shot can be pointed at by name ("heroG is the+# one"), but ONLY when the stem is wordy. Bridge captures are named by+# timestamp; a "2026-09-02T12-59" handle would be pure noise on every card, and+# those shots already have their ref (#id). A deliberate `inject --tag` wins.+stem="${base%.*}"+letters="$(printf '%s' "$stem" | tr -cd 'A-Za-z' | wc -c | tr -d ' ')"+digits="$(printf '%s' "$stem" | tr -cd '0-9' | wc -c | tr -d ' ')"+TAG=""+if [ "$letters" -ge 3 ] && [ "$letters" -gt "$digits" ]; then+ TAG="$(printf '%s' "$stem" | tr -c 'A-Za-z0-9._-' '-' | sed 's/^[-._]*//; s/[-._]*$//' | cut -c1-16 | sed 's/[-._]*$//')"+fi+ # 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.@@ -95,7 +108,7 @@ esac for attempt in 1 2 3; do if shotlog inject -c read-captures \ -d "Auto-captured screenshot Claude read for analysis: ${base}" \- -s "$SRC" ${THREAD:+--thread "$THREAD"} ${CONTEXT:+--context "$CONTEXT"} $META_ARGS $ORIG_ARGS "$f" >/dev/null 2>&1; then+ -s "$SRC" ${TAG:+--tag "$TAG"} ${THREAD:+--thread "$THREAD"} ${CONTEXT:+--context "$CONTEXT"} $META_ARGS $ORIG_ARGS "$f" >/dev/null 2>&1; then break fi sleep 2
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.28.2",+ "version": "0.28.3", "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.2",+ "version": "0.28.3", "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+8−3@@ -287,9 +287,11 @@ body.compact .card-popups .ph { padding-top: 4px; } .shot-card-img { display: block; width: 100%; height: 220px; flex-shrink: 0; background: #111; object-fit: contain; object-position: center top; transition: opacity .15s; } .shot-card:active .shot-card-img { opacity: .85; }-/* Direct-to-fullscreen shortcut: a ⛶ button on the thumbnail (hover-revealed)- that skips the modal and fills the window in ONE click. The image itself- still opens the modal on click — this is purely additional. */+/* Fullscreen affordance: a ⛶ button on the thumbnail (hover-revealed).+ The thumbnail IMAGE also fills the window in one click (see the click+ handler on img, "ONE click on a thumbnail goes STRAIGHT to fill-window"),+ so this button is the discoverable, keyboard-focusable route to the same+ thing rather than a separate shortcut past the modal. (Ray, PR #1) */ .card-img-wrap { position: relative; } .card-fill-btn { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center;@@ -925,6 +927,9 @@ body.compact .card-popups .ph { padding-top: 4px; } // A card whose info overlay is REVEALED (compact mode ⓘ) is being read: // no tooltip may float over it (Ray: "the tooltip obscures the popover"). if (el.closest && el.closest('.shot-card')?.querySelector('.card-info.show')) return;+ // ...and nothing incidental may cover ANY surface the human deliberately+ // opened: another card's overlay, or the brand hover card (Ray, PR #4).+ if (document.querySelector('.hover-popover.show, .card-info.show')) return; // Rich (authored, trusted) HTML via el._ttHtml; else plain text. Dynamic // values interpolated into _ttHtml are escaped by the builders below. if (el._ttHtml) { tip.innerHTML = el._ttHtml; }
src/main.rs+72@@ -281,6 +281,27 @@ enum Commands { #[arg(long, default_value_t = 25)] limit: usize, },+ /// 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+ /// #42"). (Ray, wiki PR #2)+ Shots {+ /// Channel to list+ #[arg(short, long)]+ channel: String,+ /// Port of the running shotlog server+ #[arg(short, long, default_value_t = 8820)]+ port: u16,+ /// Max rows to show, newest last (default 25)+ #[arg(long, default_value_t = 25)]+ limit: usize,+ /// Only shots that carry a --tag handle+ #[arg(long)]+ tagged: bool,+ /// Raw JSON (the newest `limit` entries, every field) instead of a table+ #[arg(long)]+ json: bool,+ }, /// Install everything: skill, bash completions Install, /// Generate shell completions (bash, zsh, fish)@@ -531,6 +552,9 @@ async fn main() { Commands::Channels { port, since_min, limit } => { run_channels(port, since_min, limit); }+ Commands::Shots { channel, port, limit, tagged, json } => {+ run_shots(&channel, port, limit, tagged, json);+ } Commands::Install => { run_install(); }@@ -944,6 +968,54 @@ fn run_channels(port: u16, since_min: Option<i64>, limit: usize) { } } +/// `shots`: read a channel back (Ray, wiki PR #2, adapted).+fn run_shots(channel: &str, port: u16, limit: usize, tagged_only: bool, json: bool) {+ let endpoint = format!("/api/shots?channel={}", channel.replace(' ', "%20"));+ let val = match http_get(port, &endpoint) {+ Ok(v) => v,+ Err(e) => { err(&format!("Cannot connect to shotlog on port {port}: {e}")); hint("Start it: adom-shotlog serve &"); std::process::exit(1); }+ };+ let rows = val.as_array().cloned().unwrap_or_default();+ let has_tag = |r: &serde_json::Value| r["tag"].as_str().is_some_and(|t| !t.is_empty());+ let picked: Vec<&serde_json::Value> = rows.iter().filter(|r| !tagged_only || has_tag(r)).rev().take(limit).collect();+ if json {+ let v: Vec<&serde_json::Value> = picked.into_iter().rev().collect();+ println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into()));+ return;+ }+ if rows.is_empty() {+ println!("No shots in channel {channel}.");+ hint("Channels that DO have shots: adom-shotlog channels");+ return;+ }+ let now = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);+ let fmt_ago = |secs: i64| -> String {+ let m = secs / 60;+ if m < 1 { "just now".into() } else if m < 60 { format!("{m}m ago") }+ else if m < 1440 { format!("{}h ago", m / 60) } else { format!("{}d ago", m / 1440) }+ };+ let total = rows.len();+ let tagged_total = rows.iter().filter(|r| has_tag(r)).count();+ println!("{:>6} {:<16} {:>9} {}", "REF", "HANDLE", "AGE", "DESCRIPTION");+ for row in picked.into_iter().rev() {+ let id = row["id"].as_u64().unwrap_or(0);+ let tag = row["tag"].as_str().filter(|t| !t.is_empty()).unwrap_or("-");+ let epoch = row["timestamp"].as_str()+ .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())+ .map(|d| d.timestamp()).unwrap_or(0);+ let ago = if epoch == 0 { "-".to_string() } else { fmt_ago(now - epoch) };+ let desc = row["description"].as_str().unwrap_or("");+ let desc = if desc.chars().count() > 58 { format!("{}...", desc.chars().take(55).collect::<String>()) } else { desc.to_string() };+ let tag = if tag.chars().count() > 16 { format!("{}..", tag.chars().take(14).collect::<String>()) } else { tag.to_string() };+ println!("{:>6} {:<16} {ago:>9} {desc}", format!("#{id}"), tag);+ }+ println!("\n{total} shots, {tagged_total} with a handle. Every shot is addressable by its ref: tell the human \"#{}\" and they see exactly that card.", rows.last().and_then(|r| r["id"].as_u64()).unwrap_or(0));+ if tagged_total == 0 && total > 2 {+ hint("Iterating on variants? A handle reads better than a number: `adom-shotlog inject --tag heroG ...` so the human can say \"heroG is the one\". Read-hook captures get a handle from a wordy filename automatically; timestamp-named files keep just their ref.");+ }+}+ fn run_install() { ensure_read_hook(); let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());
src/store.rs+8−1@@ -320,6 +320,10 @@ 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))+ .filter(|t| !t.is_empty()); entries.push(Entry { id: next_id, timestamp,@@ -338,7 +342,7 @@ impl Store { popups, thread, context,- tag: None,+ tag, full, }); next_id += 1;@@ -423,6 +427,9 @@ impl Store { 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); }+ // 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); } fs::write(&meta_path, meta_json.to_string()).ok(); let mut channels = self.channels.write().await;