SurfSense/docs/architecture/studio.md
Anish Sarkar 42e3641a42 docs: accept ADR 0066 and fold the web pages proposal into the architecture
The proposal is built, so it is deleted and ADR 0066 is accepted with the decisions made while building it: light pages, SVG and CSS only, a Markdown-only skill. agent.md and studio.md list the page tests, and comments and docs that named only Word and PDF as refinable name written web pages too.
2026-10-22 00:13:35 +05:30

75 KiB
Raw Permalink Blame History

Studio

Studio turns a selection of sources into a deliverable: a Markdown document, a Word document, slides, a spreadsheet, a web page, a PDF, a mind map, flashcards, a quiz, a podcast, an image or an infographic. The user picks a format and sources and may add a prompt; a background job has the selected model write the content, through the API's model route (admission), and the app renders it, as a file for ten of the twelve formats. Word and PDF take one of two paths by the selected model, and PPTX and XLSX by a model-written script that runs in the script runner. Each result is an ordinary ARTIFACT document, searchable and citable like any source, with a sidecar row for the format and the bytes. The agent's Word documents, PDFs, decks and workbooks are Studio artifacts too: script documents, each version a script that SurfSense keeps and runs in a separate process. Studio's own Word documents and PDFs keep their spec the same way, and Refine rewrites it into the next version; the agent's are edited in its chat. A revised copy is the user's own Word, Excel or PowerPoint file with the agent's edits applied by an engine, Word's as tracked changes; the user's file is never written.

Code: modules/artifacts/, worker/studio/, worker/document_script/, frontend/src/features/studio/ Decisions: ADR 0003, ADR 0008, ADR 0010, ADR 0028, ADR 0039, ADR 0049

The tables, artifacts and artifact_files, are in data-model.md.

The job seam

