Commit Graph
60 Commits
Author SHA1 Message Date
Daniel Buckner 695948271f feat: user pins for local model limits (context, max output, thinking)
A local endpoint that publishes no limits — LM Studio's /models says nothing —
left every model on the 8192/4096 defaults with no way to say "this one has a
131k window". Each discovered model now gets three optional pins in the
endpoint editor: context window, max output tokens, thinking (auto/on/off).

A pin always wins: user pin → what the endpoint published → compiled default.
Blank means no opinion, so discovery keeps ruling wherever you don't pin. Pins
live in customProvider.overrides keyed by model id, stored apart from the
discovery snapshot: a reconnect refreshes the models and prunes the pins of
models that vanished while keeping the rest.

Pins are written by PATCH /api/custom-provider, which never probes — the whole
point is configuring a server that isn't answering /models correctly. Connect
still probes and still fails loudly when the endpoint is down. A pin under 2048
tokens is rejected on the write side and clamped up on the read path, matching
the compaction-floor discipline.
2026-10-27 16:25:03 -07:00
Daniel Buckner 286a95ba04 feat: disconnect a custom endpoint (DELETE /api/custom-provider + Disconnect in its editor)
Connecting was one-way: POST could only replace the base URL, and the only
escape was hand-editing settings.json. DELETE now mirrors POST — drop the
stored section, delete the reserved key, unregister the provider from
piModels() — and the connect editor grows a Disconnect beside Connect,
following the stored-key rows' Delete pattern (quiet text, one click, no
confirm, only once there is an endpoint to remove).
2026-10-27 15:56:17 -07:00
Daniel Buckner 5b1fd81815 fix: vision slot filters to image-capable + custom models (was relaxing for all providers)
The prior change made the vision slot show every model (image-capable first),
which relaxed the filter for built-in providers too — built-in text-only models
became selectable for Vision. Centralize the vision rule in ModelMenuList:
built-ins must report image input; custom-endpoint models stay selectable (their
capability may be unreported). Image-capable still float first.
2026-10-27 15:56:17 -07:00
Daniel Buckner dbf80803cc refactor: trim custom-endpoint comments and share synthetic provider const
Review pass: shorten verbose doc comments across the custom-endpoint code,
merge duplicate imports, share one synthetic provider entry between the keys
section and setup dialog, collapse the duplicated connect-editor block, revert
an unrelated settings-route drive-by.
2026-10-27 15:56:17 -07:00
Daniel Buckner 870a1ad343 feat: custom endpoint as a first-class provider (add via dropdown, required key, vision ordering) 2026-10-27 15:50:51 -07:00
Daniel Buckner 07b6403d94 feat: custom OpenAI-compatible endpoint as a first-class provider (vision + context from /models) 2026-10-27 15:48:16 -07:00
amosblomqvist 8337836199 Use sans settings labels and link Deepgram key setup 2026-10-27 22:32:55 +02:00
amosblomqvist 27e277099f Align dictation controls with settings dropdowns and show setup guidance 2026-10-27 22:18:01 +02:00
amosblomqvist fbafc5e53a Finish Deepgram language settings with shared searchable picker 2026-10-27 21:51:09 +02:00
amosblomqvist 683eb93bcc Fix model labels and catalog-backed setup initialization 2026-10-26 19:43:09 +02:00
amosblomqvist c0c670b810 feat: enable subscription connections in web and dev 2026-10-26 03:00:40 +02:00
amosblomqvist 769fc85e17 fix: honor automatic updates preference and add Settings controls 2026-10-25 19:19:38 +02:00
amosblomqvist 3cc598ded5 fix: distinguish ChatGPT authorization recovery from storage failures 2026-10-25 01:52:52 +03:00
amosblomqvist 55a6501a09 fix: streamline subscription connection dialogs and actions 2026-10-25 01:34:46 +03:00
amosblomqvist d6e317c6fe feat: add app-owned Copilot and Grok subscription connections 2026-10-25 00:27:52 +03:00
amosblomqvist 24d1dda07d fix(chatgpt): use private JSON credential storage without Keychain 2026-10-24 18:41:03 +03:00
amosblomqvist 6788a01add Add accounts from ChatGPT management dialog 2026-10-24 15:58:26 +03:00
amosblomqvist 102aa6f7ae Fence stale ChatGPT cancellation replies and steady toggle cursor 2026-10-24 15:45:54 +03:00
amosblomqvist 42abeb65ae Unify ChatGPT background use and account management 2026-10-24 15:28:26 +03:00
amosblomqvist 8691470d03 Fix pending ChatGPT login lifecycle and IPC error handling 2026-10-24 14:22:44 +03:00
amosblomqvist b8abad2b4f Refine ChatGPT setup and model provider settings 2026-10-24 14:02:54 +03:00
amosblomqvist 0fc6d4ab94 feat: add verified desktop ChatGPT subscription connection 2026-10-24 12:17:08 +03:00
amosblomqvist 4bd20be529 fix: keep research available without search keys 2026-10-24 06:52:36 +03:00
amosblomqvist 487a63eaa5 ui: polish first-run setup copy and dialog dismissal 2026-10-17 00:38:57 +03:00
amosblomqvist 1beb54d3d2 ui: figures join the document — no card chrome, caption row below
Owner round: the figure card loses its container entirely (no border,
no padding box) - the figure is document material. Image first, then
the CAPTION ROW below it: 'Figure' label aligned to the image's left
edge, the controls (invert toggle, download, fullscreen) on the same
row aligned to its right edge. The wrapper carries the natural-sizing
law, so for a declared-width svg the row hugs the centered figure's
own edges, not the column's.

The invert affordance: the house Toggle gains an optional icon disc -
the figure's toggle renders lucide's Contrast (the half-filled circle)
AS the sliding disc, plus a native 'Invert colors' tooltip; every
plain toggle elsewhere is unchanged. Interactive demo cards keep their
boxed stage (owner ruling: an interactive surface reads as its own
stage).
2026-10-08 19:01:04 +03:00
amosblomqvist e523bea670 keyboard: the one-Esc law — a surfaced layer claims at the document level (keyboard 4 of 4)
Owner ruling (keyboard round 2026-10-06): Esc (or any key) does ONE
action, on the topmost active layer. The plan-open-while-dictating
double-act was the symptom: the plan popup listened at WINDOW level
with no claim, and same-node window listeners all fire — the global
chain (registered first) cancelled the dictation while the plan's own
listener closed the plan. One Esc, two actions.

