16 KiB
Viewer Screenshot References
Status: inbox
Created: 2026-05-21
Goal: Surface screenshot images (PNG/JPG/GIF/WebP) referenced by tool calls as inline thumbnails in the observation feed at the worker viewer (http://127.0.0.1:<worker-port>), with a click-to-zoom lightbox. Worker serves the image bytes from disk through a path-safe endpoint.
Problem
When a session uses screenshot tools (gstack/browse screenshot, MCP screenshot tools) or reads/writes image files, those paths flow through pending_messages.tool_input but are dropped before reaching the observations table. The viewer feed only sees text summaries — never the actual images that were captured. We want screenshots inline in the feed.
Constraints
- Local-only: worker binds to
127.0.0.1, so an image endpoint that reads arbitrary local paths is reachable only by the user — but path-traversal must still be guarded (defense in depth, and to avoid leaking unrelated files via a malicious request from a browser tab). - Open-source core: no Pro-only gating. Endpoint and viewer component ship in the core.
- No new dependencies: reuse Express, esbuild, existing modal/CSS patterns.
- Backwards-compatible storage: new column on
observationsmust be nullable, no migration of historical rows required.
Phase 0: Documentation Discovery (consolidated)
Storage layer
- Schema:
src/services/sqlite/schema.sql:57-92—observationstable. Columns includefiles_read,files_modified(bothTEXT, JSON-stringified arrays),metadata(TEXT, JSON),facts,narrative,concepts.UNIQUE(memory_session_id, content_hash)withON CONFLICT DO NOTHING. - Queue:
src/services/sqlite/schema.sql:126-152—pending_messagestable holds rawtool_input/tool_responseJSON during processing. Discarded after the AI summarization step. - Insert path:
src/services/sqlite/observations/store.ts:19-80—storeObservation()writes 17 columns fromObservationInput. Raw tool I/O is not persisted here today. - Read row type:
ObservationRowinsrc/services/sqlite/types.ts— what every observation API returns.
Worker HTTP
- App setup:
src/services/server/Server.ts:98-105—setupCors(), body-parser, route registration. CORS already on. - Observations endpoint:
GET /api/observationsregistered inDataRoutes.tsviahandleGetObservations→paginationHelper.getObservations(...). - Search:
GET /api/search/observationsinsrc/services/worker/http/routes/SearchRoutes.ts:109. - Static assets:
src/services/worker/http/routes/ViewerRoutes.ts:49—app.use(express.static(path.join(packageRoot, 'ui'))). Serves built viewer assets only; no tool-artifact endpoint. - Port resolution:
src/shared/worker-utils.ts:64-73—getWorkerPort()readsCLAUDE_MEM_WORKER_PORTfrom settings.json; default37700 + (uid % 100). User's local port happens to be37777.
Viewer (React, esbuild)
- Entry:
src/ui/viewer/index.tsx→App.tsx. - Feed component:
src/ui/viewer/components/Feed.tsx— mergesobservations,summaries,prompts, sorts bycreated_at_epoch, renders<ObservationCard>/<SummaryCard>/<PromptCard>. - API constants:
src/ui/viewer/constants/api.ts— central list of endpoint paths. - Realtime updates: SSE
/stream(plus paginated GET viausePagination). - Build:
scripts/build-viewer.js(esbuild) bundles toplugin/ui/viewer-bundle.js;viewer-template.html→plugin/ui/viewer.html. Invoked fromscripts/build-hooks.js. - Reusable modal pattern:
ContextSettingsModal.tsx(backdrop + centered panel + close). No existing lightbox lib — build a small overlay component.
Allowed APIs (verified)
express.static(dir)— already imported.res.sendFile(absPath, { headers, dotfiles: 'deny' })— Express built-in, streams the file.fs.promises.stat,fs.promises.open(for magic-byte sniff),path.resolve,path.extname.- React
useState/useEffect/ portals (already used elsewhere).
Anti-patterns (do NOT do)
- Do not read raw
tool_inputJSON from the livepending_messagesqueue inside an HTTP handler. That table is a transient processing buffer. - Do not decode base64 image blobs into the database. Reference by absolute filesystem path only.
- Do not wire CORS to allow
*for the image endpoint — keep it scoped like the rest of the worker (same-origin for the viewer). - Do not invent a
tool_usetable read path — observations are the surface area for the feed.
Phase 1: Detect & persist image references on observation write
Goal: When an observation is created, record any image file paths that were touched by the underlying tool calls so the viewer can render them.
What to implement:
-
New nullable column on
observations:- Column:
image_refs TEXT(JSON-stringified array of absolute paths). Mirrors the pattern offiles_read/files_modifiedalready present atsrc/services/sqlite/schema.sql:57-92. - Add a migration entry alongside existing schema migrations (find the migration runner near
src/services/sqlite/schema.sql; copy the additive pattern used for any prior column add —ALTER TABLE observations ADD COLUMN image_refs TEXT). - Anti-pattern guard: do not drop/recreate the table; additive ALTER only.
- Column:
-
Extend
ObservationInputinsrc/services/sqlite/observations/store.tswithimage_refs?: string[]and include it in the INSERT statement (lines 35-41). Stringify on the way in. -
Add
image_refs: string[] | nulltoObservationRowinsrc/services/sqlite/types.tsso it flows out of every read API. -
Populate
image_refsat observation generation time. Two sources, applied in order:- Source A (primary): while the AI summarization step is still holding raw
pending_messages.tool_input/tool_response, scan for absolute file paths whose extension is inIMAGE_EXTENSIONS = ['.png','.jpg','.jpeg','.gif','.webp']. Find the file that buildsObservationInputfrom pending messages (search for the call site ofstoreObservation— likelysrc/services/observations/orsrc/services/queue/) and inject the extraction there. - Source B (fallback): post-filter the already-extracted
files_read∪files_modifiedfor image extensions. This catches images that survived only in the summary. - Dedupe + sort the final list before persisting.
- Source A (primary): while the AI summarization step is still holding raw
-
Add a tiny pure helper
extractImagePaths(toolInput: unknown, toolResponse: unknown): string[]insrc/utils/image-refs.ts. Unit-testable, no I/O, no DB access. Handles:Readtool:tool_input.file_path.Write/Edit:tool_input.file_path.- Generic
image_path,screenshot_path,output_pathkeys. - Arrays / nested objects (recurse one level).
- String tool_response containing
file://...pngor absolute paths. - Returns only absolute paths (
path.isAbsolute) with image extensions.
Documentation references:
- Copy the column-add pattern from any prior
ALTER TABLE observations ADD COLUMN ...insrc/services/sqlite/schema.sql. - Mirror the
files_read/files_modifiedlifecycle: written by the same function that buildsObservationInput, parsed by the viewer asJSON.parse(row.files_read ?? '[]').
Verification checklist:
sqlite3 ~/.claude-mem/claude-mem.db ".schema observations"showsimage_refs TEXT.- Unit test for
extractImagePathscovering each tool shape (Read, Write, screenshot, nested arrays, non-image extensions excluded, relative paths excluded). - Trigger a session that calls a screenshot tool; confirm
image_refsis populated on the resulting row viasqlite3query. - Existing rows still load —
image_refsreturns asnulland is tolerated by readers.
Phase 2: Safe image-serving endpoint on the worker
Goal: Add GET /api/images?path=<absolute-encoded-path> that streams an image file from disk if-and-only-if it passes path-safety checks.
What to implement:
-
New route module:
src/services/worker/http/routes/ImageRoutes.ts. Copy the handler-wrap pattern fromDataRoutes.ts(this.wrapHandler((req, res) => { ... })). -
Register the route in the same place existing
DataRoutes/SearchRoutesare registered (find thesetupCoreRoutes/setupRoutescall chain offServer.ts:98-105). Register beforeexpress.staticinViewerRoutes.ts:49so it takes precedence on/api/*. -
Handler contract:
- Input:
req.query.path(URL-encoded absolute path). - Reject (
400) if missing, not a string, orpath.isAbsolute(decoded) === false. path.resolve(decoded)— if the resolved string differs from the decoded input, reject (catches..traversal).- Reject (
415) ifpath.extname(resolved).toLowerCase()not in['.png','.jpg','.jpeg','.gif','.webp']. - Allowlist root: must live under one of
process.env.HOME, theCLAUDE_MEM_DATA_DIR, the OS temp dir, or any absolute path currently present in any observation'simage_refscolumn. The DB-membership check is the strongest guard — only paths the system has already chosen to surface can be fetched.- Implementation:
SELECT 1 FROM observations WHERE image_refs LIKE '%' || ? || '%' LIMIT 1against the resolved path (parameterized; theLIKEis safe because the path is already absolute and we furtherJSON.parseand.includes()to confirm exact match).
- Implementation:
- Magic-byte sniff: open the file, read first 12 bytes, confirm PNG / JPEG / GIF / WebP signature. Reject (
415) on mismatch. - Set
Content-Typefrom extension. SetCache-Control: private, max-age=60. Stream withres.sendFile(resolved, { dotfiles: 'deny' }). - On any error:
404with no body (don't leak existence).
- Input:
-
Add
IMAGES: '/api/images'tosrc/ui/viewer/constants/api.ts.
Documentation references:
DataRoutes.tsfor thewrapHandler+req.queryparsing convention.ViewerRoutes.ts:49for static-mount ordering reference.
Verification checklist:
curl 'http://127.0.0.1:<port>/api/images?path=<encoded>'returns 200 + correctContent-Typefor a known image referenced in an observation.curl '.../api/images?path=../../etc/passwd'→ 400.curl '.../api/images?path=/etc/passwd'→ 404 (absolute, but not in DB and wrong magic bytes).- Renaming an extension
.png→.txtafter the path lands in DB still rejects on magic bytes. - Unit test the path-safety predicate in isolation.
Anti-pattern guards:
- Do not accept
pathas a request body — query string only, keeps it GET-cacheable. - Do not bypass the DB-membership check, even for "obviously safe" paths.
- Do not
fs.readFilethe whole image into memory —res.sendFilestreams.
Phase 3: Viewer types + data plumbing
Goal: Get image_refs from the API into the React Observation shape and parsed into string[].
What to implement:
- Find the viewer-side
ObservationTypeScript type (likelysrc/ui/viewer/types.ts— confirm via grepinterface Observation). Addimage_refs: string[](parsed) plus update the rawObservationRowFromApi(or equivalent) withimage_refs: string | nullfor the JSON-string form. - Find the place where API rows are normalized for the feed (search for
JSON.parse(row.files_reador similar). Add a siblingimage_refs: row.image_refs ? JSON.parse(row.image_refs) : []line. - SSE consumer: confirm the
/streampayload reflects the new column (same row shape — no separate change needed if it reuses the same serializer).
Verification checklist:
- React DevTools / a temporary
console.logshowsimage_refs: [...]on observation objects that have images. tsc --noEmitpasses (no type drift).
Phase 4: Render thumbnails + lightbox in ObservationCard
Goal: Show a horizontal strip of small thumbnails inside each observation card; clicking opens a lightbox overlay.
What to implement:
-
New component
src/ui/viewer/components/ImageStrip.tsx:- Props:
paths: string[],onOpen: (path: string) => void. - Renders nothing if
paths.length === 0. - Each thumbnail:
<img src={/api/images?path=${encodeURIComponent(p)}} loading="lazy" />, sized ~80px height, rounded corners,cursor: pointer. - On
<img onError>, swap to a placeholder div (title="missing image") — handles deleted files gracefully.
- Props:
-
New component
src/ui/viewer/components/Lightbox.tsx:- Copy the backdrop/portal pattern from
ContextSettingsModal.tsx. - Props:
path: string | null,onClose: () => void. - Renders nothing when
path === null. - Centered
<img>withmax-width: 90vw; max-height: 90vh. - Close on backdrop click, on
Escapekey, and on close button. - Do not add zoom/pan in this phase — keep it simple. Future enhancement.
- Copy the backdrop/portal pattern from
-
Wire into
ObservationCard.tsx:- Add
const [lightboxPath, setLightboxPath] = useState<string | null>(null);. - Render
<ImageStrip paths={observation.image_refs} onOpen={setLightboxPath} />after the existing card body content. - Render
<Lightbox path={lightboxPath} onClose={() => setLightboxPath(null)} />.
- Add
-
Add minimal CSS in the viewer's existing stylesheet (find it via grep
.observation-card):.image-strip { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; } .image-strip img { height: 80px; width: auto; border-radius: 6px; object-fit: cover; cursor: zoom-in; } .lightbox-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: flex; align-items: center; justify-content: center; z-index: 1000; } .lightbox-backdrop img { max-width: 90vw; max-height: 90vh; } -
Rebuild:
npm run build-and-syncregeneratesplugin/ui/viewer-bundle.jsandplugin/ui/viewer.html.
Documentation references:
src/ui/viewer/components/ContextSettingsModal.tsxfor backdrop + close pattern.src/ui/viewer/components/Feed.tsxfor howObservationCardis invoked.
Verification checklist:
- Open
http://127.0.0.1:<port>/. Observations withimage_refsshow a thumbnail strip. - Clicking a thumbnail opens the lightbox; Escape and backdrop click both close it.
- Observations without images render unchanged (no empty container, no layout shift).
- Deleted-on-disk image shows the placeholder, doesn't crash.
Anti-pattern guards:
- Do not fetch image bytes via JS and convert to blob URLs — let the
<img src>do it. - Do not block the feed render on image load —
loading="lazy". - Do not store image data in component state.
Phase 5: End-to-end verification
- Fresh session with screenshot tool
- Start a clean session, trigger a tool that produces a PNG (e.g.,
gstackscreenshot). - Wait for observation to be generated.
- Hit
GET /api/observations?limit=1— confirmimage_refsis a JSON array containing the screenshot's absolute path.
- Start a clean session, trigger a tool that produces a PNG (e.g.,
- Endpoint security
- Run the four
curlcases listed in Phase 2.
- Run the four
- Viewer
- Confirm thumbnails appear, lightbox works, missing files fall back to placeholder.
- Regression sweep
- Run any existing viewer tests (search for
viewerin test directories — Vitest or Playwright). - Confirm older observations (no
image_refs) still render and pass type checks.
- Run any existing viewer tests (search for
- Build & sync
npm run build-and-syncsucceeds without warnings.- Worker restarts cleanly with the new route registered (check startup log for
/api/images).
Out of scope (future inbox items)
- Video / GIF playback controls beyond
<img>autoplay-on-GIF. - Zoom-and-pan in the lightbox.
- Side-by-side image diffs.
- Thumbnail caching layer (the worker re-streams each request; cheap enough at single-user scale).
- Pro Memory Stream UI integration — that UI hits the same
/api/imagesendpoint, no extra core work. - Backfilling
image_refson historical observations.