# Changelog

All notable changes to this project are documented in this file.

## [0.4.2] - 2026-08-07

Protocol-native decision and execution layer — the gates that encode human
decisions demand in-band consent (2026-07-28 MRTR + elicitation), the
long-running tools hand out durable task handles
(io.modelcontextprotocol/tasks) instead of blocking, and every tool
declares its output contract (JSON Schema 2020-12 + structuredContent).

Reliability and ranking-stability round — the fetch cache is per provider
and can no longer swallow transient failures, relevance weights stop
flipping between runs (persistent, time-decayed IDF corpus), the daily
radar honors the client's freshness window at query time, and every list
can page past its cap.

### Removed

- **Reddit sources dropped entirely.** Unauthenticated Reddit is dead:
  `.json` endpoints 403 since the May 2026 deprecation, and the remaining
  `.rss` feed is rate-limited into uselessness (429 on most requests).
  `fetchReddit` and the `reddit` sourceType are gone; stored `type='reddit'`
  sources are skipped by `brief run` with a digest note telling the user to
  remove them, and stale reddit harvest-cache rows are excluded from both
  serving and re-fetch.

### Fixed

- **Fleet audit round 2 — the agent can now see what the server stores, and
  not-found errors tell the truth.**
  - `brief save` echoes the stored ideas back with stable `idea:<n>` ids
    (the same scheme the run's suggestions use), so an agent can reference a
    curated idea instead of guessing; `get` returns the same ids, so echoing
    them back into `save` is idempotent.
  - Every `draft` response (approve/reject/get/list) now carries
    `workspaceId`, matching the per-client addressing that notes, briefings,
    briefs and artifacts already expose.
  - Missing ids are discriminated: an id that exists in another client keeps
    the actionable "pass workspaceId" hint, while an id that exists nowhere
    now says so plainly ("does not exist") instead of a blanket misdiagnosis.
    Applies to note/draft/briefing/artifact `get`, `update`, and `delete`.
  - `note list` returns bodies only when `includeBody:true` is passed — lean
    lists no longer pay for body payloads.
  - `sources_add` returns `skippedUrls: [{url, reason}]` naming each duplicate
    instead of a bare `skipped: N` count.
  - `voice_lint` reports `vacuous: true` when the voice has no configured
    rules, so a green report is never mistaken for an actual pass.
  - `web fetch` now parses RSS/Atom feeds (rendered as a markdown headline
    survey) instead of rejecting them as non-HTML; an XML `content-type` that
    fails to parse is reported as "not a parseable feed" rather than leaking
    a parser error.
  - `brief delete` echoes the removed date as `deletedDate` (the `deleted`
    key was ambiguous against the boolean half of the cascade result).
  - `draft` decisions require `format` at the schema layer: approve/reject
    without one fails before the handler with a clear message.
  - A task runner that finishes after the database is closed no-ops instead
    of throwing "database connection is not open" during teardown.
- **`draft` formats are now truly trimmed and capped at 40 chars, not
  rejected.** A 50-char format used to fail schema validation ("format is
  too long") despite the documented "trimmed, capped" contract; it now
  stores the first 40 chars, and the `update` description documents that a
  flip to `approved` clears the stale rejection reason.
- **Radar no longer ranks a doc whose only match is a lone weak token.**
  Tokens that appear only inside multi-word profile entries ("stress" in
  "stress testing tools") are discounted (half credit without a corpus) and
  penalized when they are the sole domain signal — a tennis article about
  "fratura de stress" no longer surfaces as a top idea for a stress-testing
  client. Tokens that are also their own single-word entry stay strong.
- **`note` save/update reject whitespace-only bodies**, matching the title
  guard (title-only notes remain legal).
- **A failed provider no longer silently rides the cache.** The whole-run
  cache stored provider errors as-is for 4h; a dead feed or a rate-limit
  blip left the client blind on every rerun. Failed rows are now
  re-attempted per run and the failure stays visible in `sources` until
  the provider heals.
- **`briefing delete` reports the artifact cascade.** Deleting a briefing
  always removed its rendered scaffolds (an orphaned artifact would be
  stuck — re-render needs the briefing); the response now returns
  `deletedArtifacts` so the cascade is never invisible.
- **Backdated `brief run` records describe their date, not today.** A run
  for a past date used to store whatever the radar happened to fetch now —
  items published *after* the run date appeared in the record labeled
  `hot`, and age/freshness were measured from today. Runs before today now
  exclude items published after the run date (and items with no provable
  publish date), score age/freshness/decay **as of the run date** ("2 days
  ago" relative to the record), and report the exclusion count in the note:
  "Backdated run (before today): N item(s) published after the run date
  were excluded". `harvested` still reports the raw harvest.
- **A digest that is entirely location-only now says so.** When the profile
  has topics/coreOffer but none of its domain terms land, `brief run`
  returns a `warning` (mirrored as a WARNING in the note) pointing at the
  profile instead of silently serving a city-events digest week after week.
- **Location evidence is surfaced with its original accents and case.**
  Matching still runs on accent-stripped text, but location hits — the
  `why` line, idea headlines, angles, and prompts — now read "São Paulo",
  not "sao paulo" (Portuguese templates interpolate the display form).
- **`sources_remove` `missing` counts inputs that resolved to nothing, per
  channel and deduplicated.** The same source given via both id and url
  previously reported `missing: 1` (inputs − removals double-counted the
  duplicate); it now reports `removed: 1, missing: 0`.
- **`nextId` is atomic across processes.** The shared id sequence was a
  read-modify-write outside a transaction — two server processes sharing a
  DB (two clients on one HOME) could hand out the same id and hit a
  primary-key conflict. The sequence now runs as an IMMEDIATE transaction.
- **`web search` requires two shared query tokens** on multi-token queries:
  a lone overlap ("paulo" in "São Paulo flower shop" matching a biography of
  the Apostle Paul) is too cheap to count as relevance. Single-token
  queries stay exempt.

### Changed

- **Google News queries respect the client's `freshnessDays`.** The
  largest keyless provider used to be blind to the freshness window
  Tavily already got (`days: freshnessDays`) — a client with a 30-day
  window still harvested months-old wire items that only the post-hoc
  score decay (weakly) penalized. Each Google News RSS search now carries
  an `after:YYYY-MM-DD` bound derived from `freshnessDays` (local
  calendar days, day precision), so the feed itself is pre-filtered by
  age. The score decay stays as a belt-and-braces safety net for the
  providers without a query-time bound.
- **`brief run` persists its generated ideas as `suggestions` on the
  record.** The radar's proposals used to vanish after the run (the record
  read `ideas: []` until a separate `brief save`); they now survive on the
  record as `suggestions` — `brief get` returns both, marking each
  suggestion `saved: true` when a curated idea references its source link.
  `brief list` and range reads report `suggestionCount` alongside
  `ideaCount`. Curated ideas still live in `ideas` only — the decision
  remains the human's. Existing DBs migrate with a default-empty column.
- **The harvest cache is per provider, not per run.** Each feed URL /
  provider name caches its own rows with its own ~4h TTL. On a cached
  `brief run`, fresh error-free rows are served as-is while stale or
  previously-failed providers are re-fetched — a transient network blip
  or a temporary 403 heals on the next run instead of poisoning the whole
  cache for 4h. Serving cached rows never extends their TTL, and a run
  that re-fetched reports it in the digest note ("N provider(s)
  re-fetched"). The cache is still dropped wholesale on radar/source
  edits and pruned after 3 days on fresh runs.
- **`brief run` reports its state write machine-readably.** The response
  carries `recordStored: true` + `stored` (items persisted) on every
  completed run — including empty ones, which the note already announced in
  prose — and `recordStored: false` on the unconfigured-client no-op and on
  cancellation, so agents can branch on the flag instead of parsing the
  note.
- **Relevance weighting is stable across runs: a persistent per-client
  IDF corpus.** Each fresh harvest folds its term/phrase document
  frequencies into the client's history (SQLite, 30-day half-life decay,
  faded rows reaped on every fold, scrubbed on client deletion). Every
  run blends that history with its own corpus, so a small harvest keeps
  corpus-relative weights (the old <10-doc fallback to flat weighting
  only remains for brand-new clients) and a term's specificity no longer
  flips run to run. Cached reruns never double-count — the history is
  folded only by fresh harvests.
- **`briefing create` classifies skipped `noteIds`.** `skippedNoteIds` is
  now accompanied by `skippedReasons` mapping each skipped id to *why*:
  "not found in this client" (typo/stale id) vs "belongs to another client"
  (re-target or pass workspaceId) — two problems that need different agent
  reactions.
- **`workspace create` signals the selection switch.** Creating a client
  still auto-selects it (the copywriter flow — create then immediately
  configure — demands it), but the response now carries a note naming it
  the active client, so an agent mid-workflow on another client cannot miss
  the redirect. `sources_add` documents its format-only validation
  (http(s) shape; feed reachability is checked at `brief run` time and
  reported per source).
- **`voice_lint` reports `configured: false`** when the voice defines no
  lint-affecting rules (no banned terms, constructions, style rules,
  preferred terminology, or length limits), and the report string notes
  that a 100/100 PASS on such a voice is vacuous rather than an endorsement.
- **`sources_add` returns per-URL verdicts** — `invalidUrls: [{url,
  reason}]` alongside the `invalid` count, so an agent can see exactly which
  URL failed and why instead of a bare number.
- **`web search` filters results that share no query token** on multi-token
  queries: an engine whose results are all irrelevant is treated as a dry
  query and the chain moves on (engine `'none'` when every engine is), so
  garbage queries stop surfacing junk as "results". Single-token queries
  stay exempt — the engine is the only authority there. (The floor later
  tightened to two shared tokens — see Fixed.)

### Added

- **`offset` pagination on every list action** (`brief list`, `draft
  list`, `note list`, `briefing list`, `artifact list`): bounded
  `offset` (0–9999) with `LIMIT ? OFFSET ?`, and `truncated` now means
  "more rows exist beyond this page" (`offset + rows < total`). The
  `{total, items, truncated}` envelope was a dead end at the cap — older
  rows past 50/500 were unreachable; now they're a page away.
- **`sources_remove` accepts urls as well as source ids.** Symmetric with
  `sources_add`: urls resolve to the client's own sources (a URL registered
  only under another client is never removed by accident), and unmatched
  inputs are reported via `missing` + `unmatchedUrls` instead of a silent
  `{removed: 0}`.
- **Ideas are specialized on each item's evidence.** An idea's angle,
  headline, and generation prompt anchor on the item's rarest matched
  domain term (else its matched location) instead of repeating the shared
  category template — same-category items now read as distinct takes.
- **Location-only matches are score-penalized.** The location was being
  credited twice (a dedicated match channel *and* semantic similarity via
  the profile's location line) and pure location hits could outrank genuine
  domain matches. The location is now excluded from semantic similarity,
  and a doc with no domain evidence and no qualifying semantic overlap gets
  a `locationOnly` penalty (exposed in `relevance` + `why`) that deflates
  its score while keeping fresh location-only items just above the digest
  floor (the 3-item cap and strong-first ordering are unchanged).
- **`brief get` ranges default to the compact shape.** A multi-day range
  previously returned full payloads (~80KB for a week); ranges now return
  compact items by default (`verbose: true` for full), and range items are
  normalized to the same `snippet` shape as single-date reads.

### Changed

- **Cache-age reporting never renders a placeholder.** A cached run whose
  rows were all stale re-fetched everything, and the digest note rendered
  a literal `cacheAgeMinutes:?`; the served age is now always a real number
  (0 when nothing was served from cache).
- **Not-found errors are workspace-aware.** Records are id-scoped per
  client, so "note not found" misled when the id lived in another client;
  every store now errors with `… not found in this client — pass
  workspaceId if it belongs to another client`.
- **Draft flip message and description clarified.** Flipping a draft to
  rejected without a reason says `reason is required when flipping a draft
  to rejected`, and the tool description makes explicit that the 40-char
  cap applies to the format, not the content.
- **Notes list orders by `updated_at` then `id`** — deterministic ties,
  stable pagination; the ordering is documented as "recently-updated
  first".

- **In-band consent gates for the human-decision tools.** `draft approve`,
  `draft reject`, `brief save`, and `workspace delete` return an
  `input_required` result carrying a form-mode elicitation when the client
  declares the `elicitation` capability (per-request envelope on the
  2026-07-28 era, initialize-negotiated capabilities on 2025-era
  connections): the client renders a native confirmation UI, and only an
  explicit `accept` completes the call. `decline`/`cancel` store nothing
  (`consent declined — …`). An agent can no longer record a decision the
  human never made. Clients without the capability keep the agent-mediated
  flow — `confirm: true`, conversation-asked reasons — unchanged. On
  2025-era connections the SDK's legacy input-required shim drives the
  elicitation round trip.
- **Durable async execution (io.modelcontextprotocol/tasks).** `brief run`,
  `web fetch`, and `web ingest` run detached when the client declares the
  `io.modelcontextprotocol/tasks` extension, and their response carries a
  durable `task` handle (`taskId`, `status`, `ttl`, `pollInterval`) instead
  of blocking for up to 90s. Tasks live in SQLite (survive disconnects),
  report per-provider progress via `statusMessage`/`progress`, and are
  served over the 2025-era method registry:
  - `tasks/get` — current state; terminal tasks carry the result or error
  - `tasks/cancel` — cooperative; the runner observes it at its next phase
    boundary and the record is not stored after a cancel
  - `tasks/list` — cursor-paged, newest first
  - `tasks/getPayload` — the original tool call for re-issue/resume
  - Working tasks expire after a 10-min TTL; terminal tasks keep a 24h
    retention window so late polls still answer.
- **`outputSchema` on every tool** (JSON Schema 2020-12 via zod v4,
  SEP-2106) and **`structuredContent` on every success result** — the
  schema-aware client contract is now validated on the wire instead of
  inferred from descriptions. The schemas are deliberately permissive
  (all fields optional, `additionalProperties: true`) so every action's
  shape validates, while pinning the fields and types that matter.

### Changed

Reliability, quality, and spec-conformance round — the web layer stops
failing silently (DuckDuckGo soft-blocks are now detected and routed
around), fetched content gets Reader-Mode-grade extraction, and the
2026-07-28 protocol surface (CacheableResult hints, per-request log level)
is fully wired and verified on the wire.

- **`web search` is now a keyless engine chain — DuckDuckGo → Bing →
  Mojeek — with block detection.** DuckDuckGo's reverse-engineered html
  endpoint fingerprint-blocks bots with an HTTP 202 anomaly page, which
  `res.ok` treats as success and which parses to zero results — a silent
  empty list that looked exactly like "no results". The 202 (or a 200
  anomaly-page body) now fails that engine, the chain moves to the next
  engine, and the response reports which engine served (`engine:
  ddg|bing|mojeek`). A genuine dry query across all engines returns
  `engine: none`; only when every engine fails does the tool error, with
  each attempt in the message. The Instant Answer API remains a last
  resort inside the DuckDuckGo step.
- **`web fetch` / `ingest` extract main content with Mozilla Readability**
  (the Firefox Reader Mode algorithm, deterministic and keyless), falling
  back to the article/main/body heuristics for short or non-article pages.
  Nav/ads/sidebars no longer leak into ingested research notes and the
  briefings that fold them in.
- **2026-07-28 spec conformance: cacheable results carry `ttlMs` /
  `cacheScope` on the wire.** `tools/list` (60s, private) and
  `server/discover` (5min, public) at server level; per-resource TTLs
  (30s, private) at registration for voice/note/briefing/artifact reads.
  The e2e suite now drives the server as a stateless client (every
  request carries the `_meta` envelope) and asserts the cache fields,
  `server/discover`, and the resource list endpoints.
- **Per-request log level.** Handlers scope their stderr logging to the
  `io.modelcontextprotocol/logLevel` a client declares in the request's
  `_meta` (2026-07-28 spec); concurrent requests without a declared level
  keep the `QUILLBY_LOG_LEVEL` threshold, and unknown levels are ignored.

### Notes

- The 2026-07-28 era's method registry in the current SDK is closed and has
  no tasks entries (the extension lives outside its core): modern-era
  clients receive task handles through the tool result, but the tasks RPCs
  answer on the 2025-era registry — where today's hosts still live. When
  the SDK grows extension support, the RPCs move to the modern registry
  unchanged.

## [0.4.1] — 2026-08-03

UX and hardening release — the daily loop got faster, leaner, and quieter.

### Changed

- **`brief run` defaults to the token-light compact digest** (title/url/
  score/category/seen; headline/format/source per idea). The full shape
  (snippets, `why`, prompt-ready briefs) is opt-in via `verbose: true`;
  `compact: true` forces compact even alongside it. The daily loop now costs
  a fraction of the context it used to.
- **Calendar days are local-timezone, not UTC**: `today()` rolled over 3–7h
  early for the Americas, filing evening runs under tomorrow's date (and
  skewing idea expiries). The daily record now follows the user's calendar
  day.
- **Harvest cache invalidation**: editing the radar profile (topics/avoid/
  location/directUrls) or the source list drops the ~4h fetch cache, so a
  change is never silently ignored by a stale `brief run`.
- **Provider timeboxing**: the key-gated providers (Exa/Tavily/Firecrawl) now
  run their queries/direct URLs in parallel under a single overall budget
  (45s / 60s) instead of serial per-request timeouts — worst-case `brief run`
  dropped from ~180s to ≤60s, and a provider that times out empty reports the
  fact instead of vanishing.
- **SQLite driver: `node:sqlite` → `better-sqlite3`**: the industry-standard
  synchronous driver replaces Node's still-experimental built-in module —
  no more `ExperimentalWarning: SQLite is an experimental feature` noise on
  every boot. The driver surface was already API-compatible (prepare/run/
  get/all, WAL, foreign keys), so the migration was mechanical and the DB
  file format is unchanged — existing databases open as-is. Tradeoff: a
  native module is now a dependency (prebuilt binaries for macOS/Linux/
  Windows x64/arm64; source compile fallback).
- **Radar relevance is corpus-relative IDF, not stem blacklists**: the
  hand-maintained PT generic-stem list and the term-length qualification
  rules are gone. Every matched profile term now earns credit weighted by
  its inverse document frequency over the harvested corpus (BM25-style,
  normalized) — "fresh" appearing in dozens of unrelated headlines earns
  near-zero credit, a rare term like "bouquet" earns full credit, and the
  harvest itself defines what is generic. Genericity no longer needs a
  maintainer; the data decides. Multi-word profile entries ("fresh
  flowers", "open source") match as contiguous phrases with their own
  phrase-level IDF instead of being mangled into generic tokens. Below a
  10-doc corpus the engine falls back to flat weights (small-sample
  smoothing). `relevance` now exposes `termWeights` and
  `method: 'lexical+idf'`; `why` strings are unchanged.

### Added

- **Per-provider progress notifications**: while a fresh harvest runs, the
  server emits `notifications/progress` (when the client passes
  `_meta.progressToken`) as each source/provider settles, so the agent sees
  "N/total sources harvested" instead of waiting blind.
- **Per-provider `elapsedMs`** in `brief run`'s `sources` array, so slow
  providers are visible.
- **`draft list` free-text search (`q`)** over content and reject reason —
  the learning loop can now find past feedback themes by keyword instead of
  paging through everything.
- **`cacheAgeMinutes`** in `brief run` on cache hits, plus a `note` hint
  when the ~4h cache is half-expired ("pass refresh:true if freshness
  matters").
- **Actionable non-HTML guidance** in `web fetch`/`ingest`: PDFs and other
  content types now say what Quillby can and can't do, instead of a bare
  `Not an HTML document` dead end.
- **LIKE wildcard escaping** for `q` searches (draft + note): literal `%`
  and `_` no longer over-match.
- **Radar `audience` setting** (`consumer` | `business` | `mixed`, default
  `mixed`): a client can now declare who its content serves. A deterministic
  flavor detector (en/pt signal sets — grower/wholesale/trade-show vocabulary
  vs. customer/home/gift vocabulary) penalizes audience-mismatched items in
  ranking, so a consumer flower shop stops surfacing grower-policy and
  retailer-costs news. Default `mixed` keeps every existing client's behavior
  unchanged.
- **`workspace create` now reports `selected: true`** on the created client —
  creating a client silently made it the active one; the response now says so
  instead of leaving the selection change implicit.
- **`brief run` empty-radar warning**: with sources but no topics/coreOffer,
  the run returns a `warning` field ("radar profile is empty — results will
  be generic") instead of silently scoring noise.
- **Compact ideas carry substance**: the token-light idea now includes the
  `angle` and a truncated `reason` (why the item matches), so a compact run
  stays usable for curation instead of repeating the same category headline.
- **`brief run` reports `lastBriefDate`** when every ranked item was already
  surfaced, so an all-seen rerun answers "when did anything fresh last
  appear?" without a second call.
- **SQLite durability settings**: `busy_timeout` (a second process touching
  the same DB waits instead of throwing `SQLITE_BUSY`) and a 3-day harvest
  cache retention policy pruned on every fresh run — the ~300KB per
  client-day cache rows no longer accumulate forever.
- **Consistent list envelopes**: `workspace list` and `sources_list` now
  return `{total, items, truncated}` like every other list — no more bare
  arrays mixed with enveloped lists.
- **Structured `voice_lint` output**: the response now carries
  `score`/`passed`/`stats`/`issues` (each issue with `rule`, `severity`,
  `message`, `matches`) alongside the human-readable `report` string —
  agents can act on the findings directly instead of parsing prose.
- **ISO timestamps everywhere**: `brief get`/`brief list`/range `days` now
  report ISO 8601 `createdAt` (SQLite's `YYYY-MM-DD HH:MM:SS` format leaked
  through), and the record age is exposed on single-date reads.
- **Rounded scores**: digest `score` is now rounded to 2 decimals
  (`64.80` instead of `64.8042727360639`).
- **Concise tool descriptions**: the per-tool descriptions are now a few
  sentences (what it does, actions, defaults, error contract) instead of
  multi-paragraph manuals — every MCP host re-sends tool schemas into agent
  context, and long descriptions get truncated by models.

### Fixed

- **No-op `brief run` stores nothing**: when a client has no sources and no
  radar profile, the run returns its guidance note without persisting an
  empty record — `brief get` keeps saying "no record for this date yet",
  and `brief list` shows no phantom zero-item day.
- **Tighter idea validation**: `brief save` rejects empty `thesis` values
  (trimmed) and malformed `expiresAt` (must be an ISO 8601 date or null —
  a garbage string used to be stored and silently never expire).
- **Title length caps**: note and briefing titles are now capped at 200
  chars, matching workspace names — an agent can no longer store a
  paragraph as a title.
- **Non-http(s) URLs are rejected up front** in `web fetch`/`ingest` with a
  clear "Unsupported protocol" message, and network failures surface the
  underlying cause (e.g. `connect ECONNREFUSED 127.0.0.1:9999`) instead of
  the opaque undici `fetch failed`.
- **`brief run` no longer mislabels an empty provider as google-news**:
  without radar topics, the first key-gated slot was kept unconditionally
  as the "google-news slot" (an empty `exa` row masquerading as a ran
  provider); only the real google-news run keeps its slot now.
- **Single date error message**: `date: 'banana'` used to report both
  "must be YYYY-MM-DD" and "must be a real calendar date" for the same
  value; the redundant shape regex is gone, leaving one precise error.
- **`Local Opportunity` now requires location evidence**: an item is only
  categorized as a local post when the location terms actually hit — a US
  partnership article previously categorized as "Local Opportunity" for a
  São Paulo client now falls back to its next-best category.
- **Per-provider digest caps**: Google News (409 of 529 items in a test
  harvest) could crowd out niche RSS sources; ranking now caps each
  provider's contribution before the final digest, keeping the digest
  representative of every source.
- **`sources_remove` reports unmatched ids**: removing ids that don't exist
  silently returned `{removed: 0}`; it now also reports `missing` so the
  agent can react instead of assuming success.
- **Draft `format` normalization**: formats are trimmed, capped at 40 chars,
  and the tool description documents the well-known formats (instagram,
  linkedin, twitter/x, blog, email, newsletter, tiktok, carousel) — the
  decision log no longer accumulates arbitrary strings by accident.
- **Draft `createdAt` is ISO 8601**: drafts read back SQLite's
  `YYYY-MM-DD HH:MM:SS` timestamp while every other resource returned ISO —
  the read path now normalizes to ISO so clients can compare timestamps
  across resources.
- **Sentence-rule severity scales with the breach**: exceeding
  `maxSentenceWords` (or the min/max char rules) by more than 1.5× now lints
  as an error, not a warning — a 40-word sentence against a 20-word cap no
  longer passes with a gentle −5.
- **Banned-term linting catches inflections and accents**: word boundaries
  are now Unicode-aware (a banned `café` actually matches `café` — JS `\b`
  silently never did), and single-word bans match their inflected forms
  (`unleash` also catches `unleashes`/`unleashed`/`unleashing`,
  `synergy` catches `synergies`) — without ever matching inside unrelated
  words ("rest" inside "forest"). The same boundary fix applies to
  preferred-term checks.
- **`brief save` preserves the evergreen sentinel**: an explicit
  `expiresAt: null` (documented as evergreen) was silently replaced by a
  +7-day default expiry (`??` treated null as missing); only a *missing*
  `expiresAt` gets the shelf-life default now.
- **Workspace deletion scrubs the seen-memory**: `brief_seen` (by design
  FK-free so brief-record deletes never reset dedup) leaked orphaned rows
  when a whole client was deleted — `deleteWorkspace` now clears them, and
  existing orphaned rows are reaped.
- **Cleaner `why` strings**: dropped the redundant "NOT this week's news"
  from evergreen items (the age label already says it) and made "rich
  provider context" concrete ("high-authority provider (exa/tavily)") in
  verbose digests.

## [0.4.0] — 2026-08-03

Release readiness pass — bugs, robustness, and packaging.

### Changed

- **Keyless by design**: the radar no longer calls any embedding API.
  `QUILLBY_OPENAI_API_KEY`, `QUILLBY_EMBEDDING_MODEL` and
  `QUILLBY_EMBEDDING_DIMENSIONS` are removed — the server needs no LLM-related
  key to set up or run. Semantic ranking is now deterministic lexical
  matching (`relevance.method` is always `lexical`), upgraded with phrase
  (bigram) awareness so multi-word concepts ("open source", "café especial")
  match as units instead of loose tokens. Domain/location hits, freshness,
  authority and avoid penalties are unchanged.
- Node engine requirement raised to `>=24.0.0` (LTS); `node:sqlite` is still
  experimental in Node, but pinned by the engine range.

### Fixed

- **Range `brief get` reported stale `seen` flags**: dedupe kept the oldest
  occurrence of a link; it now keeps the newest (correct `briefDate` + `seen`).
- **`compact: true` was a no-op on single-date `brief get`**; it now returns the
  token-light digest on both single-date and range reads.
- **Deleting a brief record reset the seen-memory**: links are now tracked in
  an immutable `brief_seen` table, so previously surfaced links are never
  re-flagged as new after a delete.
- **Misleading `draft` validation**: `format` and `content` errors are reported
  separately instead of a combined "format and content are required".
- **Legacy draft status drift**: one-time migration normalizes `reject` →
  `rejected`.
- **`workspace delete` response**: now reports `activeWorkspaceDeleted` so the
  active-client change is unambiguous.
- **Version drift**: the server advertised `0.8.0` while the package was
  `0.8.4`; the advertised version now comes from `package.json` (single source
  of truth).
- **Stale compiled files**: `npm run build` now cleans `dist` first, so orphan
  modules from earlier layouts no longer ship in the tarball.

### Added

- `brief run` emits the client's voice guide once at the top level instead of
  duplicating it inside every idea, and reports `surfaced` (digest items
  already seen) alongside `newItems`.
- `fetchReddit` now has a 20s timeout, matching the other providers.
- Harvesting is parallel: workspace sources, Google News queries, and the
  key-gated providers (Exa/Tavily/Firecrawl) run concurrently, bounding `brief
  run` latency by the slowest provider instead of the sum of timeouts.
- Graceful shutdown on SIGINT/SIGTERM (transport + DB close).
- Stderr logging behind `QUILLBY_LOG_LEVEL` (default `info`); stdout remains
  protocol-only.
- MIT `LICENSE`, `CHANGELOG.md`, and a GitHub Actions CI workflow (typecheck →
  unit tests → build → e2e → `npm pack --dry-run`).
- `npm run test:all` runs the full gate locally; `prepublishOnly` now runs it.
