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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
Rework mobile navigation into a proper left slide-in drawer with a dimmed
backdrop, sharing a new SidebarContent component with the desktop sidebar so
mobile users can reach Admin/Scheduler/Sign out. The sidebar now loads only the
10 most recent conversations; a dedicated /conversations page browses the full
list with server-side search and "Load more" pagination.
- Backend: limit/offset/search query params on GET /api/conversation/
- SidebarContent shared by desktop sidebar and mobile drawer
- useConversationBrowser hook powers the dedicated page
- Remove duplicated inline conversation lists from ChatScreen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strip the card, border, shadow, and accent bar from AnswerBubble so
Simba's replies read like Claude/ChatGPT — plain full-width markdown on
the page. User messages stay bubbled for turn contrast. Widen the reading
column to max-w-3xl with roomier spacing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Give each conversation a deep-linkable URL (/c/:conversationId) so threads
survive refresh, support browser back/forward, and are shareable within a
session. The app was already an SPA but had no router.
- Add react-router-dom; wrap the app in BrowserRouter and define routes
("/" for the empty/new state, "/c/:conversationId" for a thread)
- Make the URL the source of truth for selection: selectedConversation is
derived from the :conversationId param; create/delete navigate instead of
mutating local selection state
- Load a thread's messages in a URL-keyed effect that clears immediately for
an instant switch, with a stale-response guard
- Preserve the optimistic message + in-flight stream when the first message
lazily creates a conversation and navigates into it (skip-ref keyed on the
new id, guarded against the null/home-route collision)
No backend changes: app.py already serves index.html for unmatched paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frontend:
- Add stop-generation button (wire up the unused AbortController)
- Surface stream/network errors in a dismissible banner instead of console
- Add rename and delete for conversations via a per-thread menu
- Make simba mode a single controlled source; fix miscast keydown handler
- Auto-grow the message textarea; remove dead ConversationMenu component
Backend:
- Scope get/query/stream-query/rename/delete conversation routes to the
authenticated owner via a shared _get_owned_conversation helper (fixes IDOR)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>