README
¶
photoscrawl
Local-first Apple Photos crawler for the OpenClaw crawl-family ecosystem.
photoscrawl builds a photos.sqlite archive from a user's Photos library. The
goal is not photo backup. The goal is to help users understand their own library:
where photos were taken, when they were taken, what is visible, which
documents/screenshots/receipts exist, which assets belong together, and what
evidence supports each result.
Principles
- Go product code only.
- Use
github.com/openclaw/crawlkitfor shared crawler mechanics. - Local-first by default; no cloud model calls unless the user explicitly selects assets or derivatives to send.
- Read-only Photos access. Never write back to Photos.
- Snapshot before crawling live library state.
- Metadata for all assets, local classification for high-signal coverage.
- Store observations and evidence, not final people/trip/place truth.
Installation
Current builds require macOS 13 Ventura or later on Apple silicon or Intel. photoscrawl uses
native Objective-C/CGO bridges to PhotoKit, CoreLocation, MapKit, CoreImage,
CoreGraphics, and ImageIO, so release archives are intentionally
Darwin-only. Download the archive for your architecture from GitHub Releases,
extract photoscrawl, and place it on your PATH.
photoscrawl --version
There is no Homebrew formula in the debut release.
Development
Building from source requires Go 1.27.0 or later and the macOS SDK. The preferred
build toolchain is Go 1.27.1, selected by the toolchain directive in go.mod.
The Makefile exposes the same core targets as the other OpenClaw crawler repositories:
make help
make build
make check
make snapshot
make snapshot builds local GoReleaser artifacts without credentials and never
publishes them.
On macOS, go test -v -run TestExportNativeIntegration ./cmd/photoscrawl builds
the CLI with a required synthetic PhotoKit library. It exercises native export
cancellation, late callbacks, and filesystem failures without accessing Photos.
Releases
Official releases run only through the manual Release (unified) GitHub Actions workflow, which signs and notarizes the Darwin artifacts before publishing them:
gh workflow run release-unified.yml --repo openclaw/photoscrawl -f version=X.Y.Z
make release refuses local publishing and prints that exact command.
First Commands
go run ./cmd/photoscrawl metadata --json
go run ./cmd/photoscrawl init --json
go run ./cmd/photoscrawl status --json
go run ./cmd/photoscrawl crawl --library "$HOME/Pictures/Photos Library.photoslibrary" --json
go run ./cmd/photoscrawl import-apple --library "$HOME/Pictures/Photos Library.photoslibrary" --json
go run ./cmd/photoscrawl crawl --provider sqlite --library "/path/to/scratch.photoslibrary" --json
go run ./cmd/photoscrawl classify --limit 100 --json
go run ./cmd/photoscrawl classify --local-model gemma4:e4b --limit 20 --json
go run ./cmd/photoscrawl classify --local-model photoscrawl-qwen3-vl-8b --local-model-api openai --local-model-url http://127.0.0.1:1234/v1 --limit 20 --json
go run ./cmd/photoscrawl search --query "drone beach portugal" --json
go run ./cmd/photoscrawl timeline --from 2026-05-27T00:00:00Z --to 2026-05-28T00:00:00Z --json
go run ./cmd/photoscrawl open --id asset:<id> --json
go run ./cmd/photoscrawl export --id asset:<id> --output /path/to/export --json
go run ./cmd/photoscrawl export --id asset:<id> --output /path/to/export --timeout 2m --json
go run ./cmd/photoscrawl neighbors --id asset:<id> --json
go run ./cmd/photoscrawl evidence --row-id asset:<id> --json
go run ./cmd/photoscrawl place-context --input <private-eval-run>/metadata/E001.json --json
go run ./cmd/photoscrawl place-card --input <crawlkit-cache-dir>/place-context/<key>.json
go run ./cmd/photoscrawl place-backfill --json
go run ./cmd/photoscrawl eval-card --library "$HOME/Pictures/Photos Library.photoslibrary" --allow-icloud-downloads --limit 1 --models gemma4:31b-cloud --ollama-url https://ollama.com/api --json
Default runtime paths come from crawlkit platform dirs. The primary database is
photos.sqlite under the crawlkit data dir; provider caches and exported
originals use the crawlkit cache dir.
Original exports wait until completion by default. export --timeout <duration>
opts into a time limit (0 keeps the unlimited default). Ctrl-C or an expired
deadline cancels the native PhotoKit resource request and removes its temporary
file. A completed export atomically replaces the destination; failures preserve
an existing file. PhotoKit reads the active system library; export cannot target
a separate Photos library.
crawl tries PhotoKit first for metadata. PhotoKit enumerates the active system
Photos library; the --library path is validated and recorded as the requested
source. If PhotoKit is unavailable or denied, the POC falls back to a read-only
database/Photos.sqlite transaction and labels that evidence as
photos_sqlite_snapshot.
crawl does not export originals or force iCloud downloads. It records already
local package media paths for derivatives/renders/originals when they exist, so
content classification can use local files without changing Photos or iCloud
state. Every imported asset is queued for classify.
import-apple makes verified, consistent copies of Apple Photos' live
databases in a private temporary directory, reads those copies, and removes
them when the import finishes. It
adds Apple's existing named-person records, search index, original camera and
EXIF details, edit state, and Photos quality scores to photos.sqlite:
captions, keywords, detected text, scene labels, activities, venues, dates,
places, people, camera/source clues, and photo types. Both the current
psi.sqlite search index and the newer leo.sqlite layout are supported. The
import is an authoritative refresh of only the Apple-derived observation rows;
it never writes to the Photos library or uploads the source databases.
Before writing, Apple imports share one verified private archive copy for schema and library-binding checks. Writable archive opens still require temporary disk space for a complete archive copy; the shared check avoids a second concurrent copy during Apple imports.
Apple's Photos database is a private schema and can change between macOS releases. The importer validates every required table and column before it writes observations, so an unknown schema fails closed instead of silently mislabeling photos.
After upgrading an existing archive to this version, run photoscrawl init --json once before status, search, or other read-only commands. This
applies the required archive migration without recrawling media. If macOS
reports operation not permitted while opening the Photos database, grant Full
Disk Access to the installed photoscrawl executable in System Settings →
Privacy & Security → Full Disk Access, then rerun the import from the logged-in
macOS session or its background worker.
Crawls merge into the archive. An asset missing from a later enumeration stays live; only an explicit provider deletion signal creates a tombstone. Asset tombstones retain their reason and also tombstone archived resource rows such as derivatives and thumbnails. A later explicit live record restores the asset and the resources present in that record without discarding other archive history.
classify drains that queue into evidence-backed local metadata observations.
With --local-model <model>, it also sends already-local image bytes to a local
Ollama or OpenAI-compatible vision server and stores typed candidate
observations:
scene summaries, visible-text summaries, place-type/name/venue candidates,
objects/foods, anonymous people presence, privacy hints, cluster terms, and
uncertainties. These are evidence-backed model observations, not durable
people/place/trip truth.
Local-model endpoints must resolve entirely to loopback addresses. Redirects are checked under the same rule. Evidence records the actual response endpoint and that image bytes were transmitted over the loopback interface.
neighbors returns source-level adjacent assets only. It does not create trips,
people, places, or clusters. Current reasons are deterministic archive facts:
same burst id, same album id, same resource hash, nearby creation time, nearby
raw GPS, and shared local observation labels.
timeline returns raw geotagged asset observations for one explicit half-open
time range. It preserves the asset and location-observation identifiers and
reports upstream horizontal accuracy when available. It does not infer stops,
routes, trips, or events.
place-context enriches one asset's own latitude/longitude/accuracy/time into
address hierarchy and candidate nearby POIs. Apple's network-backed
CoreLocation reverse geocoder is the required step. MapKit POI search is
optional venue evidence: no POI found is recorded as poi_status: "none",
while real provider errors still fail. Text output is a compact deterministic
place card; --json returns provider evidence. Apple address areas of interest
are rendered as map context, not as POIs.
place-card renders cached provider evidence into the same deterministic
Markdown card without re-calling providers. It keeps address detail, normalizes
map context, caps useful POIs, and omits raw coordinates, warnings, provider
counts, provenance, and invented confidence. It is for eval harnesses and
private provider experiments.
place-backfill is a private evidence command for full-library Apple provider
probes. It reads photos.sqlite, dedupes exact location/accuracy keys, retries
provider failures, and writes the manifest, attempts, raw successful provider
outputs, and final errors under the crawlkit data dir's
backfills/place-context-full/apple-ingest subtree.
An evidence write failure stops the current backfill round, cancels pending attempts, and returns the original write error.
Ctrl-C cancels backfill retry waits promptly. If a command is blocked in a native call or input read, a second Ctrl-C terminates it.
eval-card is an opt-in research harness for prompt/model evaluation. It uses
the tracked prompt files in prompts/, prepares canonical full-resolution JPEGs
from originals, passes full metadata as a sidecar prompt input, and writes all
private images, metadata, and model responses under the crawlkit data dir's
evals subtree. If --allow-icloud-downloads is set, PhotoKit may download
missing originals into the crawlkit cache dir's originals subtree; normal
crawl/classify commands do not force iCloud downloads.
Preparation tries at most three times the requested card limit. Downloaded
originals are limited to 256 MiB each and 512 MiB per run, streamed into owned
temporary cache files and removed after preparation, including on failure.
Existing local originals and older cache files are never deleted by this cleanup.
The summary includes assets_attempted; retained JPEGs and metadata remain in
the output directory, while temporary original paths are provenance only.
The eval-card summary is written only after its manifest flush and close succeed; manifest persistence errors fail the run before the summary is written.
Current Useful Output
Today the POC sees useful source facts and optional local multimodal observations:
- asset timing, media type, dimensions, favorite/hidden state, timezone, and burst metadata;
- resource type, UTI, filename, local/remote availability, iCloud download need, and resource hash when already local;
- regular, shared, and smart album membership, album folder paths, and raw GPS observations with evidence refs;
- Apple Photos captions, keywords, detected text, scene labels, activities, venues, people, camera/source clues, and media categories imported from the local Photos search index;
- metadata-only observations for media type, local content availability, geometry, burst membership, resource UTI/type, and weak screenshot/document/receipt candidates from filenames, albums, and metadata;
- optional local model observations from already-local image derivatives or originals, plus normalized terms for search and later clustering;
- quality observations for model failures such as prompt leakage;
- status coverage counts for GPS, observations, local resources, remote resources, classification queue state, and observation types;
- search/timeline/open/evidence/neighbors JSON that points every claim back to source rows or evidence ids.
It does not create durable identities, trips, places, relationships, embeddings, or global clusters yet.
Why This Shape
This is a local-first personal media index:
- typed local objects;
- provenance on every derived claim;
- entity and link resolution as explainable pipelines;
- graph traversal and timelines as first-class query shapes;
- clusters and trips as later hypotheses, not v1 truth;
- user-owned local archive with no sharing or hidden scoring by default.
Photos are useful because a saved image usually records something the user cared about: a place, person, document, trip, purchase, home, event, hobby, meal, screenshot, or drone flight. The crawler's job is to preserve that context without pretending GPS, face labels, or classifier labels are perfect facts.
v1 Scope
Build photos.sqlite with:
- assets and resource metadata from Apple Photos;
- local original-download queue with bounded cache/ringbuffer;
- GPS observations as raw coordinates only;
- album membership;
- file/resource hashes when originals are available;
- Vision/Core ML observations: labels, OCR, faces, barcodes, screenshot/document markers, quality/similarity signals where useful;
- evidence refs for every observation;
- JSON status/search/timeline/open/neighbors/evidence commands.
Out of scope for v1:
- durable person identity;
- durable trip/place/event truth;
- relationship inference;
- global photo clustering;
- cloud classification by default;
- Photos writeback.