The law (now the house pattern, documented at the chain):
- field-level handlers claim at their element (focus in a field: the
  field's action) — unchanged, already correct
- every SURFACED layer claims at the DOCUMENT level with preventDefault
  + stopPropagation — document fires before the window chain, so the
  chain stays silent under any surface
- the window chain is the bottom-up fallback: dictation (a live capture,
  NOT a surface — it survives the Esc that closes a plan or overlay),
  then abort (escAbortTarget layers its own targets)

Converted to the claim: plan popup, session overlay, chain table,
settings dialog, setup dialog, the delete/abort dialogs (window-level
stopPropagation was never enough — same-node listeners all fire), and
the stats popover. Popovers (popover-dismiss), menus, model picker, and
the sidebar context menu already claimed correctly.

1427 tests, tsc, lint, build green.
2026-10-07 00:08:49 +03:00
amosblomqvist 962d3086c0 graph: the rendering switch — chains view as the home (release polish 2)
The settings Graph section splits into two switches (owner, 2026-10-06):

- EXTRACTION (was the lone "Graph" row): the DATA channel — pipeline,
  auditor, prober graph tools. Key unchanged (graph.enabled), label now
  names its job; off = the graph stops growing, nothing is deleted.
- GRAPH RENDERING (new, graph.rendering, default on): display-only.
  Off = the home's main view is the CHAINS VIEW instead of the canvas;
  data, reasoning-chain model, and the pipeline keep working untouched.

The chains home (components/chains-home.tsx): ChainTableBody in flow —
same body as the overlay, same powers (query + sort, selection, the
delete fence with its confirm dialog), but onClose is absent so the
top-bar X does not render (the table IS the page; ChainTableBody now
takes onClose optionally). The chrome survivors float top-right below
the top bar: Import notes (ALWAYS — the entry-point law) + the cost
pill (GraphCostControl, now exported). The composer blob persists over
everything; the table scrolls CLEAR of it via a fixed pb-40 band (text
under a floating box is noise — unlike the canvas, which runs under).

Pick flow mirrors the overlay contract exactly (home lifts the pick
set; selectMode swaps the bar's confirm to Done). The chip relabels to
"Pick propositions" (no "from the map" — there is no map); while
picking, the chip row hides so the floating bar above the composer is
the one voice. Settings resolve rendering through the one core
resolver + renderingEnabled(); read server-side per navigation, so the
switch applies on the next visit home.

Smoke-verified live (headless browser): chains home renders without the
X, import + search + composer clearance confirmed, pick flow checked 2
propositions -> Done -> chip count -> practice session started with the
graph context carried (2 props, 2 chains), settings restored.

Known follow-ups (flagged, not built): after Done the bar morphs to the
table's Clear/Delete management bar while the chip also shows the count
(two voices, same set — the table's own editor law); /chat/new's pick
chip still says "from the map".

Tests: rendering in factory/sanitize/parse/resolve pins, the GraphSection
render pins (Extraction label + rendering row), the home-variant
chain-table pin (no close X). 1414 pass; tsc, lint, build green.
2026-10-06 08:26:55 +03:00
amosblomqvist ffc4df1938 compaction: the surface — settings row, two ruled dividers, summary dialog, send gate (round 14 slice 3)
- settings: compaction.thresholdTokens row on the Agent tab (owner pick)
  in a Context group — CommitInput mirroring the graph budget row, hint
  resolved through the SAME core resolver the trigger reads; PUT rejects
  below the 32k floor with the anti-churn rationale; own section
  (compaction) in SETTINGS_SECTIONS + the settings-signal union
- view fold: one marker → two dividers — the minimal MOMENT divider at
  the marker (retrying-message register, no rule lines) and the longer
  CUT divider emitted immediately before the first kept message
  (phase-divider geometry, chip carries '· Nk tok'); both carry the full
  summary payload; full history stays visible to the user
- components/compaction-divider.tsx: the dividers + summary dialog
  (pure exported body for SSR pins, the delete-dialog pattern; Esc +
  Close dismiss, backdrop does not)
- chat.tsx: compaction-changed SSE flips the running state — composer
  canSend, onSend/doSend/doSteerOrSend, and both docks' submit + hint
  ('compacting session…') gate on it (the server's registry mutex is the
  structural gate; this is the affordance); dialog mounts beside the
  abort dialog
- pins: settings-body (4), view fold (2), divider SSR (4)
2026-10-05 22:51:08 +03:00
amosblomqvist 76387b043b coverage meter one-truth fix + chunk-budget setting UI: fetchGraphRead resolves through the shared core resolver (the stale 4096 fallback filled the ring at half the 8192 truth); Graph section gains the extraction chunk budget row (floor 500, empty = factory) and the master switch composes instead of replacing the section 2026-10-02 21:18:51 +03:00
amosblomqvist 2f39766528 free-recall explain tool: the quizzing-mechanic switch (owner 2026-09-27)
Sycophancy mitigation as a structural commitment: the model writes its
full correctAnswer (plus explanation, in that order) BEFORE the learner
sees the question; the commitment rides the persisted call args, is
revealed on submit, and grades his free text in the model's next reply —
the client never grades free recall.

- tools.explain setting (segmented 'Quizzing — Multiple choice | Free
  recall' in Agent settings' Tools group): free arms explain and drops
  quiz for BOTH agents (prober variant carries no nodeId, same pattern
  as the probe quiz); mechanics keys never pass filterEnabledTools —
  quiz stays unwritable (the 2026-09-19 wedge ruling stands)
- doctrine lives pi-natively in the tool descriptions (owner ruling —
  no conditional system-prompt chunks): explain carries the commitment
  rule; the MC option-building craft moved out of the prompt into the
  quiz description; shared/20-question-tools.md slims to the
  quiz-agnostic core
- display seams: parseExplainInput (LaTeX-debris restoration included),
  dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard
  reveal (The answer / Why, no verdict chips), [Explain answer] format
- phases/plan seams recognize both quizzing tools' nodeId tags; explain
  answers deliberately feed no ledger evidence (no client grading to
  key on — a future ruling can parse the model's grade)

1049/1049 tests, typecheck/lint/build green.
2026-09-27 08:55:13 +03:00
amosblomqvist ac1c1a2818 Font picker fix round: one search field, honest empty-enumeration fallback, tooltip only on the closed trigger
- FontMenuList: the separate custom-family input is gone; the search
  field absorbed its job — a no-match query renders a 'Use <query>'
  action row (customFamilyFor, pure, pinned), Enter picks it
- openFontMenu: an empty-but-successful queryLocalFonts() now falls back
  to the curated list (Electron 44 grants local-fonts and still
  enumerates zero fonts — reproduced with a hidden-Window probe); the
  footer split states WHY: 'could not be read' vs 'needs the desktop app'
- The Hint tooltip wraps the trigger button only and unmounts while the
  menu is open — it no longer pops over the dropdown
2026-09-25 20:12:57 +03:00
amosblomqvist ad61301324 Appearance wave: searchable reading-font dropdown + emphasis inks default to text color
Two owner rulings (2026-09-26):

READING FONT — the free-text CommitInput is retired; a searchable
dropdown of the user's INSTALLED fonts over model-menu's exact
fuzzy + arrow-walk + Enter machine (FontMenuList in model-menu.tsx,
fed by window.queryLocalFonts in the desktop shell):

- The DEFAULT row (Source Sans 3) is a fuzzy CANDIDATE, not a ghost:
  fontMenuResults (pure, pinned) matches it on empty/source/sans/
  default queries only, so an unfiltered list opens with the default
  first and a searched list drops it honestly — Enter can never pick
  a row that isn't on screen. Picking it writes fontFamily null
  (empty = Source Sans 3 semantics preserved).
- The enumeration is CLICK-bound (queryLocalFonts requires user
  activation — it cannot be a mount effect), deduped to unique
  families, the factory family excluded (the DEFAULT row is it),
  cached for the section's life; a failed enumeration falls to the
  curated list rather than an empty picker.
- Dev-browser fallback is honest, never fabricated: queryLocalFonts
  absent → a small curated list (the app's own knowns + ubiquitous
  macOS families) with the footer 'Common families — your installed
  list needs the desktop app'. The manual escape lives inside the
  menu: a custom-family input + Set commits any family name the
  enumeration didn't surface (the old free-text input's heir).
- composeFontFamily (pure, pinned): null/blank → null, else verbatim.
- The trigger gets a Hint (tooltip wave convention); the row's
  empty-state hint keeps reading 'Empty = Source Sans 3'.

⚠ electron/main.cjs — one line, FLAGGED FOR PLATFORM: 'local-fonts'
joined the allowedPermissions set (the Local Font Access grant the
picker needs in the desktop shell; the loopback-origin discipline is
untouched).

EMPHASIS INKS — the factory #ee928c/#6fa9d6 are gone: both globals.css
theme blocks now default --lh-ink-strong/--lh-ink-italic to
var(--foreground) — unset emphasis renders the regular text color.
Explicit hex overrides ride unchanged (the appearance seam was already
null-honest); the rows' empty-state labels now say 'same as text'.

Tests (+16, 983 total): fontMenuResults default-row candidacy +
filtering, composeFontFamily null/blank/verbatim, the curated roster
(no factory-family duplicate), FontMenuList SSR shape (default row,
families, effective highlight, custom escape, loading state), the
trigger-replaces-input pin, and the globals.css factory pin (no old
hexes in the declarations; var(--foreground) ×2 in both blocks).

Live-verified on :3001 (dev browser — the curated-fallback path):
dropdown opens with the default row + curated list + honest footer;
search filters ('serif' → CMU Serif only); picking Georgia lands
record + --font-prose live; the default row unsets (var cleared);
the custom input commits Baskerville verbatim; Esc closes the menu
WITHOUT closing the dialog. Ink: strong/em computed = rgb(230,230,230)
(the text color) at factory; #ff0000 through the dialog renders red
and clears back cleanly. The desktop queryLocalFonts path cannot run
in this environment — the seam is click-bound + cached with the
failure fallback, and needs one desktop-shell smoke.
2026-09-25 20:02:19 +03:00
amosblomqvist 3dfc331d7b Prompt editor affordances: version-in-use hint, dirty-gated Save, auto-on
Owner-confirmed semantics (verified in composeInstructions first: switch
ON = the user's override text is what the agent reads; OFF = the default
composes, the saved file stays on disk unused). Four refinements:

1. The small text under a file name states WHICH VERSION IS IN USE:
   'Your version is in use' (PROMPT_IN_USE_HINT, pinned) when the
   switch is on; NOTHING when the default composes — even with a saved
   override sitting on disk. The old presence-based 'Your version is
   saved' (shown whenever a file existed, regardless of the switch)
   dies; the switch's meaning becomes legible through the line it
   visibly controls.

2. Save is clickable only when there's something to save: grayed and
   inert while the draft equals what's on disk (including the
   untouched editor just opened), full-contrast the moment the text
   differs. The always-active same-color button was the 'doesn't feel
   like it does anything' half of the report.

3. Saving auto-turns the switch ON — writing an override and putting
   it to work collapse into one action (a save with the switch off was
   the did-something-changed-nothing trap). Fires whenever the record
   genuinely has the path off, so a re-save after toggling off re-on's
   too. Order is the safe one (file first, then the switch the
   validation requires it).

4. A quiet inline 'Saved' / 'Deleted' confirmation rides next to the
   buttons (the old status line echoed the file path at the section's
   bottom — production-copy violation and easy to miss).

Plus the mirror-symmetry the auto-on needs: deleting an override while
its switch is on now composes the switch OFF in the same action — not
just redundant with Platform's 643a693 server-side clear: the dialog's
record mirror is what composes the next section write, so a stale ON
would silently defeat the auto-on after a save-following-delete (the
mirror would think the switch is already on and skip the write the
server record needs).

Live-verified on :3001: untouched editor grays Save; typing activates
it; reverting re-grays it; save-with-switch-off lands toggle ON + hint
+ 'Saved' + Save re-grayed in one click; toggle-off drops the hint
with the file retained on disk; re-save while off re-on's; delete
while on clears switch + file + hint with record left clean and a
subsequent agent-section write landing 200.
2026-09-25 19:18:57 +03:00
amosblomqvist 178995c987 Agent prompts section: re-read on write, dedupe, owner-ruled layout
The prompts area's five owner-reported symptoms, root causes found
in code and verified live before fixing:

1. STATE ECHO — the toggle's truth (SegmentInfo.enabled) derives
   from the settings record's agent switches, but the segment view
   was read ONCE per section mount: the switch write landed (PUT
   /api/settings/agent) yet the toggle painted its old state until
   a remount re-fetched. Root-cause fix in the settings-saved
   family: the section now re-reads /api/settings/prompts when the
   'lh:settings-saved' signal lands for the agent section — the
   reader the signal law was missing. Toggle flips verify at ~40ms,
   no navigation.

2. DUPLICATION — the server contract returns the shared segments
   in BOTH per-agent lists; the UI rendered both lists back to
   back, so every shared chunk appeared twice (18 rows, 5 dupes).
   sectionedSegments (pure, exported, pinned) dedupes: the agents'
   own chunks plus the shared chunks ONCE.

3. ORDERING — Teacher first, then Prober, then Shared last
   (owner-ruled), each section headed, chunk order within a section
   preserved from assembly order (the server lists ARE assembly
   order: base, shared sorted, own numeric-prefix sorted).

4. GRAYING — the whole-row dim died; only the TOGGLE dims now when
   there is no override file (a wrapper span on the control; the
   file name is always full-contrast).

5. DESCRIPTION LOGIC — the 'No changes yet' line died. No
   description when there's no override (the empty state is clean:
   toggle + name); 'Your version is saved' (PROMPT_SAVED_HINT,
   pinned) appears ONLY when an override exists. The redundant
   'Shared base — appears in both agents' editor note died too —
   the Shared section header carries 'Used by both agents'.

Also: saveSegment/deleteSegment reuse the readPrompts callback
(keeps last-good on a failed re-read; the mount effect owns the
loading state).

Live-verified on :3001: sections in the ruled order with 13 unique
rows, clean-row and overridden-row states per the rulings, toggle
flip instant (view + record agree), save/delete round-trips, and a
shared-segment save + toggle through the single Shared control.

Open finding (NOT fixed here — needs a ruling): an ON switch whose
override file is later deleted orphans the record, and the
agent-section write validation 400s every subsequent write (an
ON switch must have an existing file) while the UI's disabled
toggle cannot reach the OFF escape hatch the validation
anticipates. Repro: Save + switch on, Delete the override, flip
any tool. Server validation + row semantics are Platform's /
owner's call.
2026-09-25 19:00:11 +03:00
amosblomqvist b19e7e7eae Production tooltips: styled tooltips everywhere, shortcuts surfaced, dev-speak culled
Owner-approved full pass (tooltip wave, 2026-09-25). Mechanism: icon/pill
controls ride the styled Radix tooltip via a new Hint composition
(components/ui/hint.tsx) — the shared primitive adopted as-is, no
restyling; the shortcut appends as an em-dash suffix off the LIVE registry
(a rebind in Settings → Keyboard changes the suffix): "Dictate — ⌘D".
Surface-owned keys (Esc closes, Enter submits) ride a raw binding prop.
Native title= stays only for truncation-reveals (dock spans, the rename
field) and menu-interior abbreviations (MenuShell untouched). One
em-dash pattern; per-state truth on the send square (busy = "Stop the
reply — Esc", idle = "Send — ↵"), Enter on the plan verdict's accept,
"Stop dictation (Esc)" kept verbatim per the owner's flag.

Copy: the dev-speak cull — no pipeline/prober/tier/wave vocabulary
anywhere a user reads. New pins in tests/tooltip-mechanism.test.tsx
(hintText composition, asChild no-wrapper-DOM, the auto-update hint
constant); dock truncation titles pinned native; graph-chrome + settings
pins updated to the new copy.

Live-verified on :3001 — 12.6ms hover appearance (no native 1s lag);
glyphs render on every suffixed surface (⌘D, ⌘K, ⌘⇧F, ⌘,, ⌘B, F2, ↵,
Esc); per-page probes confirm Settings entry, Home logo, session search,
Start session, Close sidebar, model + thinking pills, stats summary,
rename title, plan chip, coverage ring, send square, cost button, node
search (⌘F), graph gear, settings close (Close — Esc), keyboard section
"Fixed — part of this screen", auto-update hint, light-mode "Coming
soon", the three slot rosters, graph-tools hint. Esc still closes the
settings dialog.
2026-09-25 01:52:20 +03:00
amosblomqvist 1f777f770c Zero-keys wave: provider-key gate renders honestly everywhere
Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:

1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
   (classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
   (GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
   ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
   CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
   dispatches after save AND delete.

2. Setup stage 1 is self-contained: provider picker (MenuShell +
   ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
   STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
   re-runs on it, and the stage flips to the pickers on its own.
   onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.

3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
   parseModelsRefusal) joins the revert-draft-and-open-setup interception;
   activity-dock reads 'needs a provider key'; dispatch-card shows
   'Add a key' — both deep-link setup, which lands on stage 1 by itself.

4. Dictation trio: the token route's only 503 (missing Deepgram key)
   becomes the client-authored DICTATION_KEYLESS short line; the errored
   idle click clears the error and deep-links Settings -> Keys ->
   DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
   -> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).

Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
2026-09-24 23:20:59 +03:00
amosblomqvist f7a04f6c02 Default bold ink #c96b4a -> #ee928c
Owner default change (2026-09-23): the factory strong-color token
moves to the softer coral. The factory lives in the stylesheet
token (both theme blocks in globals.css) - settings' boldColor:null
means "factory" by design, so no record/schema change. The settings
row hint + placeholder follow, plus the placeholder test and two
comments that cited the old literal.

Live-verified: unset boldColor renders rgb(238,146,140) (#ee928c) on
the real <strong>; dialog hint reads "Empty = #ee928c".
2026-09-23 22:05:58 +03:00
amosblomqvist 7a17230f26 Wave 7 close-out: the graphTools surface, wrapped cluster labels, the orphan sweep
Part 1 — the surface:
- Settings ▸ Agent ▸ Tools gains the prober GRAPH-TOOLS row as its FIRST
  row (owner ruling: in the Tools section, not the tool-toggles list —
  its subject is the map, its mechanism is agent capability). Writes
  agent.graphTools {enabled} (absent = on, the toggle discipline) via
  the section PUT; composeGraphTools pure seam. Its own key in the agent
  contract — never in the tools map.
- The row carries the search-row hint discipline: "Connects prior
  sessions' knowledge into new ones — the prober reads the knowledge
  map" (owner wording). The honest interplay composes both: when
  graph.enabled is off the row dims + disables with a "Graph is
  disabled" hint (the effective gate itself is server-side, per-turn
  fresh). AgentSection reads the graph section (record.graph, passed by
  the dialog) — read-only.
- Cluster labels wrap: wave 7 made labels session names and FILE PATHS,
  which the old single-line fillText could not bound. wrapRegionLabel
  (pure, exported): word-wrap first, char-split long paths (the
  node-label discipline), capped at 3 lines, overflow folded into an
  ellipsized last line (owner: wrap, cap 2–3, ellipsize). Pre-wrapped
  once per geometry build at the SCREEN-fixed size (viewer-relative
  labels — the wrap never goes stale mid-camera-move), drawn centered
  line-by-line at the centroid. Deleted-session clusters keep their
  hull-only honest silence (label null → no lines).
- The orphan sweep: StatsBar's leftSlot prop is dead (its one consumer,
  the knowledge-context slider, died in c690181) — removed, and pre-chat
  views now render NOTHING on the left (owner ruling this round: a
  "— · —" summary over an empty popover is pure noise) — the dictation
  middle just widens. Stale knowledge-context comments swept in
  chat.tsx (adoption-window + activity-nudge docs), activity-dock.tsx
  (useActivity ground rules), dictation.tsx (×2 adaptive-meter docs),
  home.tsx (pre-chat dock comment).

Part 2 — Teacher's c690181 review: clean. Imports, KIND_ICON/KIND_LABEL
entries, render branches and event seams all excised whole; the probe-
handoff branch re-indented intact and merge-adopted; tests updated in
lockstep (merge-adopted, record-adapter, render-new-session retired).
Platform's 4b60e0f roster-string fixes (labeler dropped from both
SLOT_META blocks) reviewed — consistent across agent-section +
setup-dialog.

Gates: tsc clean, 854/854 (8 new: wrapRegionLabel cases, graphTools
SSR rows ×3; stats-bar + ladder fixtures updated to the new rulings),
lint 0, build ok. Live on :3001: graphTools row first-in-group with
hint, off→{enabled:false}→on round-trip, graph-off dimming + hint +
disabled toggle, pre-chat rows summary-free, chat view keeps its
summary, long-title cluster label wrapped without console errors
(visual read unavailable this session — pure wrap is unit-pinned).
2026-09-23 15:57:59 +03:00
amosblomqvist d1b46132b9 Wave 7 close-out: the embedding ledger seam dies; notes birth via mapping lines
Part 1 — the ledger + remnants interlude (master-dispatched, owner ruling
2026-09-23: nothing configures the graph beyond chat models):

- The embedding ledger surface is gone: readEmbeddings/writeEmbeddings
  ports, the EmbeddingsLedger contract, EMBEDDINGS_FILE (config, store
  impls, graphRevision source list), and graph-deletion's vector-pruning
  step. On-disk data files stay untouched (inert history).
- The extraction embedder seam dies wholesale: ExtractionPipelineDeps
  drops the embedder dep (chat-handler + backfill/import-notes stubs go
  with it), the 'embedding' phase leaves ExtractionPhase, the usage
  capture/persist helpers die, and stages.embedding leaves
  ExtractionRunUsage (historical records keep their shape — reads are
  lenient). Extraction is two stages again, like the local-embedder era.
- Consumer-less remnants: latestQuizOutcomes + QUIZ_EVENT_KINDS,
  clusterContentId, matchClusterLabelings + Community + ClusterLabeling,
  the readClusterLabels/appendClusterLabel port seams + store impls +
  LEDGER_FILES.clusterLabels. NamingRunUsage/readNamingRuns STAY
  (standing ruling — the cost pill renders historical naming spend);
  clusterHue + MIN_CLUSTER_SIZE stay (live).
- The 'labeler' role dies: BACKGROUND_MODEL_ROLES, ROLE_SLOT, ModelRole,
  the catalog pin projections, and the UI roster strings. RolePins keys
  shrink with the union.

The notes birth fallback (owner-approved 2026-09-23, surfaced by the
interlude's live smoke: production notes imports create provenance-EMPTY
nodes (importNotes passes messages: null — the file's anchor is the
mapping line), so the provenance-only birth rule left every imported
file unclustered and invisible to the prober's session list, against the
locked file-birth ruling. A provenance-empty node now births under its
FIRST mapping line's chatId (ledger order); only notes-born nodes ever
hit the fallback (chat nodes always carry provenance; a re-encountered
node's link-append re-opens the provenance rule). Deletion-safe: mapping
lines of deleted chats are stripped alongside their provenance.

Live-verified on port 3004: a fresh notes import clusters under its
src_ id with the file path as the label, end to end with no embedder.

845/845 tests, typecheck/lint/build green.
EOF
)
2026-09-23 15:37:34 +03:00
amosblomqvist c86430e8bf Wave 7 chunk B: remove the embeddings machinery — the ladder collapses
The owner's wave-7 ruling: embeddings die entirely (git history is the
stash); clustering is birth-session derived (chunk A, 950ddfd), labels
are session names, and the prober's graph tools replace injection.

- settings: GraphSettings collapses to { enabled } — embedding,
  embeddingModel, clustering, labeling all die. Migration story: stale
  record keys are UNREAD and dropped by the sanitizer's reconstruction;
  the next graph-section PUT (replace-whole) persists the collapsed
  shape. The settings-panel GraphSection renders one master row.
- deleted: src/server/embeddings.ts (adapters, resolveEmbeddingPortFor,
  embedNewNodes, embeddingPort, embeddingModelCatalog), the
  GET /api/models/embeddings route, scripts/embed-graph.ts, tests for
  all three, and the /api/models/embeddings picker surfaces in the UI.
- prices: EMBEDDING_PRICES overlay + callCost fallback die. cost-summary:
  the embedding stage fold dies — back to three stages (extraction/
  audit/naming) per the owner ruling; historical stages.embedding spend
  in old run records is simply not displayed.
- ports: EmbeddingPort/EmbeddingResult/EmbeddingTask die. The embeddings
  LEDGER surface (readEmbeddings/writeEmbeddings + EmbeddingsLedger +
  EMBEDDINGS_FILE + graph-deletion's vector pruning) STAYS — inert data,
  kept only so graph-context.ts compiles until Teacher's pass removes
  it; the seam dies in the follow-up interlude.
- chat-handler (approved cross-lane unwiring, wave-6 precedent): the
  extraction pipeline's embedder becomes a documented no-op (nothing
  embedded, nothing spent, ledger untouched) until Teacher's pass
  removes the phase; the graph-context injection dispatch gate, the
  graphContext deps wiring, the setContextRespawn seam, and
  turn-service's triggerGraphContext are gone — injection is dead
  code pending Teacher's file deletions.
- scripts: backfill-graph/import-notes pass the same no-op embedder;
  replay-layout derives clusters from birthSessionPartition (the same
  partition the real settle uses) instead of the embeddings ledger.
- settings-body: parseGraphSettingsBody reads the master switch only —
  old-shape keys in a body are ignored, not rejected; bad shapes 400.
- next.config: the @huggingface tracing-exclusion guards die with the
  last reference.

Cross-lane flags: extraction.ts/graph-context.ts keep stale embed
comments + the embed phase until Teacher's full pass (their parallel
interlude owns those files — zero overlap kept by design);
semantic-clusters.ts + its test stay one more round (extraction.test.ts,
Teacher's file, still imports it — they delete it with the namer cases);
ChatSettings.graphContextTokens + the graph-context-control UI stay
until Teacher+UI.

Gates: tsc clean, eslint clean, 885/885 tests, build compiles.
Live-verified on the port-3004 dev tree: PUT /api/settings/graph with
dead keys collapses on write, bad shapes 400, GET reads {enabled: true},
/api/models/embeddings 404s.
2026-09-23 15:02:55 +03:00
amosblomqvist fb66606555 Wave 6 chunk B: the embeddings surfaces — picker, re-embed gate, gray-outs, cost pill
- Settings ▸ Graph grows the embedding-model picker (graph-row-embedding-model):
  GET /api/models/embeddings' live-federated BYOK catalog (re-fetched on mount
  and every settings save), rows priced from EMBEDDING_PRICES ("$x.xx/1M in",
  honestly "—" when unlisted), ids shown AND stored as full colon-safe slugs
  (openrouter:liquid/…:free is never split), plus an Unset row writing null.
  Empty catalog = nothing keyed: the trigger BECOMES the deep link to Keys
  ("Add a provider key first…") — and deep-links fired while the dialog is
  open now actually switch sections (initialSection was read once at mount;
  the body follows it now — pre-existing gap, first exercised by this link).