service.py::create_artifact_job(session, workspace, payload, *, tool_call_id=None) is the one entry for every format Studio drafts. The REST route passes no tool_call_id, and neither does the agent's surfsense_create_artifact, whose call reaches SurfSense without one (agent). Explicit or agentic, same code. A script document has its own entry, below.

  1. Look the format up in the catalog (422 if unknown) and check that it is available (409 if not, whose detail is {message, code}: the listing's reason and its unavailable_code). Regenerate answers the same 409, except for a script document, which runs as stored and needs no model (below), and a file a tool made or converted.
  2. Resolve the sources: at least one (422, code no_sources), all in this workspace (422) and all ready (409; a scope with nothing ready because its sources are still indexing is sources_indexing). A refusal with a code sends detail as {message, code}, like the 409 of step 1; a source named by id that is in another workspace or still indexing has none yet.
  3. Validate the options with the format's validate_options hook. Only podcast has one; a bad brief is a 422.
  4. Create the ARTIFACT document, pending and titled with the format's label, and its artifacts sidecar with generation 1, created_by_tool_call_id, and the source ids, prompt and options in artifact_metadata.
  5. Commit, then enqueue studio_job(artifact_id).

Status lives on the document, not the sidecar, because ADR 0003 keeps indexing state on the Document. The worker moves it from pending to processing and then to ready or failed; a cancel makes it cancelled.

Formats

The catalog is a tuple of twelve Format rows in formats.py, computed into availability on each request and never stored.

Key Label Needs Worker path Stored file
summary Markdown text_gen content/summary/ .md, the body as a file
docx Word text_gen office/ .docx
pptx Slides text_gen office/ .pptx
xlsx Spreadsheet text_gen office/ .xlsx
html Web page text_gen web/html/ .html
pdf PDF text_gen office/ .pdf
mindmap Mind map text_gen content/mindmap/ none; the outline is the body
flashcards Flashcards text_gen content/flashcards/ deck JSON
quiz Quiz text_gen content/quiz/ quiz JSON
podcast Podcast text_gen, audio_gen media/audio/podcast/ MP3 audio, or WAV without a working encoder
image Image image_gen, text_gen media/visual/image/ the image
infographic Infographic image_gen, text_gen media/visual/infographic/ the image
  • requires_model_types is a tuple in the order the pipeline's render() takes its models. A format is available when every required model type has a selection; otherwise the reason names every missing one in a fixed reading order: "Needs a chat model", "Needs an image model", "Needs an audio model", or two of them joined, as "Needs a chat model and an audio model". Naming only the first missing type made selecting it look like the gate moving to the other.
  • podcast runs with an audio_gen model on this computer or on a server. The format list and the brief read the model's voices without calling it, so neither needs the server's host allowed; the job does. Opening a podcast brief without an audio model answers 409 with "Needs an audio model" and the code needs_audio, in the same {message, code} detail as a refused create. The app ships Kokoro and chooses it at startup when no audio model is chosen, so that answer needs the choice cleared since the last start, or a build without audio.cpp (local-models/selection.md).
  • Which formats are available is the server's answer to what is selected, so the panel asks again whenever the chat selection changes or the settings dialog closes, since the image model is chosen inside settings and nothing else reports it.
  • An image selection can resolve to the bundled sd-server as well as to a remote connection, so needing an image model does not mean needing a key or a network.
  • tests/unit/worker/test_studio_job_router.py asserts that job_router.py names every catalog key and nothing else, that each key has a pipeline, and that each pipeline takes its models, the sources, the prompt and, for a format with options, the options.

The builder rule, and where it is broken

The rule (ADR 0010): the model writes structured content, and a committed builder for that format renders it. Nothing the model wrote executes on the user's machine, so a bad reply is malformed JSON rather than arbitrary code, and there is no sandbox, no Docker and no receipt to verify. That is what lets Studio fit an offline app with weak local models. ADR 0028 has since allowed model-written code to run on the user's machine without a sandbox, and ADR 0039 runs a model-written document script without asking in a process of its own; the builder rule still describes the eight formats below.

Eight formats follow it:

  • summary, labelled Markdown: the model's markdown is the body, titled by its first # heading, and is kept as a .md file the panel offers to download. The prompt asks for the document the user's prompt describes, and a summary of the sources when there is none. One made before the file was kept has none until it is regenerated.
  • mindmap: JSON nodes, at most 10 branches, become a markdown outline, an H1 over nested bullets, which Markmap draws on the client and which stays readable as text.
  • flashcards: JSON cards, written under a schema (schema.py), at most 20, become a deck JSON file and a markdown body.
  • quiz: JSON questions, written under a schema (schema.py), at most 10, each kept only with exactly four options and an answer among them, become a quiz JSON file and a markdown body.
  • html: two paths by the selected model, chosen by writes_script() as for Word and PDF (ADR 0066).
    • Below the bar: a JSON title and sections, at most 10. Every value is HTML-escaped into a fixed template, so the page cannot carry a script.
    • At the bar: the model writes the whole page in one reply, kept as the version's spec so Refine can rewrite it, (written/page.py). Its tier prompt carries the agent's design guide whole (skills/surfsense-html/design.md), the no-network contract and the user's focus. The page is taken by its own markers, from its doctype (else its <html> tag) to its last </html>, never by a code fence, since a page may hold fenced samples of its own; it is stored with the no-network <meta> after its doctype, and checked: a reply with no page, one cut off before </html>, or one over 300 KB with its <meta>, goes back to the model with the reason, three attempts in all, then fails the job with "The web page failed: …"; it never falls back to the template. The title is the page's <title>, else its first heading, else the prompt or the sources; the body is the page's readable text (page_text.py).
    • The agent uses neither: it writes its own page (agent).
    • Tests: test_studio_written_page.py holds the split, the guide in the prompt, a page out of a code fence, a cut-off or oversized page sent back, and the third failure failing the job; test_refine_web_page.py holds a written page's spec, its Refine into the next version, a template page refused, and the guide counted against the window. The viewer's sandbox and policy are in html-viewer.test.tsx.
  • podcast: the model outlines the episode from the brief, then drafts it segment by segment. Every segment sends the same one-line system prompt and the sources, and its whole draft prompt after them, because llama-server reuses a prompt only up to its first changed token. Measured through the drafting code on Qwen3 1.7B with about 6,200 tokens of sources: with the draft prompt first, every segment re-read the sources; after them, segments 2 to 4 read about 230 tokens each. The draft prompt goes after them whole: split, with what the segments share left ahead of the sources, the same model wrote half the dialogue, in long lines. Each reply is capped at 12 tokens per word of the segment's target, and never under a planned 250-word segment's worth. Uncapped, Qwen3 1.7B looped on a 225-word segment until its 40,960-token window was full, and the JSON retry, which replays the failed reply, could not fit. The target is the outline's own guess: Qwen3 1.7B once gave a segment 20 words, wrote past them, and a 240-token cap ended both replies mid-JSON. The chosen audio model voices every line through audio.cpp's server, and the transcript is the body.
  • image: the model writes a title and an image prompt, and the image model paints the prompt.
  • infographic: the model writes a factual brief (a title, a summary and up to 8 sections of label, value and detail), and the image model paints a prompt built from it in a fixed sketchnote style.

Two formats break it. PPTX and XLSX go through office/: the tier prompt asks the model to "write one standalone Python script" for the format's library (python-pptx or xlsxwriter), with a SKILL.md of authoring guidance beside each format, to the runner's contract: start with a # title: comment and save the file at OUTPUT_PATH. office/pipeline.py runs the reply through the runner, in its own process, never with exec() in the worker's. A failing script goes back to the model with its error and the traceback's end, for up to three attempts, and its failure reason does not start Script error: , as for Word and PDF. The title is the # title: comment, else the prompt, the sources or the format; the body is the file's own text, read as a script document's is. No spec is kept: a deck or workbook Studio drafts has no versions and no Refine.

Word and PDF

Studio's Word and PDF buttons take one of two paths, chosen by office/document/strength.py::writes_script(). The model's measured level decides (model capabilities): agent and agent_limited write a script, studio_only writes Markdown. A model not measured keeps the provisional rule: one served from this computer counts as small and a remote one as strong. Local is the selection's Fingerprint.local: llama.cpp, or a connection whose host is loopback, such as Ollama or LM Studio. A server on another computer of the network, or one addressed as 0.0.0.0, counts as remote, as it does for egress.

  • A small model writes Markdown in one call (markdown_path.py), and committed builders render it under ADR 0010's rule (office/markdown/). markdown-it-py parses CommonMark with GFM tables and strikethrough; raw HTML stays text. The Word builder starts from python-docx's default template with its Heading, List Bullet and List Number (three levels), Table Grid, Quote and Caption styles. The PDF builder uses ReportLab's sample stylesheet on A4 with 2 cm margins and escapes every character ReportLab's paragraph markup would read. A link keeps its target only for http, https and mailto.
    • Figures: an image whose target is image:<source id>-<n> places that source figure (documents) at its own size or the text width, with its alt text as the caption below. Any other target, or a figure the selected sources do not hold, becomes its caption in italics.
    • Charts: a fenced chart block holds {type, title, labels, series: [{name, values}]}, with type bar, line or pie, one number per label, and one series for a pie. matplotlib draws it through its object API, with no pyplot state. A block that does not parse becomes a note; one that parses but does not match becomes a table of its data with a note saying why.
    • The body is the Markdown itself, and the title its first # line.
  • A strong model writes a script (script_path.py) to the runner's contract: save at OUTPUT_PATH, open a figure at IMAGES_DIR/<name>.png, start with a # title: comment. It runs through the runner, never exec(), with the selected sources' figures it names. A failing script goes back to the model with its error and the traceback's end, for up to three attempts. Its failure reason does not start Script error: , because Retry, which asks the model again, is the fix here. The title is the # title: comment, else the file's first heading.
  • Both prompts list the figures of the sources that reached the model (grounded_document_ids) with their captions, each cut at 200 characters, so the model can place them (figure_list.py). A source whose figures were never extracted, or a Word source whose figures pass has not run yet (documents), offers none this time, and asking queues its figures pass.
  • Either way the spec is kept, as {"kind": "markdown" | "python", "text", "format", "images"} with the figures it places, and the draft becomes v1 of its own document under the same version keys as a script document, so the version switcher shows it. artifact_metadata["recipe"] says how the spec was made: {"kind": "draft"}, or {"kind": "refine", "instruction", "base"}. Regenerate repeats the recipe: a draft re-resolves its recorded source_scope (Grounding) and drafts from it again, and a refine asks for the same rewrite again, which needs none of its sources; a script document has no recipe, so Regenerate runs its script as stored. A refine's Regenerate first runs Refine's window check against the model selected now, and refuses with the same reason when the rewrite no longer fits.

Refine

POST /artifacts/{id}/refine with {instruction} (1 to 2,000 characters) rewrites the spec of a Word document or PDF Studio made, or of a web page a model at the bar wrote, into its next version (studio_documents/).

  • It refuses an artifact keeping no spec (not_refinable), one still being made (already_generating), one whose last run failed or was stopped (version_unfinished; it keeps the spec it had, but the panel no longer shows that version as made), and a script document the agent wrote, which keeps no recipe and is edited in its chat (agent_made) (07, decision 8). Each is a 409 whose detail is {message, code}: the sentence saying so, and the code the interface translates. It also refuses a spec the selected model's window cannot rewrite in one call (409, with a reason naming the model, the document's size and the window, and no code yet), and answers without a usable chat model with the {message, code} detail a refused create sends, needs_chat. The window is the one the agent's limits come from: what llama-server loaded a local model with, which may load it, or what the remote catalog records, else 32,768 tokens. The check prices text at 4 characters a token: the prompt's 1,500 tokens, the list of figures (for a web page, the whole design guide its prompt carries instead), the instruction, and the spec read once and written again a quarter longer (fits.py).
  • It creates the next version, pending, with the base's title, prompt, resolved source_document_ids and grounded_document_ids, whose figures the rewrite may place and the window check counts, and the refine recipe, and answers 202 with it. Its spec_kind is the base's while it waits.
  • The job sends the whole spec, fenced, and the instruction in one call, gets the whole revised spec back, and renders it as a draft of that kind would. A web page's spec is the page as the model wrote it, {"kind": "html", "text", "format": "html"}, without the policy SurfSense stores ahead of it; its rewrite is checked as a written draft is, three attempts in one conversation (written/page.py). Nothing is patched or diffed. The rewritten spec is kept only when it renders: a failed refine keeps none, is not retried by Huey, and leaves every earlier version as it was.
  • The agent's surfsense_read_document and surfsense_render_document refuse any version of a document Studio made, with "This document was made in Studio: refine it there, or make a new document." (07, decision 8).

