8090 is held by a long-running service on the dev box, so the e2e server
(and the app's drive-app / Detox, which read E2E_PORT too) move to 8093.
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>
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>
- 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>
- 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>
Fixtures gain an XMP-linked version (renamed file), a name-linked version
and an edit with a reference photo; e2e.sh counts with include_versions and
runs check_versions.py. CLAUDE.md documents the feature.
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>
- 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>
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>
Grid and memories tiles requested `size=large` (up to 2048px) for tiles
that display at ~590px (two columns) to ~1180px (one full-width column)
on a 3× phone, downloading 10–15× the pixels they show. `medium` caps
the long edge at 1024: crisp at both, a quarter of `large`'s pixels.
Cached under `_medium/` like the other tiers.
The large and xlarge generators were copies differing only in their
cap; fold them into one `generate_preview(src, dest, max_dim)` so the
third tier doesn't make a third copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app's new search screen keeps tags, type, date, camera and place as
chips and sends free text to the unified endpoint. For the AI to add to
those chips instead of replacing them, the endpoint now takes them:
- /photos/search/unified accepts the /photos filters explicitly (tag_ids
with All/Any, exclude_tag_ids, media_type, dates, camera make/model,
gps circle) plus a recursive `path`. Explicit tags are their own
predicate on top of the model's ANY-mode tags; single-valued filters
replace the model's guess. `interpreted` echoes only what the model
added, so the client doesn't show the user's own chips twice.
- interpret=false skips the LLM and uses q verbatim as the CLIP term,
which replaces the app's separate "Semantic" mode with a visual match
inside the user's filters.
- /image/tags/all also returns `cameras` (make + model with counts) so
the camera picker can offer e.g. "Nexus 6" directly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Long insight/persona chats on llama-swap failed with "exceeds the
available context size" even after raising Context in the app:
- llama-server fixes its window at launch (-c) and ignores num_ctx, but
the history budget trusted the requested num_ctx, so raising it kept
MORE history than the server could hold. LlmClient::context_window()
now reports the served window (llama-swap: /upstream/<model>/props,
cached 10 min) and the budget uses min(requested, served).
- apply_context_budget could only drop tool-call pairs, so plain Q&A
chats never shrank. It now also drops whole earlier exchanges (keeping
user/assistant alternation) and, last, the current turn's tool results,
never the in-progress question.
- The budget only ran once, before the tool loop. ContextFitter now
fits every LLM call (on a copy; the stored transcript is untouched)
and, on a context-overflow error, shrinks the budget from the server's
reported token counts and retries once.
GET /insights/models reports each llama-swap model's context_window when
it's known without loading the model (cached, or currently running), for
the app's Context picker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /video/original mints a URL for one video's original bytes, signed
with a key derived from SECRET_KEY (distinct from the login-token key)
and valid for 6 hours. GET /video/original/stream?token= serves it via
NamedFile, so Range/seek works, without an Authorization header — native
players can use it directly. HLS stays the default path.
Also extracts generate_video's library fallback into resolve_request_path
and fixes the stale /video/stream entry in CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs said tests need BASE_PATH in .env. The full suite (lib, bin and
integration tests) passes with no .env and no BASE_PATH; the only thing
it needs is DATABASE_URL, and ":memory:" works. Without it, DB-backed
tests such as reels::tests::reel_capture_preserves_owner_defaults panic
with "DATABASE_URL must be set".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
date_from / date_to on /photos, /photos/exif and unified search ran on
the legacy mixed-convention date_taken, so a photo could display inside
a range yet be filtered out of it by up to its UTC offset (or by years,
for rows the filename class re-dated).
The filter lives in the shared ExifDao::query_by_exif, so it's fixed
once there:
- query_by_exif takes Option<date_view::InstantRange> (bounds + display
and server zones) instead of raw date_from / date_to.
- SQL pre-filter: converted rows on date_utc exactly; unconverted rows
(drain pending, permanent floor) on date_taken widened by
LEGACY_INSTANT_BAND_SECS (±15 h, now shared with /memories). Converted
rows are deliberately not bounded by date_taken: a re-dated row's
legacy value can sit years from its instant.
- Exact pass in Rust via InstantRange::contains → row_true_instant, so
every returned row's true instant is inside the range.
Calendar days now mean the owner's days: nl_query::iso_to_unix builds
day bounds in the display zone (it used UTC midnights, which only lined
up with the legacy wall-as-UTC column), and unified search's "today" is
the owner's today rather than UTC's. The midnight-with-DST-gap fallback
moves from memories_anchor into date_view::day_start_instant.
Compatibility: clients already sending real instants (Apollo's
/photos/exif windows) get correct edges with no change. Clients sending
UTC-midnight day bounds see ranges shift by up to the UTC offset until
they send display-zone bounds.
No index on date_utc; the OR clause scans, which is milliseconds at
personal-library scale (same as /memories today).
dev/e2e check gains /photos/exif wall_clock + true_instant assertions and
range-edge checks ([t, t] in, [.., t-1] and [t+1, ..] out). Not run here:
the harness needs the WSL + Windows toolchain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logs were a separate pipeline: env_logger in debug (never exported) and
opentelemetry-appender-log in release (exported without trace context,
so SigNoz could not link logs to traces).
- tracing-subscriber `tracing-log` feature: try_init installs LogTracer,
so log:: records become tracing events: one console format, and
RUST_LOG per-module directives still apply to them.
- opentelemetry-appender-tracing exports events as OTel log records when
OTLP_OTLS_ENDPOINT is set (any build), stamped with the enclosing
span's trace_id/span_id. A per-layer filter drops the exporter's own
crates.
- The OTel span layer keeps only WARN/ERROR events as span events; the
info/debug copies duplicated the correlated logs.
- Remove env_logger init in the server, init_logs, and the unused
opentelemetry-appender-log / opentelemetry-stdout deps.
- tests/otel_log_correlation.rs pins correlation (native + bridged),
the span-event filter, and per-module filtering of bridged records.
- Constitution 1.2.0 (Principle V logging rule), CLAUDE.md, README,
.env.example.
Closes#147
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Principle V now reflects the merged tracing migration (#148):
- tracing + tracing-opentelemetry spans in every build, with OTLP export
gated on OTLP_OTLS_ENDPOINT;
- no manual Context threading;
- every spawn / fan-out carries its parent span;
- RUST_LOG gates span export (run at info/debug).
Layer and Tech Stack wording matches.
Also corrects stale "release builds only" telemetry notes in CLAUDE.md and
.env.example, updates the CLAUDE.md dependency list, and fixes the README's
RUST_LOG default. It also documents OTLP_OTLS_ENDPOINT in the README, as
Principle IV requires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every payload a client renders a capture time from now carries
`wall_clock`, a naive ISO-8601 string computed by the existing
`date_view::row_display_wall`: the camera's wall clock (true instant +
camera offset; the owner's display zone without an offset), with the
per-source fallback for unconverted rows. Clients render it verbatim and
do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with
the rule kept server-side: the drain's permanent `date_utc IS NULL` floor
(#120: edited/exported files) would otherwise force every client to
carry the per-source legacy conventions forever, and insight prompts
already use the same function, so prompt and UI agree by construction.
- `/image/metadata` (+ the GPS / date / date-clear mutation responses):
`file_wall_clock` — the row's wall, else the filename-class date, else
the earliest fs time in the display zone (unindexed files).
- `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`.
- `get_all_with_gps` returns full rows instead of a 7-tuple that dropped
`date_taken_source`, which unconverted rows need.
- `date_view::owner_display_zone` resolves prefs -> app default per request.
Legacy fields are untouched, so current clients see byte-identical
responses (additive; safe to deploy ahead of the client flip, per #120).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The subscriber's single EnvFilter fronts both the console and the OTel
layer, so RUST_LOG decides which spans exist for export. Documented in
CLAUDE.md (supported range info/debug) and at the filter in init_tracing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trace_db_call no longer takes an OpenTelemetry Context: it opens a
db.<query_type>.<operation> span (same name via otel.name, same
db.query_type / db.operation attrs) on the caller's ambient tracing span
and runs the DAO body inside it. The cx / ctx / context parameter is gone
from every DAO trait method, impl and call site, and from the helpers and
background drains that only threaded it.
The remaining request-handler spans (files, tags, faces, image, video,
favorites, duplicates, memories) move to #[instrument]; the
Context::current_with_span / with_remote_span_context plumbing is gone, as
is extract_context_from_request (the tracing-actix-web root span covers
it). otel::set_span_attributes replaces BoxedSpan::set_attributes.
Background-job raw spans (thumbnails, video actors) are left for #142.
tests/otel_db_spans.rs pins name, attributes, status and nesting (across
an instrumented spawn); otel_traceparent now uses an #[instrument]
handler. CLAUDE.md documents the tracing conventions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`dev/e2e/e2e.sh up` runs an isolated ImageApi (port 8090, own build dir,
db, thumbnails, HLS) next to a real local server, serving a copy of a
synthetic fixture library. Every fixture image has its path, date and
scenario burned into the pixels; manifest.json records expected dates,
GPS, cameras, tags and counts. `reset` restores the baseline.
- LOCAL_BIND_URL makes the hardcoded localhost:8088 listener
configurable (default unchanged; empty disables it) so a second
instance can start.
- create_user bin: the server has no registration route; idempotent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes#127.
Grows user_ai_prefs with five nullable owner-default columns and makes
them the server-side fallback layer: request param -> stored pref ->
hard default, at insight generation (agentic + standard), all three
insight-chat turn variants, and reel creation.
- PUT /prefs accepts the fields with the existing three-state partial
update semantics (TriState generalized to TriState<T> for the numeric
reel_max_segments); validation (canonical BackendKind::parse,
MemoriesSpan::from_str) runs before any write.
- GET /prefs returns the new fields.
- capture_prefs reads the existing row and carries the AI owner-defaults
through unchanged (the upsert overwrites all columns), mirroring only
the effective reel span/segments.
- MemoriesSpan gains Display + FromStr so stored spans round-trip
losslessly.
1463/0 tests (+27), clippy clean.
Review follow-up: rows that exist but can't be faithfully resolved spent
the cap and were re-collected first every tick. Carry a per-library
cursor across ticks (wrapping at end of table), share the cap parser
between the two date drains, and name the conversion tuple.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per-tick drain (backfill_utc_dates) at the existing date-drain seam,
inside the per-library online gate. Selects date_utc IS NULL AND
date_taken IS NOT NULL (new partial index idx_image_exif_utc_backfill),
re-runs resolve_dates_batch with empty priors (one exiftool subprocess
per batch, same call shape as the #117 audit), and writes only the
new-contract triple; the legacy pair stays frozen. Capped by
DATE_BACKFILL_MAX_PER_TICK; missing-file rows are paged past without
spending the cap. Legacy manual overrides convert via
manual_date_resolution rather than being re-resolved from the file.
Day-boundary invalidation deletes the day-span precomputed reels
covering a re-dated row's old/new display-zone month-day so the nightly
pre-gen regenerates them. Progress: imageserver_date_backfill_remaining
gauge + date_backfill_remaining on GET /libraries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The filename date class is now first-class (ADR-0001):
- memories::extract_date_from_filename returns FilenameDate:
Wall(NaiveDateTime) for the screenshot/dash/compact patterns,
Utc(DateTime<Utc>) for the unix-timestamp patterns. No patterns
added or removed; every existing pattern carries its convention.
- Waterfall inversion in both resolve_date_taken and
resolve_dates_batch: an in-class name is the original date of
record and resolves BEFORE EXIF / container dates are consulted
(shared filename_class_resolved step; in-class files skip
exiftool entirely). Wall patterns run the offset cascade over the
kamadak prior's evidence (empty evidence -> server-zone
assumption, so a class file always resolves); timestamp patterns
are true instants (tz_source utc_direct, no offset).
- Legacy date_taken stays byte-identical: wall-as-UTC for wall
patterns, epoch for timestamp patterns, via
FilenameDate::legacy_datetime / legacy_timestamp at the seven
consumer call sites.
- Out-of-class files keep the global EXIF-wins waterfall unchanged.
- CLAUDE.md waterfall section rewritten (step 0 = filename class;
the EXIF-beats-filename note becomes out-of-class-only).
Tests: five new waterfall-seam tests (wall no-evidence ->
assumed_server; wall outranks EXIF with offset-tag evidence;
wall GPS evidence; timestamp utc_direct; in-class video name
resolves without exiftool) plus enum-shape assertions in every
extractor test. Code-reviewed on both axes (standards: no
violations, 3 of 4 P2 notes applied; spec: all acceptance
criteria verified, no scope creep).
716 integration + 639 lib tests pass; fmt + clippy clean.
The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:
- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
UTC-rendered creation_time fields -> true instants (utc_direct).
Running them through the camera wall-clock cascade was the
"videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
the offset cascade (OffsetTime* pairing + GPS evidence pulled in
the same exiftool pass).
The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.
Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.
ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
- 38 skills from mattpocock/skills into .agents/skills/ (symlinked into
.claude/skills/ for Claude Code)
- skills-lock.json for reproducible installs
- docs/agents/: issue-tracker (Gitea via tea CLI), triage-labels
(default five-role vocabulary), domain docs (single-context)
- CLAUDE.md: add '## Agent skills' section pointing at docs/agents/
- .gitignore: un-ignore docs/agents/ under the local /docs/* rule
OpenRouter reserves credit against the requested max_tokens, falling
back to the model's full output window when omitted, so expensive
models failed with credit-limit errors even for short replies. Default
the cap to 16384; OPENROUTER_MAX_TOKENS overrides it and 0 disables it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A JSON map (TTS_PRONUNCIATIONS_PATH, default tts_pronunciations.json)
rewrites mispronounced words — place names, initialisms, dotted
abbreviations — to phonetic spellings before synthesis, applied after
markdown cleanup in both /tts/speech paths. Whole-word smartcase
matching (lowercase keys match any casing, uppercase keys exact),
longest key wins, hot-reloaded on mtime change with last-good fallback
on parse errors. See tts_pronunciations.example.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trialing Qwen3-Embedding-0.6B (1024-dim, instruct-prefixed queries)
against nomic required code changes at every hardcoded seam; now it's a
config flip plus a reembed_embeddings run.
- EMBEDDING_DIM env (default 768) replaces every hardcoded dim check:
daily summary / calendar / search / location DAOs, Ollama batch
validation, reembed_embeddings
- entities gains the dim guard it never had — a wrong-dim vector
silently kills dedup/recall (cosine over mismatched lengths is 0),
so store None and warn instead
- embed_query / embed_document split with EMBED_QUERY_PREFIX /
EMBED_DOCUMENT_PREFIX (literal \n expanded): retrieval models treat
the two sides differently — nomic wants search_query:/search_document:,
Qwen3 wants Instruct:...\nQuery: on queries only. All query-side
call sites and all corpus writers now declare their side.
- document the contract in CLAUDE.md: change the model or any of these
vars → re-run reembed_embeddings or search is garbage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The history-truncation budget assumed an 8192-token context whenever a
chat request omitted num_ctx, while the llama-swap chat slots serve
20k-131k. Replayed transcripts past ~6k tokens were silently gutted
every turn — losing conversation history and destroying llama.cpp
KV-cache prefix reuse (full SWA re-prefill per turn).
Default is now 32768 (real conversations top out around 16k), with
AGENTIC_CHAT_DEFAULT_NUM_CTX to override per deploy, floored at
headroom + 1024. Explicit per-request num_ctx still wins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long insights are chunked + synthesized server-side and can run past the shared
180s chat/embedding client timeout, causing spurious timeouts. /tts/speech now
uses a per-request timeout from LLAMA_SWAP_TTS_REQUEST_TIMEOUT_SECONDS
(default 600), overriding the client default without affecting chat/embeddings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync CLAUDE.md with the Chatterbox TTS feature: the /tts/* endpoints and the
LLAMA_SWAP_TTS_MODEL / _VOICE / _REF_SECONDS env vars (only need LLAMA_SWAP_URL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
llamacpp models now receive images directly instead of
describe-then-inline. LLAMA_SWAP_VISION_MODEL defaults to the
primary model. Document the ResolvedBackend dispatch pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the per-request backend="llamacpp" value. Chat/vision/embedding
backend is now a deploy-time decision (LLM_BACKEND=ollama|llamacpp),
applied globally across chat, vision describe, and embeddings — so
embedding vectors stay in one space across the index.
- Per-request backend whitelist back to "local"|"hybrid". A request
arriving with backend="llamacpp" is rejected.
- LLM_BACKEND=llamacpp swaps the entire local stack to llama-swap:
chat hits the chat slot, describe hits the vision slot, embeddings
hit the embed slot. Hybrid mode still routes chat to OpenRouter
but uses LLM_BACKEND for the describe pass.
- Drops env vars HYBRID_VISION_BACKEND, LLAMA_SWAP_VISION_MODELS,
EMBEDDING_BACKEND (the last never shipped). Drops the
LlamaCppClient.vision_models allowlist — capability inference now
reports has_vision only for the configured vision_model slot.
- Drops the /insights/llamacpp/models handler. /insights/models is
the single endpoint; returns Ollama servers under LLM_BACKEND=ollama
and llama-swap slots (from LLAMA_SWAP_ALLOWED_MODELS) under
LLM_BACKEND=llamacpp. Same envelope shape either way.
- New ai::embed_one helper routes embeddings through llama-swap when
LLM_BACKEND=llamacpp (else Ollama). Wires it into the four
insight_generator embedding sites.
- Cross-replay matrix simplifies to pre-llamacpp shape (local↔local,
hybrid↔hybrid, hybrid→local allowed; local→hybrid rejected).
Wires a new LlamaCppClient (OpenAI-compatible /v1 wire format) alongside
OllamaClient and OpenRouterClient. Per-slot routing for chat/vision/embed
via env (LLAMA_SWAP_URL + *_MODEL vars); capability inference uses an
env allowlist since /v1/models doesn't report modality.
InsightGenerator + InsightChatService gain three-way dispatch on
chat_backend = "local" | "hybrid" | "llamacpp". Hybrid and llamacpp
share the describe-then-inline path (text-only chat after a separate
vision describe). HYBRID_VISION_BACKEND=llamacpp lets hybrid route its
describe pass through llama-swap's vision slot while chat still goes
to OpenRouter.
Cross-replay matrix added (validate_cross_replay): local<->llamacpp
and hybrid<->llamacpp allowed; local->hybrid and llamacpp->hybrid
rejected. New /insights/llamacpp/models handler mirrors the OpenRouter
shape.
The drain queried `date_taken IS NULL OR date_taken_source = 'fs_time'`
ORDER BY id ASC LIMIT 500 every watcher tick. The resolver is
deterministic on file bytes + filename + fs metadata, so any row that
landed on fs_time once landed there again on every retry — the drain
spun on the same lowest-id rows in perpetuity, never advancing to
rows 501+ while still logging more_remain=true.
Side effect: 500 auto-commit UPDATEs per tick sustained the SQLite
write lock long enough that other writers on separate DAO connections
hit the 5s busy_timeout. Manifested as intermittent 500s on
PATCH /image/faces/{id} that succeeded on retry.
Narrow the partial index and query predicate to `date_taken IS NULL`.
If exiftool installs or a new filename regex lands, an operator can
re-resolve fs_time rows out-of-band rather than re-introducing the
steady-state churn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>