- Re-embed gate (owner design): picking a model that differs from the stored
  one holds the write behind a confirm strip carrying the honest node count
  from GET /api/graph; zero propositions skip the strip (nothing to re-embed)
  and land directly. Unset never confirms — the port just stops resolving.
  The copy says the re-embed runs with the next background pass (the staleness
  machinery — a settings PUT alone never triggers it; no server endpoints
  were added).
- Gray-out, the searchStatus precedent: with no model configured, the ladder
  rungs Embedding/Clustering/Labeling dim + disable with a "Needs an
  embedding model" hint (master switch stays lit), and the stats-bar
  GraphContextControl swaps its slider for a dimmed deep-link button to the
  Graph section. Loading never grays; every settings save re-reads (signal).
- Cost chrome: the pipeline popover grows the Embeddings stage row
  (costs.stages.embedding, wave 6's folded stage — "— · 0 in / 0 out" until
  runs exist, same as its siblings).
- Master's ride-along: the core-barrel ChatDeleteMode comment now says two
  type-only things (it always exported both).

Gates: tsc clean, 912/912 (8 new: price lines, model-dependency predicate,
unset/set SSR dimming; 2 ladder fixtures updated to the new no-model
semantics), lint 0, build ok. Live-verified on :3001: unset deep-link →
graph section, menu pricing + colon-safety (37 options), confirm strip with
"20 propositions", Cancel keeps the record, Unset writes immediately,
in-dialog empty-catalog deep-link lands Keys, Embeddings row in the popover.
2026-09-23 12:50:59 +03:00
amosblomqvist 0a4a9a11de BYOK provider embeddings (wave 6 chunk B): kill local transformers
Replace the @huggingface/transformers local embedder (~316MB ONNX cache)
with an EmbeddingPort seam: graph.embeddingModel (nullable "provider:id"
slug) selects an OpenRouter/OpenAI/Gemini adapter, resolved fresh per
call from settings + the auth store so key rotations land immediately.