Studio uses no Electron printToPDF, which only draws the agent's Word and PowerPoint previews and its sources' pages (agent), and there is no ffmpeg in surfsense_local. A PDF is ReportLab through the builder, a Studio script or a script document.

Script documents

The agent makes a Word document, a PDF, a PowerPoint deck or an Excel workbook by writing a Python script that SurfSense runs and keeps (agent). Studio's own Word and PDF keep a spec too (above), and a strong model's script runs through the same runner. Studio's own PowerPoint and Excel scripts run through it too, but keep no spec: they draft through office/ as above.

The spec and the versions. script_documents/service.py::create_script_document() refuses a title outside 1 to 200 characters, a script outside 1 to 200,000, a format other than docx, pdf, pptx or xlsx, an image name no source in the workspace has, a base artifact outside the workspace, made in Studio (it keeps a recipe), or not a script document of the same format, and a template that does not fit, each in a sentence the agent reads. A template (template_source_id) is a source of the workspace whose original file is a .docx for a Word document or a .pptx for a deck; PDF and Excel take none (template_source.py). A next version whose call names none keeps its base's, checked again, and a base's template that has since gone is refused with what to do instead. The template is recorded among source_document_ids. It creates the ARTIFACT document, pending and titled as asked, and the artifacts row with that format and this artifact_metadata:

{
  "spec": {"kind": "python", "text": "<the script>", "format": "docx", "images": ["12-1"], "template_source_id": 9},
  "version": {"root": 40, "number": 2, "parent": 40},
  "source_document_ids": [12],
  "prompt": null
}

Each version is its own artifact. v1 is its own root; a version made from another takes that one's root and the root's highest number plus one, and names it as its parent. Nothing is migrated: the keys live in artifact_metadata, and the artifact_versions table the file agent proposal designs replaces them later. The service commits, then enqueues studio_job. ArtifactRead and the detail carry version ({root_id, number, parent_id}) and spec_kind, both null for an artifact that keeps no spec.

The job. When the spec's kind is python and it keeps no Studio recipe, as every agent's document of the four formats, the job skips job_router and asks no model (script_document/pipeline.py). It copies the PNG of each named source figure (documents) or analysis chart, which it finds in the version's thread's outputs/analysis/ (script_images.py), and the template's original when the spec names one, and runs the script through the runner with OUTPUT_PATH named document.<format>. A template whose source has gone fails the run with that reason. A file that does not open as its format fails the run ("the script wrote a file that is not a valid .docx"). The body is the file's text: a Word file's paragraphs and table rows in order, with headings marked, through python-docx; a deck's slides in order, each headed ## Slide <n>: <title>, with its text, table rows, [picture] and [chart: <title>] markers and speaker notes, through python-pptx (deck_text.py); a workbook's summary through openpyxl, read-only: its sheet and chart counts, each sheet's used range and first 10 rows of up to 8 columns, and up to 20 formulas as Sheet!B5: =SUM(B2:B4), at most 6,000 characters (workbook_summary.py); or a PDF's text layer through pypdfium2, under the process's one pdfium lock (shared/pdfium.py), since pdfium is not thread-safe and four jobs run at once. The stored file is named after the title, with the characters no system accepts dropped. Status, cancellation and persisting are shared with every other job.

Recalculated workbooks. A workbook with formulas goes through recalculation.py after its run, because Studio's viewer and every preview show cached values, and xlsxwriter caches 0 and openpyxl none. With Office support on (office pack), LibreOffice recalculates a copy within 45 seconds and cached_values.py writes each formula's value into the workbook the script wrote, at the XML level: only <v> and t of formula cells, and of the other cells an array formula fills, change, and calcPr gets fullCalcOnLoad="1", so the formulas stay and Excel recalculates on open. LibreOffice's own re-written workbook is never kept. A formula LibreOffice computed to an error, or gave no value, is left blank rather than keeping the script's stale 0. The summary's rows then show those values in the formula cells, the formulas still listed below. The version's metadata records recalculation: {by, formulas, left_blank, reason}: by names LibreOffice and its version, or is null with the reason the values were not recalculated ("Office support is off.", LibreOffice busy or out of time, or a failure, which never fails the version); a workbook without formulas records null. Without Office support the file is delivered as the script wrote it. The render result reads the record (agent).

No retry. A failed script document, the agent's and so without a recipe, is never retried by Huey, whatever failed: running the same script fails the same way, and its agent reads the failure and renders the fix as the next version, which a retry turning the old one ready would race. Its reason is Script error: , the error and as many of the traceback's last lines as fit in 500 characters; a failure outside the script, such as the embedder, keeps its own reason without that start, so the agent renders the same script again instead of rewriting it (agent). Regenerate on a ready one runs the same script again.

The runner (document_script/run.py) runs one script in its own process, because only a process can be stopped at its limit (ADR 0039). The agent's analyses run through it too (below):

  • The folder. A fresh <data>/tmp/document-scripts/<uuid>/ holds script.py, images/<name>.png and, for a template, a copy of the source's file as template.docx or template.pptx, so nothing the script does reaches the source; it is removed afterwards. A worker killed mid-run leaves its folder, so the Studio worker removes every one when it starts, before it takes a job. The script SurfSense stored is the spec, so a script that rewrites its own file changes nothing kept.
  • The process. The worker in its --run-document-script <folder> mode, which loads no queue, database or settings: the frozen worker binary when packaged, worker.py on the backend's Python in development. The child changes into the folder and runs script.py as __main__; a failure prints the traceback without the runner's own frames and exits 1.
  • Its environment is built, not inherited: PATH, plus SYSTEMROOT, WINDIR, TEMP and TMP on Windows or HOME and LANG elsewhere, then OUTPUT_PATH, IMAGES_DIR, TEMPLATE_PATH only when the run has a template, MPLBACKEND=Agg, MPLCONFIGDIR inside the folder, PYTHONUTF8=1 and PYTHONDONTWRITEBYTECODE=1. No key, secret or OPENCODE_* variable reaches it.
  • The limit is 120 seconds, and a cancelled job stops its script within a second. A cancel check that finds the database locked past SQLite's busy wait skips that look rather than fail a script with nothing wrong. Either way the script and everything it started are killed: on Windows a job object holds them from the start and dies with the worker too; elsewhere the script leads its own process group, which it kills when the worker's end of its stdin closes.
  • The result is the file's bytes, or one line the agent can act on ("SyntaxError: …", "timed out after 120 s", "the script wrote no file at OUTPUT_PATH") and the last 30 lines of the traceback. The worker keeps only the last 64 KiB of the script's stderr, which a runaway script could fill with gigabytes. Paths in the run folder are written relative to it, anywhere in that stderr: a warning's, a frame's, or one in an exception message, which on Windows has its backslashes doubled. Its stdout is drained too, so a script that prints a lot never blocks; a document script's is dropped.

