Commit Graph
193 Commits
Author SHA1 Message Date
Cameron CordesandClaude Opus 5.5 431c3a7492 Every error response in the { error, code, details? } format (step 2)
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>
2026-11-01 13:29:13 -05:00
Cameron CordesandClaude Opus 5.5 ccead2d7c0 chore: keep real names out of code and tests
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>
2026-10-30 15:06:21 -04:00
Cameron CordesandClaude Opus 5.5 080a75c336 feat(versions): link at ingest, hide in listings, version endpoints
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>
2026-10-30 11:28:34 -04:00
Cameron CordesandClaude Opus 5.5 09b9e87396 docs: document llm.toml and llama-swap metadata discovery
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>
2026-10-26 17:40:42 -04:00
Cameron CordesandClaude Opus 5.5 d68d9b09f3 feat(ai): warn at startup when llm.toml disagrees with llama-swap
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>
2026-10-26 17:37:05 -04:00
Cameron CordesandClaude Opus 5.5 e10fc26ca7 feat(ai): discover the llama-swap picker and vision from model metadata
/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>
2026-10-26 17:34:28 -04:00
Cameron CordesandClaude Opus 5.5 aa3536afee refactor(ai): read LLM settings from LlmConfig instead of env
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>
2026-10-26 17:30:24 -04:00
Cameron CordesandClaude Opus 5.5 5b8a99a2b2 feat(ai): LlmConfig loaded from llm.toml with legacy env overrides
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>
2026-10-26 17:26:32 -04:00
Cameron CordesandClaude Opus 5.5 e194c02393 fix(db): stop 'set sqlite pragmas' lock flake on concurrent connects
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>
2026-10-21 10:16:14 -04:00
Cameron CordesandClaude Opus 5.5 e029c81274 fix(persona-chat): review fixes before merge
- 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>
2026-10-20 19:38:08 -04:00
Cameron CordesandClaude Opus 5.5 ba39628a0d feat(insight-chat): allow switching a local photo chat to hybrid
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>
2026-10-18 21:00:05 -04:00
Cameron CordesandClaude Opus 5.5 cc11884272 fix(openrouter): never send image data to OpenRouter
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>
2026-10-18 21:00:05 -04:00
Cameron CordesandClaude Opus 5.5 edfa48d116 feat(ai): date_info tool for exact date arithmetic
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>
2026-10-18 20:33:00 -04:00
Cameron CordesandClaude Opus 5.5 7215c1bdfe fix(persona-chat): don't offer describe_photo in persona chats
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>
2026-10-18 20:17:03 -04:00
Cameron CordesandClaude Opus 5.5 3f1bf42f1c feat(persona-chat): practice mode — play the contact
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>
2026-10-17 18:39:27 -04:00
Cameron CordesandClaude Opus 5.5 4ac574d4d2 feat(persona-chat): continue an SMS thread with a persona
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>
2026-10-17 18:07:49 -04:00
Cameron CordesandClaude Opus 5.5 640cd3833a refactor: compile the crate once — main.rs uses the library
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>
2026-10-17 16:03:33 +00:00
Cameron CordesandClaude Opus 5.5 dc90407afb feat(insights): read location context from Apollo (#177)
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>
2026-10-17 16:03:06 +00:00
Cameron CordesandClaude Opus 5.5 b95adb4f77 fix(images): pick the decoder from the bytes, not the extension
`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>
2026-10-17 04:24:02 +00:00
Cameron CordesandClaude Opus 5.5 e75e60786d fix(clip): stop re-sending undecodable files every pass
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>
2026-10-17 00:20:00 -04:00
Cameron CordesandClaude Opus 5.5 305eda5894 fix(chat): budget chat history against the model's served context window
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>
2026-10-15 19:21:04 -04:00
Cameron CordesandClaude Opus 5.5 19fbf43d40 feat(dates): photo date filters on the true instant (#152)
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>
2026-10-10 21:52:48 -04:00
Cameron CordesandClaude Opus 5.5 f510940819 Tracing: carry span into insight panic-isolation tasks; root the watcher boot probe (#143)
Found by the parity capture: both insight-generation handlers run the
generation inside a nested tokio::task::spawn (panic isolation) that was
not instrumented, so the background ai.insight.* trace still split off
from its 202 request. The watcher's boot-time availability probe ran
before the first tick span, leaving its count_for_library DB spans as
roots; it now opens `watcher.initial_probe`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:13:36 -04:00
Cameron CordesandClaude Opus 5.5 b6deca2a52 Tracing: DAO layer + handler spans on tracing, drop all Context params (#141)
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>
2026-09-29 17:02:40 -04:00
Cameron CordesandClaude Opus 5.5 1c508cdabd Tracing: LLM clients + streaming spans on tracing, drop cx threading (#140)
LlmClient methods no longer take an OpenTelemetry Context. llm.request is
now a tracing span (otel.kind=client, llm.provider/operation/model) that
parents on the caller's ambient span; traceparent injection reads the
span's OTel context via OpenTelemetrySpanExt.

The src/ai feature spans (handlers, insight generation, chat, persona
chat, daily summaries, TTS) move to #[instrument] / info_span!, and every
LLM-bearing tokio::spawn carries its parent span with .instrument(): the
streaming chat turn (chat_turn_stream_inner), async chat / persona turns,
background insight jobs and TTS jobs. Sub-spans in the agentic loops
(ai.chat.loop, ai.agentic.loop) instrument each LLM / tool await.

DAO calls keep their explicit Context (derived from the tracing span)
until the DAO migration (#141).

tests/otel_llm_spans.rs pins nesting + traceparent for the blocking and
streaming paths across a spawn on a multi-thread runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00
Cameron Cordes f850c535f1 Otel: thread request context through streaming chat path (#129 review)
The legacy SSE streaming path (chat_turn_stream -> run_streaming_turn ->
run_bootstrap/continuation_streaming) built its LLM spans from
Context::current(), which is always the empty root context here: the codebase
threads OTel contexts explicitly and never attaches one, there is no
context-aware tokio layer, and the turn runs on a tokio::spawn. So every
llm.request span on that path was an orphaned root span, uncorrelated with the
HTTP request.

Thread the handler's extracted request context down through
chat_turn_stream -> chat_turn_stream_inner -> run_streaming_turn ->
run_bootstrap_streaming / run_continuation_streaming, and use it for the
describe_image and agentic-loop calls. Mirrors the non-streaming path, which
already threads parent_cx -> loop_cx.

Also rename the block-scoped db 'cx' in run_streaming_turn to db_cx so it no
longer shadows the new parameter.
2026-09-28 21:41:38 -04:00
Cameron Cordes 10ed84d7b4 Otel: dedupe LLM span error/status + traceparent helpers (#130 follow-up)
Addresses the P2 refactoring follow-ups parked on issue #130:

- otel::with_traceparent(builder, &SpanContext) — one shared W3C
  traceparent injector. Replaces the 4-line inline header loop in
  llamacpp (3x) and openrouter (3x) and removes ollama's private
  inject_traceparent method (4 call sites now use the shared helper).
  Takes &SpanContext (not &dyn Span) because the Span trait is not dyn
  compatible in opentelemetry 0.31.

- otel::record_http_failure(span, status, message) and
  otel::set_http_status(span, status) — collapse the repeated
  'set http.status_code attribute + set_status(Status::error)'
  boilerplate across all three clients (~10 sites).

Net -45 lines. The remaining if-let-Some(span.as_mut()) guards (token
attributes, parse-failure status) stay inline rather than growing a
SpanHandle wrapper type for a pattern that's already readable.
2026-09-28 10:35:28 -04:00
Cameron Cordes 696d9fa8f3 Otel: llm.request client spans + traceparent on LLM calls (#130)
Add OpenTelemetry tracing to every LLM client call so an insight/chat
turn is a single trace from HTTP entry to each downstream inference.

- LlmClient trait: thread a &opentelemetry::Context through all 5
  methods (generate, chat_with_tools, chat_with_tools_stream,
  generate_embeddings, describe_image), matching the DAO convention of
  explicit context threading rather than ambient Context::current().
- otel::start_llm_request_span: single shared helper that every
  provider (openrouter / llamacpp / ollama) funnels through, so the
  span shape is identical: name llm.request, SpanKind::Client,
  llm.provider / llm.operation / llm.model.
- Per-method spans in all three clients with the spec's operation enum
  (chat | chat_stream | embeddings | describe_image | generate); stream
  spans are owned and moved into the async_stream generator so they end
  when the response drains, not when headers arrive.
- llm.tokens.input / llm.tokens.output recorded on non-stream and
  stream completion; llm.cost_usd from usage.cost (OpenRouter);
  http.status_code + error status on failure.
- W3C traceparent injected on outgoing LLM HTTP requests (llama-swap,
  Ollama, OpenRouter) gated on span_context.is_valid() so debug
  no-op-tracer builds don't send an all-zero parent.
- Turn-level spans (ai.chat.turn.execute, ai.persona_chat.turn.execute)
  now carry llm.backend + llm.model; with_entry streaming paths thread
  the turn context (turn_cx) to child describe_image / agentic-loop
  calls instead of the ambient context.

clippy clean; 690 lib + 775 integration + 4 doc tests green.
2026-09-28 01:21:46 -04:00
Cameron Cordes a699c940b8 Prefs: owner defaults (model, backend, persona_id, reel span/length)
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.
2026-09-27 19:48:45 -04:00
Cameron Cordes af87765bbb Switch read paths to the true-instant date contract (ticket #119)
- New src/date_view.rs: pure read-side functions (row_true_instant,
  row_display_wall, row_display_day, filename_true_instant) applying the
  per-source convention table for unconverted rows and the new-contract
  triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
  display zone (prefs -> app default), not the client device offset.
  SQL is a coarse interval pre-filter over the full years-back window;
  exact month-day/week/month matching across past years happens in Rust
  (memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
  fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
  tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
  proximity timestamp and chat context block use the true instant;
  InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
  legacy date_taken.

Closes #119
2026-09-26 19:28:26 -04:00
Cameron Cordes a97bf53603 feat(dates): filename date class — per-pattern conventions + filename-over-EXIF priority (#116)
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.
2026-09-25 16:54:46 -04:00
Cameron Cordes d2263be6ac feat(dates): container video dates, filesystem branch, manual edit (#115)
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.
2026-09-25 15:26:26 -04:00
Cameron Cordes 2f2107d4fa feat(dates): foundation for the true-instant date contract (#114)
Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):

- camera_tz module: offset cascade (offset tag -> GPS zone -> server
  zone) as a pure, testable function; bundled offline TZF geometry for
  GPS -> IANA resolution; DST-correct offsets for the capture date;
  display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
  display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
  the winning date tag; resolve_date_taken takes an ExifPrior and
  emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
  branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
  -> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
  camera_offset_minutes / date_taken_tz_source and
  user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
  legacy pair; watcher, upload, and set_image_gps call sites feed the
  cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
  /image/metadata and /photos/exif expose it (null for unconverted
  rows); reel requests mirror display_tz into prefs.

cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
2026-09-24 21:58:46 -04:00
Cameron CordesandClaude Opus 5.5 33fe7ce77c feat(openrouter): cap max_tokens per request (OPENROUTER_MAX_TOKENS)
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>
2026-09-22 21:32:56 -04:00
Cameron Cordes 4e3ee1973c feat(agentic-loop): retry transient knowledge-write locks + re-open tools on loop exhaustion
Transcript analysis (359 insights / ~3.3k tool results) showed 31
"database is locked" errors from knowledge-memory tools and 50/359
runs (14%) exhausting the 6-iteration budget with no final answer.
The exhaustion nudge re-invoked chat with an EMPTY tool list, so a
model that still needed information could not call anything.

- utils: add retry_with_backoff_if — same exponential backoff + jitter
  as retry_with_backoff, but retries only while the predicate classifies
  the error as transient; deterministic failures short-circuit.
- insight_generator: wrap the four knowledge write tools
  (store_fact/upsert_fact, store_entity/upsert_entity, update_fact,
  supersede_fact) plus the store_fact photo-link upsert in
  retry_with_backoff_if gated on a SQLITE_BUSY detector ("database is
  locked"/"database is busy" massage in DbError.source). WAL +
  busy_timeout absorb most contention; a parallel insight run or the
  face-watch write storm still surfaces it, and a short backoff retry
  clears it instead of surfacing a hard error to the model.
- insight_generator: exhaustion nudge now re-offers the full tool set
  instead of vec![] and explicitly says the model may call more tools;
  if it does take one, results are collected and a bounded tool-less
  turn forces the actual answer so we never store an empty insight.
  Same treatment for the read-only tool loop.
- models/knowledge_dao: derive Clone on InsertEntity, InsertEntityFact,
  InsertEntityPhotoLink and FactPatch so retried FnMut closures can
  re-issue the same write value on each attempt.
2026-09-21 19:24:28 -04:00
Cameron CordesandClaude Opus 5 65cceaa67c feat: multiple persona conversations with branching and generated titles
Persona chat stored a flat Vec<ChatMessage> keyed on (user_id, persona_id),
which meant one rolling transcript per persona and no way to revisit a turn.
This moves it onto the same ChatHistoryStore tree the file chat uses and
gives conversations their own identity.

Storage
- messages_json now holds a serialized ChatHistoryStore. Reads accept the
  old flat array and upgrade it in place, so existing transcripts survive
  without a data migration. The upgrade drops the v1 seed greeting, which
  the flat renderer hid but the tree renderer would surface as a bubble the
  user has never seen.
- New migration re-keys persona_chat_conversations on an opaque
  conversation_id and adds title + created_at, so one persona can hold any
  number of separate threads. Every DAO read and write is scoped by user_id
  as well: a conversation id is a bearer token for someone's transcript and
  must never grant access on its own.
- The per-conversation lock and in-flight turn slot key on conversation_id,
  so two threads with the same persona can run turns concurrently.

Endpoints
- POST/DELETE /persona_chat/conversations — start and remove a thread,
  replacing /persona_chat/reset.
- GET /persona_chat/conversations — the chat list, with snippet and counts
  derived from each tree's active branch.
- POST /persona_chat/rewind, POST /persona_chat/switch-branch,
  GET /persona_chat/branches — rewind and fork, mirroring the file chat.
  Index 0 is rewindable here (it is the user's own first question, not a
  synthetic prompt) and re-anchors on the seed node.
- history/turn/rewind/switch-branch/branches all key on conversation_id;
  history gained branch_id and now returns real fork_info, active_leaf_id
  and viewing_branch_id instead of placeholders.

The turn body no longer carries a persona at all — it is read from the
stored conversation, so a stale client cannot swap a thread's voice midway.

Titles
After the first turn persists, the conversation is named from its opening
exchange on the same backend the turn ran on. Small models wrap titles in
quotes, prefix them with "Title:" and append explanations, so sanitize_title
strips all of that and truncates on a character boundary. Any failure falls
back to the user's opening question. Generation runs after persistence: a
failed title must not cost the turn.

Fixes found along the way
- insight_chat: both file-chat turn paths captured path.len() before
  apply_context_budget drained messages out of the middle, then sliced
  messages[path_len..] for the new tree nodes. Once truncation fired that
  dropped the user turn from the tree or panicked on an out-of-range start
  index. Now read after the budget pass as history_len.
- Persona chat had no context budget at all and hardcoded truncated: false
  in the done frame, so a rolling transcript grew unbounded.
- A cancelled turn persisted a half-finished transcript and pushed a second
  terminal frame; it now returns early like the file chat.
- The seeded system prompt was frozen at conversation creation, so editing a
  persona never reached a thread already in flight. Re-resolved per turn.
- turn_count was overwritten each write with the per-turn message delta;
  it is now the cumulative assistant-turn count on the active branch.
- is_initial is always false: the file chat reserves it for its synthetic
  "describe this photo" prompt, and marking a persona chat's first question
  with it made the opening reply impossible to regenerate.

600 lib tests pass, clippy --all-targets clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:23:10 -04:00
Cameron Cordes 883e2a0e1b fix: send persona_chat wire shapes in snake_case like file chat
The mobile client dispatches POST /persona_chat/turn with snake_case
keys (persona_id, user_message, num_ctx, ...) per the file-chat
convention, but the persona request/response structs carried camelCase
serde renames (personaId, userMessage, ...), so every dispatch 400'd
with 'missing field personaId'. Drop the renames from
PersonaChatTurnRequest, PersonaChatHistoryView, RenderedPersonaMessage,
and PersonaChatResetRequest — the persona wire shapes now match the
rest of the API (history query, 202 turn_id, SSE skip_before) — and
pin the contract with serialization round-trip tests.
2026-08-24 21:34:46 -04:00
Cameron Cordes 3b9d985025 feat: add /persona_chat endpoints for open chat with persona
- PersonaChatSession dispatches turns through the shared agent loop,
  persisting transcripts keyed on (user_id, persona_id) in the new
  persona_chat table (migration included).
- GET /persona_chat/history returns a rendered transcript (tool
  invocations folded, is_initial flag) matching the file-chat shape.
- POST /persona_chat/turn returns 202 with a turn_id; SSE replay and
  cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the
  actix-attributed handlers so both route families share the logic.
- POST /persona_chat/reset clears the persona transcript.
- Concurrent dispatches for the same (user, persona) are rejected with
  409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard
  drops with the spawned turn task, freeing the slot on completion,
  error, or abort.
2026-08-24 21:25:48 -04:00
Cameron Cordes 5e6bd69405 style: run cargo fmt 2026-08-08 13:26:21 -04:00
Cameron Cordes ef54f0d646 test: add nested fork regression tests
- branch_options_handles_nested_forks: verifies branch picker returns
  correct branches for both root fork and sub-fork
- render_tree_path_nested_forks_only_emits_at_divergence: confirms fork_info
  doesn't propagate downstream (only at actual divergence point)
2026-08-08 10:58:13 -04:00
Cameron Cordes a1a2d22b5b fix: only emit fork chip at actual divergence point
Changed last_fork.clone() to last_fork.take() in render_tree_path
for both assistant and user branches. This prevents fork_info from
propagating to every downstream message, ensuring the chip appears
only at the actual divergence point in the conversation tree.
2026-08-08 10:51:39 -04:00
Cameron CordesandClaude Opus 4.8 9ce7cbf3df Branch previews show the first differing message, not the shared child
Rewind & Regenerate resends the identical question, so both branches'
first child matched and every picker option read the same. Snippets now
walk the branches' visible messages (tool scaffolding skipped) in
lockstep and preview the first position where the contents diverge; a
branch that runs out beforehand falls back to its own last message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 20:07:31 -04:00
Cameron CordesandClaude Opus 4.8 ee8a11d421 Scope branch listings to a divergence point via node_id
ForkInfo now carries the divergence node's id, and the branches endpoint
accepts node_id + viewing_branch_id to return only the position-ranked
sibling branches at that fork (snippets taken from after the divergence,
skipping tool scaffolding) instead of every leaf in the tree. Options in
the same subtree as the viewing leaf anchor to that leaf so clients can
reliably identify "the branch I'm on". Tree-wide listing is unchanged
when node_id is absent.

- llm_client: descendant_leaves + branch_options_at helpers; optional
  position rank on BranchLeafInfo.
- insight_chat: ForkInfo.node_id set during render_tree_path; get_branches
  takes the scoping params.
- handlers: dedicated ChatBranchesQuery; unknown node_id maps to 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 19:39:57 -04:00
Cameron CordesandClaude Opus 4.8 1512fc5bdc Add conversation branching to insight chat (rewind preserves forks)
Store chat history as a tree in training_messages instead of a flat
array. Rewind now sets active_leaf_id to the target node rather than
truncating, so discarded paths survive as alternate branches. Fork
indicators ("X/Y") mark divergence points and let the client load or
switch to alternate paths.

- llm_client: StoredChatNode/ChatHistoryStore with tree traversal
  helpers (path_to_leaf, children_of, fork_at_node, leaves*, etc.) and
  from_flat_array for backward-compatible reads of the old flat format.
- insight_chat: load_history/chat_turn/rewind_history operate on the
  tree; new switch_branch and get_branches. render_tree_path walks every
  node (including non-rendered tool-dispatch nodes) when computing fork
  info, so a fork whose diverging child is a tool call still surfaces on
  the following rendered message.
- handlers: GET /insights/chat/branches, POST /insights/chat/switch-branch,
  and a branch_id param on the history endpoint.

Backward-compatible: flat arrays are converted to a tree lazily on read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 19:19:20 -04:00
Cameron Cordes 38aaddfd2a Retry insight persistence with exponential backoff to avoid SQLite lock contention
Add retry_with_backoff utility (100ms base, ±25% jitter, 3 attempts)
and replace all 11 direct DAO calls in insight_chat.rs and
insight_generator.rs. The Mutex guard drops between retries so the
maintenance connection can release its SQLite write lock.
2026-08-05 18:22:36 -04:00
Cameron CordesandClaude Opus 4.8 475072810e AI: add enable_thinking reasoning toggle plumbed to llama.cpp
New optional SamplingOverride forwarded to llama-server as
chat_template_kwargs.enable_thinking (gates Qwen3-style reasoning
blocks). None leaves the template default; other backends ignore it.
Wired through the agentic-insight and chat-turn request bodies/handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:39:16 -04:00
Cameron CordesandClaude Opus 4.8 e7f6dd56da clip_client: log encode_text failures (URL + status/body or network error)
The CLIP encode failure reason was only ever returned in the HTTP response
body, never logged server-side, making 502s from /photos/search opaque. Log
the underlying cause — network error to the URL, or the Apollo HTTP status +
response body — so CLIP-service problems are diagnosable from the ImageApi log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:39:16 -04:00
Cameron CordesandClaude Opus 4.8 fd00dc936a Unified NL search Phase 2: /photos/search/unified endpoint
Composes the two existing engines (Path A orchestration):
- Translate NL -> StructuredQuery via local LLM, respecting LLM_BACKEND
  (resolve_backend(Local) -> ollama or llama-swap; no hybrid).
- Forward-geocode the place name into a gps circle.
- Structured filters (tags/EXIF/geo/date/media) build a candidate set of EXIF
  rows; CLIP ranks within it, joined by content_hash. Degenerate cases match
  existing behavior: semantic-only -> plain CLIP; filters-only -> date-sorted.
- Echoes the interpreted query (incl. resolved place) for editable client chips.

Refactor: extracted reusable cores from clip_search (score_photos, resolve_hits,
parse_library_scope, score_error_response) shared by both endpoints. Removed the
Phase 1 allow-until-wired attributes now that nl_query + geo are consumed.

fmt + clippy clean; 23 backend tests pass (7 geo, 12 nl_query, 4 unified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:39:16 -04:00
Cameron CordesandClaude Opus 4.8 09ccb347c7 Unified NL search Phase 1: NL→structured-query translator + forward geocoding
Foundation for the /photos/search/unified endpoint (Phase 2). Two new,
fully unit-tested pieces, not yet wired into a route (allow-until-wired,
mirroring llm_client.rs):

- ai/nl_query.rs: translate a free-text query into a StructuredQuery via one
  grounded LLM call. Two-stage — the model emits names/ISO dates, then a pure
  resolve step maps tag names against the real vocab and converts dates to
  unix seconds. Hallucinated (non-vocab) tags are surfaced in unmatched_tags
  rather than silently used as hard filters — the anti-noise guard. 12 tests.

- geo::forward_geocode + bbox_to_circle: resolve a place name to a circle via
  Nominatim /search, collapsing the bounding box to centroid + circumscribing
  radius so "Portland" and "Italy" both map onto the existing gps circle
  filter with no schema change. Radius is the max centroid-to-corner distance
  (corners aren't equidistant on a sphere). 4 tests.

fmt + clippy clean; 19 new tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:39:16 -04:00
Cameron Cordes 5c9ee56527 Fix agentic reel audit issues: midnight bug, DAO wiring, dead code, DST timezone, validation
Blocking fixes:
- secs_until_next_run_hour: same-hour now returns 0 instead of 24h
- capture_prefs: called at both handler return points, never fails request
- capture_prefs: resolves library param, upserts to user_ai_prefs via DAO
- Scheduler: uses AppState DAOs instead of separate connections
- Pregen dedup: uses resolved library param instead of hardcoded 'all'
- run_readonly_tool_loop: added #[allow(dead_code)] (used in main.rs only)
- run_readonly_tool_loop: removed dead messages.push() call
- InsightGenerator: added exif_dao() getter for scheduler reuse

Medium fixes:
- Input validation: run_hour clamped 0-23, week_dow clamped 0-6
- DST-sensitive timezone: fixed_tz_offset() with env var config

Low fixes:
- Documented REEL_PREGEN_MAX_TOOL_ITERS and REEL_PREGEN_TZ_FIXED_MINUTES
- Removed dead test_app_state function and unused imports

Also fix: UpsertUserAiPrefs import path, chrono::Local::with_ymd_and_hms
requires TimeZone trait + .single(), unwrap_or_else closure simplification
2026-06-13 14:59:00 -04:00