- src/server/embeddings.ts rewritten: slug grammar, injected-fetch
  adapters (OpenRouter input_type, Gemini taskType batching, OpenAI
  index-ordered rows), honest resolution (null = skip-with-info, set
  model with missing key = honest error), embedNewNodes with model-change
  re-embed wipe + ghost pruning + usage steps, federated catalog
  (OpenRouter /api/v1/embeddings/models — VERIFIED live, the inverse
  path 404s; OpenAI text-embedding- filter; Gemini embedContent filter).
- GET /api/models/embeddings: federated catalog, empty when no keys.
- Extraction tail records stages.embedding usage into the run's money
  channel; EMBEDDING_PRICES overlay in core/prices.ts (outside the
  sync-generated block), callCost falls back to it; cost-summary folds
  the fourth stage; graph-context injection resolves its embedder per
  run, fails honestly when unconfigured, records query tokens.
- Settings: GraphSettings.embeddingModel (null factory), section parser
  validates slug shape (id may contain colons — OpenRouter :free
  suffixes, live catalog fact).
- Removal: @huggingface/transformers uninstalled, PATHS.modelCache and
  onnx tracing entries deleted, @huggingface tracing-excludes guard
  added, stale sharp/libvips comments updated to historical.

Live-verified on port 3004: catalog federates 36 rows (33 OpenRouter +
3 OpenAI), embedNewNodes embedded 10 nodes via the free liquid/lfm-2.5
model (dim 1024, 435 prompt tokens, 1 call, $0), settings PUT rejects
bad slugs with 400.