Analysis runs. surfsense_analyze_data (agent) queues analyse_data on the Studio queue at priority 100, ahead of Studio's own jobs, dropped if not started within 60 seconds, and waits for its Huey result (data_analysis/task.py). It runs in the Studio worker because the packaged worker is the binary that ships pandas. analysis_run.py gives the script a run folder with input/, holding a read-only copy of each source's original file under its own name (a second file of the same name gains [<id>]) and manifest.json, which lists each copy's file, document_id and title, and an empty output/; the environment has INPUT_DIR and OUTPUT_DIR in place of OUTPUT_PATH and IMAGES_DIR, the rest as above. It keeps the first 64 KiB of stdout and counts the rest. On success the files at the top of output/ move into the thread's outputs/analysis/<run>/ (kept_outputs.py): only .csv, .tsv, .png, .jpg, .jpeg, .svg, .xlsx, .json and .txt, never Markdown, which opencode could read as instructions, at most 20, each at most 25 MB, named with letters, digits, _ and - and at most 40 characters before the suffix. Anything else is named as not kept. A failed run keeps nothing. The folder goes afterwards, its read-only copies made writable first.

The libraries a script may use are the worker's: python-docx, ReportLab, python-pptx, xlsxwriter, openpyxl, matplotlib, Pillow, numpy and pandas (packaging). openpyxl and pandas, which Docling also brings, are named in pyproject.toml and frozen by name in bundling/worker.spec.

Converted PDFs

surfsense_convert_document (agent) makes a PDF artifact whose metadata holds conversion: {artifact_id, document_id}, version as v1 of its own document, and, for a source, source_document_ids naming it (converted_documents/). The job takes it before any other path: it finds the file again, a ready Word or PowerPoint version's own file or a source's .docx or .pptx original, and fails in a sentence when either is gone; commits; then LibreOffice converts a copy within 120 seconds (converted_document/pipeline.py). The body is the PDF's text layer, as a script PDF's is. With Office support turned off since, the job fails with "Office support is off, so nothing can convert this file to PDF." A failed conversion is not retried by Huey, since the tool waits on it; Retry and Regenerate convert the same file again. renders_as_stored() counts a conversion, so Regenerate needs no chat model and re-resolves no sources: a PDF of an artifact has none. The PDF keeps no script, so it cannot be continued with artifact_id; the agent changes the Word version or deck and converts again.

In Studio. The list shows each document once, as its newest version with a "v3" badge, and opens the newest version that finished while the next one is still being made (artifact-versions.ts). When the newest version failed and an earlier one is ready, the row stands for that ready version, with a muted mark whose tooltip names the failed one (artifact-list.tsx). The viewer has a version switcher, and a newer version of the open document opens itself when it appears. A version its script failed, whose reason starts Script error: , offers no Retry and no toast, and its alert's tooltip says to ask the agent; one that failed for any other reason, such as the app closing mid-run, offers Retry, from the row's menu as "Retry v4" when an earlier version is ready (can-retry.ts). Deleting the document deletes every version, after a dialog that counts them, and is refused while one is being made. None of this depends on the format: a deck or workbook the agent made lists, switches and retries as a Word document does, in the Slides and Spreadsheet viewers. An artifact without a version looks as it always has.

Revised copies

The agent edits the user's own .docx, .xlsx, .xlsm or .pptx source through surfsense_revise_document (agent). The result is a new artifact derived from the source, never the source itself (the file agent proposal, decisions 11 and 12), and each later edit is its next version.

The versions. revised_copies/service.py makes each one as a script document's is: an ARTIFACT document, pending, and an artifacts row in the format the suffix picks (docx, xlsx for .xlsx and .xlsm, pptx), with version and source_document_ids as above, spec: null, and a revision record (revision.py):

"revision": {
  "derived_from_document_id": 42,
  "source_name": "MSA_Acme.docx",
  "source_sha256": "…",
  "base_number": 1,
  "action": "edit",
  "operations": [{"op": "replace_text", "quote": "within 30 days", "text": "within 45 days"}],
  "report": {"schema": "revise-report/1", "saved": true, "ops": [], "applied": 1, "refused": 0, "skipped": 0, "must_tell_user": [], "input_sha256": "…", "output_sha256": "…"},
  "counts": {"changes": 2, "comments": 0},
  "internal_comment_ids": [],
  "clean": {"storage_key": "…/clean.docx", "size_bytes": 1, "checksum_sha256": "…"},
  "external": null
}

v1 is titled <source stem> (revised) and starts from the source's original file; source_sha256 is the file as the tool checked the edits against it, and base_number is null. A next version starts from the newest ready version, whichever version was named, and is refused while any version of the copy is still being made (versions.py). action is edit, or accept_all and reject_all for the panel's buttons.

The engines (revised_copies/engines/) are pure functions over files, written from ECMA-376: no database, no model and no Office. Each parses the package with lxml with entity resolution, DTD loading and network access off, refuses a part with a DOCTYPE or a damaged compressed stream, and caps every part at 64 MiB decompressed, the package at 512 MiB and 10,000 entries. Operations apply in order and all or nothing: one refused operation skips the rest and saves nothing, and a run that changes nothing saves nothing. Each returns a report of every operation as {code, values} with an English sentence. Word writes every change as a tracked change by "SurfSense", a comment where asked, and accepts, rejects, counts and strips internal comments; Excel writes cell values and formulas into the sheet XML alone (a formula whose brackets or quotes do not pair up is refused, since Excel would drop it in a repair), leaving charts, drawings and every other part byte for byte, and marks the workbook to recalculate on open; PowerPoint replaces text in place and deletes or duplicates slides. Quotes are matched against the text as it reads now, within one paragraph, and must match once.

The job. _generate takes a version with a revision before any other path but a conversion (revised_copy/pipeline.py): it finds the input in a short read, commits, and copies the input into a scratch folder, so nothing is written beside the user's file. v1 fails with "The source file changed on disk after the edits were checked. Ask again…" when the copy's SHA-256 is not source_sha256, and when the source's file is gone. The engine runs on the copy with no transaction open; a report that saved nothing fails the version with its first refused operation. A Word version then gets its counts (w:ins, w:del, w:moveFrom and w:moveTo in the body, and comments), external when any of its comments is internal (those comments removed), and clean, every change accepted and no comments; its indexed body is the clean file's text, so search finds the wording as it now reads. A workbook's body is its summary and a deck's its slide text, as a script document's. After persist, clean.docx and external.docx are written beside the primary in the artifact's folder and recorded in revision, with the report and counts (record.py). A failed revised copy is never retried by Huey: the same edits on the same file fail the same way. Regenerate and Retry apply the stored edits to the same input again and ask no model.

