CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to epicurus are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`v0.1.0` is the first release — the first version usable on a server with a UI.
A release is cut by pushing a semver tag (`git tag v0.1.0 && git push origin
v0.1.0`); GitHub Actions then publishes the GitHub Release and versioned container
images to GHCR.
## [Unreleased]
- **Web search through OpenRouter, once an OpenRouter key is stored** (#984) — web search ran
only through the bundled SearXNG, whose engines are blocked or rate-limited on many home-lab
IPs (#920, #936). The websearch module's settings on the Modules page now have a **Search
provider** choice: SearXNG (the default, unchanged) or **OpenRouter web search**. The
OpenRouter option is greyed out, with a hint to add the key on the Models page, until the
tenant's OpenRouter key is stored; the core refuses the same save without one (409
`provider_key_required`), and the form shows the core's sentence rather than
"[object Object]". With OpenRouter chosen, `web_search` asks the core — new `POST
/platform/v1/web-search`, `PlatformClient.web_search` — which runs one search through
OpenRouter's `openrouter:web_search` server tool (the documented replacement for the
deprecated `web` plugin and `:online` suffix) with the tenant's key, turns the `url_citation`
annotations into `{title, url, snippet, engine}` results, and meters it under the caller's
tenant (`llm.usage` gains `web_search_requests`). The key never reaches the module. Results
get the same chips, hover-cards (engine "OpenRouter"), dedupe and three outcomes as SearXNG's;
a missing key is a plain "no search ran, add a key or switch back" message, never an empty
result, and there is no silent fallback to SearXNG; an OpenRouter failure (bad key, no
credits, unreachable) reaches the model through the tool-error seam with OpenRouter's own
reason. Two new core settings, on Compose and the chart alike: `OPENROUTER_WEB_SEARCH_MODEL`
(`openai/gpt-4.1-nano`) and `OPENROUTER_WEB_SEARCH_ENGINE` (`exa`), chart
`core.openrouterWebSearch.{model,engine}`. `/status` gains `backend` and
`openrouter_last_result`. This also fixes a general defect: a module's Modules-page settings
were stored by the core and **never delivered** to the module. `epicurus-core` gains
`PlatformClient.get_module_config` and `ModuleConfigCache` (15-second cache, last-good on
failure), and websearch now honours its stored max-results and engines at runtime; the other
modules with a settings form (echo, knowledge, storage) are a follow-up. `web_search`'s
`num_results` is now optional (`null` = the configured max). `core-app` 0.130.0→0.131.0
(MINOR) · `epicurus-core` 0.41.0→0.42.0 (MINOR) · `websearch` 0.4.0→0.5.0 (MINOR) · `web`
0.152.0→0.153.0 (MINOR) · chart 0.3.0→0.4.0 (MINOR).
- **Sign-in on Compose and Kubernetes, and how to set it up** (#969) — the core's new OpenID
Connect sign-in only helps if every way of running epicurus can turn it on, and if an operator
can find out how. On **Compose** the core-app fragment now passes `AUTH_MODE`,
`AUTH_SESSION_DAYS` and every `OIDC_*` key through from `.env`, with the core's own defaults —
and the switches and the day count arrive as `false` / `30`, never blank, because a blank
boolean is a start-up error, not "unset"; `.env.example` gains a commented Pocket ID block.
On **Kubernetes** the chart gains an `auth:` block. A release that says nothing about it
renders exactly one env entry more than before — `AUTH_MODE=none` — and a test proves it
against the chart with the sign-in wiring cut out, on the default, published and hosted-only
shapes. `oidc` renders every key, the client id from a value or a Secret and the client
secret only from one (`auth.oidc.existingSecret`, else the shared Secret; optional, since a
public client has none), and the render **refuses** what the core would refuse to start with:
no issuer, an issuer or public URL that is not an absolute http(s) URL, no client-id source,
or no admission rule — an empty allowlist, a list of blanks and `--set-string …=false`
included, because against Google an empty allowlist would admit the internet. The Kubernetes
smoke gate turns sign-in on through these values (`helm upgrade --reuse-values`), so the
chart's own path is what boots. A sign-in variable in `core.extraEnv` fails the render too, rather than emit a
duplicate env entry that server-side apply (Flux) rejects. `NOTES.txt` prints the callback to
register, flags a plain-http one, and warns when a release publishes an Ingress with sign-in
off. The new [sign-in guide](docs/infrastructure/sign-in.md) walks through Pocket ID on both
platforms (the Secret created without touching shell history, SOPS for GitOps), the admission
rules, Authentik / Keycloak / Authelia / Kanidm / Google, the phone PWA, sign-out and a
troubleshooting table keyed by every `auth_error` code — and says plainly what sign-in does
not cover: the published internal ports and the Compose gateway's `<module>.localhost`
routes. So the remote-access recipes now point `tailscale serve` and Caddy at the web shell
(`8084` / `web:8080`) instead of the gateway, and present built-in sign-in as the option to
use beside a network perimeter. chart 0.2.1→0.3.0 (MINOR).
- **A sign-in screen for OpenID Connect sign-in — and the OAuth callback reaches the core
again** (#969) — with the core's sign-in on, the web shell now asks `GET
/platform/v1/auth/session` before it mounts anything, so a signed-out browser sees a sign-in
screen instead of a page of refused requests: the ε mark and one button, "Sign in with
{provider}" (or "Sign in"), a centred card on a desktop and a full-height screen with a tall,
thumb-reach button and safe-area insets on a phone. The button sends the window to the core's
login route with a `next` that brings it back to exactly where it was — the screen a session
ran out on, a share waiting at `/?share=1`, a notification's deep link. A failed sign-in comes
back as `auth_error`, and every code reads as a sentence that says what to do ("your account
isn't allowed to use this epicurus — ask whoever runs it to add you"; "the operator needs the
`groups` scope"); the raw value is never shown, and the parameter is removed from the address
bar. The operator's auto-redirect sends a signed-out visitor straight to the provider, with a
30-second loop guard that shows the button instead of bouncing a browser that came back still
signed out. Any 401 from the platform — every fetch, both stream readers, the archive upload —
returns to the sign-in screen, is never retried, and never lights the "can't reach epicurus"
banner. Settings gains an **Account** card with who is signed in and **Sign out**, which holds
auto-redirect off in that tab so signing out sticks. With sign-in off — the default — none of
it shows, and a core that is down or predates sign-in opens the app exactly as before. The
change also fixes a real bug: the service worker answered **every** top-level navigation with
the cached app shell, `/platform/` included, so on any device that had installed the PWA the
**connected-account OAuth callback** (`/platform/v1/oauth/callback`, Google's redirect back)
never reached the core and connecting Google silently failed; navigations to `/platform/` now
go to the network, which the sign-in routes need too. The dev and preview proxies add
`X-Forwarded-*` (`xfwd`), so `npm run dev` sees the core's enforcement as production does.
`web` 0.151.0 → 0.152.0 (MINOR).
- **Sign in with an OpenID Connect provider** (#969) — epicurus had no sign-in of its own:
whoever reached the web shell reached `/platform/v1/*`, which is everything — chats, memory,
mail, calendar, files, provider keys — and the documented ways to close that (basic auth in
Caddy, oauth2-proxy) all sat outside the app, so it never knew who was using it and a phone PWA
met a browser password prompt. The core is now an **OpenID Connect relying party**
(`AUTH_MODE=oidc`): authorization code flow with PKCE, a `state` bound to an HttpOnly
transaction cookie, a `nonce` bound to an ID token verified against the provider's JWKS with the
algorithm pinned to asymmetric ones the provider advertises, userinfo merged in, and an
admission rule — allowlisted emails (never an unverified one), allowlisted groups, or an explicit
`OIDC_ALLOW_ALL_USERS=true`, because an empty allowlist pointed at a public provider would admit
the internet. Four endpoints under `/platform/v1/auth` (`session`, `login`, `callback`,
`logout`); every failed sign-in lands back on the shell as `/?auth_error=<code>` from a closed
set of ten, never on a JSON page. Sessions are server-side — an opaque 256-bit cookie, only its
SHA-256 stored, sliding at most hourly — in two new tables (revision `0007`) that a tenant
archive deliberately leaves behind. The enforcement is a **trust boundary**, not a login wall: a
request that came through a proxy (the web shell's nginx, an ingress, the Compose gateway)
needs a session or gets a 401, and an unsafe one must also pass a cross-site check (403) because
`SameSite=Lax` does not stop a sibling subdomain; a module calling the platform API directly on
the internal network is untouched, so no module changed. The core **refuses to start**
half-configured, naming everything missing in one line, yet never contacts the provider at
boot — discovery is lazy, so a provider that is down cannot take the platform with it. Both
smoke gates now end with sign-in on and prove the web door answers 401 while module ↔ core
traffic still flows, on Compose and on Kubernetes. `AUTH_MODE=none` — the default — is the
platform exactly as it was. `core-app` 0.129.0 → 0.130.0 (MINOR).
- **MinIO images now pull from a registry that still serves them anonymously** (#973) — Quay
followed Docker Hub (#934) and started refusing anonymous pulls of `quay.io/minio/minio` and
`quay.io/minio/mc` — a `401` on the whole repository, not one tag — so every fresh
`compose up`, the `quality` gate's testcontainers suites, `runtime-smoke`, and `k8s-smoke`
all failed the same way. `docker.io/pgsty/minio` and `docker.io/pgsty/mc` are a
**community-maintained fork** of the AGPL MinIO server and client (published by Pigsty — not
MinIO Inc. and not a Docker Hub verified publisher) that still serves anonymously with pinned,
multi-arch (amd64/arm64) release tags; the Compose fragment, the chart's `minio.image` / `minio.initImage`
defaults, the testcontainers pins, and the docs now point there
(`RELEASE.2026-08-04T00-00-00Z` / `RELEASE.2026-09-16T00-00-00Z`). Entrypoints, `Cmd`, and
environment surface are unchanged from upstream, so nothing else moves. An operator who
overrode MinIO to `quay.io` in their own values must switch too — that source is now
anonymous-401 for everyone. Chart 0.2.0→0.2.1 (PATCH); no component bump.
- **A hosted-only deployment runs no local runtime** (#962) — running with hosted chat and
hosted embeddings and *no* Ollama was a documented capability that nothing actually supported.
The Helm chart refused to render it (`ollama.enabled: false` demanded an external URL), Compose
could not express it (an unconditional fragment and a hard `depends_on`), and an operator who
forced it with a placeholder URL met a core that collapsed three different facts into one:
**absent** (no runtime configured — a deliberate choice), **unreachable** (one is configured
and does not answer) and **ok**. `GET /platform/v1/llm/models` **500ed**, on a page that polls
it every ten seconds; pull and delete 500ed the same way; the chat warm-up indicator reported
the model "warming" forever, on every turn, for the life of the deployment; and startup spent
180 seconds polling an address that was never going to answer. Absence is now a first-class
mode, spelled `OLLAMA_URL=""`: the model list answers **200 and an empty array** in both
non-serving states, the new `GET /platform/v1/llm/local-runtime` says which one applies, the
local-only actions (pull, delete, unload, the KV-cache setting) refuse with **409** and a
sentence naming the mode — and pull and delete answer **502** when a configured runtime is
unreachable, never a bare 500 — readiness reports the model as **n/a** instead of warming,
and the bootstrap logs one line
and returns. A local model id asked to serve is refused before any provider call with the
capability error the rest of the gateway already speaks (ADR-0140), so a hosted embedding model
keeps working while a bare one fails with the fix in the message instead of a connection error.
On **Kubernetes** the chart renders an empty `OLLAMA_URL`, blanks `LLM_BOOTSTRAP_MODELS` on its
own, and gains the guard actually worth having — it refuses to render a runtime-less release
whose chat or embedding default is still a bare local name, naming the hosted alias to set. On
**Compose** it is an opt-out overlay — `task hosted-only-up` — which removes the Ollama
services *and* blanks `OLLAMA_URL` in one step, the same idiom as the Docker-socket and
external-mount opt-ins; the documented install (`git clone`, `docker compose up -d`, no
`.env`) is byte-identical and still starts the local runtime, which a compose profile on
those services would have quietly stopped doing. Three gates cover it: `compose-validate`
proves both the default stack and the overlay resolve, `chart-validate` renders the
hosted-only release *and* proves the guard refuses the half-working one, and `k8s-smoke`
upgrades a live release into the mode and asserts the core answers. `core-app`
0.128.0→0.129.0 (MINOR), chart 0.1.2→0.2.0 (MINOR).
- **A deployment with no local AI says so once, instead of warning forever** (#962) — the web
shell had two inline strings for a dead local runtime ("The local runtime is unreachable — is
the ollama service up?", "local runtime unreachable") and rendered every other Ollama control —
the pull card, the catalog, the KV-cache card, the context-window card, the `Local (Ollama)`
optgroup — as though a runtime existed. On a deployment that deliberately runs **none** (hosted
chat, hosted embeddings, `OLLAMA_URL=""`) that reads as a fault the operator is supposed to fix,
when in fact nothing is wrong and there is nothing to fix. The shell now reads the runtime's
state from `GET /platform/v1/llm/local-runtime` — never from a failing model list, which since
this change answers `[]` with a 200 in *both* unhappy states and so carries no information at
all — and when that state is `absent` the local half of the Models page collapses into one
line: local AI is not configured on this deployment, hosted models are unaffected. The five
Ollama cards are removed rather than disabled, the embedding picker drops its local group so an
unrunnable model cannot be chosen, the chat picker drops its `Local` heading while keeping the
core-default row (that default may itself be hosted), the first-run welcome stops offering a
pull, and a module's model slot with nothing left to offer says why instead of looking broken.
`unreachable` keeps today's warning, because that state *is* an error and must still look like
one, and an older core with no such endpoint keeps today's behaviour exactly.
`web` 0.150.0→0.151.0 (MINOR).
- **The bound on a turn is the operator's; runaway is caught by behaviour** (#925) — the
**Agent cycles** setting stopped at 12, and the route enforced it *silently*: type 40 and 12 was
stored. A genuinely long task — search → read → read → summarize → write — ran out of rounds and
handed back a truncated answer with nothing saying why, and there was no way to give it more.
The ceiling is gone: the pref and `AGENT_MAX_STEPS` are floored at 1 and otherwise taken as
written, and the Settings copy names the real trade-off (time and tokens) instead of a range.
What actually keeps a confused model from looping forever is the loop's behaviour, so the
existing guards became rules you can state: **three identical tool calls in a row** end the turn,
counted per run of repeats — every fresh repetition now gets the same allowance, rather than the
whole turn sharing one nudge — the three-consecutive-tool-errors stop is unchanged, and a new
**per-turn wall-clock budget** (`AGENT_TURN_DEADLINE_S`) is the last backstop, **off by
default**, because a deadline can kill exactly the long turn this change exists to allow. When
it does fire the turn still answers, and the answer *says* it stopped early: the final round is
told the time ran out, so the caveat reaches the web, the chat bridges and the automation log
alike rather than depending on each surface to render a stop reason. Because a turn may now
honestly run for minutes, it reports where it is — every `tool` stream frame carries
`round`/`max_rounds`, and the finished turn carries `rounds`/`max_rounds` — so the activity
indicator reads "Working… · round 7 of 40" instead of spinning with nothing to show. A SaaS tier
that wants a ceiling imposes it from the overlay, where tier policy belongs. `core-app`
0.127.0→0.128.0 (MINOR), `web` 0.149.0→0.150.0 (MINOR).
- **A failed reply always says so, and never in the provider's words** (#944, #947) — a streaming
turn that died *after* it had started answering left the transcript with a sentence that simply
stopped: no banner, no note, just Copy and Regenerate under half a reply. The terminal error
event now fires on that path too, the message itself is persisted as interrupted so the mark
survives a reload (or a re-attach that lands on history rather than on the failing stream), and
a re-attach that gives up says the connection was lost and the reply may be incomplete instead
of going quiet. The other half is what the banner *says*: a hosted model's refusal used to be
rendered verbatim, which put the provider's raw JSON — account identifier included — in the
browser. Every failure is now described in a sentence written here, naming the model and the
provider it was routed to and quoting the provider's own message only when that message survives
a redaction test; the raw exception stays in the log, at ERROR, once. A new
`epicurus_core_llm_stream_failures_total{tenant,reason}` counter gives the observability stack
something to alert on. `core-app` 0.126.0→0.127.0 (MINOR), `web` 0.148.0→0.149.0 (MINOR).
- **A dimension change is healed or named, never silently degraded** (#944 part 2, #879, #860) —
switch the embedding model to one with a different output size and every vector already stored
becomes unqueryable. Cross-chat memory was supposed to repair itself when that happened; on the
owner's install it did not, and said nothing: the reconcile met a vector configuration it did
not recognise, resolved no width, repaired nothing — and still cached the collection as
repaired, so every later recall repeated the same rejected query while the assistant quietly
answered with no memory at all. The cache now records a width that was actually **confirmed**,
a configuration it cannot read is raised instead of shrugged off, and the rebuild is shielded
from the recall time-box, so a corpus too large to re-embed inside that budget finishes healing
instead of being restarted and cancelled on every turn. What cannot be healed is now *said*:
recall logs the two widths and the cure, `knowledge` search answers "the embedding model
changed — run Re-embed everything" instead of forwarding Qdrant's raw dimension error, and the
Models page shows the stuck state on the embedding card and names the cure — which, for
recall memory, is the **Memory facts re-embed** maintenance job rather than "Re-embed
everything" (that one fans out to the modules only). The
same card stops calling a **refused** re-embed a failure: a module that declines to rebuild
from a source reading empty is protecting your data, and now says so, with its reason, in its
own third state. `core-app` 0.125.0→0.126.0 (MINOR) · `web` 0.147.0→0.148.0 (MINOR) ·
`knowledge` 0.31.0→0.31.2 (PATCH) · `notes` 0.15.0→0.15.2 (PATCH) — the wave's ladder
reserved a patch each for a lane that turned out not to touch them.
- **A model's capabilities are known, not assumed — and a turn that cannot work says so before
it starts** (#944, #947, #879; ADR-0140). Two dogfood failures with one cause: the core knew
nothing about a model beyond "is it hosted". An embedding model starred as the chat default
took every turn straight to an opaque provider 400, and a hosted model served without tool
calling ended a "hi" in a red card holding the provider's raw JSON — account identifier and
all. A saved hosted model now carries a **role** (chat / embedding / unknown) and a **tool
capability**, each resolved as *operator override → what the gateway learned from the provider
→ the shipped catalogue*, and the Models page's Capabilities block gained the two controls
beside Image input. A model whose role is known and wrong is refused **before any provider
call**, by one gate every entry point shares — chat, stream and embed alike — with a sentence
naming the single action that fixes it, and the same rule guards the write, so the bad default
cannot be set from the star in the first place. Tool support is **learned rather than guessed**:
whether a model accepts a tool list is a property of the deployment serving it, so on a
provider's refusal the core records it for that tenant, says why at WARNING without quoting the
payload, and **retries the same turn once without tools** — the turn answers, and the shell says
the model can only chat, for a hosted model as well as a local one. The tool-less turn is also
told it has no tools, so it stops narrating actions it never took. Two long-standing Models-page
nits go with it: a provider-only `claude/` no longer reads as a hosted id in the browser when
the core says otherwise, and an id the shipped catalogue has never heard of now says
**unlisted** instead of showing an empty chip. `core-app` 0.124.3→0.125.0 (MINOR), `web`
0.146.0→0.147.0 (MINOR).
- **`auto` model bootstrap now seeds an empty runtime only, instead of every restart** (#923,
ADR-0118 amendment) — "first-boot" was a docstring, not a guard: the bootstrap diffed the
effective chat + embedding defaults against the local runtime on *every* start, so a model
deleted on the Models page was silently pulled back on the next restart — routine on both
Compose and Kubernetes (a rollout-restart, ADR-0134). `auto` now no-ops the instant `/api/tags`
reports any installed model at all — logged at INFO with the count — and never resolves the
defaults again; a from-scratch install still gets its defaults exactly as ADR-0118 intends. An
explicit `LLM_BOOTSTRAP_MODELS` list is unchanged: a stated pin, ensured on every start
regardless of what else is installed. `core-app` 0.124.2→0.124.3 (PATCH).
- **The portability seam's last two gaps: a bodyless crash, and a remove/apply race** (#918) —
an unhandled exception inside a module's `/import` route used to escape as Starlette's default
500 (`text/plain`, no JSON), so a module's own crash reached the operator's report line as
nothing at all and the only recourse was `docker compose logs`; `add_portability_routes` now
catches it and answers with a `detail` naming it, the same courtesy a deliberate refusal
already got. `#903`'s null-normalisation helper (`TableSpec.encode`/`.normalize`) existed as
two identical copies in `calendar` and the core's own `core_data`; it is promoted once into
`epicurus_core.PortableTable`, and both adopt it — the other five portable modules
(`tasks`, `notes`, `knowledge`, `storage`, `mail`) go through a domain store with explicit
per-field defaults and never had the defect to begin with, so there was nothing there to
adopt. And a `DELETE .../imports/{id}` racing a `POST .../apply` for the same job could delete
the row (and the staging directory the applier was about to open) between the apply's read and
its write; `PortabilityService` now holds one lock per job id so the two requests serialize.
`epicurus-core` 0.40.0→0.41.0 (MINOR), `core-app` 0.124.1→0.124.2 (PATCH),
`calendar` 0.22.0→0.22.1 (PATCH).
- **A degraded web search no longer looks like an empty one** (#936, #920) — SearXNG can answer
`200 {"results": [], "unresponsive_engines": [...]}` when every engine it asked timed out,
was blocked, or got rate-limited, and `web_search` used to discard that field, making a
genuinely empty query indistinguishable from a search instance quietly failing. The tool now
tells the three outcomes apart — results, genuinely empty, degraded — logs a WARNING naming
the unresponsive engines, and `GET /status` gains a `degraded` flag reflecting the most recent
search rather than a separate probe. `web_search` also stopped swallowing every exception into
a clean `[]`: a SearXNG failure now reaches the model as an actionable message through the
ADR-0136 tool-error seam, which is why `httpx.HTTPStatusError`/`TransportError` joined
`epicurus-core`'s anticipated-exception set in the same change — a provider HTTP error is
expected traffic, not a crash, so it logs at WARNING instead of ERROR-with-traceback (fixing
the same misclassification for mail's Gmail client along the way). The default agent prompt
now says plainly when search is unavailable instead of narrating "no results" and quietly
falling back to training data. `epicurus-core` 0.39.0→0.40.0 (MINOR), `websearch`
0.3.1→0.4.0 (MINOR), `core-app` 0.124.0→0.124.1 (PATCH).
- **The backup archives your files, and the gates stop grading themselves on a curve**
(#919, #895, #864) — three quiet failures of the same kind: something reported success while
doing less than it said. `infra/backups/backup.sh` looped over two volumes that had not
existed since the file space moved to `epicurus-files`, and it never archived
`epicurus-files` itself — the core's `/data`, and the bytes behind every knowledge document
and note. A backup taken before this holds no user files at all, and it exited 0. The loop
is now correct, the file space is archived whether it lives in the named volume or in the
host directory `EPICURUS_FILES_ROOT` points at, a restore puts it back wherever *this*
machine keeps it, the `valkey` cache is no longer archived (nothing in the codebase reads
it), and a repo test holds both scripts to the volumes the compose files really declare — so
the next stale name fails CI instead of silently shrinking a backup. **Take a fresh backup.**
On the CI side the two smoke gates now assert the same things on both runtimes: the Compose
gate was never starting the web shell (so nginx's runtime-derived resolver was gated on
Kubernetes only), neither gate checked that the MinIO bucket seed succeeded, the Kubernetes
gate had no Ollama workload so the restart arm of the container-runtime seam — and the chart
Role's `statefulsets` verb — never ran, nothing proved a file write reaches the cluster's
RWO volume, and every cluster boot was a *fresh* install, the one shape that cannot show an
upgrade defect. All five are closed, the gate-parity test no longer passes on needles that
match a comment, Helm's download is checksummed like every other pinned tool, and the
JetStream integration suite is isolated by per-test stream and subject names instead of by
wiping a shared stream — which is what it had actually been failing on in three CI runs.
Version bumps: none — no shipped component changed.
- **The core's schema is migration-managed, and #903's `NULL` is fixed at the source** (#834,
#927) — core-app owns 40 tables, by far the biggest schema here, and built them at every
startup with 29 separate `create_all` + additive-reconcile calls, each wrapped in its own
"log it and carry on". One `run_migrations` call replaces all of them, and is deliberately not
wrapped: a database the core cannot reach now fails the boot, with a restart and a clear log
line, instead of bringing the core up with 29 error lines and no working feature. Two
**backfill revisions** finish what the old reconcile could not start:
`module_prefs.suggestions_enabled` — the column behind #903, where a preferences *write* on a
long-lived install aborted on a `NULL` the reconcile had no way to avoid leaving — and
`automations.agent_gated_delivery`, in exactly the same position. Existing rows get the value
every reader was already pretending they had, and both columns become `NOT NULL` for real. A
third revision repairs five column defaults that have been wrong on disk since they shipped
(`module_prefs.models` / `.disabled_tools` / `.collections`, `llm_prefs.hidden_models`,
`saved_models.added_at`): declared as plain strings, they compiled to a default whose value
included the quote characters when the table was created fresh, and to the intended value when
the reconcile added the column — one release, two different databases. Nothing user-visible
changes, and nothing about a schema change here is guesswork any more: a model edited without a
revision fails CI. `core-app` 0.123.0→0.124.0 (MINOR).
- **`mail` adopts the migration foundation** (#834, #932) — the five `mail_*` local-cache tables
(ADR-0096) are now schema-migration-managed like `storage`: a baseline revision, applied
in-process at startup under a Postgres advisory lock, replaces the old `create_all` +
`ensure_columns` reconcile. Mail turned out to need neither of the two per-service defects the
foundation warned about: it carries no plain-string `server_default` (only `func.now()`), and
every `default=`-without-`server_default=` NOT NULL column has existed since its table's
first release rather than being added later to a populated table, so the #903 backfill rule
finds nothing to write here either — both are recorded in the PR body's audit. `mail`
0.21.0→0.22.0 (MINOR).
- **`knowledge` schema is migration-managed** (#834, #931) — the fourth service to adopt the
migration foundation. Every model change now ships as an Alembic revision: startup runs
`run_migrations` once, before any store touches a row, and no store reconciles its own schema
on Postgres any more. The gate caught the same defect it found in `storage`: a *plain string*
`server_default` is a literal SQLAlchemy quotes for you, so `knowledge_suggestions.to_path`'s
`server_default="''"` compiled to `DEFAULT ''''''` (a default whose value carries two quote
characters) while the additive reconcile, pasting the same string in as raw SQL, produced the
real empty string — the model now says `text("''")`, and a revision normalises an existing
database. A further backfill-audit pass adds a matching database-level default to eight more
columns that carried a Python-side one only, closing the gap on principle even though none of
them was ever at risk of a `NULL` row. `knowledge` 0.30.0→0.31.0 (MINOR).
- **`notes` adopts the migration foundation** (#834, #930) — the note bodies, folders, version
history and the suggestion queue + its audit trail are now Alembic-managed, one baseline
revision applied at startup in place of the four `create_all` calls the lifespan used to make.
Unlike several other services, notes carried no plain-string `server_default` bug, so no
normalisation revision was needed; a backfill audit of its seven `default=`-without-
`server_default=` `NOT NULL` columns found none at risk — every one was introduced alongside
its table, never added to a populated one. `notes` 0.14.0→0.15.0 (MINOR).
- **`tasks`'s schema is migration-managed** (#834, #929) — the module adopts the Alembic
foundation (#926): a startup `run_migrations` call replaces `TaskStore`/`LeadTimePrefsStore`/
`FiredMarkerStore`'s `create_all` + additive-reconcile, under a Postgres advisory lock, across
the four tables its three `DeclarativeBase` objects own (`tasks_local`, `task_repeats`,
`tasks_lead_time_prefs`, `tasks_fired_markers`). The baseline revision absorbs the reconcile
that used to repair a pre-#218 `tasks_local` missing `status`/`priority`/`tags`/`repeat` on an
existing deployment. The backfill audit (#903's rule) found one `NOT NULL`
`default=`-without-`server_default=` column, `tasks_local.completed` — no revision needed, since
it has been part of the table since v1 and both its writers always set it explicitly, so no
deployment can have carried a `NULL` there. `tasks` 0.24.0→0.25.0 (MINOR).
- **`calendar`'s schema is migration-managed now** (#834, #928) — the second service to adopt
the Alembic foundation (#926), after `storage`. A baseline revision covers all four
`DeclarativeBase`s (six tables: the local event store, the lead-time preference, the
lead-time scheduler's fire-once markers, and the reconcile layer's sync cursor /
observed-event cache / self-write ledger); the startup reconcile calls
(`epicurus_core.db.ensure_columns`, `_ADDED_COLUMNS`) are gone from every store, replaced by
one `run_migrations` call in the lifespan. The backfill audit found **seven** `NOT NULL`
columns with a Python-side default and no server default — `calendar_events.{all_day,
excluded}` (which the old reconcile could only add *nullable* to a populated table, so an
upgraded deployment could genuinely hold `NULL` there, #903's shape of bug) and
`calendar_sync_state.collection` / `calendar_synced_event.{collection,title,all_day,
change_hash}` (always `NOT NULL` in practice, since those tables have no reconcile history) —
a revision backfills any real `NULL` and adds the server default to all seven. `calendar`
0.21.1→0.22.0 (MINOR).
- **Schema changes are real migrations now** (#834, #926) — schema was additive-only by design:
the startup reconcile could add a column and nothing else, so a rename, a retype or a backfill
was un-shippable, a `NOT NULL` column added without a server default reached existing rows as
`NULL` (the cause of #903), and no gate anywhere ever exercised an *upgrade* — the unit tests
build tables from the models and the smoke gates boot an empty database, so both production
failures that motivated this (#214, #218) were visible only on a deployment that had been
running a while. epicurus now uses **Alembic, one migration environment per service**, applied
in-process at startup under a Postgres advisory lock so two replicas or an overlapping restart
never migrate at once — the same on Compose and on Kubernetes. Each service keeps its own
version table (`alembic_version_<service>`), because all seven share one database and own
disjoint tables in it. A service's *baseline* revision is idempotent, so one `upgrade head`
serves an empty database, one built by the old reconcile, and one already at head — with no
branch and, unlike a stamp, no revision ever silently skipped. `storage` is migrated as the
reference; authoring is `task migrate:new` and `task migrate:check` (a second, on SQLite), and
a new **`migrations`** CI gate proves every migrated service on real Postgres: fresh install,
restart, adoption of a pre-Alembic database, and adoption of a *drifted* one. A model change
with no revision now fails review instead of production. The gate earned its keep on its very
first run: a *plain string* `server_default` is a literal SQLAlchemy quotes for you, so the
house pattern `server_default="'fs'"` has been compiling to `DEFAULT '''fs'''` — a default whose
value carries the quote characters — while the additive reconcile, pasting the same string in as
raw SQL, produced `DEFAULT 'fs'`: two deployments of the same release could disagree about their
own column defaults. Storage's is corrected and an existing database normalised by a revision —
the first change here the reconcile could never have made. The remaining services carry the same
pattern and each lane fixes its own. `epicurus-core` 0.38.0→0.39.0 (MINOR), `storage`
0.11.0→0.12.0 (MINOR).
- **Add a hosted model straight from the Models page** (#922) — the Hosted models card's
"None yet" copy used to send the operator to a chat's model picker just to save an id; now an
**Add a hosted model** row sits on the card itself, with a provider select drawn from what the
core actually reports (never the client's static alias list, so an unregistered provider can't
be picked), a model-id field that prepends the alias for you (OpenRouter's two-slash ids stay
intact), and an inline hint when the chosen provider's key is `missing`/`unavailable` — the
first place the core's `key_state` (#728) is rendered. `web` 0.145.0→0.146.0 (MINOR).
- **MinIO images now pull from Quay** — Docker Hub no longer serves the `minio/minio` and
`minio/mc` repositories (a `404` on the repository itself, not just the tag), so every
fresh `compose up`, the `runtime-smoke` and `k8s-smoke` gates, and a chart install failed
with `pull access denied for minio/minio`. The same pinned releases exist on Quay under
the same names, so the Compose fragment, the chart's `minio.image` / `minio.initImage`
defaults, and the docs now point at `quay.io/minio/minio` / `quay.io/minio/mc` — identical
digests, nothing else moves. An existing deployment that already holds the images keeps
running; it picks the new ref up on its next pull. The two testcontainers suites that
boot MinIO (`epicurus-core` `test_s3_round_trip`, storage's object-store tests) pin the
same Quay image instead of the library's Docker Hub default, so the `quality` gate pulls
from the same place. Chart 0.1.1→0.1.2 (PATCH); no component bump.
- **The Helm chart has now actually booted** (#894) — the chart shipped rendered and
schema-checked and never once started, which left a whole class of failure (a bad probe, an
unwritable mount, an RBAC grant one verb short) uncaught until an operator hit it, and left
the Kubernetes half of the container-runtime seam proven against nothing but a mock
transport. A new `k8s-smoke` CI job creates a kind cluster, builds the service images from
the checkout and loads them into it, `helm install`s the chart, and asserts the integration
last mile from a curl pod inside the namespace. The assertions themselves moved into
`infra/ci/smoke-assert.sh`, shared verbatim with the Compose gate, so both runtimes are held
to one list that cannot drift — and on top of it the Kubernetes gate proves the OpenBao
bootstrap Job and its unseal loop work, that the web shell resolves the core through the
pod's own DNS, and that a confirmed module removal really does scale that module's
Deployment to zero through the chart's namespace-scoped Role. Deliberately not a required
check until it has been green for two weeks. **The first boot immediately earned its
keep**, four times over. The chart's OpenBao bootstrap read the unseal key out of `/v1/sys/init`
under the *CLI's* field name (`unseal_keys_b64`) rather than the HTTP API's
(`keys_base64`), so it initialised a vault and discarded the only key that could ever open
it — unrecoverable, and invisible to every existing test because the stub they run against
mirrored the same mistake. And SearXNG crash-looped forever: Kubernetes hands every pod a
`SEARXNG_PORT=tcp://10.96.x.x:8080` service-link variable, SearXNG's entrypoint feeds that
straight into `GRANIAN_PORT`, and the server dies on a URL where it wanted a port number —
so the chart now turns that Docker-links injection off on every pod, since nothing in the
stack reads those names and they collide with the ones it does. And the web UI was broken
outright on Kubernetes: nginx resolves the core's name per request and never applies
`/etc/resolv.conf`'s `search` list, so the bare `core-app` it was handed SERVFAILed and
every `/platform/` call 502'd — while both probes stayed green, because `/healthz` is a
static handler that never touches the resolver. The chart now gives the shell a fully
qualified core URL (`clusterDomain` / `web.coreAppUrl`). And OpenBao could not survive its
own restart: its container drops every capability but four, and `chown -R` on the data
directory needs `DAC_OVERRIDE` to descend into the 0700 directories the file backend
creates — so a fresh vault booted, a vault holding data crash-looped forever, which is a
node drain or an upgrade away from any operator. None of the four is reproducible under
Compose. `epicurus` chart 0.1.0→0.1.1 (PATCH).
- **Storage honours the tenant the caller names** (#836) — the object store's rows and buckets
were always tenant-scoped, but the module resolved the tenant for you: its HTTP routes pinned
`DEFAULT_TENANT_ID` and merely *accepted* a `tenant_id` "for forward-compatibility", so a
request that named a tenant was answered from the default's catalogue — the tenant-first
constraint honoured everywhere except at the door. Browse, search, read, download, move and
delete now scope to the `tenant_id` the caller sends; `/ingest` files an upload under the
`x-epicurus-tenant` header the core's upload sink has always stamped on it; an unnamed tenant
still means the deployment default, so a single-tenant self-host is unchanged; and a malformed
one is a 400 rather than a silent cross-tenant read. The MCP tools resolve the bound tenant
context instead of one fixed at build time — nothing carries a tenant across the MCP hop yet,
so today they still land on the default. No backfill: every existing row and object was
written under the default tenant and stays exactly where it is. `storage` 0.10.0→0.11.0
(MINOR).
- **The import report shows what happened to your files, not just your records** (#905) — a
module that keeps files of its own (`storage`, where every chat attachment lives) has always
answered an import with a byte-level result: how many objects were written, which ones were
**left untouched because the bytes here differ**, and which arrived as a record whose file the
archive did not carry — the entry is findable and its download answers 404 until the operator
copies it across. None of that was rendered anywhere (#886), so the one component where it
matters most reported only record counts and the operator had to read the JSON to learn that
a file had not come with them. Export & import now shows a `bytes` line under the component's
own row and both id lists, each headed with what it means and what to do about it, collapsed
past five entries so a module with forty conflicts cannot push the rest of the report off the
card. Plain rows in the shell's own style; the core decides what they mean and the card shows
them (ADR-0018). `web` 0.144.0→0.145.0 (MINOR).
- **A `NULL` no longer costs the operator a whole data set, and a failed import can be thrown
away** (#903) — dogfooding the tenant import on a second machine lost the entire `prefs` set
to a single row. A column added after its table's first release with no `server_default` is
reconciled in **nullable** (there is nothing to backfill a populated table with), so a
long-lived source carries `NULL` where the model says `NOT NULL` — and a fresh target, whose
`create_all` made the column `NOT NULL` for real, refused the insert. One row took
`llm_prefs`, `saved_models`, `model_settings`, the timezone, the page order, push and the
maintenance schedule with it, because a set is one transaction. Both ends now normalise a
`NULL` to the column's own default, on export *and* on import, so an archive is portable
whichever reconcile its source went through and archives already written apply cleanly; a
null that genuinely cannot be defaulted costs **one row**, named with its column in the
report, and the rest of the set still lands. The same shape was what made `calendar` answer
the import with a 500 and lose the calendar — `all_day` and `excluded` are exactly that kind
of column — and it is fixed the same way at both ends. And a job can now be **removed**:
`DELETE /platform/v1/portability/{exports,imports}/{id}` drops the row and its staged
archive (409 while it is running — a delete is not a cancel), with Remove on the import half
and on every settled row of Recent jobs. Until now jobs left only through the retention
sweep, which runs when the *next* job starts, so a failed import sat on the card with its
report for a day and the only way to clear it was to cause another one. `core-app`
0.122.0→0.123.0 (MINOR) · `web` 0.143.1→0.144.0 (MINOR) · `calendar` 0.21.0→0.21.1 (PATCH).
- **The editor's text cursor now reads in the accent colour, not body text** (#904) —
dogfooding turned up a `.ProseMirror` caret that inherited `color` and so sat at exactly
the same lightness as the character next to it, easy to lose in a long note, worst on the
dark theme. `caret-color: var(--ep-accent)` fixes it with one inherited declaration —
it reaches code blocks, blockquotes and the title line too, since none of them set their
own `caret-color` — and needs no custom ProseMirror decoration: the accent clears WCAG
1.4.11's 3:1 non-text floor against the editor surface on every theme/power combination.
`web` 0.143.0→0.143.1 (PATCH).
- **The published Helm chart is a release artifact, not a nightly one** (#907, part of #889) —
`testing.yml` published a chart on every push to `testing`, which made the on-demand
`0.0.0-testing.<sha>` build look like a supported second track when the standard install and
upgrade path is, and always was, a tagged release pulled straight from
`oci://ghcr.io/baakhoff/charts/epicurus`. `testing.yml` no longer packages a chart at all;
`release.yml` is unchanged. A new `chart-branch.yml` packages and pushes a chart from any
branch or sha on a manual dispatch only — `0.0.0-<branch>.<7-char sha>`, with the dispatch
naming the image tag that ref's images actually carry (`testing` is the one ref that can
infer it) — for the owner's own testing clusters, never automatic and never the documented
path. `docs/infrastructure/kubernetes.md` now leads with the OCI release install and gives the
checkout path its honest name, "developing the chart." No component bump.
- **A tool's failure message reaches the model again under mcp 2.1** (#908) — mcp 2.1
started masking any tool exception other than `ToolError`/`ResourceError`/`MCPError` as
a bare `Error executing tool <name>`, dropping the model-actionable text every module's
`KeyError`/`ValueError`/`LookupError` failures rely on (37 tests across every module
caught it). `EpicurusModule.tool()` now wraps the registered function so any exception
it raises reaches the SDK already as a `ToolError` carrying the original message,
chained — restoring the mcp 2.0 contract exactly. The wrapper logs an anticipated
exception (`KeyError`/`LookupError`/`ValueError`/`PermissionError`/`FileNotFoundError`)
at WARNING and everything else at ERROR with a traceback, so a genuine crash still
stands out to the operator even though the model still gets a readable message. `mcp`'s
floor moves to `>=2.1,<3`. `epicurus-core` 0.37.0→0.38.0 (MINOR).
- **An import now shows its work, and the Platform card tells the truth** (#893) — dogfooding a
tenant import on a second machine found four things the Settings card never said. The upload
was a busy button: `fetch` cannot report upload progress at all, so the archive route is now
the app's one `XMLHttpRequest` (its error taxonomy and connectivity evidence reproduced
intact) and a multi-gigabyte archive shows a real percentage instead of looking hung. The
apply was a bare spinner: the import job now carries the same `progress` list an export does
— sets, modules, the file space, then two `rebuild` rows for the forced rescan and the
re-embed — seeded from the preview in the apply's own answer, which is also what makes a
second Apply press *impossible* rather than merely refused with a red 409 (the card's polled
`staged` copy used to outlive the mutation's fresh one). The report never mentioned the
commonest reason a clean import still feels broken — **no embedding model on the new box** —
because the re-embed fan-out cannot report it: every module accepts the job and parks in
`error` minutes later, out of sight. The core now asks the gateway *before* it asks the
modules and states the finding plainly, pointing at Models → pull → "Re-embed everything"; a
runtime that is down or a vault that will not answer is reported as *unknown*, never as
absent. And the Platform card showed `epicurus_core`'s version under the label "core
version", so the one number an operator quotes in a bug report named the wrong component:
`GET /platform/v1/info` now carries `core_app_version`, `library_version` and the
`release_track` actually pulled beside the unchanged `core_version`, and the card renders
them apart. `core-app` 0.121.0→0.122.0 (MINOR) · `web` 0.142.0→0.143.0 (MINOR).
- **The Helm chart ships beside the images, not as a separate download** (#892, part of #889) —
`release.yml` and `testing.yml` now package `infra/k8s/epicurus/` and push it to
`oci://ghcr.io/baakhoff/charts/epicurus` in the same run that builds and pushes the service
images, so `helm install epicurus oci://ghcr.io/baakhoff/charts/epicurus --version <x>` works
with no chart repo or index to host. A release tag publishes the chart at that tag's version
(`appVersion` matching); every push to `testing` publishes
`0.0.0-testing.<7-char sha>` with `appVersion: testing`. Both publish steps are
no-op-safe — they skip cleanly on any commit before `infra/k8s/epicurus/Chart.yaml` exists
(#890), so this lands independently of chart's own PR. No component bump.
- **epicurus runs on Kubernetes — one Helm chart for the whole stack** (#890, part of #889) —
the stack was Docker Compose or nothing, even though the architecture was built for a
cluster all along: stateless services with externalised state, swappable storage and LLM
backends, health and metrics on every service, images on GHCR. What was missing was the
packaging. `infra/k8s/epicurus/` now installs the lot — the core, the web shell, all nine
modules, and a data plane where each piece is either in-chart or pointed at a managed
endpoint (`postgres.enabled=false` + `external.host`, and so on), so a homelab installs one
release and a real cluster brings its own Postgres. The core is a singleton with a
ReadWriteOnce file volume, which is honest about what it is today rather than pretending it
scales. Only the web shell gets an Ingress — a module answers the core and nobody else, and
the optional NetworkPolicy makes that a rule the cluster enforces rather than a convention.
Its default ingress annotations carry the lesson from the archive-import fix: no body cap,
no request buffering, or a real export is refused before the core sees a byte. The hardest
part, OpenBao, is a Job that behaves the way the compose script does — initialise once,
unseal, mint the periodic app token — except it stores the unseal key in a Kubernetes Secret
it creates itself, so `helm uninstall` cannot take your vault with it, and a companion loop
unseals a restarted pod. Passwords are generated once and kept across upgrades. A new
`chart-validate` CI job renders every branch and schema-checks it with `kubeconform`, the
bootstrap script is exercised against a stubbed API in pytest, and `task new-module` writes
the chart entry too — so a module can no longer be wired into compose and forgotten here.
The object store is on by default, as it is under Compose: `storage` keeps chat uploads,
agent-written objects and the byte half of a tenant export there, so shipping it off would
have installed a module that starts and then fails at call time. Chart `0.1.0`; no
component bump.
- **The core no longer assumes a Docker daemon, and the web shell no longer assumes Docker's
DNS** (#891, part of #889) — the two places a Kubernetes deployment would have failed
silently. The one audited path that touches containers (tearing down a removed module's
container, restarting Ollama after a KV-cache change) now sits behind a runtime seam with
three arms, chosen by `CONTAINER_RUNTIME` (`auto` by default): Docker exactly as before under
Compose; in a pod, the cluster's own API through the ServiceAccount — a module's Deployment
scaled to zero, Ollama rollout-restarted, workloads found by label and never by name, under a
Role that grants nothing else; and `none`, which says once at startup that teardown is
unavailable instead of deferring forever in silence. A refusal (RBAC saying no) lands where a
missing socket already landed — the module is removed and hidden at once, only its workload
waits for the next restart. The web shell's nginx used to hardcode Docker's embedded resolver,
which does not exist in a cluster: it now derives `NGINX_RESOLVER` from the container's own
`/etc/resolv.conf` at start, so one image proxies the core in both worlds. Riding along, a
repo test now fails if nginx's upload cap is ever set below the core's own — the trap that
cost #887 an unimportable archive. `core-app` 0.120.0→0.121.0 (MINOR) · `web` 0.141.1→0.142.0
(MINOR).
- **A real archive can now actually be imported** (#887) — the web shell's proxy capped every
`/platform/` request body at 12 MB (right for chat attachments), and the archive upload from
the Settings card went through the same block, so any export that carried a knowledge vault
or stored objects was refused by nginx before the core saw a byte — with nothing in the job
list to say so. The import route now has its own proxy location with no nginx cap and
request buffering off, so the archive streams to the core and the core's own
`PORTABILITY_MAX_ARCHIVE_MB` ceiling is the only one; and an upload the core never answers
is named as "never reached the core" instead of "Failed to fetch". `web` 0.141.0→0.141.1
(PATCH).
- **Your uploads move house too — the tenant archive now carries bytes, not just rows**
(#876, part of #866) — every other module's data is rows, and `storage`'s is not: the files
you drop into chat and the objects the agent writes live in a per-tenant object bucket, which
is nowhere in the file space the archive already carried. Exporting the catalogue alone would
have handed you a list of filenames at the far end. So the module contract grows an optional
**byte half** — a second, tiny protocol (`blobs` / `open_blob` / `put_blob`) that the same
`add_portability_routes` call serves, discovered by the core from a route answering rather
than declared in a manifest, so no module that has no bytes is touched by it. An archive now
carries `modules/<name>/blobs.ndjson` and one member per object, and `storage` exports its
catalogue as records whose stable id is the file's path — the *same* id its bytes travel
under, so a row and its file pair up with nothing to join on. Nothing is ever held whole:
each object is streamed through the disposable staging directory in both directions, and in
and out of the object backend behind the module's own seam (multipart above 8 MiB), so a
four-gigabyte upload is never a four-gigabyte allocation and nothing here assumes MinIO.
Idempotency is by **content**: the core hashes each archived member and declares the digest on
the way in, the module skips an object it already holds byte-identically, and an id that holds
*different* bytes keeps them and is named as a conflict — the same rule the file space already
had, so a second apply writes nothing and an import can never overwrite an edit you made
since. The per-file ceiling applies per object; an over-cap one is omitted and **named**,
while its catalogue record still travels, so the file stays listed with its size and you know
exactly which bytes to carry across by hand instead of discovering the gap later. `storage`
0.9.3→0.10.0 (MINOR) · `epicurus-core` 0.36.0→0.37.0 (MINOR) · `core-app` 0.119.0→0.120.0
(MINOR).
- **An export survives a reload — and the archive says which bridges to reconnect** (#877,
#875, part of #866) — the Export & import card kept its job id in component state and
nothing else, so refreshing the tab mid-export orphaned the run: it finished staging on the
server, was never offered to anyone, and was quietly cleaned up a day later. The job row was
durable all along; what was missing was a way to *find* it without an id. The core now
answers `GET /platform/v1/portability/jobs` — this tenant's last 20 jobs, newest first, both
kinds, each with an `archive_available` answered from the filesystem rather than from the
row, so a download link appears exactly when the download would work and a cleaned-up
archive says so plainly instead of handing out a link that 410s. The card reads that list on
mount and re-attaches to the newest job of each kind, whatever this tab did or did not
start: a running export resumes its progress, a finished one offers its download, an apply
left mid-flight comes back with its report, and a **Recent jobs** disclosure holds the rest.
It re-attaches on a second device and in a different browser too — which is why the server's
list is the source of truth and `localStorage` is not used at all. Alongside it, the
archive's secret inventory grew a third part (`module_secrets`): the export probes each
enabled module's declared OpenBao paths for **presence** — never the value — so a module
that persists no rows and still holds a credential is no longer invisible to the fan-out.
`messaging` is exactly that module: nothing to export, and a bot token per connected bridge,
so the import report now says "re-enter in module settings: messaging — discord, telegram"
instead of leaving the operator to find out on the far side that Discord went quiet.
`core-app` 0.118.0→0.119.0 (MINOR) · `web` 0.140.0→0.141.0 (MINOR).
- **Mail evaluated for tenant export/import — nothing to carry, so the flag stays off** (#874,
part of #866) — `mail` now serves `GET /export` / `POST /import` (`MailPortability`, schema
`mail/1`) for the core's tenant export/import orchestrator (#867), but declares
`portable=False`. Checking what the module actually persists (`mail_thread`, `mail_label`,
`mail_sync`, `mail_landing`, `mail_category`) found that every one of them is a Gmail-derived
cache (ADR-0096, #623/#765) — mail has no local mailbox, so nothing it stores is
source-of-truth data, and ADR-0133 excludes exactly that from an export. Per the documented
convention for a module with nothing worth carrying, the flag stays off and the core records
mail as excluded, rather than listing an always-empty component in every tenant archive; the
routes are still wired (`add_portability_routes`) so a future genuine per-tenant setting is a
one-line flip. `mail` 0.20.0→0.21.0 (MINOR).
- **`knowledge` joins tenant export/import: the suggestion queue travels, the vault's indexes
rebuild** (#873, part of #866) — the module now declares `portable: true` and serves `GET
/export` / `POST /import` (schema `knowledge/1`) so an operator's pending
agent-proposed changes and their resolved-decision history move with them into a new
install. What travels is exactly the module's source-of-truth data outside the vault
itself: `knowledge_suggestions` and `knowledge_suggestion_decisions`, each upserted by the
stable `sid` the module already mints — never the surrogate row id. Everything derived
stays behind and is rebuilt after import instead: `knowledge_notes` / `knowledge_doc_index`
/ `knowledge_module_docs` (index ledgers), `knowledge_versions` (editor save history), and
the `<tenant>__knowledge` / `<tenant>__docs` Qdrant collections, whose vectors are specific
to the source's embedding model. The vault's own files ride the archive's `files/` tree, not
a record here; the core's post-import rescan and #332 re-embed fan-out walk them back into
an index on the target install, safely — a fresh install's ledgers start at zero rows, which
is exactly the case the #848 mass de-index fuse always lets through. `knowledge` 0.29.0→0.30.0
(MINOR).
- **Your notes move house with their words intact** (#872, part of #866) — `notes` now implements
the module half of tenant export/import (schema `notes/1`): every note, every folder you made,
the agent's pending suggestion queue and the decision trail behind it travel in the archive and
land in the new installation under its own tenant. **Bodies travel with the rows**, which is the
opposite of how it first looked: a note is already mirrored to `notes/<slug>.md` in the shared
file space, and the file space is in the archive — but that mirror is write-only derived output
that nothing ever reads back into the store, so carrying metadata alone would have handed the
operator a Files tree full of markdown and a Notes page full of empty documents. What stays
behind is what can be rebuilt or was never ours: per-save version history (already deduped and
pruned to 50 per note, and every row a whole body of text whose current state travels beside
it), the Qdrant vectors (rebuilt by the re-embed fan-out, since a vector belongs to the model
that made it), the `.md` mirror itself, and the review on/off toggle (a core preference).
Import upserts by the note's slug — never a surrogate key — in a single transaction, never
deletes, and a second apply of the same archive is a no-op; a record that omits a column leaves
that column alone, so nothing can blank a body it did not carry, and a note with no body at all
is refused rather than conjured into the editor as an empty document. `notes` 0.13.0→0.14.0
(MINOR).
- **Tasks joins tenant export/import** (#871, part of #866/#867) — `tasks` implements the
module half of the portability contract: `portable=True` plus `GET /export` / `POST /import`
(schema `tasks/1`) over local tasks, a Google-linked task's recurrence rule (the *only* copy
of it — Google Tasks has no recurrence field of its own, ADR-0082), and the operator's
`task_due_soon` lead-time preference when set. Each carries its own stable id (a task's own
uuid, never the surrogate `pk`; a repeat rule's natural `list_id`/`task_id` pair; a fixed id
for the single per-tenant lead-time row) so import is a plain upsert and re-applying the same
export is a no-op. The Google task itself is not exported — it lives in the operator's
account, not this module's database — and the operator's enabled/active list selection is
left to the core's own export (`module_prefs.collections`) rather than duplicated here; the
scheduler's fire-once markers are operational and excluded too. `tasks` 0.23.3→0.24.0 (MINOR).
- **Your calendar travels with you** (#870, part of #866) — the tenant archive now carries
calendar. Everything the local store owns goes: plain events, all-day events, and a recurring
series *whole* — its repeat rule, the wall-clock zone it expands in, an occurrence you moved or
retitled, and one you deleted — plus your `event_starting_soon` lead time. Import upserts by
each event's own id, so re-importing an archive changes nothing and importing into a calendar
you have already started using merges rather than replaces; a dry run tells you what would land
before anything does. What is deliberately left behind is everything that describes *this*
installation rather than your calendar: the Google mirror and its sync cursors (reconnect the
account and the next sync rebuilds them from Google itself, which still holds those events),
the fire-once reminder markers (so the new install still tells you about tomorrow's meeting),
and — of course — the OAuth token, which never leaves OpenBao. Which calendars are enabled and
which one new events land on already travel in the core's own half of the archive (they live in
`module_prefs`), so they are not duplicated here. `calendar` 0.20.1→0.21.0 (MINOR).
- **Take everything with you: tenant export and import, from Settings** (#867, part of #866) —
an operator could stand up a second epicurus but had no way to *move into it*. The volume
backups in `infra/backups` image one deployment for disaster recovery; nothing carried a
person's own data across installations. Settings now has an **Export & import** card that
writes the whole tenant — chats, memory facts, playbooks and instructions, automations,
notifications, every preference, the file space, and each portable module's own data — into a
plain `.tar.gz` you can open and read (`manifest.json` · `core/<set>.ndjson` ·
`modules/<name>.ndjson` · `files/…`), and reads one back. The module half is a contract shaped
exactly like `reindexable`: a `portable` manifest flag plus `add_portability_routes`, which
serves `GET /export` / `POST /import` over NDJSON for any store implementing three members —
so a module lane implements its half without inventing an export format. Four properties are
load-bearing and tested as such: **import never destroys** (upsert by stable id, so applying
the same archive twice is a no-op and importing into a populated install merges rather than
replaces — and a file whose bytes differ here is named as a conflict, never overwritten);
**one component's failure is one component's failure** (a module that is disabled, unreachable,
or speaking a schema this core cannot read is skipped with its reason while everything else
lands); **only source of truth travels** (the file index and every Qdrant collection are
rebuilt afterwards by the forced rescan and the #332 re-embed fan-out, because a vector is
specific to the model that made it — #848 would otherwise rightly refuse a fresh install's
empty→populated flip, hence *forced*, and the report says so); and **no secret ever enters an
archive** (keys stay in OpenBao; the manifest carries their *names* so the import report can
say what to re-enter and reconnect, and a test greps the produced bytes for the fixture's key
values). Jobs are durable rows, so the work outlives the request that started it and stays
readable by id; the archive itself is a disposable staging artefact (constraint #2), and a
download of a swept one answers 410 rather than pretending. Module implementations (calendar, tasks, notes, knowledge, mail, messaging,
storage) are separate follow-up issues. `epicurus-core` 0.35.0→0.36.0 (MINOR) · `core-app`
0.117.0→0.118.0 (MINOR) · `web` 0.139.0→0.140.0 (MINOR).
- **OpenRouter, and embedding models that don't have to run on your box** (#865) — reaching
OpenRouter meant spending the single generic OpenAI-compatible slot on it, and hosted
*embedding* models were not reachable at all: `LlmGateway.embed` hardcoded `ollama/<model>`,
sent Ollama-only runtime options, and refused every call while the runtime was paused. Both
are fixed at the root rather than per-case. `openrouter` is now a first-class provider alias
(key at `llm/openrouter`), so LiteLLM's own OpenRouter dispatch and its context/cost map
apply — and because an OpenRouter model id carries a vendor segment of its own
(`openrouter/openai/text-embedding-3-small`), every place that classifies a model id now
splits on the *first* slash only, in Python and in TypeScript alike. `embed` classifies its
model through the same provider registry chat uses: a local id behaves exactly as before,
while a hosted one takes the provider path with the tenant's key fetched from OpenBao at call
time, no Ollama options, and — mirroring hosted chat — keeps serving while the runtime is
paused, so memory recall and module indexing survive a paused GPU. Both classes emit the same
tenant-scoped usage event naming the model actually called. The Models page's **Embedding
model** select now lists saved hosted models beside the local ones and opens the hosted
settings sheet for them (no keep-alive, no Run-on), saying plainly that the saved list can't
tell a chat model from an embedding one — and that a hosted choice sends the whole
notes/knowledge/memory corpus to that provider. Switching between a 768-d local model and a
1536-d hosted one is a *vector-size* change, which Qdrant used to answer with an opaque
dimension error on the next upsert and on every search; the knowledge and notes indexers now
recreate the collection at the new size and rebuild from source instead, with the two sources
sharing `<tenant>__docs` healing together so neither is left claiming vectors that are gone,
and the #848 mass de-index fuse still weighed first and untouched. "Re-embed everything"
remains the documented step after a model switch; this is the net for switching without it.
Fixed along the way: an explicit `local/<model>` embedding id reached the runtime verbatim as
the nonexistent model `local/<model>`. `core-app` 0.116.0→0.117.0 (MINOR) · `web`
0.138.0→0.139.0 (MINOR) · `knowledge` 0.28.1→0.29.0 (MINOR) · `notes` 0.12.3→0.13.0 (MINOR).
- **Six module manifests were lying on screen** (#845) — the Modules page badge reads
`manifest.version`, which every module hardcodes as a literal in `build_module()` rather than
reading its own package's declared version. Six of them had drifted behind `pyproject.toml`
(echo 0.5.0 vs 0.5.2, knowledge 0.27.0 vs 0.27.4, messaging 0.2.0 vs 0.3.1, notes 0.12.0 vs
0.12.2, storage 0.9.0 vs 0.9.2, tasks 0.23.0 vs 0.23.1), and each module's own test pinned the
same stale literal the manifest hardcoded — so the drift passed the gate release after release
while the badge quietly told the operator the wrong number. Calendar and websearch had already
been fixed this way (wave 15); the same pattern now covers all six: the manifest still
hardcodes a literal (a package can't read its own `pyproject.toml` from an installed wheel),
but the test now compares it against the version *declared* in that `pyproject.toml` via
`tomllib` rather than pinning a second copy of the same literal — a stale editable install can
lie about installed metadata, so declared beats installed. Two modules (echo, storage) had no
version assertion at all; one is added. The service template's own test never pinned a version
literal, so it needed no change. `echo` 0.5.2→0.5.3 · `knowledge` 0.28.0→0.28.1 · `messaging`
0.3.1→0.3.2 · `notes` 0.12.2→0.12.3 · `storage` 0.9.2→0.9.3 · `tasks` 0.23.2→0.23.3 (all PATCH,
this lane's own bump landing on top of #847's and #848's — this PR is the wave's merge-train
anchor and lands last).
- **A file space that reads empty no longer takes your indexes with it** (#848) — after two
weeks of downtime the dogfood stack came up with a stale bind mount: the container saw an
empty `/data` while every file sat intact on the host. Nothing failed. The core's file scan
reconciled `core_files` to zero rows, a knowledge re-index purged the note ledger and emptied
the vault's Qdrant collection, and not one error was logged — because "the source is empty" is
a legitimate state everywhere in this codebase, and only a *raising* backend failure ever
stopped a reconciler from deleting. The mount was stale, not the data, so nothing was
irrecoverable that time; with an S3 backend or a slower embedding pass the same five seconds
destroy hours of derived state. This puts a fuse in front of every reconciler that deletes
because its source went quiet. Before a pass touches anything it weighs the deletion against
what is already indexed, tenant-scoped and namespace-scoped: a source reading empty against a
non-empty index is refused at any size, and so is a pass deleting at least half of an index
(with an absolute floor, so ordinary editing — two notes of three — never trips a guard meant
for wholesale loss). A refused knowledge pass is abandoned whole, ledger and collection
untouched; a refused file scan still records what it saw and withholds only the deletions, so
a stale mount leaves a *stale* index instead of an emptied one. The refusal is loud where
silence did the damage: an `ERROR` log with the counts, a `/metrics` gauge and counter per
tenant and source, `index_fuse_tripped` on knowledge's status panel, and
`GET /platform/v1/files/scan-status` on the core. Deliberate wholesale deletion stays possible
— `force=true` on `knowledge_reindex` and `POST /reindex`, `POST /platform/v1/files/rescan?force=true`
— and `POST /reindex` now refuses *synchronously* rather than starting, because it drops each
ledger before rebuilding and would otherwise erase the very evidence the fuse weighs. The
first-ever index has nothing to protect and never trips; the first clean pass re-arms a tripped
fuse. `knowledge` 0.27.4→0.28.0 (MINOR). · `core-app` 0.115.0→0.116.0 (MINOR).
- **Mail no longer blames Google when the problem is the core** (#835) — the mail module's
availability check returned a bool and read *any* failure of the token fetch as "not
available", so an unreachable core, an expired module→core token and "nobody has connected
Google" were one indistinguishable answer. Every surface then rendered that answer as the
not-connected one: a core that was merely restarting produced a Mail page reading *"Google is
not connected. Connect it in Settings"*, and an operator who followed that advice would
disconnect and reconnect an account that was fine. The provider now answers with three states
and a reason — `connected`, `not_connected` (the core answered, and it holds no Google tokens),
`unreachable` (we could not find out) — the same failure-attribution split the core already
makes for model keys after the #728 misdiagnosis. `/status` reports which state and why; the
mailbox page carries `unreachable` instead of `disconnected`, and the shell draws it as its own
empty state naming the actual reason, offering a retry and pointedly *not* a trip to Settings;
`mail_send` refuses with advice that fits; and the background poller stops treating an
unreachable core as an idle one — it warns once with the reason and backs off, instead of
silently skipping every tick for as long as the outage lasts. `is_available()` survives as a
thin compatibility wrapper, and tokens still come only from `PlatformClient.get_oauth_token`
(ADR-0020). `mail` 0.19.1→0.20.0 (MINOR) · `web` 0.137.1→0.138.0 (MINOR).
- **Fixed a pre-existing race in the tasks board-backlog test** (#847) — the booted-lifespan
fixture runs the real app, whose lead-time scheduler ticks once at boot and independently
calls `list_tasks(tenant, scope="open")` in the background (#664); the backlog test asserted
on the mock's `await_args` — the *last* await — so a scheduler tick landing between the
request and the assertion made it read the scheduler's call instead of the page's own
`scope="all"` fetch, failing (or passing on the wrong evidence) nondeterministically. It now
searches `await_args_list` for the specific call carrying `scope="all"`, which the page makes
and the scheduler never does — independent of any interleaving. The rest of the file and the
other tasks test files were audited for the same `await_args`/`assert_awaited*` pattern
against a mock the scheduler also touches; this was the only instance. `tasks` 0.23.1→0.23.2
(PATCH).
- **A recurring series you changed here is announced once, however the provider chops it up**
(#843) — calendar's reconcile suppresses the changes this module made itself by looking them
up in a short-lived ledger, and a series-scoped write is meant to leave its marker in place,
because one such write legitimately comes back as one change per occurrence. It did not:
collapsing a series' occurrences into a single emission re-keys that emission onto the series
id, which made the id it looked up indistinguishable from an ordinary event id, so the
*series* marker took the consume path and was deleted on the first pass. Occurrences arriving
on a later pass — the delta paginates, the poll interval lands mid-write, a restart cuts a
pass in half, one occurrence is edited a minute after the rest — then found nothing to match,
and the write was announced a second time; a duplicate the spine's `dedup_key` absorbs only
when the two payloads happen to coincide. Suppression now decides peek-versus-consume from
where the id came from rather than by comparing ids, so a collapsed group peeks and only a
genuine single-occurrence match consumes. A peeked series marker is released by its TTL alone
(`SYNC_SELF_WRITE_TTL_S`, 15 minutes): nothing says how many occurrences one series write will
come back as, so there is no "fully observed" moment to release it at.
`calendar` 0.20.0→0.20.1 (PATCH).
- **Calendar now notices changes you didn't make here** (#831) — the three calendar change
events had exactly one emitter, the provider-write seam, which sees every write made *through*
this module and, structurally, nothing else: an event created, moved or deleted in Google
Calendar's own UI was never observed and never announced. Every downstream consumer — the
automations matcher, push alerts, the event feed — was correct and simply never heard about
half the changes to the operator's calendar. This adds the second emitter, on the shape mail's
reconcile proved (ADR-0096): a per-collection incremental sync over Google's `syncToken`, with
a tenant-scoped local cache of *what this module last observed* — which is what turns a
provider's flat "here is a changed event" into the three different things the spine wants to
hear. An id never seen is a creation; an id whose change hash moved is an edit, with
`time_changed` a real before/after comparison against the cached start/end rather than a
guess; an id that vanishes is a cancellation with a title still worth printing, since a
tombstone is little more than an id. An expired token (`410 GONE`) is reported through the
provider seam as `None` rather than an exception — a lapsed cursor is a recoverable state,
not a failure — and the loop answers it by full-syncing and **diffing against that cache**,
so the gap is reported exactly instead of replayed blindly or swallowed; a row that merely
fell out of the forward-moving window is pruned in silence, because the passage of time is
not an operator action. A first-ever sync stays silent (a calendar you already had is not
news), and the signal is the sync-state row's mere existence, so a *restart* resumes and
reports what changed during the outage rather than absorbing it into a silent prime. The hard
part is having two emitters without ever double-announcing one action: each write records a
short-lived durable marker keyed `"<event type>|<provider>:<id>"` — plus the series id when
the write was series-scoped — which the reconcile consumes (exact id) or peeks (series)
before emitting. That key deliberately omits the change hash the `dedup_key` carries, so
suppression survives the provider normalising content on the way back and survives the
series → occurrence identity shift; both sides now build payloads, dedup keys and suppression
keys from one module, because two copies of a dedup rule are one copy of a bug. Markers are
written only for external-provider writes, so a local-only deployment gains no churn at all —
and by the same one-rule degrade (#815) an unconfigured or disconnected provider resolves to
zero sync targets, so an idle tick makes no provider call and prints no log line. A recurring
series arrives as one change per occurrence, so a pass collapses them per
`(series, event type)` — one click in Google's UI is one event, not thirty — and the whole
pass is capped, mirroring mail's resume-backlog ceiling. `invitation_received` /
`attendee_responded` are deliberately still **not** declared: they are Google-only, and a
declared-but-never-published event would repeat the mistake mail's docs already record as a
lesson learned once. The manifest's hardcoded version had meanwhile drifted two minors behind
`pyproject.toml` (0.18.0 vs 0.20.0) — its test pinned the same stale literal, so the gap passed
the gate while the Modules page badge read it; the manifest is corrected and the test now reads
the declared version, so it cannot drift again. `calendar` 0.19.0→0.20.0 (MINOR).
- **The module event spine survives a restart** (#832) — the spine was at-most-once by
deliberate choice (ADR-0103 §4): core NATS pub/sub, JetStream enabled on the server and
pointedly unused, because in July nothing consumed events so nothing could miss them. That
stopped being true when proactivity — listeners → automations → alerts → the notification
centre — moved inside 1.0, and a core restart began quietly costing the world's changes.
It is now **at-least-once**: a JetStream stream (`EPICURUS_EVENTS`) over the *unchanged*
`<tenant>.events.>` subjects, provisioned idempotently on every boot, and a **durable pull
consumer** (`core-event-intake`) whose server-side cursor outlives the process. Not one
emitter changed — persistence is a property of the subject, not of the publisher's API, so
a stream captures whatever lands on its subjects whoever published it. The rule that makes
it worth anything is one line of ordering: **the message is acked only after the row is
committed** to `module_events`. A core killed mid-message never acked it and gets it back;
a store that fails is *naked*, so a database outage costs latency rather than history; and
a message that can never be stored — unparseable, or claiming one tenant on the subject and
another in the envelope — is *terminated*, because unlimited redelivery with no terminal
case is an infinite loop. Fan-out to `on_event` listeners deliberately runs *after* the
ack: holding it would blow through `ack_wait` and trigger a redelivery that dedup then
absorbs as a no-op, skipping the listeners anyway. Redelivery is safe because the log's
`(tenant, module, dedup_key)` uniqueness is a **database constraint**, not a convention —
verified rather than assumed, and now asserted on the consume path and not only on the
store. The honest half: emitting is still an *unacknowledged* publish, and that is not a
trade that was available to make differently. A tenant-scoped subject puts the tenant in
the leading token, so the stream's subject starts with `*` — and NATS refuses a stream
whose subjects overlap its reserved `$JS.>` namespace unless publisher acks are disabled
(`no_ack`). Publisher-side acks would mean changing the *subject scheme*, which is a
contract change, not a transport one. So the guarantee is: **at-least-once from the NATS
server to the durable log; best-effort from the emitter to the NATS server**, with the
windows documented rather than glossed — and the widest is open on a *healthy* connection:
nats-py appends a publish to a pending buffer and only *queues* the flush, so `emit_event`
returns before the bytes are on the wire, and an emitter killed in that gap loses the event
with NATS entirely up. **The guarantee starts at publish, not at the source of truth**;
closing that needs an outbox in the emitter, not a transport change. The rest — a stream at
its limit discarding oldest-first, an unclean server crash — are documented beside it, and
the one loud case is a publish while the bus is disconnected, which raises rather than
buffering across a reconnect. The `module_events` table stays the copy of record; the stream
is a bounded delivery buffer in front of it (7 days, 512 MiB, discard oldest), not a second
archive. Also folded in: the LLM diagnostics no longer report "no key configured" when the
truth is "OpenBao could not be reached" — a bare `except SecretError` had collapsed the two
since #728, so a provider list read as *unconfigured* during a vault outage. `ProviderInfo`
now carries `key_state` (`not_required` / `present` / `missing` / `unavailable`) plus the
store's reason, with `configured` unchanged for existing readers.
`epicurus-core` 0.34.0→0.35.0 (MINOR). · `core-app` 0.114.2→0.115.0 (MINOR).
- **`mypy --strict` now actually checks test code** (#833) — the root `[tool.mypy]` block was
packages-only, so CI's bare `uv run mypy` step structurally never looked inside any service's
or lib's `tests/`, and 606 strict-mode errors had piled up invisibly behind that blind spot
(mostly mcp 2.0's content union replacing the SDK's older, looser types — `content[0].text` is
now a real `union-attr` error against `TextContent | ImageContent | AudioContent |
ResourceLink | EmbeddedResource`, plus a long tail of `# type: ignore` comments nobody had
re-validated in years). The config moves from `packages` to `files`, because mypy's own CLI
rejects combining `packages` with `files`/`modules` in one run ("May only specify one of:
module/package, files, or command"); `explicit_package_bases` + `namespace_packages`, plus a
`mypy_path` matching every package's `src`, keep same-named test files across services (several
ship their own `test_service.py`) resolving to distinct module names instead of colliding on a
bare one, and keep a file reached directly the same module as the one reached by import —
otherwise mypy reports the source "found twice under different module names."
`scripts/new_module.py` now wires a new module's `mypy_path`/`files` entries too, so `task new-module`
keeps producing a gate-clean scaffold with no manual follow-up. All 606 errors fixed, test-side
only: `.text` access on an mcp content union narrowed with a small `isinstance(x, TextContent)`
helper per file rather than erased to `Any`, ignore comments whose bracketed code had drifted
stale corrected to what mypy actually reports now (`[attr-defined]` where it's really
`[method-assign]`), and a few dict/list literals annotated at their wider expected type at the
point of construction instead of left to infer the narrower concrete one.
- **De-raced `test_manual_run_history_reports_source_manual`** — `current_run` reads `None` both
*before* a maintenance run starts and *after* it finishes, so polling for "cleared" immediately
after `POST /run` (itself fire-and-forget, #561) could observe the pre-run idle state and never
actually wait. The poll now matches on the completed run's own history `id` — assigned once
persisted, and strictly increasing — instead of the transient flag, which is unambiguous even
across repeated runs in the same test. (A `started_at` string comparison looked simpler but
doesn't survive the round trip through SQLite's `DateTime` column, which quietly drops the UTC
offset on the way back out.)
- **`services/web/src/components/EventAlertsCard.tsx` carried a raw NUL byte** — `keyOf`'s
map-key separator, sitting inside a template literal since the file was first added, which made
git treat the whole file as binary and drop it from every reviewable diff from day one. Escaped
to `\0` instead (identical at runtime); a new fast repo-wide test (`tests/test_no_nul_bytes.py`)
guards against the next one slipping through the same way unnoticed.
- **`compose-validate` now also lints the observability profile** — the existing step resolves
every service regardless of active profile, which doesn't actually prove
`infra/observability/compose.yaml` (gated behind `profiles: [observability]`) activates cleanly
under its own profile; a second `--profile observability` invocation does.
`web` 0.137.0→0.137.1 (PATCH). · `tasks` 0.23.0→0.23.1 (PATCH). · `core-app` 0.114.1→0.114.2
(PATCH). · `mail` 0.19.0→0.19.1 (PATCH). · `knowledge` 0.27.3→0.27.4 (PATCH). · `notes`
0.12.1→0.12.2 (PATCH). · `echo` 0.5.1→0.5.2 (PATCH). · `messaging` 0.3.0→0.3.1 (PATCH). ·
`storage` 0.9.1→0.9.2 (PATCH). · `websearch` 0.3.0→0.3.1 (PATCH). `epicurus-core` and
`calendar` also had test files fixed here but carry **no line of their own**: both take a
MINOR in this same wave (#832, #831), and a PATCH landing first would only collide with
their version-line edits.
- **Every public status surface told a retired story — reconciled to the milestone
roadmap** (#830) — `README.md` and `docs/index.md` still opened with "Phases 0–3
complete… Phase 2/chat bridges next," a model the project moved off of. Both now state
the actual gate: working toward **1.0.0 — Foundation complete & stable** (a quality bar,
not a feature checklist), with the `messaging` module (loopback, Discord, Telegram)
already shipped and Slack/WhatsApp landing at milestone 2.0.0; latest tagged release
stays v0.2.0. `docs/index.md`'s at-a-glance module tree was missing `notes` and
`websearch`; both added. `docs/user/installation.md`'s port table listed 3 of the
platform's 11 module ports; rebuilt against the source-of-truth
`docs/reference/ports.md` (module band, data plane, and the opt-in observability
profile), plus a new First-run models section documenting what the ADR-0118 bootstrap
(#773) actually does — the effective chat/embedding defaults it resolves, its
backoff/skip/no-op behavior, and the `LLM_BOOTSTRAP_MODELS` knob. `docs/services/index.md`'s
mail row dropped a stale, unrelated-to-current-version "(v0.1)" tag. `docs/services/web.md`'s
Chat row was a single ~9,690-character table cell; it's now a one-line summary pointing at
a new prose `### Chat` subsection carrying the exact same content (verified word-for-word
against the original, nothing dropped). That page's "two board pages from one module"
illustrative example (tasks' board + Can) has had no live instance since #820 folded Can
into the Tasks page, so the dead example was dropped and the underlying `BoardView` keying
behavior kept, described generically. `docs/services/core-app.md` and
`scheduled_turns.py`'s module docstring both still called event-driven listeners/alerts
"a later milestone"; that shipped inside 1.0 via #662–#672 (the module event spine,
automations engine, push notifications, and event alerts) — both corrected to point at the
automations engine. `core-app` 0.114.0→0.114.1 (PATCH).
- **The document pane now types** (#654) — v1 (#541, ADR-0101) opens the pane when an annotated
`writes_document` call *lands*, by which point the model has already written the whole body;
v2 shows it arriving. The blocker was never the pane: tool-call fragments were assembled
**inside** `LlmGateway.stream_chat` and surfaced only on the final `result`, so the agent loop
could not observe a call being written at all. So this starts with a gateway streaming-contract
change — a new optional `StreamEvent.tool_call` carrying `ToolCallFragment{slot, id?, name?,
arguments?}`, emitted as each fragment arrives. It is strictly additive: the accumulation and
the final `result` are untouched, and `slot` is the accumulator's *own* slot rather than a
second guess at it, so #324's hard-won index discipline (OpenAI shares an `index` across a
call's fragments; LiteLLM leaves it unset for Ollama's complete-per-fragment calls, and
honouring `index or 0` once fused two calls into invalid JSON that crashed the next turn on
replay) is inherited rather than duplicated — `arguments` is the fragment's delta, `id`/`name`
are the call's values as resolved so far, and the whole-dict provider flavour reports no delta
because it is replaced rather than appended. Reading the body out of that stream needs a value
extracted from JSON that is still an unterminated fragment, which `json.loads` can only reject:
`agent/partial_json.py` is a hand-rolled resumable scanner rather than a tolerant-parser
dependency, because the job is narrower than "parse partial JSON" and its real failure modes
are the ones a general parser does not solve for us — it hands out the *delta* since last time
rather than re-decoding the document per fragment, it matches only top-level keys with a real
scanner (so `{"title": "content", "content": "real"}` cannot confuse the two), and it holds back
anything a fragment boundary cut in half: a `\` split from what it escapes, a `\uXXXX` split
from its digits, a surrogate pair split down the middle. That last one is not cosmetic — a lone
surrogate lives happily in a Python `str` and then fails to encode into the SSE frame carrying
it, so the pane would have killed its own stream. Malformed JSON marks the reader broken and
keeps what it decoded; a preview is never worth an exception in a turn. On top sits the throttle,
the answer to #541's "a large document must never starve the chat deltas": since the document
and the answer share one stream, what protects the answer is bounding the document, so a
`doc_preview` frame goes out at most every **100 ms** per call (`PREVIEW_INTERVAL_S`) or sooner
once **4096** characters have piled up (`PREVIEW_MAX_CHARS`) — the interval bounds the frame
rate at ~10/s whatever the model's token rate, the cap bounds frame size, the first slice goes
out immediately so the pane appears at once, and the tracker is flushed when the gateway stream
ends so the tail is never withheld. The new `doc_preview` SSE kind is the first top-level kind
added since the protocol settled, and the only **purely ephemeral** one: `{tool, text, preview}`,
where `text` is the coalesced body delta and `preview` is `{module, target?, title?}`, repeated
on every frame so a frame stands alone. It never reaches the timeline — live or persisted — for
the same reason v1's finished `document` payload doesn't (ADR-0041), only more so, because a
preview reads an *unfinished* call; `target`/`title` are reported only once their argument has
closed, so the header fills in instead of flickering through a half-typed title. **Re-attach
needed a decision and got the smaller one:** previews ride the same seq-tagged live-run buffer
as every other frame, so `GET /runs/{id}/stream?after_seq=N` replays them in order — from 0 a
reload rebuilds the body from its deltas, from N a resume gets only what it missed. Holding the
latest coalesced snapshot and re-sending *that* would make the run carry document state it has
no other reason to keep, and would hand a resuming client a prefix it already has. On the web
the pane opens on the first character the model types and steers on every one after it: the
store concatenates deltas into `liveDocument` (`streaming: true`), a caret marks text still
arriving, and the panel's `replace(payload, title)` finally has its first caller (#659's
affordance) so a title that finishes typing late reaches the header instead of leaving it
reading "Document" for the whole write. The hand-off is v1's settle path unchanged and is what
keeps ADR-0101's promise that the pane never lies about whether a write happened: the following
`tool` frame carries the arguments as actually *parsed* and replaces the previewed body
wholesale, so a preview that drifted cannot outlive the real thing. The pane stays **read-only**
from the first previewed character through the settle, so #541's edit/write conflict — the one
v1 sidestepped structurally — stays impossible rather than becoming live; closing the pane
mid-typewriter stays closed for the rest of that write. `core-app` 0.113.0→0.114.0 (MINOR),
`web` 0.136.0→0.137.0 (MINOR).
- **Send it any link and the agent can actually read it** (#739) — `web_search` could *find* a
page; nothing in the platform could *open* one. No URL-fetch tool existed anywhere, in
websearch or in the agent's built-ins, so "save the substance of this article to my knowledge
base" bottomed out at a search snippet plus whatever the model happened to remember about the
site. The save half was already done (`knowledge_propose_edit`, #220/#722) and so was gateway
vision (#633/#711) — but only for a chat attachment, never for an image behind a URL. The
missing piece was the reading. websearch gains a second tool, **`link_ingest(url)`**,
returning `{kind, title, site, author?, published?, text, image_descriptions?, transcript,
notes}` across three tiers: articles and ordinary pages (readability-grade body text plus
OpenGraph/JSON-LD metadata), direct images (described by the core's vision model), and public
video/reel/audio links (metadata plus the uploader's own subtitles). It stays **inside
websearch** rather than becoming a module of its own — the roadmap forbids new modules before
1.0, and metadata-only ingestion needs no ffmpeg and no OS packages, so the image is
unchanged. It calls **no other module** (ADR-0004): the tool returns an extract, and composing
it into a document and filing it is the agent's own next step through the knowledge tools —
which is why the steering lives in the tool docstring and in a new rung on #703's grounding
ladder in the default agent instructions (read the link, work from what came back, keep the
source URL and retrieval date on anything filed). Extraction is `trafilatura`, chosen over
`readability-lxml` + `beautifulsoup4` because one dependency covers both the body text and the
metadata the contract needs; `yt-dlp` is a lazily-imported, failure-tolerant extra that
degrades to oEmbed + OpenGraph when it is absent or an extractor breaks overnight.
Two things are deliberately refused rather than approximated. **Transcription**: `transcript`
is reserved and always empty — ASR is a new gateway modality (milestone 5.0.0) — and the
platform's own machine captions are not used either, since passing a platform's speech
recognition off as the video's text would blur exactly the line the contract draws;
uploader-published subtitles *are* included, in `text`, labelled as captions.
**Authentication**: no sign-in, no credentials, no cookie jar, no CAPTCHA circumvention —
structurally rather than as a promise, since the extractor is handed no credential source at
all and a URL carrying `user:pass@` is refused outright. A private or login-walled link comes
back as `kind: "unreachable"` with a note saying so, and the same is true of a dead host, a
PDF, a captionless video, or an image no model could see: every failure is a well-formed
result carrying an honest note, never a raised exception. The agent's job is to relay the
gaps, not to fill them.
This is the first tool in the platform that fetches an **arbitrary, operator-supplied URL from
inside the Docker network**, where the core, Postgres, Valkey, Qdrant, OpenBao, and the docker
proxy all answer without authentication — and nothing server-side existed to reuse. So it
ships a purpose-built SSRF guard: `http(s)` only; no credentials in the URL; single-label
hosts refused (on this network a dotless name *is* a service name) along with `.localhost`,
`.local`, `.internal`, `.localdomain`, `.home.arpa`, and `.onion`; the host resolved and
**every** returned address checked against the private, loopback, link-local, reserved,
multicast, and CGNAT ranges in both families, with IPv4-mapped and 6to4-wrapped IPv6 unwrapped
first so `::ffff:127.0.0.1` cannot smuggle a loopback through; and — the part that matters
most — redirects followed **by hand** so every hop is re-validated before it is requested,
because a public URL that 302s to `169.254.169.254` is the classic exploit and the hop is what
it turns on. Bytes, wall-clock across all hops, redirect count, and content types are all
capped, tunable through new `LINK_INGEST_*` settings with conservative defaults. yt-dlp does
its own HTTP outside that client, so it is confined to an allow-list of known public media
platforms and only ever sees a URL that already passed the guard. One residual gap is stated
rather than papered over: the guard resolves the host and httpx resolves it again to connect,
so DNS rebinding between the two is not caught — closing it needs connect-time address
pinning, which httpx does not expose without a custom transport.
Tier 2 forced a gap in the core into the open. `POST /platform/v1/chat` had **no vision
gate**: `supports_vision` guarded only the interactive agent turn (#633), so a module sending
image content-parts to a text-only model got either a silent ignore or a raw provider error —
precisely the two outcomes that gate exists to prevent — and `link_ingest` would have been the
first module-initiated vision inference to hit it. The endpoint now applies the same check
before any provider call and refuses with a structured **400**
(`{"error": "unsupported_media", "message": …, "model": …}`) a caller can branch on; both the
OpenAI `image_url` and the Anthropic-native `image` spelling trigger it, and an image anywhere
in the history counts, not only in the last message. websearch catches exactly that 400 and
degrades to metadata plus a note naming the model and the fix, so an operator with no
vision-capable model configured still gets the article — just without the picture described.
Text-only requests are untouched and pay no extra capability lookup. No new client helper was
added to `epicurus_core`: `ChatMessage.content` has accepted `str | list[dict] | None` since
#633, so the module builds content parts over the existing `PlatformClient.chat` and the wire
contract is unchanged. `websearch` 0.2.2→0.3.0 (MINOR), `core-app` 0.112.0→0.113.0 (MINOR).
- **Going Google-free is now a first-class path, per module and platform-wide** (#764) — the
operator's instinct was that it wasn't possible at all, and the shell had been quietly proving
them right. Everything needed already existed: the ADR-0030 collections panel could untick a
Google list, and Settings could disconnect the account outright. But "stop using Google in
tasks" was N individual unticks, after which the Google block kept its full visual weight and
nothing anywhere said the module was now local-only — configuration archaeology dressed as a
setting. The panel now offers **"Stop using Google in this module"**: one write against the
existing prefs API that disables every one of that account's collections, clears the write
target back to the built-in local default (and only when the active collection belonged to
that account — a second provider's target is never collateral), and collapses the block to a
single quiet row, *"Google — not used · Use again"*. Tokens are untouched, so it is per module
by construction: every other module keeps working, nothing at Google changes, and one click
undoes it. Two deliberate decisions, recorded as ADR-0122. The collapsed state is **derived,
never stored** — "not used" *is* "none of this account's collections are enabled", the only
place it could live given `CollectionPrefs` holds `{enabled, active}` and nothing else, so it
can never disagree with the toggles it replaces (safe as a default because connecting an
account seeds every collection enabled, #209, making a connected account with nothing ticked
always a deliberate act). And **"Use again" re-seeds rather than replays**: it enables all of
the account's collections and makes the first writable one active — exactly what a fresh
connect does — because restoring a hand-picked subset would need hidden session state that
silently stops working after a reload, i.e. two behaviours behind one button; the toggles show
what is on, so re-narrowing is one tick.
The other half was the global disconnect, which worked but didn't *look* like it had.
Disconnecting deletes the tokens and strips the provider from every module's stored selection
(#209), yet the web kept several deliberately long-lived caches describing what modules can
see — the calendar's account view holds Google calendar names and colours for five minutes,
the mailbox its thread list for thirty — so the next page the operator opened still painted a
connected account. The disconnect mutation now invalidates the module-facing keys by prefix
alongside its own status. And mail, the one module with **no local provider** (ADR-0032: no
collections, no fallback mailbox), had no story at all for the disconnected state: the page
relayed a raw `httpx.HTTPStatusError` under a scope hint that didn't apply, and the tools
re-raised it at the agent. The token seam now distinguishes the case —
`GmailProvider._get_token` maps the core's documented 404/400 to `MailNotConnected`, translated
*there* precisely so it can never be confused with Gmail's own "no such message" 404 — and
every surface answers honestly: each MCP tool returns one model-actionable sentence naming both
ways out (connect it in Settings, or disable the module) instead of raising, `mail_send`
refuses to compose a draft that could never be delivered, the page returns a valid empty list
carrying `disconnected` that the shell renders as an honest empty state with a tap to each
exit, a leftover message chip answers 503 rather than a 404 claiming the message was deleted,
and `mail.sync_failed` is no longer emitted — an absence the operator chose is not a failure to
alert on. The local cache is kept but not served, so a reconnect restores the mailbox with no
resync and no restart. Not in scope, and deliberately so: local-first replacements for the
Google-backed capabilities themselves (no local mailbox, no richer local calendar) — going
Google-free means keeping the local half, not re-implementing the other one. New operator note,
`docs/user/running-without-google.md`. `web` 0.135.0→0.136.0 (MINOR), `mail` 0.18.1→0.19.0
(MINOR).
- **Folded the Can into the Tasks page as a Show → Backlog option, instead of a second nav
entry** (#820) — the Can's own **partition** (#766, the board shows only dated tasks, the
backlog holds the rest) was right; its **placement** wasn't. A second left-nav page gave
the backlog the visual weight of its own module and split one workflow — triage the
backlog, schedule things onto the board — across two pages. The *Show* control (already
shared by the board and the Can, open/completed/all) gains a fourth value, **`backlog`**:
a page-level dated-ness partition, deliberately **not** a widened `TaskScope` — the app
branches on it before the provider fetch rather than teaching the providers a fourth read
scope. The Can's own `PageSpec` and its `GET /pages/can` route are gone (`can` now 404s
like any other unknown page id); its data now comes back from
`GET /pages/board?show=backlog`, rendered by a new `build_tasks_backlog` that keeps the
Can's exact shape — a flat column, an Add with no due/repeat field, and each card's
leading Schedule action. *Group by* is omitted whenever Show is Backlog (a flat backlog
has nothing to group, the same dead-knob rule #767 already gives List/Calendar), and the
**Calendar** view drops `backlog` from Show's own options entirely — a backlog has no due
dates to place on a grid — correcting a stale or explicit `show=backlog` back to Open
whenever `view=calendar`, so the control's echoed value is always inside its own offered
options. One axis nuance needed a decision: Show used to mean *status scope* on the board
and, independently, the Can page's *own* Show filter over the backlog; folded onto a
single control, only one Show value can be active at a time, so the backlog can no longer
carry that second, independent filter. The chosen rule fetches every status for the
backlog regardless and splits internally — open and in-progress tasks lead in a flat
**Backlog** column, any completed undated task follows in its own muted **Completed**
column (an ordinary struck-through card) — rather than making completed/all leak undated
items onto the dated board, which would have broken its "dated tasks only, no 'No date'
bucket" invariant. Either column is dropped when empty, neither is ever omitted, which is
what keeps the acceptance bar: no undated task, open or completed, becomes unreachable.
Agent- and web-form-facing copy was swept from "the Can" to "the backlog" / "Show →
Backlog" throughout — `tasks_add`'s tool description, both `due` parameter descriptions,
and a task's hover-card `href` (now a `?show=backlog` deep link on the one Tasks route
rather than a separate page's URL) — so the words the agent uses match what the operator
sees.
`tasks` 0.22.1→0.23.0 (MINOR).
- **Disabling a connected calendar account silently emptied the Calendar page** (#814) —
`CollectionRouter` resolved a *missing provider* in exactly opposite ways on the write and
read paths. The trigger is a stale `enabled`/`active` collection reference: nothing prunes
the operator's stored selection when the account behind a reference is disconnected, so the
dead reference stays in place. A write (`calendar_create_event`, `calendar_find_free`) hit
`self._provider_for(ref.account) or self._local` and fell back to the local store; the
`list_events` aggregate hit the same condition and `continue`d — and because its fallback
(`prefs.enabled or [_LOCAL_REF]`) only triggers when `enabled` is *empty*, never when a
reference inside it is dead, it never consulted local at all. So an event created through a
stale reference was written, persisted, and then absent from the Calendar page, while the
tool honestly reported a real, saved `Event`. Neither side was lying; they simply disagreed
about what "this reference's provider is gone" means. The skip was also completely silent —
the neighbouring transient-failure path logs, but a disconnected account left no trace, so
nothing in the logs hinted that a read source was being dropped. This is the calendar twin
of the same defect reported against tasks (#795); the blast radius was smaller here only
because `_search_refs` always appends the local reference, so single-event lookups
(`get_event`/`update_event`/`delete_event`) already walked past a dead reference and reached
local — the aggregate read had no such backstop.
Fixed by giving every read and write one shared rule, `_resolve_provider`: a live provider
for the reference's account is used as-is; a missing one **degrades to the local
collection** and logs a warning naming the account and collection that went missing. The
degraded reference is replaced by the local reference rather than merely re-pointed, so a
write is never handed the dead account's collection id and the events a degraded read
returns are tagged `local` — a token the page's per-calendar visibility toggles can match,
rather than a calendar the operator no longer has. A `_dedup_refs` helper applies the rule
across a multi-reference read and de-duplicates by *effective* target, so two independently
stale references — or a stale one landing on a local entry already enabled — read local once
instead of double-counting its events in the returned window. Transient read failures keep
their existing skip-and-log (#209): a provider that is live but erroring is a different
condition from one that is gone. With no `active` set the write-default scan is choosing
among candidates rather than honouring a stated one, so passing over a dead reference there
stays silent and unchanged; an explicitly-set `active` that has gone stale degrades to
local, deliberately not to some other external calendar the operator never named.
`calendar` 0.18.1→0.19.0 (MINOR).
- **The MCP SDK moved to 2.0** (#792) — a genuine breaking API change in the two places that
carry the module↔core contract: `epicurus_core.module`, imported by every module, and the
core's MCP host. `FastMCP` became `MCPServer` (the decorator API itself unchanged), every
transport parameter moved off the server constructor onto `streamable_http_app()`, the result
attributes went snake_case (`inputSchema`→`input_schema`, `isError`→`is_error`, the wire JSON
unchanged via aliases), `call_tool` returns a `CallToolResult` rather than a
`(content, structured)` pair, the low-level transport/session/`initialize()` layering
collapsed into a single `Client` owning all three with `float`-seconds timeouts instead of
`timedelta`s, and the SDK now raises `httpx2` exceptions from its own httpx fork. **No module
source changed**: the `EpicurusModule` constructor, the `tool()` decorator, `manifest()`,
`http_app()`, and the `session_manager.run()` lifespan hook all survive 2.0 verbatim. Two
additive members close the one hole 2.0 opened — module *tests* were reaching through
`module.mcp` into raw SDK surface that moved — so `EpicurusModule.call_tool()` is now the
stable in-process invocation surface returning the pre-2.0 pair, and `ToolError` is
re-exported from `epicurus_core`; an SDK reshape now lands in the library once instead of in
every module's tests. `McpHost`'s outward contract is byte-identical: discovery returns the
same specs and routes, and a call still distinguishes a tool that ran and reported failure
(#435) from a module that never answered (#472), with every hop bounded. One deliberate
hardening: discovery now carries the same 30s RPC read bound as a call, because 2.0's default
HTTP read timeout is 300s — sized for long-lived SSE streams, far too generous for a scan
that runs on every agent turn.
`epicurus-core` 0.33.0→0.34.0 (MINOR), `core-app` 0.112.0 (MINOR), and a test-only PATCH for
every module whose tests were migrated: `tasks` 0.22.1, `mail` 0.18.1, `calendar` 0.18.1,
`notes` 0.12.1, `knowledge` 0.27.3, `storage` 0.9.1, `echo` 0.5.1, `websearch` 0.2.2.
- **A push notification could die at any of six gates without a single trace** (#797) — push
crossed six independent conditions on the way to a device (no event subscription, the
per-subscription rate cap, the category's push toggle, quiet hours, the tenant-wide rate cap,
no registered device) and several of them declined in complete silence, so "I got no
notification" was indistinguishable from a broken pipeline and there was no way to bisect the
two without instrumenting the code. Every gate now logs one distinct, greppable line naming
the tenant and the category or module/type — `event alert declined: no subscription for this
event`, `event alert rate cap reached`, `push skipped: push disabled for this notification`,
`push queued for quiet-hours digest`, `push rate cap reached; delivery skipped`, `push
skipped: no registered devices`, `push send failed`, `pruned dead push subscription` — and a
clean send logs none of them, so a decline line always means a decline. Settings gained a
delivery-state surface behind a new `GET /platform/v1/push/status`: the registered-device
count and the most recent delivery *attempt* with what became of it, in plain language, where
a send that reached zero devices with failures reads as failed rather than as success. A
warning banner now fires at configure time when push is enabled somewhere but no device is
registered — the misconfiguration that used to fail silently once per delivery. The
last-attempt readout is in-memory, single-instance v1, the same disposable-cache trade the
rate windows make: `null` after a restart means "nothing attempted since boot", not "never".
**Behavior change — the notification center is now a superset of push, amending ADR-0102 §4
and ADR-0104 §1.** `push` and `center` used to be independent per-category toggles, so a push
missed while the device was off — or that failed outright — could simply be *gone*, and
`center: off` suppressed the durable row entirely. The center row is now written
unconditionally for every notification that fires, quiet-hours-queued and outright-failed
deliveries included, and `push` means "*also* deliver to devices". `ChannelPrefs.center` is
vestigial: still accepted, stored, and returned on the wire, so nothing on the contract
breaks, but delivery no longer consults it, and `NotifyResult.notification_id` is therefore
always set. **Anyone relying on `center: off` to suppress rows is affected** — that state no
longer exists; the only way for an alert not to be recorded is for it not to fire. The event
alerts card's independent Push/Center switch pair collapses accordingly into two coupled
switches over the unchanged `{push, center}` wire shape — **Alert** (the master, on iff either
stored flag is on) and **Push** (also deliver to devices, implying Alert) — and legacy
`{push: true, center: false}` rows read as both on. The per-subscription event-alert rate cap
still gates the whole notification, center row included: a firehose into the center is still a
firehose. The tenant-wide push cap gates delivery only.
`core-app` 0.111.0 (MINOR), `web` 0.134.1→0.135.0 (MINOR).
- **Tasks added while an account was disconnected were saved and then invisible forever**
(#795) — `TasksRouter` resolved a *missing provider* in exactly opposite ways on its write and
read paths, and the trigger is a stale `enabled`/`active` collection reference, which nothing
prunes when the account behind it goes away. A write fell back to the local store — but kept
the stale reference, so the task was mislabeled with the account and collection it had fallen
back *from*, visible in the emitted `tasks.task_created` event's `dedup_key` and in the list
id handed to the local provider. The read aggregate hit the very same condition and
`continue`d past it, never trying local at all, because its fallback only fires when `enabled`
is *empty*, never when an entry inside it is dead. So `tasks_add` reported a genuine,
persisted task and the very next `tasks_list` or board read came back empty — not lost, just
unreadable through the router — and the skip was silent, unlike the neighbouring read-failure
path which logs. Fixed by giving every read and write one shared rule, `_resolve_provider`: a
live provider for the reference's account is used as-is; a missing one degrades to the local
collection — replacing the reference rather than merely re-pointing it, so a write is never
mislabeled — and logs a warning naming the account and collection that went missing. A
`_dedup_refs` helper applies the rule across a multi-collection read and de-duplicates by
*effective* target, so two independently stale references, or a degraded one landing on a
local entry already enabled, read local once instead of double-counting its tasks. The
explicit-`list_id` write branch funnels through the same rule, which also closes a related
asymmetry: a `list_id` matching a stale enabled reference now degrades to local instead of
falling through to "the sole external provider owns this unlisted id" and potentially
misrouting to an unrelated account. Pruning the stale reference itself is deliberately left
alone — the core's disconnect cleanup is best-effort by design and a module-side prune would
need a new write-back into the shared library — because the router-level rule makes staleness
permanently harmless whatever caused it. The calendar module had the identical defect; it is
fixed separately as #814.
`tasks` 0.21.0→0.22.0 (MINOR).
- **`mail.received` only fired while the Mail page was open, so automations and push alerts on
new mail never ran** (#796) — the event had exactly one emitter, `CachedMailbox.reconcile`, and
exactly one caller: the mailbox page's `?reconcile=1` read, which the web fires when an operator
opens Mail. Mail had no background worker at all, unlike calendar and tasks, both of which have
shipped a scheduler since #664. So "notify me when mail arrives" could only ever fire *after* the
operator had already seen the mail — the inverse of the feature. Nothing downstream was broken:
the automation matcher and the per-event alert listener were wired and waiting on an event nobody
emitted. Worse for the reported case, a cold or expired cursor fell back to a full sync, which by
design emits nothing (the no-firehose rule), so the first open after a while — exactly when the
most mail had accumulated — was the one guaranteed to announce nothing at all.
Mail now runs the loop itself (`epicurus_mail.poller`, started and cancelled by the service
lifespan, the same shape calendar and tasks use). A tick is `is_available()` →
`reconcile("INBOX")` and nothing else: deliberately only a *caller* of the existing path, so the
events, payloads, dedup keys and every consumer downstream are literally the ones the page
produces, and the two can never drift. It is **on by default** at `MAIL_POLL_INTERVAL_S=300`
(`0` disables the loop entirely) — an event contract that holds only while a human is watching
the page is not a contract, and the cost is one history-delta call per tick. An unconnected
deployment costs one token-presence check per tick and logs nothing at all.
Two supporting changes fall out of having a second caller. Reconcile is now **single-flight per
account**, so a poll tick and a page read serialize on the change cursor instead of both
announcing one message (per process, like the tasks scheduler's `_claim_materialize`; the
spine's message-id `dedup_key` is the backstop beyond that). And the no-firehose rule is split
in two by the cache's `synced_at` stamp: a **first-ever** sync still emits nothing, while a sync
that *resumes* a mailbox synced before — the cursor lapsed while the service was down — replays
`mail.received` for what arrived since that instant, newest first, capped at 50 messages, via a
new capability-gated `MailProvider.messages_since` (Gmail: one `messages.list` with an
epoch-second `after:` term). A provider that can't answer inherits the empty default and simply
gets no replay; the full sync itself is never blocked or failed by it.
`mail` 0.17.0→0.18.0 (MINOR).
- **Entities the assistant named in an answer were plain text, never clickable** (#794) — the
shell has rendered an inline `epicurus://entity/<module>/<kind>/<ref_id>` markdown link as an
interactive chip since ADR-0019, excluding anything so linked from the trailing "Sources"
pill, but the model was never told the syntax exists — so it never emitted one, and every
reference fell through to the pill unconditionally. The one place the model does see
reference ids, the "Referenced items" block appended to a tool result whose envelope carries
entity refs (ADR-0079), framed them as tool *inputs* only: "pass an item's id to a tool that
needs one". That block now carries each reference's ready-made link beside its id, and the
intro teaches the syntax — link what you mention, using the URL shown, verbatim. Fixing it in
that one place fixes every module at once, the same reasoning that introduced the id listing.
Every component of the URL is percent-encoded, `/` included, because the web's inline-link
matcher stops at the first `)` or whitespace and then decodes: a `ref_id` containing either
would otherwise break the link, and being markdown, break it *silently*. Handing the model
the finished URL rather than three raw fields to assemble removes the failure mode entirely.
The instruction is deliberately to link what is actually named rather than everything — a
twenty-hit search should not become twenty chips, and the pill remains the outlet for the
long tail. No web change: the renderer, the pill exclusion, and the graceful degradation for
an unknown or malformed link all already existed.
`core-app` 0.109.0→0.110.0 (MINOR).
- **A bare weekday name landed the event exactly one week late** (#793) — the `now` built-in
handed the model the current date, time and weekday *name*, leaving weekday-to-date
conversion as head arithmetic: count forward from today's weekday, carry the month or year
boundary, hope. Nothing anywhere downstream could catch a slip, because the calendar accepts
any well-formed ISO date — a date a week out is a perfectly valid event, not an error — so
the failure was silent and self-confident, and "Wednesday" reliably meant the Wednesday after
the one that was asked for. `now` resolves it in the tool now rather than describing it: the
payload gains `today` and `tomorrow` as ISO dates, plus an `upcoming` map from every weekday
name to its next date, strictly in the future — asked on a Friday, `upcoming.Friday` is next
Friday, not today. All three are computed from the same zone-resolved instant the rest of the
payload already uses, so the operator's configured timezone decides what "today" is rather
than UTC, the same trap #559 closed for calendar read paths. The tool description now tells
the model to take `upcoming` verbatim and never to count days itself, and to ask rather than
guess when phrasing like "next Monday" is genuinely ambiguous *and* the difference matters.
`core-app` 0.108.0→0.109.0 (MINOR).
- **Cold-switching documents in the editor could save the outgoing note's content over the
newly-opened one — data loss** (#781) — every `EditorView` host (Notes, Knowledge, the chat
document pane) shares the same Preview surface: Milkdown's Crepe (#377), deliberately
*uncontrolled* after mount — it reads its markdown once and, by design, ignores later prop
updates, so a live cursor never fights a reset; the parent instead re-keys the component on
the document path to reseed it with fresh content, per its own doc comment. That contract
broke on a **cold** switch — opening a document whose content hasn't reached the browser yet.
`openDoc` flushed and re-pointed `selectedPath` to the new document immediately, remounting
the WYSIWYG under its new key, but the seed that hands it real content only runs once the
fetch resolves and the placeholder-data guard (#712) lets it through — so the remount picked
up whatever the buffer still held: the *outgoing* document's markdown. Crepe mounted on it
while `selectedPath`/`seededPath` had already both moved on to the new document, and the next
transaction in that live-but-foreign surface — a click, a keystroke, Crepe's own post-init
normalization — reported it back through `onChange` as if it were a genuine edit of the new
document. Every save guard then passed honestly: the idle timer, the re-click flush, and the
leave/refresh flush each wrote the *old* note's content to the *new* note's path. A warm
switch (the document already cached this session) never showed it — the seed lands during the
same render, before the remount commits — which is why "click over and back" looked fine and
made the bug easy to miss in practice.
Fixed with two independent layers. The editor pane now gates on `selectedPath ===
seededPath`: neither the WYSIWYG nor the raw-source view mounts until the newly-selected
document's own content has actually seeded the buffer — a cold switch shows a brief loading
state instead of a frozen, still-editable snapshot of the old note; a warm switch stays
instant, since the seed already resolves before the gate is checked. As defense in depth, the
WYSIWYG now reports the identity of the document it was mounted for (`docKey`) with every
change, captured once at mount and never re-read; the parent drops any report whose `docKey`
no longer names the live `seededPath`, so a stray write can never land on the wrong document
however it arises. The vitest mock for the WYSIWYG was itself part of why this stayed
invisible to the suite: it was fully *controlled* (`value={value}`, re-rendering on every prop
change), so it could never reproduce a stale mount — it is now `defaultValue`-based and
mount-faithful, like the real Crepe. Every save already snapshots a version (ADR-0046); a note
clobbered by this bug can be recovered from the previous entry in its History dropdown.
`web` 0.134.0→0.134.1 (PATCH).
- **The "can't reach epicurus" banner no longer flaps on a single dropped request**
(#791) — the banner (`ConnectionBanner`, `src/App.tsx`) used to be one single-strike
fetch failure away from covering the shell: any one `TypeError` or gateway 502/504,
anywhere in the app, rendered it on the next paint, and any one healthy response
cleared it — on a loaded self-hosted box (CPU-only inference, Docker Desktop VM) a
transient gateway hiccup reads exactly like an outage, so the banner appeared
regularly, lived a few seconds, and vanished, with nothing shown about what actually
failed. The banner's own signal (`confirmedDown`, `src/stores/connection.ts`) is now
debounced: a first failure only arms a `pendingDown` state, and `useConnectionWatch`
immediately fires one confirming re-probe (the same vitals refetch recovery already
uses); the banner renders only once that evidence persists past a short grace window
(`UNREACHABLE_GRACE_MS`, 5 s) or a second failure confirms it sooner — so an ordinary
blip resolves silently and a genuinely stopped stack still surfaces within ~5 s.
`coreDown` itself is unchanged (still eager, single-strike) — the composer's Send-gate
and the Files/Mail "connection lost" states still refuse instantly rather than wait out
a debounce window on an action that would just fail anyway. Every report now also
carries its evidence — the failing request's method, path, and failure class
(`TypeError`/`502`/`504`) — kept in the store and logged via `console.debug`, and named
in the banner's `title` tooltip on hover, so a flap can be attributed without
network-tab archaeology. `web` 0.133.0→0.134.0 (MINOR).
- **The automations `push` sink is wired up — all ten starter templates now deliver
something** (#723) — `push` has been valid sink vocabulary since the automations engine
shipped (ADR-0105), but `SinkDispatcher` never had a handler registered for it: a run
configured with `sinks=["push"]` executed, recorded a ledger row, and delivered nothing an
operator could see — exactly the case every one of the ten starter templates #717 shipped
was in. `make_push_sink` closes the gap the same way `document_sinks.py` closed it for
`notes`/`kb`: a thin adapter that calls `PushService.notify` under a fixed `"automation"`
category (with `automation_id`, so a per-automation override can silence just one without
touching the category default), title the automation's own name, body the run's raw output.
It goes *through* `notify()`, never around it, so quiet hours, the tenant-wide rate cap, and
the push/center toggles all apply exactly as they do for the settings UI's test button or
#732's event alerts. `PushService.notify` gains `NotifyResult.notification_id` — the
notification-center row's id, set the moment that row is written regardless of what push
delivery itself then does — so the sink can record it as an `EntityRef`
(`module="core"`, `kind="notification"`) on the run's `artifacts`, the same ledger field the
notes/kb sinks already populate; the runs feed renders a chip for it with no further UI
work. `PushService._send_now` also gains a 500-character cap on the outgoing push payload's
body (an automation's full report should not be able to blow a push service's own size
ceiling) — the notification-center row keeps the caller's complete text regardless. `core-app`
0.107.0→0.108.0 (MINOR).
- **Nightly reflection can no longer propose edits to the base system prompt** (#762) — the
pass (ADR-0093) offered two targets: a named playbook, or `"instructions"` — the base
prompt itself. The second target is removed, by design: reflection reads **tainted
transcripts** (mail bodies, web results, document text the agent quoted), so outside text
could surface as a plausible edit to the very document the agent's rules live in; an
instructions proposal was a **full-document replacement** reviewed nightly under approval
fatigue, and a poisoned approval would persist in every future turn's system prompt; and
the governed system drafting its own governing document is a conflict of interest even
with review. Enforced at three layers: the reflection prompt now offers **playbooks only**
(the base prompt stays in its context read-only, explicitly non-proposable, so playbooks
don't duplicate base rules); `_resolve` drops any `"instructions"` target the model
returns anyway (logged); and the review sink refuses to stage a proposal against the
instructions path **for every origin**, with the instructions apply-path removed outright
— no future proposal source can quietly reintroduce the target. A pending instructions
proposal from before the upgrade still renders with its diff and is cleanly rejectable;
Approve refuses it with a clear message. Operator editing of the base prompt in Settings
is unchanged. Reflection also gains its **own off-switch**,
`PLAYBOOK_REFLECTION_ENABLED` (default on, now that it is playbook-only): disabled, the
nightly job reports "skipped — disabled" without spending a gateway call — previously the
only way to stop it was disabling all nightly maintenance. `core-app` 0.106.0→0.107.0
(MINOR).
- **Invisible chats** (#772) — a ghost toggle left of the model chooser starts a **fresh
invisible session**: everything works normally while you're in it, and when you leave —
toggling off, switching sessions, starting a new chat, or closing the app — the
conversation is **deleted, not archived** (the #771 cascade, so nothing survives anywhere).
The design is *persist-flagged, then hard-delete*: the session writes normally under a
server-side flag (`PUT /agent/sessions/{id}/ephemeral`, table `ephemeral_sessions`), so an
accidental mid-chat reload keeps the thread, while the flag hides it from the sessions
list and from **every learner** while live — never enqueued for fact extraction (checked
at learn time in both extraction modes, failing closed), excluded from nightly
reflection's transcript scan (via an `include_ephemeral=False` default on the store's
`sessions()`), and from `memory_search`'s past-conversation half. An **orphan sweep** —
at startup and on every session-list read (`GET /agent/sessions?active=<live one>`) —
erases any flagged session a crash left stranded, sparing the client-named live one and
any session with a turn in flight. Honest semantics, in the UI's own words: the header
reads "Invisible — deleted when you leave", and the footnote states that nothing is
learned while tool effects (tasks, mail, files) persist — like downloads from a private
browser window; usage metering still records tokens (the events carry no content).
Normal chats are byte-identically unaffected. `core-app` 0.105.0→0.106.0 (MINOR),
`web` 0.132.1→0.133.0 (MINOR).
- **Deleting a chat now deletes the chat — everything it produced, everywhere** (#771) —
`DELETE /agent/sessions/{id}` removed only the `agent_messages` rows, so a "deleted"
conversation lived on across every sidecar it had touched. Worst: the fact-extraction queue
(ADR-0051) carried the exchange *text* with no `session_id`, so a deleted chat's queued
exchanges could not even be targeted — the nightly drain still distilled the "deleted"
conversation into memory facts that night. Also surviving: the uploaded attachments' bytes
(`agent_attachments`), the per-session model override (`session_models`, #707), and any
paused runs (`agent_suspended_runs` / `agent_pending_drafts` / `agent_pending_approvals`) —
each carrying the conversation verbatim — while an in-flight turn kept generating into the
deleted session. Now: the queue is **stamped** with a nullable `session_id` at enqueue
(reconciled additively, ADR-0067; legacy rows drain as before), and the route runs a
tenant-scoped **cascade** (`SessionDeleteCascade`) — cancel + evict the live run, purge
queued extractions, clear the model override, drop the paused runs and the automation badge
mapping, delete the attachment bytes, then the messages last (a failure partway leaves the
session visible and retriable). The audited referencing stores keep deliberate exceptions:
`scheduled_turns` rows are operator-authored standing config (kept — the next delivery
starts the session afresh), `notifications` carry no session linkage (kept), and facts
distilled on *previous* nights are curated memory managed in the Memory view — the web
confirm dialog now states that boundary honestly instead of implying total erasure.
`core-app` 0.104.0→0.105.0 (MINOR), `web` 0.132.0→0.132.1 (PATCH — confirm-dialog copy).
- **Tags become a first-class part of the tasks board: group by them, and pick them as
chips** (#763) — the model and tools carried `tags` all along, but the UX was half-wired:
a bare comma-separated text input and no grouping. **Group by → Tags** joins the board's
dimensions, offered (like *List*) only when a visible task actually has a tag — the first
**multi-membership** grouping: a task appears under **each** of its tags, untagged tasks
land in an **Untagged** column, and columns sort alphabetically (case-insensitive,
Untagged last, stable across reloads). The add/edit forms' tags field becomes a **chips
input** — "a box inside a box": removable chips inside the field, Enter/comma commits,
backspace erases into the chips — via a new `format: "tags"` hint (the ADR-0082 seam),
with a typeahead over the module's distinct tags supplied as **`field_suggestions`** (a
new open-valued `field_choices` sibling: suggestions are offered, never enforced, so a
new tag is created by typing it). Serialization stays the tool's comma-separated string —
the MCP contract is unchanged. **Google honesty**: tags are local-only (Google Tasks has
no such field; the provider ignores them on write), so the field is hidden wherever the
write would land on Google — a Google task's Edit form, and Add forms whenever the
(external-only) list picker shows — never a silent no-op; Google tasks group under
Untagged. Drag-and-drop stays honest too: list-grouped columns now carry their
**`list_id`** and the shell matches drop targets by id — never by a display title a tag
column might share — and with no list columns on screen, cards don't offer a grab that
could only dead-end (drag-to-retag deliberately not implemented in v1). `tasks`
0.20.0→0.21.0 (MINOR) · `web` 0.131.0→0.132.0 (MINOR).
- **The Tasks page gets three representations of the same data — Board, List, and
Calendar — switchable in place** (#767). The `board` archetype gains a **reserved `view`
control** (an extension of ADR-0049's declarative controls): a board page declaring
`{id: "view"}` opts into the shell's client-side representations, rendered via the
standard segmented view switcher. **List** flattens the columns to one deduped row set —
title, due, priority, list, tag chips, the same per-card actions — with every column
client-side sortable (stable, missing values last); **Calendar** is a month grid
(Monday-start, the calendar archetype's geometry) placing each card on its due date,
with prev/today/next paging, chips toned by the board's overdue/today language, and a
chip opening the ordinary card + actions in a sheet (no new mutation paths anywhere).
To support this, board cards additionally carry their **structured fields** — `due`
(bare ISO date), `priority`, `tags`, `list_title` — as data beside the rendered badges
(additive; a board that sets none renders as before). The chosen view **persists per
page** (localStorage, the #743 pattern), a `?view=` deep-link wins over the stored
choice (and is kept in sync on switch), junk clamps to the kanban, and a board with no
`view` control ignores the param. The tasks module declares the control (Board / List /
Calendar), echoes the clamped `?view=`, and omits *Group by* off the Board view —
grouping shapes kanban columns; the flat/date-keyed views would render it as a dead
knob — while *Show* applies under every view; the payload is identical across views.
Undated tasks never appear in any of the three — they live in the Can (#766). The
calendar *page's* cross-module task overlay (#469) is untouched. `BoardView` is now
keyed per page in `ModulePageScreen`, so tasks' two board pages can't leak control
state onto each other. Day-cell quick-add is deferred. `tasks` 0.19.0→0.20.0 (MINOR) ·
`web` 0.130.0→0.131.0 (MINOR).
- **The Can: undated tasks get their own backlog page, and the board shows only what's
scheduled** (#766) — undated tasks used to land on the Tasks board in a "No date" bucket
under the due grouping and inside every other grouping's columns, so the backlog and the
plan shared one surface. The tasks module now declares a second left-nav `board` page,
**Can** (`/m/tasks/can`): one flat **Backlog** column holding every task without a due
date, across the same enabled lists the board aggregates (category badges preserved). The
board and the Can partition the same fetch by `due` alone — the board excludes undated
tasks under **every** grouping (the "No date" bucket is gone). Each Can card leads with a
one-tap **Schedule** action (a due-only `tasks_update` form prefilled to today, rendered
as the shell's native date picker via a new `format: "date"` hint on both tools' `due`
params — the ADR-0082 format seam, no new web code); clearing a due date moves a task
back to the Can. The Can's Add offers no due or repeat field, and its only view control
is *Show*, so completed backlog items stay reachable. Nothing vanishes silently:
`tasks_add`'s description and both `due` param descriptions (which double as the web
form's field hints) say an undated task files into the Can, and a task's hover-card
`href` now targets the page it actually lives on. Purely a read-partition — no provider
or DB change; lead-time notifications are untouched (they key on due dates, which Can
items don't have — by design). `tasks` 0.18.0→0.19.0 (MINOR); no web change — the shell
already renders manifest pages, form actions, and the date field.
- **Approving a delete suggestion no longer reports success when the file is already gone**
(#761) — `SuggestionReview.approve`'s `delete` branch caught the file API's 404 (the vault
document had already been removed some other way) and swallowed it outright, so both
callers landed on ordinary success: the operator's approval in the Suggestions UI closed
silently, and review-off auto-apply printed "Delete of '<path>' applied directly — review
is off" even though nothing was deleted. The suggestion is still fully resolved either way
— index cleanup runs, the audit row is recorded, the row drops from the queue, exactly as a
real delete — a stale suggestion must not be left un-resolvable just because there's nothing
left to remove. But `approve` now raises afterward instead of returning a false "approved":
a 404 naming the path and suggesting a next step (the #742 not-found bar: `"<path>" does not
exist — it may have been deleted or moved; list the folder for current contents`), so the
Suggestions UI shows an error instead of a silent close, and the review-off tool call fails
structurally instead of claiming the delete happened. `knowledge` 0.27.1→0.27.2 (PATCH).
- **A knowledge indexer test no longer fails one run in 256** (#769) — `test_indexer`'s
`test_mtime_ns_reconciles_from_a_raw_seeded_value` opened by asserting that a raw
`st_mtime_ns` and the indexer's derived `round(mtime * 1_000_000_000)` must differ, i.e. that
the `int → float → int` round-trip is always lossy. It is not: a float64's ULP near `1.8e18`
(the epoch in nanoseconds, as of 2026) is exactly 256, so any mtime landing on a multiple of
256 round-trips unchanged and the two agree — about 1 run in 256, and only where stamps carry
nanosecond granularity (ext4/CI; NTFS's 100ns ticks hide it entirely). Because `quality` is a
required check, an unlucky run blocked unrelated PRs — it was hit twice during the 2026-07-29
train, once on an npm-only change, which is what identified it as ambient rather than caused.
The scenario needs a ledger row whose `mtime_ns` *disagrees* with the derived value; how the
disagreement arose is immaterial, so the fixture now supplies one directly instead of relying
on float imprecision. Every real assertion in the test is untouched. `knowledge` 0.27.0→0.27.1
(PATCH).
- **The docs link checker now validates anchors in source references, not just the file** (#725)
— tier 2 of `scripts/check_docs_links.py` scans shipped source for repo-relative `docs/….md`
strings and asserted only that the file exists, so a comment pointing at
`docs/foo.md#some-section` kept passing after the heading it named was renamed or removed —
exactly the rot the checker exists to catch, and it fails silently: the reader follows the link
and lands at the top of the page with nothing reported anywhere. Anchored references now
resolve against the target file's headings, the same way tier 1 already validates internal doc
links. `_anchors_in()` additionally recognizes explicit `{#custom-anchor}` markdown syntax
alongside the auto-generated slug, so a heading that pins its own anchor is accepted rather
than reported broken. Tooling only — no package version bump (ADR-0017 scopes SemVer to
`services/*` and `libs/epicurus-core`).
- **Vision models are no longer mistagged as code models in the model catalog** (#727) —
`_derive_tags` matched the bare substring `cod(?:e|er|ing)` against each catalog entry's name
and description, and "encoder" contains "code": llava, whose blurb describes a *vision
encoder*, came back tagged `code`, so the Models page filed it under coding models and the
`code` filter offered a model that cannot write code. The pattern is now word-bounded. Its
sibling `multiling` keeps a **leading** boundary only, deliberately — it is a prefix that must
still match "multilingual"/"multilinguality", and bounding both ends silently dropped
gemma3:1b's tag, which `test_parse_derives_tags` caught on the first push. Ships inside
`core-app` 0.104.0.
- **First boot pulls the default models instead of 404ing until someone finds the Models page**
(#773, ADR-0118) — models are never baked into the Ollama image (the core owns the model
lifecycle, constraint #8, ADR-0010), so a fresh install booted an empty volume and the first
chat or embedding call failed with `model not found`, while the knowledge indexer failed
noisily in the background — verified on a from-scratch deployment. The core now closes that gap
itself: a fire-and-forget lifespan task waits for the runtime, resolves the deployment's
*effective* chat + embedding defaults (stored prefs, else `LLM_DEFAULT_MODEL` /
`MEMORY_EMBED_MODEL`), skips hosted-prefixed ids, and pulls whichever are missing through the
same `gateway.pull()` the Models page uses — then applies the same post-pull context sizing
(#386), so a first-boot model opens correctly sized too. It never blocks startup, readiness, or
a live turn; per-model retries back off exponentially (Ollama resumes partial downloads) and
exhaustion is a warning naming the Models page, never a crash. An unreachable runtime — a
hosted-only deployment running no Ollama at all — costs one warning after a bounded wait.
`LLM_BOOTSTRAP_MODELS` tunes it: `auto` (default), blank to disable, or an explicit
comma-separated list; the CI smoke override pins it blank, since the gate asserts the wiring,
not the weights. `core-app` 0.103.0→0.104.0 (MINOR).
- **Observability no longer mounts the raw Docker socket** (#724, #726, ADR-0109) — Prometheus
and Alloy each bind-mounted `/var/run/docker.sock` for container service discovery and log
tailing, and Prometheus additionally ran as `user: root` purely to read it — full Docker API
access, write included, for two components that only ever read. Both now reach Docker through
`docker-proxy-observability`, a third read-only `wollomatic/socket-proxy` sibling of
`docker-proxy-core` and `docker-proxy-traefik`, gated behind the same `observability` compose
profile so it exists only when that stack is up; Prometheus's `user: root` override is gone
along with the mount. The allowlist is GET/HEAD-only and was derived from a live boot rather
than copied from `docker-proxy-traefik` — both consumers use Prometheus's `discovery/moby`
library, which issues a `HEAD /_ping` probe Traefik never sends and calls `GET /networks` to
resolve each container's network labels, and omitting either makes discovery error outright
rather than merely degrade a label; Alloy's `loki.source.docker` separately needs
`GET /containers/{id}/logs` to tail at all, whose absence fails silently. New
`tests/test_docker_proxy_allowlist.py` renders the real `docker compose config` and pins all
three proxies' allowlists by equality, so widening one fails as loudly as breaking one — it
also asserts the socket reaches nothing but the proxies (scanned across every rendered service,
not a named few) and that the hardening flags (`cap_drop: [ALL]`, `read_only`,
`no-new-privileges`) stay put. Infra, docs, and a repo-root test only — no service or library
version bump.
### Added
- **Inbox category tabs on the mail page** (#765) — Gmail's Primary / Promotions / Social /
Updates tabs, in our mailbox: a strip over the Inbox list, each tab carrying its unread count
and a dim one-line preview of its newest message, and Forums appearing only when it has mail
(exactly as Gmail hides an empty one). Selecting a tab filters the thread list through the same
label/query mechanism the rail and search already use, so **Primary correctly means
inbox-minus-categorized**. Gmail's `CATEGORY_*` labels stay out of the folder rail where
ADR-0087 put them — a tab over one folder is a different UI element, and this is that element.
The `MailProvider` seam gains two **concrete, capability-gated** members — `list_categories`
(the tabs, as presentation-ready data) and `category_query` (the single point where a neutral
tab id becomes provider query syntax) — so a provider that doesn't classify mail inherits the
no-op defaults and the page renders exactly as it did before. The strip is drawn entirely by
the shell from module *data* (ADR-0018): no markup leaves the module, and the neutral tab ids
are the local-first seam — a future local mail provider can back the same tabs by classifying
through the core's LLM gateway (constraint #8) with zero shell change. Assembling the tabs is a
provider fan-out, so they are cached in a new tenant-scoped `mail_category` table on a short TTL
(`MAIL_CATEGORY_TTL_S`, default 60s, with a negative-cache row so an *uncategorized* mailbox
isn't the expensive case); a mark-read drops that cache, so the active tab's count converges
through the shell's existing invalidation with no full reload. `mail_search` gains a matching
`category` argument, so "summarize today's Promotions" works in chat without the model knowing
Gmail query syntax. `mail` 0.16.0→0.17.0 (MINOR) · `web` 0.129.0→0.130.0 (MINOR).
- **External file mounts** (#731) — add a directory to Files the way you'd add a drive: an
operator binds a host folder (or a whole drive) into `core-app` via a compose overlay
(`services/core-app/compose.external-mounts.yaml`, `task external-mounts-up` — never a
default bind, never auto-loaded), and it shows up as an additional top-level root on the
Files page and in the agent's `storage_list`/`storage_search`/`storage_read` tools,
addressed as `mount:<name>/<sub-path>`. Read-only by default; a declared `rw` mount is
writable through the same operations the tenant space has (upload/write/delete/move/mkdir)
— enforced **server-side** (403), not just hidden in the UI. Traversal and symlink-escape
attempts are rejected (400) via `PathEscapeError`, a new `epicurus_core.files` exception
type an app-level handler catches uniformly. Indexing/watching is **opt-in per mount**
(`FILES_EXTERNAL_MOUNTS_INDEXED`, with exclude globs) — a whole-drive mount never
auto-indexes by default. `LocalFileStore` gained a `tenant_subdir` flag so a mount can
address its own root directly rather than nesting a `<tenant>/` segment inside the
operator's drive. A move never crosses between the tenant space and a mount, or between two
mounts — mounts are isolated compartments. `epicurus-core` 0.32.0→0.33.0 (MINOR) ·
`core-app` 0.102.0→0.103.0 (MINOR) · `web` 0.128.0→0.129.0 (MINOR).
- **Per-event alerts** (#732) — "push me when X happens" for any module-declared event, no
automation required. A tenant-scoped `(module, event_type) -> ChannelPrefs` subscription
store, off by default, backs a new `EventAlertListener` wired beside the automations engine
at the same event-intake seam — a dumb fan-out (no agent turn, no ledger, no autonomy dial),
not an automation. `PushService.notify_effective` delivers through the existing
center/quiet-hours/rate-cap send path for a caller whose channel prefs don't come from a
category; a second, per-subscription rate cap keeps one chatty event type from spending a
tenant's whole push budget. An automation triggered by the same event still fires
independently — two notifications, by design. Settings gains an "Event alerts" block,
grouped by module with a Custom section for a free-typed `(module, event_type)` pair.
`core-app` 0.96.0→0.97.0 (MINOR) · `web` 0.125.1→0.126.0 (MINOR). ADR-0114.
- **Persisted maintenance-run history** (#733) — the Maintenance orchestrator's `last_run` was
a single in-memory slot, gone on restart, with no record of whether a run was scheduled or
manually triggered. A new tenant-scoped `maintenance_runs` table is the durable counterpart,
written via an `on_recorded` hook (mirroring `AutomationRunner`'s live-runs-feed pattern) on
every completion — including a shutdown-interrupted batch, persisted from `shutdown()` itself
since the driver's own `except CancelledError` can't safely await again once caught. Every run
now carries a `source` (`scheduled` | `manual`). `GET /platform/v1/maintenance`'s `last_run`
reads the store (survives a restart); a new paginated `GET /platform/v1/maintenance/runs`
pages back through it, newest-first; retention prunes past a row cap or age cutoff as its own
nightly-eligible maintenance job. Settings' Maintenance card gains a "Run history" list —
time, source, duration, per-job status chips, expandable detail, "Load more" pagination.
`core-app` 0.97.0→0.98.0 (MINOR) · `web` 0.126.0→0.127.0 (MINOR). ADR-0116 (amends ADR-0060).
- **`ask_approval` — pause a turn for an inline Approve/Reject of a staged change** (#745) — a
third sibling of `ask_user` (ADR-0053) and draft-first send (ADR-0085): after the model stages
a change through an existing propose tool, it can call `ask_approval(summary, refs)` to pause
in place rather than telling the user to go check Suggestions separately. The chat UI renders
an inline card (summary + a chip per staged entity); Approve/Reject each call the linked
change's owning module's *own* review-decision endpoint directly — the operator's own click,
never the agent, preserving `suggestions.py`'s existing "the agent must never approve its own
work" boundary — then resume the turn with the outcome. Chat-only by construction: the tool is
spliced only into the interactive streaming path, which has no `automation_id` concept at all,
so it is structurally absent from every automation/scheduled/inbound-bridge turn regardless of
autonomy level — those keep today's async review-queue behavior unchanged, no new toggle
needed. A new `agent_pending_approvals` table (a third sibling of
`agent_suspended_runs`/`agent_pending_drafts`) holds the pause; it decays to the same async
queue on expiry (`ASK_APPROVAL_TTL_HOURS`, default 24h) — nothing lost either way.
`core-app` 0.100.0→0.101.0 (MINOR) · `web` 0.127.0→0.128.0 (MINOR). ADR-0117 (extends ADR-0053,
ADR-0033, ADR-0085).
- **Knowledge and Notes remember where you left off** (#743) — opening the page always
landed on the module's default project, with every folder collapsed back to its initial
state and no document open, no matter where you'd actually been working: the active
scope, fold state, and open document were all plain component state, hydrated from
nothing on every mount. All three now persist to `localStorage` — the scope choice keyed
by `(module, pageId)`, fold state and selection by `(module, pageId, scope)` since tree
paths are scope-relative (restoring one project's folds onto another would be worse than
restoring nothing). Reopening the page — navigating away and back, switching projects and
back within one visit, or a full reload — restores scope, then folds, then the open
document, in that order; the document lands in preview per #729, same as any other open.
An explicit `?doc=` deep-link or a host-provided document (#541) still always wins over
restored state — they set the selection during render, before the restore effect (which
only ever fills an untouched slot) gets a chance to run. Decay is graceful: a restored
scope that no longer exists falls back to the module's own default (which re-triggers the
same restore for whatever that default resolves to); a restored document that's gone is
simply never selected, so it never gets a doc-fetch to 404 on — no error flash, just the
ordinary empty-state prompt. A real bug surfaced while testing this against a genuine
remount (not just a scope switch within one mount): the write-back effect was gated only
on `scope` being known, not on the restore having actually run for it yet, so on a fresh
mount it fired one render early with the plain in-memory defaults and clobbered the very
state the restore was about to read — fixed by gating the write on the same "restored for
this scope" marker the restore effect itself sets. Scroll position and in-document
read-position are out of scope. `web` 0.124.0→0.125.0 (MINOR).
- **Move Knowledge documents by drag-and-drop, and an honest actions menu** (#741) — there
was no way to move a document to another folder (or back to the root) at all, and the
row's "three dots" — the icon that universally means "more actions" — just deleted the
file; the real rename affordance was a bare `ChevronRight`, easy to mistake for
navigation. The move machinery already existed (`moveItem`, which rename already used) —
the gap was purely presentational. File rows are now **drag-and-drop movable**: drop one
on a folder row to move it in, or on the tree's own background to move it to the top
level; a collapsed folder auto-expands the moment a drag hovers over it, and Escape (or
dropping outside any target) cancels for free — the browser fires `dragend` regardless of
how the drag ended. The three old hover icons are replaced by one honest **⋯ actions
menu**: Rename, Move to… (a compact folder picker), New file in folder (folders only),
and Delete — still gated by the same themed confirm (#488), no longer the bare ⋯ icon's
own click action. Moving the currently open document keeps it open at its new path (no
dead pane, no 404 fetch) — the existing `moveItem.onSuccess` already handled this for
rename, unchanged. Both Move to… and drag-and-drop reuse the not-yet-saved guard #740
added for rename: moving an unsaved document swaps its path locally instead of a server
call that would 404. Drag-and-drop is desktop-only by design (native HTML5 DnD doesn't
fire from touch input), so the menu is what covers mobile and touch. Folder-to-folder
moves are out of scope for this pass — files only. Notes is untouched: `can_manage_files`
stays false there, so it shows no hover actions, same as always. `web` 0.123.1→0.124.0
(MINOR).
- **Resizable tree panel for Knowledge and Notes** (#730) — the editor archetype's document
list fixed the tree column at 18rem, so long titles truncated on a narrow tree and a wide
screen couldn't give the tree more room. A drag handle now sits between the tree and the
editor: pointer-drag resizes live (clamped to 12rem–40rem), double-click resets to the
18rem default, and the handle is keyboard-accessible (`role="separator"`, arrow keys nudge
the width) rather than mouse-only. The width persists per `(module, pageId)` in
`localStorage` (`editor-tree-width:<module>/<pageId>`) — the same pattern CalendarView
already uses for its own view state — so Knowledge and Notes remember independent
preferences. Below the `sm` breakpoint the layout is unchanged (a single stacked pane, so
there's nothing to divide); the fixed-width grid column becomes a CSS custom property
(`--tree-w`) read only by the `sm:` grid-template, so the responsive stack is undisturbed
by the same inline style always being present. One archetype change covers both pages
(Notes and Knowledge share the `editor` archetype, ADR-0018) — a shared `ResizableSplit`
component is deliberately not extracted yet; that's for if/when a second consumer (e.g. the
Files browser) actually needs one. `web` 0.122.1→0.123.0 (MINOR).
- **Knowledge/notes: suggestion lifecycle tools** (#744) — the agent could only *add* to its
review queue; "change that suggestion" had no better option than proposing a near-duplicate,
and the agent had no way to see what was already pending. Four new tools per module —
`{knowledge,notes}_list_suggestions` (the pending queue: id, kind, target, a content
preview), `_read_suggestion` (one suggestion's full proposed content), `_update_suggestion`
(revise content/note/target in place — stays pending, `proposed-at` refreshes, one queue
entry never two), `_withdraw_suggestion` (retract a pending suggestion, kept as history) —
let it check first and revise instead of duplicating. Update/withdraw stay strictly inside
the pending queue and never touch the vault/note, so they're safe to expose even though
approve/reject correctly stay off the MCP surface (letting the agent approve its own
proposals would defeat the review gate, ADR-0033). A refusal on an already-resolved or
unknown suggestion raises with a message naming *why* (already approved/rejected/withdrawn,
or unknown outright) rather than a bare 404 the model can't act on (#697 precedent). Notes'
`notes_read_suggestion` is a deliberate, narrow exception to "notes are private, no read
tool" — it echoes back only the agent's own draft, never a note's stored body. Every
existing propose-shaped tool in both modules now nudges the agent to check the pending
queue first. `knowledge` 0.26.0→0.27.0 (MINOR) · `notes` 0.11.0→0.12.0 (MINOR).
- **Per-saved-model capability overrides** (#711) — a saved hosted model can now carry the
operator's correction to what the core *believes* it can do. LiteLLM's static cost map is the
only source for a hosted model's vision support and context length, and it is missing ids
entirely (`grok/grok-latest`, which resolves to an unmapped `xai/grok-latest`) and mislabels
others; the consequence was concrete — `supports_vision()` resolved `False` and the image gate
(#633) refused image turns for a genuinely vision-capable model, leaving "rename your model to
a mapped id" as the workaround. The override is consulted **before** the map in both
`supports_vision()` and `_hosted_details`, and applies even when the map lookup fails outright
— an unmapped id is precisely the case it exists for, so that path must not be the one that
skips it. `vision: auto` (the default) with no `context_length` is today's behaviour exactly,
so an absent override changes nothing. New `PUT /platform/v1/llm/saved-models/capabilities`
(404 for an unsaved id — an override is a property of a saved row, never a way to create one);
`GET /saved-models` rows now carry both the **resolved** capabilities and the raw `override`,
so the editor round-trips without a client-side merge. The Models page's hosted-model sheet
gains the controls, and the saved rows finally render capability badges (they never did) —
driven by the resolved answer, so an override shows the moment it's saved. Two new columns on
`saved_models`, reconciled additively (ADR-0067). Gating and display only: routing, keys, and
metering are untouched (constraint #8). The `get_model_info`/`supports_vision` lookup misses
now warn **once per model id per process** and debug thereafter — an operator-saved alias
outside a curated static map is expected, not anomalous. `core-app` 0.92.1→0.93.0 (MINOR) ·
`web` 0.119.0→0.120.0 (MINOR).
### Fixed
- **The OpenBao app token no longer silently expires 32 days after bootstrap** (#728) — the
bootstrap minted the token with `-explicit-max-ttl=0` and called it "non-expiring". That flag
only removes the *explicit cap*: the result was a plain service token falling back to the
system default lease TTL of 768h, and nothing ever renewed it. Every self-hosted deployment
therefore lost **all** secret reads exactly one month after bootstrap — provider keys, OAuth
clients and tokens, module config, bridge credentials, push VAPID keys — presenting as a
per-path `permission denied` that reads like a policy bug (and, after a restart, as
`OpenBao client is not authenticated`, with core-app refusing to start). No test shape catches
this: CI's vault is minutes old, so the defense has to be design. The bootstrap now mints a
**periodic orphan** token (`-orphan -period=768h`) — the only non-root token with an unlimited
lifetime — and `SecretStore` grows the maintenance to match: `renew_self()`, a
`run_token_renewal()` loop core-app starts from its lifespan (daily against a 768h period, so
renewal can fail for weeks before it becomes an outage), and a **403 self-heal** that drops
the cached client, re-authenticates, and retries the operation once. Because the token is
re-resolved on rebuild, a rotated `OPENBAO_TOKEN_FILE` now takes effect without a process
bounce. Renewal failures are bounded warnings (the first, then every 7th) with a recovery
line, never a crash; a non-renewable dev root token is detected with a lookup-self and the
loop exits quietly. A 403 that survives the retry now names token expiry as the likely cause
instead of reporting a bare "permission denied". `messaging` holds the same token, so the
core's single renewer covers it too. **Existing deployments must still mint a periodic
replacement by hand** — this prevents future expiry, it cannot revive an already-expired
token; the migration is in [docs/infrastructure/secrets.md](docs/infrastructure/secrets.md),
and the symptom now has a runbook entry in `startup-and-recovery.md`. Also repairs a drift the
release process missed: `epicurus_core.__version__` still read `0.30.0` while the package
published `0.31.0` — a test now pins the two together. `epicurus-core` 0.31.0→0.32.0 (MINOR) ·
`core-app` 0.101.0→0.102.0 (MINOR).
- **The agent now verifies a stale-memory entity before mutating it, and recovers instead of
blind-retrying a not-found** (#742) — reported from a real trace: create a file via chat,
delete it in the Files UI, come back to the same chat and ask to move it — the agent acted on
the path it remembered from earlier in the conversation and failed on a nonexistent file.
`DEFAULT_AGENT_INSTRUCTIONS`'s "Doing things" paragraph gains two rules: **verify before
mutating** — something known only from earlier turns, not a tool result just now, gets
re-checked (list/stat/read/search) before a mutating call relies on it; **recover on
not-found** — when a tool reports something missing or different from expected, re-ground
with a list/search and report reality rather than retrying the same call. Both are prompt
text, so an operator who has already replaced the default doesn't inherit them automatically
— a docs note covers porting them into a custom prompt. Alongside: `files_routes.py`'s
`read`/`stat`/`move` 404s now name the path and suggest a recovery step (`"<path>" does not
exist — it may have been deleted or moved; list the folder for current contents`) instead of
a bare `"not found"` — the platform file API's own contract, hardened independently of which
specific tool a caller used. `core-app` 0.99.0→0.100.0 (MINOR — a behavior-visible
instructions change, the #704 precedent).
- **PDF attachments reach the model as real text, not mojibake** (#738) — the attachment
expander decoded every non-image file as UTF-8, so a PDF arrived as `[file: report.pdf]`
followed by thousands of replacement characters — the same noise images used to produce
(#633), just mistaken for text instead of flagged as binary. A new `document_extraction.py`
seam (`pypdf`, pure-Python, no system deps) reads a PDF's real text layer instead, page by
page (`[page N]` markers), bounded to 20k characters with a truncation note. An encrypted
(beyond an empty/owner-only password) or scanned (image-only) PDF renders an honest metadata
block — `[file: report.pdf — PDF, 12 pages, no extractable text]` — never mojibake, and the
attachment is never silently dropped either. The same honest-block treatment now catches any
*other* file whose UTF-8 decode turns out mostly replacement characters (a zip renamed
`.bin`) — no format gets to pretend binary is text. Out of scope, deliberately: OCR, docx/pptx
extractors (the seam is built to make adding one trivial, not built yet), and native PDF
pass-through for a hosted model that accepts documents directly. `core-app` 0.98.0→0.99.0
(MINOR — a new `pypdf` dependency; no web change).
- **A flaky `AutomationsScreen` test under full-suite CPU contention** (#758) —
`"toggles an automation without a reload"` reached for a `getByRole("switch", …)` query
as its very first assertion after render, with no cheaper checkpoint first; a role query
computes accessible names over the whole tree and is measurably pricier than a text
match, so it timed out twice in full-suite (`npx vitest run`, no file filter) background
runs while every sibling test in the file either settles on plain text first or (in one
case) already follows that exact pattern. It always passed cleanly in isolation — a
timing symptom of many parallel worker processes contending for CPU, not a logic bug.
Fixed the test to check text first, matching its own sibling, and raised testing-library's
global `asyncUtilTimeout` from the 1000ms default to 3000ms in the shared test setup — the
more general fix for the same class of contention-induced timeout anywhere else in the
suite, while still failing a genuinely-hung query promptly. `web` 0.125.0→0.125.1 (PATCH).
- **Editor: Knowledge's "New document" can now be named at creation** (#740) — it used to
materialize as `new-note.md` (then `new-note-2.md`, …) with no chance to name it: the
`can_create` flow (Notes) already prompted for a name and slugified it, but Knowledge's
`can_manage_files` doors — the root "New document" and the in-folder create — hardcoded the
slug instead, and rename couldn't rescue it because an unsaved doc isn't in the tree yet (a
`moveItem` call against it would 404 before the first save). Both Knowledge doors now open
the same naming step Notes has: the root door reuses its toolbar form, and the in-folder door
gets a new inline row right in the tree at the folder you clicked, rather than a form that
pops up disconnected from it in the toolbar. A single shared submit now backs all three doors
(`can_create`'s form and both `can_manage_files` doors), slugifying the name and seeding the
same `# <name>` heading (Knowledge's slugs keep the `.md` extension its file store has always
used; Notes' never have had one) — and lands in preview per #729, same as any other door.
Rename is now safe for an unsaved document too: the shell shows a just-created, not-yet-saved
doc in the tree (Knowledge only — Notes never exposes tree actions) precisely so its hover
rename/delete are reachable, and both are guarded to act **locally** — swap the slug, abandon
the draft — instead of firing a server call that would 404; once the first save lands, both
fall through to the existing server-backed behavior unchanged. Incidental fix while touching
the shared slug helper: `uniqueSlug`'s collision suffix now lands before a `.md` extension
(`name-2.md`) instead of after it (the pre-existing, never-before-exercised `name.md-2`).
`web` 0.123.0→0.123.1 (PATCH).
- **Editor: create lands in preview — render-first now applies to creation, not just
opening** (#729) — creating a document (Knowledge or Notes) opened it in **edit** mode,
while opening an *existing* document landed in **preview** (render-first, ADR-0042); create
was the one door that showed a raw, unrendered buffer where every other door showed the
rendered result immediately. The archetype flipped to preview only on the doc-fetch seeding
path, and all three create doors explicitly set edit mode (and skip that fetch via `isNew`,
so the flip never ran): the plain "New document", the named "New note" (Notes' `can_create`
flow), and "New file in folder" — the command palette's `?new=1` deep-link funnels into the
same doors. All three now land in preview like every other open; Edit stays one click away
on the mode toggle. The two doors that seeded an empty buffer ("New document", "New file in
folder") now seed a `# Untitled` heading — matching the Notes service's own "no heading
found" fallback (`derive_title`) — so the first preview never renders a blank pane; the named
door already seeded `# <name>` and keeps doing so. One archetype change covers both pages
(Notes and Knowledge share the `editor` archetype, ADR-0018). `web` 0.122.0→0.122.1 (PATCH).
- **KV-cache fallback message: a staged choice needs a restart, not an environment edit** (#709)
— `apply_kv_cache_type` has two distinct degraded modes and the API collapsed both into
`applied: false`, so the Models page always showed the scarier, mostly-wrong instruction
("set `OLLAMA_KV_CACHE_TYPE` … in your environment"). In the common case — the env file was
written and only Docker access is missing — that instruction is busywork: Ollama's entrypoint
re-sources `/etc/epicurus/ollama.env` on every start, so a plain container restart applies the
already-staged choice. The prefs route now returns `staged` beside `applied`
(`applied` ⇒ `staged`; a `KvCacheApplyResult` replaces the bare bool), and the UI branches on
it: staged → "restart the Ollama container to apply … `docker compose restart ollama`";
not staged (the env file could not be written at all) → today's environment-variable text.
The clear-to-default path stages identically — a successful unlink *is* the choice on disk.
A core predating this reports no `staged`, which defaults to `false`, so an older backend keeps
the old copy rather than promising a restart that wouldn't help.
`core-app` 0.93.0→0.93.1 (PATCH) · `web` 0.120.0→0.120.1 (PATCH).
- **Model catalog: re-anchored on the library's current markup** (#710) — the public model
library dropped the `x-test-*` attributes the parser keyed on, so every refresh parsed to
`[]`, the box served the seed indefinitely, and a `model catalog refresh failed` warning was
written every `refresh_seconds` for weeks. The parser now keys on the page's **structure and
user-visible copy** — the per-model `/library/<name>` link, the rounded-badge idiom, and the
word "Pulls" — and classifies each chip by its *text* rather than by the Tailwind colour that
distinguishes capabilities from sizes, so a restyle degrades instead of silently mislabelling.
Three regressions fall out of the live data: mixture-of-experts (`8x7b`, `128x17b`) and
"effective" (`e2b`) size labels are now expanded into pullable entries instead of dropped;
comma-grouped pull counts (`8,171`) rank correctly instead of scoring 0; and a blurb that
merely *mentions* "updated"/"tags"/"pulls" is no longer blanked (the stats line is told apart
structurally now — this affected 7 of the library's 233 families, `mistral` among them).
Repeated failures are **bounded**: the first failure of a streak warns, a *changed* error
warns again, the rest drop to debug, and the recovery reports how long the outage ran.
`tests/fixtures/ollama-library.html` and `ollama-tags-*.html` are trimmed verbatim captures
(2026-07-25) that pin the real markup, so the next redesign fails a test instead of the box.
The tags-page selectors (#571, #330) were verified against the same redesign and needed no
change — they key on the `/library/<family>:<tag>` link, which survived. `core-app`
0.92.0→0.92.1 (PATCH).
### Changed
- **Agent grounding: local sources first, then the web — never an unsourced guess** (#703) — the
shipped default instructions (#497) gain a "Finding answers" source ladder: the operator's module
data (knowledge base, notes, calendar, tasks, mail, files) first, then `web_search` when local
sources come up empty or a fact may have changed since training, and an explicit never-guess rule
(answer from a source, or say plainly nothing was found). `web_search`'s tool description now says
*when* to reach for it, not just what it does. Tenants with a customized prompt keep their text —
the paragraph can be adopted via Settings → Assistant instructions. `core-app` 0.91.0→0.92.0
(MINOR) · `websearch` 0.2.0→0.2.1 (PATCH).
### Added
- **`set_chat_model`: switch a conversation's model by asking** (#707) — "answer with grok from
now on" now works. The new core tool resolves the request against exactly what the web's model
picker offers — installed local models and the tenant's saved hosted models — case-insensitively,
exact match first then a substring match but only when it's unique; an unknown or ambiguous name
changes nothing and returns the available list rather than guess. The choice persists to a new
`session_models` sidecar table (there is no other "session row" — a session is derived from
`agent_messages` via `GROUP BY`) and survives a reload; `GET /sessions` reads it back for the
picker, and an explicit picker change writes the *same* field via a new
`PUT /sessions/{id}/model` (`model: null` clears it back to the device default) — one owner of
truth, so a tool switch and a manual pick can never fight. The **server** resolves that row at
turn time (`/chat`, `/chat/stream`, `/regenerate`, `/edit`), so `model` on a request is the
caller's *default* and the conversation's own choice wins whenever it has one. Resolving it
client-side would have left a window: a turn sent between the tool writing the row and the
client's cached sessions list refreshing would have run the old model, silently — the agent
says it switched and the next answer proves otherwise. A store hiccup degrades to the
caller's default rather than costing the turn. Classified `write`, following the
ordinary autonomy dial like `remember`/`ask_user` rather than a bespoke automation exclusion; it's
naturally inert for an automation with no chat sink (no session to apply it to). `core-app`
0.95.0→0.96.0 (MINOR) · `web` 0.121.0→0.122.0 (MINOR). ADR-0111.
- **Least-privilege Docker control, on by default** (#708) — a KV-cache change now applies
immediately and a removed module's container is torn down at once, out of the box, with no
operator setup. `core-app` reaches Docker through a new `docker-proxy-core` allowlist proxy
(`wollomatic/socket-proxy`) instead of a raw socket — scoped to exactly the calls
`DockerController` makes (list/inspect a container, stop/restart/remove one by id), refusing
exec/create/attach/images/volumes/networks/system regardless of who asks. The edge gateway
gets its own read-only sibling, `docker-proxy-traefik` (list/inspect/events/version, no write
verb at all), replacing its previous raw `:ro` socket mount — implementing the socket-proxy
option #656 proposed (final posture confirmed at review). The pre-existing raw-socket overlay
(`services/core-app/compose.docker-socket.yaml`) remains as a documented escape hatch for an
operator who'd rather core-app hold the socket directly. `core-app` 0.94.0→0.95.0 (MINOR).
- **Agent-gated delivery: a `quiet` run outcome the model opts into** (#706) — a per-automation
"agent decides delivery" toggle (`agent_gated_delivery`, off by default) offers the run's turn a
run-scoped `finish_quiet(reason)` tool, bound at the tool surface — spliced into `Agent._loop` only
when both `automation_id` and the toggle are set, never a global built-in, so it can never reach an
ordinary chat turn. Calling it marks the run outcome `quiet` and skips the push/notes/kb sink
fan-out; not calling it delivers exactly as before (fail-loud beats fail-silent). The ledger still
always records the run, with the model's own reason (`AutomationRun.quiet_reason`); `chat` is
deliberately exempted (it is turn-time — ADR-0108 — and rolling continuity needs the next run to
see this one's reply regardless). The Automations editor gains the toggle; the runs feed and the
per-automation history badge `quiet` outcomes distinctly with their reason. `core-app` 0.93.1→0.94.0
(MINOR) · `web` 0.120.1→0.121.0 (MINOR). ADR-0110.
- **Propose-autonomy automations can now actually reach mail's compose tools and knowledge/
notes' suggestion tools** (#721) — completes the sweep #705 started: `mail_send`/
`mail_reply`, knowledge's six `knowledge_propose_*`/`knowledge_create_document`, and notes'
four propose-shaped tools now declare `side_effect="propose"`. Previously unannotated
(defaulting to `write`), a `propose`-autonomy automation had zero usable tools on any of
these three modules — the tier was structurally dead for them. Knowledge's and notes' tools
stage a suggestion unless the operator has turned suggestion review off for that module, in
which case they apply directly, same as they already do in chat (ADR-0112 documents why this
is an accepted interaction with an existing per-module setting, not a gap the annotation
should paper over); `mail_send`/`mail_reply` carry no such caveat — they compose a draft
unconditionally, and an unattended automation turn already degrades a draft it can't show
into an informative error rather than ever sending. `mail` 0.15.0→0.16.0 · `knowledge`
0.25.0→0.26.0 · `notes` 0.10.0→0.11.0 (all MINOR). No `core-app` change.
- **Starter automation templates for every module** (#705) — the Templates tab was
effectively empty (only `echo`'s reference `on-ping`); mail, calendar, tasks, notes, and
knowledge each now declare two curated presets — mail (triage new mail, morning unread
digest), calendar (tomorrow-at-a-glance, event-starting-soon — "notify on a new invitation"
was dropped, no such event exists yet), tasks (due-today digest, overdue alert), notes
(weekly review, on-note-created), knowledge (large-vault-sync notify, index-failed notify).
All ten are `notify`-autonomy with a `push` sink, never auto-instantiated (the operator
instantiates from the Templates tab, same as ever). Getting these actually useful required
annotating each module's read tools `side_effect="read"` (`mail_search`/`mail_read`,
`calendar_list_events`/`calendar_find_free`, `tasks_list`/`tasks_lists`, `notes_list`/
`notes_tree`, `knowledge_search`/`knowledge_list_projects`/`knowledge_tree`/
`knowledge_read_document`) — unannotated defaults to `write`, which a Notify automation can
never call, so a schedule-triggered template (no triggering-event payload to fall back on)
would otherwise reach zero tools and produce nothing useful. `mail` 0.14.0→0.15.0 ·
`calendar` 0.17.0→0.18.0 · `tasks` 0.17.0→0.18.0 · `notes` 0.9.1→0.10.0 · `knowledge`
0.24.1→0.25.0 (all MINOR). No `core-app` change — the templates contract already existed
(ADR-0105).
- **Automations completion: conversational drafting + the three sinks** (#667, #672) — closes the
automations loop opened in W7. `propose_automation` is a core built-in that drafts an automation
from a natural-language ask and **only stages** it as a `ReviewSuggestion` — a hard guardrail with
no path to create or enable at any autonomy. Approval on a **second in-process `core` review page**
(a `CorePages` composite declaring both `PageSpec`s; `ModuleRegistry` already fans out over a
manifest's `pages`, so no registry change) is the one create path, and it creates the automation
*enabled* — the review is the consent. The review modal gains an editable **model picker** over an
additive `ReviewSuggestion.automation` (`AutomationPreview`). The engine's sink seam (ADR-0105, left
registered but empty) is now wired under the owner rules: **chat is turn-time** — the runner passes
`session_id` only when the chat sink is set, so an unchecked chat sink means zero sessions; a new
`automation_sessions` table badges those chats and groups a per-run automation's chats, and the
post-run dispatcher **skips** chat. **Notes/KB** route through the existing
`ModuleRegistry.save_page_doc` (the #541 no-second-write-path rule) at a per-automation
`DocumentTarget` (a `path_pattern` with `{date}`/`{datetime}`/`{time}` tokens, create or append);
each write records an `EntityRef` on the run's new `automation_runs.artifacts` column, so the runs
feed (#688) links what was produced. `automations.sink_config` and `automation_runs.artifacts` are
the first columns added since #682, so `AutomationStore.init` now runs `ensure_columns` (ADR-0067).
Implements ADR-0107 and ADR-0108. `epicurus-core` 0.30.0→0.31.0 (MINOR) · `core-app`
0.89.0→0.91.0 (MINOR) · `web` 0.116.0→0.118.0 (MINOR).
- **Web: the Automations page** (#668, stacked on #669) — the operator's controls for the
engine, a first-class core surface beside Settings. The list reads each row **in words**
("When mail.received arrives, matching subject contains invoice, between 09:00–17:00" ·
"Weekly on Tuesday at 09:00") with an autonomy badge, sink icons, an enabled toggle that
takes effect without a reload, and last-run status; the tenant-wide **kill switch** is
pinned above everything. One Sheet editor serves create / edit / instantiate-a-template and
edits **every field the engine stores** — instructions, a per-automation model
(core-default fall-through, local + hosted lists), an event trigger whose type picker is
driven by the live event catalog (module manifests' declared `events.*` subjects, with a
free-text escape hatch for the core's own `files.*`/`core.*` families) plus the matcher
builder and active-hours window, or a schedule in the ADR-0092 cadence/hour/weekday
vocabulary; sinks + chat mode; the 4-level autonomy dial with its reach spelled out; rate
cap and digest window. Saved **explicitly** (the fields are interdependent — the ADR-0098
posture), and a server-rejected save shows its reason inline with the sheet still open.
The **Templates** tab groups module presets by module; *Use* prefills the editor and
saving creates an independent `source="template:<module>"` row — **enabled on save** (the
editor pass is the review; owner-decided) and never retro-edited by later template
changes (provenance is not editable, enforced server-side). Per-row **Run history** reads
the ledger and deep-links into the runs feed (`/observability?tab=runs&automation=<id>` —
the observability screen learned to open on a requested tab), and the feed's automation
badge now links back. **Run now** exercises the real runner. The engine gains the missing
management endpoint — `PUT /platform/v1/automations/{id}`, create-shape validation before
any write, `source`/`chat_session_id`/`created_at`/last-run stamps never touched — and
`AutomationStore.update`. The old **scheduled-turns Settings card is absorbed**: the card,
its api client and contract are gone (the engine already migrated the rows and keeps the
old endpoints answering); migrated rows simply appear as automations. `web`
0.115.0→0.116.0 (MINOR) · `core-app` 0.88.1→0.89.0 (MINOR — the management endpoint).
- **Observability: the Automation runs feed** (#669, stacked on #666) — the third live tab,
and the engine's glass: a triggered run is traceable end-to-end in one place, fire → filter
verdict → run (model, tokens, duration) → sinks delivered / error. **Skips are the point**:
a rate-capped or paused run appears as loudly as a real one with its *why* inline ("rate cap
reached (4/hour)", "runtime paused") — a cap being hit should be visible, not inferred from
an automation that mysteriously went quiet — and a `silent_act` run, whose only trace is the
ledger, is visible here too. Backed by `GET /platform/v1/automations/runs/stream` (SSE,
history-then-live per the ADR-0031 console shape; a new `RunFeed` fans out every ledger entry
the moment the runner's new `on_recorded` hook records it) and an `outcome` filter on the
list endpoint (`ok`/`error`/`skipped`, server-side). Each run's `trigger_refs` come back as
`trigger_entity_refs` — the triggering events' `EntityRef`s resolved from the event log
(`EventLogStore.by_ids`) — so rows carry ADR-0019 hover-card chips to the source entities
with no per-module code. The tab filters by automation and outcome server-side (they
re-subscribe) and by trigger module client-side over the automations list (a run itself
carries no module), reusing `useSseFeed` exactly as the Logs and Events tabs do; the
automations list also names each run. `web` 0.114.0→0.115.0 (MINOR) · `core-app`
0.88.0→0.88.1 (PATCH — endpoints and hook exist to serve the new surface, per the issue's
own bump call).
- **Automations: the engine** (#666, ADR-0105) — the centerpiece of event-driven proactivity,
and what the event spine exists to feed: modules announce that the world changed, and this
decides whether the assistant should do anything about it. A tenant-scoped `automations` row
is a **trigger** (an event — module + type + a deterministic payload filter + an optional
local-hour window — *or* a schedule, reusing the ADR-0092 cadence vocabulary), an **agent
step** (prompt + optional per-automation model + autonomy level), **sinks**, a rate cap, and
a digest window. **The 4-level autonomy dial is structural, not persuasive.** A Notify
automation is not asked to avoid writing — it is handed no tool that can. That needed a
vocabulary the codebase didn't have, so tools now declare `side_effect` on their `ToolSpec`:
`read` (observes), `propose` (**stages for approval by construction** — a draft-first send,
a propose tool that files a suggestion), `write` (applies directly). Three classes, because
two collapse the dial: with only read/write, Propose and Act get identical surfaces and the
middle of the dial becomes prompt wording again. It is *declared*, never inferred —
`mail_mark_read` contains "read" and mutates — and **defaults to `write`**, so a forgotten
annotation costs availability, never containment. Enforcement filters the `route` the agent
dispatches on, not just the specs the model is shown: a withheld tool is **unroutable**, and
a model that names it anyway is told `error: unknown tool`. Levels: notify→read ·
propose→+propose · act→+write · silent_act→ the same reach as act, reporting **only** to the
ledger. The runner matches on the event intake, queues durably (ADR-0051), batches a digest
from the *oldest* pending trigger, runs one agent turn with the triggering events as
**context, explicitly not instructions**, then fans out to sinks **deterministically after**
the turn — a model that could choose its own reporting could choose not to report. Safety:
a **Postgres-persisted** kill switch (unlike the in-memory power pause — a stop a restart
undoes is not a stop), rate caps that a *failing* run also consumes, a rate-limited
`core.automation_failed`, and a **depth-1 loop guard** — an event a run produces carries a
`causation_id` and the matcher refuses *any* event carrying one, because A→B→A is a loop too. `core-app` 0.87.0→0.88.0, `epicurus-core` 0.29.0→0.30.0, `echo` 0.4.0→0.5.0 (all MINOR).
`automation_runs` always records, at every level, with **dual metering**: tenant *and*
automation, on the ledger and on `UsageEvent`, since an automation quietly burning tokens is
otherwise indistinguishable from the operator's own chatting. **Scheduled turns (#614) fold
in** — migrated at startup, idempotently and non-destructively, keeping cadence, session,
enabled flag and last-run stamp, at `notify` because that is what they already were. Modules
may declare `automation_templates`; they are **never auto-instantiated** (the contract
carries no `enabled` field to set).
- **Events: the module event spine** (#662, ADR-0103) — the keystone of event-driven
proactivity: modules announce that the world changed, the core keeps the copy of record, and
the automations engine (a companion issue) decides whether anything should happen about it.
Nothing consumes events yet, which is exactly why the contract is fixed now — every emitter
and every consumer will be written against it. **One envelope** in `epicurus_core.module_events`
(`tenant_id · module · type · occurred_at · dedup_key · entity_ref? · payload ·
schema_version`) plus an `emit_event()` helper over the existing NATS plumbing, on a dedicated
`events.` subject namespace (`<tenant>.events.mail.received`) so the spine is subscribable as
a whole while the bus's existing per-module subjects keep their names. Payload discipline —
**pointers and metadata, never content, never secrets** — is *enforced*, not requested: a
4096-byte cap a mail body cannot fit through, and rejection of credential-shaped payload keys
(an over-match by design: `idempotency_key` is refused exactly like `api_key`, because a false
positive costs a rename and a false negative leaks a credential to a browser tab). A `type`
must be prefixed with its own module, so a subject is self-describing and a typo fails at emit
rather than mis-attributing an event forever. **Durable intake** in core-app subscribes
`*.events.>` — one subscription, *every* tenant (`EventBus.subscribe_any_tenant`), departing
from the inbound-messaging consumer's per-tenant subscribe, which would leave a tenant added
at runtime silently unheard until restart. Each message carries two independent tenant claims
(the subject's and the envelope's); a mismatch is dropped rather than filed under a guess.
Events land in a tenant-scoped `module_events` log, deduped by a database constraint on
`(tenant, module, dedup_key)` with **first write wins** (a later delivery of an
already-recorded change carries no newer truth), bounded by `EVENTS_RETENTION_DAYS` (30;
`0` keeps everything). Delivery is **best-effort v1** — core pub/sub, at-most-once; JetStream
is enabled on the server and deliberately unused, which is *why* the log rather than the bus
is the copy of record. A **raw Events feed** joins the log console on the Observability screen
behind a new tab strip (the automation-runs feed lands as a third tab); the ADR-0031 redaction
rule moved into `epicurus_core.redaction` so both surfaces share one list rather than drifting.
**echo** gains `echo_ping` / a "Ping the spine" action emitting `echo.pinged` — the reference
emitter — and `task smoke` now asserts the whole chain end-to-end on a fresh stack: emit →
NATS → intake → durable log → feed, plus the log's idempotency. `docs/reference/events.md`
starts **the event catalog**; real module emitters extend it.
- **Mail: the first real module emitters — `mail.received` / `mail.sent` / `mail.sync_failed`**
(#663, stacked on #662) — mail's cache reconcile (ADR-0096, #623) is the one place a
genuinely-new message, as opposed to a flag flip, is already known, so that's where all three
now fire. `mail.received` needed message-granular detection the sync seam never had: Gmail's
`changed_threads_since` was thread-granular only (`_history_thread_ids` conflates
`messagesAdded`/`messagesDeleted`/`labelsAdded`/`labelsRemoved` into one set), so
`ThreadChanges` gains a narrower `new_message_ids` field, filled from `messagesAdded` history
records specifically — a flag flip on an existing message never fires it. Each new message gets
one `provider.read()` (thread-summary data reflects only a thread's *latest* message, wrong the
moment two new messages land in the same thread within one reconcile window) for an accurate
`from`/`subject` (capped)/`folder`/`has_attachments` payload — `MailMessage` gains its own
`label_ids` so `folder` reflects the message's real placement, not whichever label triggered the
poll. **No-firehose, by construction, not by a special case**: a cold cache and an
expired-cursor forced resync both already route through the same `_full_sync` path, which the
reconcile loop never touches for `mail.received` — the initial/full-sync case costs no new code.
`mail.sync_failed` fires on a provider/auth error or an expired sync cursor, rate-limited per
instance (`MAIL_SYNC_FAILED_COOLDOWN_S`, default 900s) so a flapping account can't storm the
bus. `mail.sent` — previously declared but publishing on the bare `mail.sent` subject via a raw
`EventBus.publish` with an ad-hoc, uncapped payload — now rides the same spine as its two new
siblings. IMAP does not exist in this codebase yet (only `GmailProvider` is implemented,
despite the module's provider-neutral design); the catalog's provider-caveats section notes
what a future IMAP provider would need to fill. `mail` 0.13.0→0.14.0 (MINOR).
- **Calendar & Tasks: lifecycle + lead-time events** (#664, stacked on #663) — "two modules,
one pattern," including the new lead-time scheduler both now share the shape of. **Calendar**
gains `event_created`/`event_updated`/`event_cancelled` on `CollectionRouter`'s
create/update/delete_event — the **provider-write** seam only, since calendar has no
sync/reconcile layer analogous to mail's (ADR-0096); a change made directly in Google
Calendar's own UI is never observed. `event_updated`'s `time_changed` flag is a real
before/after comparison (the router snapshots prior state first), and its `dedup_key` folds
in a change hash of the event's mutable fields, so a genuinely different edit is its own log
entry while a retried write with identical resulting state dedups — the same "dedup provider
id + change hash" posture `tasks.task_updated` now also uses. `invitation_received` /
`attendee_responded` (Google-only, ADR-0030) are **deliberately not implemented**: both need
the same kind of external-change detection a sync layer would provide, a materially larger
feature than wiring emission into an existing seam — declaring either without actually
publishing it would repeat mail's own "declared but never emitted" mistake rather than avoid
it. **Tasks** gains `task_created`/`task_completed`/`task_updated` on `TasksRouter`'s
add/complete/update_task, and `task_moved` on the ADR-0038/#257 cross-list seam (fired
instead of `task_updated`, never alongside it — Google Tasks has no move API, so a move
recreates in the target and deletes the source). A recurring task's auto-materialized
successor (`_materialize`, ADR-0082) calls the inner provider directly, bypassing the
router's own `add_task` — a deliberate scope limit, so it does not currently emit
`task_created`. **The lead-time scheduler** is a new periodic background job — the first for
either module — durably fire-once via a `(tenant, entity_id, marker)`-unique marker table
(`BigInteger` epoch column) proven to survive a restart; each module also gains a
settings-primitives-shaped tenant preference for its own lead (calendar: minutes, default
15; tasks: days, default 1 — storage only, no settings UI yet). Calendar's lead is pure
instant math; tasks' `task_due_soon`/`task_overdue` evaluate against the *operator's local
calendar day* (ADR-0039), reusing the exact `operator_clock` the overdue-recurrence sweep
already resolves, so the two can never disagree about what day it is. `calendar`
0.16.0→0.17.0 (MINOR) · `tasks` 0.16.0→0.17.0 (MINOR).
- **Notes, Knowledge, Files: content events + suggestion-decision events** (#665, stacked on
#662) — the operator-content half of the emitter sweep. **Notes/knowledge doc events**:
`note_created`/`doc_created` and `note_deleted`/`doc_deleted` fire immediately at the change
(editor, file tree, or an approved suggestion — both authors converge on the same pages
seams), but `note_updated`/`doc_updated` are **debounced to settled saves**: the ADR-0042
auto-save fires a PUT on every ~4s idle pause, so each save re-arms a per-document quiet
window (`NOTES_EVENTS_DEBOUNCE_S` / `KNOWLEDGE_EVENTS_DEBOUNCE_S`, default **120s**) and one
event fires when it passes untouched — carrying the *last save's* timestamp as `occurred_at`
and the count of saves coalesced. The debounce is a swept dict driven by a pure
`flush_due(now)` (the ADR-0092/0098 test idiom), duplicated per module rather than landed in
the high-contention core lib (rule of three; the engine PR is already bumping it); pending
entries flush on shutdown, deletes cancel them, renames re-key them (knowledge folder moves
re-key by prefix). `knowledge.vault_synced` is **one batch event per watcher pass** with the
pass's honest counts (`indexed` merges added+updated — the walk doesn't distinguish); no-op
passes and the startup index emit nothing (no-firehose). `knowledge.index_failed` is
rate-limited (`KNOWLEDGE_INDEX_FAILED_COOLDOWN_S`, 900s, the mail.sync_failed posture) and
fires on the initial index giving up (#230's retry budget) or a watcher pass failing.
**Files events are core-emitted** (the core owns the file space, #434) at the file-API seam:
`files.file_added` (upload, or a module/agent write of a genuinely-new path),
`files.file_deleted` (one per API action — a folder is one event), `files.file_moved`
(file-space + object-store fallbacks). Deliberately **no `file_updated`**: an overwrite emits
nothing, so mirrored module content doesn't double-signal its own `*_updated` here.
**Suggestion decisions are core-emitted at the one review funnel**
(`ModuleRegistry.review_action`): `core.suggestion_approved` / `core.suggestion_rejected`
fire once per decision whether the operator used a module's review page (HTTP-proxied) or
the core-hosted pseudo-module surface (ADR-0093 §2), with `operation`/`path` lifted from the
surface's `ApplyResult`. Two legacy subjects retire: notes' bare `notes.saved` publish (no
consumer — the mail.sent migration, #663) and knowledge's declared-but-never-published
`knowledge.index.completed` (the manifest now only advertises events that fire). All emission
is best-effort — a spine hiccup never fails the save, delete, or decision that already
landed. `notes` 0.8.0→0.9.0 (MINOR) · `knowledge` 0.23.0→0.24.0 (MINOR) · `core-app`
0.86.0→0.87.0 (MINOR).
starts **the event catalog**; real module emitters extend it. `epicurus-core` 0.28.0→0.29.0
(MINOR), `core-app` 0.85.0→0.86.0 (MINOR), `web` 0.113.0→0.114.0 (MINOR), `echo`
0.3.0→0.4.0 (MINOR).
- **Push: web push end-to-end** (#670) — VAPID-signed browser push, the "push alerts" half of
event-driven proactivity. A tenant's VAPID keypair is generated on first send and stored in
OpenBao — no operator provisioning step. Per-category + quiet-hours preferences
(`push_prefs`, shared with the notification center, #671) gate every send: category off
skips it, quiet hours (tenant timezone, ADR-0039) queue it for one summary digest once the
window ends rather than dropping it, and an in-memory per-tenant rate cap is the last gate.
A subscription the push service reports Gone (404/410) is pruned automatically.
`services/web/src/sw.ts` gains `push`/`notificationclick` handlers; a new Settings → Push
notifications card handles this-device subscribe/unsubscribe, per-device management,
category toggles, quiet hours, and a test-notification button. `PushService.notify()` is a
core-internal contract (no HTTP route) for the automations engine's future push sink and
system notices — today's only caller is the settings UI's test button. Desktop Chrome/Edge
and installed Android/iOS PWA (16.4+) both work. Implements ADR-0102. `core-app`
0.83.0→0.84.0 (MINOR), `web` 0.111.0→0.112.0 (MINOR).
- **Push: in-app notification center** (#671) — the durable record every push-worthy
notification lands in, written by `PushService.notify()` itself the instant a category's
`center` toggle is on — independent of whether push delivery fires, queues for quiet
hours, or is itself disabled, so a quiet-hours-suppressed push still appears in the center
immediately rather than waiting for the digest. A new **Notifications** page (list, category
filter, unread-only filter, mark read / mark all read, `EntityRef` hover-cards + deep links
via the existing `CardLink`) and a live unread-count badge on its own nav entry (polled every
15s, the same shape as the #492 "finished while you were away" watcher). Retention is a
per-tenant row cap (500), not time-based. Stacks on #670's shared `{push, center}` prefs
object — no second settings surface. Implements ADR-0104. `core-app` 0.84.0→0.85.0 (MINOR),
`web` 0.112.0→0.113.0 (MINOR).
- **Agent: nightly reflection proposes playbook/instruction edits** (#615) — the other half of
governed playbooks: what actually notices a lesson worth keeping. A new `playbook-reflection`
job on the maintenance orchestrator's nightly batch (additive — one entry appended to the
registry, registered `nightly=True`, so it rides the orchestrator's existing schedule rather
than a knob of its own). Per tenant it scans the sessions active since its last run and makes
**one** gateway call over them, staging zero or more candidate edits — to the base instructions
or a named playbook — as review proposals. It **cannot apply anything**: it is handed a proposal
sink and a read-only playbook lookup, never the stores that own the documents, so ADR-0093's
"nothing self-applies" is enforced by construction. The call is metered under **the tenant whose
sessions it scanned**, never the default tenant (constraints #1/#8, the ADR-0051 drain's
precedent). Recently **rejected** proposals are digested into the prompt as negative context
from the ADR-0090 audit trail, so a declined idea isn't re-proposed unchanged; a document with a
proposal still pending is skipped, so the queue can't stack drafts while the operator is away.
The create-vs-update operation is derived from what exists rather than taken from the model — a
mislabelled create would render an empty *current* side and hide what an approval would
overwrite. A durable per-tenant watermark (`agent_reflection_state`) bounds the scan, snapshotted
before it and advanced only on a completed pass. Junk costs nothing: a non-JSON reply, an unknown
target, or a runaway generation stages nothing rather than raising, and a tenant with no new
activity spends no gateway call at all. New `PLAYBOOK_REFLECTION_MODEL` (blank = the default chat
model). Implements ADR-0093 §1/§5/§6. `core-app` 0.80.0→0.81.0 (MINOR).
- **Agent: governed playbooks — storage + a core-hosted approval surface** (#616) — the agent's
behaviour improved only when the operator hand-edited the base prompt; nothing captured what the
system learns in use. **Playbooks** are named, independently enable-able blocks of guidance
stored beside that prompt (`agent_playbooks`) and composed into every turn — base first, then
each enabled playbook under its own heading. The composition happens **below**
`AgentInstructionsStore.get_instructions`, so `Agent._assemble` is untouched and still leads the
turn with one opaque string; a failed playbook read degrades to the base prompt rather than
costing the turn. Nothing self-applies: an edit arrives as a `ReviewSuggestion` (ADR-0090) and
only the operator's **Approve** writes it — through the existing instructions store for the base
prompt (the same path a manual Settings edit uses), or the playbook store for a named one. Both
halves gained ADR-0046 snapshot-on-save versioning (`agent_instructions_versions`,
`agent_playbook_versions`; capped at 50, oldest pruned), snapshotting the body each save
*replaced* so an approved agent-authored edit is always undoable. The approval UI is the
existing, unmodified `ReviewView`/`SuggestionReviewModal`: the core registers a reserved **`core`
pseudo-module** that `ModuleRegistry` answers **in-process** (no loopback HTTP), riding
`GET /platform/v1/modules` so the shell discovers its `review` page like any module's. It is
deliberately *not* a configured base, so it can never leak into the agent's MCP tool surface or
the re-embed fan-out; `enabled` / `DELETE` / `suggestions-enabled` all **403** for it — its
review is mandatory. Web-side, the Suggestions inbox shows *Always reviewed* instead of a toggle
for that group, and the Modules screen filters the reserved name out. Implements ADR-0093
§2/§3/§4. `core-app` 0.79.0→0.80.0 (MINOR), `web` 0.109.0→0.109.1 (PATCH).
### Fixed
- **Web: no more cold-mount flash on a folder/search/pagination switch** (#712) — mail's
thread list blanked to a spinner on every folder change (search, and pagination too),
because each switch is a new react-query key and, left at the defaults, that mounts cold
with no data. `MailboxView`'s list query now keeps the previous folder's data on screen
(`placeholderData: keepPreviousData`) with a small inline spinner as the only sign a fetch
is under way, plus a tuned `staleTime`/`gcTime` so every folder visited in a session stays
warm and a revisit renders instantly. A borrowed placeholder also has to be guarded wherever
one query gates on another's success: the cache-first reconcile read (#623) waits on the list
query, and a placeholder resolves that to `success` before the newly-selected folder's own read
has landed, so the gate now also requires `!isPlaceholderData` — without it a first visit fired
both provider reads at once, the exact double round-trip ADR-0096's gate exists to prevent.
Auditing the other three archetypes for the same pattern found it live in two more places —
`BrowserView` (a directory/search switch) and
`EditorView` (opening a different document) — and fixed both; `EditorView`'s fix also
guards the draft-seeding effect on `!doc.isPlaceholderData`, since without it the *previous*
document's still-visible placeholder content would seed into the newly-selected path and
strand the real content unread once it arrived. `CalendarView`/`BoardView` already did this
(Calendar since #378/#379) and needed no change — they were the reference the audit checked
the others against. `web` 0.118.0→0.119.0 (MINOR — the audit landed changes beyond mail).
- **CI: no gate ever checked a docs/ cross-reference — a dead one reached shipped operator
UI** (#692). Issue #661 existed because `docs/DEPLOYMENT.md` was referenced from shipped,
operator-facing UI copy and a compose comment while the file doesn't exist in the public
tree (the real doc is the gitignored `.workspace/docs/DEPLOYMENT.md`); nothing caught it,
and a generic markdown-link checker wouldn't catch the next one either — it never looks
inside a `.tsx` file or a compose comment. New **docs-linkcheck** CI job
(`scripts/check_docs_links.py`, stdlib-only) checks (1) every relative link between `docs/`
pages, anchors included, and (2) a repo-relative doc path quoted in shipped source (web UI
copy, compose comments, other top-level READMEs, `.env.example`) — excluding test
fixtures (which commonly use plausible-looking-but-synthetic doc-shaped paths that were
never meant to resolve) and `CHANGELOG.md` (narrates *past* fixes, not live references).
The gate's first real run found and fixed four genuine, previously-invisible cases: a
`docs/services/core-app.md` anchor named in two other pages
(`../services/core-app.md#push-notifications-adr-0102`, `#raw-events-feed` missing its
`-adr-0103` suffix in two places) that had no matching heading at all — added the missing
"Push notifications (ADR-0102)" section documenting the `push/` package's send path and
HTTP surface (previously undocumented entirely), and corrected the `-adr-0103` suffix — plus
one heading-renamed-out-from-under-it link in `docs/services/mail.md` (`#operator-setup-google`
→ `#google-cloud-setup`). The two offenders this issue named as still-live
(`.env.example`, `docs/developer/releases.md`) were already fixed by #661 before this PR;
verified no live occurrence remains. No component bump — CI/docs only.
- **CI: no gate ever parsed a shell script with a POSIX shell** (#691). #675 shipped a bash
array in `infra/cd/reconcile.sh` — invoked as `sh infra/cd/reconcile.sh` everywhere (its own
header comment, the `task reconcile` Taskfile entry, and both scheduled-task lines in
`docs/infrastructure/auto-deploy.md`), and on the deploy box's real `/bin/sh` (dash) a bash
array is a parse error — but every check, CI included, ran under Git Bash's `sh`, which *is*
bash, so the bug was invisible everywhere but production. New **shell-lint** CI job runs
`shellcheck` over every `*.sh` (discovered via `git ls-files`, not a hardcoded list), with the
shell it checks against inferred from each script's own shebang. Fixed the live mismatch this
surfaced: `reconcile.sh`'s shebang said `bash` while every invocation and its own content
(already rewritten to POSIX positional parameters, no arrays) said `sh` — corrected the
shebang to match, and dropped `pipefail` from its `set` line (undefined in POSIX sh; Debian
dash rejects it outright, which would have been the exact same silent-until-production
failure this gate exists to catch). Also cleaned up the two other findings the gate's first
real run turned up: `infra/backups/backup.sh`'s `ls | grep` (rewritten as a plain Python
`os.listdir` filter) and a few intentional-but-unflagged-until-now `infra/ci/smoke.sh`
patterns (a deliberately unquoted service-name word list standing in for POSIX sh's missing
arrays, `CDPATH= cd`, sourcing a `mktemp` path) marked with `# shellcheck disable=SCxxxx` /
`# shellcheck source=/dev/null` naming why. No component bump (CI/docs only).
- **Knowledge/notes: a rejected write returned a success envelope, so the live document pane
could open on content that was never written** (#690). `knowledge_create_document` /
`knowledge_propose_edit`'s `_stage_doc_write` (a bad path, an already-existing path) and
their shared `_finalize` (a failed review-off self-apply), plus notes' equivalent `_stage`
(an invalid slug, the same failed self-apply), all caught the rejection and returned a
normal `tool_envelope` — so the call read as `is_error=False` to the agent loop, and the
document pane (#541, ADR-0101) keys `doc.failed` off exactly that structural signal, not
the reply text. A rejected write with review off would open the pane's editor over stale
(update) or nonexistent (create) content. These paths now raise instead, so FastMCP reports
`isError` and the pane correctly shows "the write failed." The suggestion itself is
unaffected — a failed self-apply still leaves it staged, nothing is lost. Swept mail,
calendar, tasks, and storage for the same "error path reuses the success constructor"
shape; none had it — their write tools already raise directly, and their only
`tool_envelope`-on-empty-result usages are legitimate (no rejection involved). `knowledge`
0.24.0→0.24.1 (PATCH), `notes` 0.9.0→0.9.1 (PATCH).
- **Infra: `docs/DEPLOYMENT.md` was referenced from shipped operator UI and a compose comment,
but that file doesn't exist in the public tree** (#661). The real document was always the
gitignored `.workspace/docs/DEPLOYMENT.md` — since the repo went public, anyone following the
Modules page's Docker-status card (#652/#622) or the `docker-socket` compose comment to
`docs/DEPLOYMENT.md` got a 404. Both now point at `docs/infrastructure/index.md`'s
"Docker-socket access" section, which #652's own docs already established as the real home for
that content. Also swept two conceptual "DEPLOYMENT.md" mentions (`.env.example`,
`docs/developer/releases.md`) that cited the same gone file for the image-pinning /
immutable-image principle — retargeted at `docs/infrastructure/auto-deploy.md`, mirroring the
link `releases.md` already uses two sections below for the same document. `web` 0.111.2→0.111.3
(PATCH — the Modules page string). `core-app`'s `compose.yaml` touch is comment-only, no
runtime effect — no bump.
- **Web: confirming a mid-history edit could trim the transcript to a state the server was never
asked to produce** (#660). Editing a user message further back than the last turn discards every
real turn since it, so `saveEdit()` confirms the count first — but it only guards
`chat.streaming`/a dropped connection at the moment the dialog *opens*. A run starting elsewhere
(another tab, a scheduled turn) or the connection dropping while the dialog sat open went
unchecked: clicking **Resend** still applied the optimistic trim and re-ran the edit regardless.
The dialog's own confirm now re-checks both at click time, the same guard `saveEdit()` already
applies — blocked exactly like Cancel, leaving the inline editor open with the draft intact to
retry. `web` 0.111.1→0.111.2 (PATCH).
- **Web: the document pane's "Review & approve" hard-reloaded the SPA, and its review-state
query key missed the toggle's own invalidation** (#659). `Panel.tsx`'s `DocumentView` was the
only SPA-internal hard navigation in the app (`window.location.assign`) — it dropped the live
SSE stream for no reason (recoverable via ADR-0055 re-attach, but nothing to recover *from* if
it just doesn't reload); now it navigates in-app via `useNavigate()` and explicitly dismisses
the pane first (the panel is Shell-global and persists across routes, so — unlike the reload
it replaces — nothing implicitly clears it). Separately, the pane's `["suggestionsEnabled",
module]` query key was a duplicate camelCase cache entry alongside `ReviewView`/
`SuggestionsScreen`'s established `["suggestions-enabled", module]`, so toggling review while
the pane was open missed the invalidation the toggle fires — self-healed on refetch, but could
leave the pane's "applied vs. staged" branch briefly stale. Also fixed while in the area: the
panel store's `replace()` accepted a title update, but silently discarded it; and `EditorView`'s
`doc` prop (the pane's applied→editor handover) had no direct test coverage. `web` 0.111.0→
0.111.1 (PATCH).
- **Infra: `task reconcile` silently reverted the docker-socket opt-in on every deploy** (#655) —
the #622 opt-in (`services/core-app/compose.docker-socket.yaml` + `DOCKER_GID`) only lasted
until the next pull-based reconcile: `infra/cd/reconcile.sh` ran plain
`docker compose up -d` with no overlay, so `core-app` was recreated without the socket mount,
silently dropping back to deferred-teardown mode (fails safe, but the opt-in didn't stick — the
pull-based reconcile is the actual deploy path, not a one-off `docker compose up`). Now
`reconcile.sh` reads `DOCKER_GID` the same way it already reads `EPICURUS_VERSION` /
`EPICURUS_TRACK_BRANCH` (env, falling back to `.env`) and includes the overlay on both the pull
and the up when it's set — unset stays exactly as fail-safe as before. `docs/infrastructure/`
updated to cover persisting the opt-in, not just the fresh-deploy default. Infra-only; no
component version change.
- **Agent: nightly reflection never showed the model the document it was asked to rewrite**
(#658) — the `update` path's system prompt demanded "the FULL new text… it replaces what is
there," but `_build_prompt` passed only transcripts and recent rejections, and `list_playbooks`
was used for names only; `get_base` was never called. Since `instructions` is always an
`update` (there's nothing to create), that path asked for a full regeneration of text the model
had no access to — mostly rejectable noise, at the cost of a real gateway call per tenant per
night. Now the prompt includes the current base instructions (`get_base`) and every existing
playbook's current content — the model edits real text instead of reconstructing from nothing.
Folded in from the same review: the out-of-window `continue` in the session scan is now a
`break` (`sessions()` is DESC-ordered, so the first miss guarantees the rest miss), and
`test_reflection.py`'s model-override test now constructs the reflector with `model=` instead
of poking the private attribute, so `settings.playbook_reflection_model` reaching the
constructor is actually covered. `core-app` 0.83.1→0.83.2 (PATCH).
- **Core: a DB error on the reserved review page emptied the entire Suggestions inbox** (#657) —
`ModuleRegistry._core_suggestions` caught only `HTTPException`, but the core review page is
dispatched **in-process** (no loopback HTTP), so a storage failure — e.g. a degraded startup
that left `playbook_proposals` uninitialized — surfaced as the driver's own exception and
escaped the handler, 500ing `GET /platform/v1/suggestions` and taking every module's pending
suggestions down with it, not just the core's own. Now catches bare `Exception` and logs a
warning instead, matching the precedent in `agent/instructions.py`'s enrichment fallback — a
broken core queue drops only its own entry, everything else in the feed survives. Also closed
the matching read/write asymmetry: `GET .../core/suggestions-enabled` now 403s like `PUT`
already did, rather than answering `true` for a toggle that can never exist (review of the
agent's own instructions/playbooks is mandatory, ADR-0093). `core-app` 0.83.0→0.83.1 (PATCH).
- **Infra: the "docker socket unavailable" message overstated the impact, and the socket was
mounted by default without ever actually working** (#622, ADR-0099). Module removal was never
disabled — an earlier fix (ADR-0056) already made it tombstone the module immediately either
way, deferring only the container teardown — but the log line (surfaced via the Observability
console) still said "module removal disabled," and the socket mount in
`services/core-app/compose.yaml` was unconditional despite never being reachable on a real
deployment anyway (the core drops to an unprivileged uid at startup, ADR-0069, with no group
matching the host's docker-socket GID). Now: the message says what's actually deferred; a new
`GET /platform/v1/modules/docker-status` lets the Modules page state that proactively, with the
one-line enablement, instead of an operator finding out by attempting a removal; and the socket
mount is an explicit opt-in (`services/core-app/compose.docker-socket.yaml` + `DOCKER_GID`) —
losing no real capability (nothing worked by default before either) while closing a
root-equivalent attack surface that was pure liability. `core-app` 0.78.0→0.79.0; `web`
0.108.0→0.109.0.
- **Mail: paging pinned to the bottom, action row at the top + icon-only on phone** (#624, #626) —
two mailbox UX fixes. The **Newer/Older** paging controls scrolled away with the message rows
(attached mid-content); each mailbox view now owns its scroll, so the paging bar is a stable
footer pinned to the bottom of the list (#624). The per-message **action row** (mark read/unread,
archive, trash) sat at the bottom of the message and is now anchored at the **top**, and on a
narrow viewport the buttons are **icon-only** — labels hidden from `sm:` down, with the
aria-label + tooltip preserved so they stay named and accessible (#626). `web` 0.107.0→0.108.0.
### Added
- **Chat: a live document pane — watch the agent write, then edit or review it in place** (#541,
ADR-0101) — the agent writing a document showed only a `knowledge_create_doc ✓` chip; the
artifact, usually the whole point of the turn, was invisible until you navigated to the module
afterwards. Now an annotated call (the `writes_document` seam below) opens a **split pane beside
the chat** — a resizable column on desktop, a sheet on phones — showing the document as it is
written, read-only while the call is in flight so a user edit can't race the agent's.
**What the pane becomes depends on what the write actually did.** Knowledge and notes *propose*
documents rather than writing them (ADR-0033), and review defaults **on** — so with review on
nothing was written, and the pane shows the proposal with **Review & approve** into that
module's review page (where the draft is already editable, ADR-0090). With review **off** the
change was applied, and the pane hands over to the **real editor archetype**: auto-save
(ADR-0042), version history (ADR-0046), the same module document APIs, no second write path.
The outcome is read from the core's own review setting — the same one the module consulted —
rather than guessed, so the pane never claims a write landed when it didn't. Generic
throughout: the module names the arguments and the shell reads the annotation (no module is
named in web code, ADR-0018/0019), and the pane finds that module's editor/review pages from
its own manifest, so a future adopter needs no web change. Best-effort by construction — a
failed or slow lookup costs a pane, never the turn — and the body rides SSE only, never the
persisted activity, so ADR-0041's caps are unchanged (the pane is a live-turn affordance; after
a reload the turn's entity-ref chip is the way back, as it already is for un-annotated writes).
`knowledge` and `notes` are the first adopters, on their four **full-body** write tools;
`notes_append` is deliberately left out (its `text` is a fragment the server concatenates on
approval, not a document). Also brings both modules' manifest versions back in line with their
packages — they had drifted a minor behind, under-reporting on the Modules page. `core-app`
0.82.0→0.83.0; `web` 0.110.0→0.111.0; `knowledge` 0.22.0→0.23.0; `notes` 0.7.0→0.8.0.
- **Modules: a `writes_document` tool annotation** (part of #541, ADR-0100) — the groundwork for
the live document pane. A module declares, per tool, that it *writes a document* and names the
arguments the document travels in — `writes_document: {content_arg, title_arg?, target_arg?}` on
`ToolSpec`, declared through the usual decorator (`@module.tool(writes_document=…)`). The shell
will use it to open the document beside the chat while the agent writes it, for **any** module,
with no per-module web code (ADR-0018/0019) and no name-sniffing. It is an annotation, not a
capability: the tool keeps its own name, schema, and behavior, gains no endpoint, and a tool that
writes no document simply omits it. The named arguments are **validated against the tool's own
input schema** at manifest-build time — a typo fails there instead of surfacing later as a pane
that silently never fills — and declaring one for a tool that never registered is an error rather
than a silent drop. Additive on the wire (`CONTRACT_VERSION` unmoved): a core predating the field
ignores it, so a module can adopt it independently. No consumer yet — the pane, and knowledge +
notes declaring the annotation, follow. `epicurus-core` 0.27.0→0.28.0.
- **Chat: edit any user message in history, regenerating from that point** (#552) — editing was
limited to the **last** user message (#302), so fixing a typo three turns back meant retyping the
conversation. Every user message now carries the same inline **Edit** affordance (revealed on
hover/focus on earlier turns, the #480 per-turn pattern), opening #302's in-place composer.
Editing keeps #302's decision — revise in place and truncate, never branch — so editing turn *k*
discards turns *k+1..n* and streams a fresh answer under it. Because that throws away **real**
content, a mid-history edit now **confirms with the count** ("removes the N later messages");
editing the last message is unchanged and confirms nothing. `POST /sessions/{id}/edit` gains an
optional `message_id` (absent ⇒ the last user message, so existing callers are untouched), and
the transcript (`GET /sessions/{id}`) now carries each message's `id` as the anchor to name.
Validation runs **before** any write — the anchor must be a **user** message of **this** session,
and no turn may be running — so a rejected edit can never leave the conversation truncated. That
ordering also fixes a latent bug inherited from #302: `/edit` revised and truncated *before*
reaching the one-run 409, which for a mid-history edit would have destroyed real turns and then
refused to re-answer. Nothing re-indexes the revised text — messages are not a recall corpus
(ADR-0045), so `memory_search` reads the new text (and none of the discarded turns) straight from
the store; the extraction queue (#326) is likewise unaffected, holding a **text snapshot** rather
than a reference to a message that may no longer exist. `core-app` 0.81.0→0.82.0; `web`
0.109.1→0.110.0.
- **Mail: mark message read on open** (#625) — a message stayed unread until acted on explicitly.
Opening a conversation now marks its unread messages read at once: the shell flips the list row
(and the messages' badges) **optimistically**, then calls a new operator-gated
`POST /pages/mailbox/mark-read` in the background, which clears each message's flag at the
provider (`set_unread`, the #277 seam) and **writes the read state through to the local cache**
(ADR-0096) so the row converges immediately — no reconcile-race flicker — and reverts on a
provider failure. Marking a whole thread read is the one case where a thread-level cache
write-through is unambiguous (this gives #623's write-through primitive its first caller). `mail`
0.12.0→0.13.0; `core-app` 0.77.0→0.78.0; `web` 0.106.0→0.107.0.
- **Core: maintenance schedule controls — on/off, cadence, time of day** (#621, ADR-0098) — the
maintenance panel read "manual only — nightly schedule off" with no way to change it: the
nightly trigger was a code-level default (`MAINTENANCE_SCHEDULE_ENABLED`/`MAINTENANCE_HOUR`),
fixed for the process's whole lifetime, with no endpoint to edit it. Now a real, per-tenant,
runtime-editable schedule — enable/disable, an hourly/daily/weekly cadence, an hour (+weekday
for weekly), read in the tenant's timezone (ADR-0039) — governs the orchestrator's nightly
batch as a whole (the job registry itself stays untouched and additive-only, so the incoming
reflection job, #615, still rides this one shared hour per ADR-0093). Persisted per tenant
(`maintenance_schedule_prefs`, the same settings-primitives shape as `timezone_prefs`/
`page_order_prefs`); a missing row falls back to the env-configured default, so a fresh
install behaves exactly as before. The orchestrator now **polls** every
`MAINTENANCE_POLL_INTERVAL_S` (default 60s) and re-reads the schedule fresh each tick — a
fixed `sleep_until_hour` computed once at wake couldn't react to a schedule changed while it
slept. `GET /platform/v1/maintenance` gains `schedule_cadence`/`schedule_weekday`/
`next_run_at` (an estimate for display); a new `PUT .../schedule` validates and persists the
whole schedule at once (400 on an invalid shape, e.g. weekly with no weekday). The Settings
panel grows enable/cadence/hour/weekday controls plus an effective-schedule + next-planned-run
summary — a multi-field draft the operator edits and explicitly saves (auto-saving per field
would fire invalid combinations mid-edit). `core-app` 0.76.0→0.77.0, `web` 0.105.1→0.106.0.
- **Models: show the max context window with the other model info** (#618) — the Models page
badged each model's capabilities (tools/vision/…) but never its trained context window, so
choosing between two similarly-named variants meant guessing. Local models now carry a compact
"128k"/"1M"-style chip alongside the existing capability badges, sourced from the same
`/api/show` call the capabilities already come from (opt-in, one call per model — the chat
picker stays light). Hosted (saved) models get the same chip, sourced from LiteLLM's own
model-cost map — the identical lookup #633 added for hosted vision/tool capabilities — always
included since it's a static lookup, not a network call. Omitted, never a fake default, when
the runtime/map doesn't report a length. `core-app` 0.75.0→0.76.0, `web` 0.104.0→0.105.0.
- **Chat: image input — vision models see the picture, end-to-end** (#633, ADR-0095) — attaching
an image was silently mangled regardless of model: every `file` attachment was blindly
`decode("utf-8")`'d into a text preamble, so a vision-capable model never received real pixel
data, hosted or local. Now an image resolves separately (checked against the **stored**
upload's real content-type) into multimodal content parts (`image_url`) spliced into the
assembled turn just before the provider call — never into what gets persisted, so a stored
turn never balloons with base64. Gated on the selected model's **actual** vision support
(`gateway.supports_vision`) — stricter than the existing tool-capability check, since a
mis-sent image either gets ignored or draws a provider 400: hosted models are checked against
LiteLLM's own model-cost map (never assumed capable), and a local model with unreported
capabilities defaults to **not** vision-capable. A non-vision model gets a clear explanation
before any provider call, same shape as a normal answer, not a raw error. The same LiteLLM
lookup also fills in **hosted-model context length + capabilities** for
`GET /llm/models/details` (previously local-only), so the composer's "can't use tools"-style
hint now works for hosted models too, alongside a new "can't see images" hint (advisory —
Send still works; the server's own gate is the real enforcement). Web: the upload picker's
accepted types now align with the server's #175 allowlist. `epicurus-core` 0.26.0→0.27.0,
`core-app` 0.74.0→0.75.0, `web` 0.103.0→0.104.0.
- **Calendar: toolbar reworked into one stretched row; calendars picker clamped on mobile**
(#628, #629) — the control row read as cramped and unbalanced; it now follows the shell's toolbar
convention (the board's `gap-x-3 gap-y-2` bar) — a **Today · ‹ › · period** navigation cluster on
the left, the page actions + **Calendars** picker + view switch pushed right by `ml-auto` so the
row stretches the full width; icon-only "New event"/Calendars keep it to one line on all but the
narrowest phones (where it wraps to a tidy second line). The **Calendars** visibility popover
opened **partly off a phone screen**; it is now **clamped to the viewport** — positioned `fixed`
from its trigger, shifted horizontally to stay on-screen, flipped above when there's more room up
than down, and height-capped with a scroll — so every calendar is reachable regardless of trigger
position. Calendar-local components only; no shared shell component changed. `web`
0.102.0→0.103.0.
- **Calendar: tap a month day to open its week; slim event lines on phone** (#630, #632) — in the
month view, tapping a day **navigated into a half-started create**; it now **opens that day's
week view** (with the day highlighted), making the month a navigator and putting legible detail
one tap away in the hourly grid. Event **creation** moves to the explicit affordances — the
toolbar **New event** and the week grid's **empty-slot tap** (the #473 slot-seed create,
relocated from the month cell to the grid where Google-style calendars put it). On a **phone**,
a busy day showed a few blank-looking chips plus a `+2 more`; it now renders **every** event as a
**slim textless colour line** (density over labels — the tap-through carries the detail),
collapsing to a `+N` marker only past what genuinely fits. **Desktop** keeps the labelled chips.
`web` 0.101.0→0.102.0.
- **Calendar: week view is now an hourly day-grid with drag-to-move** (#631) — the week view was a
plain per-day list of event cards; it is now a Google-Calendar-like **hourly grid**: one column
per day over hour rows, timed events **placed and sized by start/duration**, overlapping events
**split into side-by-side lanes**, a **pinned all-day strip** (ADR-0037) that stays put while the
hours scroll, a **current-time line**, and a default scroll to the morning. A timed event on a
writable calendar is **dragged to move** (or its bottom edge dragged to **resize**), snapped to the
quarter-hour and applied **optimistically**; the write goes through the event's *own* editable-
calendar **Edit** action (`calendar_update_event`, #208/ADR-0034) — the same tool the Edit form
calls, so **no module contract changes** (the module supplies data, the shell renders, ADR-0018) —
and rolls back with a dismissible message on provider failure. A read-only event stays
click-to-open. On a phone the grid **pans horizontally** with the time gutter and day headers
pinned. Placement and drag maths are framework-free and unit-tested
(`services/web/src/components/archetypes/calendarGrid.ts`). `web` 0.100.0→0.101.0.
- **Mail: render HTML email properly (images, styling)** (#627, ADR-0097) — HTML mail rendered
badly: the shell decoded every message to plain text (no images, no layout), because rendering
raw mail HTML in the shell would be an XSS surface. The module now surfaces the message's
**`body_html`** (plus inline images' `Content-ID`s, marked `inline`), and the shell renders it in
a **sandboxed iframe** (`allow-same-origin allow-popups`, **never** `allow-scripts`) — so email JS
can never run and the email's CSS can't bleed into or restyle the app shell. Two independent safety
layers: the HTML is first sanitized by an **inert `DOMParser` pass** (strip `<script>`/`<link>`/
`<iframe>`/`<form>`, every `on*=` handler, `javascript:`/`vbscript:` URLs) — the raw HTML never
touches the live DOM — then the sandbox neutralizes anything missed. Inline **`cid:` images** are
rewritten to the module's same-origin attachment proxy (fetched through the module, never a direct
provider URL), so they load with the session cookie; inline images are kept out of the download
row. **Remote images are blocked by default** (a remote `<img>` is a tracking pixel) with a
per-message "Load images" affordance — the deliberate privacy default. Plain text stays the
fallback for text-only mail and the `mail_read` tool; emails render on a white canvas in both app
themes (the mainstream mail-client convention) for legibility. `mail` 0.11.0→0.12.0; `web`
0.99.0→0.100.0.
- **Mail: local cache + incremental sync — stop full-fetching on every open** (#623, ADR-0096) —
the mailbox page fetched everything from Gmail on *every* open (the rail + one metadata
`threads.get` per thread, ~28 calls for a 25-row page), so opening Mail was slow. The module now
keeps a tenant-scoped **local cache** (`mail_thread`/`mail_label`/`mail_sync`/`mail_landing`, owned
by the mail service — no shared DB) and reconciles it incrementally. The plain landing view serves
the cached rows + rail with **no** provider call (the first open of a folder is a one-time cold
sync; every open after renders in ~a second); the web then fires a background `?reconcile=1` read
that pulls **only the delta** — via a provider-neutral change cursor (`MailCursor {history_id,
uid_validity, uid_next}` behind the `MailProvider` seam; Gmail uses `historyId` +
`users.history.list`, IMAP's `UIDVALIDITY`/`UIDNEXT` reserved) — rebuilding only the touched thread
rows so new/changed messages, read/unread flips, and archives appear without a manual refresh. A
cursor too old to replay (Gmail expires history after ~a week) or an IMAP `UIDVALIDITY` rotation
triggers a full resync. Read/unread converges both ways (a mark-read writes through to the cache
optimistically). Search (`?q=`) and deeper pages (`?cursor=`) still read live — the cache only
accelerates the landing open. Large-int columns are `BigInteger` (a Gmail `historyId` and the
epoch-millisecond `sort_ts` ordering key both exceed int32); the schema evolves via `create_all` +
the shared additive `ensure_columns` reconcile (ADR-0067). `mail` 0.10.0→0.11.0; `web`
0.98.0→0.99.0 (the mailbox view's cache-then-reconcile flow).
- **Agent: loop hygiene — stop on repeated calls and error streaks** (#524, ADR-0091) — the step
loop continued on the blunt rule "the model made a tool call", so two shapes burned the whole
`max_steps` budget and ended in a silent stop: the model re-issuing the *exact same* call over and
over, and a streak of tool errors (retrying a broken call to exhaustion). A small `_LoopGuard`
wraps the loop (ADR-0001 stays thin — outcome-aware *stopping*, not planning), applied identically
to `run` and `run_stream`. A repeated identical call (canonicalized `(name, sorted-args)`, so
distinct-args paging/per-item repeats pass untouched) earns a one-shot nudge and is **not
re-executed** — a repeated write would double-apply — then ends the turn `stopped="repeat_call"` on
a further repeat; three consecutive tool errors end it `stopped="tool_errors"` (any success resets
the streak). Both take the same single tool-less final round `max_steps` already uses, so the turn
ends with a **real answer** — "here's what I found / what failed" — never a silent stall. The new
`stopped` reasons ride the streamed `done` event for the web to key copy off; the repeated/errored
steps stay visible in the activity timeline. (Web stop-reason badge deferred — the transcript
carries no per-message `stopped` yet; the live reason is already on the `done` event.)
`core-app` 0.73.0→0.74.0.
- **Agent: `memory_search` built-in tool — deliberate recall over past sessions and facts**
(#523, ADR-0089) — structural recall only injects top-k facts every turn, with no way for the
agent to *dig*; "what did we decide last week about the backup strategy?" failed unless
extraction happened to distil that exact decision into a fact. A new core built-in
`memory_search(query, scope = facts | sessions | both, limit)` closes the gap: the agent
deliberately searches the durable **fact store** (Qdrant, the same ranking a turn's ambient
recall gets) **and** past **conversations** (a portable case-insensitive content match over
`agent_messages`, joined back to each conversation's title), and gets back a compact, capped
set of the most relevant facts + past-conversation excerpts. It registers alongside
`now`/`remember`/`ask_user` (ADR-0039) and shows as a normal step in the activity timeline.
Tenant-scoped on every query (recall crosses sessions, so scoping is a privacy boundary,
constraint #1); best-effort like the rest of memory — the facts half embeds through the
gateway (constraint #8), so a cold embedder degrades to just the sessions text search (no
embed) rather than failing the tool call. Results are capped (≤10/source, snippets trimmed)
for token discipline — never a raw session dump. `core-app` 0.72.0→0.73.0.
- **Agent: scheduled turns — recurring prompts that run unattended** (#526, ADR-0092) — the
agent was purely reactive, needing an HTTP caller or an inbound bridge message for every
turn; an operator can now author a prompt with a daily/weekly cadence at a local hour, and
it fires on its own, delivering into its own chat session (a fresh session that comes into
being — titled from the prompt itself — the moment it first fires). A single background poll
loop (not one wake-at-hour task per row, since each row carries its own independently
configured hour and rows are created/paused/deleted at runtime) finds due rows each tick and
runs them sequentially through the normal headless-turn path, metered under the row's own
tenant. Respects power state: a paused runtime skips a due row and records the skip (not a
silent no-op, and never a burst of catch-up runs on resume). Settings-surface only (no module
UI) — a new **Scheduled turns** card: list, create (prompt + cadence + hour + weekday),
enable/disable, delete. `core-app` 0.70.0→0.71.0, `web` 0.96.0→0.97.0.
- **Memory: a standing user profile, synthesized nightly and injected statically** (#527, ADR-0094)
— recall was top-k vector search over the fact store at *turn time*, so the stable common case
(who the user is, durable preferences) paid an embedding round-trip and a single-GPU model-swap
risk on **every** turn, for content that barely changes. Now a nightly `profile_synthesis_job`
(added to the ADR-0060 maintenance registry, beside the extraction drain) distils each tenant's
facts into one compact **standing profile** via a single gateway call, and `Agent._assemble`
injects it as a static system block with **no turn-time embed** — moving the common-case cost off
the response path (the same trade ADR-0051 made for fact extraction); vector recall stays for the
long-tail specifics. Stored per tenant and versioned (`standing_profiles`, last 5 kept). Visible,
**editable, and clearable** in the Settings → Memory view (`GET/PUT/DELETE /memory/profile`): an
operator edit is **pinned** and survives re-synthesis (the synthesizer skips a tenant whose
profile is `edited`) until cleared. Best-effort throughout — no profile is exactly today's
behavior, a failed synthesis keeps the previous profile. Nightly auto-runs ride the opt-in
maintenance schedule (`MAINTENANCE_SCHEDULE_ENABLED`) or the manual "run everything" trigger.
`core-app` 0.69.0→0.70.0, `web` 0.95.0→0.96.0.
- **Review: edit the draft before approving, with an audit trail** (#542, ADR-0090) — review
surfaces (knowledge's Suggestions, notes' Note suggestions) were approve/reject only; the
operator can now hand-edit the proposed content directly in the review window before
approving — "edit anywhere before approving anything" — layered on top of the existing
per-hunk merge. Approve carries the edited draft back to the module, which writes what was
actually approved. Every approve/reject now also records an audit row (the agent's original
proposal alongside what was actually applied), visible in a **Recently resolved** panel
under the pending queue — the pending queue itself still holds only unresolved suggestions
(ADR-0033), so this is the durable trail that survives resolution. The wire contract
(`ReviewSuggestion`/`ReviewData`/`ApplyResult`/`ApproveBody`/`ReviewDecision`/
`ReviewAuditData`) moves into a shared `epicurus_core.review` module so every review-page
adopter — knowledge and notes today, governed playbooks (#525) next — gets
edit-before-approve and the audit trail for free instead of reimplementing it. `epicurus-core`
0.25.0→0.26.0, `knowledge` 0.21.0→0.22.0, `notes` 0.6.0→0.7.0, `core-app` 0.68.0→0.69.0, `web`
0.94.0→0.95.0.
- **Mail: a full mail client in the shell** (#550, ADR-0087) — mail becomes a first-class
left-nav page like Files / Calendar / Tasks / Notes, through a new **`mailbox` page
archetype**: a labels rail with unread counts → a cursor-paginated thread list → the full
conversation, with **compose and reply**. Browsing is folder-scoped; a search (`q`, Gmail
syntax) spans the whole mailbox, and the page size is capped so one fetch can't scan an
unbounded mailbox (#539). The mail module ships **zero markup** (ADR-0018): it declares the
archetype and supplies data, and the core shell renders — reusing the *same* `MailMessageView`
the panel `email-reader` already uses, not a fork. **Plain-text-first**: an HTML-only message
is decoded to readable text server-side (`_html_to_text`, adversarially tested), so no HTML is
ever rendered in the shell — zero mail-XSS surface. `mail` 0.9.1→0.10.0, `core-app`
0.66.3→0.67.0, `epicurus-core` 0.24.0→0.25.0, `web` 0.90.0→0.91.0.
- **Calendar: show task due-dates on the calendar page** (#469, ADR-0088) — "what's on my
plate today" meant checking the tasks board and the calendar separately; open tasks with a
due date now show as read-only, checkbox-glyphed chips on their due day, distinct from real
events. The core gains a new cross-module **calendar-feed** aggregate
(`GET /platform/v1/calendar-feed?start=&end=`, `ModuleRegistry.calendar_feed_items`) —
deliberately **not** a manifest-declared capability like `resolver`/`attachable`: a module
opts in purely by serving `GET /calendar-feed?start=&end=` itself, and the aggregator
probes every enabled, healthy module for that path, skipping a 404/unreachable one exactly
the way the existing `/suggestions` feed already tolerates a down module — reusing that
pattern kept every line of this change inside `services/tasks` and `services/core-app`,
with zero touches to the shared `libs/epicurus-core` (which was under concurrent edit for
an unrelated in-flight archetype at the time). `tasks` is the first module to implement it:
`calendar_feed_items` filters the already-fetched open-task list to a due date in range,
carrying each item's own status (open vs. in-progress) and a `kind` field (beyond the
issue's own sketch) so the shell's click handler can call the *generic* hover-card resolver
without hardcoding "task". Every task hover-card also gained a link back to the Tasks board,
not only ones reached from the calendar. Verified live: a task chip renders on its due day
alongside real events, resolves through `GET /resolve/task/{ref_id}` and opens in the right
panel on click, and a failed feed fetch never blanks the events that did load. Month-grid
view only for this pass — week/agenda are a follow-up, mirroring #473's own scoping of the
time-grid slot-click to "no hour-grid view exists yet." `tasks` 0.15.3→0.16.0, `core-app`
0.67.1→0.68.0, `web` 0.93.0→0.94.0.
- **Calendar: click a day to create an event, pre-filled** (#473) — creating an event meant
reaching for the toolbar's "New event" button and re-typing a date the calendar was already
showing. Clicking empty space in a month-grid day cell now opens the page's own existing
create-event form (ADR-0034 — no new module contract) pre-filled with that date, `all_day`
on by default so the date pickers collapse correctly (`date_toggle`, #252); the operator can
still adjust or switch to a timed event before submitting. Event chips and the "+N more"
overflow button keep opening event/detail as before — each stops the click from also
bubbling into the new cell handler, so only genuinely empty space triggers create. The
existing calendar-picker default (`form_values.calendar_id`) survives untouched, since the
seed only ever adds `start`/`end`/`all_day` on top of it, never replacing the whole set.
Two small, generic extensions carry this rather than a calendar-specific one-off:
`ActionControl` gains optional `initialValues` (merged over a module's `form_values`) and
`open`/`onOpenChange`/`hideTrigger` (drives its form sheet from outside, with no visible
button of its own) — any future archetype wanting the same "click something else to open
an existing form, pre-filled" pattern reuses it as-is. The day/time-grid slot-click and
click-drag range-select the issue also describes has no substrate yet — `WeekView` is a
per-day list, not an hour grid — and is left for whenever that view exists; the acceptance
criteria's own "(where those views exist)" qualifier anticipated the gap. `web` 0.92.0→0.93.0.
- **Websearch: results as Sources-pill chips, at parity with local sources** (#551,
ADR-0019) — a `web_search` answer previously left the operator unfolding raw tool-call
JSON to see which pages informed it; local sources (knowledge/mail/calendar/tasks) had
this solved since #333. `web_search` now returns a `ToolEnvelope` (text unchanged —
still a ranked title/URL/snippet listing the model can cite — plus one `EntityRef` per
result), so results render as the same **Sources (N)** pill and chips. The module stays
**stateless**: a new `epicurus_websearch.refs` codec (mirroring
`epicurus_knowledge.refs`'s self-describing-id pattern) base64url-encodes each result's
url/title/snippet/engine directly into its `ref_id`, so the new `GET
/resolve/result/{ref_id}` hover-card resolver reconstructs everything with no store —
hover-cards keep resolving in a session reopened long after the search ran. Because
websearch has no right-panel view of its own, its chip is the first to diverge from the
generic click-opens-the-panel behavior: it always carries an `href` and a chip click
resolves then opens the source page directly in a new tab
(`rel="noopener noreferrer"`), with an external-link glyph on the chip itself so a web
source is never mistaken for an in-app entity — both already-generic frontend pieces
(`CardLink`'s scheme-gated external-link branch, the core's cross-call `_RefCollector`
ref-id dedup) needed zero core-app changes to support this end-to-end. Same-page
duplicates within one search (SearXNG returning a URL from multiple engines) are
collapsed before refs are built; a malformed or tampered `ref_id` (bad base64, non-JSON,
or a non-`http(s)` scheme) 400s cleanly, never a 500 and never an unsafe `href`.
`websearch` 0.1.0→0.2.0, `web` 0.91.0→0.92.0.
- **PWA: share target + app shortcuts** (#493) — the installed app was inert to the OS around
it. Two manifest-level features, especially useful on Android: **share a link, text, or
file/photo from any app straight into a chat turn** (`manifest.share_target`, `POST`
`multipart/form-data`), and **long-press the icon → New chat / Calendar / Tasks**
(`manifest.shortcuts`). The share target needed a service worker that can intercept a POST
before the browser discards its body navigating away — something `vite-plugin-pwa`'s
auto-generated `generateSW` service worker has no way to express — so the service worker is
now a custom source file (`src/sw.ts`, `injectManifest` strategy) that reproduces the SPA
navigation fallback and the `registerType: "prompt"` update-skipWaiting wiring the old
declarative config gave for free (its own top comment explains both), plus the new
share-target `fetch` handler: it stashes the shared `title`/`text`/`url`/file in the Cache
API and 303-redirects to `/?share=1`; the chat screen prefills the composer (appended to a
draft already in progress, never clobbering it — the composer never sends on the operator's
behalf, the #480 starter-chip principle) and uploads any file through the same path a
paste/drop already uses. The Calendar/Tasks shortcuts reuse `ModulePageScreen`'s existing
"no such module page" empty state as their degrade when that module is off — no new code
needed there. `src/sw.ts` is excluded from `tsconfig.json` (a service worker's `WebWorker`
lib can't coexist with this project's `DOM` lib in one project) and from the `no-restricted-
globals` bare-`fetch` guard (#529) — it is its own global scope entirely, with no `epFetch`
to route through. `vite preview` also gained its own `proxy` block (it doesn't inherit
`server.proxy`), since a production build is the only way this whole surface ever runs —
verified live against one: the service worker registers and activates, a real POST to
`/share-target` is intercepted and redirects correctly, and the chat screen consumes the
staged payload end-to-end. `web` 0.89.0→0.90.0.
- **Chat: a "finished while you were away" indicator for background turns** (#492) — a turn
outlives its connection (#376) and other sessions can generate concurrently (#396's pulsing
dot shows that *while* it runs), but nothing told the operator once a background turn
*finished* — the answer just sat there until they happened to reopen that conversation. A
shell-level watcher (`useAwayFinishedWatch`, mounted once in `Shell()` alongside the existing
`useConnectionWatch`) polls the same `["active-runs"]` query `SessionsSheet` already used
(gated on being open) — now always live, a steady 15 s, regardless of which screen is
showing — and diffs each poll against the previous one: a session that *was* active and no
longer is, and isn't the one currently open, just finished unseen. One boolean marker per
session (no counts, no push notifications — those are Phase-4/bridges territory): the
session's row in the Conversations sheet gets a static accent dot + a bolder title, the chat
header's History button picks up the same dot plus an `aria-label` update, and the document
title gets a `•` prefix so a backgrounded tab/PWA shows it too. All three clear the instant
that session opens — funneled through the one `openSession` store action every entry point
(sheet row, palette, hover-card) already calls, so nothing needed wiring per call site. No
extra polling while the tab is hidden — inherited for free from React Query's default
`refetchInterval` behavior, verified live (the watcher's poll visibly stalls while
`document.visibilityState` is `"hidden"` and resumes the moment it flips back). `web`
0.88.6→0.89.0.
- **Maintenance: live progress + refresh-proof batches** (#561) — running maintenance showed no
progress beyond a spinner, and refreshing the page during a batch lost it entirely (the card's
running state was pure client mutation state). The batch itself was never at risk — verified
empirically (a real uvicorn server, a client disconnecting mid-request both gracefully and via a
hard reset) that this stack does not cancel a plain in-request `await` on client disconnect — but
the batch still had no way to be *observed* after a refresh, and nothing stopped a second manual
trigger (or an overlapping nightly window) from racing a duplicate batch. `POST /run` now starts
the batch as a **detached background task** (the same shape as chat turns, `agent/live_runs.py`,
#376) and returns **202** immediately with its live progress instead of holding the request open
for however long a full re-embed takes; the orchestrator tracks a `current_run` with per-job
`pending`/`running`/`ok`/`skipped`/`error` status as it sequences, exposed by `GET` alongside the
last *completed* run. A second `POST` while one is live responds **409** rather than double-running
— the nightly scheduler treats the same conflict as a benign skip — and `shutdown()` cancels an
in-flight batch cleanly at app teardown instead of orphaning it against infra about to close. The
Settings **Maintenance** card renders per-job progress from `current_run`, rehydrates onto it on
mount (a refresh mid-batch lands back on the same run), and polls a few seconds apart while one is
live. `core-app` 0.65.0→0.66.0, `web` 0.87.0→0.88.0.
- **Models: a context budget for hosted models — long chats compact instead of overflowing** (#570) —
a saved hosted/API model had no context-window control and no compaction path: both readers of the
per-model setting sat behind an `is_local` guard, so a long conversation grew until the provider
rejected the turn with `context_length_exceeded`. Hosted rows now take the same per-model context
setting local models already have (#289/#328), read as a **compaction budget** rather than an Ollama
`num_ctx` — the size the history is trimmed to fit before the call, giving both overflow protection
and a per-turn input-spend cap. Resolved by **exact model id only** (never the global Ollama pref,
never the loose local-family match); an unset budget leaves behavior identical to today. `core-app`
0.64.1→0.65.0, `web` 0.86.1→0.87.0.
- **Models: real GB sizes everywhere + honest cloud-only rows** (#571) — the model browser
never showed a download size (the library *index* the catalog parses publishes none, so
`size_gb` was seed-only and blanked after the first live refresh), and **cloud-only** models
(`deepseek-v4-flash` — one upstream `cloud` tag, no weights) rendered as bare rows with a
plain **Pull** that couldn't do what it said. Now the per-family **tags page** — the same
page the quant-variant lookup (#330) already fetches — supplies real sizes end to end:
`ModelVariant` gains `size_gb` (the pick-list shows exact per-quant sizes and judges fit and
the *recommended* mark by them, estimates only as fallback), and a **background size fill**
backfills catalog rows most-popular-first, **one rate-limited lookup per
`LLM_CATALOG_SIZE_FILL_SECONDS`** (default 30 s; 0 disables) through the lookup's new
per-family TTL cache — the catalog refresh itself stays **exactly one** upstream request,
enriched sizes carry across refresh swaps, sized rows take their bare tag's size and
size-less downloadable families (embedding models) take `latest`, and any tags-page failure
just leaves that family size-less. On-demand variant lookups piggyback their sizes onto the
catalog immediately. The tag vocabulary gains **`thinking`** (chip only) and **`cloud`** on
*both* sides of the seam; `cloud` applies only to a pill-marked family's **size-less bare
entry** (hybrids like gemma3/gpt-oss keep their downloadable rows untagged — the pill has
*no* `x-test-capability` hook upstream, so the parser matches the pill span itself, verified
live 2026-07-09). Cloud rows are **badged with the reason on hover/touch, offer no Pull, and
show no fit verdict — by design**; cloud aliases in the variant list are labelled `cloud`
and never given an estimated size. `core-app` 0.62.0→0.63.0, `web` 0.84.0→0.85.0.
- **Files: upload from the Files page — with a mobile source menu** (#479) — the Files page
could browse, move, rename, and download, but nothing could be *put in* from the UI. A new
core endpoint (`POST /platform/v1/files/upload?dir=`) lands one file per request through the
FileStore seam (local-FS ↔ S3, constraint #3), tenant-scoped, **indexed immediately** so it's
listed and searchable with no rescan, and bounded by the **shared #175 caps** (`ATTACHMENT_MAX_BYTES`
→ 413, `ATTACHMENT_ALLOWED_TYPES` → 415; nginx's `/platform/` 12 MiB body cap already fronts it).
A name collision suffixes (`photo-2.jpg`) rather than overwrites; module-owned destinations are
refused. The web's Files toolbar gains **Upload into the current directory**: phones get a
Telegram-style bottom-sheet **source menu** (Photo or video → gallery, Camera → capture,
Document → file manager), wide screens go straight to the file dialog, and the listing accepts
**external file drops**. Multi-file picks upload sequentially with per-file progress pills —
a rejected file pins the server's own 413/415 detail and raises a toast — and the listing
refreshes per success. Movability in the Files view now follows the real ownership rule:
**operator-space files are movable like object uploads; module-owned subtrees (the module
hostnames — `knowledge/…`, `notes/…`) and directories stay read-only.** `core-app`
0.60.0→0.61.0, `web` 0.82.0→0.83.0.
- **Cmd+K command palette** (#491) — the wayfinding capstone on #480: one keyboard-first
overlay over everything the shell already knows. Ctrl/Cmd+K toggles it on every screen
(a "Search… ⌘K" affordance in the side rail opens it by pointer); typing fuzzy-filters
conversations (recency-ordered, from the sessions cache), core surfaces + module pages
(the same registry data the rail renders), and a few actions — New chat, Wake/Pause,
and New note when the notes module is installed (a `?new=1` deep-link that opens the
editor's create flow). Arrows + Enter navigate, Esc closes and restores focus (#487
contract, combobox semantics). Deliberately not a second API surface: the palette only
reuses queries the shell already holds; the fuzzy scorer is a dependency-free
subsequence ranker in `src/lib/fuzzy.ts`. Also fixes the calendar event-chip hover
pairing `text-canvas` with a runtime calendar colour (#531): the hovered chip's text
colour is now computed per colour (house ink → white → pure black, first to clear
WCAG AA — `src/lib/color.ts`), so a light calendar on the light theme no longer washes
the label out. `web` 0.81.0→0.82.0.
- **Web: fetch-guard lint rule + connection-gate regenerate/edit/resume** (#529, #530) — two
follow-ups from the #519/#494 outage-detection review. (1) A `no-restricted-globals` rule
(the same mechanism already banning `alert`/`confirm`, #488) now rejects a bare `fetch(`
anywhere in `src` outside `src/lib/http.ts`'s own `epFetch`, so a future call site can't
silently bypass the outage detector. (2) `regenerate()`, `saveEdit()`, and the `ask_user`
resume-answer submit were the three remaining send-adjacent actions that still fired while
the core was unreachable and failed into the generic error card instead of the composer's
existing gate; all three now bail on `connectionLost` and disable their buttons the same way
Send does, reusing the existing hint pill — no new UI. `web` 0.80.1→0.81.0.
- **Tasks: overdue recurrence sweep** (#515) — a recurring task nobody ever completed used to
sit overdue forever (materialization was on-complete only). Every read (`tasks_list`, the
board) now also materializes a fresh instance for an open, overdue recurring task: the
overdue task itself stays open and untouched — only its rule retires, moving the recurrence
to a new successor (skip-missed, like a late completion). Also: a materialize failure (next-due
computation, successor creation, or rule retirement) is logged and never breaks the
completion/read that triggered it, with one retry on the retire write before giving up;
`tasks_update` now rejects setting `repeat` on a task with no due date (matching `tasks_add`);
and the shared board `SchemaForm` now sends an explicit clear for an optional field that had
a value and was blanked — on a task, "Does not repeat" over an existing rule actually clears
it now instead of being silently dropped. The **calendar** edit form deliberately ignores a
blanked repeat picker for now (`""` means "leave the series unchanged", the pre-existing
behaviour): calendar has no clear-recurrence contract yet, and passing the blank through
would reach Google as a bare `RRULE:` (API 400). `tasks` 0.14.0→0.15.0, `calendar`
0.14.1→0.14.2, `web` 0.80.0→0.80.1.
- **Bound the entity-ref id block and a module's list text for large results** (#468,
ADR-0084) — a large ref list (a wide search, RRULE-expanded calendar events over a long
window, #443) previously echoed every ref's id into the model's context uncapped, roughly
doubling an already-large listing's cost (ADR-0079). The core's entity-ref id block now
truncates past `LIST_CAP` (50) refs with a "showing 50 of N — narrow the query/range or
ask for more" note, logged with the tenant id — the full ref list still reaches the UI's
chips unchanged. A new shared `epicurus_core.capped_listing` helper lets a module cap its
own hand-built "Found N ...:" text the same way; `calendar_list_events` adopts it as the
first caller. `epicurus-core` 0.22.0→0.23.0, `core-app` 0.59.0→0.60.0 (both MINOR — flag
a version-line collision at merge time against other in-flight core-app PRs), `calendar`
0.14.0→0.14.1 (PATCH).
- **Editable assistant system prompt — and a real base prompt at last** (#497, ADR-0083) — the
agent ran with **no** base system prompt: its identity and behaviour were emergent from the tool
schemas and the model's own defaults. This introduces the mechanism *and* the editor. A
tenant-scoped prompt (new `agent_instructions` table, following the timezone-pref pattern) is
injected as the **first** message of every turn — chat and headless bridge turns alike — ahead of
recalled memory and attached context, where the compaction leading-prefix rule protects it from
being trimmed. It's resolved per turn, so edits apply on the next message with no restart.
`GET`/`PUT /platform/v1/agent/instructions` back a new **Settings → Assistant instructions** card
(a textarea prefilled with the effective prompt, Save, Reset to default, and a soft-size warning —
the prompt counts against every turn's context and is never trimmed). A shipped default
establishes who epicurus is, a concise and candid voice, and tool-use discipline (with no
date/time baked in — the `now` tool owns that). **Behaviour shift for existing installs:** with no
stored prompt, every turn now gains the default preamble where before there was none. `core-app`
0.58.0→0.59.0, `web` 0.79.0→0.80.0.
- **Hosted/API model ids you enter are now saved per tenant** (#496) — a hosted model typed into
the chat picker (e.g. `claude/<model-id>`) used to live only in the browser (`recentModels`,
capped at five, per device *and* per origin): come back from another device, a VPN-hostname
origin, or after a PWA reinstall and it was gone. The core now persists the ids the operator uses
in a tenant-scoped `saved_models` table, behind `GET` / `POST` / `DELETE
/platform/v1/llm/saved-models`. The chat picker renders that server list as pick rows and
**auto-saves on use** (the free-text box stays for one-off / new ids); the Models page lists them
under each provider, where they can be **removed** or **set as the global default** (the star
local models already had); and they're now assignable to a **module model slot** (ADR-0029).
Saving rejects anything that isn't a *hosted* id — a known `<provider>/` prefix — which also
fixes the client's old `includes("/")` heuristic that mis-filed a local `hf.co/org/model:tag` as
hosted. `core-app` 0.57.1→0.58.0, `web` 0.78.0→0.79.0.
- **Web: offline / backend-unreachable banner** (#494) — the PWA now says when the backend can't
be reached instead of failing silently. A transport-level detector (`epFetch`, wrapping every API
fetch site) marks the core unreachable on network errors and 502/504 — 503 is deliberately
excluded (a paused house is not an outage) — and any healthy response clears it. PowerOrb's
existing 15 s power poll doubles as the heartbeat, so there is no new polling (and none while the
tab is hidden). A moonlight banner appears (offline wording wins when the device itself is
offline), the composer keeps the draft but gates Send, and recovery refetches vitals and
invalidates queries once per outage. `web` 0.77.0→0.78.0.
- **Web: AA accent fills, one notification corner, drop gating, EventDetail focus**
(#505, #510, #511, #512) — four overlay-polish fixes in one pass. A new
`--ep-on-accent`/`--ep-accent-hover` token pair gives every accent-filled control an AA-passing
label and hover fill in both themes and both power states, asserted by `contrast.test.ts` against
the live CSS (the light "paused" label is white — the issue's ink estimate computed to 4.25:1).
Toaster, UpdateToast, and DownloadTray now stack in one fixed `CornerStack` column instead of
overlapping (rule: never add a new fixed corner element). Drag-drop attach is suppressed while
any `aria-modal` overlay is open — `dragover` still `preventDefault`s so the browser can't
navigate away. And the calendar's EventDetail overlay adopts the shared `useModalFocus` trap.
`web` 0.76.0→0.77.0.
- **Recurring tasks + a friendly repeat picker** (#471, ADR-0082) — tasks can now **repeat**, on
both providers, even though the Google Tasks API has **no recurrence field** (repeat is UI-only).
A task carries an optional RRULE; **completing it materializes the next instance** with the next
due date and retires the rule on the completed one, so the recurrence lives on exactly one open
task at a time (re-completing can't double-fire; a `COUNT`/`UNTIL` series ends cleanly). The rule
is stored per provider — a `repeat` column on the local row, a module-owned `task_repeats` side
table keyed by task id for Google — but materialization is provider-agnostic (in the
`TasksRouter`). The next due date uses a **skip-missed** policy (a late completion rolls forward
to the next *future* occurrence). `tasks_add`/`tasks_update` gain a `repeat` parameter; the board
card shows a *Repeats weekly* badge. The web form renders `repeat` — and the **calendar's**
`recurrence` field, replacing its raw RRULE box — as a shared **friendly repeat picker** (None /
Daily / Weekdays / Weekly / Monthly / Yearly / Custom…) via a new `format: rrule` form widget; the
agent tools still accept a raw RRULE. Google caveats accepted explicitly: the rule is invisible in
Google's own UI, a task changed directly in Google is reconciled on our next refresh, and deleting
it in Google retires the rule (GC on miss). `tasks` 0.13.0→0.14.0, `calendar` 0.13.0→0.14.0,
`web` 0.75.0→0.76.0.
- **Web: paste & drag-drop attachments in the chat composer** (#489) — pasting a screenshot
or file from the clipboard into the composer, or dropping files anywhere over the chat
column, now attaches them exactly as the AttachMenu picker would: same
`POST /platform/v1/agent/attachments` endpoint, same pill, same server-sourced 413/415
size/type messages (surfaced as an error toast). Text pastes flow through untouched; a
themed "Drop to attach" hint appears only for real file drags (a depth counter stops
enter/leave flicker across child boundaries, and in-app drags never trigger it); in-flight
uploads show spinner pills; multi-file drops upload every file. On a PWA whose main
surface is chat, paste-to-attach was the highest-QoL missing interaction. `web`
0.74.0→0.75.0.
- **Web: overlay focus management for Sheet/Confirm** (#487) — the two overlay primitives
declared `role="dialog"`/`aria-modal` but had no focus handling at all: on open, focus
stayed behind the backdrop; Tab walked the page underneath; closing dropped focus on
`<body>`. A shared `useModalFocus` hook (hand-rolled, dependency-free) now gives both the
full keyboard contract: on open, focus moves into the dialog (yielding to a child's
`autoFocus` — stealing from a search/rename field would pop the phone keyboard shut);
Tab/Shift+Tab wrap inside; on close, focus returns to the triggering element. `Confirm`
additionally gains an Escape-to-cancel handler (capture-phase, so a Confirm stacked above
an open Sheet closes alone) and lands its initial focus on **Cancel** — the safe default
under a destructive prompt. `Button` now forwards a `ref` like the other kit primitives.
`web` 0.72.0→0.73.0.
- **Web: themed toasts replace every native browser dialog** (#488) — every mutation-failure
path that fired a `window.alert(...)` popup (12 sites: editor tree operations, file-browser
open/move, board card move, suggestion approve/reject) now raises a themed toast instead — a
bottom-anchored card in the shell's own style (`role="status"` polite live announcement,
manual close, auto-dismiss with errors lingering longer than info, identical re-raises
replacing rather than stacking). The store-driven `Toaster` (`src/stores/toasts.ts`,
`toast.error()`/`toast.info()`) is callable from any non-hook code path. The editor's three
`window.confirm` prompts (restore version over unsaved edits, delete file, delete folder) now
route through the shared `<Confirm>` primitive with the danger treatment. An ESLint
`no-restricted-syntax` + `no-restricted-globals` guard (the #394 pattern) bans
`window.alert`/`window.confirm` so native dialogs can't come back. `web` 0.71.1→0.72.0.
- **Mail: thread-aware reply** (#461) — `mail_send` only ever composed fresh messages, so
the agent's "reply" started a new conversation on both ends: no `In-Reply-To`/`References`
headers, no Gmail thread association. A new **`mail_reply(message_id, body)`** tool fetches
the original message's threading headers (a lightweight metadata-only Gmail call — no body
fetch), then sends with RFC-2822 `In-Reply-To`/`References` (the full reference chain, not
just the immediate parent) and the Gmail `threadId` in the send payload. The recipient (the
original sender) and subject (`Re: <original>`, not doubled if already a reply) are derived
from the original message, so the caller supplies only the new body. Declared a **danger
action** (ADR-0007) exactly like `mail_send`; `MailProvider` gains the `reply` seam so a
future non-Gmail provider mirrors it. `mail` 0.7.0→0.8.0.
- **Tasks: create a task list from the UI or the agent** (#474) — previously the only way to
get a new Google task list was outside epicurus, in Google Tasks' own UI, and the local store
had no list concept to create at all. A new **`create_list`** provider seam, a
**`tasks_create_list(title)`** MCP tool, and a board-level **New list** action (shown
wherever the Add form's list picker already is) all route through `TasksRouter` to the sole
configured external provider — **Google-only**: the local store is a single implicit list by
design (ADR-0030), so `LocalTasksProvider.create_list` raises `NotImplementedError` rather
than a half-working local multi-list system. The returned id is immediately usable as
`list_id` / `to_list_id` on the other tools, but — like any newly discovered Google list — it
still needs the operator's one-time enable toggle in the connected-accounts Lists section
before it appears as a board category; the module has no write path to the operator's
collection prefs to auto-enable it, a natural scoped follow-up. Renaming/deleting a list is
deliberately out of scope (destructive; needs a policy for the tasks inside). `tasks`
0.12.0→0.13.0.
- **NATS authentication** (#50) — the event bus now **requires credentials**; it previously
ran open, so any client on the internal network could publish/subscribe across all subjects.
A new `infra/compose/nats-server.conf` defines an account/user model with three roles — `core`
(full bus), `module` (tenant-scoped subjects), and `sys` (monitoring) — and the `EventBus`
authenticates with a per-role `NATS_USER`/`NATS_PASSWORD`. The OpenBao bootstrap generates strong
per-role passwords (recorded in OpenBao, written to `.env.secrets`); compose keeps weak
`epicurus-dev` defaults so local/dev `up` is unchanged. New modules authenticate as `module`
automatically via the service template. Enforced **per-tenant** isolation (account-per-tenant)
is the deferred SaaS-track step (ADR-0066). `epicurus-core` → 0.19.0.
- **OpenTelemetry tracing → Tempo** (#57) — the observability stack's third signal. `epicurus-core`
gains `epicurus_core.tracing` (`setup_tracing` / `get_tracer`): optional, env-driven distributed
tracing that instruments FastAPI requests and the NATS `EventBus` (publish / request / handle), with
W3C trace-context propagated across the bus so one trace spans publisher → handler, exported to Tempo
over OTLP/HTTP. **Off by default** (`OTEL_TRACES_ENABLED`); a runtime no-op when disabled, so the lean
stack pays nothing. Spans carry only structure (route, subject, tenant, byte size) — never payloads or
prompt content, the logs' redaction posture. The service template + echo + core-app wire it, so a new
module traces out of the box; enable fleet-wide with `OTEL_TRACES_ENABLED=true` and the `observability`
profile. ADR-0068. `epicurus-core` 0.17.0→0.18.0, `core-app` 0.51.0→0.52.0, `echo` 0.2.2→0.3.0.
- **Discord chat bridge + connect/manage bridges from the web** (#366, #369) — the first real
Phase-4 bridge, and the operator surface to run it. The `messaging` module now runs **every
bridge at once** (a `BridgeManager`): the always-on **loopback** echo plus each real bridge,
dormant until connected — each `messaging.outbound` reply is **dispatched to the bridge named by
the message**, and a new `POST /bridges/{bridge}/reload` control path lets a bridge connect at
runtime with no restart. The **Discord** provider (`discord.py`) reads inbound over the gateway
(DMs always; in a server only when **@mentioned**; ignores its own messages) and posts replies
over REST (thread-aware, chunked to Discord's 2000-char limit), reading its bot token from
OpenBao. The core gains a **bridge-admin** surface — `GET /platform/v1/messaging/bridges` plus
connect (write-only token) / on-off / disconnect — that writes the token to OpenBao and reloads
the module, so the browser never holds a token (constraint #6). The web adds a **Settings → Chat
bridges** card (connect/disconnect, an on/off switch, live per-bridge status). ADR-0062.
`messaging` 0.1.0→0.2.0, `core-app` 0.49.0→0.50.0, `web` 0.65.0→0.66.0.
- **Messaging foundation: chat bridges, inbound → turn → outbound** (#364) — the gating
foundation for Phase 4. A new **normalized inbox contract** in `epicurus-core`
(`InboundMessage` / `OutboundMessage` + the `messaging.inbound` / `messaging.outbound`
subjects + `session_id_for`), the **first inbound NATS consumer in core** — it runs a
**headless** agent turn per bridge message (keyed `session_id = "<bridge>:<channel>[:<thread>]"`,
reusing `Agent.run`, persisted like any turn) and routes the reply back out — and a new
provider-pluggable **`messaging` module** (host port 8093) that carries both ends via a
`BridgeProvider` seam (`start()` / `send()`), with a built-in **loopback** bridge so the path
works with no external account and per-tenant bot tokens read from OpenBao
(`messaging/<bridge>`). Memory/facts stay tenant-scoped → one brain across the web UI and
every bridge. Power-aware (paused → skip). The individual bridges (Telegram #365, Discord
#366, …) fan out after this as new providers. ADR-0058. `epicurus-core` 0.15.0→0.16.0,
`core-app` 0.46.0→0.47.0, new `messaging` 0.1.0.
- **Tasks: drag a card between columns to move it** (#380) — the board could only move a task
via the move picker / Edit form. Cards are now **draggable**: dropping one on another column
moves the task, reusing the card's **existing** move action (`tasks_update` with `to_list_id`,
#257), so the backend contract is unchanged. It applies where a column maps to a list (grouped
by **list**) — the dragged card's move choices are matched to the drop column by title; dropping
on a due/status/priority column is a no-op (the move can't change those dimensions). The
action/Edit path stays as the accessible, pointer-free fallback. `web` 0.56.0→0.57.0.
- **Calendar: choose which calendars are shown, and the month paints instantly** (#378, #379) —
the calendar view gave no way to hide a busy calendar, and reopening it refetched before
showing anything. Each event the module returns is now **tagged with its calendar**
(`calendar_id`, an `account:collection` token), so the view offers a **Calendars** menu of
per-calendar visibility toggles (each with a colour dot, persisted per page); hiding a calendar
drops its events client-side with no refetch. And each month window is **cached** in
localStorage: reopening paints the cached month **instantly** and revalidates in the background
(stale-while-revalidate, bounded to the last 12 windows). `calendar` 0.9.0→0.10.0,
`web` 0.56.0→0.57.0.
- **Notes & knowledge: the rendered Preview is now editable (WYSIWYG)** (#377) — the `editor`
archetype opens render-first, but its Preview was read-only, so editing meant toggling to the
raw markdown source. Preview is now a **WYSIWYG surface** (Milkdown's Crepe — ProseMirror +
remark) you type into directly, with **markdown kept authoritative**: edits serialize back to
the same buffer, so the existing idle/leave auto-save and version history (ADR-0042 / ADR-0046)
work unchanged. The Edit toggle still drops to the raw source; a **read-only** vault (a watched
Obsidian mount or the bundled reference docs) still renders without editing. The editor is
**lazy-loaded** so it never enters the main bundle. Adds the `@milkdown/crepe` dependency.
`web` 0.56.0→0.57.0.
- **Chat: the assistant can ask a clarifying question mid-turn, answered inline** (#360, ADR-0053)
— the core `ask_user` tool (backend #345/#361) pauses a turn and ends the stream with an
`awaiting_input` event carrying the question; until now the web just stopped the spinner. The chat
now **renders that question with an inline answer input** in the live turn (beneath the partial
answer), and submitting posts to `POST /platform/v1/agent/runs/{run_id}/resume` so the turn
**continues streaming** to completion. The pending question is **persisted**, so a hard refresh
mid-question keeps the prompt (the suspended run stays durable server-side for 24h); the main
composer remains an escape hatch that abandons the question. `web` 0.56.0→0.57.0.
- **Chat: the Conversations list shows which chats are still generating** (#396) — turns now run
server-side regardless of the client (#400/#376), so a conversation you've navigated away from can
still be answering, but the list gave no sign of it. Each session row now shows a subtle **pulsing
accent dot** while it has an in-flight turn: the current chat reflects its own live state instantly,
and other sessions are polled (while the list is open) from a new
`GET /platform/v1/agent/active-runs` — the session ids generating right now (tenant-scoped,
best-effort: the live-run buffer is a disposable cache). `core-app` 0.44.0→0.45.0, `web` 0.56.0→0.57.0.
- **Chat survives a hard refresh and PWA backgrounding** (#376, ADR-0055) — an agent turn used to
run *inline* in the SSE request, so a dropped connection (a phone backgrounding the PWA, a hard
refresh, a network blip) aborted it before the answer was persisted: the reply was lost and the
client stuck on a "network error" that never ended. Turns now run **decoupled from the request**
in a `LiveRunRegistry` — a detached task buffers the turn and always persists the answer, so a
disconnect only drops the *listener*. The web persists its `sessionId` (the transcript rehydrates
on reload) and **re-attaches** to a still-running turn on a dropped stream / reload / tab-resume
(`visibilitychange`/`online`); if it finished while away, the now-durable transcript shows it.
New: `GET /platform/v1/agent/runs/{id}/stream` (re-attach, with `after_seq`/`Last-Event-ID`),
`GET`+`DELETE /platform/v1/agent/sessions/{id}/active-run` (rediscover / Stop), an `id:` seq on
each chat SSE frame, and `LIVE_RUN_GRACE_SECONDS`. core-app 0.43.0→0.44.0, web 0.55.1→0.56.0.
- **One Suggestions inbox for every module's agent-proposed changes** — agent edits are staged
for review (knowledge's vault, notes' notebook, and any module that adopts the `review`
archetype), but each module surfaced its own queue as a separate left-nav page (knowledge's
*Suggestions*, notes' *Note suggestions*) — two places for the same kind of thing. They are now
a single top-level **Suggestions** surface (`src/screens/SuggestionsScreen.tsx`) that reads the
existing cross-module feed (`GET /platform/v1/suggestions`) and **groups it by module**: each
group carries that module's **review on/off** toggle (`suggestions-enabled`) and its pending
changes, each opening the shared review window (Approve / Reject / Ignore). The per-module
`review`-archetype nav entries are filtered out of the rail (`reviewPageNavs`); the pages still
exist at `/m/{module}/{review-page}` for deep links. It shares the `["suggestions"]` query with
the chat composer's suggestion bubble, so acting in one updates the other (`web` → 0.47.0).
- **Model capabilities are surfaced — tool support, vision, and more — and a tool-less model
just answers in text** — the runtime reports what each model can do (`/api/show`
`capabilities`), but nothing used it. Now: (1) the **agent offers tools only to a
tool-capable model** — passing tools to one that can't makes the runtime error, so a
tool-less local model falls back to a plain **text answer** and the chat composer shows a
**"can't use tools — chat only"** hint (driven by `GET /models/details`, which gains
`capabilities`); (2) the **Models page badges** each downloaded model with what it does
(tools / vision / …) — `GET /platform/v1/llm/models?capabilities=true` opt-in fills them
from `/api/show`; (3) the catalog browser gains **Tools** and surfaces **Vision** as search
filters (the upstream `tools` capability is now mapped into the tag vocabulary); (4) the
**chat model picker shows each model's size**. `ModelInfo`/`ModelDetails` gain `capabilities`
(`core-app` → 0.35.0, `web` → 0.45.0).
- **Chat: the activity timeline persists and now shows the model's thinking** — the agent's
process (its tool steps) used to disappear the instant a turn finished. Now the turn's
**thinking + tool steps** are persisted with the message: the timeline **folds** to its
summary rather than vanishing, and reappears folded when you reopen the conversation. The
model's chain-of-thought is surfaced in a collapsible **Thinking** block — captured both
from a provider's native reasoning field and from inline `<think>…</think>` spans (local
reasoning models), and kept out of the answer. Adds a `thinking` SSE event and an additive
`activity` JSON column on `agent_messages` (ADR-0041) (`epicurus-core` → 0.13.0,
`core-app` → 0.23.0, `web` → 0.31.0).
- **Memory view — see and curate what epicurus remembers about you** — the cross-chat
semantic-recall corpus (every user/assistant turn is embedded into Qdrant and the most
similar past snippets are pulled into future chats as context) is now visible in a new
top-level **Memory** screen. Browse it newest-first, **search** to see exactly what recall
surfaces for a topic (the same ranking a chat turn gets), and **forget** any snippet so it
stops being recalled — forgetting drops the recall **vector only**, leaving the source
conversation intact. Backed by `GET /platform/v1/agent/memory?q=&limit=` and
`DELETE /platform/v1/agent/memory/{id}`; each snippet's role + timestamp are joined from
`agent_messages` by point id, so there's no change to the indexing path and it covers
existing memories (closes #276, ADR-0040) (`core-app` → 0.22.0, `web` → 0.30.0).
- **The assistant knows the current time and your timezone** — the agent gained a built-in
`now` tool (its first non-module tool) so it stops guessing the date from its training
cutoff; combined with a new **Timezone** setting (Settings → Timezone, default `UTC`,
editable; env `DEFAULT_TIMEZONE`) it creates calendar events at the right local date and
time. `now` also surfaces the connected Google Calendar's timezone and flags a mismatch
with your setting. Previously, "add it at 19:00" could land on the wrong day at the wrong
hour. ADR-0039 (`core-app` → 0.21.0, `calendar` → 0.9.0 for the `/status` timezone,
`web` → 0.29.0 for the Settings card).
- **Live model catalog — the core parses the model list from upstream on a schedule** — the
Models screen's "Browse models" list used to be a hand-maintained static file
(`services/web/src/data/catalog.ts`) that went stale and forced a web release for every new
model. The core now owns it (constraint #8): a new `ModelCatalog` fetches a configurable
source (`https://ollama.com/library` by default), parses each model's sizes, description,
capabilities (→ tags) and popularity, caches the result, and refreshes it **regularly** on a
background loop. New endpoint `GET /platform/v1/llm/catalog` → `{ entries, source, updated_at,
stale }`; the web shell fetches it (keeping `filterCatalog` unchanged) and shows provenance
("From ollama.com/library · updated 3m ago"). Resilient: a failed/disabled refresh serves the
last-good snapshot, and a small built-in **seed** when nothing has been fetched yet (cold or
air-gapped), so the browser is never empty — the bundled list is the offline fallback. New
knobs: `LLM_CATALOG_URL`, `LLM_CATALOG_REFRESH_SECONDS` (default 6h), `LLM_CATALOG_MAX_MODELS`
(0 = unlimited), `LLM_CATALOG_ENABLED` (closes #269) (`core-app` → 0.20.0, `web` → 0.28.0).
- **Mail: mark messages read / unread** — mail is no longer read-only. Two new MCP tools
(`mail_mark_read` / `mail_mark_unread`) let the agent flip a message's read state on request
("mark my newsletter as read"), and the right-panel email reader gains a **Mark as read /
Mark as unread** toggle (a tool-backed action, ADR-0024) that invokes the tool through the core
proxy and re-fetches so the toggle flips. The provider seam gains `set_unread(message_id,
unread)`; the Gmail provider implements it via `messages.modify` on the `UNREAD` label, which
needs the **`gmail.modify`** scope — it **replaces** `gmail.readonly` (which it supersets), so
**an operator who connected Google before this change must reconnect once** (Settings → Connect)
to grant it; until then the mark tools return a reconnect hint rather than a 500. No core-app
change — the core's `/messages` and `/tools` proxies are generic pass-throughs (closes #277)
(`mail` → 0.7.0, `web` → 0.27.0).
- **The chat composer keeps your unsent draft when you leave the page** — the message you're
typing now lives in the chat store rather than the screen's local state, so switching to
Models / Modules / a module page and back (which unmounts the chat screen) no longer discards
it. The draft is restored with its auto-grown height intact and is cleared only when the
message is actually sent. It persists for the app session (not across a full reload) (#278)
(`web` → 0.26.0).
- **Context-window management (hardware-aware, UI-settable)** — the local runtime's context
window (Ollama `num_ctx`) is now a persisted, per-tenant preference set from a new **Context
window** card on the Models screen, instead of an env-var-only knob. This fixes empty replies:
the agent's system prompt (instructions + every module's tool schemas + recalled memory) is
sizeable, and at the default 4096-token context it filled the window with no room left to
generate. The card probes the host — `GET /platform/v1/system/info` reports the GPU
(multi-vendor: NVIDIA via `nvidia-smi`, AMD via `rocm-smi`/`/sys`, Intel via `/sys`, all
best-effort and graceful) or, with no GPU, system RAM, plus the active model's on-disk size —
and offers a **suggested range** from a documented, conservative KV-cache-per-token estimate
(explicitly labelled an estimate, not a measured maximum). A number input + slider bound to the
pref and a **Use suggested** button apply it; the gateway resolves the value **per turn**
(`effective_context_window`: the pref if set, else the env default), local models only, stored
alongside the existing defaults via the same additive `_ensure_columns` migration. The optional
NVIDIA GPU overlay (`infra/ollama/gpu.yaml`) now also reserves the GPU for `core-app` so the
probe can read VRAM (AMD/Intel need their own `/dev/dri` + `/dev/kfd` mounts — out of scope;
detection degrades to system RAM without them). The chat model picker now also drives the
warming/readiness bar for the model the turn will actually run on (not the global default), and
the Models screen drops the confusing duplicate `chatting` badge — the persisted **default** is
shown there, while the per-session override lives only in the chat picker (`core-app` → 0.19.0,
`web` → 0.25.0).
- **Gemma 4 in the model browser** — the curated Ollama catalog now lists the Gemma 4 family
(`gemma4:e2b` / `e4b` / `12b` / `26b` / `31b`), Google's multimodal (text + image) models with
a 128K–256K context window. They show up in the Models screen and pull like any other entry
(`web` → 0.24.0).
- **Calendar: all-day events (fixes events showing a day early) + per-create calendar picker**
— all-day events are now modeled as a floating date range end-to-end. Google returns them
date-only; the module coerced that to a UTC-midnight instant, which the shell then shifted
into the viewer's local zone — landing on the **previous day** for any negative UTC offset.
Now `Event.all_day` is carried through; all-day `start`/`end` serialize as bare `YYYY-MM-DD`
and the shell parses them with the local `Date` constructor (no timezone shift), with an
**"All day"** toggle in the create/edit form. The create form also gains a **picker to choose
which calendar** a new event lands on (`calendar_create_event` accepts an optional
`calendar_id` `account:collection` token). The local store persists `all_day` via an additive
`_ensure_columns` migration (mirrors #248) (closes #252) (`calendar` → 0.8.0, `web` → 0.22.0).
- **Tasks: each Google list is a category, pick the list per task** — the Tasks board now
**aggregates open tasks across every enabled list** (not just one "active" list), tagging
each card with the list it came from, and the **Add task** form gains a **list picker** so
you choose the category per task. Per-card Complete / Edit route back to the list the task
belongs to; a single failing list is skipped, not fatal. Previously, enabling several Google
lists without marking one active left the board reading the empty local store — nothing
showed and there was no way to choose a list when adding (#253). Tasks is now `multi` like
calendar (ADR-0036, refining ADR-0030); the web board gained a `field_choices` option type
so a `<select>` can show a list's title while submitting its id (`tasks` → 0.8.0, `web` →
0.23.0).
- **Connecting Google grants each module's API scopes (incremental)** — modules now declare
the OAuth scopes they need in their manifest (`oauth_scopes`, e.g. calendar →
`…/auth/calendar`, tasks → `…/auth/tasks`, mail → the Gmail scopes), and the web **Connect**
button requests them: Settings connects with the **union** across all modules (one connect
grants everything), and a module card's Connect requests just that module's scopes
(incremental — the core accumulates). The core always includes the default identity scopes
and unions the requested ones onto them. Previously Connect requested only `openid email
profile`, so after connecting, the Calendar / Tasks / Gmail APIs returned 403 — the gap
surfaced by #209 (closes #241, the #102 wiring) (`epicurus-core` → 0.12.0, `core-app` →
0.18.0, `calendar` → 0.7.0, `tasks` → 0.7.0, `mail` → 0.6.0, `web` → 0.20.0).
- **Connecting Google auto-connects the modules that use it; settings no longer 502** —
connecting a Google account now **auto-enables** the calendar/task-list collections of
every module that uses it (and disconnecting clears them), so the operator connects once
and calendar/tasks work with no per-collection toggling (builds on ADR-0030). The mail
card's connection status is now accurate and fast — it reports whether a Google token is
present (`is_available`) rather than making a live Gmail API call that could exceed the
core's status-proxy timeout. And the core's module proxies (status, docs, pages, resolve,
attachments, accounts) now map an upstream failure to a controlled response — a module's
4xx passes through, a 5xx/timeout/connection failure becomes a clean `502` with a reason —
instead of an unhandled exception surfacing as an o