Commit Graph
65 Commits
Author SHA1 Message Date
bvandeusen 1fde646c60 feat(design-systems): formulas — derived tokens that follow their source
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 42s
Operator: "build in a way to support formulas like this so that the colors shift
as expected and have less to clean up when testing color changes."

The storage needed no change at all, which is the good news. A formula is just a
value:

    --fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)

It passes the value sanitiser untouched (verified, and now pinned by a test —
had `color-mix(... var(...) ...)` been rejected as unsafe, derivation would have
needed a storage shape of its own), and the browser resolves the `var()` at use
time. Change `--fs-accent` and everything derived from it shifts.

**One declaration covers every mode**, and that is the "less to clean up" part.
A derived token written once in the base layer follows its source through dark
mode automatically, because `var()` resolves where it is USED rather than where
it is written. A stored computed literal would need a row per mode and would
silently stop tracking the source the moment the source changed — the whole
problem this avoids.

What derivation DID need is the check. A formula pointing at a token that does
not exist is invalid-at-computed-value-time: the browser drops the declaration
outright and the token has no value. No error, no warning, nothing in the
toolchain notices — the same family as `--color-accent`, `_parent_map`, and the
scripted edit whose anchor matched nothing.

So `derivation_report` returns three things alongside the sheet: which tokens are
computed and from what, which formulas point at nothing, and which derive from
each other in a loop. CSS resolves a loop to nothing rather than hanging, so the
cycle check is about telling the operator, not protecting the renderer — but a
token that quietly resolves to nothing is exactly what is worth being told.

A self-reference with a fallback (`var(--fs-x, 8px)`) is deliberately not a
dependency; counting it would report every such token as a one-node loop.

The UI leads with broken formulas, then loops, then the healthy derived set —
the first two are unambiguously wrong, where a duplicate value is a judgement
call.
2026-07-31 09:51:54 -04:00
bvandeusen 23a385e2db revert(design-systems): drop the rulebook import — a migration, not a feature
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Operator's call, and it corrects a scope error rather than a bug:

  "this is a path for a user to go from a rulebook to a design system. we don't
   need to build this path in the app itself ... you should be the one that does
   the import ... going forward no one else should have to do such a migration."

Right. Nobody starting from a design system will ever go rulebook -> system, so
the whole path was permanent product code serving a single act on one install.
Rule #22: remove it, don't flag it off. Gone from the service, the REST route,
the MCP tool, the UI panel, the API client and its tests.

There is a second consequence I had missed, and it is the better argument. The
parser was WORSE at this than doing it by hand. `propose_tokens` leaves radius
steps and type sizes valueless because "Small 4px" is not a hex and nothing here
parses it — a limitation I documented carefully and shipped anyway. But that
limitation only exists because the importer had to run unattended. Done as work
rather than as a feature, those values are just read and written, and the result
is a complete design system instead of one with a dozen blanks and a count
explaining them.

Scaffolding built around my own absence from the loop, when I am the loop.