Gates: typecheck, lint, 895/895 tests, build.
2026-09-23 03:53:57 +03:00
amosblomqvist 525cba0398 Fix wave round 2: serif toggle actually toggles; empty number settings stay empty
- The session-name-serif toggle looked dead because --font-title was an
  @theme INLINE token: the .font-title utility carried a baked copy of the
  family, so the runtime var the toggle wrote was never read by any
  consumer. Moved --font-title to a plain @theme block — the utility now
  reads var(--font-title) and the :root factory default emits — the toggle
  flips the session name's computed font (lh-cmu ↔ Source Sans 3).
- Empty reading-font-size and heading-weight commits wrote the clamp
  minimum (Number("") is 0 → 12 / 100) instead of unsetting. Empty now
  omits the key — absence is the delta schema's unset for numbers — so the
  field stays empty, the record unsets, and the hint carries the resolved
  default (16px / 900). Typed values still clamp visibly (-5 → 12).

Gates: tsc clean, 866/866, lint 0, build ok. Live-verified on :3001.
2026-09-23 01:20:02 +03:00
amosblomqvist 22266973f3 Fix wave: dropdown keyboard/focus, appearance resets, inline key editors, content-sized slider
- MenuShell: after placement, focus lands in the search when the menu has
  one, else the body (tabIndex -1) — the lists' mount-time focus ran during
  the hidden measuring pass and silently failed, so no dropdown ever opened
  focused. The body owns the arrow machine for searchless menus:
  ArrowDown/Up cycle options with wrap (first/last entry from the body,
  native-select convention), Home/End, Enter activates, preventDefault so
  arrows never scroll the page.
