804 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
993442f98a |
feat: the API describes itself, at /api/openapi.json
docs/api-reference.md was hand-written, and by the time anyone checked it was documenting twenty-three endpoints that answer 404 while missing others that exist. That is what hand-written reference material does: it is correct on the day it is written and silently wrong afterwards. A second hand-written document, in YAML this time, would rot the same way. So paths, methods and mount points are read from the Express router stack at request time. They cannot disagree with the app, because they are the app: 186 paths, 215 operations, and — checked — no /learning endpoints, which is what the prose version went on claiming for weeks after that feature was deleted. What introspection cannot know is what an endpoint is *for*. That half lives in src/utils/openapiRoutes.js, keyed by "METHOD /path", and it is the half that rots, so it is the half that is enforced: a Playwright spec fetches the live document and fails when the number of operations without a summary rises above 199 — the debt as measured today. A ratchet, not a target. Adding an endpoint pushes the count over and fails the build; describing one lowers the number. The failure lists the operations by name, so it says what to write. Whether an operation is public is stated per route rather than inferred from middleware. Guessing wrong there is worse in both directions: calling a public endpoint protected hides a hole, and the reverse invites a bug report. The contract spec lives in e2e rather than the unit suite because it needs the whole app mounted, and requiring server.js from node:test pulls in the database pool and hangs the run — that has happened here before. Also: e2e now runs in CI on dev, gated by a shell check inside the step rather than a job-level "if", which this Forgejo dispatches anyway and then kills with "Early termination". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
6b039c4d87 |
test: bring the e2e specs up to date with the app they test
Three specs were asserting screens that no longer exist. They passed for as long as they did only because the e2e stack shared production's database and its configuration; against a clean one they failed honestly. Signing in is a stepped flow now — email, then a choice between a password and an emailed code — so #login-password is in the DOM but hidden until that choice is made. The spec asserted it visible on the landing screen. Replaced with one test for the landing step and a new one that walks the transition, which nothing covered before. The register link is hidden only when registration is disabled. This install has it enabled and invite-gated, so the link shows and the invite field is required; the spec asserted display:none. Connecting Nextcloud by signing in to Nextcloud is now the offered path, with the username and app-password fields folded behind "Use an app password instead". The spec asserted all three visible at once; it now checks the primary path and then opens the fallback. learning-tab.spec.js is deleted and `learning` is out of the smoke tab list — that feature was removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
5a666f5ca5 |
test: e2e runs against its own throwaway database, not production's
The e2e stack shared production's Postgres — same server, same database, same table. Seeded robots sat in `users` beside real clinicians, and anything a test wrote, or a migration under test changed, landed on real data. Nothing about "run the tests" should be able to reach an account belonging to a person. Now it has a Postgres and a Redis of its own, both on tmpfs: created empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one command that recreates the stack, seeds it, runs the browser and leaves the app up at 127.0.0.1:3553 so it can be clicked around in, with the report served at :3554. Two bugs fell out of it immediately, both of which only a database that did not already exist could have found: The schema could not be built from nothing. The entrypoint migrated before the app created its baseline tables, so the first migration failed on saved_encounters not existing. It never showed because every database this has ever run against already had the baseline. Then, one layer down, 1777800000000_generated-images creates a table with a foreign key to learning_content — which the baseline stopped creating when Learning Hub was removed. Restoring into a brand-new database could not have booted. The entrypoint now stands aside when the database is empty and lets the app do it in the order it already gets right, and the foreign key is only created where its target is. All 20 migrations replay from empty, producing the same 23 tables production has. Configuration lives in the database, so a throwaway one starts at defaults — 14 settings against production's 49. That is why every model picker was empty: models.custom did not exist. The tests were right and the environment was incomplete, so the seed now states what the suite depends on, with fictional model ids: a test should not pass because of a setting somebody changed on the live system last week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
38b8fd584a |
fix: the sign-in code email no longer mentions the password
"Your password still works as usual" is not reassurance for everyone who gets this mail. An account created through an invite has no password at all, and one signing in with a code may never use theirs — for them the sentence raises a question rather than settling one. What the paragraph is for is saying that ignoring the mail is safe, and it says that without the second sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
36cb742ce7 |
ci: fix the failing job, split deploy out, and drop the Android build
Three things, one subject: making CI say the truth about this repo.
## The red on every run was ours, not the runners'
Every docker-build run came back success, success, failure — the same
shape for weeks. The failing job was `deploy`, and it was failing to
*not run*:
if: ${{ github.event.inputs.deploy == 'true' }}
On a push there is no github.event.inputs at all. This Forgejo does not
treat that as false and skip; it dispatches the job, the runner cannot
resolve it, and the task ends in "Early termination". The runners were
never at fault, and nothing about them needed changing.
The `'runs-on' key not defined` line is a red herring: the `build` job
prints it too and succeeds. It names the job's *needs* target, not the
job, and the old android-apk workflow used `needs:` happily for months.
Deploy is now its own workflow with only workflow_dispatch — no
condition to evaluate, so nothing can be dispatched by mistake. No job
in either file now carries a job-level `if`. The one conditional left is
a *step* (push to registry), and step conditions are evaluated by the
runner once the job is already running, which is why that one has always
worked.
## dev and main
docker-build now runs on `dev` as well. Both branches prove the same two
things — tests pass, image builds — and only `main` publishes the image,
so nothing on `dev` can be mistaken for something deployable. Deploying
stays a person pressing a button after looking at the change.
CONTRIBUTING.md documents the flow.
## Android
Removed: the mobile/ Capacitor project, docs/mobile-build.md, and the
Android bits of scripts/release.sh. All of it is in git history —
|
||
|
|
ceccd18387 |
docs: take the Learning Hub out of the docs
The feature was removed; the docs still described it as live. Worst of it was api-reference.md, which documented nine /api/learning endpoints and fourteen /api/admin/learning CMS endpoints — routes that answer 404 — plus POST /api/user/webdav-path, whose column was dropped by migration. Anyone reading them was reading fiction. Checked against the running system rather than assumed: no learning table exists, users.webdav_learning_path is gone, generated_image_links is gone, and no route mounts /api/learning or /api/admin/learning. Two things that look like Learning Hub and are not, so they stay: - learningRetrieval.js is live — My Resources uses it. Its settings keep the learning.* names because renaming them would orphan whatever an administrator has already set. retrieval-tuning.md now says so instead of listing the rows under two different feature names. - the moderator role is still assignable. It gated the CMS and now grants nothing; authentication.md says that rather than implying powers it does not have. moderatorMiddleware has no callers left, which is worth removing on its own. auth-admin-learning.md is now auth-admin.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
8ae47feeff |
build: drop the Android APK workflows; CI is the Docker image and the web app
Requested: no more Android building, focus on the Docker app build and the web app. android-apk.yml also ran on every push to every branch, so every commit started an APK build beside the one workflow anyone was watching. Worse, its runner — ped-ai-android-runner — advertises the same forgejo-local label as the general runner, so it was also picking up the Docker build job and dying instantly with "'runs-on' key not defined". That is why docker-build showed a failure next to a success on the same commit. Removed: .forgejo/workflows/android-apk.yml, and the two GitHub APK workflows, which never ran at all — there is no GitHub remote on this repo, only forgejo. The Capacitor project under mobile/ is untouched and still builds by hand; docs/mobile-build.md now says plainly that nothing builds it for you. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
5f738fbe30 |
feat: a theme is shown by a sample deck you download, not a picture
A picture of one slide answers a narrower question than the one a theme picker is asked. What a person wants to know is what a deck will look like in this theme — all of it, at the size it will be shown, with the fonts substituted the way they will be. One rendered slide showed one layout, in content someone then read instead of looking at. So: a sample deck per theme. Every layout the renderer can draw — title, bullets with a sub-point, both two-column forms, table, callout, figure, full-slide figure, section divider, a custom slide with shapes, an arrow and a chart, and a references slide — with filler text throughout. Download it, open it, see the theme. The text is deliberately meaningless. Clinical content in a specimen invites you to read it, and then you are judging the teaching rather than the type; that is what the old croup slide got wrong. No engine. The previous preview needed a Gotenberg round trip and a pdftoppm to produce a PNG, cached on disk because of what it cost, and could fail in ways a missing picture cannot explain. python-pptx builds the file in ~330ms and PowerPoint draws it. The link is a plain anchor, so it is there whether or not anything on the server is well. The figure placeholder rides the same path a generated figure does — attachFigures downgrades a figure slide with no picture to bullets and an image slide to a section, so without it the sample would silently stop showing those two layouts. Verified: all five themes build, 11 slides, the chart is a real chart and the placeholder embeds as real media. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
8602c7bd14 |
fix: the theme preview says what it is doing, and says when it fails
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The box was hidden until an image loaded and hidden again on error, so a preview that failed and a preview that did not exist looked identical — an empty gap under the dropdown. That is what it looked like from the outside, and it cost a diagnosis: the endpoint renders a correct 934x525 PNG in 0.66s when called exactly as the browser calls it, cookie and all, and nothing on the page could have told anyone that. The first view of a theme is a pptx render, a Gotenberg round trip and a pdftoppm — a second or so before anything appears, and then cached on disk. A second of silence reads as a feature that is not there, so it now says it is drawing, and on failure says the look itself is unaffected, which is true: the preview is a picture of the export, not the thing that produces it. img.src is assigned after both handlers. A cached preview can complete before a handler attached below the assignment exists, which would leave the box stuck on "Drawing...". describeTheme already ends by calling it, so the call sitting beside it fired a second identical request on every load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
a505244b97 |
refactor: remove the embedding settings, whose only consumer is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Embeddings existed here for Learning Hub semantic search — the card said so itself. Learning Hub was removed, and nothing took its place: the clinical corpus is embedded by the indexing service, not by this app. What was left was a settings page that configured a model, tested it, reported its dimensions, and fed nothing. src/utils/embeddings.js had exactly one importer, src/routes/adminConfig .js, which used it for the three routes this deletes. Outside those, the only mentions of embedding in the server were a comment and a settings prefix. Gone: the module, its three admin routes, the dimension probe, the Discover & test kind and its two panels, the admin.js block behind them, the embeddings. prefix from both the writable-settings allowlist and the lockdown list (it can no longer be written at all, so locking it says nothing), and docs/embeddings-setup.md, which documented Learning Hub search end to end. 'embedding' stays in NON_CHAT_MODES — that is the filter keeping embedding models out of the chat-model list, and the gateway still serves them. Docs still describe nine /api/learning endpoints that no longer exist, left from the Learning Hub removal. Not touched here; that is its own subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
9df2230479 |
refactor: rebuild the admin settings page around what each card does
The page had grown by accretion: model discovery scattered across five cards with a search box each, one Save writing eight keys from the bottom of a card that also held a second Save for something else, and a banner apologising that a button "applies only the settings above it". Now it reads in groups — Accounts, Models, Assistant & prompts, Site — and every card is a <details> that folds, so Save & Close means something. The rule is that each card saves exactly what it shows, which is what removed the need for the banner. Models is one workflow in three steps. Discover & test has a single search box and a kind switch (chat / image / speech / transcription / embedding); the five discovery calls are unchanged, the switch only decides which one answers. Roster is what has been added, including the image roster, which had no visible list before. Availability is the chat model, the two allowed lists, the per-workflow image settings and the slide reviewer, under one Save. Splitting the eight-key save follows from that rule: Save & Close writes the five retrieval and citation keys; Save availability writes the chat model, both allowed lists, the reviewer and the three image-settings PUTs. No route, request shape or setting key changed. Switching kind clears the results first — a row button would otherwise add an image model to the chat roster. The kind switch dispatches its event through document.defaultView's CustomEvent. jsdom refuses one built from another realm, and the existing announceModelsChanged() has exactly that bug: its event is built from the Node global, dispatchEvent refuses it, and a try/catch swallows the error — so models-changed propagation has only ever been source-grepped, never actually tested. Left alone here to keep this change to one subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
c68e3a6219 |
fix: "Sign in with Nextcloud" opens the sign-in page
The button did nothing. It claimed a blank tab during the click and pointed it at the login URL once the request returned — the standard way around a popup blocker, and it cannot work in this app: helmet sends Cross-Origin-Opener-Policy: same-origin, which severs the handle to that tab the moment it goes cross-origin. Assigning its location was a no-op. A blank tab opened, nothing else happened. The handle was never needed. window.open with 'noopener' asks for none, and a click's user activation outlives the fetch, so the browser does not treat it as a popup. The status line now also carries the sign-in URL as an ordinary link, so there is a way through whatever any particular browser decides about opening windows. Verified the server side against the real Nextcloud first: the flow starts, both returned URLs pass the SSRF guard and the same-host check. The fault was entirely in the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
89e71b3b86 |
fix: stream the assistant's answer a block at a time
Markdown only means anything once a block is finished. Half a table is a row of pipes; half a fence is a stray ```. Re-parsing the whole partial answer every 180ms therefore flickered between a broken parse and the real thing, and the previous answer to that was to abandon markdown past 3500 characters or eight pipe rows and dump raw text into a <pre> that had no CSS at all — inheriting the browser's black monospace default. That is the dark block people saw while a table streamed. Now the text is split at the last finished block — a blank line outside a code fence — and everything before it is rendered once and *appended*. Only the unfinished tail is plain text, styled as prose. Settled content is never re-parsed and never rebuilt, so a diagram or chart that has already drawn is not thrown away by the next token. Two blank lines are not boundaries: the gap inside a loose list, which would render one list as two each restarting at 1, and one inside indented code. Telling the first from the perfectly good boundary between a sentence and the list it introduces takes looking at both sides of the gap, not just ahead. renderEmbeddedBlocks now marks what it has drawn. mermaid.render is async and replaces the element; a second Chart on one canvas throws. Marked before the await, not after, or two frames race the same element. Streaming is a preview: 'done' still renders the whole answer from scratch, so anything transient here is settled by the final pass. That is what makes appending safe. Mutation-tested: removing fence tracking, the list guard, the backward half of that guard, the append, the drawn marker, or the state reset in fillMessageBubble each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
a7132c218e |
feat: a ciphertext now only decrypts in the row it was written for
enc1 said what a value was but not where it belonged. The blob in one user's nextcloud_token would decrypt perfectly if copied onto another user's row, and that account's exports would then go into someone else's storage. Nothing about the ciphertext objected. Adds enc2, which binds a context string — 'users:nextcloud_token:41' — as AES-GCM additional authenticated data. The auth tag covers it, so a moved ciphertext fails to open rather than opening somewhere wrong. The context is authenticated, not encrypted: it is not a secret, it is a claim about location. enc1 is still read, unchanged and forever — every existing row is enc1. Bound rows are distinguishable (isBound), so the Nextcloud token is rebound in place the next time it is successfully used, the same way a legacy plaintext one was already upgraded. That gate moved from isEncrypted to isBound; isEncrypted is true for enc1, so it would have skipped exactly the rows that need rebinding. Bound so far: Nextcloud app passwords, and invite codes — which bind to their own code_hash, since unlike the row id that exists at INSERT time. Decrypting an invite moved into list(), so the cipher and the hash that opens it no longer leave the module together. Mutation-tested: dropping the AAD, dropping the guard, gating on isEncrypted, or dropping a context at a call site each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ba66a80491 |
chore: checkpoint before AAD binding and incremental streaming render
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
191b3b433b | wip: invite codes stay copyable | ||
|
|
46112e1221 |
feat: connect Nextcloud by signing in to Nextcloud
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m7s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Asking someone to find Settings → Security → Create new app password is a poor first run, and it is the step people give up on. Nextcloud has its own answer: Login Flow v2. The person enters their server address, signs in on Nextcloud the way they normally do — SSO, 2FA, a password manager — and Nextcloud generates an app password for this app. We never see their real password. Pasting an app password still works, behind "Use an app password instead". It is the fallback, not the front door. The security of this is all in what is trusted. The remote server chooses both the login URL and the poll endpoint, so both are SSRF-checked and both must be on the host the person actually typed — an endpoint pointing elsewhere would make this a request-forgery gadget aimed at whatever it named. The server Nextcloud reports at the end is re-checked before it is stored. The poll token is a credential, so polling happens server-side and the browser holds only an opaque handle bound to its own account. Flows live in memory with a 20 minute life, matching Nextcloud's own expiry: a login lasts minutes, and a restart mid-flow is a retry rather than a loss. Starting a second flow replaces the first, which is what clicking again means. The tab is opened from the click itself, before the request — opening it after an await is what a popup blocker stops. Removed with Learning Hub: the WebDAV browse path. Its field, its route and its column are gone, since nothing browses Nextcloud any more. nextcloud_folder is a different column and still in use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
24c8d71b7e |
feat: send a resource to your own Nextcloud, as the rendered file
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A .pptx landing in someone's own storage is worth having; a markdown blob is not, and it is not what they would have downloaded. So this renders through exactly the path the download uses — stored deck, its figures, the chosen theme — and PUTs the bytes. The file never travels through the browser. Offered only when a Nextcloud is connected: an action that always fails is worse than one that is not offered. An article offers Word, a deck PowerPoint, and asking for slides from an article is refused with the reason. Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two callers want it and neither should grow its own copy of the WebDAV dance — make the dated folder a segment at a time, PUT, migrate a legacy plaintext token — because it reaches into storage that is not ours and a second slightly different copy is how the two drift. It also replaces a route importing another route. Also: a model that leaves the roster now leaves every list that names it. clinical_assistant.allowed_models and the image roster are advisory copies of the roster, and a stale id there was invisible until someone asked a clinical question and the request failed at the gateway. Removing or disabling a model prunes it; clearing the roster clears them. Re-enabling deliberately does not re-allow it — that is a separate decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
025290d64a |
feat: retire Learning Hub
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources generates better slides than Learning Hub ever did — a typed deck the model fills in, rendered by python-pptx with fit-to-slide text, figures, a vision review and themes, against Learning Hub's markdown-through-pandoc — and the articles and quizzes now live in the quiz app. Keeping a second, weaker generator and a whole CMS beside it was not earning its maintenance. Removed: three routers, the Learning Hub and Content Manager tabs, their components and frontend modules, the five database tables, the WebDAV browser, the content embedding column and its vector index. Content was exported first — every article as markdown plus a full SQL dump of all five tables — to ops-backups/learning-hub-export-*. That export is the restore path; the migration's down() can recreate the shape but never the rows, and says so. Two things this simplifies rather than merely deletes: generated_image_links existed only to record which published content an image appeared in, and it was the sole reason a generated image could be read by someone who did not make it. Images are now owner-only — the visibility rule is one WHERE clause instead of a join across two tables and a published flag. embeddings.js keeps the model discovery the admin panel uses and loses searchSimilar and generateContentEmbedding, which queried a table that no longer exists. Kept deliberately: Nextcloud connect, disconnect and export, which are how a generated note reaches a real filesystem and have nothing to do with Learning Hub; learningRetrieval, which despite its name is the clinical corpus search My Resources depends on; and the pandoc reference deck, still the fallback when the python renderer fails, moved from assets/learning to assets/deck now that the old name misleads. Tests: four Learning-Hub-only files removed, and the individual cases inside shared files that asserted its behaviour. Where a test used a Learning endpoint only as a convenient example — the account-boundary token test, the policy matrix — it now uses one that still exists, so the property it proves is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
59226f2109 |
feat: deck themes — a palette you pick, previewed by the renderer itself
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A deck had exactly one look. The slide vocabulary is structural — bullets, compare, table, callout, figure — and none of it carries a colour, so "make it yellow" had nowhere to land but the image prompts, and produced yellow figures on a blue deck. A theme is a palette and a typeface in assets/deck-themes.json. render_pptx.py rebinds INK, MUTED, ACCENT, RULE and PAPER from it in one place, so every slide builder follows without a line changing in any of them — five themes restyle ten slide types for free. An unusable theme leaves the default standing, because a deck in the wrong colours beats a deck that will not render. The theme rides on the deck, which is already the renderer's spec, so nothing has to thread it through. It is validated against the same catalogue the renderer reads: an id the renderer would ignore is never stored, so a deck cannot claim a look it does not have. PUT /my-resources/:id/theme re-skins a stored deck — a column write, no model call, nothing that can reword a slide — and the next download is in the new colours. Offered in the library only on rows that have a deck; flat markdown has no palette. Previews are rendered by the renderer, one representative compare slide per theme, cached because each costs a pptx render, a Gotenberg round trip and a rasterise. Drawn rather than mocked up: a hand-made swatch drifts the moment a palette or a layout changes, and a preview that is not true is worse than none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
bf4f895f2c |
fix: article uploads are 10 MB, type-checked both ways, and sniffed
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 1m55s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The ceiling was 100 MB per file with ten files allowed at once, and every file is held whole in memory to be parsed — so the old limit let a single request ask for a gigabyte of heap. A source article that size is not a thing anyone uploads here. Now 10 MB, defined once and used by both the multer limit and the post-upload check. The filter accepted `allowed mime OR allowed extension`, so naming a file .pdf was enough on its own, whatever it declared — and the extension is chosen by whoever uploads. Both are required now. Neither of those sees any bytes: multer filters on the headers, before the file has arrived. verifySources() runs once the buffer exists and refuses a file whose contents are not what its type claims, using the same helper as documents, S3 uploads and assistant attachments. It runs before extraction, because an extractor handed a malformed file is where the damage would happen. The CMS screen said 100 MB and listed four of the ten accepted formats; it now says what the server actually does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
bd8e413bc7 |
fix: an assistant attachment must be the image type it claims to be
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The MIME type was taken on trust here. Anything at all could be posted as image/png: it passed the size and base64 checks, was stored in the saved chat, and was handed to a provider as a data URI. Documents and S3 uploads have always been sniffed by fileType.js; this was the one upload path that was not. Now sniffed with the same helper, so there is one idea of what a PNG looks like. A PHP payload, a shell script, an ELF or PE binary, a zip, or a real PDF labelled image/png are all refused with a message that says what is wrong. What this does not claim: bytes hidden after a valid PNG header still make a valid PNG, and no sniffer can promise otherwise. The protection is that the file is never executed and never served as anything but an image. Existing fixtures used buffers of 0x07 as stand-in images, which are correctly refused now. They carry real file headers instead — a fixture should be the thing it claims to be, exactly like a real upload. Also adds the deck theme system: five palettes in assets/deck-themes.json, render_pptx.py rebinding its palette from the theme rather than hardcoding it, the theme carried on the deck and validated against the same catalogue the renderer reads, a picker on the generate form, and PUT /my-resources/:id/theme to re-skin a stored deck with no model call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
cda74e1fe2 |
fix: trim the account-boundary message to what a reader needs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ac1adddd50 |
fix: the account boundary explains itself instead of reading as an SSO failure
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Tapping SSO on a frozen realm did nothing at all — no navigation, no message. The guard is correct: a page bound to one account must not start a login for another. But silence is indistinguishable from a broken button, and it was reported as "single sign on error" when no SSO request had been made; the server logged no OIDC activity because the click never left the page. The button now says what is happening and names the control that clears it. The guard itself is unchanged — same checks, same order, same outcomes. The recovery screen said "your saved credentials no longer match this session", which describes the mechanism rather than the situation. The ordinary cause is signing in as a different account on a device that already held one. It now says that, and says plainly that nothing was lost and no data was mixed — which is the reassurance the safeguard has earned and was not giving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
74aa0c1b89 |
feat: Enter sends in the assistant, and the person chooses
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m23s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Enter made a newline and Ctrl+Enter sent, which is backwards from every chat people use. Enter now sends by default, with a toggle in the composer's + menu to put it back. Two rules hold whatever is chosen, because they are the habits people arrive with and a setting that broke either would be worse than no setting: Shift+Enter is always a newline, Ctrl/Cmd+Enter always sends. Both are checked before the preference, so neither can be switched off. A keystroke during IME composition never sends. Enter accepts a candidate word in Chinese, Japanese and Korean, and on predictive Android keyboards; sending there would cut a sentence off mid-word. Stored per device rather than per account, because a keyboard preference belongs to the keyboard: Enter-to-send suits a desk and usually does not suit a phone, where Enter is how you get a second line. Unset, it defaults by device class — send where there is a real keyboard, newline on a touch screen — and a blocked localStorage falls through to that default rather than throwing. The composer's tooltip says which key sends, where someone already looks when they wonder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
b1e039d834 |
fix: a newly added model reaches every picker, including the user-facing ones
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The first pass at this covered the two admin cards. It missed the pickers that matter most: the per-tab model selectors in app.js, which every clinical tab uses, and the My Resources model dropdown. Both were filled once at page load, so a model added in Admin was still invisible where people actually choose one. app.js's boot fetch is now a named loadModelList() that also runs on models-changed; My Resources re-runs loadOptions(), which is the same call that decides whether the model row is shown at all. Both rebuilds keep a choice already made. These selects can be rebuilt while someone is halfway through a form, and silently moving them off the model they picked would be worse than not refreshing. Verified against a mutation: removing the app.js listener fails the test that says every picker listens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7305443243 |
fix: the last run's illustration no longer sits under an empty form
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
app.js loads a tab's component once and marks it data-loaded, so the DOM survives leaving and returning. Nothing cleared the result area, and an illustration from a previous generation stayed on screen under a blank form as though it were output for a topic nobody had typed. A full page refresh rebuilt the component and cleared it, which is why it looked like a leak that fixed itself. Cleared at the start of a generation, at the start of a modification, and on re-entering the tab — not on the first visit, where there is nothing to clear. Covers the illustration area, the searches line, the image-failure line and the status text. Also documented what a modification can actually change. The deck vocabulary is structural — bullets, compare, table, callout, figure, image, section, title — and none of those carries a colour; the palette is fixed in render_pptx.py and the model never sees it. So "make it yellow" lands on the only field that takes a colour, image_prompt, and yellow figures appear on an otherwise blue deck. That is not modify reaching only the images; it is the model using the one lever the schema gives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
31e634e0ce |
fix: a model added in Admin now appears everywhere models are chosen
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Adding a discovered model refreshed exactly one dropdown — the default-model one, in the same card. Every other picker had been filled when the admin tab loaded, behind a guard that makes its loader run once per visit, so the Clinical Assistant chat model, the allowed-models list, the slide reviewer and the image-model selects all kept the roster they were given. The model was genuinely added; it simply could not be selected until the page was reloaded, which reads as the add having failed. Every mutation of the roster — add, remove, clear-all, enable/disable — now dispatches `models-changed`, and the cards that list models listen and refetch. The event carries no payload: a listener re-reads the list itself, so there is one source of truth rather than a copy to keep in step. Same pattern as the existing `assistant-image-roster` event. Each listener clears its own guard before re-running, and returns early if a load is already in flight. The assistant loader keeps unsaved drafts, so re-running it costs nothing but a refreshed set of options. The add toast said "now select it as default and click Set Default" — advice that only made sense when the default dropdown was the one thing that updated. Verified against a mutation: removing the guard reset fails the propagation test, because the listener then fires into a loader that returns early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ff2b2bc9d3 |
chore: Kitten and Supertonic retired; Kokoro is the local voice
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Both were removed from the gateway in favour of Kokoro, so the app's curated voice lists for them went too. Kokoro's own check no longer has to exclude them — it names its own voices and the list is open, so it accepts anything that is not another family's voice. Voices stay curated per family rather than discovered. Models are discovered from /model/info; voices are not, because no TTS provider exposes its voice list consistently, and a list guessed from a model id is how a picker ends up offering a voice the provider rejects. Tests that pinned the retired voices now pin the families that remain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fce05a2749 |
fix: the active library view is unmistakable, and a leading question cannot mislead the vision model
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Documents and Images were two buttons of the same weight, which reads as two buttons rather than as a current view and an alternative. The active one now carries the surface, the weight and a hairline — keyed off aria-selected, so the visible highlight and what a screen reader announces cannot disagree. The look_at_image question is written by a model that has not seen the image, from what the user said, so it can presume something that is not there — "describe this rash" about a photograph of a drug chart. Answering the presumption would send it back as fact. The seeing model is now told the question may assume something absent, and to say so first and describe what is actually there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1f06a19007 |
feat: a text-only model can ask a model that can see; and the image regex is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**The regex is gone.** The route ran a pattern over the user's message and enqueued an image from the answer text when the model had not called the tool. It was a compatibility path for models without tool calling and it did more harm than good: it decided in English only, it could not see the conversation, and "image summary" fell through it while reading as an obvious image request to the model itself — which was measured, not assumed. A second and worse decision-maker sitting behind the first. Whether a message deserves a picture is now the model's call, made from the tool description, which is the only place it ever belonged. **Lending eyes.** The same shape, for a different capability. When someone attaches a photograph and the chat model cannot accept image input, the attachment was either refused by the provider or silently dropped — an answer about a picture nobody had looked at, which is worse than a refusal. The chat model is now offered look_at_image beside the image tool and decides when to use it. The attachment goes to clinical_assistant.vision_model, whose description comes back as a tool result, and the chat model answers in its own voice with its own sources. Only the seeing is delegated; the clinical reasoning stays with the model an administrator chose. The seeing model is told to report and not to diagnose, because it has a picture and no context and an opinion from it would carry weight it has not earned. Delegation triggers only on an explicit supports_vision: false from the gateway. An unknown is left alone — most of a roster reports nothing, and treating silence as blindness would route good models through a detour. The capability lookup moved to its own module, is cached for five minutes because it runs on exactly the requests that are already slowest, and is never inferred from the model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the other gateway helpers live. The new setting is guarded like the slide reviewer: a model the gateway calls text-only cannot be saved as the one that looks at images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
03621752e8 |
feat: image fallback chains for every workflow, and a library worth looking at
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**Fallbacks.** One image model meant a refusal, a rate limit or a model the gateway had since dropped ended as a missing picture. Every workflow now tries its model, then each fallback in order, stopping at the first that produces an image. Primary plus two, capped: each hop is a paid request, and a chain long enough to need a cap is long enough to surprise someone. My Resources previously had no fallback at all — only the Clinical Assistant did, and only one. That is backwards: a missing figure is most visible in a deck, where it leaves a hole in a slide. The retry rule is now a classifier that says *why*, rather than a boolean. Transient faults, a 404 for a model the gateway does not have, and a content refusal all move to the next model — a refusal because policy is a vendor decision, not a fact about the request. 401/403 stop immediately (one gateway, one set of credentials, the next model fails identically), as do 413 and any other 4xx, which are malformed everywhere. Refusals are recognised from the message: no provider sends a machine-readable reason and the status varies. Each hop re-leases the job, so a chain cannot outlive its claim and let a second worker repeat the same paid work, and the row records the model actually being paid for so a picture made by the third model is not attributed to the first. The old singular `fallback_image_model` is still read, so an existing configuration keeps working without anyone re-entering it. **Library.** Documents/Images tabs in My Resources, with a real grid: fixed aspect tiles so the rows line up whatever shape the pictures are, a source badge on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails land, and a lightbox that closes on Escape or the backdrop and restores focus. Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete behind :hover would put it out of reach on touch and keyboard. Downloads go through privateImageBlob rather than a bare `<a download href>`: a mobile client's session is a bearer token an anchor cannot send, and these assets are served no-store on purpose. The gallery lives in My Resources only. Assistant images appear in it, which was the point; the assistant page does not grow a gallery of its own, and a test asserts no assistant module lists the endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
259b4858be |
feat: an image library — every picture this account has made
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A figure outlives the deck it was drawn for: the deck gets replaced, the diagram is still good. Until now a generated image could only be seen in the resource it was made for, and there was no way to find one again or to delete it. Library → Images is a grid of every finished image the account owns, across all three workflows, newest first. GET /api/generated-images is scoped by owner_id in the statement rather than filtered after, returns only finished jobs — an unfinished one is a broken frame in a gallery — and pages by keyset, because a gallery that grows while you scroll repeats or skips rows under OFFSET. Most of this already existed. Thumbnails were already rendered at 256 and 640 by sharp and already served by ?w=, with their own checksum so the client's tamper check passes on a derived copy; hydrateImage already handles auth, the account boundary and caching. The tiles ask for the 256px preview, so thirty of them cost a few kB each rather than thirty full-size downloads, and the prompt is decrypted for the caption because it is the only human-readable label an image has. Deleting needed new work. The storage interface had no remove at all, so a delete that dropped the row would have left the object and both previews in the bucket — paid for, and still readable by anything with credentials. Storage now removes all three keys, and the bytes go before the row: a row pointing at a missing object is a broken image in a gallery, while an object without its row is only wasted space, and unreachable storage refuses the delete outright rather than reporting a success that left the picture behind. THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift, and the drift that matters is a width that gets written and never deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
83206e907c |
fix: a second deck reused the first deck's pictures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The image job key was the request-body hash plus the figure's position — slide index for a deck, reply index for a document. Two generations from the same form produce the same body hash, so figure 4 of the second generation collided with figure 4 of the first on the unique (owner, workflow, idempotency_key). The constraint handed back the existing job, and the new deck displayed the old deck's artwork. The decks are not even the same length, so the reused picture could land on a slide about something else entirely. Keyed on what is being drawn now: the body hash stays, so submitting the identical request twice still dedupes rather than billing twice, and a hash of the prompt (plus layout and shape) is what makes two different pictures two different jobs. Same fix in deckBuild and resourceImages. Also split fileLog out of logger. logger requires the database at module load, so importing it to record a diagnostic pulls in a connection pool — wrong on its own terms, and it hung the whole test suite when imageTool started logging its refusals: a unit test that never touches a database inherited an open pool handle and never exited. logger.file now delegates to fileLog, so there is still one implementation of where a line goes and how it is redacted. With that in place, every image-tool refusal is recorded durably. There are five of them, they want five different fixes, and until now none of them left any trace once the container was replaced. Verified against a mutation: restoring the index-based key fails two of the four collision tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
94f320f140 |
fix: an empty answer from the model was the one failure never caught
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The Clinical Assistant answered "image summary" with a zero-length string, saved that as the turn, and drew it as an empty bubble. No error was raised, nothing was logged, and the empty turn stayed in the conversation history where it degrades every answer after it. Confirmed by decrypting the saved chat: the final assistant turn is content length 0. The cause is one line. finalizeAssistantAnswer only regenerates an answer that shouldRegenerateTruncatedAnswer flags, and that function opens with `if (!answer) return false` — there is no dangling conjunction to detect in a zero-length string, so empty was classified as "not truncated" and returned as a result. Every other failure mode had a path; this one had none. An empty answer is now asked for once more — a model returning nothing is usually transient — and if it comes back empty again it raises 502 `empty_answer`, which both callers already turn into a visible error. Whitespace counts as empty, and so does an answer that strips to nothing. Without a callAI to retry with it still raises rather than returning empty. Verified against a mutation: removing the new branch fails five of the six tests. This is separate from the image question in the same request. No image job was created, because "image summary" does not match the text fallback pattern — which requires a verb (create/generate/draw/…) before the noun — and the model did not call the tool. That is left alone for now: the request is genuinely ambiguous, and guessing at it is how an assistant starts making pictures nobody asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
4dcd0b4fc7 |
docs: the generation comment still said "one pass, on generation only"
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m52s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Modifying now reviews its own result, so that line described the rule as it was a commit ago. Also says what the generation pass can actually see: the deck is rendered there without its figures, because they were enqueued a few lines earlier and are still being drawn, so it judges the space a figure will occupy rather than the artwork that lands in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e244ee5240 |
feat: modifying a deck can see it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people actually ask for unanswerable: "that slide is too crowded", "the diagram is in the wrong place", "this one looks empty" are facts about the rendered page, not about the JSON. When a vision model is configured, modifying now renders the current deck — with its figures, unlike the review pass, which runs while they are still being drawn — and hands the model one image per slide alongside the JSON. Same pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF, pdftoppm, capped at MAX_SLIDES. The vision model then does the editing, which is a second and separately measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the deck unchanged for "make it better" — the echo reported yesterday — while openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo even without sight. A model the author picks explicitly still wins over both. The result is rendered and reviewed again. Generation-only was the old rule, on the reasoning that refining is a text edit; it is not. The edit is made against how the deck looked before it, so a slide that gains two bullets only overflows once it is rendered again. The reviewer may reposition but is held to the same words, so a verification pass cannot quietly undo what was just asked for. Whether an instruction achieved anything is judged on the model's edit, before the reviewer runs, or a reviewer nudging a slide into two columns would mask an instruction that did nothing. Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a render that fails — each falls through to editing blind, and a test covers each of those paths. Verified against two mutations: keeping the text model when images are attached, and dropping the verification pass, each fail exactly one test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
973f9d554f |
feat: a text-only model cannot be set as the slide reviewer
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The reviewer is shown rendered images of the deck, so a text-only model there fails on every generation — at request time, long after the one moment an administrator could have picked differently. Nothing checked it. Saving my_resources.review_model now asks the gateway what it reports for that model and refuses only an explicit supports_vision === false. Three answers, not two. Most of this roster carries no supports_vision at all (every openrouter-* entry here), and refusing unknowns would block the reviewer this deployment already runs on. An unreachable gateway is not evidence about a model either, so it never blocks the save. Empty means review is off and skips the lookup entirely. The capability is read from the gateway, never inferred from the model id. Verified against a mutation: relaxing `canSee === false` to `canSee !== true` fails the two tests that say unknown must stay allowed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
491a2b0811 |
fix: the My Resources diagnostics survive a deploy
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
logRefine writes the one line that answers "did that modification change anything" — path, before and after size, CHANGED=yes/no, figures, model, instruction. It went to console, so it lived in the container's stdout and was destroyed the next time the container was recreated. That cost a diagnosis today: a modification came back unchanged, the user asked why, and the evidence had already been deleted by a deploy. The deck-fallback warnings and the deck-vocabulary gaps had the same problem, and those exist specifically to be read later — the vocabulary gaps are meant to show which shapes to build next, which is a question about weeks, not about one container. All of them now go through logger, which writes the dated file in the scribe-logs volume and ships to Loki when it is configured, and carries the event as structured data rather than only as a formatted string. console.error is left alone: those are failures, and logger.error already echoes to the console. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
c9b6d6b3c9 |
docs: deploy.sh is the deploy path, and it was documented nowhere
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m22s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
scripts/deploy.sh landed on 2026-09-11 as "a deploy you can repeat, and prove afterwards". It moves the PED_AI_IMAGE pin, waits for health, reads /api/build to find out which revision is actually serving, and rolls back when that is not the revision asked for. Its own comment says it: "Healthy is not the same as running what was asked for." No doc mentioned it. Both deployment.md and DEVELOPMENT.md instead showed `build-image.sh` then `docker compose up -d --no-build` then `curl /api/health` — a sequence that moves no pin and proves no revision. Following it deployed nothing four times in one session, eventually reverting the app by 31 commits and removing a feature, which was then reported as a bug. Both docs now lead with deploy.sh and say why `up` by hand is not a deploy. Also replaced yesterday's build-image.sh warning, which told the operator to `sed` the pin themselves. That reimplemented, badly, one of the three things deploy.sh already does — and it was written while auditing these very files without noticing the script was there. It now prints the deploy.sh line to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
739286b53b |
fix: warn when the built image is not the one a deploy will start
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
`docker compose up` starts whatever PED_AI_IMAGE in .env names, and
build-image.sh does not move that pin. A pin left from an earlier deploy
therefore starts the older image while every signal reports success: the build
completes, `up` says the container started, and /api/health returns {ok:true}
from the wrong revision.
This is not hypothetical. The pin here had been sitting on a revision from two
hours before the Modify card was added, so a rebuild-and-restart rolled My
Resources back 31 commits and removed the feature. The missing card was then
reported as a new bug, and three deploys in this session had in fact deployed
nothing.
build-image.sh now compares the pin to the revision it just built and, when they
differ, prints the pin, says that `up` will start it instead, and gives the
command to move it. It does not correct the pin: naming a revision is also how a
deliberate rollback is done, so this is said rather than silently overridden.
Both deployment docs now check /api/build against `git rev-parse HEAD` after
starting, because /api/health passing only proves a container is up, not that it
is the one you built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
07d1c541a0 |
fix: a deck the model fumbles once is asked for again, not abandoned
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A presentation is generated as a typed deck — the model chooses each slide's layout — and that deck is what scripts/render_pptx.py renders. When the reply did not parse as a deck, the route fell straight back to asking for markdown, and slideSpec.build() then inferred slides from it. Both paths go through python-pptx, but the fallback's layout is guessed from heading and list structure rather than chosen, so everything lands as title-and-bullets. Measured on the stored library: since decks landed, 7 of 8 generations produced one and 1 did not. Models are stochastic, so one unlucky reply was costing the whole layout. It now asks a second time with the same prompt before giving up. The fallback was also invisible. It warned to the console, where the person who would simply have generated again could not see it, so they kept the plainer deck without knowing a better one was one click away. The response now carries deckFallback and the UI says it came out as plain slides, and why. Fixed the reason heuristic while adding tests for it: truncation was claimed for any reply not ending in "}", which is every prose refusal. It is now only claimed for a reply that began as JSON and stopped. The four generate tests run the handler. Verified against a mutation: removing the retry fails tests 6 and 7 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e69eb9a9f7 |
fix: modifying a presentation failed whenever illustration was ticked
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
existingDeck was declared below the branch that reads it, so `var` hoisting made it undefined there. With illustration on, a deck modification therefore appended the *markdown* instruction — "Returning the markdown is still required; a tool call is not a substitute for it" — to a prompt whose body asked for deck JSON. The model was told to produce two different artifacts in one reply, the reply parsed as neither, and deckBuild.parse returned null, so the handler answered 502 "That change could not be applied." Moved the declaration above its first reader. Same class of fault as the savedFigureIds one, in the same file. Two things made it hard to see, both fixed: - The library row read created_at, so a modification that did apply left the visible timestamp on the generation time. That timestamp is what led to "modification doesn't work" — it was the only signal available, and it was reading the wrong column. Rows now show the modified time when there is one. - A model can also return the document back unchanged. That was logged server-side and answered "Applied. Download it to see the result", which sent people to download an identical file. The response now carries `unchanged` and the UI says so, keeping the instruction in the box so it can be reworded. Also surfaced has_deck on the library list: 28 of 38 stored presentations have no deck and go through the weaker flat-markdown path, and nothing in the UI distinguished them. They now read "plain text, no slide layout". test/my-resources-refine.test.js runs the handler rather than reading it, since all three faults were invisible to source reading. Verified against a mutation: putting the declaration back where it was fails test 1 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
cfb8aab77d |
fix: /api/health/detailed reported a text-to-speech provider that does not exist
The endpoint answered from environment variables of its own rather than from the speech code, so ELEVENLABS_API_KEY being set made it report tts: 'elevenlabs'. There is no ElevenLabs path in ttsProvider.js — getTTSProvider() only ever returns 'litellm' or 'none' — so the one endpoint an operator checks to find out what speech is doing was reporting a provider this app cannot use. Likewise whisper: OPENAI_API_KEY, which stopped describing STT when transcription moved behind the gateway. Both now ask getSTTProvider() and getTTSProvider(), the same functions the routes ask, so the answer cannot drift from behaviour again. Removed the two other ElevenLabs leftovers: the FAQ told users their notes were read aloud by "Google, OpenAI, or ElevenLabs", and a TTS test used 'elevenlabs' as its sample value for the passthrough of TTS_PROVIDER, which implied the provider was supported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
67e416c6d9 |
docs: merge the duplicate pairs and correct them against the running app
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m38s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:
- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
of browser-Whisper paths that must stay removed)
Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:
- Google Vertex was still documented as a provider across nine files. The SDK
is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
the rows rendered as text.
Filled the gaps the audit exposed:
- database.md was missing 12 of 29 tables, including user_resources,
personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
features were absent. Added an endpoint index covering Clinical Assistant,
My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.
Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.
Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
5614a436be |
fix: a deck asked for a figure and never got one — three causes, one symptom
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
"Include a diagram" produced decks with no picture. Three separate faults, each hiding the next, found by generating the same deck after fixing each one. First, deck generation ran on the default 4000-token budget. A deck's JSON is several times the size of the prose it holds, so a long deck came back truncated, failed to parse, and fell back to markdown — which has no way to request a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram ...]" as its first bullet. Deck generation now gets room, and the fallback says how the reply failed: empty, cut short at N characters, or not a deck. Second, the figure request sat inside the layout vocabulary, one line among forty, and the model passed over it. It goes last now, after the author's own instructions — the same placement lesson the image tool taught earlier. Third, and the one that actually mattered: image_prompt is only read on the figure and image types, so an image_prompt on a bullets slide was dropped in silence. The instruction said "add image_prompt to N slides" without saying which types carry one. It now names them, and a misplaced request is honoured rather than discarded — a slide with words becomes a figure, one without becomes a full-slide image. Image markup is also stripped wherever text enters a slide, on both paths: a described figure is not a figure, and a bullet of raw markdown is worse than no bullet. Verified end to end after: the same request produced a deck with one figure, the job completed, and the exported pptx carries one embedded image across 21 slides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
2c3fbbcf37 |
fix: decks were falling back to markdown, so no figure could ever be requested
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
A deck's JSON is several times the size of the prose it holds, and generation used the default 4000-token budget — raised for refine and for slide review, but never here. A sixteen-slide deck ran past it, came back truncated, failed to parse, and fell back to markdown. Markdown has no way to ask for a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram — "Neonate with rash" → ...]" as its first bullet, above the steps it was meant to illustrate. That is why no generated deck was arriving with an image. Deck generation now gets room for a deck. The fallback also says how the reply failed — empty, cut short at N characters, or simply not a deck — because those want different fixes and "not usable" covered all three. Image markup is stripped wherever text enters a slide, on both the deck and markdown paths, since a described figure is not a figure and a bullet of raw markdown is worse than no bullet. The model is also told plainly: if a figure is wanted say so with image_prompt, and if that is not on offer, write the slide without one rather than describing the picture you would have drawn. Separately, the Documentation list showed ARCHITECTURE, CLINICAL_ASSISTANT, DEVELOPMENT, MODULE_CONVENTIONS and SCALING shouting in caps with underscores intact: the label builder replaced hyphens but not underscores, and uppercased the first letter of each word rather than normalising the case, so a SHOUTING_FILENAME stayed shouting. It now reads "Clinical Assistant", keeps acronyms as acronyms (AI, API, OpenID, LiteLLM) and leaves joining words lower. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
9b2cb339a1 |
test(e2e): drive My Resources through the browser, and assert what it sends
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The unit tests for this feature read source files and assert patterns. They prove the code says the right thing, not that the screen does it, and nothing exercised the browser at all — so a mismatch between what the form sends and what the route reads passed all of them. Three real bugs shipped through that gap in one session: a modification that updated the markdown but not the deck, generation that failed whenever the slide reviewer was off, and a figure generated for a slide that never referenced it. Every one was found by driving the running server by hand. So these assert the request bodies, not only the rendering: that Generate sends topic, kind, slideCount, refinement, model and all four options as the strings the route compares against; that unticking the library sends 'false' rather than omitting the field, which the route would read as on; and that Modify posts to the right resource with every source option. Plus the screen's own behaviour — availability gating on both cards, the illustration hint switching on and staying off once overruled, the bounded searchable library, the two different empty states, an article never being offered as slides, a local refusal that spends no round trip, and a refused modification surfacing its reason. Fourteen tests, both viewports. The API is stubbed. This is the contract between the screen and the route, and stubbing keeps it fast, free and deterministic. Proven to catch regressions rather than merely pass: renaming useCorpus in the form failed two tests, breaking the availability gating failed one, and truncating the modify picker failed another. Two flakes of my own were fixed rather than retried. openTab slept 400ms for the library and picker instead of waiting for them, which made Modify report "nothing to modify yet" under load. And the console-error guard failed on net::ERR_ABORTED and net::ERR_NETWORK_CHANGED — a request in flight when the context closes, and the host network reconfiguring under a browser that runs on it. Both are the harness, not the page: anything genuinely failing carries a status code and is still caught. Five consecutive clean full runs after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
3ec65a91f6 |
fix: a figure asked for while modifying a deck now belongs to a slide
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Ticking "Add illustrations" on Modify offered the image tool regardless of what was being edited. The tool returns job ids and has no way to place them, which is fine for markdown — there is nowhere to put a figure in markdown anyway — and wrong for a deck, where figures are placed by a slide declaring them. So modifying a deck with illustrations on generated a figure, paid for it, recorded it against the resource, and referenced it from nothing. Measured: one figure recorded, zero referenced by a slide, and absent from the export. Deck mode now asks the revised deck to declare its figures and draws them with the same drawFigures() generation uses, so each one belongs to the slide that wanted it. Slides that already have a figure keep it. The tool path stays for markdown resources, where it is the only option. Verified: the same modification now records one figure, one slide references it, and the exported deck embeds one image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
012346528c |
fix: generation stopped working whenever the slide reviewer was switched off
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 51s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m35s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
savedFigureIds was declared inside the review branch, so with no reviewer
configured — the default, and what everyone is running — it was undefined by the
time the INSERT stringified it. JSON.stringify(undefined) is not a string, the
column is NOT NULL, and every generation failed with "Generation failed". `var`
is function-scoped, so nothing complained until the database did.
This is the second bug of exactly this shape in this file, so the test asserts
position rather than presence: the value must be declared before both the review
and the insert read it.
Found by the logging added in the same change, which is the other half of this
commit. Every modification now says what it did:
[my-resources] refine id=29 path=deck outcome=applied 13→14 slides changed=yes
[my-resources] refine id=37 path=markdown outcome=applied 2635→3018 chars changed=yes
CHANGED=no is warn-level and deliberately shouty, because that is the failure
worth catching: the response says success either way, the row updates, and the
download is identical — which is exactly how the deck bug went unnoticed. A
refusal logs its reason. ped_ai_resource_refine_total{path,outcome} counts the
same thing over time, so "did that modification do anything" is answerable
without watching logs live.
Verified across every path rather than the one that was broken: a deck
presentation modified and exported to both pptx and docx carries the change; a
legacy presentation with no stored deck still takes the markdown path and
carries it; an article generates, modifies and exports; and a presentation
generates with the reviewer off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|