setup-node's `cache: yarn` needs yarn already on PATH, and the Node it
installs does not ship one, so the frontend job failed at setup on every
run. Install the lockfile's Yarn 1.22.22 explicitly instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The container ran Obsidian Sync in pull-only mode, so notes, tasks and journal
entries the agent wrote stayed in its local copy. OBSIDIAN_SYNC_MODE now
defaults to bidirectional (merge on conflict); pull-only and mirror-remote
remain available.
Blue and green share the vault volume, and two sync clients writing one vault
and its sync state would race during a deploy. Setup and sync now run under a
flock on a file in the vault, so a new container starts syncing only after the
old container's sync process exits. Clearing Obsidian's stale sync lock is safe
inside that lock. Setup runs in the background, so startup no longer waits for
Obsidian login.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every graph step stores a full snapshot of the conversation, nothing pruned
old ones, and deleting a conversation left its thread behind (about 1.6 MB per
checkpoint at 200 turns). After each turn, only the newest
CHECKPOINT_KEEP_PER_THREAD (default 20) checkpoints of that thread are kept,
plus their pending writes; state reads only use the newest, and pruning a
200-checkpoint thread takes about 2 ms. Deleting a conversation also deletes
its thread, without failing the request if the checkpoint store errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The periodic sync walked the vault, read files and ran synchronous database
deletes on the event loop, freezing active chat streams while it ran. Those
blocking steps now run in threads. Deletes use JSONB containment, which the
cmetadata GIN index (jsonb_path_ops) supports, and are batched into one
transaction per pass. Deleted files leave the index cache only after their
chunks are deleted, so a failed delete is retried on the next sync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compaction replaces a tool's text with its artifact, and the search artifact
only carries UI snippets, so the full chunk the retriever found never reached
the model. Search tools now attach the full text as model_context, which
compaction prefers and shortens under budget pressure while keeping source
ids; the persisted artifact and UI are unchanged. The per-chunk header drops
the raw metadata dict (about a fifth of search output tokens) for title, date
and source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local model is decode-bound, so output length drives latency. The system
prompt asked for "thorough, detailed answers" and to "think things through",
which lengthened both answers and reasoning. It now leads with the answer and
expands only on request. Per-integration tool lists that duplicated the bound
tool docstrings collapse to one paragraph of routing and duplicate-avoidance
rules. The prompt drops from about 1,446 to 897 tokens and stays static, so
the llama-server prompt cache still hits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/conversation/image/<key> presigned any S3 key for any logged-in
user, and /upload-image wrote into whatever conversation_id the form named.
Both now require the conversation in the key (or form) to belong to the
caller and return 404 otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No committed migration creates the table: migrations 6-10 were generated
outside the repository, so fresh databases fail once anything alters it.
The new migration is CREATE TABLE IF NOT EXISTS, a no-op where the table
already exists. Aerich cannot generate it because its saved state already
includes the table; the DDL is Tortoise's schema output for the model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
while a durable run owns the conversation.
Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
ordered SSE log by cursor, so generation survives refresh and device switches.
Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.
Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
Legacy sent rows are not forked into new series. Malformed bodies return 400.
History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.
Migration 20 is generated by aerich.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Fail startup on unsafe or malformed production settings (scripts/validate_config.py),
run it before migrations, and stop on a failed `aerich upgrade`.
- Forward the context-budget, timeout and metrics settings through Compose; accept
the historical TAVILIY_API_KEY spelling.
- Build the image from uv.lock and yarn.lock in a multi-stage Dockerfile.
- Add Gitea Actions CI: Ruff, unit tests, context evaluation, frontend tests,
typecheck and build.
- Update README and deployment/development docs to match the current stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ledger supersedes the "Earlier conversation excerpts" message, as on
main; sending both duplicated dropped-turn content. Removal is consistent
whenever the ledger is included, so the cached prefix stays stable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The summary ledger is regenerated every turn. At the head of history it
invalidated the cached retained turns on every request once trimming
started. Insert it immediately before the current turn's HumanMessage so
system, tools and retained history stay byte-identical; excerpts of dropped
turns stay at the head since they only change when the cut moves.
Reserve a fixed ledger ceiling (SUMMARY_RESERVE_TOKENS) rather than its
current size, so a shrinking ledger cannot pull the cut back and change the
retained prefix; fall back to the actual size for tight budgets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
llama-server only reuses a byte-identical prompt prefix. Several things
changed the prefix every turn, forcing most of the prompt to be re-prefilled:
- Memories: rank still chooses which memories fit the budget, but the chosen
ones are emitted in stored order, so the block is identical across queries.
- History trimming: the conversation-evidence summary is only included when
turns were actually dropped (it duplicated retained turns otherwise). On
overflow, old turns are dropped at stable cut points down to ~65% of the
budget, so the retained prefix holds for several turns instead of shifting
by one turn every request.
- Tools: TOOL_ROUTING_MODE=static (default) binds the full role-appropriate
tool set in registration order with no discovery tool; admin gating is
unchanged. TOOL_ROUTING_MODE=dynamic keeps the keyword router.
- Observability: per-call model_calls in the stream_request log now include
cached_prompt_tokens when the provider reports cache reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Postgres had only primary keys on these tables, so loading a
conversation, listing conversations, checking for a first message on
every send, and fetching memories/artifacts were sequential scans.
Migration generated with aerich; index-only apart from the no-op JSONB
re-casts aerich emits on every run (as in migrations 16 and 18), which
were checked not to rewrite tables. Upgrade and downgrade verified on a
throwaway pgvector database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
process_image takes ~560ms on a 12MP photo and image_content_block
decodes HEIC/JPEG with PIL; both ran synchronously inside async
handlers, freezing every concurrent chat stream. Run them via
asyncio.to_thread in the upload handlers, the chat stream, and iMessage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent tools call ensure_fresh(120s) but the background loop ran every
900s, so most finance questions waited on an inline YNAB fetch. Default
the loop to 90s (40 requests/hour, well under YNAB's 200/hour limit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
index_documents sent one embedding request and one insert per chunk.
Send chunks in batches of 32 and, only when a batch fails, retry it one
chunk at a time so the rejected chunk is still named in the logs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
token.json is mounted :ro, so writing refreshed creds back raised
PermissionError at startup once the stored access token expired. The broad
except in agents.py then disabled Calendar and Gmail tools entirely.
Keep the refreshed creds in memory when the write fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 30s time-to-first-token guard measured from the turn's first model call
to the first answer token, so thinking, tool execution and the second call's
prefill all counted against it. Long reasoning on a long conversation was
cancelled mid-generation (llama-server: stop: cancel task at n_gen=1287).
- stream_query: the timeout now fires only when an in-flight model call goes
silent for TTFT_TIMEOUT_SECONDS. Any chunk (thinking or empty) resets the
clock, each model call starts a fresh one, and time spent in tools between
calls doesn't count.
- Trim replayed history (blueprints/conversation/context.py): tool results
and user messages from earlier turns are capped at 4000 chars (attachments
included) and their images replaced by a placeholder. The current turn is
untouched. The window no longer starts with orphaned ToolMessages.
- Log per-call prefill time, duration and prompt/output tokens
(stream_usage=True) plus max_prompt_tokens in the stream_request line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beside the panel (lg and up) it still covers only the chat column. Below
lg the panel is a fixed z-40 sheet, so the overlay covers the window at
z-[45] (under the z-50 mobile drawer) instead of being hidden by it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- extract_text (PyMuPDF for PDFs) now runs via asyncio.to_thread in both
/upload and stream-query, so a large PDF doesn't stall other streams.
- /upload returns 422 when extraction yields no text: a scanned PDF
without a text layer, or an empty text file.
- Mock server: *scan*.pdf uploads return that 422.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- pendingImage becomes pendingAttachment (image or PDF/TXT/MD/CSV), uploaded
through POST /api/conversation/upload and sent as image_key or file_key.
- MessageInput shows an icon/name/size chip for files, an inline error for
unsupported or oversized files, and a paperclip picker.
- DropOverlay covers the chat area with "Drop to attach" while files are
dragged over the window; drags without files are ignored.
- QuestionBubble renders a saved trailing "📎 name" as a chip.
- test-fixtures/cowork/mock-server.mjs: dependency-free mock API (static
dist/, conversations, contract-shaped SSE, uploads, artifacts) for
Playwright runs without Docker or OIDC.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tauri v2 intercepts drag-and-drop by default, so the chat's drop overlay
never saw dropped files. Disable its handler on the main window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>