Downloads. "With changes" serves external when the version has internal comments, and nothing when that file is gone, never the primary that holds them; else the primary; "Clean" serves clean, Word only. Each is named <source stem> (<suffix> v<n>).<ext>, the suffix being the interface's translated "revised" or "clean", English when none is passed, with the characters no file system takes dropped, a _ before a Windows device name, and at most 255 bytes (downloads.py).

In Studio. The artifact panel shows "Revised copy of MSA_Acme.docx" and "7 tracked changes, 2 comments", or for a workbook or deck "4 changes in this version"; Accept all and Reject all for a Word copy with changes, on its newest ready version only (a decision starts from that one), disabled while a version is being made, which post the route and open the new version once ready; and the two downloads in place of the file's own, on a ready version only: a version being made, failed or cancelled has no file of its own (revised-copy-bar.tsx, revised-copy-downloads.tsx). The Word viewer and the agent's page previews draw tracked changes, insertions underlined and deletions struck through.

Files a tool made

The agent's PDF tools make their file themselves (agent), as the agent writes its web pages (agent), so made_file.py::keep_made_file() stores it before any job runs: it refuses a title outside 1 to 200 characters and a workspace with no embedding model chosen, creates the pending ARTIFACT document and the artifacts row with its chat_thread_id, writes the bytes to the artifact's folder as primary.<format> with their artifact_files row, named after the title, commits, and enqueues studio_job. Its artifact_metadata:

{
  "made_by": {"tool": "pdf_pages", "operation": "extract", "pages": "1-3"},
  "derived_from": {"document_ids": [12], "artifact_ids": [40]},
  "source_document_ids": [12],
  "prompt": null
}

A PDF tool's file keeps no spec and no version, so it lists as a document of its own and no render continues it. A saved page is the exception: add_made_file() stores it without committing, and saved_page.py adds the version keys a script document has before the commit, so its versions list together; made_by is {"tool": "save_page", "path": "outputs/<name>.html"}. renders_as_stored() counts it, so the job asks no model and Regenerate needs none: the job reads the kept file's text as a script document's is read, for a PDF, Word file, deck or workbook, or a page's readable text as Markdown (page_text.py), and persists the same bytes (made_file/pipeline.py). A kept file that is missing or does not open fails the job, never retried, with "The kept file does not open as a PDF."

Voicing a podcast

providers/audiocpp/ voices a podcast with the audio_gen model, an audio.cpp build installed in the audio folder (local-models/catalog.md):

  • The voices are the model's roster, from its manifest entry: each voice with the languages it speaks, one for a Kokoro or Kitten voice and all 31 for a Supertonic voice. The brief's language list, A to Z by the name shown in the interface language, and each speaker's voice picker come from it, the picker grouped into female and male voices. The brief opens in American English where a voice speaks it, else in any English, else in the roster's first language, and a remembered brief the chosen model cannot voice falls back to that.
  • Memory is checked before anything loads, twice. The worker compares the operating system's available memory (MemAvailable on Linux, free plus inactive pages on macOS, GlobalMemoryStatusEx on Windows) with the model's peak measured while voicing with every chunk of text full, from its entry, plus 1 GiB, and refuses short of it: "Voicing needs about 3.5 GB free; this computer has 1.8 GB. Supertonic 3 needs about 1.6 GB." The second sentence names the first other curated audio model, in the manifest's order, that would fit, and is left out when none would. It checks first, before the chat model drafts anything, so a machine that cannot voice the episode does not spend minutes writing it. Short there, it gives up the text runtime through the API (the image job's hold), which lets running replies finish and unloads the chat model, checks again, and lets it go for drafting, because voicing frees that memory anyway: on 16 GB, Qwen3 1.7B resident left 3.3 GB free and refused Kokoro's 3.5 before a word was drafted. At voicing it gives the runtime up again and holds it until the last turn is voiced, so a chat sent meanwhile waits instead of loading the chat model back into memory voicing needs. The hold also unloads the speech recognition model and starts no recording's window until it ends, while dictation goes on behind its own memory check (admission). The router reloads the chat model on the next request after either. Each check waits up to 10 seconds for memory the app is giving back before it refuses, because a starting podcast ends sd-server's idle minutes but Electron stops it only on its next 5-second poll (the image path). audio.cpp's own guard counts only the file it reads.
  • One speech request per turn, with the turn's voice. The brief's language goes in the request only for a voice that speaks several; a Kokoro voice's name already fixes its language. The turns are joined with 0.35 s between them.
  • The episode is MP3: the joined 16-bit WAV is encoded in-process by LAME through lameenc, at 64 kbit/s per channel and the model's own channels and rate, about a sixth of the WAV (encoded_episode.py). The podcast pipeline encodes whatever WAV a voice returns, local or a server's, so a voice's synthesize stays WAV for every other caller, such as Settings' voice check, which plays it as it came. No ffmpeg and nothing on the user's PATH is used. Where lameenc cannot be imported, as in a checkout that skipped it, or its encode fails, the episode stays WAV, which the viewer's <audio> plays the same: the voices are paid for by then, so the episode is kept rather than failed and voiced again.
  • A turn the server fails ends the episode with the server's own words: "audio.cpp could not voice turn 3 of 40: unknown Kokoro voice id: nobody". The server was reached, so this is not "The model could not be reached", which stays for a server that does not answer.
  • The model is unloaded when voicing ends, with POST /v1/tasks/unload_all_models, success or failure, so its memory is back before the chat model's next job. The server's five-minute idle unload is the backstop.

On a server

