From 454a12ea77bba7552d8b8ca7cc6e9625c62e5449 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 10:26:42 -0400 Subject: [PATCH 01/19] docs(s013): G1 plan with the renderer measured, and the slice opens ROADMAP row 013 to in_progress. NOTES.md carries the renderer measurement SLICE.md asks for, each row with the command that produced it, from phoenix_streamdown 1.0.0-beta.4, earmark 1.4.49, mdex 0.13.5 and mdex_native 0.2.8 read in a scratch directory: the beta is still the latest release and unpinnable, earmark is retired on hex with an open XSS advisory so the gate refuses it, and mdex completes fragments on its own. The outcome is mdex, open to the owner's veto at G1, as is the font decision (a stack, no bundled file). Then the fifteen-line build plan, the three-item manual queue, and four deviations stated before any code. plan_check: PASS. Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- slices/013-liveview-chat-streaming/NOTES.md | 111 ++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 slices/013-liveview-chat-streaming/NOTES.md diff --git a/ROADMAP.md b/ROADMAP.md index bea1b92..1d53aa8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ standards register names the rows that ask for them. | 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | approved | | 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved | | 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved | -| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | planned | +| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | in_progress | | 020 | Tool protocol + registry | 2 Tools | M | 012 | planned | | 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned | | 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | planned | diff --git a/slices/013-liveview-chat-streaming/NOTES.md b/slices/013-liveview-chat-streaming/NOTES.md new file mode 100644 index 0000000..095afd6 --- /dev/null +++ b/slices/013-liveview-chat-streaming/NOTES.md @@ -0,0 +1,111 @@ +# Slice 013: NOTES + +## The renderer, measured 2026-09-20 before any code + +SLICE.md names `phoenix_streamdown` as the first candidate and `earmark` or `mdex` as the fallback, and asks +for the measurement. Packages read from their Hex tarballs in a scratch directory, never added to the tree +(`mix hex.package fetch --unpack`); the hex.pm API read with `curl -s +https://hex.pm/api/packages/`. + +| Candidate | Result | Derived by | +|---|---|---| +| `phoenix_streamdown` 1.0.0-beta.4 | Still the latest release, dated 2026-05-03; `latest_stable_version` is null. 801 lines in four files. It depends on `mdex ~> 0.11` and does two things over it: completes unclosed markdown syntax per token (its `Remend` module) and splits the text into blocks so finished blocks sit under `phx-update="ignore"`. VERSIONS.md forbids a pre-release, and the owner has not accepted one. | the API's `releases[0]`; `find lib -name '*.ex' \| xargs cat \| wc -l`; `grep '{:' mix.exs` | +| `earmark` 1.4.49 | **Retired on hex** (`retirement.reason: deprecated`, "Earmark is no longer maintained. Migrate to a replacement, for example MDEx") **and carries an open advisory**, EEF-CVE-2026-48591 (medium, stored XSS via unescaped HTML attribute values). Both gate steps refuse it: `mix hex.audit` prints "Found retired packages" and `mix deps.audit` the advisory. Not a candidate. | `mix hex.audit` in the scratch project, exit 0 with both findings printed; the API's `releases/1.4.49` `retirement` field | +| `mdex` 0.13.5 | Stable, released 2026-07-29, 1.4 M downloads. Its own `streaming: true` option completes fragments (`MDEx.FragmentParser`: `"Some **bold te"` renders `bold te`, an unclosed fence closes), which is the half of streamdown that matters; the other half is a design rule SLICE.md already states. With `render: [unsafe: false]` raw HTML is omitted and a `javascript:` href is emptied; the default sanitizer (ammonia) on top drops the omission comments and adds `rel="noopener noreferrer"`. Cost: 6,420 bytes of mixed markdown render in 5.6 ms, 642 bytes in 0.7 ms (100 runs each, `:timer.tc`), so twenty renders a second of a long in-progress message is a tenth of a core. | the scratch `bench.exs`, output kept below | +| `mdex_native` 0.2.8 (mdex's NIF) | A Rust NIF through `rustler_precompiled`: artifacts for eleven targets, among them the four the packaging chain builds for (x86_64 and aarch64 darwin, x86_64 and aarch64 linux-gnu, x86_64 windows msvc and gnu). This machine downloaded `libmdex_native_nif-v0.2.8-nif-2.15-x86_64-unknown-linux-gnu.so` at compile and `mix hex.audit` reports nothing on the set. Slice 001 proved the packaging chain with a pure-BEAM release; a NIF is new to it. | `mix compile` in the scratch project (the download line is in its log); `checksum-Elixir.MDExNative.Native.exs` in the package | + +``` +$ mix run bench.exs (scratch project, mdex 0.13.5) +100 streaming renders of 6420 bytes: 563.142 ms total, 5.63142 ms each +100 streaming renders of 642 bytes: 70.593 ms total, 0.70593 ms each +``` + +**Decision at G1: `mdex ~> 0.13`, and no `phoenix_streamdown`.** `earmark` is refused by the gate. The +pre-release stays unpinned as VERSIONS.md requires; its two contributions are one `mdex` option and one rule this +slice keeps anyway (completed messages rendered once, the in-progress message as one assign replaced whole). The +NIF is the cost: the `package` workflow runs on the `mix.lock` change and builds on all three runner operating +systems, which is the first measurement of a NIF in the bundle, cited in PROOF.md by run id. Open to the owner's +veto at G1. + +## Design language, decided at G1 + +SLICE.md decides the design language here and asks for tokens later slices consume. Dark by default with the +three-way toggle kept (system, light, dark), clean rounded panels, and a font stack the tokens name once: +`Ubuntu, Comfortaa, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`, with `"Ubuntu Mono", ui-monospace, +SFMono-Regular, Menlo, Consolas, monospace` for code. **No font file ships at this slice**: Ubuntu is under the +Ubuntu Font Licence 1.0 and Comfortaa under the OFL 1.1, and bundling either adds a licence file and a REUSE row +to a tree meant for donation; slice 100 decides what the packaged app carries once its target list is fixed, and +a web font is an egress this slice does not open (CLAUDE.md §8: every egress gets a redaction row). Until then a +machine without Ubuntu or Comfortaa installed falls through to its system font, which is what the stack is for. +Open to the owner's veto at G1: bundling Comfortaa (OFL 1.1) now is the alternative. + +The tokens live in `assets/css/app.css` as two daisyUI themes (`trinity-dark`, default; `trinity-light`) and a +Tailwind `@theme` block (fonts, type scale, radius, spacing); `docs/03-conventions.md` gains a UI section naming +them and the component vocabulary (`TrinityWeb.ChatComponents`) later surfaces take. + +## G1 plan, 2026-09-20 + +Tree at `835485c` on `main` (012 approved); branch `slice/013-liveview-chat-streaming`; ROADMAP row 013 set to +`in_progress` in this commit. Each line names its test; the order is the build order. + +1. `chore(s013): add mdex ~> 0.13` as its own commit; `lib/trinity/versions.ex` gains the row and marks + `phoenix_streamdown` not used, `mix versions.gen` rewrites VERSIONS.md. Test: the gate's audit steps. +2. `TrinityWeb.Markdown.to_html/2` (`streaming:` boolean): mdex with strikethrough, table, autolink and tasklist, + `unsafe: false`, the default sanitizer. Test: raw HTML omitted; a `javascript:` href emptied; an unclosed fence + completes when streaming; the same text renders the same HTML twice (pure). +3. Design tokens in `assets/css/app.css` (above), the root layout defaulting to dark, `docs/03` UI section. + Test: `mix assets.build` exits 0 (CI builds no assets; the run is pasted in PROOF.md). +4. `Trinity.Sessions.default_persona/0` (the row named `default`, created on first use with no soul, so the + prompt keeps its "You are Trinity." fallback; slice 030 seeds the SOUL into it), `Sessions.set_model/2` + (writes `sessions.model`; refuses an id the registry does not know), and the Session re-reads its row at + the start of every turn (one line in 012's `start_model_call`, so a model set between turns is the next + turn's model). `Session.state/1` gains `text`, the in-progress draft, for a remount mid-stream. The fake + provider records the last request it saw (`last_request/0`). Tests: set_model then a turn, the fake saw the + new model (AC6's core); an unknown id refused; `state/1` shows the text mid-stream. +5. Router: `live_session :chat` with `/` (`SessionLive.Index`) and `/s/:id` (`SessionLive.Show`); the scaffold + home page, its controller and its test go, the index is the home. `TrinityWeb.Plugs.ContentSecurityPolicy` + on the browser pipeline: a per-request nonce, `script-src 'self' 'nonce-…'`, the inline theme script and the + LiveDashboard carry it; the slice-000 sobelow skip that named this slice as the owner is retired. Test: `GET /` + answers with the header and the inline script carries the same nonce. +6. `SessionLive.Index`: the session list (title or first user line, model, last activity), "New session" on + the default persona, navigating to it. Test: a session is listed; "New session" creates a row and redirects. +7. `SessionLive.Show` mount: subscribe, `ensure_started/1`, history into a `:messages` stream, the state view + into `@status` and `@draft`; delta messages already queued when the state reply arrives are drained, because + the reply's text contains them (the Session sends both, in order). Events: `user_message`, + `assistant_message` and `turn_interrupted` insert into the stream and clear the draft; `assistant_delta` + appends to the draft assign; `state` sets the status; `tool_call` adds a running tool card; `error` sets the + banner. Tests: AC2 (send, deltas, the final message once in the DOM), AC5 (remount mid-stream: the draft so + far, then the final message, no duplicate, no gap), AC4's test half (`Process.exit(session, :kill)`: the + banner appears, the next message works). +8. `TrinityWeb.ChatComponents`: `message` (role, time, usage badge, markdown), `tool_card` (name, args summary, + status, expandable result), `composer` (textarea; Enter sends, Shift+Enter a newline, through a colocated + hook; disabled unless idle), `model_picker` (`Trinity.LLM.models/0`), `cancel_button`, `interrupted_banner` + with Retry (re-sends the last user message), `status_pill`. Tests: AC3's test half (cancel: the banner and the + interrupted message), AC6 (the picker changes `sessions.model`). +9. Keyboard: a `Shortcuts` hook on the window (`Ctrl/Cmd+K` new session, `Esc` cancel) pushing two events the + LiveView already handles; no other key reaches the server. Test: the two events, driven directly. +10. AC7: a test attaches to `[:phoenix, :live_view, :render, :stop]` for the Show process, streams 1,000 deltas + and asserts at most 25 renders with the text intact; the count is recorded in PROOF.md. +11. The fake provider in dev: `Trinity.LLM.Providers.Fake` moves from `test/support` to `lib/trinity/llm/providers` + (it is the scripted provider, and the dev registry needs it); `TRINITY_FAKE_PROVIDER=1` in `config/runtime.exs` + replaces the dev registry with the fake and gives it a markdown demo script with short sleeps. Tests unchanged. +12. `mix sobelow --exit --skip` with no new finding; the CSP skip retired, the reasons file rekeyed if a line moved. +13. docs/01 (the web layer at 013: the two LiveViews, the subscribe path); docs/06 gains the NIF-in-the-bundle risk + row with its lift condition (the `package` run green on three operating systems). +14. Screenshots under `proof/` from the running app with the fake provider, taken here where a browser exists; + the real-provider run is the owner's. +15. Gate, coverage row, PROOF.md, ROADMAP to `done`, pull request, tag. + +Manual verification queue, for the owner at G4: +- **AC1**: `TRINITY_FAKE_PROVIDER=1 mix phx.server`, open `/`, New session, send "hello", watch the markdown stream + in; then the same with a real provider (`set -a; . ./.env; set +a; mix phx.server`). Screenshot or GIF. +- **AC3**: send a message with the fake's demo script (it streams for a few seconds), press Cancel or Esc mid-stream: + the partial text stays, marked interrupted, with the banner and Retry. Screenshot. +- **AC4**: with the page open mid-stream, in `iex -S mix phx.server`: `Process.exit(Trinity.Sessions.whereis(id), :kill)`. + The banner appears without a reload; the next message works. Screenshot. + +Deviations from SLICE.md, stated before building: the renderer is `mdex`, not `phoenix_streamdown` (measured +above). The fake provider moves into `lib/` so dev can run it (the slice asks for a dev config flag, which needs the +module compiled in dev). Two small changes land in 012's modules (`start_model_call` re-reads the row; `state/1` +carries the draft text), each a `fix(s012)`-style line inside this slice's commits, because AC5 and AC6 cannot be +met without them. The CSP arrives here because the slice-000 skip named 013 as its owner. From 482b9e6653cf8c7a4714228e48681f2509c77609 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 10:27:59 -0400 Subject: [PATCH 02/19] chore(s013): add mdex ~> 0.13 The chat's markdown renderer, chosen by the measurement in the slice's NOTES.md: phoenix_streamdown is still a beta, earmark is retired on hex with an open XSS advisory, and mdex completes streaming fragments on its own. It brings mdex_native 0.2.8, a Rust NIF precompiled through rustler_precompiled 0.9.0: the first NIF in the bundle, which the package workflow proves on the mix.lock change. VERSIONS.md regenerated: the phoenix_streamdown row reads not used and says why; the mdex row is new. versions.verify: OK, 83 locked packages; hex.audit and deps.audit clean. Signed-off-by: Ayla Croft --- VERSIONS.md | 3 ++- lib/trinity/versions.ex | 11 +++++++++-- mix.exs | 3 +++ mix.lock | 3 +++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/VERSIONS.md b/VERSIONS.md index 804641f..afa6671 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -133,7 +133,8 @@ never pin a version hex marks as retired or vulnerable. | `ex_tauri` | ~> 0.2 | ✅ in `mix.lock` | ⚠️ Declares `otp_release: "~> 27.0"`, and Slice 000's probe refutes the reason it gives: OTP 28 macOS universal returns 200 and OTP 27 returns 404. Whether it runs on the pinned OTP is Slice 001's first measurement. ⚠️ 439 downloads all-time, so the ADR-0004 fallback matrix carries real weight. | | `nostrum` | ~> 0.10 | 🔍 not yet a dependency | Discord. ⚠️ No release in roughly 18 months. R11's trigger already fires. Check intents and components against the current gateway before Slice 072. | | `telegex` | **not pinned** | 🔍 not a single package | Telegram. ⚠️ The latest release on hex is a release candidate, roughly two years old, and this file's own rule forbids pinning an `-rc`. Alternative: ex_gram. Slice 071 decides with the measurement. | -| `phoenix_streamdown` | **not pinned** | 🔍 not a single package | Streaming markdown renderer for LiveView. ⚠️ Pre-release, and this file's own rule forbids pinning an `-rc`; a beta is the same category. Verify at Slice 013; fallback: earmark or mdex with chunk buffering. | +| `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | 🔍 not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. | +| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`, precompiled through `rustler_precompiled` for the four packaging targets): the first NIF in the bundle; the `package` workflow proves it on three operating systems, cited in Slice 013's PROOF.md. | ### Dev and quality diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 8b33972..86d4091 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -255,10 +255,17 @@ defmodule Trinity.Versions do }, %{ name: "phoenix_streamdown", - pin: "**not pinned**", + pin: "not used (measured at Slice 013, 2026-09-20)", lock: nil, note: - "Streaming markdown renderer for LiveView. ⚠️ Pre-release, and this file's own rule forbids pinning an `-rc`; a beta is the same category. Verify at Slice 013; fallback: earmark or mdex with chunk buffering." + "Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package." + }, + %{ + name: "mdex", + pin: "~> 0.13", + lock: "mdex", + note: + "Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`, precompiled through `rustler_precompiled` for the four packaging targets): the first NIF in the bundle; the `package` workflow proves it on three operating systems, cited in Slice 013's PROOF.md." } ] diff --git a/mix.exs b/mix.exs index 6705a33..c8e2ac0 100644 --- a/mix.exs +++ b/mix.exs @@ -100,6 +100,9 @@ defmodule Trinity.MixProject do # Slice 011: the provider layer behind Trinity.LLM (docs/adr/0003). What it brings into # mix.lock is counted in the slice's NOTES.md, because the desktop binary carries it. {:req_llm, "~> 1.22"}, + # Slice 013: the chat's markdown renderer, behind TrinityWeb.Markdown. Chosen by the + # measurement in the slice's NOTES.md; it brings a Rust NIF (mdex_native), precompiled. + {:mdex, "~> 0.13"}, {:phoenix_html, "~> 4.1"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_view, "~> 1.2.0"}, diff --git a/mix.lock b/mix.lock index e4c9615..f378ff9 100644 --- a/mix.lock +++ b/mix.lock @@ -38,6 +38,8 @@ "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, + "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"}, + "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, @@ -61,6 +63,7 @@ "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "req_llm": {:hex, :req_llm, "1.24.0", "cdc5c5cd7f38c0e17cf7a949ced99b6760dcba0d860cd4d066fea1aeba422929", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.4", [hex: :ex_aws_auth, repo: "hexpm", optional: true]}, {:goth, "~> 1.4", [hex: :goth, repo: "hexpm", optional: true]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, ">= 2026.9.3 and < 2027.0.0", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 1.1.0", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "8ab6bda68e28afc3adf070cc6616d51831b42b8ff144116a9f104999af4c763f"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "server_sent_events": {:hex, :server_sent_events, "1.1.0", "54606238b9182ba673a10ce90bd95f81a0939c1baa91cdf86b66acc04edd1832", [:mix], [], "hexpm", "8e164db8e295a2d869a8faafbf4a1eeaa749b62fe93f66271adff6394d93ce15"}, "sobelow": {:hex, :sobelow, "0.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"}, "sourceror": {:hex, :sourceror, "1.12.3", "f58eebef0765c7a369a49a755ab2a5ee88d92777f403ea5a08b722cefcc37f51", [:mix], [], "hexpm", "d5f2f37099de794840f08c54ae546d7f6e4ea015e397be64aebe4996fa9f7da7"}, From ce4b1f43d13a6d42997dae180c4f5eb62f087409 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 10:30:09 -0400 Subject: [PATCH 03/19] test(s013): a model set between turns is not the next turn's model (red) Slice 013 line 4, committed failing by name: "set_model/2 (AC6's core) the next turn of a running session uses the model set between turns". Sessions.set_model/2 writes sessions.model and refuses an id the registry does not know; the Session still reads the row it loaded at init, so the fake sees model nil on the second turn: left: nil right: "fake:embed" Four of the five tests in the file pass: default_persona/0 creates the row named default once; an unknown id and a missing session are refused; state/1 now carries the in-progress text and is empty idle. The fake provider moves from test/support into lib/trinity/llm/providers so development can run it (TRINITY_FAKE_PROVIDER=1 in config/runtime.exs puts it in the dev registry with a markdown demo script), and records the last request it saw, which this test reads. Signed-off-by: Ayla Croft --- config/runtime.exs | 29 ++++++++ .../trinity/llm/providers/fake.ex | 66 +++++++++++++++-- lib/trinity/sessions.ex | 35 +++++++++ lib/trinity/sessions/session.ex | 12 ++- .../sessions/model_and_persona_test.exs | 74 +++++++++++++++++++ 5 files changed, 208 insertions(+), 8 deletions(-) rename test/support/fake_provider.ex => lib/trinity/llm/providers/fake.ex (64%) create mode 100644 test/trinity/sessions/model_and_persona_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index 6ab086a..267d515 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -41,6 +41,35 @@ if config_env() == :dev do http: [port: String.to_integer(System.get_env("PORT", "4000"))] end +# Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider: +# the registry becomes the fake's two entries and a fresh stream answers with its markdown +# demo, so the UI can be exercised and screenshotted with no key and no egress. Development +# only; the test registry is config/test.exs and production never reads this variable. +if config_env() == :dev and System.get_env("TRINITY_FAKE_PROVIDER") in ["1", "true"] do + config :trinity, :llm, + default_model: "fake:chat", + providers: %{fake: Trinity.LLM.Providers.Fake}, + retry: [attempts: 3, base_ms: 1], + models: [ + %{ + id: "fake:chat", + provider: :fake, + model: "chat", + caps: [:stream, :tools, :json], + price: %{input: 1.0, output: 2.0} + }, + %{ + id: "fake:slow", + provider: :fake, + model: "chat", + caps: [:stream, :tools, :json], + price: %{input: 1.0, output: 2.0} + } + ] + + config :trinity, Trinity.LLM.Providers.Fake, script: :demo +end + if config_env() == :dev do # Reload browser tabs when matching files change. config :trinity, TrinityWeb.Endpoint, diff --git a/test/support/fake_provider.ex b/lib/trinity/llm/providers/fake.ex similarity index 64% rename from test/support/fake_provider.ex rename to lib/trinity/llm/providers/fake.ex index 3e28a28..e1ec41a 100644 --- a/test/support/fake_provider.ex +++ b/lib/trinity/llm/providers/fake.ex @@ -2,12 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.LLM.Providers.Fake do @moduledoc """ - A scripted provider for tests. Slice 011, extended at 012. The default script streams two + A scripted provider. Slice 011, extended at 012 and 013. The default script streams two text deltas, one tool call in three chunks, usage and done. A test sets a script (`script/1`) or a sequence of scripts consumed one per call with the last repeating (`scripts/1`), or asks for `n` failures before success (`fail/2`). State is global (a persistent term), because a session's Task is not on the test process's `$callers` chain; tests using this provider are not `async: true`, and `clear/0` runs in their setup. + + Since slice 013 it lives in `lib/` rather than `test/support`, because development runs it + too: `TRINITY_FAKE_PROVIDER=1` puts it in the dev registry (config/runtime.exs) with the + `:demo` script, a markdown answer streamed with short pauses, so the chat can be exercised + and screenshotted without a key. The script a fresh stream uses when no test set one comes + from `config :trinity, Trinity.LLM.Providers.Fake, script: :demo`; absent, the default above. """ @behaviour Trinity.LLM.Provider @@ -24,6 +30,31 @@ defmodule Trinity.LLM.Providers.Fake do {:done, :tool_calls} ] + @demo_text """ + Hello. I am **Trinity**, running on the scripted provider, so nothing here left the machine. + + Things this answer exercises while it streams: + + - inline `code`, *emphasis* and a [link](https://example.com) + - a list that grows one item at a time + - a fenced block: + + ```elixir + defmodule Trinity.Demo do + def answer, do: {:ok, "streamed"} + end + ``` + + | column | value | + |---|---| + | deltas | many | + | patches | few | + + > Raw HTML in the answer is dropped, not rendered: + + That is the whole demo. + """ + @type step :: Trinity.LLM.Event.t() | {:sleep, pos_integer()} | :raise_now @doc "The events every following stream emits." @@ -48,15 +79,33 @@ defmodule Trinity.LLM.Providers.Fake do @spec calls() :: non_neg_integer() def calls, do: :persistent_term.get({__MODULE__, :calls}, 0) - @doc "Forgets scripts, pending failures and the call count." + @doc "The last request a stream call received, or nil. Slice 013's model-switch test reads it." + @spec last_request() :: Trinity.LLM.Request.t() | nil + def last_request, do: :persistent_term.get({__MODULE__, :last_request}, nil) + + @doc "Forgets scripts, pending failures, the call count and the last request." @spec clear() :: :ok def clear do - for key <- [:scripts, :fail, :calls], do: :persistent_term.erase({__MODULE__, key}) + for key <- [:scripts, :fail, :calls, :last_request], + do: :persistent_term.erase({__MODULE__, key}) + :ok end + @doc "The `:demo` script: the markdown answer above, word by word, 30 ms apart." + @spec demo_script() :: [step()] + def demo_script do + words = Regex.split(~r/(?<=\s)/, @demo_text) + n = length(words) + + Enum.flat_map(words, &[{:text_delta, &1}, {:sleep, 30}]) ++ + [{:usage, %{input_tokens: 12, output_tokens: n}}, {:done, :stop}] + end + @impl true - def stream(_request, _opts, emit) do + def stream(request, _opts, emit) do + :persistent_term.put({__MODULE__, :last_request}, request) + with :ok <- maybe_fail() do events = next_script() @@ -117,12 +166,19 @@ defmodule Trinity.LLM.Providers.Fake do defp next_script do case :persistent_term.get({__MODULE__, :scripts}, nil) do - nil -> @default_script + nil -> configured_script() [only] -> only [head | rest] -> (:persistent_term.put({__MODULE__, :scripts}, rest) && head) || head end end + defp configured_script do + case Application.get_env(:trinity, __MODULE__, [])[:script] do + :demo -> demo_script() + _ -> @default_script + end + end + defp maybe_fail do :persistent_term.put({__MODULE__, :calls}, calls() + 1) diff --git a/lib/trinity/sessions.ex b/lib/trinity/sessions.ex index 8119e0a..81651f4 100644 --- a/lib/trinity/sessions.ex +++ b/lib/trinity/sessions.ex @@ -62,6 +62,41 @@ defmodule Trinity.Sessions do @spec archive(SessionRow.t()) :: {:ok, SessionRow.t()} | {:error, Ecto.Changeset.t()} def archive(%SessionRow{} = session), do: Store.update_session(session, %{status: "archived"}) + @default_persona_name "default" + + @doc """ + The persona new sessions belong to: the row named `default`, created on first use with no + soul (so the prompt keeps its fallback). Slice 013 adds it so the chat can open a session; + slice 030 seeds the SOUL into this same row. + """ + @spec default_persona() :: Persona.t() + def default_persona do + case Store.get_persona_by_name(@default_persona_name) do + nil -> + case Store.insert_persona(%{name: @default_persona_name}) do + {:ok, persona} -> persona + # Two callers raced; the unique index let one through, and it is the row. + {:error, _} -> Store.get_persona_by_name(@default_persona_name) + end + + persona -> + persona + end + end + + @doc """ + Sets the session's model to a registry id, or to nil for the registry default; refuses an id + the registry does not know. The running process reads the row at the start of each turn, so + the next turn uses it (slice 013, AC6). + """ + @spec set_model(session_id(), String.t() | nil) :: {:ok, SessionRow.t()} | {:error, term()} + def set_model(session_id, model) do + with {:ok, _entry} <- Trinity.LLM.Registry.lookup(model), + %SessionRow{} = session <- Store.get_session(session_id) || {:error, :no_session} do + Store.update_session(session, %{model: model}) + end + end + @doc "The number of messages in a session." @spec message_count(session_id()) :: non_neg_integer() def message_count(session_id), do: Store.message_count(session_id) diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index 35a9ae3..56a4449 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -45,12 +45,17 @@ defmodule Trinity.Sessions.Session do @spec cancel_turn(pid() | String.t()) :: :ok | {:error, :idle} def cancel_turn(ref), do: :gen_statem.call(target(ref), :cancel) - @doc "The state name and a redacted view of the data: no grants, approvals or pending calls hide here." + @doc """ + The state name and a redacted view of the data: no grants, approvals or pending calls hide + here. `text` is the in-progress assistant text (slice 013 reads it when a page mounts + mid-stream); it is empty outside a turn. + """ @spec state(pid() | String.t()) :: %{ state: atom(), pending: [map()], turns: non_neg_integer(), - draft_id: String.t() | nil + draft_id: String.t() | nil, + text: String.t() } def state(ref), do: :gen_statem.call(target(ref), :state) @@ -122,7 +127,8 @@ defmodule Trinity.Sessions.Session do state: state, pending: (turn && turn.pending) || [], turns: (turn && turn.turns) || 0, - draft_id: turn && turn.draft_id + draft_id: turn && turn.draft_id, + text: (turn && turn.text) || "" } {:keep_state_and_data, [{:reply, from, view}]} diff --git a/test/trinity/sessions/model_and_persona_test.exs b/test/trinity/sessions/model_and_persona_test.exs new file mode 100644 index 0000000..c9b2c14 --- /dev/null +++ b/test/trinity/sessions/model_and_persona_test.exs @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Sessions.ModelAndPersonaTest do + @moduledoc """ + Slice 013 line 4: the default persona, `set_model/2`, the Session reading its row at the + start of every turn, and the draft text in the state view. + """ + use Trinity.SessionCase + @moduletag :capture_log + + alias Trinity.Factory + alias Trinity.LLM.Providers.Fake + + describe "default_persona/0" do + test "creates the row named default once and returns the same row after" do + first = Sessions.default_persona() + assert first.name == "default" + assert first.soul == nil + assert Sessions.default_persona().id == first.id + end + end + + describe "set_model/2 (AC6's core)" do + setup do + row = Factory.session!() + :ok = Sessions.subscribe(row.id) + {:ok, id: row.id} + end + + test "refuses an id the registry does not know, and the row is unchanged", %{id: id} do + assert {:error, {:unknown_model, "nope:model"}} = Sessions.set_model(id, "nope:model") + assert Sessions.get_session(id).model == nil + end + + test "refuses a session that does not exist" do + assert {:error, :no_session} = Sessions.set_model(Trinity.UUID.generate(), "mock:chat") + end + + test "the next turn of a running session uses the model set between turns", %{id: id} do + Fake.script(script_deltas(2, "a")) + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "one") + _ = collect(id, &match?({:assistant_message, _}, &1)) + assert Fake.last_request().model == nil + + assert {:ok, %{model: "fake:embed"}} = Sessions.set_model(id, "fake:embed") + {:ok, _} = Session.send_user_message(pid, "two") + _ = collect(id, &match?({:assistant_message, _}, &1)) + assert Fake.last_request().model == "fake:embed" + assert Sessions.whereis(id) == pid, "the process was not restarted to pick the model up" + end + end + + describe "state/1 carries the in-progress text" do + test "mid-stream the view holds what has arrived; idle it is empty" do + row = Factory.session!() + :ok = Sessions.subscribe(row.id) + + Fake.script([ + {:text_delta, "so far "}, + {:sleep, 400}, + {:text_delta, "done"}, + {:done, :stop} + ]) + + {:ok, pid} = start_drained(row.id) + {:ok, _} = Session.send_user_message(pid, "go") + _ = collect(row.id, &match?({:assistant_delta, _}, &1)) + assert %{state: :thinking, text: "so far "} = Session.state(pid) + _ = collect(row.id, &match?({:assistant_message, _}, &1)) + assert %{state: :idle, text: ""} = Session.state(pid) + end + end +end From 57aca615be7c93f765ec17b9b2ffeb6f1091091d Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 10:30:13 -0400 Subject: [PATCH 04/19] fix(s012): the Session reads its row at the start of every turn The fix for the red committed just before it ("the next turn of a running session uses the model set between turns"). start_model_call re-reads the session row, so a model written between turns by Sessions.set_model/2 is the next turn's model rather than the next incarnation's. Slice 013 needs it for AC6; the change is one read in 012's module and every 012 test still passes (22 in test/trinity/sessions). Signed-off-by: Ayla Croft --- lib/trinity/sessions/session.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index 56a4449..118dc56 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -218,7 +218,11 @@ defmodule Trinity.Sessions.Session do ## The turn - defp start_model_call(%State{id: id, session: session, task_sup: sup, turn: turn} = data) do + # The row is read again at every turn (slice 013): a model set between turns through + # `Trinity.Sessions.set_model/2` is the next turn's model, not the next incarnation's. + defp start_model_call(%State{id: id, task_sup: sup, turn: turn} = data) do + session = Store.get_session(id) || data.session + data = %{data | session: session} persona = session.persona_id && Store.get_persona(session.persona_id) request = Prompt.build(session, persona, Trinity.Sessions.history(id, limit: 500)) ref = make_ref() From 50f35db91ff278fcb2415c2f2f8774ae91f2c7ba Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 10:47:06 -0400 Subject: [PATCH 05/19] feat(s013): the chat: two LiveViews, the component vocabulary, the renderer, the policy Lines 2 to 12 of the G1 plan. TrinityWeb.Markdown is the one path from model output to the DOM (mdex, raw HTML omitted, the sanitizer on top, fragments completed while streaming; five tests, and the tree's only raw/1 call carries a scoped sobelow skip). The design tokens: two daisyUI themes, dark by default, and a @theme block for fonts, text sizes and radii, with a UI section in docs/03 naming them and the component vocabulary later surfaces take. TrinityWeb.ChatComponents: message, tool_card, draft, composer, model_picker, status_pill, banner, local_time. SessionLive.Index (the list, New session on the default persona) and SessionLive.Show (subscribe, ensure_started, history into a stream, the turn in flight from state/1 with the already-queued deltas drained, one assign for the in-progress text, catch-up of tool rows past the last seen seq). Four hooks in assets/js/hooks.js: Composer, Shortcuts (Ctrl/Cmd+K, Esc), ScrollToBottom, LocalTime. TrinityWeb.Plugs.ContentSecurityPolicy on the browser pipeline with a nonce per request; the root layout's theme script and the LiveDashboard carry it; the slice-000 skip rekeyed with the new reason. The scaffold home page goes; / is the index. Sessions.set_title/2 for the first line. The Trinity boundary exports Sessions.Message and Sessions.SessionRow. Tests, 21 in test/trinity_web: AC2 (send, deltas, the final message once), AC3's test half (cancel, banner, Retry), AC4's test half (Process.exit :kill under an open page), AC5 (a second page mounted mid-stream: four rows on both pages, none twice), AC6 (the picker writes sessions.model and the fake sees it on the next turn), AC7 (six renders for 1,000 deltas, counted on [:phoenix, :live_view, :render, :stop]), the CSP header and nonce, the index and the two hook events. Screenshots under proof/ from the dev server on the fake provider driven by a headless chromium: streaming, final, cancelled, killed, the next message after a kill, and the light theme. mix gate exit 0, 176 tests; credo --strict --all clean; sobelow clean; docs/01, docs/03 and docs/06 (R6 closed, R24 opened for the NIF) updated; REUSE.toml covers proof/. Signed-off-by: Ayla Croft --- .sobelow-skips | 2 +- .sobelow-skips.reasons | 2 +- REUSE.toml | 2 +- assets/css/app.css | 166 +++++--- assets/js/app.js | 3 +- assets/js/hooks.js | 90 +++++ docs/01-architecture.md | 13 +- docs/03-conventions.md | 20 + docs/06-risk-register.md | 2 + lib/trinity.ex | 7 +- lib/trinity/sessions.ex | 9 + lib/trinity_web/components/chat_components.ex | 360 ++++++++++++++++++ lib/trinity_web/components/layouts.ex | 57 +-- .../components/layouts/root.html.heex | 14 +- .../controllers/page_controller.ex | 9 - lib/trinity_web/controllers/page_html.ex | 12 - .../controllers/page_html/home.html.heex | 199 ---------- lib/trinity_web/live/session_live/index.ex | 91 +++++ lib/trinity_web/live/session_live/show.ex | 271 +++++++++++++ lib/trinity_web/markdown.ex | 45 +++ .../plugs/content_security_policy.ex | 50 +++ lib/trinity_web/router.ex | 10 +- .../proof/ac1-final-fake.png | Bin 0 -> 54915 bytes .../proof/ac1-streaming-fake.png | Bin 0 -> 45925 bytes .../proof/ac3-cancelled.png | Bin 0 -> 62657 bytes .../proof/ac4-killed-banner.png | Bin 0 -> 54226 bytes .../proof/ac4-next-message-works.png | Bin 0 -> 61298 bytes .../proof/design-light-theme.png | Bin 0 -> 60218 bytes .../controllers/page_controller_test.exs | 10 - test/trinity_web/live/session_live_test.exs | 286 ++++++++++++++ test/trinity_web/markdown_test.exs | 45 +++ .../plugs/content_security_policy_test.exs | 21 + 32 files changed, 1455 insertions(+), 341 deletions(-) create mode 100644 assets/js/hooks.js create mode 100644 lib/trinity_web/components/chat_components.ex delete mode 100644 lib/trinity_web/controllers/page_controller.ex delete mode 100644 lib/trinity_web/controllers/page_html.ex delete mode 100644 lib/trinity_web/controllers/page_html/home.html.heex create mode 100644 lib/trinity_web/live/session_live/index.ex create mode 100644 lib/trinity_web/live/session_live/show.ex create mode 100644 lib/trinity_web/markdown.ex create mode 100644 lib/trinity_web/plugs/content_security_policy.ex create mode 100644 slices/013-liveview-chat-streaming/proof/ac1-final-fake.png create mode 100644 slices/013-liveview-chat-streaming/proof/ac1-streaming-fake.png create mode 100644 slices/013-liveview-chat-streaming/proof/ac3-cancelled.png create mode 100644 slices/013-liveview-chat-streaming/proof/ac4-killed-banner.png create mode 100644 slices/013-liveview-chat-streaming/proof/ac4-next-message-works.png create mode 100644 slices/013-liveview-chat-streaming/proof/design-light-theme.png delete mode 100644 test/trinity_web/controllers/page_controller_test.exs create mode 100644 test/trinity_web/live/session_live_test.exs create mode 100644 test/trinity_web/markdown_test.exs create mode 100644 test/trinity_web/plugs/content_security_policy_test.exs diff --git a/.sobelow-skips b/.sobelow-skips index 71b14d2..d6318d3 100644 --- a/.sobelow-skips +++ b/.sobelow-skips @@ -1,4 +1,4 @@ -Config.CSP: Missing Content-Security-Policy,lib/trinity_web/router.ex:12,2DBC9F9 +Config.CSP: Missing Content-Security-Policy,lib/trinity_web/router.ex:14,1DC4768 Traversal.FileModule: Directory Traversal in `File.mkdir_p`,lib/trinity/data_dir/lock.ex:57,35AB9EC Traversal.FileModule: Directory Traversal in `File.read`,lib/trinity/data_dir/lock.ex:143,55D5ADB Traversal.FileModule: Directory Traversal in `File.rm`,lib/trinity/data_dir/lock.ex:82,21D88E2 diff --git a/.sobelow-skips.reasons b/.sobelow-skips.reasons index 38747f7..f365a4f 100644 --- a/.sobelow-skips.reasons +++ b/.sobelow-skips.reasons @@ -9,7 +9,7 @@ # exactly that. Re-run mix sobelow --mark-skip-all and rekey this file when it happens. # # format: -2DBC9F9 Missing CSP on the browser pipeline. The scaffold ships no Content-Security-Policy, and setting one needs LiveView nonce handling, which belongs with the UI rather than the toolchain. Slice 013 owns it. Skipped rather than fixed blind: a CSP written without the UI it protects is a guess. +1DC4768 Slice 013, the browser pipeline: the policy IS set, by TrinityWeb.Plugs.ContentSecurityPolicy with a nonce per request, placed before put_secure_browser_headers. sobelow recognises only a header map given to put_secure_browser_headers, so it keeps reporting the pipeline. test/trinity_web/plugs/content_security_policy_test.exs asserts the header and the nonce on every browser response. Rekeyed from 2DBC9F9 (router.ex:12, slice 000, "slice 013 owns it") when the plug moved the line to 14. 35AB9EC Slice 010, Trinity.DataDir.Lock.acquire/2: the directory is the application's own data directory from config or Trinity.Paths, never a request or a model output; the lock has to create it before the Repo does. Low confidence traversal by the tool's own label. 55D5ADB Slice 010, Trinity.DataDir.Lock read/1: reads /LOCK, a path this module composes from the data directory and a constant file name; no caller supplies it. 21D88E2 Slice 010, Trinity.DataDir.Lock.release/2: removes /LOCK only when the file carries this process's own token; same composed path as the read. diff --git a/REUSE.toml b/REUSE.toml index 70a0864..44888a2 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,7 +11,7 @@ SPDX-PackageDownloadLocation = "https://github.com/ScriptKittyOS/Trinity" # the gap is recorded rather than glossed. [[annotations]] -path = ["mix.lock", "src-tauri/Cargo.lock", "**/*.md", "**/*.json", "**/*.tsv", "**/*.cff", "**/*.toml", ".tool-versions", ".gitignore", ".sobelow-skips", ".sobelow-skips.reasons", "priv/name_digests.txt", ".formatter.exs"] +path = ["mix.lock", "src-tauri/Cargo.lock", "**/*.md", "**/*.json", "**/*.tsv", "**/*.cff", "**/*.toml", ".tool-versions", ".gitignore", ".sobelow-skips", ".sobelow-skips.reasons", "priv/name_digests.txt", ".formatter.exs", "slices/**/proof/**"] precedence = "aggregate" SPDX-FileCopyrightText = "Sudo Apt Holdings LLC" SPDX-License-Identifier = "Apache-2.0" diff --git a/assets/css/app.css b/assets/css/app.css index da837e5..a82481c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -20,76 +20,98 @@ themes: false; } -/* daisyUI theme plugin. - We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired - on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */ +/* Trinity design tokens, decided at slice 013 (SLICE.md: "the design language is decided here"). + Every later surface (the approval card at 021, the memory panel at 030, the skills list at + 040, tasks at 050, gateways at 070, activity and cost at 090, settings at 100) takes these + rather than inventing its own. Two daisyUI themes carry the palette; the @theme block below + carries the fonts, the two extra text sizes and the radii. Dark is the default. + + Fonts: a stack, no bundled file (NOTES.md, "Design language"): Ubuntu where the OS has it, + Comfortaa where installed, then the system font. Slice 100 decides what the packaged app + ships once its target list is fixed. */ +@theme { + --font-sans: "Ubuntu", "Comfortaa", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "Ubuntu Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + /* Type scale: Tailwind's default steps, plus the two the chat reads most. */ + --text-ui: 0.9375rem; + --text-ui--line-height: 1.5rem; + --text-meta: 0.75rem; + --text-meta--line-height: 1rem; + /* Radii: panels, fields, pills. daisyUI's --radius-box/field/selector below match them. */ + --radius-panel: 1rem; + --radius-field: 0.75rem; + --radius-pill: 9999px; +} + +/* The dark theme: the default. Deep slate surfaces, a teal primary, violet secondary, amber accent. */ @plugin "daisyui/packages/bundle/daisyui-theme" { name: "dark"; - default: false; + default: true; prefersdark: true; color-scheme: "dark"; - --color-base-100: oklch(30.33% 0.016 252.42); - --color-base-200: oklch(25.26% 0.014 253.1); - --color-base-300: oklch(20.15% 0.012 254.09); - --color-base-content: oklch(97.807% 0.029 256.847); - --color-primary: oklch(58% 0.233 277.117); - --color-primary-content: oklch(96% 0.018 272.314); - --color-secondary: oklch(58% 0.233 277.117); - --color-secondary-content: oklch(96% 0.018 272.314); - --color-accent: oklch(60% 0.25 292.717); - --color-accent-content: oklch(96% 0.016 293.756); - --color-neutral: oklch(37% 0.044 257.287); - --color-neutral-content: oklch(98% 0.003 247.858); - --color-info: oklch(58% 0.158 241.966); - --color-info-content: oklch(97% 0.013 236.62); - --color-success: oklch(60% 0.118 184.704); - --color-success-content: oklch(98% 0.014 180.72); - --color-warning: oklch(66% 0.179 58.318); - --color-warning-content: oklch(98% 0.022 95.277); - --color-error: oklch(58% 0.253 17.585); - --color-error-content: oklch(96% 0.015 12.422); - --radius-selector: 0.25rem; - --radius-field: 0.25rem; - --radius-box: 0.5rem; - --size-selector: 0.21875rem; - --size-field: 0.21875rem; - --border: 1.5px; - --depth: 1; + --color-base-100: oklch(23% 0.016 262); + --color-base-200: oklch(19% 0.015 262); + --color-base-300: oklch(29% 0.018 262); + --color-base-content: oklch(94% 0.008 262); + --color-primary: oklch(75% 0.13 195); + --color-primary-content: oklch(18% 0.03 195); + --color-secondary: oklch(72% 0.12 300); + --color-secondary-content: oklch(18% 0.03 300); + --color-accent: oklch(80% 0.14 80); + --color-accent-content: oklch(22% 0.05 80); + --color-neutral: oklch(33% 0.02 262); + --color-neutral-content: oklch(94% 0.008 262); + --color-info: oklch(72% 0.14 230); + --color-info-content: oklch(18% 0.03 230); + --color-success: oklch(72% 0.15 160); + --color-success-content: oklch(18% 0.03 160); + --color-warning: oklch(78% 0.16 75); + --color-warning-content: oklch(22% 0.05 75); + --color-error: oklch(66% 0.2 22); + --color-error-content: oklch(98% 0.01 22); + --radius-selector: 9999px; + --radius-field: 0.75rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; --noise: 0; } +/* The light theme: the same hues on paper-white surfaces. */ @plugin "daisyui/packages/bundle/daisyui-theme" { name: "light"; - default: true; + default: false; prefersdark: false; color-scheme: "light"; - --color-base-100: oklch(98% 0 0); - --color-base-200: oklch(96% 0.001 286.375); - --color-base-300: oklch(92% 0.004 286.32); - --color-base-content: oklch(21% 0.006 285.885); - --color-primary: oklch(70% 0.213 47.604); - --color-primary-content: oklch(98% 0.016 73.684); - --color-secondary: oklch(55% 0.027 264.364); - --color-secondary-content: oklch(98% 0.002 247.839); - --color-accent: oklch(0% 0 0); - --color-accent-content: oklch(100% 0 0); - --color-neutral: oklch(44% 0.017 285.786); - --color-neutral-content: oklch(98% 0 0); - --color-info: oklch(62% 0.214 259.815); - --color-info-content: oklch(97% 0.014 254.604); - --color-success: oklch(70% 0.14 182.503); - --color-success-content: oklch(98% 0.014 180.72); - --color-warning: oklch(66% 0.179 58.318); - --color-warning-content: oklch(98% 0.022 95.277); - --color-error: oklch(58% 0.253 17.585); - --color-error-content: oklch(96% 0.015 12.422); - --radius-selector: 0.25rem; - --radius-field: 0.25rem; - --radius-box: 0.5rem; - --size-selector: 0.21875rem; - --size-field: 0.21875rem; - --border: 1.5px; - --depth: 1; + --color-base-100: oklch(99% 0.002 262); + --color-base-200: oklch(96% 0.004 262); + --color-base-300: oklch(91% 0.006 262); + --color-base-content: oklch(24% 0.02 262); + --color-primary: oklch(52% 0.12 195); + --color-primary-content: oklch(98% 0.01 195); + --color-secondary: oklch(52% 0.14 300); + --color-secondary-content: oklch(98% 0.01 300); + --color-accent: oklch(62% 0.15 75); + --color-accent-content: oklch(98% 0.01 75); + --color-neutral: oklch(90% 0.006 262); + --color-neutral-content: oklch(24% 0.02 262); + --color-info: oklch(55% 0.16 230); + --color-info-content: oklch(98% 0.01 230); + --color-success: oklch(55% 0.15 160); + --color-success-content: oklch(98% 0.01 160); + --color-warning: oklch(62% 0.16 75); + --color-warning-content: oklch(98% 0.01 75); + --color-error: oklch(58% 0.22 22); + --color-error-content: oklch(98% 0.01 22); + --radius-selector: 9999px; + --radius-field: 0.75rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; --noise: 0; } @@ -104,4 +126,28 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session], [data-phx-teleported-src] { display: contents } -/* This file is for your main application CSS */ +/* Rendered markdown (TrinityWeb.Markdown) inside a message. Preflight strips the browser's + defaults, so the block elements get theirs back here, in the theme's colours. */ +.md > * + * { margin-top: 0.5rem; } +.md p { line-height: 1.6; } +.md h1, .md h2, .md h3 { font-weight: 600; line-height: 1.3; margin-top: 0.75rem; } +.md h1 { font-size: 1.25rem; } +.md h2 { font-size: 1.125rem; } +.md h3 { font-size: 1rem; } +.md ul { list-style: disc; padding-left: 1.25rem; } +.md ol { list-style: decimal; padding-left: 1.25rem; } +.md li + li { margin-top: 0.125rem; } +.md a { color: var(--color-primary); text-decoration: underline; text-underline-offset: 2px; } +.md code { font-family: var(--font-mono); font-size: 0.875em; background: var(--color-base-300); padding: 0.1em 0.35em; border-radius: 0.375rem; } +.md pre { background: var(--color-base-300); border-radius: var(--radius-field); padding: 0.75rem 1rem; overflow-x: auto; } +.md pre code { background: none; padding: 0; font-size: 0.85em; } +.md blockquote { border-left: 3px solid var(--color-secondary); padding-left: 0.75rem; opacity: 0.85; } +.md table { border-collapse: collapse; font-size: 0.9em; } +.md th, .md td { border: 1px solid var(--color-base-300); padding: 0.25rem 0.6rem; text-align: left; } +.md th { background: var(--color-base-300); font-weight: 600; } +.md hr { border: 0; border-top: 1px solid var(--color-base-300); } +.md strong { font-weight: 600; } + +/* The streaming cursor at the end of an in-progress message. */ +.md-streaming::after { content: "▍"; color: var(--color-primary); animation: trinity-blink 1s steps(2) infinite; } +@keyframes trinity-blink { to { opacity: 0; } } diff --git a/assets/js/app.js b/assets/js/app.js index 9456a75..dd02f12 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,13 +27,14 @@ import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/trinity" import topbar from "../vendor/topbar" import { TauriHook } from "../vendor/ex_tauri" +import { Composer, Shortcuts, ScrollToBottom, LocalTime } from "./hooks" const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, - hooks: { TauriHook,...colocatedHooks}, + hooks: { TauriHook, Composer, Shortcuts, ScrollToBottom, LocalTime, ...colocatedHooks}, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/hooks.js b/assets/js/hooks.js new file mode 100644 index 0000000..ac11358 --- /dev/null +++ b/assets/js/hooks.js @@ -0,0 +1,90 @@ +/* SPDX-FileCopyrightText: Sudo Apt Holdings LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ +// The chat's four hooks (slice 013). Each is a few lines and does one thing the server +// cannot: read a key with its modifiers, keep the message list pinned to the bottom, show a +// time in the viewer's zone, or tell Enter from Shift+Enter. + +// Enter sends, Shift+Enter breaks the line; the server clears the box after a send. +export const Composer = { + mounted() { + this.el.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { + e.preventDefault() + this.el.form.requestSubmit() + } + }) + this.el.addEventListener("input", () => this.grow()) + this.handleEvent("composer:clear", () => { + this.el.value = "" + this.grow() + this.el.focus() + }) + this.el.focus() + }, + updated() { + if (!this.el.disabled) this.el.focus() + }, + grow() { + this.el.style.height = "auto" + this.el.style.height = Math.min(this.el.scrollHeight, 192) + "px" + }, +} + +// Ctrl/Cmd+K opens a new session, Esc cancels the turn. Nothing else reaches the server. +export const Shortcuts = { + mounted() { + this.onKey = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault() + this.pushEvent("new_session", {}) + } else if (e.key === "Escape") { + this.pushEvent("cancel", {}) + } + } + window.addEventListener("keydown", this.onKey) + }, + destroyed() { + window.removeEventListener("keydown", this.onKey) + }, +} + +// The message list follows new content unless the reader has scrolled up to read. +export const ScrollToBottom = { + mounted() { + this.pinned = true + this.el.addEventListener("scroll", () => { + const gap = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight + this.pinned = gap < 48 + }) + this.scroll() + this.observer = new MutationObserver(() => this.scroll()) + this.observer.observe(this.el, {childList: true, subtree: true, characterData: true}) + }, + updated() { + this.scroll() + }, + destroyed() { + if (this.observer) this.observer.disconnect() + }, + scroll() { + if (this.pinned) this.el.scrollTop = this.el.scrollHeight + }, +} + +// A