- Appearance: the CSS-var applier only ever setProperty'd — a later write
  omitting a var left the stale inline value forever (empty background
  never reset, serif toggle-back looked dead). appliedNamesRef tracks what
  was set; staleVarNames (pure, exported) returns what to removeProperty.
- CommitInput: controlled — after a commit the field snaps to the stored
  record, so the clamp on a -5 prose size is visible (12), Esc reverts.
- Keys: Add-provider dropdown closes on pick; the paste row is inline
  (label | input | Save | Cancel); existing-row editors go inline with an
  explicit Cancel beside Save/Delete — folding is visible, not Esc-only.
- Graph context: readout is content-sized (tabular-nums) — no fixed-width
  dead gap when "off"; the dictation strip fills whatever space remains.

Gates: tsc clean, 866/866 tests (new staleVarNames suite), lint 0, build ok.
Live-verified via Playwright: dropdown focus+arrow cycles+wrap+Enter pick,
background set→empty reset, serif round-trip, visible clamp to 12, provider
pick closes menu, inline editor + Cancel + Esc fold, slider width 270→277px.
2026-09-23 00:57:28 +03:00
amosblomqvist f80b1a9d2c Fix wave: scroll-lock without layout shift; every models write mirrors the stored record
Fix 1 (owner: "lock must not shift"): the settings dialog's scroll-lock no
longer toggles the content column to overflow-hidden (which hid the
scrollbar and shifted everything right). The column KEEPS overflow-y-auto —
scrollbar and widths stay exactly as they are — and while any MenuShell is
open, non-passive wheel + touchmove listeners on the column preventDefault
so it cannot scroll (owner-ruled mechanism: the scrollbar may stay,
scrolling must not happen, nothing may move). Native listeners via ref —
React attaches synthetic wheel/touch passively at the root.

