Templates with `imageapi:lora-slot:<n>:high|low` nodes take LoRAs picked
per job from a catalog built live from ComfyUI.
- Catalog: ComfyUI's LoraLoaderModelOnly `lora_name` choices (cached
60 s, re-read when a submit names an unknown id), paired by High/Low
name tokens (StyleHigh/StyleLow, x-HIGH/x-LOW, x_high_noise/...). Files
the workflow already loads outside the slots (lightx2v, switches) and
`[template.catalog] exclude` substrings never show; ids derive from the
names so they stay stable.
- POST /enhance `slots: [{lora, scale?}]` fills the slots in order: each
pick sets the slot nodes' lora_name and scales their exported strength;
unused slots run at 0, so the graph is never rewired. More picks than
slots, duplicates or unknown ids are 400s.
- GET /enhance/templates gains `slots` and `catalog: {loras, strength,
min, max}`; jobs store and return the picks (new enhance_jobs.lora_slots
JSON column).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tool opened a bare connection, so an UPDATE that met the server's
watcher holding the write lock failed at once (SQLITE_BUSY) and aborted
the whole run. Wait up to 15 s instead, like the server's own pool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So the app builds against the final shape: each [[template.lora]] gains
`strength` (the scale when on, default 1.0) and `min` / `max` (the
slider's range, default 0.0-1.5).
- POST /enhance `loras` takes true / false or a scale: true = strength,
false or <= 0 = off, a scale is clamped to min..max.
- The scale multiplies each node's exported strength_model /
strength_clip, so 1.0 = as exported and a high/low pair keeps its
ratio.
- Jobs and the job view store the resolved scale per id (0 = off);
GET /enhance/templates lists strength, min and max.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A template can offer optional LoRAs the app turns on or off.
- enhance.toml: [[template.lora]] { id, label, default } per template.
Each id drives every workflow node titled `imageapi:lora:<id>`, so
Wan's high- and low-noise pair share one switch.
- POST /enhance takes `loras: {id: bool}`; a missing id uses its default,
an id the template doesn't offer is a 400. Off sets strength_model
(and strength_clip when present) to 0, so the graph is never rewired.
- The resolved switches are stored on the job (new enhance_jobs.loras
JSON column) and returned in the job view, so a retry or adjust can
repeat them; GET /enhance/templates lists each template's switches.
- A lora-titled node without a declaration keeps its exported strength,
and a declaration without nodes isn't offered; both are startup
warnings. A lora-titled node without a numeric strength_model fails
the template.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stored vectors are only searchable with the backend, model, width and
prefixes that built them, and nothing enforced that: changing [embedding]
(or backend) without reembed_embeddings silently broke similarity search,
and the per-row dim check missed same-width model or prefix swaps.
- embedding_contract: one row (backend, model, dim, both prefixes). The
server checks it once after migrations; the src/bin tools check it on
their first embed. No row yet = record the running config.
- On a mismatch every embed (embed_many, so queries and documents) fails
with a message naming each changed field and the fix. Mixing vector
spaces corrupts the corpus for good; refusing only pauses semantic
search until the config is put back or the corpus re-embedded.
- reembed_embeddings bypasses the guard and records the contract after a
full run (all four tables, no --limit, not --dry-run).
- A DB error in the check leaves embedding allowed and is logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A busy GPU (llama-swap holding the 3090) made ffmpeg's CUDA setup fail
with CUDA_ERROR_OUT_OF_MEMORY, exit 187. The playlist generator took
that for a bad file and wrote playlist.unsupported, so the video never
played; preview clips were marked failed the same way.
is_gpu_init_failure() recognises CUDA/NVENC setup errors in ffmpeg's
stderr. When an NVENC run fails that way, the playlist and the preview
clip rerun once on libx264. A file that also fails on the CPU still gets
the sentinel. The HLS command and the timed run are pulled out of the
handler (hls_command, run_ffmpeg) so both attempts share them.
Fixes#215
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /image/favorites had no ORDER BY, so SQLite returned rows in index
order (by path). Order by id descending so the app's Favorites view
(FileViewer #73) shows what was just added on top.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /enhance/jobs without a path now answers the jobs manager (FVR #99):
every photo's queued / running / unavailable jobs, plus ready / failed /
expired ones touched in the last 3 days, newest first, discarded and
approved hidden, at most 100. DB only; the poller keeps rows current.
An optional status=a,b filter applies to both forms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Derived clips (#208) aren't counted as versions, so a photo whose only
derived file is a clip looked like a plain photo, and clients never fetched
/image/versions for it. Summaries now carry clip_count: /photos adds the
parallel array photo_clip_counts, records (memories, search, /photos/exif)
carry clip_count. All skipped when zero, so other responses are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Groundwork for Enhance "Animate" (#208): a video made from a photo.
- Version linking reads the XMP link from MP4 / MOV too (top-level uuid box
with the XMP UUID, mdat seeked over), at ingest and on Enhance approve.
- New kind `animated`, a derived clip (CLIP_KINDS): hidden behind the photo
like a version, but not counted in photo_version_counts / version_count,
never a display choice (400 clip_not_displayable), and listed as `clips`
in GET /image/versions. Approve returns the clip entry.
- Templates whose imageapi:save is ImageApiSaveVideoVersion report
output "video" in /enhance/templates (no enhance.toml change). Previews
already relay ComfyUI's content type (video/mp4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SeedVR2VideoUpscaler (the new Sharpen / Rescue templates) rejects seeds
above 2^32 - 1 at validation, and the 52-bit seeds made every job fail with
"Value ... bigger than max of 4294967295: seed". 32 bits is still plenty of
variety for the Fibo templates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.
- 500s: the body was the error's text (anyhow / diesel / {:?}); now
ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
statuses are unchanged. Shared helpers per module (faces_unavailable,
person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.
Closes#202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- error::ApiError (ResponseError): status + stable snake_case code +
display-safe message + optional details. From<anyhow>/From<diesel> and
into_http_internal_err become a 500 "internal" whose cause is logged,
never sent.
- JSON body, query and path parse failures, the Claims 401 and unknown
routes now answer in the format instead of actix's plain text.
- tags handlers return ApiResult with codes (invalid_tag_name,
tag_not_found, tag_name_taken, no_files, too_many_files,
nothing_to_change, tag_added_and_removed). The rename 409 keeps
existing_tag at the top level for older clients.
- Enhance's error_response builds through ApiError; its codes are
unchanged, and an internal error no longer sends its message.
- duplicates: eight 500s sent format!("{:?}", e) to the client.
Refs #202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- POST /image/tags/bulk { file_names, add: [names], remove: [ids] }: one
transaction over many photos; names match case-insensitively and are
created when missing; 400 if a tag is both added and removed.
- Adding a tag a photo already has is a no-op (was a 500 on
UNIQUE(rel_path, tag_id)).
- Adding a tag no photo carries reuses it (the lookup only saw tags in
use, so create_tag hit idx_tags_name_nocase: 500). update_tags could
likewise skip such ids silently.
- Removing a tag matches the name case-insensitively and deletes it from
every path sharing the photo's content, as reads union them; it used to
answer 200 and leave the tag on the other copy.
- update_tags returns errors instead of panicking with the TagDao lock
held, which poisoned it for every later tag request.
Closes#200
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parse execution_start vs the end event from /history, store it when the
poller moves a job to ready/failed (never overwritten with null), and
return it as "run_ms" on every job endpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daily-summary cleaner hard-coded two contacts' names in its opening
phrases; a pattern now strips "Today, <Name> and I discussed" for any
contact (with a test). Video path tests use a neutral folder name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
link_versions only ran the ComfyUI LoadImage match on PNGs inside an
aiedits/ folder, so early outputs saved to a sibling folder (e.g.
edited/) or beside their original were never proposed. Run it for any
PNG whose prompt names a loaded image (still review-only), and search
the output's own folder before its parent when it isn't in aiedits/.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A version no longer hides behind an original that duplicates resolve
demoted (visible_version_clause, hidden_version_rows).
- Recording a link drops the version's faces, CLIP data and clip failures
(covers link-at-ingest and link_versions --apply).
- The display file must live in the original's library; otherwise the
original shows, and PUT /image/versions/display answers 400.
- link_new_files looks up hashes in chunks of 500 and logs errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dry run by default: prints a TSV of proposed links. XMP (PNG DerivedFrom,
incl. files already ingested) and file-name links are exact and apply
without review; ComfyUI prompt LoadImage inputs, Upscayl-style names and
nearest pHash are heuristic and apply only from a reviewed TSV, checked
against current hashes. Unresolvable cases are listed, not guessed. --apply
takes a WAL-safe VACUUM INTO snapshot first and aborts if it fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention
links at ingest; unlinked name candidates are retried each tick. Versions
are hidden from /photos (SQL for the recursive listing, hidden-row set for
the walk and tag branches), /memories, reels, /photos/exif, the GPS summary,
unified search and the media gauges while their original is reachable in the
view; include_versions=true lists them. /photos gains the parallel arrays
photo_display_paths / photo_version_counts, memories / search hits /
/photos/exif rows gain display_path / version_count, all skipped when empty.
GET /image/versions and PUT /image/versions/display (library optional).
Tags and favorites on a version path bind to the original; faces, CLIP and
duplicate grouping skip version hashes; insight generation redirects to the
original. Orphan GC and the back-ref refresh cover the new tables.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
photo_versions links a version's content_hash to its primary original's;
photo_version_references keeps provenance-only reference photos;
photo_version_display holds the global display choice. VersionDao covers
linking (chains flatten to the root), the display choice, per-original
summaries for /photos, the hidden-row set for non-SQL listings, and the
copy of the original's date/GPS/camera onto version rows. Also the SQL
predicate listings use to hide a version while its original is reachable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README, CLAUDE.md and .env.example now point at llm.toml for LLM settings
(OPENROUTER_API_KEY stays in env) and describe the legacy env override
window, the discovered picker and the startup cross-check. ADR-0003 records
the move and amends ADR-0002's deployment-config home; CONTEXT.md's
Deployment config / Hard default entries follow. Source comments and the
"set LLAMA_SWAP_URL" error strings name the llm.toml keys.
Also fixes README's AGENTIC_MAX_ITERATIONS default (10 -> 12, what the
code uses).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After AppState is built, main checks the config against llama-swap's
/v1/models in the background and logs one warning per problem: a role
model (chat, vision, embedding, unified_search, tts) that isn't served or
lacks what the role needs (vision, kind: embed / tts), an embed model
whose metadata dim differs from embedding.dim, picker include/exclude ids
llama-swap doesn't serve, and models with no metadata. Warn-only: llama-swap
may still be starting. Chat-side roles are only checked under
backend = "llamacpp"; TTS always is.
Against the deployed .env this reports exactly the three stale
LLAMA_SWAP_ALLOWED_MODELS ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/insights/models now lists llama-swap's models live from /v1/models
instead of LLAMA_SWAP_ALLOWED_MODELS: every kind=chat model, with
setParamsByID aliases (:sub / :fast) hidden, filtered by
[llamacpp.picker] exclude and the optional include allowlist. Ids the
include list names but llama-swap no longer serves drop out instead of
showing as dead picker entries.
has_vision and context_window come from each model's config.yaml metadata
(read from meta.llamaswap, where the deployed llama-swap merges it, or from
meta as upstream documents). A model without metadata is text-only chat.
A live served window still wins over the configured ctx.
Agentic insights on llama-swap now ask the chat model's capabilities:
text-only models (e.g. qwen3.8-27b-code, --no-mmproj) get the local vision
model's description inlined, like hybrid, instead of an image they can't
read. If llama-swap can't be asked, images are sent as before.
Drops AppState.llamacpp_allowed_models.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every LLM env reader now goes through ai::config::llm_config():
build_ollama / build_llamacpp / build_openrouter take config sections
(OPENROUTER_API_KEY is still read from env), and the backend switch,
embedding dim + prefixes, Ollama / llama.cpp / TTS timeouts, TTS voice and
ref length, agentic caps, chat turn timeout and the unified-search model
read their sections. LocalLlm::from_env -> from_config; the
test_daily_summary and populate_knowledge bins take Ollama defaults from
the config. env_max_iterations / env_default_num_ctx are renamed to
max_chat_iterations / default_num_ctx.
The picker still uses picker.include (= LLAMA_SWAP_ALLOWED_MODELS) until
discovery lands. Legacy env vars keep working through the config's
override layer; checked against the deployed .env, which resolves to the
same values. The LLM_BACKEND parsing tests move to ai::config.
llm.example.toml now leaves the embed prefixes empty: copying the
nomic prefixes onto a corpus embedded without them breaks search.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds ai::config: one serde struct for every LLM setting (backend, llama.cpp,
Ollama, OpenRouter, embedding, TTS, agentic caps), resolved as defaults ->
llm.toml (LLM_CONFIG_PATH, default ./llm.toml) -> the old env vars. Env
overrides are kept for one release and listed in a single startup
deprecation warning; LLAMA_SWAP_ALLOWED_MODELS maps to picker.include so
current deploys keep their picker. Unknown keys, zero timeouts and
backend=llamacpp without a URL fail startup. OPENROUTER_API_KEY stays
env-only.
llm.example.toml documents the schema and is parsed by a test. main loads
the config right after tracing init; readers switch over next.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set busy_timeout before the WAL switch and retry the pragma batch on
'database is locked' within the same 5 s budget: while a file is still in
rollback mode, a shared-lock holder upgrading for the journal_mode change
gets SQLITE_BUSY without the busy handler running, so busy_timeout alone
doesn't cover parallel opens of a fresh file. Adds a 16-thread stress test
and drops the per-module DB_INIT workaround in ai::handlers tests.
Closes#157
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Thread tools are bound server-side to the chat's own thread. The model
used to pass contact_id, so practice mode could read any conversation it
named; a model-supplied contact_id is now only checked, never trusted, and
the prompts no longer hand it one.
- Practice chats need no persona. They're stored under PRACTICE_PERSONA_ID
and run with an inert stand-in, so deleting or editing personas can't hide
or break them. persona_id is optional on create for as_contact.
- The per-thread list filters in SQL (list_persona_chats_for_contact, served
by idx_persona_chat_anchor_contact) instead of decoding every transcript.
- fetch_mms_image checks the content type and a 20 MB cap before reading
the body, so a video attachment isn't downloaded in full.
- get_photos_near_time unions tags over every path sharing the content, per
the multi-library model.
- look_at_photo only opens indexed photos, so an excluded folder can't be
read by guessing a path.
- One date parser: get_photos_near_time uses date_info::parse_center.
- Practice prompt source rewrapped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local insight's transcript carries the photo itself, so continuing on hybrid
was refused. Each hybrid turn now has the local vision model describe the
photo (cached per image), sends the description in its place, and puts the
photo back before saving, so the stored chat and later local turns keep it.
If the local description fails, the turn fails. The stored model id is only
reused on the same backend, so a llama-swap slot isn't sent to OpenRouter.
Checked end to end against a recording fake OpenRouter on all three
continuation endpoints: no image data left, the stored transcript kept the
photo, and switching back to local saw the photo again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing in the client stopped it: a message carrying images became image_url
parts and went out. Photos stayed local only because callers routed around it.
messages_to_openai, which every OpenRouter request passes through, now refuses
any image (IMAGES_STAY_LOCAL), and describe_image always errors; hybrid mode
describes photos with the local model. A caller bug now fails the turn instead
of uploading a photo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Small local models get weekdays, "how long ago" and day counts between dates
wrong, confidently. Persona chats anchored on old SMS threads ask exactly
those questions. date_info answers them exactly, in the owner's display zone:
- no args: now (replaces get_current_datetime)
- date: its weekday and how long ago / until
- date + other_date: the gap in days, weeks and years/months/days, plus
hours when both carry times
- date + add_days: the date N days after (or before)
Checked live on gemma4-26b-A4B and gemma4-31: each date question took one
date_info call and came back correct.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
describe_photo describes the file chat's photo. Persona chats have no photo,
so a vision-capable model was offered a tool that could only answer "No image
available". Persona chats now build their catalog with the vision gate off;
the thread tools still cover looking at images there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An anchored conversation now has a mode. 'about' is the existing behaviour (a
persona talks with the user about the thread). 'as_contact' has the model play
the contact so the user can rehearse the conversation: its own prompt (stay in
the contact's voice, don't invent facts, coach on 'out of character'), no
persona prompt, and only the thread tools — no memory, library, calendar or
other-thread access, and no knowledge writes.
The shared persona/file-chat loop now refuses tool calls that weren't in the
turn's catalog, so the gates hold even when a model names a tool it wasn't
offered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Persona conversations can now be anchored on a message in an SMS thread.
Creating one snapshots the thread up to that message (via SMS-API) into the
row; every turn appends it to the persona prompt with guidance to talk like a
text thread rather than write an insight.
Anchored turns get four tools: get_thread_messages (page the thread),
look_at_message_image (local vision on an MMS photo), get_photos_near_time
(library photos with tags and people) and look_at_photo (local vision on a
library photo). The conversation list filters by contact_id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
backfill_utc_dates refused any fs_time re-resolution of a tag-dated row,
assuming exiftool had failed on the file. Files whose date tags are all
0000:00:00 (e.g. uv-prod-long.mp4) were read fine and simply have no
date, so they sat unconverted and re-ran through exiftool every tick.
resolve_dates_batch_report now also returns the paths exiftool read
without error (-Error surfaces ExifTool:Error in the JSON). The drain
lets fs_time stand for an exiftool-legacy row only when exiftool read
the file; read errors / missing entries and kamadak exif-legacy rows
keep refusing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.rs re-declared nearly every module (`mod ai; mod database; …`)
that lib.rs also declared, so the ~100k-line crate compiled twice in
every build (lib for the src/bin tools, bin for the server) and the
test suite ran most tests twice. watcher.rs even reached into the lib
copy via `image_api::` from inside the bin copy.
The six bin-only modules (backfill, handlers, hls_stats, knowledge,
reels, watcher) move into lib.rs; main.rs keeps only startup wiring
over `use image_api::…`. A few items main calls become `pub`, and the
dead_code allowances that only existed because each copy saw half the
callers are gone. CLAUDE.md says not to reintroduce `mod` in main.rs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local location_history table is a one-time Takeout import: it ends
in 2019, still has the ghost-device and coarse cell-fix samples Apollo
has since excluded, and was read loosely. The per-photo line took the
nearest sample within +/-3 h at any distance (17% of photo moments got a
position >2 km away), and get_location_history listed the first 20 rows
of a +/-14-day window sorted ascending (always ~14 days before the date).
With APOLLO_API_BASE_URL set:
- per-photo context comes from /api/location/at (30 min max gap; no
sample -> no line). A Place stay renders as "at Home (14:02-18:30)" in
the display zone; an unnamed sample is reverse-geocoded.
- get_location_history returns Apollo's /api/location/day timeline for
the date (+/-1 day at most), and is offered even with an empty local
table.
Both fall back to the local table when Apollo is unset or a call fails;
the local tool path now covers the requested local day(s) and samples
evenly instead of listing a window's first rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`image::open` chooses the decoder by extension, so a PNG saved as .jpg
(Android screenshots, chat exports) failed thumbnails, previews,
perceptual hashes, insights and face crops with "Illegal start bytes:
8950". New `file_types::open_image` sniffs the header and falls back to
the extension when it's unknown; every decode site uses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 422 from Apollo's /encode_image (bytes it can't decode — corrupt
files, non-images with an image extension) left clip_embedding NULL,
so the drain re-sent the file and logged the same "permanent failure
(will retry next pass)" on every scan.
Permanent rejections now record a clip_failures marker scoped to the
row's content_hash; list_clip_unencoded_candidates skips the row while
the hash still matches, so a replaced file gets another try with no
cleanup. Only 422 (new ClipError::Rejected) marks: other 4xx are
deployment problems (auth, routing) and would otherwise blacklist the
whole library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>