KEPT: `extract_expectations` and `design_expectations` in
services/design_rulebook_import.py. The live drift panel still reads them until
it is repointed at a resolved design system (#2295), and removing them now would
take the /design page's only content with it. They go with that change, not this
one.
2026-07-31 09:46:12 -04:00
bvandeusen 0f80b790c7 feat(design-systems): central prose — guidance on the system, rationale on tokens
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have
to live as one offs, there's a central system for managing it."

Two fields, both free-form:

  design_systems.guidance   the narrative a token table cannot hold — aesthetic,
                            voice and tone, what is deliberately out of scope.
  design_tokens.rationale   WHY a token is this value, which is a different
                            question from `purpose` (what it is FOR). "Success
                            equals Moss, aligned by design" is a rationale;
                            "page bg, deepest surface" is a purpose. Rules carry
                            the first routinely and a token row had nowhere to
                            put it.

Free-form rather than a column per category, deliberately. A schema with
`voice`, `aesthetic` and `scope` columns would bake one rulebook's table of
contents into every install (rule #115), leaving the next install three empty
columns and nowhere for what it actually cares about. Both nullable: a design
system with no prose at all is complete, not a draft.

`rationale` cascades like `purpose` — deepest non-empty wins — so an app
overriding a colour keeps the family's reasoning rather than blanking it. Same
argument as `supersedes`: the override was about the value, not the meaning.

In the generated sheet the inline comment prefers `purpose` and falls back to
`rationale`, so a token carrying only the why still says something instead of
rendering bare.
2026-07-30 21:52:16 -04:00
bvandeusen 46d88f9e7e feat(design-systems): check the components against the sheet they claim to use
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 35s
"The snippets use the tags from the sheet" was a relation nobody could verify.
Now it is three checks, and all three currently fail SILENTLY in this codebase:

  unknown             `var(--x)` where the system declares no `--x`. Renders as
                      nothing at all — no error, no failing test, no visual clue
                      beyond the element quietly not being styled.
  superseded literals a value the sheet said to stop writing, paired with the
                      token to write instead. Only possible because `supersedes`
                      is declared rather than inferred.
  local definitions   custom properties a snippet mints for itself instead of
                      reusing the sheet's — the bloat a shared sheet exists to
                      prevent, where a value stops being reused and starts being
                      restated per component.

The first is not hypothetical. Writing DesignSystemsView.vue earlier in this
same session I used `--color-accent` throughout; it does not exist, and nothing
in the toolchain noticed. This check is the thing that would have.

A token that is both defined and read locally is reported ONCE, as an unknown
reference — "--btn-bg does not exist in the sheet" is the more precise statement
of the same problem, and reporting both would double-count one fact.

Literal matching is boundary-aware and case-insensitive: `#fff` must not fire
inside `#ffffff` (different colours, and a finding on the wrong one sends
someone to change correct code), while `#FFFFFF` in a rulebook has to match
`#ffffff` in a stylesheet — the same trap `normalize_hex` exists for.

Snippets with nothing to report are omitted entirely. A list of everything that
is fine is a list nobody reads twice — the same principle the auto-inject menu
and the drift panel are both built on.

Two integration mistakes fixed while wiring it: `list_snippets` returns
`(rows, total)` and caps its limit at 100, and `get_snippet` returns a Note
model rather than a dict. The list rows carry a preview, not the code, so the
check reads each full body — checking the preview would have reported on a
truncation.
2026-07-30 21:47:47 -04:00
bvandeusen b0a7d9e89b feat(design-systems): the master sheet — purpose tokens, not per-element values
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 34s
Operator's new requirement (#2299, architecture in #2296): a design system does
not just hold tokens, it generates and manages a master CSS sheet. That settles
the milestone's open "authority mechanism" question — the record is
authoritative because the stylesheet comes out of it.

**The sheet is shaped by purpose and styles no elements.** It declares custom
properties, grouped by what they mean, and contains no `.btn-primary`, no
`table`, no `input`. That is the design, not a shortcut: a sheet that styled
elements would restate the same handful of values once per element and grow with
the UI, where purpose-named values are stated once and reused. Components live
as SNIPPETS that reference these names — a surface that already exists and
already carries prose, locations, drift checks, merge and write-path recall.

A token named after an element (`--fs-button-bg`) is the smell that the two have
been mixed; a purpose name (`--fs-action-primary`) is reused across all of them.

Alongside the CSS the endpoint returns what the text cannot say for itself:
which tokens are still valueless, and which VALUES are declared under more than
one name. The second is the operator's "reuse consistent values" constraint made
checkable — and it reports rather than refuses, because a design system
legitimately aligns colours on purpose ("Success = Moss, by design") and only a
human knows which case it is.

Mode maps to selector the way the codebase already does it: base on the root
selector, every other mode layered on `[data-theme="…"]`. The root selector is a
PARAMETER — #251 recorded that a container-scoped preview cannot use `:root`, so
hardcoding it would have made the generator useless to the preview surface.

A token the rulebook names but states no value for is emitted as a commented-out
declaration IN ITS GROUP rather than dropped. Its absence is the finding, and a
comment puts that finding where the reader already is.

Values are validated, not escaped, and this is a real boundary rather than
tidiness: design systems are shareable records (rule #47), so `red; } body {
display: none` in a system shared with you would otherwise inject CSS into your
page. A value containing `{ } ; @ < >`, a comment delimiter or a newline is
REFUSED and rendered as a comment saying so — rejecting beats stripping, since a
partially-sanitised value is one the operator never wrote and the sheet's whole
claim is that it is the record.

Not in scope, and deliberately: serving this as the app's actual stylesheet.
Generating and exposing a sheet is reversible; swapping theme.css for a
generated one is not, and it should be an explicit call rather than a side
effect.
2026-07-30 21:42:08 -04:00
bvandeusen 4dc57f8ab2 feat(design-systems): import a design system out of a rulebook's prose
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 3 (#2288). Reuses #251's prose extractor as the reader and
adds the part that makes it an import rather than a list of claims.

**The join is the whole trick.** A rulebook states a design system in two places
and neither half is a token: one rule names the colours ("Obsidian #14171A (page
bg, deepest surface)"), another names the custom properties
(`--fs-obsidian/iron/slate`). The import pairs them on the word — `--fs-obsidian`
ends with `obsidian` — which is the only reason it produces something usable
instead of seventy empty names. The parenthetical becomes the token's purpose,
which is the field a bare hex could never carry.

**Prohibitions arrive as replacements, per the operator's reframe.** Rule 52
declares Parchment and forbids pure white in one breath, so the import emits
"write --fs-parchment instead of #ffffff" — the same fact stated forwards. It
attaches to the FIRST token that rule supplied a value for, not to every token
of that rule, because claiming Vellum is also the replacement for white would be
putting words in the rulebook's mouth.

**A token the rulebook names but states no readable value for is still
proposed, with an empty value.** Radius steps and type sizes are prose ("Small
4px") and nothing here parses them; inventing a parse per shape would be
guessing. The name is real and the value needs a human, so the proposal says
exactly that — and the UI leads with the COUNT of those, because an import that
hid them would look more complete than it is.

Preview is the default on both surfaces and in the UI. An import is a proposal:
rulebooks are written aspirationally and some of what they describe was never
built, so every entry carries the rule id and the sentence it came from and a
reviewer can check the claim rather than trust it.

Existing token names are never overwritten. A value already in the record was
put there deliberately — most likely correcting this importer — so a re-run
fills gaps and lists the rest as skipped, which also makes it safe to repeat.

Colours the rulebook names but never exposes as a custom property produce no
token: it never asked for one, and inventing a name would put something in the
record no rule sanctions.
2026-07-30 21:23:32 -04:00
bvandeusen 3da40abcb8 feat(design-systems): declare what to write instead, rather than what not to
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 6, first half (#2295) — and this reframes the task rather
than answering it. The operator's call:

  "in this case we should declare what should be used in place of pure white,
   it's not a prohibition it's what should be used in its place."

None of the three options on the table (a constraints record / the panel reads
both sources / negative token rows) was right, because all three kept the
prohibition as a KIND OF THING. It isn't one. "Pure white is never text" is the
shadow cast by a positive fact — text is Parchment — and a design system that
stores what things ARE has no row for a ban because it never needed one.

So `design_tokens` gains `supersedes`: the literal values this token should be
written instead of. `--color-text-on-action` supersedes `#fff` / `#ffffff`. Same
fact as the rule, stated forwards, and now actionable — a finding can say what
to write rather than only objecting.

It has to be DECLARED, not derived, and that is the crux: `#fff` and Parchment
`#E8E4D8` are different colours, so no value-matching check could ever have
connected them. That mismatch is precisely why the prohibition looked
unrepresentable until it was turned around.

`supersedes` cascades on EMPTINESS rather than on None. A child overriding a
colour says nothing about which literals it replaces, and blanking the family's
declaration there would silently disarm the check for every app that customises
the token — while a child that states its own list replaces it wholesale.

Two things this deliberately does NOT do:

  - It does not feed the drift panel. Superseded literals live in component CSS,
    which `designDrift.ts` cannot see and already documents as a blind spot.
    This is input for the source lint (#2277). Declaring it with nothing
    consuming it yet is honest; wiring it to a panel that cannot check it would
    not be.
  - It does not remove the panel's `prohibited_color` arm yet — that happens
    when the panel is repointed at a resolved system, which needs #2288 first.

The declaration also exposes a missing token: most of the 67 hardcoded
`color: #fff` (#2275) are text on a coloured action button, and the system has
no token for that role at all. Every view hardcodes it. Declaring the token that
was never there is the first real output of the operator's framing.
2026-07-30 21:16:45 -04:00
bvandeusen 0937b1761e feat(design-systems): the editing surface — overrides, effective set, provenance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 15s
CI & Build / Python tests (push) Canceled after 15s
CI & Build / integration (push) Canceled after 15s
CI & Build / Build & push image (push) Canceled after 0s
Milestone #254 step 5 (#2294). /design-systems is the editable half of the
surface /design already showed: that page is what the browser renders, this one
is the record that ought to decide it. Each links to the other.

The layout follows the model rather than decorating it. Two token lists, and
they are deliberately different questions:

  Overrides  — the system's own rows. Short by design, and EMPTY is the correct
               state for an app that hasn't departed from its family yet, so
               that empty state says so rather than looking unfinished.
  Effective  — what it resolves to with inheritance applied, each row labelled
               with where its value came from.

Provenance renders PER MODE when the modes disagree. A system can own `base` and
inherit `dark` at once — that is the case the value column is a map for — and a
single badge per row would have to lie about one of them. Rows whose modes agree
(the common case) keep the single badge.

"Defined here" and "overridden here" are distinct labels. Introducing a token
and shadowing an ancestor's are different acts, and `is_overridden_in` is
already false for the first.

The parent picker filters out the selected system's descendants. The server
refuses those anyway with a message naming the loop — but a refusal you cannot
trigger beats a refusal explained well. Cycles that arrive some other way still
render a truncated chain rather than freezing the tab: the client keeps the same
defensive visited-set the server has.

Three drift bugs caught while writing the styles, all of the shape this
milestone exists to surface:

  - `--color-accent` does not exist. I had used it for every focus ring and
    active border; it would have rendered as nothing at all, silently. The
    brand token is `--color-primary`.
  - focus rings are ALREADY global in theme.css (`button:focus-visible` et al).
    My per-element rules would have overridden the house ring with a different
    one — the exact "bypassed abstraction" shape from #253.
  - every existing `.btn-primary` copy uses `color: #fff`, which is rule 52's
    prohibition and 67 live violations (#2275). This one uses Parchment and
    says why in a comment, rather than becoming the 68th.

Also wires the project pointer into ProjectView's details panel, hidden entirely
when no design systems exist (rule #115 — that is the ordinary state, not a
degraded one) and saved through its own PUT, since clearing it is a real outcome
rather than an omission.
2026-07-30 17:17:43 -04:00
bvandeusenandClaude Opus 5 4ca3ab02c4 feat(design-explorer): the drift panel — rulebook says X, tokens say Y
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 40s
Milestone #251 step 5 (#2262), plus the Settings control that makes it reachable.

The comparison is deliberately thin — set arithmetic over live token values,
which is the one thing the browser knows and the server doesn't. The hard half
(prose to claims) already lives in Python where pytest can assert on it.

Three claim kinds, and the third inverts the test: a `token` claim asks whether a
custom property of that name exists; a `color` claim asks whether any token
resolves to that value; a `prohibited_color` claim FAILS when present.

normalizeColour is the client-side twin of normalize_hex and has one job the
server cannot do: getComputedStyle reports colours as rgb()/rgba() regardless of
how they were authored. So one colour has three spellings in play — #FFFFFF in
the rulebook, #fff in the stylesheet, rgb(255,255,255) from the browser — and a
comparison that misses any of them under-reports silently rather than erroring.

THE PANEL STATES ITS OWN BLIND SPOT, which matters more than it sounds. This
compares the rulebook against TOKENS. A literal hardcoded in a component, where
a token should have been referenced, is invisible to it — the drift isn't in the
tokens at all (#2275: 67 hardcoded whites against a rule forbidding pure white).
Reading those would mean bundling every SFC's source into the app; the check
belongs in CI and is tracked at #2277. A drift report that silently omitted a
whole category would invite the reader to conclude the category is clean, so the
panel says so in the panel rather than in a comment nobody reads.

Findings are ranked violated → missing → ok, and `ok` rows are hidden behind a
toggle. Same principle the auto-inject menu is built on: a short list that gets
read beats a complete one that doesn't.

Settings gains a rulebook picker. "None" is a first-class choice, not an unset
error — most installs have no rulebook describing their design system, and
saving empty DELETES the setting rather than storing a zero. The panel's empty
state points at Settings and Settings points back at the panel, so neither is a
dead end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 15:32:29 -04:00
bvandeusenandClaude Opus 5 390846a3d5 fix(write-path): disclose cross-language prior art instead of hiding it
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
Closes #2244. Retrieval matches on concept, and concepts are language-agnostic:
asking about a TypeScript union-find scores 0.72-0.73 against a PYTHON snippet,
comfortably over the 0.68 bar. That is useful — a different-language solution
gives you the shape even when the code isn't reusable — but the menu line said
nothing about it, so the reader either dismissed a good structural reference or
pasted Python into a .ts file.

Worth noting this predates the concept-query change: raw TS code already matched
the Python snippet at 0.73, because the embedder reads identifiers and structure
semantically rather than syntactically. The fail state has been shipping quietly;
#2242 only made it an intended use rather than an accident.

- knowledge._note_to_item projects `language` from the data mirror, same shape as
  the existing verification projection — a plain column read, no body parsing.
- The semantic arm carries language through on the item it builds; it is the arm
  where these arise, since a snippet recorded AT the path you're editing is
  almost never in another language.
- _prior_art_line folds it into the marker: [similar 0.72 · python]. Together
  with the score rather than after the title, because the two jointly are the
  judgement being offered.
- One explanatory line is added to the menu, and only when something on it is
  actually tagged.

Two deliberate calls:

LABEL, DON'T FILTER. A stricter threshold for foreign-language hits would
suppress exactly the shape-borrowing this exists for. They were never the
problem; their being undisclosed was.

ONLY CLAIM A MISMATCH YOU CAN ESTABLISH. _foreign_language returns "" when either
side is unknown — unrecognised extension, or a snippet with no recorded language.
A wrong "· python" is worse than no tag. Same-language hits stay unlabelled, so
the common case keeps a clean line and the preamble stays off the menu entirely.
Operator-typed language names fold through an alias table first (py/python3 →
python, tsx → typescript, c++ → cpp); unrecognised names pass through lowercased,
which still makes an unknown-but-equal pair compare equal.

Trap found while building: _note() in the tests is a MagicMock, so `note.data`
auto-created a truthy mock that would have rendered its repr into a menu line.
Both test helpers now set data = None explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 10:31:56 -04:00
bvandeusenandClaude Opus 5 fe63f3985b feat(snippets): un-merge — reverse one source out of a merged survivor
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 40s
Closes the last of milestone #232. The task said to settle the design
before coding; here is what was settled and why.

THE HAZARD. Restoring a merged-in source from the trash brought the record
back but never stripped its locations off the survivor, so both claimed
the same call sites and the reverse lookup read the duplicate claims as
real. Subtracting blindly is not a fix: a location can arrive from a
source AND genuinely be the survivor's own, and _normalize_locations dedups
them into one, so blind subtraction would strip a call site the survivor
owns. Same problem defeated partial un-merge — `merged_from` recorded ids,
not which locations came from which source.

THE ANSWER. Record per-source attribution AT MERGE TIME, where it is known
exactly: each entry keeps only what that source ADDED, computed
incrementally as sources fold in. Anything the survivor already had, or an
earlier source already brought, is attributed to nobody. Both open
questions fall out of that one change — partial un-merge is exact, and a
survivor-owned location can never be stripped, because it was never
attributed in the first place.

The shape moved from [id] to [{id, locations, tags}]. Free to do: the
corpus holds one snippet and zero merges, so there is no legacy data (rule
#22). A bare int still normalizes to {"id": n} — not legacy tolerance, but
because snippet_fields falls back to PARSING THE BODY when a row has no
`data`, and the body's provenance line can only carry ids. Such an entry
shows history and refuses un-merge with a reason rather than guessing.

WHICH SURFACE. Neither option in the task, quite. Making trash-restore
notice the merge would teach the generic trash path snippet semantics for
one record type. Instead un-merge OWNS the restore: one operation, one
authorization check, trash stays ignorant. Restoring by hand is still
allowed and still leaves both records claiming the same places — so
un-merge treats an already-alive source as the normal case and goes
straight to the subtraction that repairs it. That is the state that
motivated the feature, not an error.

Adds trash.restore_entity(user_id, type, id) — the missing inverse of
delete(), which returns a batch id callers don't keep. Restores the whole
batch, since the batch is the entity plus its cascade.

Refs #2165

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:42:29 -04:00
bvandeusenandClaude Opus 5 6db791965f feat(snippets): near-duplicate finder — surface the sets worth merging
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 36s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 44s
#231's premise was unifying reusable things already scattered as one-offs.
The create gate PREVENTS a new duplicate and merge_snippets CURES one you
point it at, but nothing FOUND the duplicates already in the record —
someone had to notice them by hand, which is the exact failure the Drafter
exists to remove.

One indexed self-join over note_embeddings, not an N² Python scan:
pgvector's cosine distance is the same operator semantic search uses, so a
similarity floor is a distance ceiling and the work stays in Postgres.
`left.note_id < right.note_id` yields each unordered pair once and drops
the self-pair that would otherwise dominate the ranking.

Pairs are collapsed into merge SETS by connected components. Transitive on
purpose: A~B plus B~C puts all three together even when A and C don't
directly clear the bar, which is what merge actually does (it folds every
source into one survivor). The cost is that a chain of mild resemblances
can rope in a member that isn't really alike — so the UI presents a set as
a proposal, shows the members, and never merges without a confirm.

Two scope decisions worth naming:

- OWN snippets only. merge_snippets requires one owner across the set, so
  surfacing someone else's would propose a merge that cannot be performed.
  The report is bounded by what the operator can act on, not what they can
  see.

- Threshold defaults to 0.82, LOOSER than the write gate's 0.90, and is a
  setting rather than a constant (rule #25). The gate blocks a create and
  has to be unforgiving of noise; this only suggests a merge under review,
  so it must reach further or it would never surface the pairs the gate
  already let through — which are precisely the ones that accumulated.

Fixes a real bug in the merge flow while wiring the UI: selectedList
filtered the selection against the CURRENT PAGE, and doMerge derives its
source ids from that list. A corpus-wide suggested group with off-page
members would have rendered incomplete and silently merged only the
visible subset. A group under review is now the authority for that list.

Refs #2088

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:32:40 -04:00
bvandeusenandClaude Opus 5 35f3f09d12 feat(snippets): drift check — verify a snippet still matches its source
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Has been skipped
A recorded snippet points at a repo · path · symbol that WILL rot: files
move, symbols get renamed, implementations diverge from the copy stored
here. Nothing detected any of it, so a record degraded silently from
"canonical reference" to "confidently wrong" — worse than no record, since
it is surfaced with the same authority either way.

WHERE THE CHECK RUNS. Agent-side, which the task flagged as the design
question to settle first. Scribe has no checkout of the operator's repos
and must not acquire one: giving the server repo access would make every
install a credential problem and break instance-agnosticism (rule #115).
The agent already has the working tree, so it does the comparing; the
server remembers the verdict, makes it queryable, and knows when it has
expired. New MCP tool verify_snippet teaches the four-step procedure and
records the result; a REST endpoint mirrors it so the UI can clear a
marker after a manual fix.

WHY THE VERDICT CARRIES A CODE HASH. A verdict describes the code it was
checked against. Invalidating it on edit means deciding which edits count
— a when_to_use tweak shouldn't void a code check, a rewrite must — which
is fiddly and easy to get subtly wrong, and easy for a new write path to
forget entirely. Stamping the verdict with a hash sidesteps all of it: one
whose code_sha no longer matches is self-evidently expired, computed at
read time, no invalidation branch to maintain.

That makes "expired" the interesting filter case. It is not `drifted`
(nothing was found wrong) and not `unverified` (a check did happen), yet
it plainly needs looking at — so `verification=attention` covers both. To
keep that one index-served predicate rather than a post-filter that would
make the pagination total a lie, data now also mirrors the CURRENT code's
fingerprint as data.code_sha, and a jsonpath compares the two fields
within the row. The filter is implemented in both dialects, SQL and
Python, for the same reason the location filter is: the semantic arm's
candidates arrive already fetched.

A merge deliberately carries no verdict forward — the survivor's code is a
union of several sources, so no prior check describes it, and unverified
is the honest answer.

UI: a danger-toned drift badge on each card (an actively misleading record
outranks a merely unused one), and a "Needs attention" filter. Its empty
state says plainly that never-verified snippets don't appear there —
otherwise `attention` would mean "everything" on day one and be useless as
a worklist.

Refs #2086

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:20:33 -04:00
bvandeusenandClaude Opus 5 2b85443dd1 feat(snippets): usage signal — was a surfaced record ever actually pulled?
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 56s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 44s
retrieval_logs answers "what did the ranker return, at what scores" — the
right substrate for tuning a threshold. It cannot answer the question the
snippet corpus actually needs: did anyone open this? A snippet nobody
opens is not neutral. It takes a slot in every future auto-inject menu and
crowds out something useful.

Adds note_usage_events (migration 0071): one row per note per event,
either 'surfaced' (we put its title in front of an agent) or 'pulled'
(someone opened it in full), tagged with which surface produced it.

Closes the gap #2082 recorded against this work. The write-path PLACE arm
carries no score, so it has no home in retrieval_logs — folding it in
would corrupt the score distribution that table exists to capture. The
result was that the arm firing on the STRONGEST claim ("there is already a
canonical helper in this exact file") was the one arm nobody could
measure. Both arms now emit usage events under distinct sources, so their
pull-through rates are finally comparable.

Deliberate departures from the task as written:

- Not in-session correlation. The original framing was "correlate
  result_ids against a later get_note in the same session." There is no
  session identity server-side — the MCP endpoint is stateless and the
  hooks send no session id — and adding one would mean threading an
  opaque client-supplied token through every read path. Two independent
  counters answer the question without it: surfaced 40×, pulled 0 is dead
  weight regardless of how those events distribute across sessions.

- Pulls record at the ENTRY POINTS (MCP tools, REST detail route), not in
  snippets_svc.get_snippet, which update and merge also reach. Counting
  those would inflate precisely the number meant to say "someone chose to
  look at this."

- get_note records for every note kind, not just snippets. The auto-inject
  menu surfaces tasks and processes too; scoping this to snippets would
  pin those at zero pulls forever and make them read as dead weight next
  to snippets that merely had a counter.

Surfaced in the Snippets list as an "N/M used" badge, warning-toned once a
record has been offered 3+ times and never opened, with the tooltip saying
what to do about it (usually: its "when to reach for it" doesn't say
when). No badge at all below one surfacing — "0/0" reads as a verdict when
it's an absence of evidence. Also returned from MCP list_snippets so the
agent can see dead weight without opening the UI.

Telemetry keeps the retrieval_telemetry contract throughout: writes are
fire-and-forget, reads degrade to zeroes, and no path can raise into the
surface it observes.

Refs #2085

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:09:17 -04:00
bvandeusen dd1b5e5ddb feat(snippets): reverse lookup — find snippets by repo/path/symbol
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Failing after 27s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m42s
"What canonical helpers already live in this file?" was unanswerable:
location lived only in the body markdown. It is now a jsonpath containment
query over the `notes.data` mirror added by migration 0070.

- One predicate in two dialects in services/knowledge.py: SQL (`data @?`,
  applied in the browse arm and the keyword arm before count/pagination, so
  totals stay honest) and Python (`location_matches`, for the semantic arm
  which post-filters candidates it already holds). Both must change together.
- Parts are ANDed within a SINGLE locations entry — repo A in one entry and
  path B in another is not "recorded at A/B". `path` also matches as a
  directory prefix, via jsonpath `starts with` rather than `@>`, which the
  same GIN index serves.
- `repo`/`path`/`symbol` reach the service, the REST list and the MCP tool
  under one name with one default (rule #33); the MCP docstring teaches the
  place form, and so does the reusing-code skill (plugin.json bumped).
- UI: a Location disclosure beside the snippet search, with its own empty
  state — "nothing kept there, so what you're about to write is new."

Settles #2083's open question (pre-0070 NULL `data`) by backfilling after
all: `backfill_snippet_data` runs at startup, deriving the mirror from the
body with the same parser the read path trusts. 0070's caution was about
mangling a hand-edited body; this never touches the body. The alternative
was a permanent second body-regex arm, or a query that silently answers
"nothing here" for an old snippet and gets the helper written twice.

Refs #2083, milestone #232.
2026-07-27 23:09:37 -04:00
bvandeusenandClaude Opus 5 0d396de215 feat(snippets): record merge provenance on the survivor
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 38s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m7s
Merge kept the target's fields, unioned locations and tags, and trashed
the sources — recording nothing about what it absorbed. If a variant
handled an edge case the survivor doesn't, that difference left the
visible record entirely; recovering it meant knowing to go digging in
the trash.

The survivor now carries `merged_from`: a "**Merged from:** #2, #3" line
in the body for humans, and the same list in the `data` mirror for
queries, written from one value like every other field (#2087).

It accumulates rather than replaces — a target merged twice keeps both
histories — and skipped sources (cross-owner, per #231) are excluded, so
the record never claims to contain something it never absorbed.

Ordinary edits carry it forward. update_snippet recomposes body and
mirror from scratch, so an omission there would silently erase the
history on the next unrelated edit; that path is pinned by its own test,
including the pre-0070 case where the body line is the only copy.

Surfaced in the snippet detail view as a "Merged from" row — the view
renders parsed fields, not the raw body, so the body line alone would
have been invisible to the operator (rule #27).

Un-merge, the other half of #2087, stays open: restoring a source from
trash still doesn't strip its locations off the survivor, and what
partial un-merge should mean is a design question, not a coding one.
`merged_from` is the record that makes it tractable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-27 15:23:41 -04:00
bvandeusenandClaude Opus 5 04b58ce01e feat(acl): shared records are search-only and always labelled as someone else's
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped
Narrows the b7d6fc7 widening per the operator's call, and fixes a regression it
introduced. Decision recorded as note 2094.

Two scopes now, deliberately different:

  readable_notes_clause  — everything the ACL permits, including records reached
                           only via a direct/group note share. For EXPLICIT acts:
                           a search the caller typed, a fetch by id.
  browsable_notes_clause — the caller's own records plus anything in a project
                           they can reach. For PASSIVE surfaces: browse lists,
                           facet counts, the process->skill manifest.

The split is a trust boundary. Anything appearing unasked — in your own list,
your own counts, or as a skill installed on your machine — reads as material you
endorsed. A one-off someone shared with you hasn't earned that standing, so it
waits until you go looking. This also dissolves the shared-Process problem by
construction rather than by special case: the manifest is a passive surface, so a
directly-shared Process is never installed as an auto-surfacing skill.

Regression fix (#2093): b7d6fc7 widened the list queries but left the fetch path
owner-only, so on the MCP path a record could be listed and then not opened —
get_snippet raised not-found, get_process couldn't resolve, and the manifest
emitted stubs whose get_process call would fail. snippets.get_snippet and
notes.resolve_process now resolve the read scope. delete_snippet gained an
explicit can_write_note guard, since being able to SEE a shared snippet must not
imply being able to bin it.

Provenance, so nothing arrives looking like the operator's own work:
- access.describe_provenance / label_shared_items add shared/owner/permission;
  labelling costs no query when everything is the caller's own.
- MCP: get_snippet, list_snippets, get_process and list_processes carry it, and
  get_process now says outright NOT to follow a shared process verbatim — its
  follow-as-written contract was the sharpest instance of the problem.
- The skill stub for a shared Process names its author and asks for a go-ahead,
  instead of describing it as "the operator's saved Scribe process".
- Policy stated once in the MCP _INSTRUCTIONS and the reusing-code skill: a
  shared record is that person's suggestion, weigh it, attribute it, ask before
  adopting it.
- UI: shared snippets show "by <owner>" in the list and a notice above the code
  in the detail view, reusing SharedWithMeView's vocabulary.

Plugin 0.1.15 -> 0.1.16 (skill text changed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 22:43:00 -04:00
bvandeusenandClaude Opus 5 b33e2a79c6 fix(snippets): close the recall-surface gaps found reviewing the Drafter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231)
milestones. The theme: a snippet could be recorded but not fully corrected, and
the agent and web surfaces had drifted apart.

- #2076 language was mis-derived from the first caller tag, so a snippet created
  with tags and no language read that tag back as its language — corrupting the
  tag set and the code fence on the next update. Only the FIRST tag can carry
  the language, since compose_tags emits [language, "snippet", *caller].
- #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be
  cleared and no snippet detached from its project. Now an omitted field is left
  alone, an empty string clears, and project_id follows the -1 = detach
  convention. A service-level UNSET sentinel keeps None available as the clear.
- #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet
  could not be retired by the agent that recorded it), locations on MCP create
  and update, system_ids through the REST routes and the editor, and the
  near-duplicate gate on REST create with a "record it anyway" escape.
- #2079 project scoping: list_snippets takes project_id through the service, the
  MCP tool and the REST route, defaulting to every project — reaching across
  projects is the point when the helper you need was written elsewhere.

Sharing the list across owners is deliberately NOT in here: query_knowledge is
shared with the Knowledge browse surface, so widening it changes behaviour well
beyond snippets. Left open on #2079 for a scope decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 19:00:05 -04:00
bvandeusenandClaude Opus 4.8 7a81b7333e feat(scribe): snippet merge UI — multi-select merge, repeatable locations
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m2s
Step 3 of the snippet-merge milestone (#231): the human surfaces for
merge + multi-location, at v1 quality.

Frontend:
- SnippetListView: a Select mode (checkbox on each card) → a sticky action
  bar → a merge modal that lets you pick which selected snippet is the
  canonical (the others fold in and go to trash). Accent border on selected
  cards, Moss action buttons (Hybrid rule).
- SnippetEditorView: the single Location fieldset becomes a repeatable
  locations list (add/remove rows), so editing a merged snippet no longer
  collapses its call sites — no data loss. Sends `locations`.
- SnippetDetailView: renders every location (Location vs Locations label).
- api/snippets.ts: SnippetLocation type, `locations` on fields/input,
  mergeSnippets().

Backend (editor enablement):
- create_snippet service + POST route accept an optional `locations` list;
  PATCH route forwards `locations` — so the editor's location list works
  uniformly on create and edit. Single repo/path/symbol remain the
  one-location shorthand (MCP create contract unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 17:22:01 -04:00
bvandeusenandClaude Opus 4.8 d257c0fd67 feat(scribe): snippet management UI + REST routes; embed snippets on create
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m4s
Step 6 of the Drafter recall milestone (#227): a human-facing surface for
the reusable-code snippets that agents record via MCP, plus the REST API
behind it. Backend embeds snippets inline on create/update so they're
recallable immediately, not only after a restart.

Backend:
- routes/snippets.py: GET/POST /api/snippets, GET/PATCH/DELETE
  /api/snippets/<id>. Share-aware per rule #78 (get_note_for_user +
  can_write_note), writes performed as the owner; list owner-scoped —
  mirrors routes/notes.py. Registered in app.py.
- services/snippets.py: embed on create/update via a _embed_snippet
  fire-and-forget helper, covering BOTH the MCP tool and the REST route
  by construction. A snippet's value is immediate recall, so it can't wait
  for the startup-only backfill (see issue: MCP create path doesn't embed
  inline for notes/tasks generally).
- tests/test_routes_snippets.py: structural registration + handler/service
  contract + PATCH-field ↔ update_snippet-kwarg parity (rule #33).

Frontend (Vue 3 + TS):
- api/snippets.ts: typed client, modeled on api/systems.ts.
- views: SnippetListView (search, skeleton/empty/error states),
  SnippetDetailView (read + copy-to-clipboard, ConfirmDialog delete),
  SnippetEditorView (create/edit all fields, Ctrl/Cmd+S, Esc, autofocus,
  validation). v1 quality per rules #24/#27.
- router: /snippets, /snippets/new, /snippets/:id, /snippets/:id/edit.
- NoteType union widened to include 'snippet'; Snippets nav link added to
  AppHeader (desktop pill bar + mobile menu).
- Design system: Moss --color-action-primary for action buttons, accent
  --color-primary reserved for tags/brand (Hybrid rule); focus rings;
  JetBrains Mono for code/name/signature/location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 14:16:02 -04:00
bvandeusenandClaude Opus 4.8 12f71fabdf refactor(scribe): remove calendar + entity surfaces from web UI (frontend)
CI & Build / integration (push) Successful in 28s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 44s
Frontend half of the narrowing (milestone #194); matches backend b49efdc.

- delete CalendarView, EventSlideOver, WeatherCard (orphan)
- drop /calendar route + nav link + the g→l keyboard shortcut
- strip the calendar-events API client + event/metadata bits from note
  types and the notes store
- KnowledgeView: remove People/Places/Lists tabs, entity cards, create
  buttons and the upcoming-events widget; keep notes/tasks/plans/processes
  + the overdue-task badge
- NoteEditorView: remove person/place/list forms + list-builder + entity
  metadata; keep note + process editors (type select = Note/Process)
- DashboardView: drop the "Upcoming · 7 days" events rail card
- SettingsView: remove the CalDAV integration card + save/test (its
  endpoints were deleted backend-side)
- prune the now-dead entity/event CSS

RecurrenceEditor + task recurrence rules are kept (task machinery, not
calendar). Verified by a full dangler sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
2026-07-19 16:10:24 -04:00
bvandeusenandClaude Opus 4.8 94d32c524a feat(issues): S4b frontend — open-issues lists + issue plumbing
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Build & push image (push) Successful in 59s
- types/note.ts: Note gains systems? + arose_from_id?; TaskKind includes 'issue'.
- stores/tasks.ts: create/update accept IssueFields (kind/system_ids/arose_from_id).
- api/systems.ts: getProjectIssues + TaskLike.
- DashboardView.vue: 'Open issues' rail section from dashboard.open_issues
  (links to /tasks/<id>, project + status).
- SystemsSection.vue (project Systems tab): 'Open issues' list via getProjectIssues,
  with system chips, links to /tasks/<id>. Both reuse existing CSS tokens.

Issue-editor controls (kind selector / system multi-select / arose-from picker
in WorkspaceTaskPanel) are the remaining S4b piece. NEEDS operator browser
verification (CI typechecks only).

Refs plan 825 (S4b frontend — lists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:50:41 -04:00
bvandeusenandClaude Opus 4.8 9293a9b198 fix(issues): S4a typecheck — allow null color in updateSystem param
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 37s
vue-tsc TS2345: System.color is string|null, but updateSystem's data param
typed color as string, so the store's Partial<Pick<System,...>> wasn't
assignable. Widen the param's color to string|null (clearing a color is valid).

Refs plan 825 (S4a).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:46:40 -04:00
bvandeusenandClaude Opus 4.8 4da29562bd feat(issues): S4a UI — Systems management section in project view
CI & Build / Python tests (push) Successful in 51s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 23s
CI & Build / Build & push image (push) Has been skipped
Frontend foundation for Issues + Systems (spec #825, S4a).

- frontend/src/api/systems.ts: typed client (System + list/create/update/delete)
  over /api/projects/<id>/systems, matching the rulebooks api style.
- frontend/src/stores/systems.ts: Pinia store keyed by project (fetch/create/
  update/archive/unarchive/delete), toast-on-error.
- frontend/src/components/SystemsSection.vue: a Systems management section —
  cards (color swatch, name, description, 'N open' issue-count badge) with
  inline create/edit, archive (hidden behind a 'show archived' toggle), and a
  delete-confirm modal. v1 quality: loading skeleton, empty state, error toasts,
  keyboard a11y, focus rings; reuses existing CSS tokens (no new colors).
- ProjectView.vue: new 'Systems' tab (between Notes and Rules), rendering
  <SystemsSection :project-id>, wired like the existing rules tab.

S4b (next) adds issue-editor controls (kind=issue/system multi-select/arose-from),
open-issues lists, and the dashboard surface. NEEDS operator browser verification
(CI typechecks but can't render).

Refs plan 825 (S4a).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:42:40 -04:00
bvandeusenandClaude Opus 4.7 7861607fb8 feat(rules): project rule + topic suppressions
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m18s
Lets a project mute individual rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two new association
tables (migration 0060), 4 MCP tools (suppress/unsuppress × rule/topic),
4 REST endpoints, and an inline "× skip" affordance plus collapsed
"Suppressed (N)" section in the project's Rules tab.

get_applicable_rules now emits suppressed_rules and suppressed_topics
(detail objects with rulebook/topic context, not just IDs) so the UI
can render the suppressed list without a follow-up lookup. The main
rules projection grew topic_id and rulebook_id columns for the per-row
suppress affordance.

Project deletion cascades the suppression rows via hard DELETE — they
are pure associations with no soft-delete column, and restoring a
deleted project should start fresh, not inherit stale mutes.

Project-scoped rules (Rule.project_id) are deliberately not suppressible
— delete them with delete_rule instead.

Implements plan-task #187.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 02:26:20 -04:00
bvandeusenandClaude Opus 4.7 43a860c3ac feat(rules): project-scoped rules (S3)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m7s
Rules can now belong to either a rulebook topic OR a single project,
enforced by a CHECK constraint (exactly-one of topic_id/project_id).
Adds the create_project_rule MCP tool + REST endpoint, surfaces
project-scoped rules in get_project/get_task/start_planning under a
new project_rules field, and adds a project Rules tab section with an
inline create form so the operator can author project rules from the
UI without rulebook ceremony.

- migration 0059: rules.project_id (FK projects ON DELETE CASCADE),
  topic_id now nullable, CHECK ck_rule_topic_xor_project, index on
  project_id
- model: Rule gains project_id; to_dict exposes it
- service: create_project_rule with project-ownership guard; list_rules
  with project_id filter UNIONs subscription-derived + project-scoped;
  get_applicable_rules adds a project_rules field; get_rule / update_rule
  / delete_rule fetch via a shared _fetch_owned_rule that handles both
  rulebook and project ownership paths
- trash: project delete cascades to project-scoped rules
- MCP: create_project_rule tool registered; _INSTRUCTIONS mentions both
  create_rule and create_project_rule paths
- REST: POST /api/projects/<id>/rules (statement required, title derived
  if omitted)
- frontend: Rule type gains nullable topic_id + project_id; createProjectRule
  client; ProjectRulesTab.vue gains a "Project rules" section with inline
  create form and per-rule expand/delete
- tests: register count → 18; create_project_rule unit tests (required
  fields, title derivation, explicit-title pass-through); applicable_rules
  shape tests now include project_rules; trash cascade test updated to
  expect 5 executions

S1+S2 (always_on flag + Scribe-first prompt) shipped in 658348f.
S4 (enter_project handshake) follows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:10:18 -04:00
bvandeusenandClaude Opus 4.7 658348f208 feat(rules): always_on rulebook flag + Scribe-first prompt
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m1s
Adds rulebooks.always_on (migration 0058) and a new list_always_on_rules
MCP tool so a session-start eager pull can fetch standing rules without
needing an active-project notion. Updates _INSTRUCTIONS so Claude calls
the new tool at session start and codifies engineering rules in Scribe
rather than CLAUDE.md / auto-memory.

Seeds FabledSword family rulebook to always_on=true on migrate, matching
its design role as the cross-project standards rulebook.

Frontend: badge in RulebookListPane for always-on rulebooks; toggle in
RulebookDetailPane header bound to a new toggleAlwaysOn store action.

This is S1+S2 of the rules-consolidation plan (Scribe task #508). S3
(project-scoped rules) and S4 (enter_project handshake) follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:56:08 -04:00
bvandeusen e5796b6f5c feat(trash): frontend trash API client + Pinia store 2026-05-29 12:07:44 -04:00
bvandeusen 605dd0a13a feat(rulebook): frontend API client + Pinia store 2026-05-27 21:59:23 -04:00
bvandeusenandClaude Opus 4.7 4806c34a3c refactor: Phase 10 — Ollama service, image cache, config, frontend orphans
Final cleanup phase of the MCP-first pivot.

docker-compose:
  - docker-compose.yml: drop ollama service + OLLAMA_URL/MODEL env vars +
    IMAGE_CACHE / VAPID env comments
  - docker-compose.prod.yml: drop ollama service + Ollama env + GPU
    reservation
  - docker-compose.quickstart.yml: drop ollama service + Ollama env +
    GPU-reservation comment; quickstart instructions now point at the
    MCP Access tab instead of model-pull

Config:
  - Drop OLLAMA_URL, OLLAMA_MODEL, OLLAMA_BACKGROUND_MODEL,
    OLLAMA_KEEP_ALIVE_*, OLLAMA_NUM_CTX, EMBEDDING_MODEL (fastembed
    is hard-coded inside services/embeddings.py)
  - Drop IMAGE_CACHE_DIR, IMAGE_MAX_BYTES (image cache subsystem
    deleted)
  - Drop VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY, VAPID_CLAIMS_SUB (push
    deleted in phase 8)
  - Drop VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND (voice
    deleted in phase 8)
  - Drop Config.validate() rules for those keys

Image cache deletion:
  - services/images.py, routes/images.py, models/image_cache.py
  - models/__init__.py: drop ImageCache import
  - app.py: drop images_bp registration
  - alembic/versions/0054_drop_image_cache.py: DROP TABLE image_cache

Frontend client.ts orphan exports stripped:
  - getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice,
    uninstallVoice, transcribeAudio, synthesiseSpeech,
    VoiceStatusResult / VoiceEntry / VoiceLibraryEntry types
  - getJournalConfig, saveJournalConfig, getJournalToday/Day/Days,
    triggerJournalPrep, runJournalCurator, listPendingActions,
    approvePendingAction, rejectPendingAction, listJournalMoments,
    updateJournalMoment, deleteJournalMoment, geocodeAddress
  - JournalConfig / JournalLocation / JournalConversation /
    JournalMessage / JournalDayPayload / JournalMoment /
    CuratorRunResult / PendingCuratorAction types
  - consolidateProfile, clearProfileObservations, listProfileObservations
  - ProfileObservationEntry, learned_summary/observations_* fields on
    UserProfile
  - consolidateTask (cascading update to TaskEditorView)
  - getFableMcpInfo, getNewsItems, GetNewsItemsParams, NewsItem import

TaskEditorView:
  - Drop the auto-summary banner + Re-consolidate button
  - Drop isBodyAutoMaintained gate (editor is always user-controlled now)
  - Drop reconsolidate function + reconsolidating ref

SettingsView:
  - profile ref no longer initialises learned_summary /
    observations_count / observations_updated_at (those fields are
    gone from UserProfile type)

Surviving frontend composables/components flagged for likely future
cleanup but not deleted in this commit (no compile errors, just
unreferenced after Phase 7-8):
  - useAssist, useFloatingAssist, useTagSuggestions, useVad,
    useListenMode, useOnnxPreloader (composables)
  - WorkspaceNoteEditor, WorkspaceTaskPanel, WeatherCard, InlineAssistPanel
    (components)
  - api/client.ts still references /api/notes/assist/* and
    /api/notes/suggest-tags via useAssist + useTagSuggestions — those
    endpoints 404 now but no caller hits them; dead at runtime, harmless.

Compose stack collapses to two services: `app` + `db`. No Ollama, no
voice models, no fable-mcp wheel build. First-boot install reduces to:
  docker compose up -d
  → visit web UI → register → Settings → MCP Access → copy snippet
  → claude mcp add … → done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:10:25 -04:00
bvandeusenandClaude Opus 4.7 4048a771d2 feat(curator): pending-action API routes + client helpers (C4/5)
The HTTP surface for the review queue. Three endpoints, all under
the existing /api/journal blueprint to keep the journal-related routes
together:

- GET    /api/journal/pending — list current user's pending actions.
- POST   /api/journal/pending/<id>/approve — replay the proposed tool
         call via execute_tool(authority='user'). On success, marks
         the row 'approved'; on replay error, leaves it pending so
         the user can retry.
- POST   /api/journal/pending/<id>/reject — marks 'rejected' with no
         execution.

Each route is a thin wrapper around services/pending_actions and
delegates user-scoping to the service (which checks user_id on every
load — actions are private to the proposer).

api/client.ts:
- PendingCuratorAction interface mirroring the backend dict shape:
  id, user_id, conv_id, action_type, target_type/id/label, payload,
  current_snapshot, status, timestamps.
- listPendingActions / approvePendingAction / rejectPendingAction
  helpers for the upcoming Needs Review panel.

C5 next: the panel itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:09:40 -04:00
bvandeusenandClaude Opus 4.7 a73dd17a1b feat(journal): right-rail captures panel + manual curator trigger (Phase 1b)
Frontend half of the conversation+curator architecture. Pairs with the
backend in commit a7002a8. With this commit, you can have a journal
conversation (chat model has no tools, doesn't try to capture), then
press a button and see what the curator extracts.

JournalView.vue:
- New "Captures" section in the right rail, above the existing
  "Upcoming" events block. Shows moments from the selected day with
  timestamp, content, and entity/task/note chips.
- "Process captures" button (Sparkles icon). Disabled for non-today
  days because we're not back-running the curator over historical
  conversations. Toast on success/failure with timing + tool-call
  count from the CuratorRunResult.
- Captures auto-load on day change AND immediately after a curator
  run completes — the right rail reflects current state without a
  page reload.
- Bound CSS scoped to the rail: cards with a primary-color left
  border, monospaced timestamps, chips for people/places/tasks/notes.

api/client.ts:
- CuratorRunResult type matching the backend dataclass.
- runJournalCurator(convId) helper.
- Pass empty body to apiPost() to satisfy the 2-arg signature
  (caller-side fix, not a backend change).

What's not in this commit (deferred):
- The captures panel doesn't show captures from days where the curator
  hasn't run yet, even if they would later be captured. Visible only
  AFTER a curator pass. (Phase 2's scheduler closes this gap by
  running automatically.)
- No edit/delete affordances on captures yet — that comes when we
  add the moment-editing UI (out of scope for the conversation+curator
  architecture commit chain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:04:56 -04:00
bvandeusenandClaude Opus 4.7 39ab5d69a9 feat(voice): admin UI to browse + install piper voices from HuggingFace
Building on the kokoro→piper swap (B1), this adds the admin-side
voice management story so additional voices can be installed without
rebuilding the image. The bundled two voices stay as immediate defaults;
everything else is opt-in via a one-click install from the catalog.

Backend (services/voice_library.py):
- fetch_catalog() pulls voices.json from the piper-voices HF repo with
  a 24h in-memory TTL. Manual refresh available via ?refresh=1 on the
  library endpoint.
- shape_catalog_for_ui() projects the raw HF dict (~250 voices, lots of
  nesting) into UI-friendly cards: id, name, language, country, quality,
  size, install state. Sorted by language_code then name for stable
  display. Install state distinguishes bundled (read-only) from user
  (admin-installed, can be removed).
- install_voice() downloads .onnx + .onnx.json into /data/voices with
  atomic .tmp → rename so a failed partial download can't leave a
  corrupt model around. Idempotent — re-installing an already-present
  voice is a no-op.
- uninstall_voice() removes /data voices; bundled /opt voices raise
  PermissionError (403 at the route layer).
- Strict voice-id regex prevents path traversal in install/uninstall.

Routes (admin-only, since these write to shared /data and affect all
users on the instance):
- GET    /api/voice/voices/library
- POST   /api/voice/voices/install
- DELETE /api/voice/voices/<voice_id>

Frontend:
- New "Voice Library" section in Settings → Voice, visible only to
  admin users. Collapsed by default; expand to load the catalog
  on-demand (doesn't hammer HF for non-admins).
- Free-text filter across id, language code, language name, country,
  and dataset name. Refresh button forces a catalog re-fetch.
- Per-voice row shows id, language/country/quality/speaker count, size,
  and either an Install button, a Remove button (user voices), or a
  "bundled" badge (read-only voices in /opt/piper-voices).
- Installs and uninstalls refresh both the library list AND the active
  voice picker so the new voice is immediately selectable.
- VoiceLibraryEntry exported from api/client.ts; new client helpers
  getVoiceLibrary/installVoice/uninstallVoice.

Tests:
- Pure-transformation unit tests for shape_catalog_for_ui,
  _resolve_file_urls, and the voice-id regex (path-traversal coverage).
- DB/network paths (fetch_catalog, install_voice) need a real
  environment — left to CI integration tests or device verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:18:22 -04:00
bvandeusenandClaude Opus 4.7 a28f75994a feat(voice): swap kokoro TTS → piper-tts
Kokoro has been stale upstream since April 2025 (`requires_python<3.13`),
which broke the Python 3.14 build. Piper is the active replacement:
maintained by OHF/Home Assistant, depends only on onnxruntime +
pathvalidate (no torch, no spacy, no transformers), and has cp314
support today.

Dockerfile:
- Add `pip install piper-tts` after the STT install.
- Bundle two default voices (en_US-amy-medium, en_US-ryan-medium) into
  /opt/piper-voices at build. Additional voices can be downloaded into
  /data/voices via the admin UI (separate commit).
- Image add over the STT-only baseline: ~150 MB.

services/tts.py — full rewrite:
- New voice-discovery layer scans /opt/piper-voices + /data/voices for
  .onnx + .onnx.json pairs. /data wins over /opt for the same id so
  admin-downloaded voices can override bundled defaults.
- Single PiperVoice kept warm; switches via _switch_voice() when the
  user changes their voice_tts_voice setting.
- list_voices() returns metadata read from .onnx.json sidecars (label
  derived from filename, language, quality, sample_rate).
- synthesise() uses piper's SynthesisConfig; converts kokoro-shaped
  `speed` multiplier to piper's `length_scale` (1.0 / speed).
- `voice_blend` parameter accepted but ignored — piper has no blend
  equivalent; first entry's voice is used if anything is passed.
- Dropped: HuggingFace commit-hash tracking (~80 lines), the daily
  check_for_kokoro_updates task, voice-tensor blending math.

routes/voice.py:
- tts_backend reports "piper" in /api/voice/status.
- /api/voice/voices no longer requires tts_available() — even with
  the active voice failed to load, the catalog still lets the user
  pick a different one.
- Synthesise request body dropped the voice_blend field; speed and
  voice still supported.

alembic 0047_reset_voice_tts_settings:
- Deletes any stored voice_tts_voice (kokoro IDs that don't map to
  piper) and voice_tts_blend (no piper equivalent) rows. Both
  re-default cleanly on next read.

frontend:
- VoiceBlendEntry type removed from api/client.ts.
- synthesiseSpeech() signature dropped the voiceBlend parameter.
- SettingsView.vue Voice Blend section removed entirely (slider,
  preview, slot management). voice_tts_blend save path removed.
- Default voice id changed from "af_heart" to "en_US-amy-medium".
- VoiceEntry gains optional language/quality/sample_rate fields
  from the richer piper sidecar metadata.

Voice paths remain lazily guarded — `VOICE_ENABLED=false` (default)
starts the app cleanly regardless of which TTS deps are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:59:09 -04:00
bvandeusen 59dee3a19f feat(frontend): NoteVersion pin fields + pin/unpin client helpers 2026-05-13 13:59:47 -04:00
bvandeusen 8b0878f227 feat(frontend): description field in task editor + Goal block in viewer
Note type gains description and consolidated_at fields. TaskEditorView
adds a Goal textarea above the body editor (wired through dirty/save/
autosave paths). TaskViewerView renders Goal as a subordinate block
above the body, plus a subtle 'Auto-summarized from work logs' banner
when consolidated_at is set.

Also adds a consolidateTask client function for the upcoming
re-consolidate button (Task 11).
2026-05-13 12:20:42 -04:00
bvandeusenandClaude Opus 4.7 090b7d83dd feat(frontend): API client for profile observations + closeout flag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:43:27 -04:00
bvandeusenandClaude Opus 4.7 36cd08c236 feat(events): expose recurrence presets in EventSlideOver
Adds a "Repeat" select (None / Daily / Weekly / Monthly / Yearly) that
reads/writes the existing Event.recurrence RRULE. CalDAV-imported rules
with extra parts (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR) surface as a disabled
"Custom" option with the raw rule shown read-only — visible but
preserved unless the user explicitly picks a preset to replace it.

EventUpdatePayload.recurrence is now string | null so we can clear via
PATCH; backend service already treats null as "clear" (recurrence is in
the nullable set in update_event).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:49 -04:00
bvandeusenandClaude Opus 4.7 dbd9f00061 refactor: hard-cut RSS infrastructure (scope C)
Removes the entire RSS feature surface — feeds, items, embeddings, reactions,
discussion-note flow, briefing news context, settings, env-vars, and DB
tables. Keeps the URL-generic article-reader (the read_article LLM tool)
under a clean module so the LLM can still fetch arbitrary article content
from URLs the user provides.

Backend:
- New services/article_fetcher.py — single source of trafilatura URL→text
- New services/tools/article.py — read_article tool (was nested under tools/rss)
- Delete services/rss.py, rss_classifier.py, rss_filtering.py, article_context.py
- Delete services/tools/rss.py
- Delete models/rss_feed.py (RssFeed, RssItem), models/rss_item_embedding.py
- services/embeddings.py: drop upsert/semantic_search/backfill RSS helpers
- services/llm.py: remove _build_briefing_article_context, briefing-conv branch,
  ARTICLE_DISCUSS_SEED skip-RAG branch; drop get_rss_items / add_rss_feed from
  the actions list
- services/generation_task.py: drop _maybe_save_article_discussion_note + caller
- routes/chat.py: drop /api/chat/from-article/<id> endpoint
- routes/journal.py: re-import via web.py refactor (article_fetcher path)
- services/tools/__init__.py: register `article`, drop `rss`
- services/tools/_registry.py: drop the requires=='rss' check
- app.py: drop backfill_rss_item_embeddings + backfill_rss_article_content tasks
- config.py: prose-only edit (no env var change — RSS env vars were never first-class)

Frontend:
- stores/settings.ts: drop rssEnabled
- SettingsView.vue: drop the RSS-classification mention
- api/client.ts: drop openArticleInChat (the from-article endpoint is gone)

Tests:
- Delete tests/test_rss_service.py, test_news_api.py, test_article_reading.py

Migration:
- 0042_drop_rss: DROP TABLE rss_item_embeddings, rss_item_reactions, rss_items,
  rss_feeds; DELETE settings rows for rss_enabled / briefing_*_topics

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 12:33:30 -04:00
bvandeusenandClaude Opus 4.7 cacfcac86a fix(journal): restore weather + events panels; hide daily-prep system msg in chat
The journal UI was over-stripped earlier — weather panel, current-conditions
poll, and the upcoming-events sidebar were all dropped. Restored those (calls
the new /api/journal/weather, /api/journal/weather/current,
/api/journal/weather/refresh, /api/journal/weather/geocode endpoints).

Also: the daily-prep system message was rendering as flat text inside
ChatPanel because there's no .role-system bubble styling. Added a hideMessage
guard in ChatMessage so daily-prep system messages don't render in the chat
stream — they're already shown above as a structured prep card.

News / RSS reactions / article-discuss are intentionally NOT in the journal
(scoped out per user direction). The broader RSS infrastructure cleanup is
a separate, larger task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 12:22:51 -04:00
bvandeusenandClaude Opus 4.7 873e70c7ea feat(journal): add JournalView UI with /journal route + nav link
Restores a working browser surface for the journal feature, which was
left UI-less when Stage F of the original plan was deferred. JournalView
is a fresh write (not a rename of BriefingView) — drops the briefing-
specific weather panel, news cards, RSS reactions, article-discuss
button, and setup wizard, since none of those have journal-backend
equivalents.

What it does:
- Fetches /api/journal/today on mount; shows today's daily-prep card
  (rendered from msg_metadata.kind === 'daily_prep' sections)
- Day picker via /api/journal/days lets you switch to past days
- Refresh-prep button hits /api/journal/trigger-prep
- Center is the existing ChatPanel pinned to today's journal conversation
  (so the chat input + SSE + tool-call cards inherit unchanged)
- Right sidebar keeps the upcoming-events list (uses the generic
  /api/events endpoint, not a briefing-specific one)

Also:
- Replace the dead Briefing API client functions with Journal equivalents
  (getJournalConfig, saveJournalConfig, getJournalToday, getJournalDay,
  getJournalDays, triggerJournalPrep, list/update/deleteJournalMoment).
- Remove NewsView.vue — it was orphaned (no route, no nav) and depended
  on the deleted /api/briefing/* endpoints. If a standalone news surface
  is wanted later it'll need to be rebuilt against new endpoints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 11:54:06 -04:00
bvandeusenandClaude Opus 4.6 ba90ad8132 feat(article-discuss): unify /news + briefing entry points, persist summaries to RAG
Both the /news discuss button and the briefing discuss button now call a
shared seed_article_discussion() helper that stages the synthetic
read_article tool exchange and the conversational seed prompt — behavior
stays byte-identical across entry points. /news also auto-starts
generation so the chat screen lands on an in-flight stream.

First assistant reply in a seeded article conversation is persisted as a
Note (tags: article-summary + article topics) and backlinked via
rss_items.discussion_note_id, so the knowledge base stops being amnesiac
about articles the user has engaged with.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:54:24 -04:00
bvandeusen d290bebad2 feat(stt): pass conversation context as Whisper initial_prompt to reduce mishearings 2026-04-07 08:48:27 -04:00
bvandeusenandClaude Sonnet 4.6 ab397e78f3 fix: briefing TTS now uses saved voice/speed/blend settings
synthesiseSpeech() called without explicit params now omits voice/speed/
blend from the request body. The backend detects this and auto-loads all
three from the user's saved settings (voice_tts_voice, voice_tts_speed,
voice_tts_blend), so briefing listen mode respects the voice the user
configured in Settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:46:52 -04:00
bvandeusenandClaude Sonnet 4.6 1460863e82 feat: Kokoro voice blending — blend builder UI + weighted tensor synthesis
Add voice blend support to TTS pipeline and settings UI. Users can mix
2–5 Kokoro voices with per-voice weight sliders; the blended style tensor
replaces the single voice when enabled. Settings persist as JSON and auto-
load on synthesis when no explicit voice is supplied in the request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:10:22 -04:00
bvandeusenandClaude Sonnet 4.6 a773c11aa0 feat: RSS embeddings, semantic news in chat, article-to-chat, richer briefings
- Embed RSS items at fetch time (nomic-embed-text); backfill at startup
- Semantic news search injected into chat system prompt ("Recent News You've Seen")
  when items match query above 0.55 cosine threshold (independent of note RAG)
- "Discuss in chat" button on news cards — creates a seeded conversation with
  the article title + full content, navigates directly to the new chat
- Briefing compilation now passes 500-char article excerpts (not just headlines)
  to the LLM and uses 8192 num_ctx to accommodate the larger prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 15:12:38 -04:00
bvandeusenandClaude Sonnet 4.6 dba41879ed feat: structured user profile with LLM-learned preferences
Replaces the freeform briefing-profile note with a DB-backed user_profiles
table. Users can edit job/industry/expertise/response preferences/interests/
work schedule via a new Settings → Profile tab. The LLM appends nightly
observations; at 14+ entries they are auto-consolidated into a learned_summary.
Profile context is injected into both briefing and chat system prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:17:30 -04:00
bvandeusenandClaude Sonnet 4.6 6f84d90dff feat: voice S2S — faster-whisper STT, Kokoro TTS, PTT overlay
Implements full speech-to-speech pipeline (all 4 phases):

Backend (Phase 1):
- services/stt.py: lazy WhisperModel singleton, run_in_executor transcription
- services/tts.py: lazy KPipeline singleton, WAV synthesis at 24kHz/16-bit
- routes/voice.py: /api/voice/status, /voices, /transcribe, /synthesise
- config.py: VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND env vars
- app.py: load STT/TTS models at startup when VOICE_ENABLED=true
- llm.py: voice_mode + voice_speech_style params inject speak-naturally prefix
- generation_task.py: voice_mode passed through from chat route
- chat.py: "voice" conversation type allowed + excluded from retention cleanup
- pyproject.toml + Dockerfile: faster-whisper, kokoro, soundfile dependencies

Frontend (Phases 2–4):
- composables/useVoiceRecorder.ts: MediaRecorder PTT wrapper
- composables/useVoiceAudio.ts: AudioContext WAV playback wrapper
- BriefingView.vue: Listen button (TTS read-aloud), auto-TTS mode, mic PTT
- VoiceOverlay.vue: global floating PTT button; creates/reuses voice conv;
  full record→transcribe→stream→TTS flow; Space bar hold-to-talk via App.vue
- SettingsView.vue: Voice tab (status badge, speech style, voice/speed)
- App.vue: mounts VoiceOverlay; Space keydown/keyup fires voice:ptt-toggle
- api/client.ts: getVoiceStatus, getVoiceList, transcribeAudio, synthesiseSpeech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:03:38 -04:00
bvandeusenandClaude Sonnet 4.6 00643c778e security: fix 10 vulnerabilities from security audit
- SSRF: block private/internal URLs in image cache fetch
- SSRF: block private/internal URLs in RSS feed fetch (scheme guard)
- SSRF: block private/internal URLs in CalDAV URL setting
- Auth: require login for GET /api/images/<id> (was unauthenticated)
- Auth: restrict Ollama model pull/delete to admin users only
- Info disclosure: remove email from /api/users/search response
- OAuth: skip email-based account linking when email_verified is false
- Config: raise hard error on default SECRET_KEY when SECURE_COOKIES=true
- Rate limit: document proxy header requirement; add startup warning
- XSS: remove src/alt from global DOMPurify ADD_ATTR allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:37:13 -04:00
bvandeusenandClaude Sonnet 4.6 0a6e57e698 feat: add NewsItem type and getNewsItems() API client function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:34:34 -04:00