providers/openai_compatible/speech.py voices a podcast with an audio_gen model chosen from a connection:

  • The voices are the server's, or the user's, never guessed. The OpenAI API has no route that lists voices and models.dev carries none, so the server is asked GET {base}/audio/voices, Kokoro-FastAPI's route (voice_list.py), held for the process and never stored. A server that lists none, as OpenAI, Groq and OpenRouter do, takes the voices the user adds in Settings › Audio generation models, inside that server's section under its model in use, and in onboarding's server path alike: each is voiced once before it is kept, under voices in the selection's settings, which is cleared when the slot takes another model. For OpenRouter alone the section links the model's page, whose "Request fields" name its voices; no other provider publishes them at an address the app can build. It opens in the OS browser, which the app allows for OpenRouter pages of that shape only (egress.md). One slice, llm/voices/, answers which voices the chosen audio model offers, local or server, for the brief and Settings alike. The server is asked only once its host is allowed, and only after the request's transaction has ended, so a slow server never holds the database; the format list never asks.
  • Nothing says which languages a server's voice speaks: not the API, not /audio/voices, not models.dev. So a voice is heard, in Settings and in the Try dialog, saying a line in the interface's language, the one the user likeliest writes in, and the brief offers a fixed list of script languages. A voice that cannot speak the chosen language reads it anyway, badly: no server reports it as an error.
  • The brief reads like the local one. It adds one line, "Voiced by {model} on {server}. Each line is billed by the provider.", and otherwise keeps the local labels; only its lists differ, the voices one plain list with no gender the server did not state. With no voices yet, the brief shows only a Set up voices button, which opens Settings, and Generate is held. The brief response carries voices_source (local, server or saved) and voiced_by.
  • No voice is checked before drafting. A listed voice came from the server and an added one was heard when it was added, so the brief's own check, that each speaker's voice is one of them, is what stands between the user and a voice the server refuses.
  • Only OpenAI's /audio/speech is spoken. ElevenLabs, Gemini and other providers with their own speech APIs are reached through a gateway that speaks it for them, OpenRouter or a LiteLLM proxy, rather than through an adapter per provider here.
  • One POST /audio/speech per turn, asking for wav, because only WAV takes a pause between speakers. When the first line's wav is refused, the episode asks for mp3 instead: OpenRouter takes only mp3 or pcm, and its pcm states no sample rate. The format is read from the bytes, not from what was asked. WAV turns join as audio.cpp's do, and a reply holding several WAVs back to back is one turn. MP3 turns join frame to frame without a pause, each turn's ID3 tag and Xing frame dropped so a player reads the whole episode's length. Anything else ends the episode with "the server answered, but not as WAV or MP3". A WAV episode is then encoded as MP3 as audio.cpp's is; an MP3 one is kept as the server sent it.
  • Nothing is checked for memory, since the server spends it.
  • A failure is not retried. A refused turn, an unreachable server or an unreadable answer ends the episode with the server's own words, and the job is not retried, because a retry would draft and bill the whole episode again.

Grounding

shared/gather.py loads the selected documents' markdown, and shared/generate.py sends it as one user message, each document under its title as a heading, after the format's system prompt. A format that calls the model several times over the same sources puts what differs per call after them, in the same message (after_sources). The budget is 24,000 characters unless the caller passes budget_chars.

  • A selection that fits is sent whole, in selection order, and nothing is searched.
  • A larger one gives every document an even share of the budget. A document shorter than its share is sent whole, and what it leaves goes to the others, so no selected document is left out.
  • With a prompt, each share holds that document's passages that best match it, found by one retrieve() call scoped to the selection (search.md). They are kept in reading order, with [...] where text between them was skipped. A document with no match, and every document when there is no prompt, contributes its start.
  • When the even share would fall below 1,500 characters (MIN_SHARE_CHARS, about one chunk), cutting every document to a sliver says nothing, so one retrieve() over the selection takes its best passages for the prompt, else the format's default_focus, up to the budget. Only documents holding a chosen passage are sent.
  • The job records grounded_document_ids in artifact_metadata: what reached the model, so the panel can say "Grounded on 14 of 212 sources".
  • Markdown and mind map read a document's shape, Markdown because with no prompt it summarises, so they always take each document from its start (Grounding.WHOLE on the format in formats.py). The other formats take passages.
  • The search embeds the prompt, which loads the embedding model in the Studio worker, as persisting an artifact already does.

Each format keeps its prompts as markdown beside its code, one file per model tier, loaded for the selected model's tier (local-models/selection.md). The user's prompt joins the system prompt as a "Focus on:" line.

Layout of worker/studio/

worker/studio/
├── job.py            one job: status, sources, models, render, persist
├── job_router.py     a format key to its render()
├── shared/           gather, generate, persist, artifact (Source, Built), text parsing
├── content/          summary, mindmap, flashcards, quiz
├── office/           docx, pptx, xlsx, pdf: a spec and SKILL.md each, plus the script prompt pptx and xlsx use
│   ├── document/     Word and PDF: the strength rule, the Markdown and script paths, Refine
│   └── markdown/     the Markdown builders: parse, Word, PDF, chart
├── script_document/  a script document's run through worker/document_script/, and its text
├── revised_copy/     a revised copy's version: an engine on a copy of its input, its Word files
├── made_file/        a file a tool made: its text read, the bytes kept as they are
├── web/html/
└── media/
    ├── audio/podcast/    outline, draft, roster
    └── visual/           image, infographic

Every pipeline returns a Built: a title, the markdown that is always the indexed body, and an optional primary (and preview) with its MIME type and filename. Adding a format is a folder with a render, a case in job_router.py and a row in formats.py. Frontend entries below customize its translated label, icon, tooltip and viewer; an unknown key still appears with the server's label, a file icon, the generic tooltip and the document viewer.

Jobs

  • studio_job runs on the studio Huey queue in huey.db with retries=1. worker-studio drains it with four threads, because the work mostly waits on a model (ADR 0008).
  • A job (job.py) marks the document processing unless it was cancelled, gathers the sources, resolves one model per required model type, and commits before rendering, so no write lock is held across a generation that can take minutes. It checks for a cancel before and after rendering.
  • persist.py chunks and embeds the body with the ingest code, with no transaction open, then sets the document's title and markdown and indexes that body, so the artifact is searchable and citable. If the format has a file, it clears the artifact's folder and file rows and writes the file named by its role, recording its size and SHA-256. No pipeline writes a preview yet.
  • The document is created without a dedup_key, so it is never deduplicated against another document.
  • A failure rolls back and writes failed with a reason cut to 500 characters: the error's first line, or for an HTTP error its whole message after "The model could not be reached: ". The job is then re-raised for Huey's one retry, except an image error, since the endpoint may already have generated, and billed, an image, a podcast refused for memory, since a retry would draft the episode again and refuse again, any failure of a script document (above) of a converted PDF (above) or of a revised copy (above), and any failure of a refine (Refine), which Retry asks again.
  • Cancel marks the document cancelled and revokes a queued copy. A running job stops at its next check. During a model call it checks every second, while tokens stream, while the request waits for admission and while the model is still reading the prompt, and hangs up; closing the request leaves the line, or closes the API's stream to the model, which stops llama-server within 1.5 seconds, measured through the router (generate.py). A podcast checks before voicing each turn, so the rest of the episode is never sent, and an office job checks before each attempt, so a cancelled job asks the model for no retry. The check is a context variable (shared/cancellation.py), in shared/ because the audio.cpp provider reads it too.
  • Every Studio model call turns thinking off. Measured on Qwen3 1.7B: with it on, a mindmap over a 12,000-token prompt thought past 15,000 tokens without answering, holding a runtime slot all the while.
  • Every text call goes through the API's model route, for every model, local or remote: the API resolves the model the job started with, checks egress, and on the local runtime admits it as background work, behind chat (admission). Errors reach the job as the types a direct call raises, so its retries and its one-line reason are unchanged. The job's panel stays processing while it waits.
  • A job the app quit in the middle of is failed with interrupted when the app closed when the Studio worker next starts, before it takes a job (interrupted_documents.py): Huey dropped the job as it started it, so nothing else would end it, and Regenerate would refuse it forever.
  • Regenerate refuses a job still pending or processing, rechecks availability (not for a script document, a made file or a converted PDF), resets the document to pending, clears the error, increments generation and re-enqueues with the same sources, prompt and options. The new run replaces the files and the indexed body; the artifact and its document keep their ids.
  • Each transition the Studio worker makes sends an artifacts event keyed by artifact id; the API's own changes, to pending and cancelled, send none. The artifact list reloads on each event, and so does an open artifact, and the list still refetches every 10 seconds while a job is running, in case a notice was lost (overview.md).