Fix 2 (owner repro: xhigh pin survives a GLM swap on screen until reload):
every models-writing surface now mirrors SERVER TRUTH, never the request
body. The PUT already returns the full stored record with clamps applied —
settings-dialog write() and the composer's save() now setRecord/setModels
from the response, so a swap that rides an unsupported level shows the
clamped value in the same interaction: the row trigger, the open menu's
level list + checkmark, and the composer pill all read the stored record.

Live-verified the owner's exact repro: deepseek pinned extra high → GLM
swap via the slot menu → trigger/menu/pill showed the stored clamped level
immediately; and the lock cycle: scroll 300 → blocked while open → 300
after release, zero positional shift at engage.
2026-09-22 22:45:11 +03:00
amosblomqvist 5e92d0e73e Fix wave chunk 2: thinking levels auto-populate everywhere; legacy records show the resolved level
- thinking-default.ts: defaultOrderLevel (the ordering's first supported,
  the client twin of the server's defaultThinkingLevel) + populateLevel
  (an explicit level always rides; the WRITE clamps server-side, bd68486,
  and the cross-surface signal re-reads the clamped truth; only absence
  populates from the ordering) + THINKING_LABEL shared.
- Setup dialog (item 5): per-slot thinking selectors pre-populated with
  the default-order level per picked model, editable; disabled until the
  model pick; prefillLevelFromPin keeps a stored level as-is and populates
  legacy pins from the ordering.
- Composer + agent section (item 6): fresh model picks always write a
  level: the stored one rides, absence populates; the unset state can
  never re-enter. Item 4: records with a model but no stored level display
  the RESOLVED level (ordering's first supported) instead of a dash, in
  the composer pill and the settings slot triggers/menus.
- Item 7 verified live end-to-end: an explicit level rides into the PUT,
  the server clamps, the signal re-reads, the pill shows the clamped truth.
2026-09-22 22:05:27 +03:00
amosblomqvist e766a13eb1 Fix wave chunk 1: cross-surface settings signal, dialog scroll-lock, noneRow retired
- lh:settings-saved CustomEvent (the lh:chat-renamed pattern, owner
  ruling): every writer (composer save, SetupDialog save, the settings
  write seam — one dispatch point carrying the section) notifies after a
  successful PUT; every reader re-reads its own seam: ModelControl's
  record read, the Agent section's slots/catalog, and chat.tsx's
  configured state (clearing slots mid-session now surfaces the setup
  CTA without a reload). Self-notifications re-read idempotently.
- MenuShell now announces lh:menu-open/lh:menu-close (still placement-
  only); the settings dialog's content column locks (overflow-hidden,
  count-based) while any menu is open — wheel-scrolling with a dropdown
  down is dead; the re-place-on-scroll fallback stays for un-lockable
  surfaces.
- The settings thinking menus' 'Default' noneRow is retired (owner:
  the unset state can't re-enter once chunk 2's auto-populate lands; no
  unpin is needed).
