Commit Graph

350 Commits

Author SHA1 Message Date
Ryan Chen 19aa2b17d7 Fix _build_prompt receiving state dict instead of messages list
LangGraph passes the full agent state dict {"messages": [...]} to the
prompt callable, not just the messages list. Iterating a dict yields
its keys, so "messages" (the string) was getting appended to the LLM
input — causing the agent to respond as if the literal word "messages"
was the user's query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 23:10:22 -04:00
Ryan Chen 43e0de24d6 Restore dynamic system prompt via RunnableLambda prompt parameter
Uses prompt=_build_prompt (a RunnableLambda) so LangGraph passes the
runtime config through, allowing per-request system_prompt injection.
Restores user memory loading for web and iMessage plain-text enforcement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 23:04:43 -04:00
Ryan Chen 2a81ea8f76 Fix: migrate all remaining main_agent imports to current_app
email, scheduled_messages scheduler, and cron blueprints still imported
main_agent directly from agents. All now use current_app.main_agent so
they read the initialized value set by build_agent() in the lifespan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 22:59:13 -04:00
Ryan Chen 810d32dcc3 Fix: store agent on app object, access via current_app
Global module variable was unreliable across import boundaries. Storing
the compiled agent on app.main_agent and reading it via current_app is
the idiomatic Quart pattern and guaranteed to reflect the initialized value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 22:55:39 -04:00
Ryan Chen 6621a99149 Fix: access main_agent via module reference to avoid None at import time
from-import captures the None value set at module load; importing the module
and accessing .main_agent at call time gets the value set by build_agent().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 22:51:18 -04:00
Ryan Chen 801a90746b Fix: replace deprecated state_modifier with prompt in create_react_agent
state_modifier was removed in this version of LangGraph; use the prompt
parameter with a static system prompt string instead. Dynamic memory
injection via configurable is deferred — memories are still saved by the
save_user_memory tool, just not pre-loaded into the system prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 22:43:39 -04:00
Ryan Chen 202baa17e2 Fix: use langgraph.prebuilt.create_react_agent for state_modifier support
langchain.agents.create_agent does not accept state_modifier or checkpointer;
those are LangGraph-only params available on create_react_agent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 22:37:11 -04:00
Ryan Chen f1ab1accaf Add LangGraph SQLite checkpointing and cron_jobs migration
Replaces per-request history reconstruction with a persistent AsyncSqliteSaver
checkpointer keyed by conversation ID, so agent state (tool calls, intermediate
steps) carries forward between turns without manual message list building.
Also includes the missing migration for the CronJob model added in bb4619a.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 22:34:04 -04:00
Ryan Chen bb4619aefe Add Cronicle-driven cron jobs with local migration workflow
Adds a new cron blueprint with UUID-keyed jobs that Cronicle triggers via
HTTP POST with a shared secret header. Also adds scripts/migrate-new.sh so
migrations can be generated locally against a Docker postgres when no prod
container is running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 19:10:15 -04:00
Ryan Chen 82f16bfd6a Add Google Calendar and Gmail integration via Python SDK
Replaces the gws CLI subprocess approach with google-api-python-client.
Adds get_calendar_events, create_calendar_event, gmail_search_emails, and
gmail_get_email as LangChain tools; all admin-gated and auto-enabled when
token.json is present. Includes uv-runnable scripts/google_auth.py for
one-time OAuth2 setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 08:43:18 -04:00
Ryan Chen 7912c83ca3 Instruct LLM to avoid markdown formatting in iMessage responses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-15 23:24:43 -04:00
Ryan Chen bc19f52052 Fix iMessage webhook timeout: ack SendBlue immediately, process in background
Move LLM invocation into a background asyncio task so the webhook returns 200
before SendBlue's timeout triggers retries. Validation, conversation setup, and
user message persistence still happen synchronously before the ack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-15 23:21:20 -04:00
ryan 0122e018ac Add migration: message_handle on conversation_messages 2026-09-15 23:15:30 -04:00
Ryan Chen 7474055c97 Add iMessage threading, typing indicators, and fix tool-use response extraction
- Native reply threading: inbound replies continue their conversation via
  SendBlue message_handle lookup; standalone messages create a new conversation
- Our responses are sent with reply_to so they thread visually in iMessage
- Typing indicator fires before agent invocation and stops in a finally block
- Add extract_agent_text() to handle list-type AIMessage content and strip
  <think> tokens from Qwen3 — applied to iMessage, WhatsApp, and web chat