Routes

Method Path Does
GET /workspaces/{workspace_id}/studio/formats the catalog, each format with available, unavailable_reason and unavailable_code, the same reason as a code the interface translates
POST /workspaces/{workspace_id}/studio/jobs {format, source_scope?, document_ids?, prompt?, options?}; a source_scope is resolved on the server and recorded in artifact_metadata beside the resolved source_document_ids, and wins over document_ids; nothing ready is 422 with the code no_sources, or 409 sources_indexing while the scope's sources are still indexing; 201 with the artifact
GET /workspaces/{workspace_id}/studio/podcast/brief the podcast brief to review before submitting, the model's voices (null when they are typed) and the languages it may use
GET /workspaces/{workspace_id}/artifacts the workspace's artifacts, newest first
GET /artifacts/{artifact_id} detail: the body, the files, quiz or flashcard progress, and for a revised copy revision: {derived_from_document_id, source_name, counts, applied}
POST /artifacts/{artifact_id}/regenerate run the job again; 202, or 409 while a run is queued or running, with the code already_generating unless the artifact is a revised copy, whose refusal has no code yet. A draft re-resolves a recorded source_scope without the artifact itself, else replays the recorded ids that still exist, and gives 409 sources_gone, "None of this artifact's sources are left.", when none remain. A refine repeats its rewrite without its sources, and gives 409 when the rewrite no longer fits the selected model (Refine). A revised copy applies its stored edits to the same input again and asks no model
POST /artifacts/{artifact_id}/refine {instruction}; the next version, pending; 202; its 409s are under Refine, four of them with a code
POST /artifacts/{artifact_id}/revisions/{accept-all|reject-all} a Word revised copy's next version from its newest, every tracked change accepted or rejected; 202, or 409 for a workbook or deck, a version with no changes, or while a version is being made (revised copies)
GET /artifacts/{artifact_id}/revised-copy/download?variant=changes|clean&suffix= the revised copy as an attachment, named after the source; 404 for clean of a workbook or deck (revised copies)
POST /artifacts/{artifact_id}/cancel stop a queued or running job; 409 nothing_running if nothing is running
GET /artifacts/{artifact_id}/files/{role} stream the primary or preview file
PUT /artifacts/{artifact_id}/quiz-state/{answer|skip|retake} quiz progress
PUT /artifacts/{artifact_id}/flashcard-state/{mark|reset|order} flashcard progress
DELETE /artifacts/{artifact_id} delete the artifact, its rows and its files
  • The artifact routes are keyed on the artifact alone, since a local install has one user. An artifact's status, title and error_message are its document's.
  • A prompt is at most 2,000 characters. A podcast brief holds a language, a style, a duration and one or more speakers, each with a name, a role and a voice.
  • Files are served inline so a viewer can render or stream them, except text/html and image/svg+xml, which are forced to download so a generated page never runs on the API's origin. ?download=1 sends any file as an attachment.
  • The file route ends its transaction before the first byte. The request's session closes only once the file has streamed, and a player reads a podcast for minutes, so holding the write lock that long left every other request failing as database is locked.
  • Quiz and flashcard progress is stored in artifact_metadata, scoped to the artifact's generation, so a regenerate starts a clean run.
  • There is no manifest route; the viewer reads GET /artifacts/{id} and the file stream.
  • DELETE /artifacts/{id} deletes the document, which cascades the sidecar, its file rows and its chunks, then removes artifacts/<id>/ after the commit. This is ADR 0003's blob-purge obligation, on this route.

Viewers

viewers/registry.tsx maps every format key to a viewer, and a key without an entry falls back to the plain DocumentViewer, so a new format renders as raw content instead of breaking the panel.

Format Viewer
summary the markdown, rendered with Streamdown
docx rendered in the app with docx-preview, inside a frame whose policy loads nothing but the file's own data-URL images and fonts and runs no script; no altChunk is rendered, and a link to anywhere but a place in the file is dropped; tracked changes show, insertions underlined and deletions struck through; comments show as Word shows them, each passage shaded with a numbered marker and its comment in a balloon in the margin level with it, pushed below the one above, and hovering a balloon deepens its passage's shade (docx-comments.ts); the page opens fitted to the frame's width, never above 100%, with room beside it for the balloons when there are comments, refits as the panel is resized until it is zoomed by hand, and zooms from 25% to 300% with its buttons or Ctrl and the wheel
pptx rendered in the app with @aiden0z/pptx-renderer
xlsx parsed with ExcelJS
pdf pdf.js
html the file's text in an <iframe sandbox="allow-scripts">, through srcDoc, with the no-network policy's <meta> inserted after the doctype (no-network-page.ts, ADR 0066): only inline script and style and data: or blob: images, fonts and media load. Electron refuses any frame's navigation in the app's window but to about:srcdoc or about:blank (framed-navigation.ts)
mindmap Markmap, with a fit control
flashcards one card at a time, marks saved through flashcard-state
quiz interactive, answers saved through quiz-state
podcast an audio player over files/primary, with the transcript as markdown
image, infographic a shared media viewer
  • The flashcard and quiz viewers are keyed on id:generation, so a regenerate remounts them with a clean run.
  • Under a ready Word document or PDF that Studio made, or a web page a model wrote there, which the API marks refinable, the artifact panel floats a round Refine button at its bottom-right corner, over the document (refine-box.tsx, can-refine.ts). It opens into a one-line instruction, already focused, that grows as it is typed, with a send button inside, disabled while the instruction is empty or a version of the document is being made. Enter sends and Shift+Enter starts a new line, as in the chat composer; Escape or a click elsewhere folds it back, keeping what was typed, which a dot on the button marks. The instruction is held to the route's 2,000 characters. It posts POST /artifacts/{id}/refine; the version it starts joins the list at once, the box turns into a "Making version …" pill while the open version stays in view, and back into the button when it is ready; each change between the button, the instruction and the pill is one animated box, and the new version opens itself when ready, as any newer version does. The box belongs to the document, not the version: a switch of version keeps it and what was typed, and only the title, the body and the downloads change; the title comes from the list, so it never reads "Loading…", and nothing is sent until the version's body is on screen. A refusal shows under the instruction, which stays open and keeps it, until another version opens: the interface's sentence for the refusal's code, or the API's reason for one that has no code or one the interface does not know.
  • The artifact panel offers a download for each file, except the JSON behind flashcards and quizzes, and a revised copy's two instead (above). It links ?download=1: the API is another origin than the app's window, where a link's download attribute is ignored and an inline file opens in a window instead of being saved.