- Live-caught regression from the MenuShell migration fixed: the
  composer's outside-click handler never learned the portaled menu body
  isn't a container descendant — menu-row pointerdowns counted as
  outside and ATE THE PICK (composer picks have been dead for pointer
  users since 221c99d); rows now carve out via [data-lh-menu], the
  settings sections' discipline.
- Item 4 (resolved-level display for legacy records) deferred to chunk 2
  per owner ruling — the payload omits the level for legacy no-level
  records and DEFAULT_THINKING_ORDER was server-only (now in the core
  barrel via bd68486, exactly the seam chunk 2 needs).
2026-09-22 21:55:33 +03:00
amosblomqvist 5ab52f07f6 BYOK universal gate rendered honestly: mid-chat setup flow, refusal-aware cards
- existing sessions: a send that lands the 503 models_not_configured envelope
  renders as the SETUP FLOW, never the error banner — the record refetch drops
  the refused orphan (the gate refuses before the write-ahead persist), the
  draft goes home to the composer, and Save auto-replays it (owner ruling);
  parseModelsRefusal reads the envelope from the transport's verbatim error
  text (exported pure, tested)
- persistent 'Set up your models' CTA above the chat composer while
  slotsConfigured is false (server-read seed via app/chat/[id]/page.tsx) —
  legacy per-chat overrides may still send with the pill present; the server
  gate stays the truth
- SetupDialog prefill + 'needed' marks (owner ruling): pinned tiers open with
  their pick (matched in the federated catalog — lapsed keys don't prefill),
  unpinned tiers carry the mark; Save still requires all three;
  prefillFromSlots exported pure, tested
- refused jobs read as INFO: dispatch card shows a quiet 'needs model setup'
  line + 'Set up models' deep-link (ChatActionsContext.openSetup); dock blobs'
  activity reads 'needs model setup' instead of 'failed'; real failures keep
  the red tone; isModelsRefusal added to the envelope's home module
- agent-section ModelsBody.slots type honest to the nullable contract
2026-09-22 18:12:57 +03:00
amosblomqvist 8675b19bef Fix wave chunk B: shared MenuShell for model menus, setup CTA on /chat/new
- components/menu-shell.tsx: one portaled, fixed-anchored, viewport-capped
  (60vh pattern) menu shell with site-preferred side + honest-fit auto-flip;
  computeMenuPlacement exported pure, placement-only (sites keep their own
  open/close/Esc contracts); re-places on capture-phase scroll/resize
- migrated: settings slot menus (agent-section), keys Add-provider,
  composer model+thinking (open up, right-aligned), SetupDialog pickers
  (portaled out of the overflow-hidden dialog frame — no clipping by
  construction); graph popovers + stats-bar summary stay bespoke (owner)
- /chat/new: the setup CTA above the draft blob, mirroring home's
- tests: menu-shell-pure (geometry/cap/flip/alignment), DraftShell CTA
2026-09-22 16:56:44 +03:00
amosblomqvist f6626f1703 Fix wave chunk A: no Default option, composer = strong slot, catalog revalidation
Item 1 — no Default in the slot selectors (owner, fix wave): a
"default" model is not a thing in this app. Picking Default used to
delete the slot and re-arm the first-run gate — incoherent. Now the
model menus carry NO clear row at all (a slot holds a pick or reads
"—"), the thinking menus carry a "—" row (menu-none) that clears only
the thinking pin, and the hint line is the roster only — the resolved
factory pin is never named. Master's amendment confirmed: the
factory-pin hint is SUPERSEDED entirely (Platform removes the pins).

Item 2 — the composer control IS the strong slot (owner: "one and the
same"): ModelControl (model-picker.tsx) reads the raw models section
(GET /api/settings) and the federated catalog, and writes
PUT /api/settings/models via composeSlotModel — the same seam the
settings Agent section and SetupDialog use. Props retired: chatId,
settings, onSaved, fallbackModelId, noDefaultModel — the control is
self-sufficient and identical on every surface (composer, home dock,
/chat/new). The per-chat model PUT is gone from the UI; legacy
per-chat overrides keep resolving server-side. StatsBar/DockOverlay
plumbing slimmed accordingly; chat.tsx drops its per-chat settings
state and initialSettings prop.

Item 3 — the composer catalog cache no longer freezes per page load:
fetchCatalog(force) invalidates, and every model-menu open revalidates
in the background (stale-while-revalidate — cached list shows
instantly, the fresh federation swaps in). Owner's repro verified live:
a key saved mid-session widens the menu without a reload.

Item 4 — stale doc comments fixed: model-catalog.ts's "OpenRouter
catalog"/"multi-provider future" (Platform's file — mechanical,
flagged for review) and model-picker's retired "config-pinned"/
per-chat-PUT narrative.

Live-verified on 3001: unpicked slots read "—" everywhere; model
menus have no clear row; the "—" thinking row clears the pin but
keeps the model; composer pick writes strong and preserves cheap/
vision siblings; thinking pill writes strong.thinkingLevel; groq key
added mid-session appears in the open menu without reload. Caught in
live verification: the record fetch was mapping the WHOLE settings
record into the models section (picks never wrote) — fixed to read
body.settings.models.

Gates: typecheck, lint, 807/807, build.
2026-09-22 16:40:31 +03:00