- ConversationMessage gains a nullable message_handle field (migration needed)
- /threads command lists recent iMessage conversations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-15 23:10:50 -04:00
Ryan Chen 0478ee7568 Add Prometheus metrics endpoint for chat response observability
Tracks end-to-end response time and request counts (by stream mode and
result) via prometheus_client. Exposes GET /metrics behind a bearer
token (METRICS_TOKEN env var) so Prometheus can scrape it without
exposing raw data publicly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-15 22:57:12 -04:00
Ryan Chen 46a4b874bd Fix iMessage agent: pass user config and load memories
The iMessage webhook called main_agent.ainvoke without a config, so
save_user_memory always raised KeyError('user_id') when invoked. The
system prompt instructs the agent to save memories proactively, causing
repeated tool failures and ultimately returning the error fallback to
the user on most messages.

Fix:
- Pass agent_config with user_id/is_admin to ainvoke, matching the web
  endpoint.
- Extract _build_system_prompt_with_memories out of the conversation
  blueprint __init__ into memory.py (public name) so both the web
  endpoint and iMessage share the same implementation, giving iMessage
  users access to their saved memories.
- Remove the leftover debug print() in add_message_to_conversation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-15 22:57:04 -04:00
Ryan Chen 9777eaf9e1 Fix ThinkingBubble position: render above streaming answer not below
The ThinkingBubble was rendering after the messages list, so it appeared
below the partial answer while streaming. Inject it inside the messages
map just before the last streaming simba message so it sits above the
answer text. Keep the post-list fallback for when thinking is active
but no content has arrived yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 16:43:48 -04:00
Ryan Chen bb2103e81e Stream qwen3 thinking tokens with collapsible UI panel
Route <think>…</think> content from the llama-server stream into
thinking_delta SSE events instead of discarding it. A small look-ahead
buffer in _route_content() handles tags split across chunk boundaries.
Strip thinking tags from the persisted final_message so the DB stores
only the actual response.

Frontend: ThinkingBubble component accumulates the live reasoning text
in a scrollable panel that auto-collapses to "Thought for Xs" once the
first content token arrives. Falls back to the existing heartbeat
behaviour for models that don't emit <think> tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 16:26:25 -04:00
Ryan Chen 2a1f125fa5 Temporary debug: log chunk.additional_kwargs during think phase
Log the first non-empty additional_kwargs seen during the pre-content
thinking window to confirm whether llama-server surfaces reasoning_content
for qwen3. Will be removed after verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 16:14:35 -04:00
Ryan Chen 8fd6fa9ece Add TTFT watchdog to detect and surface LLM stalls
When llama-server is queue-backed it accepts the connection but never
produces tokens; after ~60s Caddy drops the connection and the frontend
clears loading state with no response and no error shown to the user.

Backend: replace the async-for event loop with an explicit __anext__()
iterator wrapped in asyncio.wait_for(). A 30s timeout (TTFT_TIMEOUT_SECONDS)
applies only in the window between on_chat_model_start and the first content
token — tool calls are excluded. On timeout, emits a stalled error event and
logs it rather than waiting for the proxy to kill the connection.