The Studio panel

  • Studio lives in the right rail: pick a format, pick sources, add an optional prompt, generate. The source picker is the same included set as the sources panel, so chat and Studio share one selection. An unavailable format shows the interface's sentence for the API's unavailable_code, or the API's unavailable_reason for a code it does not know. A create or regenerate refused for the same reason shows it the same way in the panel's alert. So do the five job refusals with a code (already_generating, nothing_running, no_sources, sources_indexing, sources_gone): the alert shows the interface's sentence for the code, or the API's sentence for a code it does not know.
  • The rail and the left sidebar resize by dragging their inner edge, or from the keyboard on a focused edge, a separator named "Resize sidebar" or "Resize right panel" that carries its width: an arrow moves it 16 px, and Home and End take it to its limits (resize-handle.tsx). A double-click on an edge restores its default, and a collapsed rail has no edge. The sidebar goes from 272 to 560 px and the rail from 400 to 640; a source preview or an inspected citation or artifact goes up to 800. Neither takes the chat below 520 px, and on a narrow window the sidebar gives way first. The widths are kept in local storage (column-widths.ts). They live in the section that holds the columns, so a drag renders the column boxes and their edges, never the sidebar, the thread or Studio inside them (dashboard-columns.tsx).
  • A podcast waits for its brief: the panel loads GET .../studio/podcast/brief and renders a form for style, duration and speakers before the job can be submitted.
  • The artifact list shows each artifact with its status, and each row can be opened, regenerated, cancelled or deleted. A script document's versions share one row (above).
  • A name its column cuts off, an artifact's title in the list, a source's in the panel's source picker, or the title in the header of an open artifact, citation or source preview, shows whole in a tooltip on hover or keyboard focus, and only while it is cut off (overflow-tooltip.tsx).

The image path

  • image and infographic resolve the image_gen selection. A remote selection loads its connection, which runs the egress check and decrypts the key. An sdcpp selection points the same client at the bundled sd-server's loopback URL, with no key and no egress decision, which is what makes an image possible on an offline machine.
  • sd-server runs only while Studio needs it, because it keeps a model's weights in memory until it exits. The API answers Electron's poll from the jobs (local_image_demand.py): the chosen model of the image type the oldest processing job needs; else, for 5 minutes after the last such job ended, the same one, so a second image does not reload it; else nothing, and Electron stops it. A cancel ends the window at once, since sd-server cannot stop a generation, and so does any other Studio job running, since a podcast voices in that memory and a text job loads the chat model into it. The job first gives up the text runtime through the API, held for the whole image, because the two share the graphics card and its writing is done by then: the API lets running replies finish, unloads the text models and keeps local text waiting until the image is drawn, and unloads the speech recognition model, starting no recording's window meanwhile (admission). Then it waits up to 2 minutes for /sdapi/v1/sd-models to name its weights before it posts, and fails with a reason if sd-server never comes up.
  • sd-server is launched with each file of the build on its flag and the model's reviewed image defaults as its own (-W, -H, --steps, --cfg-scale, --sampling-method, --flow-shift), since a request that carries none of them starts from the launch settings (local-models/catalog.md).
  • The client posts {model, prompt} to /images/generations, falls back once to /images only on 404 or 405, and remembers the route that worked for the life of the process. It accepts b64_json, a data URL or a URL, caps the sizes, and checks the bytes against the claimed MIME type (connections.md).
  • The text_gen model writes first in both formats. The markdown body is the image prompt or the brief, so an image can be found by what it shows.

Known gaps

  • Whether Studio's Web page template is still needed is not measured. The levels that send a model to the written path were set on document tasks; the ladder's models have not written pages yet. If studio_only models write pages that pass the structural checks and look right, the template and its three prompts go; if agent models fail, the bar needs an HTML case.
  • Outside a model call, a cancel stops a job only between steps. The podcast turn being voiced and ingest's parsing and embedding run to their end first, because jobs are threads that cannot be killed; stopping a step in flight means running each job in a process the worker can kill, as a script document's run already is.
  • Studio's own PPTX and XLSX scripts run in the runner without asking the user, like Word and PDF; nothing confines their network or file access (below).
  • A model not measured still takes the provisional rule (model capabilities): a large model served from this computer writes Markdown, and a small remote one writes a script.
  • The PDF builder keeps Helvetica for Latin-1 and sets other letters in DejaVu Sans (shipped with matplotlib) or, for Chinese, Japanese and Korean, ReportLab's CID fonts, which are not embedded and rely on the viewer's substitutes. Reading one back has the same dependence: on a computer with no CJK font, pdfium cannot measure a glyph of those fonts and drops a text run with no extent, so a run of one such character is lost and 第1章 概要 reads back as 1 概要. Studio keeps the Markdown as this builder's body, so that reaches the index only through a script's PDF that uses those fonts, whose body is read back from the file. Arabic and Hebrew are drawn in logical order and Arabic unjoined, and Devanagari and other scripts that need shaping render as boxes: both need a bidi and shaping library the app does not carry. A chart whose labels the matplotlib font lacks (Chinese, for one) is shown as a table.
  • A document or analysis script runs with the user's privileges: the runner gives it a clean environment and a time limit, but it can read any file the user can and open network connections (ADR 0039).
  • Without Office support, a workbook's formulas show the value the script stored with them, since neither the runner nor Studio's viewer recalculates: xlsxwriter stores 0 unless given one, which the skill tells the agent to pass. Excel recalculates when it opens the file. The render result says the values were not recalculated.
  • Only the agent's workbooks are recalculated; Studio's own Spreadsheet format is not.
  • A revised copy is cut from its design (03): versions are artifacts, not artifact_versions rows, and engines run in the Studio worker's thread, not an engines queue's child process; the agent anchors edits by quote, with no inspect_document ids; there are no partial workflows, no per-change accept or reject and no side list of changes, no "With changes (internal)" download and no leak scan, no review record, no reconcile when the original changes, and no "Make a revised copy" in the source viewer. Word headers, footers and footnotes are not edited. An edited workbook is not recalculated until Excel opens it, and a deck has no thumbnails or diff. The report's codes reach the user in English through the agent: the interface does not render them.
  • The Word viewer draws tracked changes and comments with docx-preview's experimental rendering. Its 0.4.0 drops what a fragment holds, which is how it makes a comment's reference, and throws on a comment with no author, so the viewer passes it an element maker that keeps the one and fills the other; a file whose comments it still cannot lay out shows without them. A comment's time shows as Word shows it, unconverted, since Word writes its own clock's time marked UTC. The balloons take about 230 px beside the page, so a file with comments fits the frame at a smaller zoom.