Frontend: separates stall errors from generic errors; shows a "Simba didn't
respond in time. Retry" pill that re-sends the original query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 16:10:44 -04:00
ryan 3875ed4e4f Merge pull request 'Add structured timing logs to stream-query for slow-request diagnosis' (#51) from stream-observability into main
Reviewed-on: #51
2026-09-12 15:48:32 -04:00
Ryan Chen c071fd12e1 Add structured timing log to stream_query for slow-request diagnosis
Every SSE request emits a JSON summary line on completion, cancellation,
or error:

  {"event":"stream_request","conv":"...","user":"ryan",
   "total_ms":45210,"thinking_ms":38100,"ttft_ms":38200,
   "tokens":112,"tools":[{"tool":"web_search","ms":820}],
   "model":"llama","cancelled":false}

Fields:
- total_ms    end-to-end wall time
- thinking_ms time from first LLM call to first content token (reasoning)
- ttft_ms     time to first content token from request start
- tokens      approximate content token count (stream deltas)
- tools       each tool invoked with wall-clock duration
- model       LLM name from LangChain metadata (catches fallback to OpenAI)
- cancelled   true when client disconnected mid-stream

Tool timers use the LangChain run_id to correctly pair start/end even
when the same tool is called multiple times in one turn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 15:48:19 -04:00
ryan 6427b71043 Merge pull request 'Show a thinking indicator while the model reasons' (#49) from agent-image-and-context-fixes into main
Reviewed-on: #49
2026-09-12 15:30:37 -04:00
Ryan Chen b132efe6a0 Don't cancel stream on navigation away
Aborting the fetch on component unmount sent a disconnect to the server,
which cancelled the in-flight generation. Now the stream runs to
completion in the background (isMountedRef guards prevent stale state
updates), the response is persisted, and it's there when the user
returns to the conversation. stopGeneration() still aborts immediately
on explicit user cancel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 15:29:53 -04:00
Ryan Chen d7639d9d0a Show a thinking indicator while the model reasons
qwen3.8-27b is a reasoning model: it can think for hundreds of tokens
before any visible content streams. The SSE stream only forwarded
content deltas, so the UI showed dead air for the whole think and long
answers looked hung (users cancel ~1 min in).

The stream now emits a throttled {"type": "thinking"} heartbeat while
the current model turn has produced no content yet, and the frontend
renders a pulsing "Thinking..." pill until content or a tool call
arrives. The heartbeat also keeps bytes flowing on the SSE connection
during long thinks, so idle-timeout disconnects in the proxy chain
can't kill the stream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 15:28:43 -04:00
ryan a591cfa343 Merge pull request 'Match llama-server speed and context handling for images and multi-turn chat' (#48) from agent-image-and-context-fixes into main
Reviewed-on: #48
2026-09-12 15:15:24 -04:00
Ryan Chen ca37c4a780 Fix history roles and persist tool results for cross-turn context
Two fixes for the agent losing track of conversation context:

- History replay compared msg.speaker (a Speaker enum) to the string
  "user", which is always false, so every prior message — including the
  user's — was sent to the LLM with role "assistant". Compare against
  Speaker.USER instead. Affected web, iMessage, WhatsApp, and email.

- Tool outputs (web searches, RAG chunks) were discarded after each
  turn, so follow-up questions about fetched content had nothing to
  reference. Persist them as speaker="tool" messages (capped at 4000
  chars) and replay them in history as assistant context. The
  conversation GET endpoint serves only their header line so the UI
  tool pill stays compact.

Also bumps the replayed history window from 10 to 20 messages since
tool messages now share it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 15:14:33 -04:00
Ryan Chen 7527ef3adf Pass images directly to the agent as multimodal content
Image messages previously ran two sequential LLM generations: a full
non-streamed vision call to describe the image, then a second agent call
with the description pasted in as text. The agent's llama-server model is
already vision-capable, so attach the image to the user message as an
OpenAI-style image_url content part and let the single agent call see it.
Roughly halves image-turn latency and streams from the start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 15:13:47 -04:00
ryan a172a71a70 Merge pull request 'Normalize image to JPEG before vision analysis (fixes HEIF/HEIC support)' (#46) from imessage-image-upload into main
Reviewed-on: #46
2026-09-12 14:13:07 -04:00
Ryan Chen 600791ea5d Normalize image to JPEG before vision analysis (fixes HEIF/HEIC support)
analyze_user_image now runs input bytes through Pillow before base64-
encoding, converting HEIC/HEIF and any other non-JPEG format to JPEG.
pillow_heif is already registered at module load so HEIC opens cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 14:12:26 -04:00
ryan 0212058677 Merge pull request 'Support image uploads via iMessage' (#45) from imessage-image-upload into main
Reviewed-on: #45
2026-09-12 14:11:02 -04:00
Ryan Chen 684460903f Support image uploads via iMessage (SendBlue media_url)
When SendBlue delivers a message with a media_url, download the image
and pass it through analyze_user_image so the agent receives a text
description alongside any caption. Image-only messages (no caption) are
now accepted; previously they were rejected as missing content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-12 14:08:25 -04:00
ryan 0a0df266c2 Merge pull request 'Allow pasting images into the chat input' (#44) from paste-image-upload into main
Reviewed-on: #44
2026-09-11 17:26:02 -04:00
Ryan Chen 1e4942e0b8 Allow pasting images into the chat input 2026-09-11 17:25:13 -04:00
ryan 2988482791 Merge pull request 'Add read timeouts to llama-server clients so hangs fail fast' (#43) from llama-timeouts into main
Reviewed-on: #43
2026-09-11 17:18:15 -04:00
Ryan Chen 56ad5f7046 Document git workflow (Gitea, tea CLI, kermit = commit) 2026-09-11 17:13:11 -04:00
Ryan Chen b361377088 Add read timeouts to llama-server clients so hangs fail fast
The OpenAI client defaults to a 600s read timeout, so a wedged
llama-server blocked requests for 10 minutes and never raised, which
meant with_fallbacks() to OpenAI never triggered. Set a per-chunk read
timeout (120s, configurable via LLAMA_TIMEOUT_SECONDS) plus one retry
on the ChatOpenAI and LLMClient clients. Context is preserved because
conversation history is rebuilt from Postgres on every request.
2026-09-11 17:05:10 -04:00
ryan ce461b6ff9 Merge pull request 'Fix missing React import in AnswerBubble' (#42) from fix-answer-bubble-react-import into main
Reviewed-on: #42
2026-09-11 16:41:15 -04:00
Ryan Chen bddd741fcc Fix missing React import in AnswerBubble 2026-09-11 16:40:48 -04:00
ryan 2db7e6f2e5 Merge pull request 'Copy button + hover highlight on assistant messages' (#41) from copy-answer-messages into main
Reviewed-on: #41
2026-09-11 16:28:52 -04:00
Ryan Chen b23fd19c4b Copy button + hover highlight on assistant messages 2026-09-11 16:27:51 -04:00
ryan 4e3842495f Merge pull request 'Update CLAUDE.md to reflect current repo state' (#40) from update-claude-md into main
Reviewed-on: #40
2026-09-11 16:15:59 -04:00
Ryan Chen 434c394651 Update CLAUDE.md to reflect current repo state
Adds the email/iMessage/WhatsApp/scheduled-message blueprints,
simbakit subproject, household integration utils (YNAB, Mealie,
Obsidian, S3, Immich), and corrects environment variable names to
match .env.example.
2026-09-11 16:14:19 -04:00
Ryan Chen 20d44bb3b3 Autofocus textarea on new conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-10 14:14:59 -04:00
Ryan Chen d8d01a5fff Zero-downtime blue-green deploys behind Caddy health checks
Split the raggr service into raggr-blue (8080) and raggr-green (8081)
behind compose profiles, add a /api/health endpoint and container
healthcheck, and add scripts/deploy.sh to build the inactive color,
wait for it to become healthy, then gracefully stop the old one.
Makefile targets resolve the active color automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 01:41:37 -04:00
Ryan Chen 3318839551 Sort conversations by last activity
Bump Conversation.updated_at on every message (not just the first) so
actively-used conversations rise to the top of the list endpoint's
existing -updated_at sort. Refresh the sidebar after every send so the
just-used conversation reorders live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 01:33:44 -04:00
Ryan Chen 4fe5b8daea Persist streamed assistant answers even if the client disconnects
The first message in a new conversation navigated to /c/:id, which remounted
ChatScreen (two separate routes) and aborted the in-flight /stream-query fetch.
Server-side that surfaced as asyncio.CancelledError, which isn't an Exception,
so the except branch missed it and skipped the add_message_to_conversation
save — the fully-streamed answer was never persisted.

Backend: handle CancelledError and persist via a detached, shielded task so a
disconnect at any point (navigation, tab close, network blip) still commits the
answer. Frontend: serve "/" and "/c/:id" from a single catch-all ChatScreen
route so first-message navigation no longer remounts and aborts the stream;
derive the conversation id via useMatch instead of useParams.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 01:33:44 -04:00
ryan 53f76f58ea Merge pull request 'Stream assistant answers token-by-token; mobile drawer + conversation pagination' (#39) from mobile-drawer-conversation-pagination into main
Reviewed-on: #39
2026-09-09 23:15:14 -04:00
Ryan Chen b918522816 Stream assistant answers token-by-token over SSE
The /stream-query endpoint previously emitted only tool-lifecycle events plus
one final complete message, so the answer still appeared all at once. Handle
on_chat_model_stream events to emit incremental `content` deltas, accumulate
them for persistence fallback, and consume them on the frontend to build a live
assistant bubble (reconciled with the authoritative final text). Hide the
redundant loading indicator once tokens start arriving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 23:14:08 -04:00
ryan eb8cd19e46 Merge pull request 'Mobile slide-in drawer + conversation pagination' (#38) from mobile-drawer-conversation-pagination into main
Reviewed-on: #38
2026-09-09 22:32:37 -04:00