Commit Graph
76 Commits
Author SHA1 Message Date
LING DUAN c4a24031b8 feat: integrate material library into main (#1896)
Adds the owner-scoped material library ("Knowledge base" in the UI) from the integration branch, as tracked in #1736 (RFC: Discussion #1716).

- Library page from the left rail: one folder-and-file list with search, automatic parsing after upload, Parse again, in-place folder creation and renaming, drag-to-move, source and folder deletion with confirmation, usage and separately counted file storage.
- Composer `@` and toolbar picker over the whole library; failed materials cannot be attached from any entry point.
- Agent tools to list, organize and parse materials, with one-line summaries in tool rows; deletion stays page-only.
- Originals and extraction outputs live in the asset pool under generic `asset_root_refs`; extraction is reused across conversations.
- Every successful upload, from classic and Pro and including existing undeleted uploads, stays in the library until explicitly deleted (#1898). The 24-hour sweep only reclaims unfinished upload reservations.
- `@openmaic/storage` 0.38.0.

Release conditions (breaking): stop all old instances before this version accepts writes, do not roll back below this release, and enable `MATERIALS_POOL_BACKFILL=1` only after every old instance has stopped. See CHANGELOG for schema additions and `ASSET_COLLECTION_GRACE_MS`.
2026-10-21 11:50:06 +08:00
杨慎andcosarah 6e20534fe0 fix(svg): validate malformed slide shapes and recover their text (#1915)
Add @openmaic/geometry, a browser-independent validator for SVG path syntax, finite view boxes and transforms and bounded resources, with structured errors and shared shape-to-text recovery. Generation, rendering, both editors and PPTX export use it: invalid shapes with a label render as text, unlabelled ones are omitted, stored records are not rewritten, and recovered shapes cannot be resized. Imported PPTX labels keep their own insets. Bumps generation 0.3.22, renderer 0.1.16, editor 0.0.12 and geometry 0.1.0.

Closes #1851

Co-authored-by: cosarah <cosarah@users.noreply.github.com>
2026-10-20 11:53:54 +08:00
wyucandClaude Opus 5.5 e19d0d4f45 release: v1.2.1 (#1944)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:19:55 +08:00
6760c2b764 refactor(chat): retire the legacy director-graph classroom chat (#1913)
* refactor(chat): move Pi's shared parser, action schemas and store-state type out of the legacy chat owners

Pi classroom chat still imports three pieces from the legacy director-graph
chat, which #1814 retires. Give each a Pi or neutral owner first, so the
legacy files can later be deleted without touching Pi:

- The structured-output parser (createParserState, parseStructuredChunk,
  finalizeParser, looksLikeStructuredFragment, ParseResult) moves verbatim
  from lib/orchestration/stateless-generate.ts to lib/chat/structured-output.ts.
  Only two comments change: they no longer name director-graph as the reader
  of the per-call ordered indices. This also stops the Pi route from loading
  director-graph and LangGraph through the parser's module.
- lib/orchestration/tool-schemas.ts moves unchanged to lib/chat/pi/tool-schemas.ts.
- AgentLoopStoreState moves unchanged from lib/chat/agent-loop.ts to
  lib/chat/store-state.ts; the Pi request path in use-chat-sessions uses it.

The remaining legacy consumers import from the new locations until they are
removed. No behavior change.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): cover classroom model precedence on /api/chat/pi with the real resolver

The user-route, lock and body precedence for classroom interaction is
covered with the real resolveModel only through the legacy /api/chat
route, which #1814 removes. Route-level Pi tests mock resolveModel. Add the
same four cases against /api/chat/pi, asserting the model handed to the Pi
runtime:

- a chat-adapter user route wins over the body model;
- a locked classroom slot wins over the user route;
- with no matching route, the body model is used;
- a locked classroom slot is kept despite unlisted body and route models.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(eval): run the whiteboard layout eval against Pi classroom chat

The whiteboard eval drove the legacy /api/chat through runAgentLoop. Point
it at /api/chat/pi, which #1814 keeps as the only classroom chat:

- one Pi request per scripted user turn, with whiteboard tools enabled as
  the app does for in-class requests;
- text and actions grouped by each agent's messageId and sealed into their
  own history message at agent_end, since one Pi request can hand the turn
  to several agents;
- the previous turn's done.directorState carried into the next request
  within a scenario run, as the app does within a session;
- an error event, a non-200 response, or a stream without done fails the
  scenario through the existing error path, which skips its remaining turns
  (previously an error event was swallowed and the turn still captured);
- a native whiteboard event fails the run, because the harness cannot see
  the native child's server-side board. Not seeing one does not prove the
  server uses the legacy child; run the eval on a server configured for it.

Scores are a new baseline and are not comparable with runs of the legacy
runner. Scenarios, capture, scoring and reporting are unchanged.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(eval): settle queued actions and cancel the request when a whiteboard turn fails

Review of the Pi whiteboard runner (#1913) found two lifecycle gaps on the
failure paths it added:

- A turn that failed (an SSE error, a native whiteboard event, or EOF without
  done) returned and disposed the scenario without waiting for its queued
  actions. The state manager drives shared store singletons, so a pending
  action could finish inside the next scenario and land in its screenshot.
  Every turn exit now settles the action queue first. A queued rejection is
  handled while the stream is still open and is logged at settle time, as on
  the success path, without replacing the turn's own error.
- Stopping early only released the reader lock, so the request kept running.
  Each turn's request now has its own AbortController, aborted on exit, and an
  unfinished response body is cancelled before the lock is released.

runScenario is exported with its CLI settings passed in, and main() runs only
when the file is the entry point, so an offline Vitest suite can exercise the
real function: failed turns settle before returning, the native early exit
aborts and cancels, a concurrent action rejection is not unhandled, and the
success path keeps one Pi request per turn, messageId grouping, directorState
carry-over and score:null on scorer failure.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(eval): run the answering and answer-content evals on Pi; retire premature-end

Both evals drove the legacy single-step director prompt and agent prompt,
which #1814 retires. Run them on the real Pi director in process instead
(eval/orchestration/pi-harness.ts): the Pi config helpers supply agent
configs and budgets, whiteboard tools are on as for classroom requests,
thinking is off, and the legacy child runtime is pinned. The harness skips
the HTTP route, so route authorization and tool-capability checks are not
exercised.

- answering classifies by the first recorded call_agent attempt in the
  director tool trace: TEACHER, OTHER_AGENT (another or a non-existent agent),
  NO_DISPATCH (none recorded; wrong, kept in the denominator), or ERROR (a
  failed run, excluded as before). A later recorded attempt never rescues
  the first.
- answer-content judges the full reply of the first recorded teacher attempt.
  A failed run, no recorded teacher attempt, or a first one that is skipped,
  marked isError or empty scores 0. The judge, opening window, threshold and
  all-samples denominator are unchanged. Pi's short teacher replies make the
  two metrics nearly coincide, and the score includes routing.
- Both score only what the trace records. A schema-rejected attempt is not
  recorded, so a later one can score, and a legacy child's partial text after
  a provider failure is judged. Scores can be overstated. Real-chain tests
  (real Pi loop and agent SDK, scripted StreamFn) pin both limits.
- The old prompt A/B variants are removed; results are a Pi baseline.
- premature-end is retired: its single END decision has no Pi counterpart,
  and the close gate does not show a question was answered.
- The three remaining eval scripts load with tsx --conditions=import, so
  ESM-only dependencies resolve.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(chat): always run classroom chat on Pi and fail builds that still opt out

The classroom chat hook, the /api/chat/pi route and PlaybackChromeRoot no
longer read NEXT_PUBLIC_PI_CHAT_ENABLED. The hook sends every turn through
the existing Pi single-request path; the frontend no longer calls the legacy
/api/chat loop (fetchStatelessChat and the runAgentLoop tail are removed).
The Pi branch itself is unchanged apart from indentation.

isPiChatEnabled and its readDefaultOnBoolean helper are removed. The
independent courseware-reference gate stays default-off.

next.config.ts now exports a phase function. In the production-build phase
it fails when NEXT_PUBLIC_PI_CHAT_ENABLED is set to anything other than '',
'true' or '1' (exact match), which covers every value that used to select
the legacy runtime. The error names the value and points to the Classroom
model setting, the google native preset for Gemini, and the administrator
for managed configurations. Other phases do not check the flag. The fixed
env pin for the flag is removed.

This is an intermediate step of #1814 PR 3: the legacy route, director graph
and agent loop are still present and are removed in a follow-up commit, and
Docker/Compose wiring and docs still describe the old rollback.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(chat): remove the legacy director-graph classroom chat backend

The frontend has run every classroom turn on /api/chat/pi since the previous
commit, so nothing reaches the legacy stack any more. Remove it:

- app/api/chat/route.ts (no stub, redirect or 410; /api/chat/pi stays)
- lib/orchestration/{stateless-generate,director-graph,ai-sdk-adapter,
  director-prompt,prompt-builder}.ts and lib/chat/agent-loop.ts
- summarizers/{conversation-summary,message-converter}.ts
- the director, agent-system and agent-system-wb-* templates, the
  speech-guidelines and whiteboard-reference snippets, and their prompt and
  snippet IDs

Pi's default legacy child, the shared structured-output parser, the Pi
tool schemas, the shared summarizers, the registry and the protocol types
are unchanged. Dependency declarations are left for a follow-up commit.

Tests:
- Drop the legacy chat route from the model-authorization tests and the
  coverage matrix. The mixed compatibility cases keep their six and eight
  stage assertions, and the exhaustive resolver call-site scan is unchanged.
  The locked-classroom case now goes through /api/chat/pi.
- Retire chat-route-user-routes (its precedence cases are rebuilt on the Pi
  route in route-model-precedence), the agent-loop async-state test, and the
  conversation-summary test.
- Keep the #511 intent as a history input contract on Pi:
  toHistoryMessages keeps the human challenge as a user turn in order, and
  buildUserPrompt still reports it after a later agent reply.
- Move the role, peer-context and language-directive prompt checks onto
  Pi's buildChildPrompt. Keep the PBL and placeholder-lint template tests and
  the getActionDescriptions LaTeX guard.
- Add middleware cases for POST /api/chat (401 without access, pass-through
  otherwise) and a Playwright spec that expects a plain 404 from the running
  app.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(deps): drop the unused @langchain/langgraph dependency

The legacy director graph was its only consumer and was removed in the
previous commit. Remove the root declaration and let pnpm update the
lockfile: @langchain/langgraph and @langchain/langgraph-checkpoint are no
longer installed. @langchain/langgraph-sdk stays (an optional CopilotKit
peer and a direct dependency of @ag-ui/langgraph).

@langchain/core is kept: CopilotKit requires it as a peer and pnpm would
install it anyway. partial-json and jsonrepair are kept for the Pi parser.

The re-resolution also moved vite 8.0.0's postcss from 8.5.15 to 8.5.23
(both were already in the lockfile; Next's postcss is unchanged) and
shortened four eslint-import snapshot keys without changing versions.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(docker): keep the Pi chat flag only as an input to the build check

NEXT_PUBLIC_PI_CHAT_ENABLED no longer selects a chat runtime; next.config.ts
only reads it to fail a production build that still opts out of Pi. Keep the
builder ARG and the Compose build-arg forwarding so an explicit false still
reaches that check, and drop the ENV line: build args are already visible to
the builder's RUN steps, and the value has nothing to configure in the image.

Remove the legacy-chat rollback comment and command from docker-compose.yml
and replace the rollback section of .env.example with a short note that the
legacy runtime is gone and which values now fail the build.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: describe classroom chat after the legacy runtime removal

- Configuration docs (all six locales): drop the Pi rollback example, the
  advice to rebuild with the flag set to false and the Compose rollback
  command. Say instead that classroom chat always runs on Pi and needs a
  tool-calling Classroom model (google preset for Gemini), that the flag
  should be removed, which exact values a production build still accepts,
  and that the Compose service env_file supplies runtime values only.
- READMEs: drop the LangGraph badge and describe lib/orchestration and the
  Pi director loop as they now are.
- CHANGELOG: add the breaking change (model requirement, the flag, POST
  /api/chat answering 404 after the access gate) and the dependency removal.
- lib/prompts/README.md: point the role and length guidelines at
  lib/chat/pi/prompts.ts, correct which tests check placeholders, use a
  missing snippet in the error example, and start the whiteboard eval server
  with the native child runtime off.
- Remove the two comments that told CI and Playwright to leave the flag
  unset.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): pin the server key on a user-route model; document answering eval limits

- route-model-precedence: when the user route switches the classroom model
  to another provider without a key of its own, assert that the model is
  built with the server key rather than the body's key.
- answering eval: state in the header comment and the generated report that
  a run which throws, is aborted or ends without done is ERROR even after it
  dispatched an agent, that ERROR samples stay out of the correct-rate
  denominator, and what the rate therefore does and does not measure. The
  scoring rules are unchanged.

Refs #1814

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-10-17 00:26:52 +08:00
wyucandClaude Opus 5.5 a9da60b3b4 release: v1.2.0 (#1917)
* release: v1.2.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* release: date v1.2.0 2026-10-16 and clarify the cumulative scope

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* release: scope the math-hardening note to the app's render paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:06:06 +08:00
wyucandClaude Opus 5.5 153d0846a3 release: v1.2.0-rc.2 (#1885)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:01:29 +08:00
wyucandClaude Opus 5.5 e07e629261 fix(sanitize): store inline math as LaTeX source and typeset it in the client (#1829)
* fix(sanitize): keep inline math through the scene-content sanitizer

The prose policy flattened the editor's inline formulas on every
persistence read and write: it dropped data-inline-math, the MathML
annotation, the layout SVG and KaTeX positioning, so saved formulas
rendered broken and their LaTeX source was lost.

Move the lift / sanitize / re-render approach from the standalone HTML
export into lib/sanitize/inline-math.ts and apply it in
sanitizeProseHtml, so the persistence boundary and the export share one
implementation. It parses with parse5, so it needs no DOM and runs on
the server and in the browser alike. Each formula is replaced by an
empty source span before sanitizing, and every surviving source span is
replaced afterwards by a fresh KaTeX render (trust: false); authored
markup never bypasses the policy. Prose without a formula skips the
parse and is sanitized byte-for-byte as before. Renders are cached by
source, and pathological markup falls back to the plain policy instead
of failing the read or write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sanitize): bound inline math rendering and detect encoded KaTeX classes

KaTeX markup is 100-150x the size of its source and is regenerated
synchronously on every persistence read and write, so one large formula
could block the event loop for seconds and pin hundreds of MB in the
render cache.

- Render a formula only if its source is at most 2000 characters, and
  within a per-call budget of 16000 source characters and 1000
  formulas. Anything else stays a source span showing its LaTeX as text,
  so nothing is lost and the editor still recovers it. Limits are counted
  in document order, never time, so repeated passes stay stable.
- Bound the render cache by markup size (1 MB) and never cache renders
  over 32 KB.
- Pre-check on `data-inline-math` or `annotation` instead of `katex`:
  element and attribute names cannot be written as character references,
  but a class value can, so an encoded `katex` class no longer skips the
  lift and loses its source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sanitize): store inline math as source and typeset it in the client

Rendering formulas on the server made every persistence read and write
run KaTeX on authored input, which user macros and long sources can turn
into seconds of CPU per document. Store each inline formula as its LaTeX
source instead and let the clients that display slides typeset it.

- The prose sanitizer reduces each inline formula (span[data-inline-math],
  else span.katex with a TeX annotation, entity-encoded class included)
  to <span data-inline-math="SRC">SRC</span> and allows only that
  attribute. The source doubles as text for consumers that do not render
  math. A source over 2000 characters is kept as plain text. The pre-pass
  uses htmlparser2, the parser sanitize-html uses, so it is linear and
  sees the same element boundaries; prose without a formula marker is
  sanitized exactly as before. The server never runs KaTeX, and the
  parse5 lift, render cache and budgets are gone.
- @openmaic/renderer typesets span[data-inline-math] in text, shape and
  table prose after injection (renderInlineMath / useInlineMath), with
  trust disabled, macro definitions blocked and bounded expansion. It
  also re-renders older content that still carries KaTeX markup. The
  app's slide renderer components use the same hook. Bump the renderer
  to 0.1.12.
- The standalone HTML export ships source-only formulas, which the
  player's renderer typesets, and ships the math fonts when a slide has
  an inline formula.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(slide-renderer): keep cell markup intact when formatting table text

formatText turned every space in a table cell into &nbsp;, including
the spaces inside tags, which broke any cell markup with attributes. An
inline formula in a cell was shown as its raw source instead of being
typeset. Only format the text between tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(renderer): allow 1000 macro expansions when typesetting inline math

A cap of 50 expansions rejects realistic long formulas: built-in
commands such as \neq, \iff, \, and \dots each count, and a formula with
a few dozen of them needs over 100. Macro definitions are blocked, so
expansion stays linear in the source and 1000 is only a backstop. This
matches the shared safe KaTeX options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sanitize): replace self-closing inline formulas in SVG and MathML once

Inside SVG or MathML the parser honours `/>` and reports the close as
implied while still on the opening token, so the replacement ended
before the formula started and the original span stayed next to the
stored one; the next pass then stored it again. End a formula no earlier
than its opening tag. Adds SVG and MathML cases and fuzzes the pass for
idempotency and formula shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(renderer): keep inline math typeset across re-renders

React can rewrite `dangerouslySetInnerHTML` markup on a re-render even
when the HTML string is unchanged, which put the stored source spans
back after a move or resize while the effect, keyed on the HTML, did not
run again. Typeset after every commit instead: formulas already typeset
are skipped, and typeset formulas are cached by source and cloned, so a
re-render costs a query and, at most, cheap clones. Typesetting never
updates React state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(renderer): budget inline math typesetting per prose root

One slide packed with large formulas could block the page for hundreds of
milliseconds on every mount. Typeset at most 4000 characters of uncached
source or 100 uncached formulas per pass over a prose root before paint,
and the rest in idle time, a chunk of the same size per idle period. The
next commit, an unmount or new content cancels pending idle work. Past
50 000 characters or 1000 formulas per root, formulas are shown as their
LaTeX text. Slide snapshots typeset everything before capture. Covers
identical-content re-renders and A, B, A, A content sequences in both
renderers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(renderer): keep inline math progress across commits and complete snapshots per root

- Give injected prose a stable dangerouslySetInnerHTML value per markup
  string (text, shape label and each table cell, in both renderers), so
  React leaves unchanged markup alone. Typeset formulas and pending idle
  progress then survive re-renders; formulas too large to cache are no
  longer typeset again on every commit. The every-commit pass stays as a
  safety net.
- Mark each prose root and complete snapshots root by root, so each text
  box keeps its own hard cap as on the live slide instead of sharing one
  slide-wide count.
- Apply the hard cap from document order on every pass, whatever earlier
  passes typeset, and let a formula shown as text by an earlier cap be
  typeset once it falls within its root's cap.
- Note that the render cache bound counts serialized characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(renderer): bump to 0.1.13 after rebase

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(renderer): typeset inline math through the shared safeKatexOptions

Replace the local stand-in options (inert macro-definition commands,
trust: false, maxExpand 1000) with safeKatexOptions from @openmaic/dsl,
keeping the inline-specific settings (displayMode: false, output: html,
throwOnError: false, strict: ignore). The shared helper's expansion budget
is the same 1000, and it also keeps the built-in \tag/\notag state and
makes \long inert.

The math engine guard now lists the renderer's inline-math module as a
render-calls site and drops the standalone HTML rich-text module, which no
longer renders KaTeX itself.

Tests: ordinary formulas typeset byte-for-byte as with the previous
options, and formula-defined macros stay inert within one formula and
across formulas in the same prose root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sanitize): keep over-long inline formulas as formulas; bound typesetting only

The 2000-character bound dropped the data-inline-math marker at save time,
so an over-long formula became plain text for good. The bound now applies
only where formulas are typeset: the slide renderer already shows an
over-long source as text, and the editor's inline_math node now does the same
instead of typesetting it, using the renderer's MAX_INLINE_MATH_SOURCE.
@openmaic/editor 0.0.11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sanitize): a 100 KB formula is kept as a formula in the node-environment suite

Follows the sanitizer change: the length bound applies to typesetting only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:58:01 +08:00
wyucandClaude Opus 5.5 08e1447ab7 feat(export): export a classroom as a single offline HTML file (#1813)
* refactor(export): share one classroom export snapshot across formats

Split buildClassroomExportZip into buildClassroomExportSnapshot, which
returns the portable manifest plus the bytes of every payload it names,
and a thin ZIP writer on top of it. The upcoming standalone HTML export
serializes the same snapshot, so both formats share one serializer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(export): standalone HTML player bundle

A small React app that plays a classroom manifest embedded in the page:
slides through @openmaic/renderer's SlideCanvas, interactive scenes in
sandboxed srcdoc iframes, client-graded quizzes, and static PBL
briefings, with prev/next, keyboard, scene list and fullscreen.

scripts/build-standalone-player.mjs compiles it at app build time (dev
and build) into public/vendor/standalone-player/: one IIFE via esbuild,
Tailwind 4 CSS covering the renderer's classes, KaTeX fonts as an
optional sheet, and ECharts as an optional script the player reads from
a global, so files without charts do not carry it. Shiki is stubbed; code
elements render unhighlighted. esbuild is added as a direct
devDependency at the version already in the lockfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(export): export a classroom as a single offline HTML file

Add "Export as HTML" to the export menu. The export takes the shared
classroom snapshot, embeds slide images, backgrounds and video posters as
data URIs (archived payloads first, concrete URLs fetched at export
time), patches interactive HTML for the sandboxed iframe, and inlines the
prebuilt player into <course-name>.html.

The file makes no network requests: a restrictive CSP (default-src
'none', connect-src 'none') is set before any script, the manifest and
config are embedded as JSON with <, > and & escaped, and interactive
pages run only in iframes sandboxed like the classroom's. PBL scenes link
to the online classroom only when it is published at export time. No
narration or video bytes yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(sanitize): move the scene-content sanitizer to a client-safe module

The policy depends only on sanitize-html, so it moves to
lib/sanitize/scene-content.ts unchanged; lib/server/sanitize-scene-content.ts
re-exports it for the persistence callers. Client export paths can now
apply the same policy to working state before it leaves the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): sanitize and trim the standalone HTML classroom

- Run slide rich text (text, shape text, table cells, LaTeX snapshots)
  through the persistence sanitizer before embedding: the renderer injects
  it into the player's own document, and the export reads working state
  that has not crossed the persistence boundary.
- Embed chart point images as data URIs (or drop and report them); rich
  text images and CSS url() values are removed by the sanitizer.
- Leave out what the player does not use: playback actions, multi-agent
  settings, the agent roster, the video manifest and PBL runtime state.
  PBL content is resolved the way the classroom resolves it, legacy
  projects upgraded, and reduced to its briefing.
- Skip narration audio, legacy audio fetches and video bytes when
  building the snapshot for HTML (captured posters are kept); the ZIP
  path is unchanged.
- Resolve each media ref once, count a video poster as unresolved only
  when no captured frame covers it, and ship KaTeX fonts only when quiz
  text or slides actually contain math.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): keep player scenes scrollable and contain scene failures

Navigate with Left/Right only, so Up/Down, PageUp/PageDown and Home/End
scroll long quiz and PBL scenes. Wrap each scene in an error boundary
that shows it as unavailable instead of blanking the player, and read
PBL briefings defensively now that the export resolves legacy projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(export): note that dev builds the standalone player once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): keep inline formulas and report dropped rich-text resources

The prose sanitizer cannot express the editor's inline formulas (KaTeX
spans with SVG and positioned parts carrying data-inline-math), so the
standalone export lifts each formula's LaTeX source out before
sanitizing (from data-inline-math, else the KaTeX tex annotation) and
puts back a fresh KaTeX render afterwards. The embedded markup is
generated from the source, never taken from the document.

Images and CSS url() values in rich text, which the sanitizer removes,
are inventoried first and reported as unresolved media so the
partial-export warning counts them instead of losing them silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): rebuild inline formulas through the DOM, not string markers

Rework the standalone export's rich-text pass so authored text is never
spliced into strings:

- Parse each prose string with the DOM. Inventory resources over the
  whole tree first, formula subtrees included: image src and srcset,
  media sources, and CSS url(), image-set() and @import references found
  by a real CSS parse (comments, quoted URLs and escapes respected).
- Replace each inline formula with an empty span whose data-inline-math
  is set through setAttribute, then run the persistence sanitizer with an
  export-only option that keeps that one attribute on span. The default
  persistence policy is unchanged.
- Parse the sanitized output and replace each such span with a fresh
  KaTeX render of the source read back with getAttribute. Generated KaTeX
  is the only markup added after sanitizing.

Tests cover replacement patterns in LaTeX, marker-like and encoded text,
resources inside formula wrappers, quoted URLs with parentheses, srcset,
escaped url() and commented-out URLs. The export tests now run under
jsdom, where the browser DOM the export relies on is available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): make the rich-text resource inventory spec-correct and fail-safe

- Decode CSS escapes per CSS Syntax: zero, surrogates and values above
  U+10FFFF become U+FFFD instead of throwing, and an escaped newline is a
  line continuation.
- Never let the inventory affect the export: each element is scanned
  under its own guard and the whole pass is guarded, so a failure can
  only under-report.
- Scan and strip formulas inside template contents, nested ones
  included, which querySelectorAll does not reach.
- Document that the inventory is best-effort and only drives the
  partial-export warning; the sanitizer and the CSP remove and block
  these resources regardless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): bound the public-classroom lookup in the HTML export

Every HTML export awaited the stage-meta request for the optional PBL
"continue online" link, so a request that never settled left the export
stuck in its busy state with no download.

The lookup now gives up after 3000 ms: the request is aborted through
the fetch it is given, and a race returns even when the response body
never finishes. On timeout or failure the link is omitted and the export
continues. Courses without a PBL scene skip the lookup entirely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): always link PBL scenes to the online classroom

A classroom is readable by anyone holding its link; the published flag
does not gate reading. Gating the PBL "Continue this project online"
link on it hid a link that works.

The export now always builds the link from the exporting origin and the
stage id, with no stage-meta request, so there is nothing to wait on.
The explicit classroomUrl option for hosts and the player's http(s)-only
guard stay. This replaces the bounded lookup and its timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): render standalone-player charts without entry animation

Line series animate in by growing an SVG clipPath from zero width. WebKit
does not repaint the clipped group when the clip path's geometry changes
afterwards, so in Safari the exported file showed a line chart's points
and labels without the connecting line. Re-setting the clip path (or the
clip-path attribute) makes the line appear, which confirms the stale paint.

The charts runtime the export ships now wraps ECharts init so every chart
renders without its entry animation; the clip path is created at its
final size. The shared renderer and the online classroom are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:44:24 +08:00
wyucandClaude Opus 5.5 c70ded88f8 feat(export): keep interactive and quiz scenes in the PPTX as placeholder slides (#1811)
* feat(export): keep interactive and quiz scenes in the PPTX as placeholder slides

The PPTX export (plain and inside the Resource Pack) now follows lesson
order: each interactive and quiz scene gets a placeholder slide at its
original position. PBL scenes stay out of the PPTX.

- Interactive placeholders in the Resource Pack link to the matching
  interactive/NN_<title>.html page through a relative external link. The
  page path comes from one shared helper (listInteractivePages), so the
  link and the ZIP entry cannot drift. A standalone PPTX has no pack next
  to it, so it shows a hint instead of a dead link.
- Quiz placeholders show the question count and the question stems, never
  options, answers or analysis.
- Slide-to-slide links resolve through the final PPTX slide numbers, so
  inserted placeholders no longer shift link targets.
- Placeholder slides carry the scene's narration as speaker notes.


Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): address review of PPTX scene placeholders

- Quiz placeholders no longer copy the scene's narration into speaker
  notes: quiz narration often explains the answers. Interactive
  placeholders keep their notes.
- Exportability now follows the planned deck rather than the slide
  count. Lessons with only quiz and/or interactive scenes export a PPTX
  (plain and inside the Resource Pack) with fallback styling; a PBL-only
  lesson is still reported as empty. Every shipped HTML page now has a
  placeholder slide, so the "PPTX skipped" path and its message are gone.
- Missing or blank scene titles no longer abort the export. Titles are
  normalized in one helper; an untitled page is saved as
  interactive/NN.html and its placeholder shows the scene-type label.


Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(classroom): open a classroom at a scene with ?scene=<id>

/classroom/<id>?scene=<sceneId> now opens the standalone classroom page
at that scene. The parameter is applied right after the course loads, so
the opening scene's media is hydrated first. Unknown scene ids are
ignored and the page opens as before; the workspace pane does not read
the parameter. Opening a link sets the cursor without marking it as an
edit.

Exported decks use these links to send viewers to a specific scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(export): link placeholder slides to the online classroom with a QR code

Every interactive and quiz placeholder slide, in the standalone PPTX and
in the Resource Pack PPTX, now carries:

- an "Open online" button linking to the scene in the online classroom
  (<origin>/classroom/<id>?scene=<sceneId>);
- a QR code of the same URL, dark on white with a 4-module quiet zone,
  about a fifth of the slide width, with the URL printed under it;
- when the classroom is not public, a hint to publish it so students can
  open it by scanning.

The origin defaults to the exporting page's own origin and can be
overridden through useExportPPTX({ classroomOrigin }). Public status
comes from the stage-meta endpoint; unreadable metadata counts as not
public.

Resource Pack interactive placeholders keep the relative link to the
offline HTML page as a smaller secondary link. The standalone PPTX no
longer shows the "export the Resource Pack" hint.

QR matrices come from uqr (MIT, no dependencies), loaded with a dynamic
import on the export path only. The PNG is written by a small 1-bit
encoder, so no canvas is needed and the same code runs in Node tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): bound the classroom status lookup during PPTX export

The export waited on /api/stage-meta with no limit, so a stalled request
kept the export (and its guard) locked, even for lessons without any
placeholder slide.

- The lookup now gives up after 3 seconds: the request is aborted and
  the classroom counts as not public, as with unreadable metadata.
- Lessons with no quiz or interactive placeholder skip the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(export): make placeholder links clickable in WPS slideshows

WPS follows a link on an uncovered shape but ignores one on a shape that
a text box covers, so clicking the "Open online" button advanced the
slideshow instead of opening the classroom.

Each link on a placeholder slide now also gets a top-most, text-less,
fully transparent hotspot with the same hyperlink: over the "Open
online" button, over the QR code together with the URL under it, and
over the Resource Pack's "Open offline copy" line. The fill is
transparent rather than absent, since some viewers only hit-test the
outline of an unfilled shape. The visible elements keep their own links
and the rendered slides are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:30:54 +08:00
wyucandClaude Opus 5.5 636fab0d7e release: OpenMAIC 1.2.0-rc.1 (server-first) (#1794)
* ci: run CI for the provider-config integration branch

Development of the provider configuration RFC (#1701) lands on
integration/provider-config; build it on push and run CI for pull
requests into it, as with earlier integration branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(config): capability slot registry and stage-to-slot mapping (#1726)

* feat(config): capability slot registry and stage-to-slot mapping

First P0 step of the provider configuration RFC (#1701, tracked in
#1725): define the capability slot forest and map every LLM stage key
to exactly one slot. Pure data with no callers yet, so there is no
behavior change.

Requirements are checked on the slot that declares them and are not
passed down to child slots, so agent.title can use a model without
tool calling even though agent requires it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): pin every stage destination and the capability roots

Review found that the station and root assertions derived their
expectations from the module under test: re-pointing a single-stage
station or dropping a capability root still passed. The tests now
compare against an independently written stage table and root list,
and pin agent.title as config-only. Also document that browserless
outlines still run on the generate-classroom model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): pin slotForStage and full slot lineages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): provider presets and the openmaic.yml schema (#1727)

* feat(config): provider presets and the openmaic.yml schema

Second P0 step of the provider configuration RFC (#1701, tracked in
#1725).

- lib/config/provider-presets.ts: one preset per built-in registry
  entry, plus the token plans as multi-capability presets whose stage
  recommendations become slot recommendations. Registry ids that
  collide across capabilities get explicit preset ids.
- lib/server/model-config/openmaic-yml.ts: parse and validate the
  operator's openmaic.yml (or the file named by OPENMAIC_CONFIG):
  ${VAR} interpolation, strict schema, and cross-checks that every
  assignment names a declared provider whose preset offers the slot's
  capability. Every problem is reported with its path.
- instrumentation.ts: an invalid file refuses to start. Without a file
  nothing changes, and nothing resolves models through the file yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): harden openmaic.yml validation after review

- Look providers up by own key only, so "constructor:m" is not taken
  for a declared provider.
- Refuse non-mapping objects YAML produces (an unquoted timestamp
  becomes a Date that the schema would accept as an empty object), and
  refuse a YAML alias that refers back to itself instead of overflowing
  the stack.
- Accept lowercase variable names; refuse a "${" with no closing
  brace, checked on the text as written, never on substituted secrets.
- Cross-check the entries that are valid on their own even when the
  schema rejects others, so every problem is reported at once without
  duplicate errors for declared-but-invalid providers.
- Name the three valid assignment shapes when a value has none of them.
- Test the boot path: an invalid file exits with code 1 before any
  schedule starts; a valid one boots.
- Ignore a root openmaic.yml in git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep secrets out of openmaic.yml diagnostics

Review round 2:

- Read only variables the environment itself has, as non-empty
  strings: ${constructor} or an inherited value is not a variable.
- Never print a value that came from ${VAR}, and report YAML syntax
  errors by reason and position without js-yaml's source excerpt,
  which can quote a literal key.
- Keep the base URL requirement on presets: SearXNG from its registry
  entry, and Azure OpenAI and self-hosted MinerU, which have no usable
  default endpoint.
- Check slot names and valid model references even inside an entry
  that fails the schema, and report a failed placeholder once rather
  than again as an empty value.
- Boot test for the no-file case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(config): validate openmaic.yml in two phases

Rounds 1-3 of review kept finding edge cases in one feature: running
the cross-checks on the half-valid parts of a file the schema had
rejected, so that every problem showed at once. It hid problems inside
a rejected entry, lost a __proto__ slot key, and built misleading
provider errors from failed placeholders. That feature is gone.

Validation now runs in two phases. Phase 1 is the document itself:
placeholders, key names (checked on the document as written, since the
schema drops a __proto__ key), and the schema. Any problem there stops
before phase 2, the references between entries, which therefore only
ever sees a fully valid file. Each phase reports all of its problems,
one per path.

YAML syntax errors now report only their position: js-yaml's reason
can quote source text too (an alias or tag name), not just its excerpt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(api)!: generate-classroom takes requirement + uploaded materials; capabilities follow server config (#1728)

Narrows POST /api/generate-classroom to { requirement, materialIds? }; capabilities follow server configuration; materials go through the owner material library (upload, reference by id, delete); new GET /api/generate-classroom/capabilities; skill docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): resolve a slot through the configuration layers (#1729)

* feat(config): resolve a slot through the configuration layers

Third P0 step of the provider configuration RFC (#1701, tracked in
#1725). resolveSlot walks from a slot to its capability root and
returns the first assignment it meets, consulting the deployment layer
(openmaic.yml, locked) before the workspace layer at every node. An
explicit null disables the subtree; nothing assigned up to the root is
unassigned, with no fallback to any vendor. The result carries the
provider, preset, registry entry, effective base URL, key, model, call
options, fallback, where it was resolved and whether it is locked, and
the requested slot's own requirements checked against the model
catalogue (met, unmet or unknown).

Pure and uncalled for now, so there is no behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): lock only written slots and trust the catalogue only where it applies

Review round 1:

- locked now means the requested slot itself is written in the
  deployment layer. Inheriting a deployment value does not lock a slot,
  since the workspace may still assign it (source and resolvedAt still
  report where the value came from).
- A custom OpenAI-compatible endpoint borrows the OpenAI registry for
  transport only, so its preset no longer trusts that model catalogue:
  requirements there resolve to unknown.
- The fallback is checked against the slot's requirements too, and the
  result exposes fallbackRequirements so retries can refuse it.
- A malformed reference fails with a path-qualified SlotResolutionError
  that does not echo the value, which is often a misplaced key.
- Requirement tests use named catalogue models instead of picking one
  from the catalogue at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): order layers deployment-first and keep references out of errors

Review round 2:

- resolveSlot no longer depends on the order its caller passes the
  layers in: deployment layers always come first, for assignments and
  provider lookup alike.
- Resolution errors name the path and the preset, never a value taken
  from the reference, so a key pasted into the provider position does
  not end up in a log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(config): cover catalogue aliases and fallback capability errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(classroom)!: save server-generated classrooms through server persistence (#1730)

Server-side classroom generation saves the finished course create-only into the request owner's library with media in the owner's asset pool; jobs move to PostgreSQL with owner-scoped polling; /api/classroom is removed; legacy data/classrooms files are imported once in the background. Adds @openmaic/storage 0.36.0 PgAssetStore.releasePending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): translate the legacy configuration into a deployment layer (#1731)

* feat(config): translate the legacy configuration into a deployment layer

Deployments without openmaic.yml keep working: provider variables,
server-providers.yml, DEFAULT_MODEL, MODEL_ROUTES and MODEL_FALLBACK are
translated into the openmaic.yml shape and serve as the deployment layer
for resolveSlot.

- Providers: each configured entry becomes a provider under its preset
  id; force-disabled entries are left out; AliDocMind's key pair goes
  into credentials.
- DEFAULT_MODEL goes on the llm root. Every other slot that serves stages
  gets what those stages used before (their route, or else the default
  model), written only where inheritance would give something else, so
  an unrouted stage under a routed parent stays on the default model.
- Stages that now share a slot but had different routes, models whose
  provider has no server configuration, and stages that used the
  browser's model but would now inherit a server one become startup
  notices, never failures.
- MODEL_FALLBACK attaches to every chat assignment without its own.
- When openmaic.yml exists it wins, with a notice if legacy variables
  are also set.

Nothing reads the layer yet; routes switch over in P1 (#1725).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): follow the agent driver's rules and keep notices value-free

Review round 1:

- The agent never used DEFAULT_MODEL: it needs a valid maic-agent-driver
  route and is off otherwise, so the translation writes agent: null where
  it would inherit a server model (and a notice for a route that does
  not work today). Unrouted conversation titles reuse the driver's model
  with thinking off, as the title generator does.
- Notices name registry ids only; a credential pasted into a model
  variable is never repeated.
- Provider entries and route options are checked against the new schema
  and left out with a notice (field names only) instead of producing a
  file that would not parse; driver-only options are dropped elsewhere.
- A route with its own fallback never falls back to MODEL_FALLBACK, even
  when that fallback cannot carry over; the retry model is part of the
  comparison with the inherited value.
- Force-off switches without a configured entry are reported, a legacy
  configuration made only of them is detected, and translating one adds
  a deprecation notice.
- loadDeploymentLayer reads the process environment and working
  directory like the legacy loaders, instead of taking parameters it
  could only partly honor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): compare routes by behavior, validate references, name only known ids

Review round 2:

- Stages sharing a slot are compared by what the route changes for them
  (model, thinking, fallback), with bare ids read as openai models and a
  route to DEFAULT_MODEL counted as no route; api and contextWindow are
  inert outside the driver.
- A model reference carries over only when it names a provider from the
  providers section and forms a valid reference.
- Section keys and force-off ids are named in notices only when they are
  registry ids with a preset.
- Retries now follow the slot; where a call site picked its retry model
  by another label (scene types under scene-content, browserless
  generation), the change is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): chat references need a chat entry; one notice for retries

Review round 3:

- A model reference carries over only when its provider was translated
  from the providers section, not merely declared by another section
  under the same id.
- Routes are compared by the retry model that takes effect, so leaving
  out a fallback equals repeating MODEL_FALLBACK.
- The per-call-site retry notices kept missing cases (streaming and
  calls outside server-managed routing never retry today). They are
  replaced by one notice, given whenever a retry model is configured,
  that retries now follow the slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(config)!: translate only providers and the default model (#1732)

* refactor(config)!: translate only providers and the default model

MODEL_ROUTES does not map one to one onto slots: several stages share a
slot, the agent driver and conversation titles have rules of their own,
and retries are picked by call-site labels. Emulating that took most of
the translation and still ended in notices that are easy to miss.

The translation now carries over only the unambiguous part: providers,
DEFAULT_MODEL as the llm root and MODEL_FALLBACK as its fallback (the
agent stays null, as it never used DEFAULT_MODEL). A deployment that
sets MODEL_ROUTES without openmaic.yml gets LegacyRoutesError asking it
to write the per-stage models as slots. loadDeploymentLayer is not wired
into startup yet; that happens when routes switch to resolveSlot (P1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): say what a dropped MODEL_FALLBACK loses

Without DEFAULT_MODEL there is no assignment to hold MODEL_FALLBACK, and
calls that retried on it (server providers picked in the browser) stop
retrying; the notice says so. thinkingSchema is module-private again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): workspace model configuration with keys encrypted at rest (#1733)

* feat(persistence): workspace model configuration with keys encrypted at rest

The store behind the web settings of RFC #1701 (#1725, P1): one row per
workspace (owner) in workspace_model_config, the same shape as
openmaic.yml without policy.

- Provider secrets (apiKey, credentials) are sealed per provider with
  AES-256-GCM under OPENMAIC_SECRET_KEY, bound to the provider id, and
  never stored in the config column. Without the variable, a secret is
  created once in data/instance-secret.key (the Docker volume).
- A secret sealed under another instance secret is reported as
  unreadable ("enter again"), and a save that brings no new secret for
  that provider keeps it, so a misconfigured secret cannot destroy keys.
- Saves replace the document with a compare-and-swap on the revision,
  after the owner's identity lock; two concurrent first saves cannot
  both land (PostgreSQL contract test).
- A claim moves the anonymous configuration to the account unless the
  account has its own (core participant, order 900).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(persistence): publish the instance secret atomically; force the races in tests

Review round 1:

- The generated secret is written and flushed under a private name,
  then published with an exclusive link, so no process can read it half
  written and exactly one of two starting processes creates it. A file
  that is not a complete generated secret (empty, truncated) is refused
  instead of deriving a key anyone could compute.
- The PostgreSQL test holds both first saves at their insert until both
  have read the missing row, and holds the row lock for the update case
  until both saves queue behind it (checked by backend pid, not by any
  lock waiter in the database).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(persistence): verify the written secret; require full GCM tags

Review round 2:

- The generated secret is written in full, read back and checked before
  it is published; the temporary file is removed on any failure.
- Sealed values open only with a 12-byte IV and a full 16-byte tag
  (authTagLength), so a shortened tag cannot weaken authentication.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): resolve slots at request time over deployment, workspace and defaults (#1734)

* feat(config): resolve slots at request time over deployment, workspace and defaults

The runtime half of RFC #1701 resolution (#1725, P1):

- deploymentConfig(): the deployment layer, loaded once per process.
  The legacy translation now splits into the deployment's providers and
  a separate default layer (DEFAULT_MODEL, MODEL_FALLBACK) that locks
  nothing and ranks below the workspace, as DEFAULT_MODEL ranked below
  the model a user picked.
- workspaceLayer(owner) reads the web settings; requestWorkspaceId(req)
  names the request's owner (none for an owner minted by the request).
- lookupSlot walks deployment and workspace over the whole tree first;
  the defaults are a second walk, so a default on a child never outranks
  a workspace choice higher up.
- resolveStageModel builds the language model: the configured slot, else
  what the request still names the old way (deprecated), else the
  defaults, else a loud error; a slot turned off fails whatever the
  request names. A workspace provider's endpoint is checked like a
  caller-supplied one and gets the transport that refuses redirects.
- resolveSlot gains the default source, and reports which layer declared
  the provider, its proxy and credentials.

Nothing calls it yet; call sites switch next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep request identity and workspace endpoints inside their bounds

Review round 1:

- A refused owner credential throws InvalidOwnerCredentialError (401)
  instead of resolving with the deployment's models and keys.
- A retired request owner gets no workspace: canonicalizing it would
  hand an old anonymous cookie the claiming account's settings. Only
  background work, which names a stored owner, is forwarded.
- A workspace provider may not be Amazon Bedrock (which falls back to
  the server's AWS credential chain) or set a proxy (which routes around
  the checked, redirect-refusing transport); the deployment still may.
- Test seams for the deployment config and the workspace loader, and
  tests for identity, forwarding and caching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): read only the named owner's settings; refuse unmet requirements

Review round 2:

- workspaceLayer reads exactly the owner it is given. Forwarding through
  a claim let a request whose owner was claimed between its check and the
  read see the account's settings; background work passes the owner it
  works for now instead.
- A model the catalogue says does not meet the slot's requirement is
  refused before anything is built (SlotRequirementError), without
  consulting the request.
- Tests: a database failure fails the call without falling back, a
  workspace provider without a key never gets the deployment's, and the
  transport each provider source gets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(identity): exempt the model settings lookup from cookie forwarding

requestWorkspaceId resolves the owner only to pick whose model settings a
generation call uses, and returns no response of its own, like the
vision prompt helper already listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config)!: resolve every LLM call through its capability slot (#1735)

* feat(config)!: resolve every LLM call through its capability slot

The LLM call sites of RFC #1701 switch to slot resolution (#1725, P1):

- resolveModel resolves a stage through its slot for the request's or
  job's workspace: the deployment and workspace configuration first; the
  model and key a request names (x-model, x-model-routes, body fields)
  only for a slot left unassigned, deprecated; then the defaults an
  older deployment set with DEFAULT_MODEL. Routes that read headers get
  this through resolveModelFromRequest; the chat routes pass their
  workspace; background work (agent runs, titles, generation jobs)
  passes the owner it works for now.
- Retries follow the slot: a slot-resolved model carries its slot's
  fallback (lib/ai/model-fallbacks.ts), which callLLM and the outline
  stream use; only a model from the request path still retries on
  MODEL_FALLBACK. A fallback that cannot meet the slot is not used.
- The agent driver resolves the agent slot: tool calling required,
  api defaults to openai-completions, thinking.effort still refused.
  Conversation titles resolve agent.title with thinking off unless the
  title slot sets it.
- /api/generate-classroom resolves each step through its slot for the
  job's owner, its outline through course.outline.
- MODEL_ROUTES is gone: loadDeploymentLayer runs at startup and a server
  that still sets it without openmaic.yml refuses to start. The
  pbl-chat and maic-agent stage keys, which nothing resolved, are removed.

BREAKING CHANGE: MODEL_ROUTES is no longer read; write per-stage models
as slots in openmaic.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): a slot fallback needs no serverManaged stamp; follow claims per stage

Review round 1:

- callLLM arms a model's attached slot fallback whether or not the caller
  passes serverManaged (the PBL agents pass none); the stamp still gates
  MODEL_FALLBACK on the request path.
- /api/generate-classroom resolves the owner the job works for now at each
  stage, so stages after a claim follow the moved settings.

Streaming calls (agent driver, classroom chat, PBL streams) still do not
retry on a fallback model, as before this change; that is the #1725 item
on retries at every call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): resolve media and tool capabilities through their slots (#1737)

* feat(config): a provider-only reference names the provider's default model

Search and document providers mostly have no model to pick, so a slot
may name just the provider (`webSearch: tavily`); the target then has no
modelId and the adapter uses its default. Chat slots still need
providerId:modelId, checked in openmaic.yml and at resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(config): resolve media and tool capabilities through their slots

The capability routes and server paths of RFC #1701 (#1725, P1): text
to speech, speech recognition, images, video, web search and document
extraction resolve through their slots, like language models.

- lib/server/model-config/media.ts: the configured slot (deployment,
  then workspace); else the provider a request names the old way
  (deprecated); else the legacy defaults; else a loud error. A slot turned
  off fails whatever the request names, and while the legacy
  <CAP>_<VENDOR>_ENABLED=false switches are in effect a switched-off
  provider stays off whoever assigns it. A workspace endpoint is checked
  like a caller-supplied one and may not use a proxy.
- The legacy translation assigns the media roots the provider the server
  picked when a request named none (first configured; web search by its
  old priority; DEFAULT_IMAGE_PROVIDER for images), with the first pinned
  model.
- Routes: /api/generate/{image,video,tts,voice}, /api/transcription,
  /api/web-search, /api/parse-pdf and /api/extract-document. A TTS voice
  applies only to the provider it was chosen for; voices register on the
  tts slot's provider.
- Server paths: agent image and video generation, scene narration, the
  voice catalog and registration, web search and fetch_url, material
  extraction (the document slot's service, the asr slot for local
  transcription), browserless classroom media, and the generation
  capabilities reported by /api/health and the classroom capabilities
  endpoint.
- A provider-only reference (`webSearch: tavily`) names the provider's
  default model; chat slots still need providerId:modelId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep slots authoritative on every media path

- Workspace providers reach media, search and document services only at
  their preset's endpoints; a custom endpoint or proxy is deployment-only
  (403 INVALID_URL).
- A configured or turned-off document slot decides the extraction service:
  deprecated request fields may only pick a self-contained extractor, and
  legacy operator credentials are no longer reached.
- Request paths resolve their own workspace without following a claim;
  background extraction still does.
- A configured TTS slot keeps its own model; legacy pins apply only on the
  deprecated and default paths.
- Local transcription and agent voice registration keep the connection's
  network policy flags.
- The agent's web search forwards the whole resolved configuration.
- An unusable DEFAULT_IMAGE_PROVIDER leaves the image slot unassigned with a
  notice instead of switching vendors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): public presets only for workspaces, plan default models

- A workspace preset whose default endpoint is on the server's own network
  (self-hosted TTS, ASR, image) is deployment-only, and a workspace
  provider's endpoint runs under the public-only policy.
- A provider-only reference to a token plan means the plan's own default
  model for that capability.
- On the legacy default provider, the request's image, video and ASR model
  still applies through its allowlist, and image/video without a model is
  still MISSING_MODEL.
- An asr assignment a workspace may not use no longer blocks document
  extraction, and a refused document service answers 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): keep legacy search and voice choices on their old paths

- Classroom search honours the provider and key a request names while the
  webSearch slot is unassigned; only /api/web-search prefers the operator's
  configured backend, as before.
- A TTS request's voice applies unless the request chose it for another
  provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): legacy search model and capability discovery errors

- On the legacy default search provider, the request's search model still
  applies through the server's pins.
- Capability discovery answers a refused credential (401) or a workspace
  service it may not use (403) instead of 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): classroom submission refuses a workspace service with 403

A classroom submission whose materials would go to a document or speech
service the workspace may not use answers 403 INVALID_URL instead of 500, and
its material check resolves the request's own workspace without following a
claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): workspace image and video providers run public-only

A workspace-configured image or video provider now uses the strict public
transport for its requests and for the redirects its clip download follows,
whatever ALLOW_LOCAL_NETWORKS says; the operator's own providers and the
deprecated per-request path keep their policies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* Revert "fix(config): workspace image and video providers run public-only"

This reverts commit 68021ded. A workspace provider cannot choose an endpoint
for media, search or document services at all: a custom base URL or proxy is
refused, and so is a preset whose default endpoint is on the server's own
network. What remains is a preset's fixed public endpoint, the same one a
deployment default uses, so these calls keep the operator's transport policy
like every other preset endpoint, and the connection no longer claims a
user-typed endpoint (userEndpoint is false).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): /api/web-search without a provider searches as before

A deprecated web search request that names no provider again means the
server's configured provider, else the default one with the request's key,
while the webSearch slot is unassigned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): browser speech recognition does not count for extraction

An asr slot assigned to speech recognition that runs in the browser gives
server-side extraction no transcription service, so audio uploads are not
advertised or accepted for extraction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): /api/parse-pdf honours an explicit local parser

A request that asks for a self-contained extractor (local parsing) parses
the PDF locally and sends it to no document service, whatever the document
slot names, as /api/extract-document already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(classroom): give the persistence contract its media slots

The generated-classroom PostgreSQL contract configured its image and TTS
providers through the legacy provider mocks, which slot resolution no
longer reads; it now sets the deployment's slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(llm): streaming calls fall back on their slot's fallback (#1739)

* feat(llm): streaming calls fall back on their slot's fallback

A stream resolved through a slot that fails before any content (the request
is refused, or the first part is a retryable error) runs once on the slot's
fallback model. A failure after content has started still reaches the caller.
The outline stream keeps its own retry and fallback handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(llm): the stream fallback is the call's last attempt, before any content

- The primary's own SDK retries run first; the fallback runs once, as the
  last attempt, and a failing fallback is not retried.
- No fallback once content reached the caller in any step (a tool that ran
  must not run again); after a fallback took over, later steps stay on it.
- The fallback gets the thinking options built for its own provider and
  model, not the primary's.
- Usage of a stream with a slot fallback is recorded per step, against the
  model that served the step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(llm): stream fallback recognises transient error payloads

Providers send a stream's error part as a plain { type, message } payload
rather than an error; a transient type (overloaded, rate limited, server
error) now counts as retryable for the slot fallback, while authentication
and request errors still do not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(config): model settings API for workspaces (#1740)

* feat(config): model settings API for workspaces

GET/PUT /api/model-config read and edit a workspace's slots and providers:
every slot with its own assignment, effective model, source and lock state;
deployment providers read-only and without credential details; workspace
keys write-only and masked. Edits are checked against the whole
configuration and a revision. The view carries the preset catalogue a
workspace may add providers from, and each provider's models per capability,
so the settings UI needs no registry code of its own.

Workspaces cannot add Bedrock, self-hosted media/search/document presets or
custom endpoints for anything but chat. POST /api/model-config/import merges
settings a browser kept, item by item under the same checks, never replacing
what the workspace or deployment already has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(config): preset ids as client-safe data

The preset id tables move to lib/config/preset-ids.ts, which imports no
registry, so browser code (the settings import) can name presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): tighten the settings API's views and checks

- Effective targets name an endpoint only for the workspace's own
  providers; a deployment's endpoints never reach a response.
- A workspace provider of a local model server preset (whose default
  endpoint is the server's own network) must name its own endpoint.
- Every change is checked against the stored shape, so an import skips a
  malformed item instead of failing the batch, and PUT validates its whole
  body (400, never 500).
- Clearing a key deletes it even when the instance can no longer open it.
- A workspace provider with its own endpoint offers chat models only, and
  an OpenAI-compatible provider offers only the models it lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): no credentials in endpoints; key-pair presets stay in the yml

- A workspace base URL cannot carry a username or password (they would be
  stored and shown in the clear), and views never show credentials a stored
  endpoint carries.
- Presets that authenticate with a key pair (AliDocMind) are not offered to
  workspaces: the settings take one key per provider.
- An import checks each proposed provider on its own, so one malformed
  provider is skipped instead of failing the batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): settings neither offer nor accept switched-off providers

A provider the operator switched off for a capability (the legacy
<CAP>_<VENDOR>_ENABLED=false switches) is left out of the capability lists,
refused as an assignment, and shown as invalid where a deployment assigns it,
as the calls themselves refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): recommendations name only capabilities a preset still offers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): a provider id follows the reference grammar before it is used

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): the catalogue lists the models a search provider offers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): refuse a fallback on a slot whose calls never use one (#1743)

* fix(config): refuse a fallback on a slot whose calls never use one

Only language-model calls retry on a slot's fallback; a fallback on a
speech, image, video, search or document slot was accepted and silently did
nothing. Resolution now refuses it by path, so openmaic.yml fails at startup
and the model settings refuse it on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): openmaic.yml refuses a non-chat fallback at startup

The boot check parses openmaic.yml without resolving slots, so the refusal
also lives in the file's cross-checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): Models section with the course model map (#1741)

* feat(settings): client for the workspace model settings

A small client for /api/model-config: it caches the view per page, applies
changes against the revision it read, reloads on a stale revision (409
CONFLICT) or a slot the deployment has since locked, and treats a server
without persistence (404) as settings managed by the server.

Pure helpers behind the settings UI live beside it: slot changes from the
card picker (follow, off, a model, a fallback, the media switches), the
provider form's change (keys stay write-only: keep, replace or remove),
the first-run setup that adds a provider and fills only the empty,
unlocked slots with its preset's recommendations, and the layout of the
course model map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings): Models section with the course model map

A new "Models" section, first in the settings dialog and the one it opens
on. It reads and writes everything through /api/model-config:

- The model map: a pannable, zoomable canvas with the course pipeline as a
  two-row serpentine flow, the default language model above the stations
  that inherit from it, and the page types under the content station. Each
  card shows the effective model, where it comes from and a lock when the
  server sets it. Solid edges follow the parent, dashed ones mark a
  setting of the slot's own.
- Editing happens in place: a picker at the card to follow the parent,
  pick a model a provider offers (or a provider, for search and document
  slots), turn the slot off, or set a fallback for chat slots. Media
  switches turn a slot off and back on.
- While no language model is configured, the default model's card offers
  a first-run setup: pick a service, give its key, and its recommended
  models fill every empty slot.
- A Providers tab lists the server's providers (read-only) and the
  workspace's own, which can be added, edited and removed as the server's
  policy allows.

The generation toolbar's "configure provider" prompt opens this section.
Below the sm breakpoint the settings nav becomes a strip above the panel.
Strings are translated for all 12 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keep what a change does not mean to touch

- A provider edit carries only the fields the form shows: a hidden model
  list or endpoint is left out, so the server keeps it; a shown field that
  is emptied is removed. A pinned model list keeps its field on edit.
- A key the server cannot read starts as "replace" (it can also be removed),
  so a key typed for it is sent instead of the broken one being kept.
- Switching a media slot off remembers what it held; switching it back on
  restores that assignment (or nothing of its own), and when that is not
  known the caller asks instead of clearing the slot to the default.
- The first-run assignments are checked against the provider as the server
  answered it: a provider with its own endpoint serves chat only, a model
  list the user gave wins over the recommended chat models, and `llm` is
  one of the provider's own chat models. The filling can be retried against
  a reloaded view, and a refusal keeps its reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): recoverable first-run, root clear action, keyboard on the map

- A first-run setup that added its provider but could not assign it (a
  stale revision, say) reports to the section, which keeps a notice with
  the provider and a way on (assign its models again, or add them under
  Providers) through any reload, instead of losing it with the form.
- The picker of a root slot with a setting of its own offers to clear it,
  leaving the server's value or default.
- A card's switch restores what the slot held; when that is unknown it
  opens the picker.
- Keyboard focus on a card outside the view pans the map to it, and the
  arrow keys pan the map while it has focus.
- The provider form offers only replace or remove for an unreadable key,
  and says that a provider with its own endpoint serves chat only.

Component tests drive the provider form, the switches, the root picker,
arrow-key panning and a first-run setup that meets a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keep picker row labels whole; one unreadable-key notice

A long note no longer truncates the row's label in the slot picker, and the
provider form stops repeating the unreadable-key warning the row already
shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a lost answer to a write is an outcome, not a throw

When the server takes a change but its answer cannot be read (a truncated
body, a dropped connection), apply() no longer rejects: it reloads the view
to reconcile a change that may have been saved and returns an `unconfirmed`
outcome with the reloaded view. The first-run setup goes on when the
reloaded view has the provider it added, counts a slot write whose answer
was lost as done when the reload shows the default model set, and says so
otherwise. Busy states in the provider form, provider removal, first-run
setup and its notice are reset in `finally`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): keyboard roving in the slot picker

The picker's rows are plain buttons (pressed for the current choice) in a
group, not listbox options without the keyboard model those promise. The
list is one Tab stop, the current choice or else the first row; ArrowUp and
ArrowDown step, Home and End jump, Enter and Space choose. The picker opens
on that row. The picker's and the card switches' busy states are reset in
`finally`, and the component tests cover the lost-answer first-run paths
and the picker keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a write lost in transport is unconfirmed, and first run can resume

A write whose request fails in transport may still have been saved, like
one whose answer is lost: apply() now reloads the settings and returns an
`unconfirmed` outcome in both cases, with the reloaded view when that read
worked. A first-run setup whose provider add cannot be confirmed (the
reload failed too) keeps a notice that says so, and "Check again" reads the
settings and either assigns the provider's models or says it was not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): retry an unconfirmed provider add under the same id

The add-provider form keeps the id it tried. When the answer was lost it
closes if the reloaded settings show that provider, and otherwise retries
under the same id, so a save that did land is updated rather than joined by
a second provider; a genuinely new add still gets a fresh id. Component
tests cover this and the first-run paths where the provider add fails in
transport after the server saved it, or cannot be confirmed at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): 5xx answers to writes are unconfirmed; reads never go back

A server error or a gateway timeout can come after a write was saved, so a
5xx answer to a write is treated like a lost answer: the settings are read
again and the outcome is `unconfirmed` with the reloaded view. A 4xx is
still a refusal, without a reload.

Reads and adopted write answers are numbered: a read's answer is dropped
when a later read started or a write's answer was adopted after it began,
or when its revision is older than the view held. The reads that must see a
write just made (after an ambiguous write, a conflict, "Check again") start
a fresh request instead of joining one begun before the write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): first run is done only once the default model is set

A slot write that succeeds without setting `llm` (another session turned it
off meanwhile, so only media slots were filled) no longer counts as a done
setup: the notice stays and says the default model is still missing, for
the user to pick on its card. Test views now carry `llm` resolved the way
the server answers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): the revision decides which settings view is newer

A view with a lower revision than the one held never replaces it, whether
it comes from a read or a write's answer, and one with a higher revision
always does, whenever it arrives; the order in which reads and adoptions
were numbered only breaks ties at an equal revision (and decides while no
view is held, so a read begun before the view was forgotten does not bring
it back). A write whose answer is older than a view read meanwhile returns
the view that is current. `adopt` takes a view or null (forget it) and is
part of the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): send each change against the view it was worked out from

apply() takes the view a change was computed from and sends that view's
revision, rather than the revision of whatever view is held when the write
goes out. A retry of the first-run assignments that waits for a reload in
flight is therefore refused (409) when the reload shows the settings
changed, instead of overwriting a slot set meanwhile; the reload then feeds
the next attempt. The slot picker, the card switches, the provider form,
provider removal and the first-run setup all pass the view they showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a provider edit sends only what changed, and rebases on a conflict

The provider form keeps the basis of an edit: the provider as the edit
began and the view it came from. A save sends only the fields changed
against that basis (a key-only edit sends the key), against the basis
view's revision. When the provider changed elsewhere meanwhile (409), the
edit moves onto the reloaded provider, keeping only what the user changed,
and the form says the provider changed before it is saved again. A 409 now
returns the reloaded view to work the change out again from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): a refused provider add keeps its draft and picks a free id

Only an add whose outcome is unknown (a lost answer, a transport failure, a
5xx) is reconciled by looking for its id in the reloaded settings. A
confirmed refusal (a 409, another tab having added the same preset under
the same id meanwhile) saved nothing of ours: the form keeps the draft,
shows the conflict, and the retry uses an id still free in the reloaded
view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(settings)!: server-side model settings only; import browser settings once (#1744)

* feat(settings): one-time import of browser model settings

The settings store's migration to version 5 builds an import proposal
from the provider state earlier builds kept in the browser (keys, custom
endpoints, the chosen model, token plan enrollment, per-capability
selections) and keeps it under its own localStorage key. Once the store
has hydrated, the proposal is posted to /api/model-config/import: a 2xx
answer removes it (and the keys) from the browser, 400 drops it, and
anything else keeps it for a later load. Per-stage routes are not
imported. Clear Local Cache keeps a proposal still waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings): stop sending provider data from the browser

Every request now leaves model and provider choice to the server, which
resolves it through the workspace's capability slots: no model, key or
base URL headers (x-model, x-api-key, x-model-routes, x-image-*,
x-video-*), no provider, key or thinking fields in chat, TTS, voice
registration, transcription, web search or document extraction bodies.
The server keeps accepting them until they are retired.

What the client still needs to know is read from the /api/model-config
view (lib/model-settings/capabilities.ts): whether a language model is
set up, which provider the tts slot names (browser speech plays locally;
voice lists follow that provider), whether speech input, image, video
and web search are available, and the course model the toolbar shows or
edits through the settings API. The scene concurrency comes from
/api/health. The unused TTS config popover and getCurrent*Config
helpers are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings)!: remove the browser provider state and its settings UI

The settings store keeps only the user's own preferences (playback,
narration voice and speed, speech input language, outline review,
agents, layout). Providers, keys, base URLs, the model choice, thinking
settings, per-stage routes, token plan enrollment and seeds, the
per-capability provider configs and selections and their on/off
switches are gone; the version 5 migration sets them aside for the
one-time import and drops them. The narration voice now records the
provider it was picked for and applies while the tts slot names it.

The Token Plan, Model Services and Course Model sections and their
components are removed, with apply-token-plan, the server provider sync
(ServerProvidersInit, fetchServerProviders) and helpers only they used.
A Voice section keeps the per-user parts of the old speech settings:
speed, a narration test, the VoxCPM and Qwen voices, and the speech
input language, showing which services the workspace uses. The home
toolbar picks the course model through the settings API, or shows it
read-only when the deployment locks it.

e2e fixtures answer /api/model-config instead of seeding browser
providers; the managed-provider spec, which covered the removed panel,
is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings): browser speech recognition while the asr slot is unassigned

Speech input worked out of the box through the browser's own recognition,
which needs no server provider; it stays available while the asr slot is
unassigned, and turning the slot off turns speech input off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): bind the model settings import to its owner and never lose staged keys

The import now asks for the browser's legacy import binding and sends
X-OpenMAIC-Legacy-Import, so owner resolution refuses it for any owner
that does not hold the browser (409 LEGACY_IMPORT_NOT_BOUND keeps the
proposal for a later load); the import route joins FENCED_ENDPOINTS.
Its completion is the proposal's own key, apart from the course ledger.

Staging reports whether it succeeded. When the proposal cannot be
written (a full storage, an unreadable proposal waiting), the migration
keeps the old settings in legacyModelSettings and every load retries,
writing the store back without them once staged. Nothing logged quotes
the proposal or an error message: fixed text, item ids, error names.

Older shapes are normalised before the proposal is built (version 0
default model, the single TTS model setting, global TTS/ASR model ids,
a TTS provider's model field, the flat web search key). Capabilities
the user turned off are proposed as off (null), and selected
server-configured media providers are named by their preset id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): retry a failed read of the model settings

A failed read of /api/model-config with nothing to show is read again
with a backoff (2 s up to a minute) until one succeeds, instead of
leaving the page without capabilities for the rest of its life.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): send no provider or model with voice registration

Voice registration, deletion and auto-registration no longer send
providerId or ttsModelId: the server registers with the provider and
model the tts slot resolves to. The model still derives the voice id
and keys the session memo. The unused OpenRouter model list hook, which
called the vendor with a browser key, is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): carry only an explicit speech input off into the import

Only `asrEnabled: false` becomes an off slot (`asr: null`): without an
asr slot the browser's own speech recognition would take over, which the
user had turned off. The TTS, image, video and web search switches were
per-browser toggles that availability following the slots replaces on
purpose; turning them into lasting workspace nulls would override the
deployment's defaults from then on, so they are not carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): gate chat and generation on the slots they resolve

Chat and discussion resolve the classroom slot, and course generation
its outline, actions and content slots; each is now allowed whenever
those resolve to a model, even with the llm root unassigned or off
(a child assigned on its own). Settings not read yet still block
nothing. The toolbar offers "Set up model" only when a course cannot
be generated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import models the user added to built-in providers

A built-in chat provider whose model list held models the user added
(ids not in its catalogue) is proposed with those models, listed after
the catalogue's: a provider's model list names the chat models it
serves, so listing only the added ones would hide the catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): wait for the commit explicitly in the media overlap test

The overlap test counted 50 microtasks for the mocked commit to be
reached; with the capability read before each pass that is no longer
enough, so the test failed alone and left a pass running into the next
test. It now awaits a signal from inside the commit, flushes every
pending microtask before asserting, and releases and awaits both passes
in `finally`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import models the user added to an enrolled token plan

An enrolled plan's chat provider whose model list held models the user
added (ids not in the plan's own list) is proposed with them, listed
after the plan's, by the same rule as built-in providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): scope voice registration memos to the tts slot's provider

The session memo and in-flight map of auto-voice registration were keyed
on the voice and model only, so after the tts slot moved to another
backend serving the same model, registration was skipped and synthesis
named a voice that backend never registered. They are now scoped to the
provider the slot resolves to (provider id, registry entry, endpoint)
and the settings revision, captured once per registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): show the view the model settings import produced

After a successful import the page read the settings again, but that
read could join the page's first read, still in flight from before the
import, and leave the unassigned view in place. The import now hands
over the view the import route answered, and adoptNewerView lets any
read in flight finish before keeping whichever view is newer by
revision, so an older answer cannot land over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): offer and resolve voices against the tts slot's model

With the tts slot on a model that cannot speak some voices (an OpenAI
slot on tts-1 and Marin or Cedar, which need gpt-4o-mini-tts), the
picker still offered them and synthesis on the slot's model failed.
The slot's model (its own, else the provider's default) is now the
model voices are offered under and checked against: the voice lists
hold only voices it can speak, a persisted voice it cannot speak falls
back to a compatible default, and narrator and agent bindings to such
a voice are not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): drop translations only the removed provider panels used

208 keys that only the removed provider settings (model services, token
plan, course model config, their dialogs and the TTS popover) used are
removed from all 12 locales: API key and base URL fields, provider and
model editors, connection tests, per-capability switches and the like.
Each was checked to have no remaining reference, literal or through a
key prefix built at run time. The TTS enablement locale test keeps the
key still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): provider options in openmaic.yml, passed to the TTS adapters

A provider in openmaic.yml may carry `options`: non-secret,
provider-specific settings (a map of names to strings, numbers or
booleans; `${VAR}` interpolation applies). They reach the resolved
slot target, the media connection and the settings view, and the
server's TTS paths (the TTS and voice routes, scene narration, classroom
media generation, the voice-clone tools) hand them to the adapter as
its providerOptions: over a request's options for a configured slot,
under them while the slot is unassigned (the deprecated path). Voice
registration follows them too (a VoxCPM backend without runtime
registration is refused). Workspace providers cannot set options: they
are the deployment's. The legacy configuration had no equivalent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): read the VoxCPM backend from the tts slot's options

The voice settings, the voice lists and auto-voice registration assumed
the default VoxCPM backend once the browser no longer chose one. They
now read the `backend` option of the provider the tts slot resolves to
(shown in the settings view), and the registration memo is scoped to
the provider's options as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): label the preset choice when adding a provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): media planning from the slots; stop when settings are unread

The outline route decided whether to plan images and video from the
client's x-image-/x-video-generation-enabled headers, and the client
sent them from settings it might not have read: a course could be
generated silently without media. The route now reads the workspace's
image and video slots itself (an explicit `false` header still lets an
API client opt out; `true` turns on nothing), and the client no longer
sends them.

When the model settings cannot be read even after another try,
generation stops with a message instead of leaving out narration (the
scene fails and generation pauses; the preview's first scene errors),
and a media pass or retry stands down without marking anything as
disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): pick the course model when none is set

Without a default model the toolbar showed no picker, so the course
model could only be set in Settings. It now offers the picker whenever
the workspace may set the llm slot and chat models exist, with nothing
selected until one is picked (which sets the slot; the server assigns
nothing on its own). The legacy translation's notice for a model whose
provider the server does not configure now says each workspace chooses
its model in Settings → Models, or to configure the provider and set
DEFAULT_MODEL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): import keyless search choices and the Claude search model

A keyless search service (Brave) that the user selected with research
switched on is proposed as a provider of its preset with the webSearch
slot on it; untouched defaults and self-hosted services (SearXNG, whose
endpoint only the deployment may set) are not. The model picked for
Claude web search is carried as `claude:<model>` instead of being
dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): refuse credential-like provider option names

A provider's `options` are shown in the settings view, so a name that
matches key, secret, token or password is refused with a pointer to
apiKey and credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): pin which course slots the generation gate needs

courseGenerationUsable requires the outline, actions and a content slot.
The agents and research slots are not required, and a test pins it: with
course.agents off the roster falls back to the preset agents, and with
course.research off web search runs on the raw requirement, so neither
request is refused by the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(settings): what the import leaves behind; extraction is a slot

The importer README lists what is not carried over and where it is set
now: per-stage routes, the per-browser media and research switches,
Baidu sub-sources, thinking settings and the VoxCPM backend (options in
openmaic.yml). The document extraction README no longer documents the
removed store fields and request-level provider fields: extraction is
the workspace's document slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): release the recorder lock when a start is refused

startRecording returned early without clearing its lock when speech input
was not set up (the asr slot off, or still unknown while the settings
loaded) or the browser lacked speech recognition, so every later click
did nothing until the component remounted. Each early return now
releases the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): decide research from a successful settings read

The home page saved `webSearch: undefined` into the generation session
when the model settings could not be read, and the preview researched
only on that saved flag, so a transient failure skipped research for the
whole generation. Saving the session now needs a successful read (read
again after a failure, else it stops with the "settings could not be
read" message), and starting or resuming generation decides research
again from the current webSearch slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(settings): type the research decision session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): a policy without workspace providers also stops using them (#1750)

* fix(config): a policy without workspace providers also stops using them

`policy.allowWorkspaceProviders: false` only stopped workspaces from adding
or editing providers; ones added before kept serving their assignments.
Resolution now leaves them out, with the workspace assignments that name
them (an assignment whose fallback alone names one keeps its model), so the
policy takes effect for existing workspaces too and the settings view shows
what the calls use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): policy keeps shared provider ids and refuses new references

- A workspace provider id the deployment also declares resolves to the
  deployment's provider, so its references are kept under the policy.
- The model settings refuse a new assignment to a workspace provider the
  policy no longer allows, while other edits leave dormant ones as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): settings validate a workspace as the policy lets the calls see it

Assignments the policy leaves dormant are not checked on unrelated edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs(config): openmaic.yml, capability slots and the model settings (#1742)

* docs(config): document openmaic.yml, slots and the model settings

The configuration docs are rewritten around openmaic.yml: providers, the
slot tree and its inheritance, assignment forms, provider-only references,
fallbacks, turning a capability off, locks and the workspace model settings,
policy, OPENMAIC_SECRET_KEY, what workspaces may add, the deprecated request
fields, and migration from provider variables, server-providers.yml,
DEFAULT_MODEL/MODEL_FALLBACK and MODEL_ROUTES (with the stage-to-slot table).
The environment-variable reference stays as the legacy configuration.

Deployment, supported models (a preset catalogue), getting started and
VoxCPM2 point to openmaic.yml; the READMEs' quick configuration and the agent
runtime example use it; the openmaic skill tells agents to configure models
through openmaic.yml or the model settings. openmaic.example.yml is a starting
point, and a test parses it and every documented openmaic.yml example with the
real schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): translate the openmaic.yml documentation

Brings the zh-CN, zh-TW, Japanese, Russian and Arabic pages in line with the
English configuration, deployment, supported models, getting started and
VoxCPM2 pages: the same sections, examples and tables, with the legacy
environment-variable reference kept as before under its own heading. Also
fixes the zh-CN VoxCPM2 link to the TTS section, which pointed at the English
anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): match the settings UI names, set a model in the quick start

- Configuration: the settings tabs are "Model map" and "Providers", the lock
  reads "Set by the server", and the first-run setup is "Connect a service".
  Browser import: keys are removed from the browser once imported (a failed
  import keeps them), and speech input that was switched off becomes
  asr: null.
- Getting started: the browser no longer sends a model, so the quick start
  sets one, with a minimal openmaic.yml or DEFAULT_MODEL next to the key, and
  names Connect a service in Settings > Models as the path without a file.
- openmaic.yml is in .dockerignore so a file with inline keys is never baked
  into an image; the deployment page says to mount it at run time.

All six locales are updated where the text changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): scope fallbacks, trim the example, translate volume repair

- Fallbacks apply to language-model slots (llm and the slots below it); a
  fallback on a media, search or document slot is refused at startup or on
  save.
- openmaic.example.yml is copied as-is by the READMEs, getting started and
  the skill, and startup refuses any unset ${VAR}: it now has one active
  provider (OPENAI_API_KEY) and the llm slot, with every other provider and
  slot as a commented optional block. The copy instructions say so.
- The translated deployment pages gain the root-owned volume repair
  paragraph and its chown command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): configure self-hosted and custom media endpoints in openmaic.yml

Workspaces cannot add custom non-chat endpoints or self-hosted media presets,
so the docs no longer send users to the browser settings for them:

- Configuration (legacy reference): custom OpenAI-compatible TTS and ASR
  endpoints and ComfyUI are declared in openmaic.yml with their baseUrl and
  assigned to tts / asr / image; custom chat endpoints are an
  openai-compatible provider in openmaic.yml or the Providers tab. The claim
  that ASR configuration stays in client settings is gone.
- VoxCPM2 (page and READMEs) is configured in openmaic.yml, with the legacy
  variable as the fallback; the per-browser Base URL option is removed.
- MinerU in the READMEs is a document provider in openmaic.yml.
- Deployment and ComfyUI: providers declared in openmaic.yml are
  server-managed and may use private endpoints without ALLOW_LOCAL_NETWORKS,
  which only matters for endpoints a user types (chat base URLs in the model
  settings, deprecated request fields).

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): document provider options and the VoxCPM2 backend option

- Configuration: the provider fields table documents `options`: non-secret,
  provider-specific settings (string, number or boolean values, ${VAR}
  allowed), shown in the model settings so never a key, and deployment-only.
- VoxCPM2 (page and READMEs): the backend is chosen with options.backend on
  the provider in openmaic.yml (vllm-omni by default, python-api, nano-vllm),
  not in the browser settings; voice registration works only on vllm-omni,
  and the troubleshooting row points at options.backend.
- The example test skips openmaic.yml blocks that set provider options until
  the schema on this branch accepts them (marked TODO).

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(docs): check the VoxCPM2 examples now that provider options exist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): describe the browser import, Voice settings and web search as released

- Browser import: the proposal and its keys are removed after any 2xx (even
  with skipped items, which are kept nowhere), and after a 400 or an
  unreadable proposal; it stays for another attempt only on 401, 404, 409,
  5xx or a network error. Lists what has to be set up again by hand
  (per-stage models, thinking settings, custom speech and transcription
  providers, AliDocMind's key pair, the VoxCPM backend, Baidu sub-sources)
  and that the per-browser capability switches do not carry over.
- VoxCPM2 voices (page and READMEs): assign VoxCPM to tts, then Settings >
  Voice > VoxCPM voices, which appears only when tts resolves to voxcpm-tts.
- Web search has no per-generation switch: it runs when the workspace's
  webSearch slot resolves, and turning it off affects later generations.
- The READMEs' persistence section no longer says keys go in .env.local only.

All six locales are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(config): startup errors name the field, or the line for broken YAML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ci: run CI for the server-first integration branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: give the lint, typecheck and unit test job 25 minutes

The job's runtime varies between about 9 and 14 minutes, and a pull request
run was cancelled at the 15-minute limit after its tests had passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(generation): extract the classic generation steps into shared server functions (#1756)

Moves the classic generation steps from the API routes into lib/server/generation/steps (no behaviour change; routes are thin wrappers). Video steps report the submitted provider task with its effective endpoint and refuse to resume on a different connection. Route characterization tests pin refusals and the outline SSE stream. Part of #1754 / #1755 (E1a).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(storage): versioned SQL migrations per store (#1757)

Every PostgreSQL store declares ordered, versioned migrations recorded in openmaic_schema_migrations; v1 baselines equal earlier releases' DDL; one-time steps run once; startup refuses a database upgraded by a newer release. @openmaic/storage 0.37.0. Part of #1754 / #1755 (M; #1658 phase 7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(agents): agent registry on the server — built-in agents in code, custom agents in the database (#1758)

Built-in agents stay in code; custom agents move from localStorage to an owner-scoped owner_agents table with an API, a server resolver (resolveAgentsForOwner) and a one-time, lock-protected legacy import. Part of #1754 / #1755 (G).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): server-side generation runs (E1b) (#1759)

* feat(generation): server-side generation runs with checkpoints and takeover

A run is an owner-scoped PostgreSQL record that executes the classic
pipeline with the shared step functions, in the browser's order and with the
context the browser threads through them, checkpointing after every step.

- Schema (store generation-runs): runs with their input, state, outline and
  revision, agents, course id, progress, lease and takeover counters; per-step
  checkpoints; an ordered event log (seq per run); idempotent commands.
- Engine: preparing -> outlining -> awaiting_outline_confirmation ->
  generating -> completed, plus paused (a step failed after its retries) and
  ended (the course was deleted). Every commit is fenced by the lease
  generation; the course document is created with the first scene, scenes are
  appended as they complete and generationComplete is set at the end.
- Runner: a lease-coordinated worker in every process, independent of the
  agent runtime flag, with heartbeats, takeover of orphaned runs and a cap on
  repeated takeovers of one step.
- API: start (per-owner active-run limit), snapshot, event stream with
  replay after a seq, active runs, the owner's run stream, confirm-outline and
  retry, all owner-scoped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): run review round 1 (deletion, discard, atomic writes, parity, streams)

- Deleting a course ends its run in every state, in the deletion's own
  transaction; a run without a course can be discarded with
  DELETE /api/generation-runs/:id. Runs waiting for outline confirmation no
  longer count toward the per-owner limit.
- The start body and an edited outline are validated against the outline
  schema with size, count and order limits, under a body byte cap.
- Narration uses the browser's shared voice logic: the teacher's voice
  options (VoxCPM prompt) and the single retry after a missing Qwen clone.
- Scene appends and completion commit the document change, the checkpoint
  and the events in one transaction fenced by the lease; completion sets
  only the completion flags.
- A takeover-capped pause keeps a resumable state; the runner hands back a
  claim of a run it still executes and stops claiming in that scan; the
  automatic outline confirmation happens in the outline's commit; prewarmed
  content is aborted before a pause; narration allocations are fenced and
  released when their attempt fails.
- Background reads follow every claim of the run's owner; run streams have
  backpressure, a bounded queue and a per-owner cap; finished runs keep only
  their final events.
- Parallel mode marks a failed scene and pauses after the others; auto agent
  generation falls back to the learner's selected presets; the agent
  resolver matches the registry's signature and error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): run review round 2 (limits, one narration step, outline normalizer, streams)

- Confirming an outline takes the per-owner lock and respects the limit on
  runs in progress (429 ACTIVE_RUN_LIMIT); starts are capped at
  OPENMAIC_MAX_WAITING_RUNS_PER_OWNER runs waiting for confirmation.
- Narration and the scene append are one step: the clips and the scene that
  names them commit together, and a failure retries both.
- One outline normalizer for the outline step's output and edited outlines:
  nulls are absent, loose optional members are dropped, hard limits stay on
  scene count, size, ids, orders and type; the outline step stops at the
  scene cap. A generated outline always confirms unchanged.
- A reader behind a finished run's compacted log gets a resync frame.
- Run streams end on the request's abort, the queue cap is hard (an oversized
  frame goes only into an empty queue), and the owner stream moves its
  cursor only past frames it queued, in commit order.
- Course writes use the owner after every claim (transitively), completion
  touches the stage row so its revision trigger fires, and auto agent
  generation falls back to the default presets when none were selected.
- A claim handed back does not count as a takeover; stop waits for a scan in
  flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): run review round 3 (scene types, byte units, retention, custom voices)

- The outline normalizer no longer checks a scene's type against the known
  ones (a bounded string is enough): as in the browser, a scene of a type no
  content path supports fails at its content step and follows the parity
  rules (serial: pause; parallel: marked, skipped, pause at the end).
- The outline step's read cap and the normalizer's size cap are both UTF-8
  bytes, and the normalized result is measured too. Quiz configurations are
  kept only when complete and usable; question counts, media requests and
  orders are bounded.
- Finished runs are no longer compacted at their final commit: a periodic
  sweep compacts them after a grace period
  (OPENMAIC_GENERATION_RUN_RETENTION_HOURS, default 24). The events stream
  sends `resync` on any gap after its cursor, on every page.
- Custom preset agents keep their voice metadata in the agents checkpoint,
  so a custom teacher narrates with its own voice.
- The engine's view of the run moves only after a commit resolved; the
  completed-scene count comes from the checkpoints; the per-owner limit is
  locked and counted under the canonical owner; a pull during an in-flight
  read is not lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): run end-to-end findings (scene validation, deadlines, narration MIME)

- The actions step validates the complete scene (content and actions) with
  the document's own scene validator before its checkpoint: an invalid scene
  (an action without a type) is an actions failure, regenerated by the
  step's retries, and pauses at actions when they are exhausted, so Retry
  regenerates the actions. Narration only ever sees validated scenes.
- Every provider-calling step runs under its browser route's budget
  (outline 300 s, agent profiles 120 s, content 300 s, actions 60 s, a
  narration clip 30 s) through a signal combined with the run's; a timeout
  is retryable. The steps pass the signal to their provider calls, so a lost
  lease or a deleted course cancels the call in flight. Retries log and
  record their cause.
- Narration clips are stored with their real media type (mp3 as
  audio/mpeg; opus as audio/ogg), so they are served inline.
- A scene type without a content slot of its own resolves through
  course.content and reaches the content step's refusal.
- No lease-lost warning for a run that gave its lease up; an edited outline
  is numbered by its position, as the browser's editor numbers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): narration webm as audio, and a quiet stop for an ended run

- Narration clips map their format with an audio-specific type: webm is
  audio/webm (the shared classroom map types it as video), so a webm TTS
  response is stored instead of being refused until the run pauses.
- A worker whose run was ended on purpose (its course deleted or discarded)
  stops with an info line, not a lease-lost warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): use the legacy document default for automatic extraction (#1760)

With only the PDF_* provider variables (no openmaic.yml), the document
slot resolves to the translated legacy default, but material analysis
used the slot's service only for a configured slot, so a request that
named no extractor fell back to unpdf instead of the configured MinerU.
Use the slot's service for a legacy default too; a provider the request
names still follows the legacy rules.

The translated default also follows the order earlier releases picked
in the browser: MinerU Cloud, then self-hosted MinerU, ahead of the rest
of the configured services.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): media lane, material images as course assets, read-only course while generating (E1c) (#1761)

* feat(generation): media lane, material images as course assets, read-only course while generating (E1c)

Runs now generate the media the outline asks for, as the browser's media pass
does: once the course exists, alongside the scenes, one item at a time in
outline order, only for the kinds whose slot resolves. The bytes go to the
asset pool and the placeholders in the course are rewritten to the allocated
ids (a scene not written yet gets them with its own write). Every item is
checkpointed: a video records its provider task at submission and a takeover
resumes the wait instead of submitting again; stored bytes are checkpointed in
the allocation's transaction, so a takeover places them instead of paying for
them again. A media failure leaves the placeholder and does not pause the run;
`retry` takes an optional media element and regenerates only it, in a run that
is generating, paused or completed (a paused or completed run is claimed for
its media alone, through the new `media_pending` flag, migration 2).

Material images are stored as course assets by the material analysis and the
outline and scenes are generated with them by id, as the content step's
server-backed transport expects; no image bytes are kept in checkpoints.

A course is read-only to every other writer (classroom editor saves, the Pro
agent's tools) until its run completes or ends: content writes through the
owner-bound document store are refused with COURSE_GENERATING (409 on the
stage routes); the run's own lease-fenced writes go through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): a media retry after completion keeps the course editable

The read-only guard now applies only while the run itself is not completed or
ended (its first media pass included). A Retry on a completed run no longer
locks the course: its result is placed with a targeted read-modify-write of
the current document (`mutateScene` of the owner-bound store, one transaction
under the course's ownership row) that rewrites only the slots still holding
the element's placeholder and keeps the author's other edits, and touches the
stage row so an open editor reloads. When the author removed the element or
its scene meanwhile, the result is dropped, its bytes are released and the
item fails as MEDIA_ELEMENT_REMOVED, which Retry refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): media lane review fixes (slots, video tasks, leases, placement, asset lifetimes)

- Media slots resolve per kind: turned off or unassigned skips the kind (a
  Retry generates it once the slot resolves), a refusal the routes map
  (INVALID_URL, INVALID_CREDENTIAL) fails its items with that code, and any
  other fault fails the media left, never the run.
- A video keeps its provider task through every failure but the provider's
  own final answer (ProviderTaskFailedError, now raised by the polled-task
  helper and the adapters for a task that failed or finished without a
  result) or a changed connection; a Retry waits on that task instead of
  paying for another. The task record is written again in place when the
  write fails for a passing reason.
- A step Retry no longer takes the lease from a worker generating a paused
  run's media; that worker goes on with the run when it is done.
- Placement into a completed course changes only the matched media slots (no
  whole-scene sanitizing), marks the item done with the last scene written,
  and recognizes bytes already placed before an interruption.
- Allocations a live run holds (material images, held media) are kept from
  expiring by the runner and released when the run ends; bytes that are gone
  anyway fail loud instead of being dropped.
- generation-complete goes through the read-only guard (409); the run
  snapshot reads the run and its media in one statement; migration 2 indexes
  the runs producing a course by stage_id.
- `generating` is checkpointed, stored bytes report done only once placed,
  posters are released with a failed item, stored bytes survive a placement
  fault, and the claims and asset calls follow the run's owner through claims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): media lane round 2 (video Retry submits anew, stored bytes are never lost)

- A video's provider task is kept only to survive a takeover: once the item
  is recorded failed, for any reason, the task is dropped and a Retry submits
  a new one, as the browser's does. ProviderTaskFailedError and the adapter
  changes are reverted; the shared polled-task path is the base's again.
- Stored bytes are done only once placed: a completed run whose placement
  failed keeps them stored with media_pending (stored counts as work in a
  completed run), and the next claim places them. At completion only bytes
  no generated scene holds are done without a placement.
- Bytes found stored before an execution are checked before they are
  placed: missing bytes fail the item loud (with a Retry), a missing poster
  is left out. A placement fault at completion keeps the bytes stored instead
  of ending the execution.
- The keep-alive locks the asset entries it extends in id order.
- Migration 2's stage index also covers completed runs with media pending, so
  both the guard's and the deletion's lookups use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): stop re-placing stored media whose placement keeps failing

Each stored item counts the placements that failed in a row. After three the
item fails with MEDIA_PLACEMENT_FAILED (retryable), its bytes are released as
every failed item's are, and media_pending clears, so a completed run is no
longer claimed on every scan for a placement that cannot succeed. A Retry
generates the item again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(generation): wait for the run's own state, not a one-second budget

Three media tests waited for a run to reach a state (a video stored while a
later scene is held, a second video call, the keep-alive) with vi.waitFor's
default one-second budget, which a loaded full suite can exceed. They now wait
for that condition within the test's own budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(api)!: headless generate-classroom on generation runs; retire the server pipeline (E2) (#1762)

* feat(api)!: headless generate-classroom on generation runs; retire the server pipeline (E2)

POST /api/generate-classroom now starts a generation run of the request
owner (outlineReview auto, course-specific agents, no interactive or
task-engine mode) and its poll reads that run in the existing job
contract. The job id is the run id; a paused run reads as failed with
the failed step and can be resumed through the run's retry command.
Submissions without an outline model are refused up front, and the
per-owner run limit answers 429 ACTIVE_RUN_LIMIT.

The separate server pipeline (classroom-generation, its media and TTS
passes, the in-process job runner and the classroom_generation_jobs
store) is removed with its tests. The start checks of
POST /api/generation-runs move into a shared startGenerationRun helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): headless review fixes: typed model refusals, paused runs outside the limit, run report

- Model configuration refusals are typed (ModelConfigurationError: a
  missing key before the adapter is built, a refused endpoint, options
  only the deployment may set), and the headless submission checks every
  model a run needs (outline, scene content, actions) up front, answering
  400 MISSING_MODEL / MISSING_API_KEY / INVALID_URL / MODEL_CONFIG_INVALID.
- Paused runs no longer count toward OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER;
  a step Retry enforces the limit, as confirm-outline does (429).
- The job view adds runState and retryable; a paused job's error says how
  to resume it.
- Runs record speech clips their narration left silent, and compaction
  keeps a summary of the media checkpoints it removes (generation-runs
  migration 3), so the job warning stays stable after compaction.
- Remove the dead generate-classroom LLM stage key and
  lib/server/scene-generation.ts; pin classroom-generation-jobs as a
  retired store name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): name the configured provider in the missing-key refusal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): check scene content per type, as the content step resolves it

The headless preflight resolved the parent course.content slot, while a
run resolves course.content.<type> (inheriting course.content, then llm).
It now refuses only when no scene type resolves, naming the first type's
refusal; a submission where only some types resolve is accepted, and a
scene of such a type pauses the run at its content step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs(deploy)!: long-running Node only; Vercel up to 1.1.x; deployment docs and complete changelog (F) (#1764)

* build!: drop vercel.json and the Vercel build switch

OpenMAIC 1.2.0 needs a long-running Node.js process: generation runs
execute in a worker inside the server process. Remove vercel.json and the
VERCEL conditionals in next.config.ts, so every build produces the
standalone output with the sharp-libvips native libraries.

BREAKING CHANGE: Vercel and other serverless hosts are not supported;
serverless deployment stays available on release/1.1.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(deploy): long-running Node only, Vercel up to 1.1.x, upgrading from 1.1.x

- README / README-zh: the Vercel section says serverless deployment is
  supported up to 1.1.x, and its Deploy button deploys release/1.1.x;
  the header badge is removed.
- Deployment docs (all six locales): Docker Compose, and the image or
  pnpm start with your own PostgreSQL, as the supported paths; the
  required configuration; the generation run variables; an "Upgrading
  from 1.1.x" table. Getting started and configuration no longer offer
  Vercel.
- .env.example documents the generation run variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): model configuration, generation runs and deployment entries

Add the missing Unreleased entries for server-side model configuration
(capability slots, openmaic.yml, Settings > Models, MODEL_ROUTES,
OPENMAIC_SECRET_KEY, the removed browser provider state and the browser
settings import), the generate-classroom request contract, generation
runs and their media lane, the Vercel removal and the browser storage
seams, so every item of the 1.2.0 breaking changes table is listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(deploy): shared data directory for several instances; Compose upgrade cases

- Deployment docs (all locales): instances that share a database also
  need one shared, persistent data directory, because uploaded material
  files, their extraction results, agent-edited course media, usage
  records and the generated instance secret are kept on local disk; the
  standalone docker run example mounts /app/data. .env.example says the
  same next to OPENMAIC_SECRET_KEY.
- Upgrading with Compose: a .env.local with PERSISTENCE_SHARED_OWNER_ID
  needs OWNER_SINGLE_USER=false, and anonymous server libraries need an
  explicit claim into the single user.
- CHANGELOG: the job record's inputSummary is removed (it was never part
  of the poll response); no advice for serverless hosts; only the
  session-scoped material reads require the agent runtime, upload and
  delete need only DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): the browser starts and follows generation runs (E3) (#1768)

* feat(generation): the browser starts and follows generation runs (E3)

The composer uploads its materials to the owner's library and starts a
server-side run; the generation preview renders the run's events (steps,
outline streaming, outline review with the 2.5 s auto-continue, agent cards,
pause and Retry) and sends it confirm-outline and retry; the classroom follows
the run's scenes, media and pauses, with media Retry as the run's command and
the course read-only until the run completes. Course cards appear from run
start and follow the owner's run stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): follow run streams only while there is something to follow

The home page holds the owner stream only while a run is active (an idle
list looks for runs now and then), a finished run's stream closes until a
media Retry wakes it, the preview opened by the composer keeps the
auto-continue beat even when the outline was ready before it attached, and
an empty preset selection is taught by the default presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): say so when a step Retry is over the active-run limit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): say a paused run's failure the way the classic preview did

A failed step records the error code the classic routes answered the same
failure with (and the provider's HTTP status), on the paused snapshot and its
step_failed event. The preview maps it to the same sentences as before, per
step; the run's own message is the fallback for an unknown code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): an empty preset selection is taught by the default presets

`{ mode: "preset", agentIds: [] }` is accepted and the run resolves it to the
default preset agents (what the learner's selection starts out as), so the
browser sends its selection as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): show the material analysis as the classic preview did

The run names whether each material is a document or audio/video when its
analysis starts (`material_kinds`) and what of the materials the outline does
not see in full when it ends (`material_truncated`). The preview shows
"Analyzing audio/video" for audio or video, drops the analysis step once the
materials are analyzed, and shows the truncation warning again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): a browser save keeps the outline's producer

A full or structural save of a course writes the browser's outline (the plan
and its completion) over what a producer recorded beside it, which dropped
`producer`/`producerRef`: a finished run's course then no longer sent its
media Retry to the run. The producer's fields are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): a run's snapshot names its failures and its material analysis

`GET /api/generation-runs/:id` gives a paused run's failure and each failed or
skipped media item the seq of the event that reported it (`failureSeq`), a
durable identity a Retry command's id is derived from, and repeats what the
material analysis reported (`materialKinds`, `materialTruncated`) so a
reloaded page shows the same. `GET ?active=1` adds the owner's limits on runs,
so a client can tell before uploading materials that a start would be refused.
Read from the event log; no new state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): harden the run client after review

- Run and owner streams: held only while a run can change on its own (a run
  waiting on its owner or settled is read now and then); a refused or
  dropped stream is reopened with a backoff from the view's seq, reading the
  snapshot meanwhile; a run that cannot be read is retried and shown as such,
  not as missing; a page shown again reads the run at once.
- Auto-continue only in the tab whose composer started the run; every other
  tab shows the review. A confirmation that lost to another tab keeps its
  edits on screen and says so; a Retry that lost says so.
- Retry command ids come from the failure's identity in the snapshot or the
  log, so a second failure is a new command.
- Classroom: read-only while the run's state is unknown too; the run's last
  writes are read (retrying failed reads) before the course is unfenced; a
  classroom on the generating page moves to the first arrived scene; a
  learner's changes during generation (PBL progress) are held and written
  once the run completes, and a scene with unsaved changes is not replaced; a
  save of a scene still holding a placeholder writes the asset the run placed.
- Media: the run's `retryable` decides the Retry; a skipped item offers the
  run's Retry once its slot resolves, else the disabled placeholder.
- The Pro switch shows, disabled, while the course is generating.
- No upload for a start the limits would refuse; upload refusals and
  fallbacks are translated.
- e2e: the run mock streams after attach, keeps the run generating through
  the classroom, checks revisions and command ids, and covers pause and
  Retry, the run limit, a lost confirmation and a second tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(generation): release the materials a run was started with when it is over

A run started with `releaseMaterials` (the composer's: the materials were
uploaded for that run only) releases them when it completes or ends, in the
transaction that finishes it: marked deleted as the material delete path
marks them, so they stop counting against the owner's quota, their bytes
going with the owner's next reclaim sweep. Only materials of the run's owner;
course assets copied from their images stay. Runs waiting for confirmation or
paused keep them (Retry needs them); callers that reuse material ids leave the
flag out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): round-2 review of the run client

- A fenced course writes no course-document content, as before: the one
  learner write to scene content during playback, PBL progress, is synced to
  its durable home (the PBL runtime store) on its own while the run
  generates; a scene the learner changed is not replaced by a server copy.
  The queued-save path is gone.
- The composer marks its uploads for release with the run, and deletes them
  itself when a start fails (a later upload, or the start).
- Stream backoff: reset only by a stream that attached, jittered, and kept
  when the page is shown again.
- A reconnect while the outline streams follows on from the view's cursor
  (the items in the gap are in the log only); snapshot reads are serialized
  and an older one is ignored.
- A run course is read once more before it is unfenced, even when the run was
  over when the classroom opened; a scene that never reads is given up on
  after a few passes.
- A lost confirmation's edits are not swept away by the classroom redirect.
- A waiting run is read every 5 s while the page is shown.
- An upload over the size limit says the limit the server enforced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): fence a run course from its load, and keep its PBL progress

The course a generation run is producing is fenced as soon as it is loaded,
not when the classroom's run follower answers, so a PBL scene normalizing its
project on mount is not queued as a document write the server refuses;
content changes queued before the fence are dropped, a scene change among them
synced to the PBL runtime store. A PBL scene the classroom holds is not
replaced by the server's copy (the run never changes it after appending it,
and the learner's progress lives in it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): convergence review of the run client

- A start releases its uploads only when the server refused it (a 4xx); a
  lost answer may hide a created run, which keeps them and releases them
  when it is over.
- A run releases its materials only when no other of the owner's runs that
  is not over names them.
- Each snapshot read is bounded (15 s, aborted, counted as a failed read), so
  one that never settles does not hold the reads after it; close aborts the
  reads outstanding.
- One run-ref check for the load-time fence and the follower; loading a
  course without a followable run lifts a fence, and a run that answers 404
  lifts it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(generation)!: remove the browser generation orchestration (E4) (#1773)

* refactor(generation)!: remove the browser generation orchestration (E4)

Classic generation runs on the server since E3; this removes what the
browser used to run itself:

- The browser scene pipeline (`useSceneGenerator`), the classroom's browser
  resume and media pass, and the client media pass of the media
  orchestrator. Media Retry stays for courses no run follows. Narration for
  one speech line moves to `lib/audio/narration-tts.ts` (the editor's
  per-line regeneration).
- The `sessionStorage` handoff and the old preview session types and helpers
  (`GenerationSessionState`, `getActiveSteps`, `foreground-retry`,
  `vocational-mode`), `session-sources` and `research-decision`.
- The IndexedDB document and image stash (`lib/utils/image-storage`); the
  device cache drops its `imageFiles` table in a version 2 upgrade.
- The per-step generation routes nothing in the repo calls any more:
  `/api/generate/scene-outlines-stream`, `/api/generate/agent-profiles`,
  `/api/generate/scene-content`, `/api/generate/scene-actions`,
  `/api/extract-document` and `/api/web-search`. Their step functions stay;
  the step-level tests that drove them through the routes now call the
  steps.
- The Vercel-only `maxDuration` route hints (`next start` ignores them).
- What only those left in use: the asset storage-full marker, the media
  document skip index, request-based server asset and vision image
  resolution, `llmApiError`, the client generation settings reader, the
  regeneration lock, `narrationPlan`, and the stage store's browser
  generation actions.

BREAKING CHANGE: the per-step generation routes listed above are removed;
start and follow a generation run instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(classroom): show pending outlines no run will produce as interrupted

A course the browser was generating before 1.2.0 (or whose run is gone)
is no longer resumed, so its pending outlines would sit as "generating"
placeholders for ever. They now show in the failed placeholder's visuals
with "Generation was interrupted", with no spinner and no Retry. A course
a server job produces keeps its placeholders while the job finishes it.

Also corrects the README-zh description of `app/api/generate/`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(classroom): offer Retry for media a pre-run course never generated

The browser media pass used to generate the images and videos a course's
outlines asked for when the course was opened. A course generated in the
browser before 1.2.0 can still carry such placeholders with no failure
record and no cached bytes, and without the pass nothing offered them a
Retry. Opening a course no run produces now records each one as a failed,
retryable task; restored refusals and cached bytes are kept, and nothing is
generated until the author clicks Retry.

Also adds the interrupted-generation selector to the playback chrome test's
stage store mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(settings)!: main's settings UI on the server-side model configuration (#1767)

* feat(config): test a saved provider from the settings by its id

The settings test buttons (and the model list fetch) name a provider the
workspace has configured instead of sending its key and endpoint: the
server resolves it from the deployment or the workspace's own
configuration, under the same rules as a slot (the policy, self-hosted
media presets, custom endpoints).

- verify-model, verify-image-provider, verify-video-provider and
  verify-pdf-provider take `provider` (and `model`); probe-models takes
  `provider` for one of the workspace's own chat providers.
- generate/tts and transcription take `previewProvider` (and
  `previewModel`) for a settings preview of a provider other than the
  slot's.
- The settings view's catalogue models carry what the registry knows of
  them (capabilities, thinking controls, context window) and the
  registry entry that serves each capability, for the settings to show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* refactor(settings)!: bring back the Token Plan, Model Services and Course Model sections on the server's configuration

The settings dialog gets its earlier information architecture and layout
back: Token Plan, Model Services (one tab per capability: language
models, image, video, text to speech, speech recognition, document
parsing, web search, each with its list of services and their panels),
Course Model Config (the pipeline with a model per stage), Skills and
General. The model map with its provider list and the separate Voice
section are removed.

The panels read and write the workspace's model configuration on the
server instead of the browser store:

- A service's key, endpoint (chat services only) and model list are its
  workspace provider (id = the service's preset id); keys are write-only
  (a mask is shown; replace or remove). A newly added service fills the
  root slots of what it serves that have nothing set yet.
- Services the deployment configures are shown read-only; key-pair,
  self-hosted and (under a policy without workspace providers) all other
  services say that only the server's configuration can set them up.
- The Course Model main model is the `llm` slot (with its thinking
  settings); each stage is its slot (follow the main model = no
  assignment of its own); the media switches set their root slot to null
  and restore what it held; locked slots are shown disabled.
- Token Plan: connecting adds the plan's provider and fills the empty,
  unlocked slots it recommends; disconnecting removes it.
- Test buttons name the saved service; no key leaves the browser.
- The narration speed and test are back in the text-to-speech panel and
  the recognition language in the speech recognition panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(toolbar): the course model picker and extractor as before, on the server's slots

The home toolbar's model picker gets its thinking control back (the
`llm` slot's thinking settings) and its groups in the plan-first order
the settings use, and the course material popover gets its extractor
select back: it sets the `document` slot (a built-in parser that needs
no key is added on first use). The set-up prompt opens Model Services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(settings): name the Token Plan, Model Services and Course Model sections

The docs, READMEs and messages pointed at the Models section (model map,
providers tab, "Connect a service") and the Voice section, which are
gone. They now name the sections the settings have again: a plan's key
in Token Plan, a service's key in Model Services, per-stage models and
capability switches in Course Model Config, VoxCPM voices in Model
Services → Text-to-Speech. The server-side explanations (locks,
write-only keys, deployment-only services, the one-time import) stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): web research dims only when search is off, as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): list a saved provider's models without naming a model

Fetching the models of a saved chat provider passed its bare id to the
chat resolver, which requires `provider:model`, so every request was
refused. The provider's connection is now resolved without a model (still
only the workspace's own providers, and the endpoint is still checked).
The settings view test also covers an OpenAI-compatible deployment
provider's listed chat models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(settings)!: Course Model Config is the course model map

Course Model Config shows the course model map again in place of the
earlier pipeline panel: the default model card on top, the pipeline as a
two-row serpentine with the content station expanding to its page
types, editing on the card (follow the parent, a model or off, and a
fallback for chat slots), "Set by the server" locks, solid and dashed
lines, zoom, pan and keyboard. Its provider tab and first-run card stay
out: services are set up in Model Services and Token Plan, and the map
links to Model Services where it needs one.

- One switch implementation (flipSwitch): off sets the slot to null, on
  restores what it held, else takes the first service that serves it
  (speech input returns to the browser's recognition), else opens the
  picker. What it turned off is remembered, and what it restored
  forgotten, only once the server confirmed the change.
- Speech input shows its switch while it runs in the browser (unset),
  and a service can be picked for it then.
- The server's providers count wherever chat models are offered (the
  map, the toolbar, Model Services); the map's "no model" note shows only
  when nothing at all offers one.
- Model Services names a provider by its preset and id
  ("OpenAI-compatible · gateway", with a generic logo), and shows one
  named after a built-in service as that service's entry.
- A typed key stays in the field when the server refuses it; it is
  cleared once saved (Doubao's paired fields too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): provider logos in the model pickers; thinking, keyless services and model discovery on the map

Logos: the home toolbar's model picker shows the provider's logo again, on
the pill and on every group and model, as it did before the settings
restructure; so do the course model map's card picker and its read-only
pill. A provider looks the same everywhere: its plan's or built-in
service's logo (a provider named after one included), its preset's, or
the generic service icon for a custom endpoint, as in Model Services. The
naming and logo helpers move to a data-only module the toolbar can load.

Review fixes:
- The map's card picker sets the thinking settings of a chat slot's own
  model (the main model and each stage), against the view it shows; locked
  slots have no picker.
- A service that needs no key (the browser's own speech) is offered in a
  media slot's picker, and the text-to-speech panel can make it the
  narration: the provider is added, then the slot assigned against the
  view the add answered.
- Fetching a provider's models reports them as added only once the server
  saved the list; a refused or lost write leaves a failure to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): removing a provider's key frees the slots that needed it

Removing the stored key of a workspace provider left the slots assigned to
it pointing at a provider that can no longer be called, so the default
model (or a stage) failed at generation time while the settings still
showed it in use. Removing the key now drops those assignments, as
removing the provider does, and the slots follow their parents again. Only
the capabilities whose provider needs a key are affected: a local model
server or a keyless search keeps what it serves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(config): connecting a token plan fills an empty web search slot

A token plan's web search has no models to pick, so the plan's preset
recommended nothing for the webSearch slot and connecting a plan (TokenDance,
MiniMax) left search unassigned, where the browser-side Token Plan used to
select the plan's search. The preset now recommends the plan's search for
it, and the first-run fill assigns the provider by itself to the slot when
it is still empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): show each TTS service's full request URL

The server-backed TTS panel showed only the preset base URL as the request
URL. Append the path each built-in service calls, as main did, so Gemini
shows /interactions and Doubao /unidirectional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix: upgrade rehearsal findings (access-code reload and import, completion flag, provider log source, COOKIE_SECURE) and upgrade docs (#1774)

* fix(access-code): reload the page's data once the access code is accepted

The library, folders, active runs and the workbench probe were requested
before the gate passed, answered 401, and stayed empty until a reload.
The guard now mounts its children again after a successful verify, and
the workbench probe no longer caches a refused answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): mirror a saved outline's generation-complete flag

A whole-document save whose outline says generationComplete now sets
stage_meta.generation_complete in the same transaction, so a course the
browser importer writes is marked complete like one imported from
data/classrooms. saveCompletedClassroom relies on the same path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): name the actual provider source in the startup log

The line said "Loaded (server-providers.yml)" even when every provider
came from environment variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(access-code): let COOKIE_SECURE=0 cover the access cookie too

The access cookie was Secure in every production build, so behind plain
HTTP the browser dropped it and the gate never opened, while the owner
cookie already honoured COOKIE_SECURE=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: upgrade, plain-HTTP cookie and migration gaps found in the 1.1.2 upgrade rehearsal

- Upgrading from 1.1.x: keep data/ (or OPENMAIC_CLASSROOMS_DIR) and set the
  identity mode before the first 1.2.0 start, since data/classrooms imports
  go to system:legacy-classrooms for good in anonymous mode; spell out the
  pnpm start upgrade steps and the shared data directory.
- Required configuration: COOKIE_SECURE=0 for plain-HTTP deployments.
- Self-host: the next start / output: standalone warning is harmless; keep
  pnpm start on a VM.
- Configuration: <PREFIX>_BASE_URL maps to baseUrl; with openmaic.yml the
  provider variables configure nothing, so declare every provider and slot;
  pbl-chat and maic-agent routes are removed.
- openmaic skill: self-hosted ACCESS_CODE needs the verify cookie, not Bearer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(legacy-browser-import): resume the import once the access code is accepted

On an ACCESS_CODE-gated deployment the page's first import run is
answered 401 before the visitor enters the code and backs off, so an
upgrading browser saw an empty library on its first visit. The ledger
now records an unauthorized pause, and accepting the code starts the
import again at once (only that backoff is skipped). This page's runs
are queued one after another, so the resume never runs beside the
scheduled run, with or without Web Locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(legacy-browser-import): wait for another tab's lock on an access-granted resume

A resume after the access code was accepted took the cross-tab lock with
ifAvailable, so it was dropped as busy-elsewhere while another tab held
it; when that tab's run was the one the gate refused, nothing retried
until a later load. The resume now waits for the lock and decides again
inside it: finished, an unauthorized pause to retry, or another backoff
that still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ci: stop triggering on integration/provider-config before it merges to main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(settings): cache provider logos so switching service tabs does not blank them (#1777)

Next serves public/ files with `Cache-Control: public, max-age=0`, so each
logo a newly mounted list draws is revalidated before it paints. Switching
tabs in Model Services mounts a fresh list, and its logos stayed blank until
every revalidation came back. Cache /logos/* for a day with a week of
stale-while-revalidate; the files are not content-hashed, so not immutable.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): hide the Skills section when the agent runtime is unavailable (#1778)

Without the agent runtime, GET /api/agent/skills returns 404 by design, so
the Settings Skills section could only ever show a load error with a retry
that cannot succeed.

Probe GET /api/agent/runtime once per tab and list the Skills item only when
it reports `enabled` (the flag plus DATABASE_URL, the same check the skills
route gates on). The item stays hidden while the answer is unknown, and a
request to open the dialog on `skills` without the runtime lands on the first
section.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): show the selected extractor's formats in the material upload hint (#1779)

The attach popover's dropzone hint claimed documents, slides, spreadsheets
and images for every extractor, so with unpdf (PDF only, plus the built-in
plain-text extractor) users could pick a .docx via "All Files" and only then
hit a generic "unsupported" error.

- The hint now lists exactly the formats the active extractors accept
  (getFormatLabelsForProviders), with the per-file size limit.
- The unsupported-file error names the extractor and its supported formats;
  it covers the file picker, drag-and-drop onto the dropzone, and the
  cleanup that drops attached files after switching extractors.
- Format labels are file-type names shared by all locales; the list
  separator is localized. Strings updated in all 12 locales.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): connecting a token plan applies its recommended configuration (#1780)

Since model configuration moved to the server, connecting a token plan only
filled the slots that were still empty, so a workspace that had already
picked models never switched to the plan's setup. The browser-side Token Plan
used to make the plan's default model, its recommended course stages and its
image, video, speech and search services the active selection.

Connecting a plan (or saving a new key for a connected one) now applies the
plan's recommendation as one slots change: the default model, each course
stage the plan names, and its media and search services. When that would
replace assignments the workspace made itself, the panel asks first: use the
plan's recommended setup, or keep the current one and fill only the empty
slots. Slots the deployment locks are never offered or changed, and a plan
yields the slots a connected plan of higher priority recommends, whatever
the connect order. A replaced language-model assignment keeps its fallback;
thinking settings go with the model they were set for. Disconnecting still
removes the provider, which frees the slots that named it.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(home): the toolbar picker sets the default model and shows stages set separately (#1781)

* feat(home): label the toolbar model as the default and show stages set separately

The home toolbar picker only sets the `llm` root, but read as if it chose
the model for everything. It now says what it changes:

- the pill reads "Default · <model>" (the prefix is hidden on phones);
- a hint after it counts the stages under `llm` whose own setting
  resolves to something other than the default (inheriting or matching
  stages do not count; deployment-set stages do), lists them in a tooltip
  with the map's stage names, and opens Course Model Config on a click;
- the dropdown says picking here leaves stages set separately alone;
- when every stage a course is always generated with (outline, each page
  type's content, actions) has its own setting, the pill summarises the
  per-stage setup (naming the Token Plan when one plan provider serves
  them all) and opens Course Model Config instead of offering a switch.

Picking a model still changes only `llm`; the locked and first-run states
are unchanged. New strings are added to all 12 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(logos): size the DeepSeek logo to its own view box

The SVG declared width 182 and height 29 around a 34x29 view box, so any
square icon slot scaled it as a wide strip and the whale drew as a dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agent): don't inherit a thinking effort the agent can't use; recover sessions after a failed first run (#1782)

* fix(agent-runtime): drop the thinking effort the agent slot inherits

The agent slot follows llm, so a thinking level picked for the default
model (the home toolbar writes it on llm) reached the agent driver, which
refuses any thinking effort because its tool calls cannot carry one. Every
agent run failed for a user who had picked a thinking level.

The agent slot now declares that it carries no thinking effort:

- Resolution drops an effort the agent inherits from an ancestor and keeps
  the rest of the thinking settings; an inherited effort of none stays
  "thinking off".
- An effort set on the agent slot itself is refused when it is saved:
  openmaic.yml at load and a workspace change through the settings API.
  The driver's own check stays as a backstop for settings stored earlier.
- The model map's thinking control for the Agent card offers no effort
  levels: an effort model that can be switched off shows on/off, and one
  that cannot shows nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(agent-runtime): keep a session usable after a run that failed while starting

A run that fails before it completes any message (for example, while
resolving its model) writes nothing to the entry tree, but its lifecycle
frames are in the event log. The runner treated any lifecycle frame as proof
that the tree should hold history, so every later run of that conversation
failed with "tree is empty after a prior run".

The empty-tree check now asks the event log whether a prior run completed a
message. The runner appends every completed message to the tree right after
its message_end event, so an empty tree is refused only when a message_end
exists: the tree lost history it held. An empty tree after runs that never
completed anything is legal, and the next run starts the conversation over:

- it resumes the conversation instead of opening it again, so the opening
  prompt is not painted twice;
- a durable message is taken as the session's opening message only when it
  was posted before the first run; a message posted after a failed start is
  delivered as a follow-up, not consumed in place of the session's prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(config): workspace-provider policy covers request headers; startup secret warnings; upgrade notes (#1783)

* fix(model-config): ignore request-named providers when workspace providers are off

With `policy.allowWorkspaceProviders: false`, users may only use the
providers openmaic.yml declares. The deprecated request paths still let a
request run on its own model, key and endpoint while a slot was unassigned.

Under that policy the language model and media resolvers now skip the
provider a request names, document extraction keeps only a self-contained
extractor from the request fields, and the header form of the provider
test routes (verify-model, verify-image-provider, verify-video-provider)
is refused. Behaviour is unchanged when the policy is true or unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* test(model-config): pin the media defaults an upgrade keeps

Before the server-side settings, the browser switched image, video and
narration on at its first sync with the server whenever the server had a
provider for them, and the classroom chat and the agent searched the web
whenever a search provider was configured. The translated legacy defaults
keep those capabilities on, unlocked, so a workspace can switch each off;
an explicit openmaic.yml assignment stays as written and locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* feat(secrets): warn at startup when stored keys will not open

Keys saved in the model settings are sealed under OPENMAIC_SECRET_KEY, or
under a secret generated in data/instance-secret.key when it is unset. On
a host whose data directory does not survive a restart, or with several
replicas, a new secret is generated and the stored keys stop opening; on
a read-only data directory no secret can be created and saving a key fails.

At boot, with one query in the background, the server now warns when:
- OPENMAIC_SECRET_KEY is unset and the data directory cannot hold a new
  secret file;
- the secret file is missing (so a new one would be generated) while the
  database holds keys sealed under an earlier secret;
- stored keys were sealed under a different secret than the current one.

Each warning says the keys have to be entered again and recommends
setting OPENMAIC_SECRET_KEY. The server still starts in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs: upgrade notes for server-side model configuration

Add breaking changes and upgrade notes to CHANGELOG.md for the move of
model configuration to the server: openmaic.yml and capability slots,
MODEL_ROUTES refusing to start without openmaic.yml (with the
route-to-slot mapping, including maic-agent-driver -> agent), the
narrowed /api/generate-classroom body, deprecated request fields and the
workspace-provider policy, the media capability defaults after an
upgrade, the one-time browser settings import and what it cannot carry,
and OPENMAIC_SECRET_KEY persistence across restarts and replicas.

The configuration and deployment docs (all languages) and .env.example
now cover replicas, ephemeral and read-only data directories, the new
startup warnings, and request fields being ignored under the policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): keep settings the server could not take; Azure regional endpoints; keyless compatible providers (#1784)

* fix(model-config): keep what the browser import could not move, and accept Azure Speech regional endpoints

The one-time import of browser model settings deleted the staged proposal,
keys included, on any 2xx answer, even when the server skipped items. The
version 5 store migration had already dropped the originals, so a skipped
provider lost its key everywhere. An Azure TTS/STT user with a regional
endpoint lost theirs this way: the server refused the endpoint as a custom
media endpoint.

Import:
- Only what the answer shows the workspace holding leaves the browser.
  Every other item (skipped as invalid, an id the deployment declares, or
  unconfirmed because the answer could not be read) is kept in the browser
  as staged, with its keys, under its own key. Skips that leave nothing
  behind (EXISTS, a locked slot) are not kept. If the items cannot be kept,
  the proposal stays for a later load.
- What the builder cannot propose is kept the same way at migration time:
  custom speech/transcription providers, AliDocMind's key pair, custom chat
  providers without an endpoint or of an unsupported type.
- Kept items are never sent again and do not re-trigger the import. A toast
  tells the user once; Settings -> Model Services lists them with the reason
  and a copy-key button until they are discarded. Setting one up again (a
  new workspace provider of its preset, or the slot) clears it. Clearing
  the local cache keeps them.
- The import answer now carries a `code` per skipped item, and a provider
  id the deployment declares is reported as PROVIDER_RESERVED.

Azure Speech:
- Workspace azure-tts / azure-asr providers take their official regional
  endpoint (https://<region>.tts.speech.microsoft.com,
  https://<region>.api.cognitive.microsoft.com or .stt.speech.microsoft.com),
  region [a-z0-9]+, https only, no userinfo, port, path, query or fragment.
  It is checked at save time and at resolution (resolve-slot marks anything
  else as a custom endpoint, which media resolution refuses), and stored
  normalised. Their registry default only names the region as a placeholder,
  so they now require it. The TTS/ASR panels get a regional endpoint field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(model-config): let a keyless OpenAI-compatible provider run

A self-hosted OpenAI-compatible server (Ollama, vLLM) declared in
openmaic.yml as `preset: openai-compatible` without `apiKey` resolved, but
building its model threw "API key required for provider: openai": the
preset rides on the OpenAI registry entry, which requires a key.

- The openai-compatible preset marks its key optional, and the slot model
  passes that to getModel (a new `requiresApiKey` override on ModelConfig).
  Every other preset keeps the registry's rule, so OpenAI itself still
  needs a key and fails with the same clear error.
- A request without a key no longer carries an empty `Authorization:
  Bearer ` header; with a key it is sent as before.

On main, a custom OpenAI-compatible provider sent from the browser was
built the same way (providerType openai, registry default requiresApiKey
true) and also needed a key on the server; keyless worked only for registry
entries marked keyless (Ollama, Lemonade). This lets the documented keyless
openmaic.yml provider work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(model-config): carry narration, image and video switched off over to the workspace

The browser import carried a capability's "on" choice but not an explicit
"off" for narration, images and video. A user who had switched one off got
it back on after upgrading, because the deployment's defaults now turn
those capabilities on.

When the old store has `ttsEnabled`, `imageGenerationEnabled` or
`videoGenerationEnabled` stored as false while a usable provider for the
capability was there, the import proposes `tts: null` / `image: null` /
`video: null`, as `asrEnabled: false` already becomes `asr: null`.

Earlier builds defaulted these switches to off and turned them on by
themselves once a provider was usable (a server provider on the first
load, a key the user entered), and off when none was. So `false` with a
usable provider (server-configured and not switched off by the operator,
or with the user's own key; browser speech synthesis does not count) is the
user's choice, and `false` without one is only the default, which is not
carried over. A server that gained a provider after the browser's first
load cannot be told apart and is read as off: that is visible in the
settings and costs nothing, while reading it as on could start paid
generation the user refused.

Web search off is not carried over: it only stopped course research, while
chat and the agent kept searching through the same provider. A slot the
deployment locks is skipped like any other import (SLOT_LOCKED).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): drop a browser-kept key only when the server holds the same one (#1785)

* fix(import): drop a browser-kept setting only when the server holds the same one

The one-time import of browser model settings could still lose a key:

- A provider id the workspace already had was answered `EXISTS`, and the
  browser deleted its copy, even when the workspace held another key. The
  server now compares the proposed provider with the stored one (preset,
  endpoint in its normalised form, models, and the key, decrypted and
  compared in constant time) and answers `EXISTS_SAME` or
  `EXISTS_DIFFERENT`. Only `EXISTS_SAME` lets the browser drop its copy; a
  key this instance cannot open is never confirmed. Nothing else about the
  stored provider is returned.
- Provider ids and slot ids shared one namespace in the answer, so a slot
  `tts` that was imported confirmed a refused provider `tts` and its key
  was deleted. The answer now names each item's kind
  (`{ kind: 'provider' | 'slot', id }`), and kept items are keyed by kind
  and id.
- The notice in Settings cleared a kept provider, key included, as soon as
  any new workspace provider of its preset appeared, even one without a
  key. A kept key now leaves only when such a provider holds it as far as
  the view shows (key set, readable, same mask); otherwise it stays, with
  its copy button, until the user discards it. Key pairs and keys too short
  to show in a mask are never cleared this way. The key mask moves to a
  module the server and the browser share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* docs(changelog): the narration, image and video off switches are carried over

The changelog still said the image, video and narration off switches were
not carried over. They become `tts`/`image`/`video: null` when a usable
provider was set up, as the configuration docs and the import README
already say; only the research switch, and a switch off only by default,
are not carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

* fix(import): never clear a kept key by itself

A kept provider that holds a key (or a key pair) used to leave the
browser once a new workspace provider of its preset held a key whose mask
matched. A last-four-characters match does not confirm the workspace
holds that key, so such an item now stays, with its copy button, until the
user discards it. Kept items without a key still leave by themselves once
the view shows them set up again. The key mask goes back to the server
module, its only user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): keep browser settings on a refused or self-contradicting answer (#1787)

Two more ways the one-time import of browser model settings could lose a
key:

- A 400 removed the proposal outright. The server refuses the whole
  proposal then (an unexpected top-level field is enough), so sending it
  again cannot succeed, but its keys existed nowhere else. Every item is
  now kept in the browser first, keys included, as refused with the
  server's message, and only then is the proposal removed; if they cannot
  be kept, the proposal stays. Kept items are never sent again. 401, 404,
  409, 5xx and network failures still keep the proposal for a later load.
- An answer that listed an item as both imported and skipped, or skipped
  it with different codes, let `imported` (or the last code) win, which
  could delete a key the server did not hold. Such an item is now kept as
  unconfirmed.


Claude-Session: https://claude.ai/code/session_01EF6G824rLqUXidRrqrMskR

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation): keep a placeholder-free message for material formats a run refuses

#1779 gave upload.unsupportedCourseMaterial {{parser}} and {{formats}}
placeholders for the toolbar, which knows the selected extractor. The run
start (a format the server's material policy does not list) and the
material upload route's 415 use the same key with no values, so the
message would have shown the raw placeholders. They now use
upload.unsupportedMaterialFormat, the earlier wording in every locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: run the prior-run-record PostgreSQL suite with the app-domain contracts

#1782 added tests/agent-runtime/prior-run-record.pg.test.ts without
listing it in the app-domain contract step, the only job that gives the
app suites a database, so it skipped everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(model-config): server defaults, explicit locks and settings shaped by what users can change (#1793)

* feat(model-config): server defaults, explicit locks and settings shaped by what users can change

openmaic.yml `slots` are now server defaults that users may change in the
settings; `lock: [slot, ...]` or `lock: all` fixes slots for everyone, each
with its whole subtree. Inside a locked subtree a slot resolves from the
deployment alone; elsewhere each node consults the workspace's assignment and
then the deployment's default. `allowUserKeys` (default true) replaces
`policy.allowWorkspaceProviders`, which is refused at startup naming the new
key. Legacy variables translate into the same deployment layer as defaults,
so the separate defaults walk is gone; a request's deprecated model fields
still replace a server default (as they did DEFAULT_MODEL), never a
workspace choice or a lock.

The settings view carries per-slot `source` (default / workspace / locked /
inherited / unconfigured), `serverDefault` and top-level `allowUserKeys`;
writes anywhere in a locked subtree answer 409 SLOT_LOCKED. The UI derives
one of three shapes from the view (set it up yourself / choose a model /
configured by the administrator) and renders a control only where using it
can change something: Token Plan, Model Services tabs and adding services,
locked cards as one read-only line, "Reset to server default", a read-only
summary when everything is locked, and the home toolbar picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(model-config): defaults, locks, allowUserKeys and the three settings shapes

Rewrite "Locks and the model settings" as "Defaults, locks and the model
settings" in every locale: `slots` are server defaults, `lock` fixes slots
with their subtrees (or `lock: all`), the three shapes the settings take and
the rule that a control shows only where it can change something. The Policy
section becomes allowUserKeys, the resolution rules, the deprecated request
fields and the migration notes follow, and the example openmaic.yml, the
READMEs, the openmaic skill and the changelog describe the same behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(model-config): keep openmaic.yml defaults ahead of deprecated request fields

A request's deprecated model and provider fields now answer only where
nothing is assigned, or over a default translated from the legacy variables
(as DEFAULT_MODEL always ranked below the model a request named). A default
written in openmaic.yml stands, as it did when the file locked what it set:
otherwise the legacy request paths that fill in a provider of their own
(web search's fallback provider) would replace it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): show a locked line's value in full and let the summary scroll

A locked slot's line keeps its value on the first row and puts the lock with
"Fixed by the administrator" below it, so a short value is no longer cut off
by the label. The read-only summary scrolls inside the dialog instead of
being clipped below the language models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agent-runtime): provision the session schema before the material schema

On a fresh database the material extraction runner's first scan created the
session-material store before anything had created agent_sessions, which its
table references, and the scan failed once. The material store now waits for
the agent-session store (and so its schema) first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): name deployment providers when presets are not served

Provider views carry their preset's name and kind, so a deployment's
providers keep their display names (MinerU, DeepSeek, Bocha) on the cards,
in the pickers and in the home toolbar under allowUserKeys: false, when the
preset catalogue for adding providers is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): read-only diagram for locked deployments, and controls only where they apply

- Configured by the administrator: Course Model Config shows the course model
  diagram with every card read-only and one line saying the administrator set
  up the models; the separate summary and its strings are gone.
- The home page model picker, a shortcut for the default model, is rendered
  only when that model can be changed (canChangeDefaultModel): not when llm is
  locked, and no read-only label in its place. The set-up shortcut appears
  only where the settings can still set a language model.
- Connecting a token plan treats a server default written on a slot as a
  current choice: the confirmation lists it (marked as the server default)
  and says the plan replaces what each stage uses now, server defaults
  included; keeping the current setup leaves it.
- Docs and changelog follow (6 locales).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(model-config): the user's choice beats server defaults; close lock and key gaps

- Outside a locked subtree a slot takes the workspace's assignment anywhere
  from the slot up to the root first, and only without one the server's
  defaults (openmaic.yml or the translated legacy variables): whatever a user
  changes wins, and a slot that follows its parent follows the user's parent.
  A deployment configured through DEFAULT_MODEL keeps the Pro agent off until
  someone chooses a default model, and the agent follows that choice.
- A document slot that `lock: all` leaves unassigned is fixed as nothing:
  material extraction no longer takes a provider, key or endpoint from the
  deprecated request fields for it (documentStatus `locked`).
- The settings view leaves out providers a workspace added before
  `allowUserKeys: false`, and the assignments naming them, as the calls do.
- Clearing a stale workspace assignment inside a locked subtree is allowed;
  setting one still answers 409 SLOT_LOCKED.
- Under `allowUserKeys: false` the raw key forms of verify-pdf-provider,
  provider/probe-models and azure-voices are refused, as the verify routes'
  are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): follow the user's parent, keep voices and connected plans reachable

- A card whose server default is overridden by the user's choice on a parent
  says it follows that parent; its picker still offers the server default,
  written as the slot's own when clearing would follow the parent instead.
- "Reset to server default" is offered only when the workspace value differs
  from the default (model, fallback, thinking and parameters compared).
- The Text-to-Speech tab stays for narration with voices of the user's own
  (VoxCPM designs, Qwen clones) in every shape, opening on the service in use.
- A token plan the workspace connected stays listed to update its key or
  disconnect, after its slots are locked.
- Docs (6 locales) and changelog: the new resolution rule, and the lock
  example no longer puts `lock: all` beside a list in one block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): suggest a provider of one's own only where adding one can change something

The read-only notice of a server-configured service says "To use your own
credentials, add a separate provider" only while adding a service for that
capability can change something (canAddService); the sentence is its own
string in every locale. The Model Services header likewise says "fill in
credentials" and "Pending setup" only for a service that can be set up here,
else "Not configured".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): count only the user's choices and locks as stages set separately

Under the resolver where the user's choice anywhere up the tree beats a
server default, a stage on an unlocked server default follows whatever
default model the user picks. The home toolbar no longer treats such stages
as set separately: it keeps the default-model picker instead of "Per-stage
setup", and lists only stages the workspace set itself or a lock holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(materials): upload and extract materials as soon as they are attached; simplify the home model picker (#1796)

* feat(materials): upload and extract course materials as soon as they are attached

The composer uploads a material when it is attached, and the server extracts
it in the background right after the upload, so Generate starts a run from
materials that are ready and the preview opens without a material analysis.

Server:
- owner_material extraction state idle | extracting | ready | failed, with the
  error, what it found (text, pages, images) and what a course leaves out of
  the material on its own (migration 3 adds the extractor lease columns and
  indexes; earlier states become idle).
- A process-scoped background extractor claims extracting materials under a
  heartbeat lease (a crash or restart is taken over once the lease is stale),
  runs the material-analysis step's own extraction with its time budget, and
  stores the result (text, images with their bytes) next to the material's
  bytes. Concurrency per process: OPENMAIC_MATERIAL_EXTRACTION_CONCURRENCY
  (default 2).
- The same bytes uploaded again by the same owner reuse a ready extraction
  made under the same extraction services.
- POST /api/materials starts the extraction (?extract=false defers it, used by
  the agent workspace, which extracts what a session binds itself);
  GET /api/materials and GET /api/materials/{id} report the owner's uploads
  with their extraction; POST /api/materials/{id}/extraction extracts a failed
  one again; deleting a material deletes its extraction result and drops an
  extraction in progress.
- The run's material step reads ready extractions, waits for running ones,
  starts idle ones and fails with a failed one's error; Retry of a run paused
  there extracts the failed materials again. The run tells the preview what it
  waits on only when it waits. Images still become course assets when the run
  uses them, so the produced course is unchanged and releasing a material
  never touches a course.

Client:
- Material chips show the upload progress, Parsing / Transcribing, Ready
  (with what a course leaves out of the material), or the failure reason with
  Retry and Remove; limits and formats are checked against the capabilities
  endpoint at attach time. Generate is disabled until every material is ready.
- Materials no run took are deleted when the composer goes away.
- The preview lists the analysis step only while a run waits for an
  extraction, and no longer shows the truncation notice.

Docs: the openmaic skill's generate flow describes the extraction state and
the optional wait before submitting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(toolbar): show the home model picker as the default model alone

The home toolbar's model picker no longer carries settings jargon a learner
cannot act on:
- The "N stages set separately" hint next to it is gone; stages with a model
  of their own are shown in Course Model Config's diagram.
- It is never replaced by a "Per-stage setup" summary: whenever the default
  model can be changed it is the picker, since changing the default still
  changes every slot that follows it (the classroom, the agents). It is
  hidden only when the default cannot be changed, as before.
- The button shows the model name (and the thinking level badge) without a
  "Default ·" prefix; the dropdown keeps its note on what picking changes.

The now unused override helpers (lib/model-settings/overrides.ts), the home
picker wrapper, the picker's valuePrefix and their i18n keys are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(materials): fence extraction results per attempt, cancel extractors, share workers fairly

Follows the cross-review of the extraction at upload:

- Result writes: every claim takes its own lease (worker id plus an attempt
  id), a process never claims a material it already extracts, and each
  attempt stores its result under its own immutable key (written to a
  temporary file and renamed into place). The settle publishes the winning
  key in the same fenced write; an attempt that loses its lease deletes only
  its own object, and an owner claim moving the material is no longer taken
  for a delete. A material's results go with it (prefix delete).
- Cancellation: the extractor config carries the caller's signal, and
  self-hosted MinerU, MinerU Cloud (polling and retries included),
  AliDocMind's polling and the local ffmpeg/ASR pipeline stop on it. The
  extraction waits until the extractor actually settles, so a deleted or
  timed-out material keeps its worker slot until its provider work stops.
- Fairness: claims are serialized and take owners in turns, at most
  OPENMAIC_MATERIAL_EXTRACTION_PER_OWNER (default 2) running per owner. A run
  no longer charges queue time to its step budget: it waits without a
  deadline while its material is queued, and an extraction gets the budget
  once a worker claims it.
- Leaks: uploads no run or agent session references are deleted after
  OPENMAIC_UNUSED_MATERIAL_TTL_HOURS (default 24) by an hourly sweep, which
  also finishes released and half-deleted materials. The composer hands its
  materials to the run before anything is awaited, keeps them when the page
  goes into the back/forward cache (and reconciles them on return), and shows
  attached files before the policy is read.
- Reuse: an extraction is reused only for the same type and the same
  document service (provider, model, endpoint, options, origin) and speech
  service (provider, model, endpoint).
- Quota: a stored result counts against the owner's byte quota, and one
  result is capped (OPENMAIC_MATERIAL_EXTRACTION_MAX_RESULT_MB, default 100;
  EXTRACTION_RESULT_TOO_LARGE).
- A run's Retry restarts its failed extractions only among the materials of
  the owner that holds the run now; a composer Retry answered 409 follows the
  restarted extraction; an empty sessionId answers 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(materials): sweep unused uploads once at start too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(materials): keep composer uploads alive, quota-check results, finish cancellation, sweep orphans

Follows the convergence review of the extraction at upload:

- The unused-upload sweep ages a material from when it was last read back
  (owner-material migration 4, `touched_at`): GET /api/materials/{id}
  refreshes it, an open and visible composer reads its materials back every
  10 minutes, and Generate checks they still exist before it starts (a
  material that went shows as removed).
- Publishing a result checks the owner's byte quota under the lock an
  upload's reservation takes; over it, the extraction fails with
  MATERIAL_QUOTA_EXCEEDED and the attempt's object is deleted.
- AliDocMind sends no request after an abort: submission, polling and every
  result page check the signal (its SDK cannot abort a call in flight).
- A cancelled ffmpeg command is terminated (SIGTERM, SIGKILL after a 2 s
  grace) and the rejection waits for the process to exit.
- The sweep also deletes result objects under a live material's
  `.extraction/` that are not its published result and are older than any
  attempt could still settle (twice the extraction budget plus the lease).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(materials): recheck sweep and claim conditions on the locked row

The unused-upload sweep chose its materials in a subquery and marked them
deleted without checking again, so a read (touch) that committed while the
sweep waited for the row lock still lost the material. The conditions (ready,
not deleted, untouched since the cutoff, no run or session names it) are now
on the UPDATE's own row, which PostgreSQL rechecks on the row's latest version
after the lock; the extraction claim does the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* release: 1.2.0-rc.1

Close the Unreleased section as 1.2.0-rc.1 with a short introduction,
set the app version to 1.2.0-rc.1, and stop triggering CI on the
integration/server-first and integration/provider-config branches,
which retire with this release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(readme): deploy 1.1.x on Vercel from a fork instead of the deploy button

Vercel's clone button reads tree/release/1.1.x as the branch release
plus the directory 1.1.x, and it does not accept a tag or a commit, so
the button could not deploy the 1.1.x branch. Describe the fork,
default-branch and import steps instead, in both READMEs, and correct
the changelog sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(readme): lead with 1.2.0 and condense the News list

Replace the 1.0 highlight with a short 1.2.0-rc.1 summary, keep one
line per release in News with the 0.x entries folded, and bring the
Chinese README's News up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(readme): drop the 1.2.0 headline and explain server-first in News

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(readme): remove the skill tip box

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs: add a Hosting and identity page

Move the operator and embedder material that lived in the README into the
docs site: Docker Compose defaults and upgrade notes, what the server
stores, access to stored data, asset collection and quotas, startup
checks, the agent runtime, owner identity (single-user mode, registering
auth methods, the signed-JWT gateway recipe, claiming anonymous work) and
the host extension hooks. The deployment pages now link to it instead of
the README anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(readme): shorten the configuration and persistence sections

Keep the minimum a new user needs in Quick Start: one openmaic.yml
example, a table of where to read more, and the longer provider examples
in a collapsed block. Docker, ACCESS_CODE, the agent workbench and
server-backed persistence become short sections with an identity-mode
table, linking to the new Hosting and identity page for the details.
Optional local providers move after the numbered steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(settings): keep the selected service in view in the Model Services list

The service panel opens on the service in use, which can sit far down a
long list (a connected token plan named after a built-in service is listed
in registry order). Its row was below the fold while the promoted first
row's ring looked like the selection, so the panel seemed to show a
service the list did not highlight. Scroll the selected row into view
whenever the selection changes, and give the selected row a stronger ring
than the promoted one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): condense the 1.2.0-rc.1 notes and explain server-first

Lead with highlights, breaking changes, upgrade steps and known issues;
keep the detailed notes folded below. Dated 2026-10-04.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(editor): let slide elements be dragged and resized in Safari

The Pro-mode slide editor's gesture hooks (drag, resize, rotate, shape
keypoint) decided between mouse and touch input with an instanceof test
against the TouchEvent global. Desktop Safari does not implement the Touch
Events API, so the global is undefined there and every mouse-down on an
element threw a ReferenceError before the gesture was armed: no element
could be moved, resized or rotated.

Recognise touch events by their changedTouches list instead, through one
shared helper. A unit test pins the helper in an environment without the
global and fails if browser code reintroduces the instanceof check; an e2e
spec removes the global before the app loads and drags and resizes an
element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the Safari editor fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(composer): compact material rows in the course-material popover

The material cards in the composer's material popover used a larger type
scale than the rest of the popover (a 14px title and 12px status beside its
12px labels and 10px hints), a large icon tile, and a progress bar loose
under the content.

Each material is now a compact row on the popover's type scale: the title
at 12px medium with ellipsis (the full name in its tooltip), the status in
10px muted text (the upload percent, Parsing or Transcribing with a spinner,
Ready with the file size), or the failure reason in the destructive colour.
The icon is a small file-type icon (slides, spreadsheet, image, audio,
video, document). The upload progress is a thin bar along the row's bottom
edge, and a pulsing one shows an extraction in progress. Retry and Remove
are compact icon buttons with their accessible names. The truncation notices
and the audio/video label stay on the row, small. The drop-zone hints and
the merge-order note now share one muted tone, and the list spacing matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(generation-preview): drop the material-analysis step from the preview

Materials are uploaded and extracted as soon as they are attached, so the
run's material step only reuses (or waits for) that extraction. The preview
still listed it as "Analyzing documents" while the step ran, which flashed by
even when every material was ready.

The preview now never lists a material step: while the run is at it, the
preview shows the step that comes next. A failure of the material step still
pauses the run and shows in the preview with its message and Retry, like any
other step's failure.

Removes what only that step used: the pdf-analysis step and its scanning
visualizer, the material kinds and analysis flags of the client run view,
and the analyzingCourseMaterial(Desc) / analyzingMediaMaterial strings in
every locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the material rows and the preview step removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(settings): hide Token Plan and Model Services when everything is locked

Under `lock: all` the settings show only the read-only course model map.
A token plan the workspace connected earlier changes nothing there, and
narration voices are not managed in this shape for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(changelog): note the full-lock settings change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(workspace): stop marking generated courses read-only

The Pro workspace treated a course whose outline a server job produced as
someone else's. Since generation runs on the server, that is every
generated course, so the owner saw a "Read-only" badge on a course they
can edit. Ownership now comes from the course list's isOwner alone (false
for a course saved from Discover), plus a run still producing the course.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(changelog): note the workspace read-only badge fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(composer): hide the extractor picker when the document slot is locked

The course material popover offered the document extractor select even
when the deployment locks the document slot (`lock: [document]` or
`lock: all`), where it could change nothing. It is now shown only where
the user may set that slot; uploading stays available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* feat(token-plan): run the Pro agent on DeepSeek V4.1 Flash with TokenDance

Connecting the TokenDance plan keeps the plan's own default model and its
slide and interactive models for those pages, and now also assigns the
agent slot to deepseek-v4.1-flash, a general model with dependable tool
calling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(whiteboard): hydrate learner boards without a 404 point read

Every classroom mount and whiteboard open hydrates the learner board through
the runtime service. When the partition listing held no whiteboard session,
selectSession then fetched the deterministic session id directly, which the
HTTP runtime store answers with 404 SESSION_NOT_FOUND. A board nobody has
drawn on yet is the normal state, so every fresh classroom logged a failed
request in the browser console, in both the legacy and the native child
whiteboard harness.

Hydration now reads the (stageId, learnerKey) partition listing only. A
same-id session of another kind in the partition is still rejected from the
listing, and an id collision the listing cannot show (a corrupt row, or an id
re-keyed into another partition by a learner merge) still fails loud on the
next append: its create collides and the existing create-race re-read
validates the winner. The runtime HTTP contract and its 404 semantics for
unknown ids are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the extractor picker, TokenDance agent and whiteboard fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(home): render the course library without waiting for every thumbnail

The home page gated the whole library section on loading every course's
first-slide thumbnail: loadClassrooms awaited getFirstSlideByStages, which
read each listed course's full document and fetched its first slide's media
bytes in one unbounded Promise.all, and `hydrated` only flipped once all of
that finished. A library of a few dozen courses fired one document read per
course plus every first-slide asset at once, and the list stayed hidden
until the last one landed. Every run-driven or import-driven list refresh
repeated the whole burst.

Render the list as soon as GET /api/stages answers, and load thumbnails per
card: a card (or a folder tile, for its cover candidates) asks for its
thumbnail while it is near the viewport, at most four loads run at once, a
card that scrolls away before its load starts withdraws it, and leaving the
page aborts the loads in flight and revokes every object URL. Thumbnails are
cached per course and versioned by updatedAt, so a list refresh reloads only
courses that changed. Cards show a skeleton until their thumbnail resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the home library loading fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* feat(home): paint the hero and a library skeleton before the page loads

Until the page's scripts had loaded and both GET /api/stages and
/api/folders had answered, the home page showed only its grey background:
the hero's entrance started from opacity 0 in the server HTML and only a
JS-driven animation revealed it, and the library section was not rendered
at all until both reads resolved.

The hero's entrance is now CSS (same timings), so the server-rendered hero
is painted and fades in without waiting for the scripts. The library
section, with its action bar, is always rendered; until the reads resolve
it shows a skeleton of its own layout: the same grid, with folder and course
tiles built from the cards' 16:9 thumbnail and title row and the thumbnail
pulse, so the cards replace it without moving anything. The grid takes the
skeleton's place without an entrance fade or stagger. An empty library still
shows the empty state, and a failed read the existing error.

A small pre-paint script applies the stored or system dark theme before the
first paint, so the server-rendered page is not painted light first in dark
mode; ThemeProvider leaves the document alone until it has read the stored
theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(home): keep course thumbnails in the device cache across reloads

Every reload of the home page read each visible course's whole document
and fetched its first slide's media again: document reads carry no
validators and asset bytes are served `private, no-store` (and the asset
client fetches with `cache: 'no-store'`), so nothing was answered from the
browser's HTTP cache. With a 33-course library a reload re-read 14
documents (1.1 MB) and 29 assets (19 MB) before the visible thumbnails
showed.

The thumbnail is derived data, so keep the derived result: a new
`courseThumbnails` table in the device cache (`maic-device-cache`) holds,
per course, the first slide and the bytes of its media, valid for exactly
the course's `updatedAt`. The thumbnail loader is told the version it loads,
answers from the cache when it holds that version, and otherwise reads the
course as before and stores the result. A changed course is read again and
its entry replaced. A thumbnail whose media could not be read (a transient
error) is not stored.

Entries are partitioned by owner, keyed by a one-way digest of the
server-derived owner key, so a claimed or switched owner never sees another
owner's thumbnails and the owner id is not written to disk. Clear Local
Cache deletes the device database, these entries included. The cache is
bounded (300 entries, 64 MB) and evicts the least recently used entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the home skeleton and thumbnail cache

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(home): show a slide-shaped skeleton until a course thumbnail loads

A card waiting for its thumbnail showed a flat pulse over the tile's own
background, too faint to read as loading, so once the library skeleton gave
way to the cards the thumbnails looked like empty grey tiles. They now show
the outline of a slide (title, text lines, an image block) pulsing at a
visible contrast, also while a loaded slide waits for the card's width, and
the library skeleton's course tiles use the same placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* docs(deployment): say what a restart or rolling update does to running runs

The guide said that stopping the server parks its runs. A stopping process
may exit before it releases its leases, so a run resumes once its lease
expires: progress pauses for about the lease TTL and the step in progress is
generated again, and a run interrupted MAX_ATTEMPTS times in a row without
finishing a step pauses with Retry. Say so, in every locale, and when to
raise the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* refactor(home): drop the composer's requirement draft cache

The home composer kept its requirement in localStorage and restored it on
the next visit, clearing it only once the generation preview confirmed the
outline. Generate now creates the course card at once, so a draft has no
job left, and it kept the last requirement in the composer after a run had
started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(home): draw video thumbnails from a poster instead of loading the video

Every home page load left aborted blob requests behind, one or more per
course whose first slide holds a video. SlideThumbnail drew a thumbnail
video as a muted <video preload="metadata"> over the video's object URL.
Chromium reads an object URL whatever `preload` says, and once it has the
metadata and a frame it drops the rest of the read, which DevTools lists as
a failed (net::ERR_ABORTED, type media) request; non-faststart files abort
twice. Safari's media stack range-reads the same URL dozens of times per
thumbnail, and lists the reads it gives up on as failed requests of type
"other". Nothing was revoked or remounted early: the element was still
mounted with its URL alive when the browser dropped the read.

A thumbnail video is now drawn as its poster image, with no media element
to load anything. Generated videos usually come without a poster, so the
thumbnail loader decodes the opening frame once and keeps it as one, in the
device cache with the rest of the thumbnail. The decode hands the bytes to
a detached <video> as a data: URL, which is decoded in memory and issues no
request at all, where an object URL would leave the same aborted reads
behind. Cached entries carry a derivation format; entries written before
this are read as a miss and derived again. A video without a poster whose
frame cannot be decoded still falls back to the <video>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the video thumbnail request fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(generation): continue after the outline on the server unless the learner reviews outlines

A classic run started with `outlineReview: "wait"` always, and only the
preview tab that started it confirmed the outline after a 2.5 s beat. A
learner who left before the outline was ready found the run waiting for a
confirmation forever.

The composer now starts the run with `outlineReview: "auto"` unless the
learner turned on "Always review outlines before generation"; the server
then confirms the outline itself and the run goes on with no page open.
A run that waits shows the review in every tab and page, with no
countdown. The preview of an auto run shows the outline read-only and
offers the setting for the next runs instead of a review entry.

Removes the client-side auto-continue (countdown, the started-here
session marker) and its string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): give the mid-stream review time to open while the outline streams

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note outline auto-continue and the dropped composer draft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(generation): count down to the outline confirmation on the server

The browser counted the 2.5 s pause before an outline continued, so a
run whose page was left before the outline was ready waited for a
confirmation forever. The previous fix confirmed such outlines at once,
which took away the review and editing the preview offered meanwhile.

The pause now runs on the server. A run started with
`outlineReview: "countdown"` (the composer's default when the learner does
not always review outlines) waits for its outline with an auto-confirm
deadline stored on the run (generation-runs migration 4); any process's
runner confirms the outlines whose deadline passed, in the same commit and
event an `auto` run makes. The new `hold-outline` command turns such a run
into a `wait` one, while the outline streams or during the countdown, and
answers a conflict once the outline was confirmed.

The preview is back to the classic flow: the outline-ready card while the
run counts down, the review entry mid-stream and on that card (it holds the
run first), editing and confirming with edits. It never confirms on a
timer, and says so when the run went on before the hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): describe the server-side outline countdown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(classroom): make the element reference control an icon button

The playback toolbar's reference control carried a text label next to its
quote icon. It is now an icon button like the whiteboard control beside it;
its accessible name and tooltip keep the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(workspace): handle courses that are still being generated

A course a classic generation run is still producing is read-only until the
run completes, but the Pro workspace treated it as an ordinary course:

- the classroom pane stayed blank. The pane is edit-locked, and the edit
  chrome never resolves for a read-only course, so `resolveStageChromeMode`
  returned its neutral `loading` shell for as long as the run lasted. The pane
  now shows a "being generated" placeholder with the run's progress and a link
  to follow it (or to Retry a paused run), and mounts the course by itself when
  the run completes;
- the course rail listed it as openable. It now reads the owner's active runs
  (the source the home page's cards use) and shows the run's progress where
  the page count goes; the row cannot be opened, dragged, renamed or deleted,
  and is not a drop target. A paused run's row opens the classroom where its
  Retry lives. The row turns ordinary, live, when the run finishes;
- the `@` picker offered it. It no longer does, not even as the open course;
- the agent did not know. The reader tools (read_stage, grep_stage,
  list_scenes, read_stage_outline, read_classroom) now start their result with
  a notice that the course is read-only until generation completes and must not
  be offered for editing; list_folder_stages and search_classrooms mark it with
  `generating`, `scenesCompleted` and `scenesTotal`; a classroom the user names
  carries the same notice. The pro-editing skill says the same in one line.
  Writes are still refused by the store, and the refusal text reaches the agent
  unchanged.

The home page's run status pill moved to a shared component so the rail and
the placeholder say the state the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note generating courses in the Pro workspace

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(workspace): list runs without a course yet in the course rail

The home page shows a card for a classic run from the moment it starts; the
Pro workspace's course rail showed nothing until the run created its course.

- The rail now lists the owner's active runs whose course is not listed yet,
  from the same `useOwnerRuns` source and the same rule as the home page's
  pending cards (`pendingCourseRuns`, now shared with `runsByCourse`), in the
  same order, titled as the card (`pendingCourseName`) and labelled with the
  shared run status pill text. A placeholder sits at the top of the loose
  courses, where its course row appears once the course is listed, so it turns
  into the generating course row in place and then an ordinary one. It opens
  what the card opens (`courseRunHref`) in a new tab, is never draggable or
  mentionable, and its one action is the card's discard. Like the home page,
  placeholders are left out of a search.
- A run started or discarded in one tab now tells the browser's other tabs
  (`runs-changed`, a BroadcastChannel), so a workspace open beside the home
  page reads the runs at once instead of at its next idle read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note run placeholders in the Pro workspace list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbL3cuQFRx8d1uJVQXz7XV

* fix(persistence): sanitize slide HTML on every document read and write (#1799)

Course documents served by `/api/persistence` are readable by id, and the
classroom renders slide text, shape text, table cells and LaTeX snapshots
with `dangerouslySetInnerHTML`. `/api/classroom` already restricts that
HTML to the renderer's vocabulary; the persistence document path did not.

The owner-bound document store now applies the same `sanitizeSceneContent`
policy to the stage and scenes on every write (save, create, putStage,
putScene, mutateScene) and every read (loadDocument, getScene), so new rows
are stored clean and rows written earlier are served clean.


Claude-Session: https://claude.ai/code/session_01Cvq11H26QM4mfbSNQQ5FdZ

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:40:42 +08:00
fa47efe1b3 feat(persistence)!: server-backed persistence only, with a one-way legacy browser import (#1710)
* ci: run CI for the persistence-default integration branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(docker): start PostgreSQL by default and add a single-user owner

* feat(docker): start PostgreSQL by default and add a single-user owner

`docker compose up` now runs a server-backed app against a bundled
PostgreSQL, started only once PostgreSQL is healthy and published on
127.0.0.1 only, with a new built-in singleUser owner auth method: every
request resolves to one fixed owner that may publish, and no anonymous
cookie is minted.

Single-user mode (OWNER_SINGLE_USER=true, OWNER_SINGLE_USER_ID default
"local") runs with or without ACCESS_CODE. Without one, the server logs
one prominent startup warning that anyone who can reach it shares, edits
and can delete the single library; it never inspects request peers or
forwarding headers. It excludes PERSISTENCE_SHARED_OWNER_ID and follows
the sharedTeam registration rules. Its principal gets a claim candidate,
so a browser's earlier anonymous work is claimed into the single owner
(OWNER_CLAIM_TRIGGER=auto in the Compose defaults).

Compose defaults live in docker-compose.defaults.env, read before
.env.local so they can be overridden. The app warns when its declared
published address (OPENMAIC_PUBLISH_ADDRESS, passed by Compose) is not
loopback while PostgreSQL uses the default password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(docker): keep claims explicit and let .env.local set DATABASE_URL

- Compose no longer defaults OWNER_CLAIM_TRIGGER to auto: an irreversible
  merge of every browser's anonymous library into the single owner must
  not happen on upgrade. The single-user principal still gets a claim
  candidate, so POST /api/identity/claim works; the docs explain it and
  warn about auto on a deployment several people used.
- The bundled DATABASE_URL moves to docker-compose.defaults.env, read
  before .env.local, so an external database or a rotated password set
  there keeps working. The password is inserted unencoded, so it must be
  letters and digits (documented).
- Upgrade docs no longer claim browser-stored courses are copied as they
  are opened: they stay in the browser, are not deleted, and are moved by
  the automatic browser-to-server migration shipped with this work.
- Single-user mode without ACCESS_CODE now logs one warning instead of
  two; the docs note that the single owner id is permanent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence)!: server-backed persistence is the only persistence (#1707)

* feat(persistence)!: make server-backed persistence the only persistence

Courses, folders and folder membership, chat history and learner runtime,
and generated media are now always stored on the server through the
embedded /api/persistence endpoint. The browser keeps no durable data of
its own.

- Remove the build-time NEXT_PUBLIC_PERSISTENCE switch: the client
  bootstrap always configures the HTTP document, runtime and asset seams,
  and the Dockerfile, docker-compose.yml, the Pi route and the whiteboard
  runtime no longer read it. Unconfigured seams refuse to resolve instead
  of falling back to IndexedDB, and the learner key is always the
  server-derived one.
- Remove the browser write paths: the browser document, runtime and asset
  stores as backends, the browser-mode branches of stage, folder, chat,
  media, narration and import storage, the Dexie backup export/import and
  the whole-database clear. The library lists through /api/stages, folders
  through /api/folders, and membership now goes through
  /api/folders/members. Regeneration always forks to a fresh asset.
- Device-local state moves to its own IndexedDB database
  (lib/device-storage, maic-device-cache): the local media and narration
  cache and refused-bytes retention, staged PDF images, undo history,
  browser voice profiles (carried over once from the old database) and
  the auto-voice cache. "Clear Local Cache" clears exactly that and no
  longer claims to delete classrooms or chat history.
- Keep the pre-server data readable for the one-way importer: the Dexie
  schema and read-only accessors for its tables and for the browser
  document, runtime and asset databases live in lib/legacy-browser-storage,
  which nothing on the regular paths reads and which has no write path.
- Require a database: the server exits at boot without DATABASE_URL, with
  a message naming `pnpm db:up` and `docker compose up`. `pnpm db:up` /
  `pnpm db:down` start and stop the Compose postgres service alone,
  published on 127.0.0.1 through docker-compose.db.yml; .env.example
  carries the matching local DATABASE_URL.
- E2E specs seed their courses through the persistence endpoint, each
  under a fresh id, instead of writing IndexedDB.
- Guard tests pin the boot requirement, the absence of any
  NEXT_PUBLIC_PERSISTENCE read, and the legacy-storage boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: run the E2E suite against PostgreSQL

The app refuses to start without DATABASE_URL, so the E2E job gets a
postgres:16 service and a job-level DATABASE_URL. The unit, storage and
render-service jobs are unchanged and need no database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: document always-on server persistence and the database requirement

README (English and Chinese), the deployment guide in every locale, the
extension cookbook and the changelog now describe PostgreSQL as required:
`pnpm db:up` for local development, `docker compose up` for a deployment,
and an external database for Vercel and other serverless hosts (the
Deploy button prompts for DATABASE_URL). They list what stays in the
browser, drop the NEXT_PUBLIC_PERSISTENCE instructions, and say that
courses an earlier browser-only build stored stay in the browser until the
one-way importer moves them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pbl): use the server-derived learner key for PBL v2 runtime

PBL v2 synchronization, hydration and drain resolved the learner key from
their default watermark KV store, which reads or mints a device key in
localStorage instead of asking runtime configuration. The runtime store is
the server one, which refuses any key but the owner's, so saving a course
with a PBL v2 scene failed before the document write, and the minted key
landed in the slot the one-way importer reads as the pre-server runtime
partition.

The learner key now comes from getLearnerKey(args.kv): the configured
server-derived key, or an explicitly injected store. The default KV store
still holds the drain watermarks and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(persistence): pin that clearing the cache keeps legacy databases

- Seed the four pre-server databases (MAIC-Database, maic-documents,
  maic-runtime, maic-asset-pool) with real rows, run Clear Local Cache, and
  assert every database and row survives; assert the device database name
  differs from every legacy name.
- The legacy-module import rule now also catches relative static,
  side-effect, dynamic and require imports at any depth.
- New rule: the runtime learner key is never read from a KV store the
  caller made up, only from configuration or an injected store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(dev-db): run the development database as its own Compose project

`pnpm db:up` / `db:down` shared the checkout's default Compose project with
`docker compose up`, so they recreated or stopped a running stack's
database. docker-compose.db.yml now declares its own project
(`openmaic-dev-db`), which gives the development database its own
container and data volume; it no longer touches the stack, and the two do
not share data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: start the development database before `pnpm dev`

CONTRIBUTING, the getting-started guide in every locale and the startup
modes reference now run `pnpm db:up` and set DATABASE_URL before
`pnpm dev`. README, the deployment guides, the cookbook and the changelog
describe `pnpm db:up` as a separate development database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(persistence): sharpen the legacy-storage and learner-key guards

- The Clear Local Cache test closes its seeding connections to
  maic-documents and maic-runtime, so a regression deleting either fails
  on the assertion that names the database instead of by timeout.
- The legacy-module and Dexie import rules accept comments inside
  import() / require(), so a webpackIgnore hint no longer hides one.
- The learner-key rule also flags an injected-looking name that is bound
  to a default local store in the same file; its remaining limit is
  documented, with the behavioural PBL test as the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(dev-db): pin the development database's Compose project

`pnpm db:up` / `db:down` now pass `-p openmaic-dev-db`, which takes
precedence over COMPOSE_PROJECT_NAME from the shell or a .env file; the
file's `name:` alone could be overridden and point the scripts at a
running stack's database. The compose test pins the flag. The docs (all
locales) and the file header say the development database is shared by
every checkout on the machine, and how to run a separate one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(persistence): import legacy browser data into the server, once (#1708)

* refactor(persistence): prepare the seams the legacy browser importer uses

- Legacy module: read-only accessors for the auto-voice cache, the course
  ids the old media and narration tables name, the pre-server learner key,
  and asset bytes from the browser asset pool.
- Narration adoption exports its row ownership rule so the importer applies
  the same one to rows of the old database.
- The quiz legacy migration is split into a non-deleting core
  (importLegacyQuizSnapshot) and the regular path, which still deletes the
  keys it migrated.
- The lazy document migration exports its snapshot canonicalizer.
- Clear Local Cache keeps the importer's per-owner ledgers, as it keeps the
  pre-server learner key.
- A library-changed window event lets an open library list courses that
  arrive in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* feat(persistence): import legacy browser data into the server, once

On the first load after the upgrade, the client moves what earlier builds
kept in this browser to the server, for the owner the server resolves:
courses from the browser document store and the original tables (with
chat, learner runtime, playback position, roster, folders and membership,
pre-runtime quiz state), their media bytes through commitToPool and the
existing write-back funnels, the bytes of server courses from earlier
opt-in server builds that only the old tables hold, and device-only rows
(failure records, refused bytes, auto-voice clips) into the device cache.

It is automatic and silent, runs after the page is idle, never writes to a
legacy store, and records every step in a per-owner ledger so it resumes
after a crash and never imports twice. Runs are serialized across tabs
with Web Locks. A course the owner already has stays authoritative; one
whose id another owner holds is imported under a derived fresh id; one the
owner deleted on the server stays deleted. Transient failures retry on a
later load with backoff; permanent ones are recorded per item.

The module is temporary and self-contained; its README lists the removal
steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): pin that each owner keeps its own import ledger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): run the legacy import against the app routes and a browser

A PostgreSQL suite routes every request the importer and the app seams
send to the real persistence, library and folder handlers, owner resolved
from the anonymous cookie: a full import, a course another owner holds, a
course the owner deleted, and an existing course whose media is filled in.
An end-to-end spec seeds an old pre-server database in Chromium, loads the
app, and checks that the course reaches the library with its narration
uploaded, stays in the browser untouched, and is visible from a fresh
browser context with the same owner cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: describe the one-way import of legacy browser data

The README (and its Chinese version), the deployment guide in every locale
and the changelog now say what the importer does instead of announcing it:
courses an earlier browser-only build kept in the browser move to the
server automatically on the first load after the upgrade, and the browser
copy stays untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): probe server assets through the shared asset-URL owner

The importer asked the pool directly whether an id exists, which the
asset-URL ownership boundary forbids outside use-asset-url. It now uses
assetRefExists, the existing metadata-only probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): guard the importer's pool probe like every other entry point

Only a reference the pool could have issued is probed, as the lease guard
requires; the importer is added to the list of guarded entry points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): import legacy browser data once per browser

Review round 1 of the legacy browser importer.

- Once per browser, not once per owner: the data belongs to whoever used
  the browser before the upgrade, so the first owner the import runs for
  claims it and any other owner gets nothing. The ledger is one key,
  maic:legacy-import:v2, recording the owner only as a SHA-256 digest
  (an anonymous owner id is a bearer credential) and a random salt.
- Handoff: when the claiming owner is retired (403 OWNER_RETIRED), or the
  current owner holds courses or folders the importer created (a claim
  moved them), the current owner continues the unfinished items; courses
  already done are never imported again, so a claim neither duplicates nor
  resurrects them, and a half-copied course keeps its runtime.
- Fresh ids come from the salt and the legacy id through SHA-256, not from
  the owner: unlinkable, unpredictable, and the same in every tab.
- 401 pauses with backoff instead of closing the import; 403
  FORBIDDEN_LEARNER stops the run and leaves the item pending.
- Without Web Locks: ledger writes merge with the stored copy, and the
  library is listed again right before a course is placed, so a second tab
  does not import a course the first one just created.
- Media the server refuses for good gets the app's failed-media record
  instead of a dangling reference; the legacy bytes stay.
- A legacy whiteboard or PBL session is not created when the server
  already has an active one of that kind.
- A folder that is gone leaves the course unfiled instead of failed, and a
  membership naming a folder the old database lacks counts as unfiled.
- Narration rows from before the course column are adopted when exactly
  one legacy course names their key; unconvertible chat rows are noted; an
  unreadable document-store copy falls back to the original tables; PBL v2
  documents go through the save-path strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover the importer's handoff, tabs, refusals and review gaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: say the legacy import runs once per browser

The README (and its Chinese version), the deployment guide in every locale,
the changelog and the module README now say that the first owner to load
the upgraded app in a browser claims its legacy data, what the handoff to
an account does, that the ledger holds no owner id, and how refused media
and the new pause and skip cases behave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): note runtime the importer cannot find without the old learner key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* feat(identity): let an owner confirm it absorbed a given owner through a claim

GET /api/identity/merged-from?salt=<hex>&digest=<hex> answers whether a
claim merged into the requesting owner an owner whose id hashes to
SHA-256(salt, id). It reads only the requester's own owner_merges rows,
never lists or names an owner, resolves the owner like every other
owner-scoped route, and is uncacheable. The one-way import of pre-server
browser data uses it to decide whether a different owner may continue an
import the first owner started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): hand the legacy import to another owner only on a confirmed claim

Review round 2 of the legacy browser importer.

- The only way the browser's legacy data moves from the owner that
  claimed it to a different owner is the server confirming, through
  GET /api/identity/merged-from, that the current owner absorbed that owner
  through a claim. The inferred signals (an owner that never wrote, a
  library listing that holds imported courses) and the retired flag are
  gone; a 403 OWNER_RETIRED only stops the run and leaves items pending.
- The first owner is bound only once the run's first authenticated request
  of its own (the library listing) succeeded, so a run that dies before
  that leaves the ledger unclaimed.
- The owner is recorded as SHA-256 of the per-browser salt and the owner
  id; the server computes the same value from the salt the browser sends.
- A "not absorbed" answer is reused for an hour instead of asking on every
  load, and the claiming owner stored first survives another tab's save
  unless that tab handed the import over; completion survives too.
- Run-level failures (401, OWNER_RETIRED, FORBIDDEN_LEARNER, OWNER_BUSY)
  from any call stop the run and leave the course pending, including the
  "is the id taken" read and the library re-list, which could previously
  mark the course failed and close the import.
- Refused media without a generation request (the user's own) gets a new
  non-retryable ASSET_REFUSED code, so it shows as failed without a Retry
  that could only fail; a skipped singleton runtime session leaves a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): pin the importer ledger's owner merge and the singleton-skip note

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: the legacy import moves to another owner only on a confirmed claim

The README (and its Chinese version), the deployment guide in every locale,
the changelog and the module README now say that the import runs once per
browser and moves to another owner only when that owner claimed the
original one, confirmed by GET /api/identity/merged-from; that the owner
is recorded as a salted digest; and how refused user media shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): never let a transient failure settle a legacy import item

Review round 3 of the legacy browser importer.

- A read of the old browser stores that fails in storage (an aborted
  IndexedDB transaction, a closed database) now pauses the run and leaves
  the course pending. Only a record that cannot be migrated, parsed or
  validated is skipped. This covers the course read (which no longer falls
  back to the older table copy on a storage failure), and the quiz-scene
  and speech indexes, which used to read a failure as "no scenes" or "no
  holder" and drop quiz state or narration for good.
- After a session-id collision, a failed read of the session propagates
  and is retried; only a successful read that shows the id taken skips it.
- Without Web Locks, two tabs of different owners can both find the
  ledger unbound. Every ledger write keeps the owner stored first, and the
  run now re-reads the stored ledger after binding, before folders, before
  each course and before each course document is created, and stops if
  another owner holds it.
- The "not yours" answer is reused for ten minutes instead of an hour, a
  stored wait further ahead than the longest the importer sets (a clock
  that ran ahead) counts as passed, and expired answers are pruned.
- Generated media refused without an error code keeps its Retry; only
  media nothing can regenerate gets ASSET_REFUSED. A poster that fails
  transiently leaves the video pending instead of being dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover transient reads, racing owners, the not-yours cache and the claim client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(persistence): say what the ledger's salt protects, and the new pauses

The salt defeats precomputed and cross-browser tables, not a targeted
guess against one browser's ledger; the module README and the digest's
comment now say so. The README also covers storage read failures, which
pause instead of skipping, the two-owner race without Web Locks, and the
ten-minute recheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* feat(identity): bind a browser's legacy data to one owner on the server

The one-way import of pre-server browser data now asks the server whose
data it is, instead of deciding in the browser.

- legacy_import_bindings (browser_id primary key, owner_id, timestamps),
  provisioned with owner_merges on fresh and upgraded databases.
- POST /api/identity/legacy-import-binding {browserId}: one atomic insert
  (ON CONFLICT DO NOTHING) and a read-back; answers only whether the
  requesting owner holds the browser. Same-origin JSON, resolved and
  write-fenced like every owner write, 400 for a malformed id.
- A claim participant re-keys the claimed owner's bindings to the account
  inside the claim transaction, so the account simply holds the browser.
- Owner resolution refuses any request carrying X-OpenMAIC-Legacy-Import
  with 409 LEGACY_IMPORT_NOT_BOUND unless the owner it resolves to holds
  that browser: one central fence every owner-scoped route goes through.
  Requests without the header are unaffected.
- GET /api/identity/merged-from is removed; the binding replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): import through fenced clients bound by the server

Review round 4 of the legacy browser importer.

- The ledger holds a random browser id and no owner information; the
  owner digest, the "not yours" cache and the client-side handoff are
  gone. A run first asks the server to bind the browser and imports only
  when the requesting owner holds it.
- Every importer request goes through its own clients that carry the
  browser id, so the server refuses any write under an owner that does not
  hold the browser: after a cookie switch in another tab, from a page whose
  memoized owner is stale, or from a tab that lost the binding race. The
  runtime learner key is asked for during each run, never taken from the
  page. 409 LEGACY_IMPORT_NOT_BOUND stops the run with items pending.
- The write-back funnels, commitToPool and the PBL save-path strip take an
  optional store, put or runtime, so the importer passes its fenced ones.
- A failed read of one legacy course holds up only what it could affect:
  the quiz-scene and speech indexes leave the course out as unindexed, and
  only quiz state or narration it could own waits. Read failures are
  counted per course; after five failing runs over at least a day, a
  course whose document-store copy cannot be read falls back to the table
  copy, or is skipped with the reason when there is none.
- Runtime ids are rewritten by replacing only their course segment, so a
  short course id cannot corrupt the prefix or the learner segment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover the server binding, its fence, bounded reads and short ids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: the server binds a browser's legacy data to its first owner

The README (and its Chinese version), the deployment guide in every locale,
the changelog and the module README now say: once per browser; the server
binds the browser to the first owner; a claim carries the binding to the
account; the importer's requests are refused for any other owner. They
also describe the bounded pause for a course the old storage cannot read,
and the removal steps for the server side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): keep a dropped asset request pending in the legacy import

The asset client reports a fetch that got no answer (a dropped request, an
aborted upload, the existence probe's deadline) as status 0 with
HTTP_REQUEST_FAILED, which the importer read as a final refusal: the media
was marked failed and the course could complete without it. Read that
code as transient, keep the client's local validation failures final, and
treat a 2xx/3xx answer a client could not use as transient too. Unit tests
drive every client the importer uses with a rejected fetch and a non-JSON
answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(persistence): ask a refused binding again only every ten minutes

A browser whose legacy data another owner holds sent the binding request
(a write) on every page load. Record a ten-minute recheck in the ledger
(clamped like every other deferral), so a claim that moves the binding is
still picked up, and open the old databases only once the browser is
bound. The read budget now counts consecutive failing runs (a run that
reads the course resets it) and counts a first failure dated in the
future from the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): answer 503 with the cookie when the import fence cannot read

The legacy import fence now reads the header name from the shared
constant, and a database error while reading the binding answers the
usual JSON error (503 PERSISTENCE_UNAVAILABLE) with the resolution's
Set-Cookie values instead of escaping as a bare 500. Route tests cover
that, a bind by an owner a claim retired (403 OWNER_RETIRED, no row), and
one header name on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(persistence): cover dropped asset requests, the recheck and the read budget

With the real asset client's error for an upload and an existence probe,
the media stays pending and a later run imports it. A non-holder's five
loads send one binding request and never open the old databases, and a
claim still hands the import to the account after the delay. The read
budget's run count and time span each hold on their own, a clock that ran
ahead does not hold a course open, and a good read resets the count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(persistence): complete the legacy import removal steps

List every file the removal touches (the claim scenarios test, the claim
participant table and list, every doc), keep the bindings table for one
release after the code goes because of rolling deploys and give the DROP
statement for that release. Add the claim's eighth participant to the
README lists, fix the fresh-id wording in the changelog, and describe the
recheck delay, the transient status-0 asset failures and the consecutive
read budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(persistence): keep legacy quiz state through Clear Local Cache until the import completes

Pre-runtime quiz drafts, answers, results and attempt ids exist only in
localStorage, and the one-way importer copies them to the server with their
course. Clear Local Cache deleted them even while the import was still
pending (it waits for an idle page and can stay pending after a failure), so
that quiz progress was lost for good.

Clear Local Cache now keeps the four quiz key families unless the importer's
ledger records the import as complete, read through a new
`legacyImportIsComplete` helper in the ledger module. The ledger key moves
there too, so the two modules do not import each other. Once the import is
complete, the keys are cleared as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs: show DATABASE_URL set in the local development setup

The getting-started guide showed the required DATABASE_URL line commented
out, so copying the block left the server unable to start. Each locale now
shows `pnpm db:up` and, separately, the `.env.local` line to set.

The `.env.example` header said every variable is optional; it now says that
provider variables are, and that DATABASE_URL is required except under
docker-compose.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(changelog): drop two upgrade notes the final code contradicts

The Compose entry said its default DATABASE_URL overrides .env.local; the
defaults file is read first, so a value in .env.local wins, as the same entry
says further on. The runtime-session entry offered turning server
persistence off as an upgrade path; that switch no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(env): restore the ACCESS_CODE warning notes in .env.example

A later change to the example file dropped the lines saying that an unset
ACCESS_CODE fails open, that the server then logs a one-time startup warning
and GET /api/health reports accessCodeConfigured: false. They are back, and
say that single-user mode logs its own warning in place of the generic one,
as the startup code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* ci: run every app PostgreSQL suite in the contract job

The app-domain step named its suites one by one, and seven had been left
off, among them the legacy importer's route suite. No other job gives the
app suites a database, so they skipped everywhere. The step now lists every
app `*.pg.test.ts`; all of them work in a schema or database of their own,
or truncate only their own tables, and pass before and after the storage
package suite in the same database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): establish the anonymous owner before the first API request (#1721)

* fix(identity): establish the anonymous owner on the page response

On a browser's first load the page sent several API requests without an
owner cookie; each minted its own anonymous owner and set its own cookie,
and the last answer to arrive won. Work done in between (the legacy import
binding, a first course write) then belonged to an owner the browser no
longer presented.

The middleware now mints the anonymous cookie on document navigations that
carry no valid one, with the route handlers' own value format and
attributes (the cookie module is Edge-safe now: Web Crypto instead of
node:crypto), and forwards it on the request so the page render resolves
the same owner. API, RSC, prefetch and Server Action requests never mint
there, and a valid cookie is never replaced. It mints only when neither
OWNER_SINGLE_USER nor PERSISTENCE_SHARED_OWNER_ID is set; host
registrations are invisible to an Edge middleware, so the README tells
hosts where to skip it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(legacy-import): bind only an owner the browser already presents

A binding request whose owner was minted by that very request answers
409 OWNER_NOT_ESTABLISHED with the minted cookie and binds nothing: other
cookieless requests may still be minting owners of their own, and a
binding to this one could be left with an owner nobody presents. The
importer treats it as transient and retries on a later load. It also says
plainly when a request after its own successful bind is refused as
another owner (the owner cookie changed mid-run); items stay pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(legacy-import): cover a first visit whose first answers arrive out of order

Against the real routes and PostgreSQL: a browser without a cookie loads a
page, sends two requests at once, and the first answer reaches it only
after the importer bound the browser; the import completes under the one
owner the page established. A second case binds nothing for an owner the
bind itself minted and imports on the next run. The E2E drives the same
ordering in Chromium by holding the first owner-scoped answer until the
binding answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* test(legacy-import): drop an unused initial assignment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(changelog): note the page response now sets the anonymous owner cookie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): let hosts turn page-response minting off

The middleware cannot see owner auth methods a host registers, so a host
with anonymousFallback: false still got an anonymous cookie on a cookieless
page load. Beside the host credential it is a claim candidate; with
OWNER_CLAIM_TRIGGER=auto it is claimed and cleared on the next request,
again after every such page load.

OWNER_ANONYMOUS_PREMINT (default on; true/1/false/0, anything else fails
startup) is read by the middleware before minting. At startup the server
warns once when a registration turns the anonymous fallback off while
pre-minting is still on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(identity): document OWNER_ANONYMOUS_PREMINT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): keep the anonymous identity for 400 days and renew it in use

The anonymous cookie had a fixed 30-day lifetime from its mint and was
never renewed, so every anonymous visitor lost their whole library 30 days
after the first visit however active they were; with persistence on the
server only, that affects every anonymous deployment.

It now lasts 400 days (the browser cap, one shared constant), and every
route handler and Server Action response that resolves to a valid
anonymous cookie re-sends the same value with a fresh Max-Age (best-effort
where next/headers refuses writes). Page responses never renew, so pages
stay cacheable, and a host principal never renews the anonymous cookie
beside it.

A response that clears the cookie (a claim, a retired owner) must not
renew it, whatever order a route merged its headers in:
withRequestOwner, and the owner-events retired answer, keep only the
clearing value for a cookie a value clears. A renewal that lands after a
claim cleared the cookie is pinned by a test: the next write with it,
alone or beside the account, writes nothing, and its answer clears it
again without renewing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* docs(identity): 400-day renewed anonymous cookie; when to turn pre-minting off

Hosts set OWNER_ANONYMOUS_PREMINT=false only when their registration sets
anonymousFallback: false; hosts that keep anonymous visitors need
pre-minting and skip it per request in middleware for requests their
methods authenticate. The CHANGELOG notes the new lifetime and renewal,
and that a lost cookie means a new owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

* fix(identity): forward owner cookies from routes that resolve the owner directly

The Pi chat and whiteboard-visibility routes and asset-id document
extraction resolved the owner themselves and never sent its Set-Cookie
values back, so an anonymous identity used only through them was never
renewed (and a minted one never stored).

attachOwnerCookies attaches a resolution's cookies to a response a route
built itself, with the clear-wins rule, before a stream's body starts. Both
Pi routes answer through it, success and error alike; resolveServerAsset
hands the cookies back with every answer and the extraction route attaches
them.

A guard test scans for every module outside the identity seam that calls
the owner resolution (or the asset helper) directly and requires it to
forward the cookies; the two callers that cannot are listed with the
reason. Behaviour tests cover the three paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRME5T4oEaQ4EBUzrDXMKz

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wyuc <dhq1204@yahoo.com>
2026-09-29 17:51:03 +08:00
wyucandClaude Opus 5.5 33553362be release: v1.1.1 (#1689)
Bump the application version to 1.1.1 and record the changelog. This is a
security release: MinerU Cloud parsing now routes every request through the
strict provider transport, holds the provider-supplied upload and result URLs
to the HTTPS public address policy, and bounds what it reads and decompresses.
There are no other changes since 1.1.0.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:20:10 +08:00
wyuc 21d83ec51b release: v1.1.0 (#1677) 2026-09-24 14:04:26 +08:00
wyucandClaude Opus 5 6914e03478 fix(docker): build workspace packages in the builder stage, not during install (#1598)
* fix(docker): build workspace packages in the builder stage, not during install

The root postinstall builds nine workspace packages inside `pnpm install`.
In the Docker deps stage that step peaks above 1 GiB, almost entirely from
the importer's rollup + terser pass, which exhausts small Docker VMs and
hangs the build (a 1 GiB container fails with a JS heap OOM).

- deps: `pnpm install --frozen-lockfile --ignore-scripts`, so the stage only
  resolves and links dependencies (peak ~1051 MiB -> ~315 MiB).
- builder: run the same chain explicitly via the new `build:packages` script,
  with `--max-old-space-size=1024` so a runaway build fails with a clear heap
  error instead of taking down the VM; public/vendor is produced here now.
- `postinstall` delegates to `build:packages`, so local installs are unchanged.
- CI: build the deps + builder stages when an image input changes; the main
  Dockerfile was not built in CI before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DYifP8wM4XJQ3Hc2qsF6zf

* ci(docker): build the main image for any non-docs change

The builder stage copies the whole build context, so application sources
and config such as next.config.ts are image inputs too. Only skip the
image build when every changed file is documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DYifP8wM4XJQ3Hc2qsF6zf

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 03:53:06 +08:00
wyucandClaude Opus 4.8 e693e11a81 release: v1.0.3 (#1517)
Bump the application version to 1.0.3 and record the changelog. This is a
security release closing three advisories — access-code token expiry and
verification throttling, a render-service network policy on untrusted HTML, and
audio provider redirect and DNS-rebinding validation — and upgrading Next.js to
patch a critical RCE, plus the fixes and features merged since 1.0.2.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 18:03:00 +08:00
4d0f88b4c9 fix: bump next to 16.3.3, patches GHSA-p293-qw3h-jr36 (#1503)
GHSA-p293-qw3h-jr36 (CVE-2026-75604, critical): unauthenticated RCE on Windows-hosted Next.js servers. Root was on 16.2.11 and packages/docs on 16.2.6 (affected: >=16.0 <16.3.3). Both lockfiles regenerated. eslint-config-next lint plugins left as-is (not the affected runtime package).

Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-15 16:20:45 +08:00
wyuc 89916f159b release: bump to 1.0.2 and add the changelog entry (#1491) 2026-09-13 18:13:06 +02:00
wyucandwyuc f50a25644c release: bump to 1.0.1 and add the changelog entry (#1388)
Co-authored-by: wyuc <dhq1024@proton.me>
2026-09-06 00:08:50 -07:00
6334e9adde fix(deps): bump next, js-yaml, undici, nanoid, lodash, sharp for disclosed CVEs (#1357)
Advisory-driven dependency bumps for already-public HIGH CVEs.

- next 16.1.2 → 16.2.11 (GHSA-6gpp-xcg3-4w24, GHSA-89xv-2m56-2m9x, GHSA-m99w-x7hq-7vfj, GHSA-p9j2-gv94-2wf4 and related)
- js-yaml 4.1.1 → 4.3.0 (GHSA-52cp-r559-cp3m / CVE-2026-59869)
- undici 7.22.0 → 7.29.0 (GHSA-f269-vfmq-vjvj, GHSA-v9p9-hfj2-hcw8, GHSA-vrm6-8vpv-qv8q)
- nanoid 5.1.6 → 5.1.16 (GHSA-28wg-ghj8-5hjv)
- lodash 4.17.23 → 4.18.1 (GHSA-r5fr-rjxr-66jc)
- sharp 0.34.5 → 0.35.4 (GHSA-f88m-g3jw-g9cj)
- eslint-config-next aligned to 16.2.11

Detected by osv-scanner. Lockfile-only + package.json version pins; no application code changes.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-03 07:18:47 -04:00
Zhenghan Songandwyuc ca3d785221 build(node): enforce direct dependency engine floor (#1337)
Why:
- The root Node 20.9 contract predates direct runtime dependencies whose declared minimums now reach Node 22.19.
- README, contributor, localized docs, and the OpenMAIC extension skill repeated the stale supported-version claim.

What:
- Raise the root Node minimum to 22.19 and align every operator, contributor, locale, and skill prerequisite.
- Add a check that compares the root minimum with installed direct production dependency engine minimums.
- Run the new contract check in CI after the frozen dependency install.

Risk:
- This changes the declared minimum only; no upper bound is added and Node 24 compatibility remains a separate concern.
- The localized docs build was validated with the independent #1306 boundary fix from PR #1307, which is not included here.

Tests:
- RED on the base: engine check reported pi-agent-core, pi-ai, svg-pathdata, and undici floors
- GREEN: root minimum 22.19 satisfies 35 engine-constrained direct dependencies
- Node 20 lockfile-only install reports the root unsupported-engine warning
- Node 22 frozen install and postinstall
- Docs build with PR #1307 boundary: 34 pages and all locale postexport checks; docs types:check
- Root Prettier, ESLint, TypeScript, i18n, package-version, and internal-dependency gates
- Root pnpm test: 7137 passed, 81 skipped

Live Docs:
- GitHub issue #1304 tracks the Node contract; #1306 / PR #1307 tracks the separate docs-build prerequisite.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-02 04:29:52 -04:00
Yizuki_Ameandwyuc 3214cead2c fix(workbench): render LaTeX in assistant messages (#1309)
* fix(workbench): render LaTeX in assistant messages

* fix(workbench): harden streamed math rendering

* fix(workbench): preserve streamed math boundaries

* refactor(workbench): use standard math syntax

* fix(workbench): preserve math in course link labels

* fix(workbench): disambiguate single-dollar math

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-02 02:21:59 -04:00
wyuc aa2bfb3c1d release: bump to 1.0.0 and add the changelog entry 2026-08-27 09:11:41 -04:00
wyucandClaude Fable 5 04621578de release: OpenMAIC 1.0.0 — the agent workbench (#1228)
* feat(storage): add an agent-session store with PG backend and layered contracts (#1163)

* feat(storage): add agent-session store with PG backend and layered contracts

* test(storage): avoid BigInt literals for pre-ES2020 root typecheck

* fix(storage): close agent-session store review findings

* docs(storage): align hook ordering and contention-probe claims with the code

* ci: run on the agent-workbench integration branch

* chore(storage): bump to 0.5.0 for the agent-session store

* fix(storage): carry replay compaction across page boundaries

* feat(agent): add the driver model contract and stage route dialect (#1165)

* feat(agent): add the driver model contract and stage route dialect

* fix(agent): validate route context windows and clarify dialect precedence

* feat(agent): adapt the agent-session store and runtime foundations (#1167)

* feat(agent): adapt the agent-session store and runtime foundations

* feat(agent): resolve request owner identity via an anonymous cookie

* docs(agent): document the opt-in compaction default and harden edge cases

* feat(agent): add the background session runner (#1169)

* feat(agent): add the background session runner

* feat(agent): wire the runner into startup behind feature flags

* fix(agent): stop clean interruptions from consuming the attempt budget

* fix(storage): charge the attempt budget for abandoned leases but not clean parks

* docs(storage): document the attempt-charging contract and decouple its tests

* feat(agent): add agent session and owner event streams (#1170)

* feat(agent): add agent session and owner event streams

* fix(agent): close the session-existence oracle and document the owner seam

* feat(agent): add agent session lifecycle routes (#1171)

* feat(agent): add agent session lifecycle routes

* fix(agent): validate session-create input and preserve the owner cookie on errors

* refactor(storage): drop the unused active-stage API from the agent-session contract (#1174)

* refactor(storage): drop the unused active-stage API from the agent-session contract

Tools address stages explicitly on every call, so the store keeps no
mutable session-level stage pointer. Removes resolveActiveStage and
setActiveStage from the store interface, their PG implementations, the
active_stage_changed lifecycle event, the session_active_stage owner
event variant, and the contract tests pinning them. The active_stage_id
column and the DDL check constraint stay untouched for schema
compatibility.

* chore(storage): bump @openmaic/storage to 0.7.0 for the contract removal

* docs: document the agent runtime configuration surface (#1176)

* fix(agent): repair orphaned and late tool results across interruption boundaries (#1180)

* fix(agent): repair orphaned and late tool results across interruption boundaries

A crash, shutdown, or provider failure can leave the durable transcript with
tool calls that have no result, or with results ordered illegally for the
provider. Three failure modes were fixed:

- Orphaned tool calls: a run that died between an assistant tool-call frame
  and its result left a dangling call in the entry tree. Resume no longer
  synthesizes and persists receipts for it: interrupted results are a
  read-time provider view owned by a shared read-boundary repair, which
  returns the original array for a healthy transcript and never mutates the
  tree.

- Late parallel results: a parallel tool can finish while pi unwinds an
  aborted assistant frame, leaving result(A), assistant(aborted), result(B)
  in durable order. Strict providers reject non-contiguous results, so the
  read-boundary repair moves existing results next to their owning assistant
  frame (in call order), omits incomplete unwind frames, and synthesizes
  receipts only for genuinely missing calls.

- Interrupted calls at the write boundary: a call still in flight when the
  run winds down (shutdown, lease loss, cancellation, provider failure) had
  no receipt at all. The runner now tracks in-flight calls from their
  assistant frames and, before the terminal flush, appends an
  interrupted-result receipt for each still-orphaned call through the same
  attempt-fenced write chain, so a lease-stealing zombie never writes and
  the next claim sees a provider-safe transcript.

* test(agent): pin the runner wiring for interruption-boundary tool repair

* feat(agent): add neutral tool foundation libraries (#1184)

* feat(agent): register a web_search tool on the session runner (#1185)

* feat(storage): add a per-session URL trust gate (#1186)

* feat(agent): add the skills system (#1189)

* feat(agent): add the skills system (builtin directories and durable user skills)

* fix(storage): serialize the user-skill quota check-and-insert per owner

Two concurrent creates at the 50-skill boundary both counted 49 rows and
both inserted (READ COMMITTED, no lock), overshooting the quota contract.
The create transaction now takes a per-owner pg_advisory_xact_lock first,
and the same-name idempotency check runs before the count check so an
at-least-once retry of the create that committed as the owner's 50th row
still returns its durable receipt instead of a quota error. The 23505
backstop is retained for writes that do not take the lock.

* fix(agent): share unstorable-character validation and align skill lookup

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate (#1190)

* feat(agent): add session materials and a fetch_url tool behind the URL trust gate

* fix(agent): harden session material fetching

* feat(storage): add an ownership scope to stage documents (#1191)

* feat(agent): add material read and search tools (#1192)

* feat(agent): add stage read and patch tools (#1194)

* feat(agent): add page generation and deck editing tools (#1198)

* test(storage): keep the PG contract suite order-independent (#1200)

* fix(agent): revoke deleted-session URL authority and reject private ISATAP endpoints (#1199)

* fix(storage): revoke deleted session URL authority

* fix(ssrf): reject private ISATAP endpoints in strict fetches

* chore(storage): bump to 0.11.1 for the session-URL authority fix

* feat(agent): add roster and voice registration tools (#1201)

* feat(agent): add folder organisation tools (#1202)

* feat(api): add stage and material HTTP routes (#1203)

* feat(workbench): add the client data layer (#1204)

* feat(workbench): add the client data layer

* docs(workbench): write the ported comments in English

* chore(edit): remove the in-editor agent panel (#1210)

* chore(edit): remove the in-editor agent panel

* style: apply prettier formatting

* fix(agent): report the runtime as unusable without a database (#1207)

* fix(agent): report the runtime as unusable without a database

* style: apply prettier formatting

* feat(agent): add image, video and pptx import tools (#1211)

* feat(workbench): add the agent chat surface (#1205)

* feat(workbench): add the agent chat surface

* docs(workbench): write the ported comments in English

* fix(workbench): label the folder and rename tools on the timeline

* fix(workbench): label the roster and voice tools on the timeline

The reconciliation test iterates every tool the runner registers and
requires a display label of its own. The roster and voice-clone tools
(list_voices, set_roster, clip_audio, register_voice) reached the
integration base with the roster/voice-registration tools but never
gained presentation rows, so they fell through to the default branch
and rendered their wire names. Port their rows from the reference
implementation (labels and i18n keys verbatim) and extend the
reconciliation allowlist with ROSTER_TOOL_NAMES and
VOICE_CLONE_TOOL_NAMES, so a future tool cannot enter the product
without a label.

* feat(agent): add the material extraction lifecycle (#1212)

* feat(storage): add material extraction lifecycle

* feat(agent): execute queued material extraction

* style: apply prettier formatting

* style: satisfy prefer-const in the extraction runner

* test: give material fixtures the extraction lifecycle fields

The media-tools slice and the extraction lifecycle slice were each green
in isolation but never compiled together: the lifecycle made derivedFrom
and extraction required on AgentSessionMaterial while the media-tool
fixtures predate them.

* chore: remove stray task notes

* fix(workbench): label the extraction lifecycle tools on the timeline

* feat(workbench): add the workspace shell (#1206)

* feat(workbench): add the workspace shell

* docs(workbench): write the ported comments in English

* i18n(workbench): align workspace keys across locales

* fix(workbench): adopt the landed data layer and label the extraction tools

- replace the sibling-slice seam stubs with the real data-layer modules
- drop ambient declarations now shadowed by landed files
- port timeline labels for the extraction lifecycle tools from the reference
- align the new i18n keys across all locales

* ci: retrigger

* feat(api): folder routes, stage-meta viewer surfaces, and the material upload contract (#1215)

* fix(storage): restore capability-based stage access

* fix(api): bind document access to request owner

* fix(agent): restore three-state stage access on the tool layer

Port probeStageAccess and the three-state StageAccess (owned / foreign /
missing / tombstoned) and gate every stageId-bearing stage tool on an owned
probe, mirroring the reference per tool:

- move_to_folder, rename_stage, read_stage_outline refuse a non-owned stage
  with the single not-yours message before touching the store.
- The course/DSL toolset and the roster toolset are wrapped by
  withOwnerStageAuthorization: read_stage, patch_stage, grep_stage and every
  writer refuse a foreign stage with the same message and refusal shape.
- Scene preview keeps its own probe and its own refusal text, and is
  registered beside the course toolset (never double-gated).
- The runner injects one probe factory at the three call sites.

Tests: the dsl cross-owner test premise (a foreign stage is readable by id)
encoded an invented capability-read policy that the reference does not have
at the tool layer; it now asserts foreign read/patch/grep are all refused
while the owner still reads. Curriculum cross-owner assertions were already
the reference's and now pass with the probes in place.

* docs: correct per-file test counts in the fidelity report

* test: fix type errors in stage-access fidelity test

* test: adapt media-tool and gate suites to the owner-scoped store seam

* feat(api): add owner-scoped course-folder HTTP routes

Port the reference implementation's /api/folders family (list, create,
rename, delete with ungroup/remove modes, and folder membership) onto the
owner-bound document store, replacing its provider-based auth with the
existing withRequestOwnerId / owner-scoped store seams.

The storage package's folder store grows the pieces the routes need:
DocumentFolder.order (schema column + max+1 assignment + ordering),
renameFolder, deleteFolder(mode) with captured member ids, and
setStageFolder(stageId, folderId | null) with idempotent un-filing.
FolderNameError moves into folder-name-validation.ts (stage-storage re-exports
it, keeping import sites intact).

Every route gates on the configured agent runtime (plain 404 when off or
unconfigured), keeps the reference's machine codes and envelopes, and is
covered by gate tests plus a behavior suite.

* feat(api): add stage-meta viewer surfaces for the classroom

Port the reference implementation's viewer-facing stage state — can-edit /
collected / published / generation-complete — on top of the stage-access
base (stage_meta + tombstones). stage_meta gains published_at and
generation_complete columns plus a stage_bookmarks table; the reference's
deployment-specific origin/claimed_at columns are stripped.

New gated routes: GET /api/stage-meta/[stageId] (per-viewer facts, 404 for
absent/tombstoned, never returns the owner id), GET /api/stages/[id]/status,
POST generation-complete / publish / unpublish (owner-only), POST
/api/bookmarks. The resolver lives in lib/server/stage-access.ts.

Wiring: a fetchStageMeta client with the reference's three-outcome contract,
stage-store isOwner/isBookmarked/readOnly fields (upstream single-user
defaults, no-op until the sidecar answers) plus setViewerAccess, the
classroom apply path computing readOnly = !(isOwner || isBookmarked), the
Stage editability gate, and a sidecar probe after each classroom load. A
sidecar 'absent' answer keeps the editable default here because the
classroom also serves local-only courses; server writes stay owner-enforced.

* feat(api): port the reference material upload contract

Rewrite POST /api/materials to the reference implementation's upload shape
so the workbench uploader (uploadWorkbenchMaterial, which posts no session
id and expects a flat 201 view) works unchanged: owner-scoped upload with
mime normalization/validation (415), per-class size caps checked on the
declared content-length and the streamed body (413), empty body (400),
quota (429), sha256 reserve->store->finalize lifecycle with abandon on
failure, flat { materialId, originalName, bytes, mime, extraction } 201,
and an x-request-id echo.

Adds the owner-scoped material library (owner_material table + quota +
24h lazy sweep, bytes in the host's asset registry as the neutral
replacement for the reference's object-storage byte path) and the material
cap configuration. The session-scoped GET list is left as-is; the
reference's owner-material extraction worker is not ported (the branch's
session-material extraction lifecycle already covers extraction).

Gate tests now cover all 23 persistence routes across the three runtime
env states; the materials behavior suite pins the new contract.

* feat(media): add an optional local ffmpeg media extractor (#1213)

Adds a local ffmpeg/ffprobe pipeline as a second media extraction
provider behind the extractor registry, ported faithfully from the
reference implementation: duration probing, keyframe-safe chunking,
per-chunk ASR with timeout and deadline budgets, and timestamped
transcript assembly.

- Availability probing feeds the registry's candidate selection: the
  provider simply is not a candidate when ffmpeg/ffprobe are absent.
- With neither ffmpeg nor a cloud provider configured, extraction fails
  with an actionable message naming both enablement paths.
- Media materials route through the same extraction lifecycle and lease
  fence as documents; no parallel queue.
- Tests inject the executable resolver so the missing-ffmpeg path is the
  default-tested one; the real pipeline test is skip-if-unavailable.
- @openmaic/storage 0.13.0 -> 0.14.0 (media routing in the material
  lifecycle surface).

* feat(storage): per-scene monotonic revisions via database triggers (#1214)

* feat(storage): per-scene monotonic revisions via database triggers

Restore the reference implementation's freshness granularity: a
per-scene monotonic revision maintained by database triggers, so every
writer (HTTP routes, agent tools, jobs, manual SQL) bumps it without
application cooperation.

- Companion revision tables + trigger functions in the storage package's
  idempotent schema bootstrap, with the lock-order invariant, pg_notify
  wakeup and the suppression switch for batch writers.
- ensureDocumentSchema gained a dollar-quote-aware statement splitter.
- The freshness and manifest routes serve per-scene revisions.
- Mutation-verified: dropping the triggers turns the revision tests red.
- @openmaic/storage 0.13.0 -> 0.14.0.

* fix: forward the freshness manifest through the owner-bound store

* feat(workbench): add the Pro entry points and preserve the mode-transition semantics (#1208)

* feat(workbench): add the Pro entry points

* feat(workbench): preserve Pro mode transition semantics

* fix(workbench): drop ambient declarations shadowed by landed slices

* fix(workbench): drop ambient declarations shadowed by the landed shell

* feat: port workspace shell sibling modules

Port the 16 leaf modules the Pro workspace shell imports but that were only
ambient-declared, replacing the compile-time bridge with real implementations
adapted from the sibling-slice reference: pure workbench helpers (session
title, rail tab, course-chat bootstrap, created-course tabs, course-tabs
memory, workspace navigation, pane navigation, pro-edit sizing,
existing-course minting, first-message session), the neutral brand context and
course-rename server API, the server-action session delete, the home
discovery hook, the classroom pane host with its load-policy leaf, the theme
toggle and floating-layer owner, plus the floating-layer-owner wiring the
dialog/dropdown/tooltip portals stamp.

Also add the workbench-shell locale copy for all 12 locales, port the
reference tests for the ported modules, and drop
types/workbench-sibling-slices.d.ts now that every declaration has a real
implementation.

* docs: keep ported comments in English and deployment-neutral

* docs: announce 1.0.0 and refresh the feature overview (#1216)

* docs: announce 1.0.0 and refresh the feature overview

* docs: finalize 1.0.0 README after feature merge

* fix(agent): control-plane routes answer 404, not 500, without a database

The agent control-plane routes gated only on the runtime flag, so an
enabled-but-unconfigured deployment (flag on, DATABASE_URL empty)
answered 500 from a store that cannot connect. Gate them on the
configured check instead, matching the stage/material routes: the
whole surface is cleanly absent until both the flag and the database
are present. The status probe keeps reporting both bits.

* test: mock both runtime gate exports in the control-plane route suites

* fix(agent): abort in-flight TTS on cancel and bound each provider request with a timeout (#1217)

The generate_tts / scene-tts path checked the runner's AbortSignal between
actions but never created the provider HTTP requests with it, so a session
cancel left a hung synthesis fetch in flight until a restart repaired the
tool result. Thread the signal end-to-end: TTSModelConfig carries an
optional signal, generateTTS combines it with a per-request timeout
(TTS_REQUEST_TIMEOUT_MS, default 30s, ported from the reference runtime's
TTS bounds) via AbortSignal.any, and every provider fetch (openai, azure,
glm, qwen incl. voice-clone + audio download, voxcpm, minimax, doubao,
elevenlabs, lemonade) is created with that signal.

A timeout now fails the tool call with TTSRequestTimeoutError (a clear
retryable error) instead of wedging the session; a caller cancel propagates
as the interruption so the runner settles the session as cancelled without
a restart.

Tests: hung-provider simulation rejects at the timeout with the retryable
error; abort mid-flight aborts the captured request signal and surfaces the
interrupted shape; removing the signal wiring makes the abort tests fail
(red), restoring them turns green.

* fix(workbench): PG-mode home listing via owner stages; keep the interrupted terminal course card (#1218)

Finding 1: with server persistence on, listStages resolved to the generic
GET /api/persistence/documents listing, which the capability model
deliberately answers 403 FORBIDDEN_DOCUMENTS for (reads by id, listings
owner-only). The home/workspace library now lists through the owner-scoped
GET /api/stages surface (same anonymous-owner cookie the workbench uses)
when server persistence is enabled; the server-side 403 is untouched.

Finding 2: a run interrupted (session_interrupted) and repaired
(session_resumed) that ends cancelled before agent_end stranded its pending
classroom sightings, so the timeline's terminal card lost the course the
answer produced. session_end (cancelled) now flushes the pending sightings
into the same course card set agent_end paints, before the stopped caption.

* chore(workbench): remove the bookmark concept and the saved-courses drawer (#1219)

* chore(classroom): remove the bookmark ('collected') concept entirely

The stage-meta viewer port introduced a bookmark surface (stage_bookmarks
table, POST /api/bookmarks, the isBookmarked sidecar field, and a
readOnly rule that let a saved course stay editable). The product has no
such concept, so remove it as a closure:

- delete the /api/bookmarks route and the stage_bookmarks table plus its
  query helpers from the persistence bootstrap
- drop isBookmarked from GET /api/stage-meta/[stageId]
- simplify the classroom read-only rule to readOnly = !isOwner across the
  sidecar client, ownership signal, classroom load, stage store and the
  classroom page
- keep publish/unpublish, generation-complete, isOwner and isPublic
  exactly as they were
- update the gate and stage-meta route suites and the README mentions

The workspace rail's Bookmark glyphs and comments describe the upstream
saved-courses (favorites) section, which is driven by isOwner and renders
no collect affordance; they are kept as unrelated homonyms.

* chore(workbench): remove the saved-courses drawer UI

The first pass removed the bookmark data model but kept the rail's
"Saved courses" drawer, judging it a separate surface driven by
`isOwner === false`. The home/workspace listing is owner-scoped, so that
flag can never occur: `allSaved` is permanently empty and the drawer
(plus the collapsed-rail Bookmark mini-button) is a dead affordance.
Remove it: the SavedDrawer component and its mount, the savedOpen /
savedSection state, the allSaved / matchedSaved derivations, the 'saved'
variant of the course-list renderers, the mini Bookmark glyph, the
drawer-only CSS, and the drawer's i18n keys from all 12 locales. The
courses tab is now exactly one folders tree.

The authored/favorites split in workspace-tree.ts goes with it; the tree
module no longer reads `isOwner`. The discovery course type keeps the
field — the shell still reads it for read-only gating.

Upstream has no collect concept; the drawer could only ever render empty
here. The reference implementation HAS this drawer (its favorites come
from its account system), so this removal is a deliberate upstream
product decision, not a fidelity bug.

* fix(workbench): restore the attach entry, add the rail settings entry, pin all three entry points (#1221)

* fix(workbench): restore the composer attach entry by gating it on the live runtime

The AttachButton's rollout probe read a `materialsEnabled` field that this
branch's /api/agent/runtime never answers (the materials routes gate on the
runtime itself, like the stages), so the gate could never pass and the attach
button never rendered — the Pro launch and chat composers showed only the
@-mention and enhance glyphs.

Substitute the field with the runtime's `enabled` value, which IS the upload
action's precondition: POST /api/materials answers 404 whenever it is false,
so the render condition now equals the action precondition (no dead button).

The button's label (`proMode.attach`) is a user-visible string that becomes
visible again; port the reference implementation's own translations verbatim
into the 11 locales that still carried the Chinese copy.

* feat(workbench): add the settings entry to the rail's bottom-left cluster

The reference's rail foot carries a cluster of utilities (its saved-courses
drawer, the language switcher, the display toggle). This branch removed the
drawer — it could only ever render empty here — and the product decision is
to fill that freed spot with the settings entry.

Add a settings trigger to the foot cluster (expanded rail, beside the
language and display toggles, and on the collapsed strip) and mount the
model/provider SettingsDialog in the rail, wired to the trigger. It is the
same dialog the classic home opens from its header pill; the workspace had no
settings entry of its own, so nothing is duplicated within a surface.

* test(workbench): pin the restored upload, attach, and settings entry points

Covers the three restored entry points:

- the courses-tab upload control: rendered beside the course name filter,
  wired to the discovery hook's ZIP import trigger, disabled while an import
  runs, and gated by the same condition as its action (the courses tab);
- the composer attach control: an actual render of AttachButton under both
  probe answers (visible when the runtime says the upload path is live,
  hidden otherwise), its mounts in the launch and chat composers, the
  branch's runtime-field substitution in the probe, and the reference's own
  `proMode.attach` copy in all 12 locales;
- the settings entry: the trigger in the rail's foot cluster (expanded and
  collapsed), beside the language and display toggles, opening the
  SettingsDialog the rail mounts.

* chore(config): the Pro workbench flag implies the MAIC Editor gate (#1223)

A workbench build without the editor toggle has no way to edit a course:
enabling NEXT_PUBLIC_PRO_WORKBENCH_ENABLED while forgetting
NEXT_PUBLIC_MAIC_EDITOR_ENABLED produced exactly that split-brain bundle.
The workbench IS Pro mode, so its flag now implies the editor gate; the
standalone flag remains for deployments that want the classroom editor
without the workbench. Documents both flags in .env.example.

* fix(agent): wake SSE tails and the runner on durable deltas (streaming fidelity) (#1222)

The Pro workbench chat did not stream: the session/owner SSE routes polled
the durable event log on a 5s/30s clock with no wakeup, so message_update
deltas (written at 150ms cadence) reached the browser in poll-sized blocks
and the thinking strip only mounted after the whole reasoning text had
accumulated.

Port the reference's LISTEN/NOTIFY delta path:

- storage: add in-transaction wake hooks (onSessionEventAppended,
  onOwnerEventAppended, onCancelRequested) so a host queues pg_notify in
  the same transaction as the durable append; align readEventsAfterForReplay
  to rank the bounded page so the first delta after the cursor is always
  kept (the live tail can never starve). Bump @openmaic/storage to 0.18.0.
- app: port the process-wide event-notify bus (dedicated LISTEN client,
  self-check probe, reconnect backoff; notify through the storage
  transaction surface), wire the store hooks, subscribe both SSE routes
  before the initial read with the reference's initializing gate, and give
  the runner one {kind:'session'} subscription whose wake runs the cancel
  check and the message drain. Polls stay as the lossy-NOTIFY backstop.
- lifecycle: start/stop the bus from instrumentation.

Tests: storage hook + compaction contract; route wakeup latency; runner
wakeup wiring with a fake agent; bus unit tests; PG contracts proving a
real append wakes the routes and a live SSE route forwards a message_update
on the wakeup, and that a rolled-back append never wakes. Also fix the
pre-existing park-attempt-budget PG test TRUNCATE (missing CASCADE against
newer FK tables).

* fix(storage): asset writes self-deadlocked against pooled PostgreSQL (#1225)

* fix(storage): refuse the non-transactional byte-write deadlock configuration

A byte store whose plain write() runs on its own pooled connection cannot be
invoked from inside a registry write transaction: after the transaction has
claimed the blob-row lock, that write blocks on the lock the transaction just
took while the transaction waits on the write - a self-deadlock PostgreSQL
cannot detect (one side is idle in transaction).

There is no lock-safe ordering for such a writer: bytes must be written after
the row claim (writing before it lets the collector delete the bytes while the
upsert waits), and any second-connection write after the claim is the
deadlock. The configuration is therefore detected and refused:

- AssetByteStore gains writesOutsideRegistryDatabase?: true, declaring that the
  layer's plain byte operations cannot contend for the registry's row locks.
- PgAssetStore refuses put()/replace() up front (and defends coordinatedWrite)
  when the byte store has no writeWith and does not declare the flag, throwing
  a clear configuration error before any row is claimed.
- The collector mirrors the guard on its delete path (deleteWith or a declared
  out-of-registry layer, else a configuration error).
- The object store declares the flag (its out-of-transaction write remains
  legitimate); the in-registry PostgreSQL byte column provides writeWith /
  deleteWith instead.
- Write transactions (put/replace/remove) set SET LOCAL lock_timeout = 30s so
  any future lock-contention variant fails loudly instead of hanging.

Bumps @openmaic/storage to 0.18.0.

* fix(persistence): forward the transactional byte methods through the lazy asset byte-store wrapper

The no-bucket case of lazyAssetByteStore returned a bare { write, read,
delete } and dropped writeWith/readWith even though the underlying
PgAssetByteStore has them. The registry's hasTransactionalWriter duck check
then failed and put() fell back to the byte store's own pooled connection,
which blocks forever on the blob-row lock the registry transaction just took
when the bytes live in the same PostgreSQL - the production self-deadlock.

The no-bucket layer is statically PgAssetByteStore, so its transaction-pinned
methods are forwarded eagerly (typed against the real signatures via
PgForwardedByteStore). The bucket case keeps its lazy-probing semantics: no
transactional writer exists there, the signed-URL method stays absent or lazy
exactly as documented, and the wrapper now declares writesOutsideRegistryDatabase
so the registry may run the plain write inside its transaction.

New tests pin the wrapper's transactional capability red-to-green and assert
put()/resolve() route byte traffic through the transaction-pinned queryable.

* fix(home): cap the generate-prep ingest drain at 3s so Generate never waits the full server budget

The classic home flow's Generate click drained in-flight ingests for the full
15s server budget. Cap the wait at GENERATE_DRAIN_CAP_MS (3000ms, documented as
a UX bound) and reuse the existing timeout fallback: sources that miss the cap
proceed on the legacy byte path and each late-resolving id is released.

* chore(storage): bump to 0.19.0 over the concurrently landed 0.18.0

* fix(agent): bound every tool call with a timeout; never resurrect a cancelled session (#1226)

* fix(agent): bound every tool call with a global timeout and settle it on cancel

A tool await that neither resolves nor rejects wedges the session forever:
the lease keeps heartbeating and the driver never reaches its next cancel
checkpoint. Race every tool execution (in buildAgent) against a hard budget
(OPENMAIC_AGENT_TOOL_TIMEOUT_MS, default 10 min, per-tool overrides for
known long runners) and against the caller's AbortSignal, so even a
signal-ignoring await cannot keep a cancelled session running.

On timeout the call rejects with AgentToolTimeoutError; the agent loop turns
the rejection into a structured error tool-result the agent can retry or
proceed from, and the abort signal is delivered to the tool's in-flight work
through a derived controller. Zombie-tool updates after settlement are
dropped.

* fix(storage): never re-lease a cancel-requested session; settle it as cancelled on claim

The claim scan treated a session with cancel_requested_at set as a normal
claim candidate: after a restart it re-leased the same session for attempt
N+1 and resumed generating despite the pending cancel. claimNextSession now
settles such candidates as cancelled under the claim lock (status cancelled,
attempt reset, lease and cancel request cleared, terminal session_end event
and owner projection) instead of leasing them, then keeps scanning.

Bump @openmaic/storage to 0.18.0.

* docs: takeaway-style 1.0.0 announcement with bilingual guide links

The 1.0.0 head is now a short takeaway block — badge links to the
official user guides (English and Chinese), five one-line highlights,
and pointers into Features and the workbench setup section — instead of
six dense paragraphs. The detailed provider-neutrality and freshness
notes move into the Features workbench section, phrased database-
neutrally (the announcement no longer names a specific database).
Release date corrected to August 27.

* fix(workbench): restore editor chrome, mode transition, streaming, materials, mentions, folders (#1229)

* fix(workbench): wire workspace folder routes

* fix(editor): restore reference workbench chrome

* fix(workbench): persist composer materials and course refs

* fix(workbench): preserve live reasoning frames

* fix(persistence): back off failed streaming saves

* chore(workbench): retire stale slice seams

* test(editor): cover element pin layer

* chore(storage): bump to 0.21.0 for the user-message ref/material fields

* chore(editor): translate ported code comments to English

* fix(agent): fence durable tool writes and consume cancel requests atomically (#1230)

* fix(agent): enforce provider force-off in agent tools and scrub vendor identity from tool results (#1231)

* fix(materials): serialize per-owner quota reservations and make crashed uploads reclaimable (#1232)

* fix(editor): resolve dock-bar i18n keys, remove dock height drag, wire element referencing (#1233)

* fix(workbench): send the opening session message exactly once with refs intact (#1234)

* feat(editor): port timeline TTS preview single-flight and voice-all state latching (#1235)

* fix(media): restore the reference classic media chain (#1236)

* fix(import): adapt imported PPTX canvas size so decks render without overflow (#1237)

* fix(editor): complete element referencing — renderer DOM contract and GenUI picking aligned with the reference (#1238)

* test(providers): reconcile the provider-config vendor-token debt count after the main merge

The integration line's AK/SK fallback for the managed document provider adds
occurrences that main's allowlist snapshot predates. Same mixed-composition
debt category the group already documents; no new vendor behavior.

* test(providers): reconcile vendor-token debt counts with the integration line

The main-merge brought main's neutrality-guard snapshot next to integration
features it predates (media-extractor fallback chain, local voice-profile
deletion semantics, the enabled-TTS helper). Same debt categories the guard
already documents; counts updated to the guard's own tally and two grouped
entries added. No new vendor behavior.

* fix(agent): carry reasoning through the completions dialect so the thinking strip renders (#1239)

* feat(skills): add Feynman and spiral curriculum methods (#1240)

* feat(agent): port missing reference tools and skills (parity audit) (#1241)

* feat(media): retire asset-registry wiring; media and materials follow the reference byte model (#1242)

* fix(classroom): center adapted canvases in the stage and send back navigation home during generation (#1243)

* feat(settings): skill management with real list, download, delete, and upload (#1244)

* feat(settings): skill management section with real list, detail, and zip download

* feat(skills): owner skill delete and upload across storage, API, and settings

* fixup! feat(settings): skill management section with real list, detail, and zip download

chore: neutralize a reference note in the settings header comment

* fix(media): persist origin-independent classroom-media references from the agent runtime (#1245)

* feat(editor): float the insert toolbar in the outer frame with collapse (#1246)

The insert strip was bounded to the slide card, so it could only ever sit on
top of slide content: the card's overflow clipped it and it could not be parked
in the padding beside the slide. Move it into the studio frame the element
picker's panel already roams (CanvasOverlayPortal + the frame selector), so both
canvas overlays share one bounding container and their handles behave the same.
While picking, the strip rises over the picker and goes inert, which is the
z-order CANVAS_OVERLAY_Z already documents.

Add a fold beside the grip: the chevron collapses the strip to that grip row and
back, with the buttons unmounted rather than hidden. The fold is session-local
state owned by EditShell, next to the drag offset, so a surface swap keeps it;
nothing is persisted. Expanding a strip parked at the bottom edge re-clamps
through the same bounds rule the keyboard move uses.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): align the chat timeline's left edge with the composer (#1247)

* fix(agent): fence session claims while an ask_user question is outstanding (#1248)

* fix(agent): settle-time rescue tracks real delivery instead of a count offset (#1249)

* fix(persistence): migrate owner_material to oss_key and drop legacy asset_id (#1250)

* docs(readme): surface the 1.0.0 user guide badges at the top (#1253)

* fix(workbench): show newly created folders in the sidebar without reload (#1254)

* docs(readme): add the release version prefix and drop the opt-in framing

* fix(workbench): single-source the chat gutter so timeline and composer share a left edge (#1255)

The transcript and the composer each established their own column: their own
`px-*` gutter and their own `mx-auto w-full max-w-*` centering wrapper. Equal
padding values were never enough, because the two columns are centered inside
different containing blocks — the transcript's is a scroll container, whose
content box is narrower than the composer footer's by the scrollbar's width:

  transcript text left = pad + (pane - 2*pad - scrollbar - measure) / 2
  composer box  left   = pad + (pane - 2*pad             - measure) / 2

The padding cancels out of the difference and what remains is `-scrollbar/2` at
every padding value, so the transcript sat half a scrollbar to the left of the
composer and tuning the two paddings against each other could not move it.

The column is now established once, by the nearest common ancestor of both
(`chatColumn`), and the scroll viewport and the composer footer are siblings
inside it that add no horizontal inset of their own. The cap carries the gutter
on top of the 760px reading measure, so the text column keeps its width. The
handed-over question row drops the padding that indented it past the agent's
prose; framed rows keep their own inner padding, which is what a card's border
sitting on the column edge means.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(workbench): lock pane-embedded classroom to edit mode (#1256)

The workspace right pane painted the full learning chrome — speed control,
play button, learner avatars, mic bar — for a course the agent had just
created, then flipped to edit once the first scene landed.

resolveStageChromeMode treated playback as the DEFAULT branch for a hosted
classroom, so every shortfall fell into it: a course whose tab opens at
stage_link time has no scenes yet, so currentSceneId is null and
isHostedSceneEditable is false. A folded pane parked the playback root
behind the fold and cross-faded it out over the pane on unfold, and a
failed editor chunk dropped into playback permanently.

Lock it at the pane instead of defaulting per entry path:

- WorkbenchPanelProvider — the single element that mounts a classroom into
  the workspace — publishes editPinned (visible && !playback). Every entry
  path passes through it, so none of them decides.
- The hosted resolution can no longer degrade to playback. Start Learning
  (workbenchLearning, new input, split out from pane visibility) is the one
  door; everything else resolves between the neutral loading shell and edit.
- Stage's chrome dispatch is exhaustive on chromeMode, so the playback root
  is no longer the else-branch of a condition about the current scene.

No flicker: chromeMode is resolved during render, and preloadEditor now
answers synchronously (isEditorPreloaded) so a remount with the chunk
already registered paints edit on the first frame. A failed import is no
longer cached forever, so the lock cannot strand the pane.

Standalone classrooms keep their stored mode unchanged.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:26:25 +08:00
wyuc 97ceb11a14 fix(storage): asset writes self-deadlock against pooled PostgreSQL (#1227)
* fix(storage): refuse the non-transactional byte-write deadlock configuration

A byte store whose plain write() runs on its own pooled connection cannot be
invoked from inside a registry write transaction: after the transaction has
claimed the blob-row lock, that write blocks on the lock the transaction just
took while the transaction waits on the write - a self-deadlock PostgreSQL
cannot detect (one side is idle in transaction).

There is no lock-safe ordering for such a writer: bytes must be written after
the row claim (writing before it lets the collector delete the bytes while the
upsert waits), and any second-connection write after the claim is the
deadlock. The configuration is therefore detected and refused:

- AssetByteStore gains writesOutsideRegistryDatabase?: true, declaring that the
  layer's plain byte operations cannot contend for the registry's row locks.
- PgAssetStore refuses put()/replace() up front (and defends coordinatedWrite)
  when the byte store has no writeWith and does not declare the flag, throwing
  a clear configuration error before any row is claimed.
- The collector mirrors the guard on its delete path (deleteWith or a declared
  out-of-registry layer, else a configuration error).
- The object store declares the flag (its out-of-transaction write remains
  legitimate); the in-registry PostgreSQL byte column provides writeWith /
  deleteWith instead.
- Write transactions (put/replace/remove) set SET LOCAL lock_timeout = 30s so
  any future lock-contention variant fails loudly instead of hanging.

Bumps @openmaic/storage to 0.5.0.

* fix(persistence): forward the transactional byte methods through the lazy asset byte-store wrapper

The no-bucket case of lazyAssetByteStore returned a bare { write, read,
delete } and dropped writeWith/readWith even though the underlying
PgAssetByteStore has them. The registry's hasTransactionalWriter duck check
then failed and put() fell back to the byte store's own pooled connection,
which blocks forever on the blob-row lock the registry transaction just took
when the bytes live in the same PostgreSQL - the production self-deadlock.

The no-bucket layer is statically PgAssetByteStore, so its transaction-pinned
methods are forwarded eagerly (typed against the real signatures via
PgForwardedByteStore). The bucket case keeps its lazy-probing semantics: no
transactional writer exists there, the signed-URL method stays absent or lazy
exactly as documented, and the wrapper now declares writesOutsideRegistryDatabase
so the registry may run the plain write inside its transaction.

New tests pin the wrapper's transactional capability red-to-green and assert
put()/resolve() route byte traffic through the transaction-pinned queryable.

* test(providers): reconcile the neutrality guard with current main and this fix

Two changes that were each green alone broke together on main: the
provider audit removed the lib/storage barrel and types entry points
and reshaped provider references while the guard still pinned the old
file list and counts. Point the file list at the surviving
lib/storage/client.ts, drop the satisfied sora debt, and update the
extract-document mineru count. The asset-byte-store pg count grows
because the wrapper now forwards the transactional byte methods of the
concrete store, which necessarily names it.
2026-08-27 12:50:31 +08:00
Som SamantrayandCommandCodeBot 5dc023d92d feat(export): download narration script as Markdown or DOCX (#1144)
* feat(export): download narration script as Markdown or Word-compatible .doc

Adds two export-menu entries that download the classroom's TTS narration
text (SpeechAction.text per scene) as a local document for lesson prep,
closing #413. The .doc is a minimal HTML document with the Word MIME type
so no new runtime dependency is introduced; .md is the portable plain-text
sibling. Scenes without speech are omitted, HTML is escaped in the .doc
path, and file names are sanitized.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* refactor(export): simplify narration script export hook

Reads stage store state at click time instead of subscribing per render,
drops unobservable exporting state (the handler is synchronous and the
menu-close is the reentrancy guard), removes a redundant empty-scenes
pre-check, and drops a type cast the discriminated union makes
unnecessary.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* fix(export): apply code review findings for narration script

- Skip whitespace-only speech text when collecting scene scripts, so a
  blank course cannot download a heading-only document with a success toast
- Trim and filter empty paragraphs in the .doc serializer to match the
  markdown path
- Strip only dangerous control ranges in the filename sanitizer so emoji
  and ZWJ sequences survive (the broad \p{C} class was splitting surrogate
  pairs)
- Add unit tests for whitespace-only speech, HTML paragraph normalization,
  the <br> branch, emoji-preserving filenames, control-char stripping, and
  markdown whitespace filtering

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* fix(export): address review feedback on narration script export

- Decouple the export menu trigger from full media-readiness gating:
  script export (.doc/.md) now stays usable while media generation is
  still pending. PPTX/Resource Pack/ZIP/Video items keep the existing
  media-dependent gate, now with visible disabled state and a tooltip
  explaining why.
- Document the HTML-as-.doc format as a deliberate zero-dependency
  tradeoff (not an oversight) directly above buildDocHtml.
- Sanitize scene/stage titles before they're interpolated into
  Markdown headings, so embedded newlines or leading # runs can't
  inject extra headings or corrupt document structure.
- Localize the "Slide N" fallback label via the i18n system instead of
  hardcoding English, adding export.slideFallback (plus export.textOnly
  and export.mediaPending for the new UI states) across all 12 locales.

Addresses review from @wyuc on PR #1144.

* refactor(export): dedupe export-readiness derivation in header controls

canExport already implies canExportText once it ANDs the media-readiness
check on top, so the trigger's title/aria-label/readiness ternaries had two
independent copies of the same 3-way branch drifting apart. Derive canExport
from canExportText, and share exportReady/exportLabel between the button's
disabled, className, title, and aria-label props.

* fix(export): escape leading # in markdown headings instead of deleting it

Two issues found in code review: (1) a leading newline could shield a
leading # from the strip regex, then trim() would re-expose it unescaped
-- a real order-of-operations bug; (2) unconditionally deleting the
leading # run silently mangled legitimate titles like "#1 Introduction".
Escaping (\#) instead of stripping fixes both: content is preserved and
the character can no longer be mistaken for heading syntax, regardless
of what precedes it.

* fix(review): apply review findings

* fix(export): remove legacy doc script export

---------

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-22 21:23:33 +08:00
jackefnandwyuc 44e1ca0ca0 feat(rag): add document lexical retrieval foundation (#1078)
* feat(rag): add document lexical retrieval foundation

* fix(rag): align foundation contracts with review

* fix(rag): handle quoted br attributes

* perf(rag): stream grapheme chunk splitting

* fix(rag): preserve exact replacement scope

* perf(rag): avoid repeated grapheme segmentation

* refactor(rag): isolate grapheme chunking

* perf(rag): avoid runtime-dependent grapheme segmentation

* fix(rag): complete review contract corrections

* fix(rag): resolve follow-up correctness review

* fix(rag): address latest review corrections

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-17 12:16:31 +08:00
Yizuki_Ameandwyuc 19a895e889 fix(video-export): make Cyrillic and Arabic Quiz fonts deterministic (#1114)
* feat(video-export): make Quiz script fonts deterministic

* test(video-export): verify Arabic shaping visually

* fix(video-export): cover Arabic extension characters

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-16 16:22:34 +08:00
wyucand杨慎 0a10af11cf feat(storage): opt-in indirect asset byte egress (#1007) (#1100)
* feat(storage): add opt-in indirect asset byte egress

A deployment can now opt an asset byte GET into a 302 to a short-lived
signed URL when the byte layer can sign. It is off by default, and
byte-for-byte unchanged when off. The byte layer gains an optional
signReadUrl capability; S3AssetByteStore implements it through the new
optional @aws-sdk/s3-request-presigner peer, resolved lazily exactly
like the client SDK, with a signer seam mirroring the commands seam so
tests can bind doubles. The signed URL pins the contract's response
headers -- the media type after the renderable allowlist, the fixed
disposition, and the no-store cache posture -- via S3 response-header
overrides. PgAssetStore mints the URL from the same ownership-checked
transactional read a direct resolve performs, so authorization still
runs per read before any URL exists, and a store whose byte layer
cannot sign falls back to direct bytes. The contract documents the
shape and the disclosure tradeoff: a Location into a hash-keyed byte
layer names the content hash, so deployments that need the
no-disclosure property keep direct egress.

Refs #1007

* feat(app): wire ASSET_BYTE_EGRESS into the persistence route

ASSET_BYTE_EGRESS=redirect opts asset byte GETs into the storage
package's indirect egress; unset, direct, or an unrecognized value
keeps the default byte-for-byte behavior, with a warning for the
unrecognized case. The lazy byte-store wrapper now forwards
signReadUrl and answers undefined when the resolved layer has no
signer, so the PostgreSQL byte column degrades to direct bytes and
the S3 layer signs without the wrapper knowing which it holds. The
collector path is untouched: it builds its byte store through
createAssetByteStore as before and only ever calls delete.

Refs #1007

* fix(storage): let the packaged client survive redirect egress

Cross-review round 1 on the indirect byte egress change found two
problems.

A byte response that followed a redirect carries the pinned content
type but no X-Asset-Revision, and HttpAssetStore required both from
the same response, so every cold resolve under redirect egress failed
MALFORMED_RESPONSE. The client now detects the shape once -- a
redirected byte response with no revision -- latches it, and probes
HEAD before each cold GET from then on, taking the label from the
probe. The probe predates the download on purpose: a replacement in
between labels newer bytes with an older revision, which the next
revalidation detects and corrects, while the reverse order could pin
stale bytes under a fresh revision with no signal to repair it. The
probe doubles as the miss check, so a redirect-mode miss costs no
download. The direct path is byte-for-byte unchanged; the one double
download per client lifetime on first contact is documented in the
contract, which gains the client-side paragraph this behavior
implements.

signedUrlTtlSeconds also accepted lifetimes beyond the seven-day
SigV4 presigning maximum, minting redirects the object store would
reject. The option is now capped at construction.

Refs #1007

* fix(storage): cap signed URL lifetime below the reclamation grace

A signed URL whose lifetime exceeds the collector grace period can
outlive its object: the last reference goes, the grace elapses, the
collector deletes the object, and the still-valid URL errors at the
object store. The previous ceiling -- the seven-day SigV4 maximum --
made that window days wide against the one-hour default grace. The
cap is now fifteen minutes, and the contract text states both the
ceiling and the rule for deployments that shorten their grace.

Refs #1007

* fix(storage): decline signing when the optional presigner is absent

An unresolvable @aws-sdk/s3-request-presigner made signReadUrl throw,
which the registry surfaced as a failed asset read -- a 500 for a
deployment whose only fault is a missing optional peer. The contract's
answer for a byte layer that cannot sign is the capability fallback:
return undefined and let the caller serve the bytes directly. Signing
errors from a resolved signer still fail loud; only the missing
capability declines.

Refs #1007

* fix(storage): answer redirect egress as a descriptor to asking clients

Following a 302 is not header-neutral: the platform fetch forwards the
original request's headers to the object store's origin, stripping only
Authorization, so a deployment whose credential travels in a custom
header would hand it to the object store -- and the preflight the
forwarded custom headers provoke commonly fails there besides. This
replaces the latch-and-probe client shape with an explicit one: the
client sends X-Asset-Egress: descriptor on every byte GET, a
redirect-egress server answers 200 with a JSON { url, revision } body
instead of a Location, and the client fetches the signed URL with no
deployment headers at all. The revision comes from the descriptor, so
the probe-first ordering and its one wasted download are gone too. The
302 remains the answer for consumers that did not ask; the descriptor
response is marked by its own header so a JSON-media asset can never
parse as one.

Refs #1007

* fix(storage): negotiate the descriptor through Accept, not a custom header

A custom X-Asset-Egress request header is not CORS-safelisted, so every
byte GET to a cross-origin server became a preflighted request -- a
regression for direct-egress deployments that never opted into
anything, against servers with no reason to allow the header. The
negotiation now rides Accept with a vendor media type, which is
safelisted and costs no preflight, and the descriptor answer is
identified by that media type as its Content-Type rather than by a
marker header.

Refs #1007

* fix(storage): sign outside the registry transaction and reserve the descriptor type

Two review findings on the indirect egress path.

resolveIndirect awaited the signer inside the coordinated read, but a
signer on refreshable credentials can wait on the network, and a
database connection plus the blob row's FOR SHARE lock would be held
across that delay. The read now closes first -- the lock still puts
the hash, label and revision in one snapshot -- and signing runs
afterwards, which cannot observe anything the read did not.

The descriptor media type is also reserved from renderableTypes: the
client identifies a descriptor answer by that exact Content-Type, and
an allowlisted asset served with it would be misread as a descriptor
and fail MALFORMED_RESPONSE. The handler now rejects the configuration
at construction, and the contract states the reservation.

Refs #1007

* fix(storage): ship the signing deps and negotiate without ambiguity

Three review findings on the indirect egress path.

The app now declares @aws-sdk/client-s3 and
@aws-sdk/s3-request-presigner as runtime dependencies, and the
standalone build force-includes them for the persistence route: both
are reached through deliberately untraced dynamic imports, so the
shipped image could not resolve them, and a deployment opting into S3
or redirect egress would have found the capability silently absent.

The descriptor negotiation matches exactly on both sides: the client
compares the Content-Type essence, so a longer media type that merely
begins with the reserved value still serves as bytes, and the server
parses Accept media ranges, so a descriptor range sent with q=0
selects the redirect instead of the descriptor it explicitly rejects.

Refs #1007

* fix(app): refuse redirect egress when the collection grace is shorter

A signed URL must never outlive its object: with the handler's
60-second default lifetime, a deployment that sets
ASSET_COLLECTION_GRACE_MS below ten minutes lets the collector delete
an object while a URL minted against it is still valid. The route now
refuses the combination at handler initialization, naming both
variables -- a deterministic misconfiguration, loud at startup rather
than silent at read time.

Refs #1007

* fix(app): ship the signing SDKs without loading them

Two findings on the standalone deployment of redirect egress.

The outputFileTracingIncludes globs did not follow pnpm's scoped
symlinks, so the standalone image carried neither AWS package and both
S3 mode and redirect egress could not resolve their SDK at runtime.
The packages are now server-external and referenced from literal --
but never called -- dynamic import thunks in the persistence byte-store
wiring, which gets them traced into the image while module resolution
still happens only on first S3 use. Verified against a local
standalone build: node_modules/@aws-sdk now contains both packages,
and the route tests that pin the never-resolve-unless-configured
discipline still pass.

Media type comparisons are also case-insensitive now, as HTTP
requires, on both the Accept parse and the descriptor recognition.

Refs #1007

* fix(app): treat an empty collection grace as unset

Number('') is 0, so a present-but-empty ASSET_COLLECTION_GRACE_MS
failed the redirect-egress coordination check and took persistence
down with it, while the collector's own parsing reads the same value
as the one-hour default. The check now trims and treats empty as
unset, matching durationEnv.

Refs #1007

* fix(storage): accept bytes too, omit signing on the known-PG wrapper, state the full tradeoff

Three review findings.

The client's descriptor request now advertises both representations --
the vendor descriptor type preferred, */* accepted -- so a strict
content-negotiating layer can never answer 406 to a client that does
in fact consume plain bytes.

The lazy byte-store wrapper no longer advertises signReadUrl when no
bucket is configured: the layer is known to be PostgreSQL at
construction, and carrying the method made resolveIndirect take its
ownership query and blob-row lock just to decline, then repeat them
in resolve, on every cold GET. With a bucket configured the lazy
validation is preserved and the wrapper still declines when the
resolved layer cannot sign.

And the contract's disclosure tradeoff now states the second edge:
object-store GETs carry ETag and Last-Modified, response overrides
cannot strip them, and because deduplicated PUTs rewrite the
hash-keyed object, Last-Modified moves when another principal
re-uploads the same bytes -- shared-object write timing, beyond byte
equality. Deployments for whom that signal is sensitive must keep
direct egress.

Refs #1007

* fix(app): degrade on bad grace, share the ttl invariant, cover the real boundary

The latest review round, four findings.

A misconfigured ASSET_COLLECTION_GRACE_MS now degrades redirect
egress to direct with a warning instead of failing the shared
handler's initialization -- the asset backend is optional, and its
misconfiguration must never take document and runtime traffic down.

The package exports assertSignedUrlTtlWithinGrace so a deployment
wiring the handler and the collector separately validates the
invariant once at its own boundary: the handler caps the lifetime,
but only the deployment knows both numbers.

The contract states that nosniff does not survive the redirect --
response-header overrides cannot set it -- and why the client-minted
blob makes that safe for conforming clients.

And the route gains a real-boundary test: the Fetch<->Node adapter,
the real createStorageHttpHandler, and the egress wiring, exercised
with an actual byte GET through the composed stack instead of mocks.

Refs #1007

* fix(storage): make the unsafe egress configuration unrepresentable

Three findings on the indirect egress path, all resolved by moving a
rule into a place where it cannot be bypassed rather than by adding a
check a consumer has to remember.

The grace/TTL invariant now lives in the option shape. byteEgress takes
'direct' or { mode: 'redirect', collectionGraceMs, signedUrlTtlSeconds? },
so enabling indirect egress without declaring the reclamation window it
lives inside no longer type-checks, and the handler validates both
numbers at construction. The flat signedUrlTtlSeconds option, its
cross-field "requires byteEgress redirect" check, and the standalone
900-second cap as the only guard all go away; the ceiling stays, but
alongside the ratio rather than in place of it. This is what the earlier
exported assertSignedUrlTtlWithinGrace could not do -- it protected only
consumers who called it -- and it costs nothing in compatibility because
the option is new in this branch. The app resolves the grace through one
shared parser with the collector, so the number the handler checks is the
number the collector runs with.

A signed URL whose object is gone is now a miss. The client maps a 404
from the byte fetch to ASSET_NOT_FOUND, matching the direct path: the
entry was owned and readable when the URL was minted, so a reclaimed
object is the same physical state the direct read reports as a miss, and
what a read means must not depend on the deployment's egress setting.
Signing still does not probe for the object -- that would restore the
round trip this feature removes and make the mint's price vary with prior
presence -- so the contract instead requires the byte layer to answer 404
for an absent object, and states that S3 needs s3:ListBucket to do so.

Route coverage now spans the whole egress matrix through the real
composed handler rather than only the direct case: 302 for a consumer
that did not ask, descriptor for one that did, direct bytes when the
byte layer declines to sign, and direct bytes when a short grace degraded
the configured mode.

Refs #1007

* feat(storage): forward byte egress options through reference server

* fix(storage): fail closed on redirects and unconfirmed 404s in indirect byte egress

A signed-fetch 404 now maps to a miss only when the object store's XML
body declares NoSuchKey; every other 404 from the signed fetch fails loud,
so a wrong bucket, access point, or endpoint can no longer make a live
asset read as absent. The descriptor byte GET is sent with redirect:
'manual' and any 3xx answer is treated as an error, so a server that
ignores the descriptor negotiation can never forward the deployment's
custom credential headers to a redirect target.

The ASSET_BYTE_EGRESS switch is now documented in .env.example and the
server-persistence deployment section, together with the object-store CORS
and s3:ListBucket prerequisites it requires, and the asset descriptor
media type is exported from the package root and the asset/http subpath.

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-08-14 23:57:21 +08:00
wyucandopencode 673af150c0 release: v0.3.2
Co-Authored-By: opencode <noreply@opencode.ai>
2026-08-14 02:37:59 -04:00
Yizuki_Ame d1eeb95e1c feat(video-export): add deterministic Quiz question-list scrolling (#1102)
* feat(video-export): add deterministic Quiz question-list scrolling

* fix(video-export): bound Quiz layout measurement
2026-08-12 10:31:13 +08:00
杨慎 4ac70cceeb feat(video-export): capture self-contained static interactive HTML (#1086)
* feat(video-export): capture static interactive HTML

* fix(video-export): harden interactive HTML capture diagnostics

* fix(video-export): initialize runtime diagnostics in manifest

* fix(video-export): close interactive capture review findings

* fix(video-export): preserve packaged interactive resources

* fix(video-export): address packaging review feedback

* fix(video-export): harden interactive HTML packaging

* fix(video-export): unify interactive asset packaging

* fix(video-export): close remaining interactive packaging gaps
2026-08-11 22:16:58 +08:00
xuyuanwei678 26d280c9b7 fix(ci): stabilize Hyperframes lint command (#1097) 2026-08-11 11:27:21 +08:00
xuyuanwei678 c38da84ef6 feat(renderer): decouple editor UI from the app (#1072)
* docs: define renderer editor task 1 integration

* docs: plan renderer editor task 1

* feat: gate renderer slide editor

* feat: adapt renderer editor intents

* feat: connect renderer editor to slide surface

* test: complete renderer intent shape fixture

* fix: restore renderer editor action previews

* fix: render editor alignment guides

* perf: smooth renderer element dragging

* feat: align renderer hidden element behavior

* feat: add renderer canvas host commands

* feat: add renderer canvas context interactions

* test: cover renderer context menu interaction

* fix: preserve renderer canvas interaction semantics

* docs: design renderer text editing

* docs: plan renderer text editing

* feat(renderer): add ProseMirror text core

* feat(renderer): add rich text editor controller

* feat(renderer): render editable text in place

* feat(renderer): enter text editing on click

* feat: connect renderer rich text editing

* feat: finalize renderer text edit lifecycle

* fix: stabilize renderer text edit commits

* fix(renderer): move elements from selected borders

* docs: design renderer editing UI text toolbar

* feat(renderer): publish editing UI contracts

* feat(renderer): add controlled text format toolbar

* style(renderer): format editing UI contracts

* fix(renderer): address Task 2 review findings

* fix(renderer): handle zero font sizes in toolbar

* feat(renderer): add editing UI color picker

* fix(renderer): address Task 3 color picker review

* fix(renderer): commit native color input changes

* fix(renderer): coordinate native color picker interaction

* feat(renderer): anchor text toolbar to canvas elements

* feat(renderer): compose editable canvas with text UI

* feat(editor): use renderer text toolbar UI

* fix(renderer): make editing UI tests type-safe

* fix(renderer): harden editing UI integration

* feat(renderer): add editable text UI

* feat(renderer): add image and shape editing

* feat(editor): extend renderer editing controls

* feat(editor): add renderer chart insertion

* docs: design renderer latex editor

* docs: plan renderer latex editor

* feat(renderer): add latex editor contract

* feat(renderer): add latex editor dialog

* feat(renderer): compose latex insert and edit UI

* feat(editor): integrate renderer latex editing

* fix(editor): consolidate latex toolbar actions

* fix(renderer): show toolbar hover labels

* test(renderer): cover localized latex tooltips

* chore(renderer): hide latex sample palette

* feat(renderer): add video editing controls

* docs: define renderer audio editing

* feat(renderer): render audio elements

* feat(renderer): add audio editing UI

* feat(renderer): add preview audio controls

* feat(editor): add renderer element clipboard

* feat(renderer): decouple editor UI from app

* chore(renderer): release 0.0.6

* fix(renderer): clear editor CI gates

* fix(editor): adapt renderer canvas to media resolution API

* fix(i18n): complete French editor labels

* fix(renderer): hide toolbar vertical overflow

* chore(renderer): address review cleanup

* chore(docs): remove planning artifacts from pr

* feat(editor): add transactional core package

* chore(editor): ignore local test cache

* build(editor): publish core entrypoint

* refactor(editor): route renderer changes through transactions

* refactor(editor): split renderer editing into editor package

* ci(editor): register editor package for release

* docs(editor): document package dependencies

* ci: rerun validation after editor release registration

* test(editor): validate deletion against existing element

* fix(editor): address transaction and package review findings

* fix(editor): validate transactions and restore editor styles

* fix(editor): protect required updates and react styles

* fix(editor): validate required transaction fields

* fix(editor): prevent insert popovers shifting canvas

* refactor(editor): own renderer editing capabilities

* fix(editor): harden editing transaction boundaries

* refactor(editor): modularize editor UI integration

* docs(editor): harden third-party integration

* fix(editor): preserve video resize aspect ratio

* fix(editor): discard text draft before deletion

* fix(editor): preserve renderer picking and line presets

* chore(editor): remove unused pick-layer import
2026-08-10 19:56:24 +08:00
wyuc c0da724ec8 ci(skill): publish OpenMAIC skill to ClawHub (#1056)
* ci(skill): publish OpenMAIC skill to ClawHub

* ci(skill): support manual ClawHub versions

* ci(skill): validate manual ClawHub versions

* ci(skill): normalize manual ClawHub versions

* fix(skill): preserve build metadata in version checks

* fix(skill): reject manual build metadata versions

* refactor(skill): share ClawHub version validation

* fix(skill): validate shared version checker inputs

* test(skill): cover ClawHub version validation

* test(skill): harden ClawHub version fixtures

* test(skill): cover ClawHub checker edge cases

* test(skill): cover ClawHub metadata contracts

* ci(skill): harden ClawHub publish workflow

* test(skill): cover ClawHub publish shell

* test(skill): strengthen publish shell regressions

* ci(skill): verify publish path on Bash 3.2

* ci(skill): harden ClawHub release guards

* ci(skill): unify publish divergence handling

* test(skill): bind publish divergence reasons

* test(skill): bind stale tree inputs

* test(skill): enforce stale guard ordering

* test(skill): lock publish guard sequence

* test(skill): enforce publish guard counts

* test(skill): enforce publish job uniqueness
2026-08-05 22:35:38 -04:00
wyuc 335180ec9c feat(generation): scaffold @openmaic/generation with pipeline types and packaged prompt assets (#1063)
Part A of #1057. Package skeleton (ESM, tsc, dsl/storage pattern), pipeline type contracts, prompt loader with package-relative asset resolution, 13 templates + 7 snippets byte-identical with golden tests, allowlist boundary lint, full publish/CI wiring, installed-tarball smoke coverage.
2026-08-05 22:22:54 -04:00
littlebullGitandwyuc 3d85525665 [codex] Add Amazon Bedrock LLM provider support (#538)
* Enable AWS-hosted text models without exposing ambient credentials

Rebase the Bedrock provider onto current main and close the review-requested security, credential lifecycle, usage attribution, configuration, and metadata gaps.

Constraint: Bedrock may use ambient AWS credentials only when explicitly enabled by the server operator
Rejected: Trust client-supplied provider types | permits built-in keyless IDs to reach Bedrock credentials
Confidence: high
Scope-risk: moderate
Directive: Keep provider ID/type validation at both request resolution and model construction boundaries
Tested: targeted Bedrock/resolver/config/usage tests; TypeScript; ESLint; i18n alignment; production build
Not-tested: Full suite has 27 failures in unchanged quiz/runtime and runtime/chat-storage tests

* Give Bedrock a recognizable provider identity

Use the official AWS architecture service icon so Bedrock no longer falls back to the generic provider cube in settings and model selection.

Constraint: Preserve the AWS-provided artwork without redesigning the service mark
Confidence: high
Scope-risk: narrow
Tested: SVG XML validation; provider unit test; TypeScript; ESLint; production build

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-03 23:10:50 -04:00
wyucandClaude Opus 5 cc95851dc5 chore(packages): publish storage and enforce version bumps (#998)
* chore(packages): publish storage and enforce version bumps

* fix(packages): cover all publish inputs and tarballs

* fix(packages): scope version guard to release inputs

* fix(packages): fail closed on new package inputs

* docs(packages): clarify version guard boundaries

* fix(packages): harden release guard edge cases

* fix(packages): align smoke peers and git inputs

* docs(packages): make release smoke policy explicit

* fix(packages): anchor version guard at repository root

* fix(packages): report version guard setup errors

* fix(packages): harden release validation

* fix(ci): validate complete main pushes

* test(storage): pin the exported PostgreSQL schemas

DOCUMENT_PG_SCHEMA and RUNTIME_PG_SCHEMA are public API. A deployment that
provisions these tables with its own migration tooling has to reproduce the
DDL exactly for ensureDocumentSchema() / ensureSchema() to stay the intended
no-op against an already-provisioned database.

Nothing guarded that today: every statement is CREATE ... IF NOT EXISTS, so
PostgreSQL silently accepts whatever table already exists under the name. A
column type, a nullability, an index or a FK action can drift apart from a
downstream migration without an error, and the first symptom is a store query
failing in production or succeeding against the wrong types.

Pin both constants verbatim, and assert every statement stays IF NOT EXISTS
guarded so the ensure functions remain idempotent. The pin does not judge the
DDL; it makes changing it impossible to do by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): gate publishing on the version check

The version check ran only in ci.yml. publish-packages.yml is a separate
workflow, so it could reach `pnpm publish` on a main push, a matching tag, or
a manual run with dry_run=false even when CI was red, skipped, or never ran.

Run the validation inside the publish job, before publishing, in a new release
mode that works for every trigger. A push range only exists for branch pushes,
so release mode judges each package against the registry instead: a version
that is not published yet is a release, and a version that is already
published is accepted only when the package source has not moved since that
release. `pnpm publish` skips an already-published version, so without that
second half a drifted package releases as a silent no-op.

The anchor for "since that release" is the @openmaic/<name>@<version> tag the
job now writes after every successful publish, falling back to the push range
for packages released before those tags existed. With neither anchor the
release stops instead of guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): make the release gate prove only what it can

Three independent reviews of the previous revision converged on one root
cause: it used git tags and a push range as the record of which tree produced
a published version. Neither is trustworthy. Tags are mutable and can be
created or moved by hand, so "the tag resolves" was being read as "the tag is
authentic"; pnpm records no gitHead; and a push range describes one push, not
the origin of a release. Packages released before the scheme existed had no
anchor at all, which made the tag and manual triggers fail closed from the
first run with no way to self-heal, dry runs included.

Drop the anchor machinery. Drift is prevented where it is provable, in diff
mode at merge time, which sees every commit before it can be released. The
pre-publish gate is now limited to claims a release can establish: every
version it publishes is new and moves forward, an already-published version is
reported and left alone, and any registry answer that is not a definitive
404 stops the release.

Around it, close what the reviews found in the workflow:

- real publishes only from a commit contained in main, so a tag or manual run
  cannot ship an unreviewed ref while holding NPM_TOKEN
- one repository-wide concurrency group, so a main push and a tag push cannot
  each decide the same version is unpublished
- publish and mark each package individually, so a partial failure stays
  retryable instead of leaving published packages unrecorded
- fail if the build rewrites tracked package files, which would otherwise
  publish content that is not in the released commit
- compare against every published version when ordering, and refuse to guess
  when the registry holds versions this check cannot order

The schema pin also asserted the constants without asserting what the ensure
functions execute, so those could diverge. Both are now run against a
recording queryable and their exact statement sequence is asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): refuse to release from a tree behind the registry

Simplifying the gate introduced a hole that a mutation check caught: a package
whose local version is already published took the "already published, skip"
path before any ordering check, so a tree rolled back to an older published
version passed silently. That is not a harmless no-op, because pnpm publish
rewrites each workspace dependency to the version in the tree, and a sibling
released alongside it would be published declaring a dependency on the older
package.

Order every package against the registry first, then decide: at the highest
published version it is a legitimate skip, below it the release stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): couple publishing to a green CI run for the same commit

Second review round, on the reworked design. The headline defect was a hard
blocker: `git fetch --depth=0` is not valid git (that is an actions/checkout
convention), so under `set -e` every real publish aborted before the gate ever
ran. Remove the depth argument; the checkout already has full history.

The rest closes the gap both reviewers reached independently, that being on
main is not the same as having passed the gate:

- wait for this exact commit's CI conclusion and require success. ci.yml runs
  concurrently with this workflow on a push to main and blocks nothing, so an
  unpublished version whose source drifted could be released while its own
  version check was still running or already red.
- require the commit on main's FIRST-PARENT history. Plain reachability also
  accepts every intermediate commit of every branch merged with a merge commit.
  A pull request that adds a bad tree and reverts it in the next commit leaves
  that tree an ancestor of main forever, and a tag pointing at it published it.
- do not cancel CI runs on main. Each run validates only its own push range, so
  cancelling one drops the range that contained the change, and its replacement
  compares against the cancelled tip and sees nothing.
- run the version check on every main push. A branch creation or force push
  reports an unusable `before`, which used to skip the gate entirely; fall back
  to the first parent instead.
- publish with --ignore-scripts. Every package's prepublishOnly reruns the
  build, deleting and regenerating the dist that was just verified and
  smoke-tested, so npm could receive bytes nothing had checked.
- run storage's PostgreSQL contract suites before publishing it. They skip
  themselves without a database, so the one backend that needs a real
  PostgreSQL was shipping unexercised.
- order registry versions with real semver, prereleases included. Rejecting
  every non-x.y.z version meant one historical prerelease anywhere in one
  package's history would refuse every future release of every package.
- warn when a release marker exists but points at another commit.

Note what is NOT enforceable here: a workflow_dispatch or tag run executes the
workflow definition from the selected ref, so a branch that edits this file can
delete these checks. The boundary has to be a GitHub Environment holding
NPM_TOKEN behind a main-only deployment branch rule. The job now declares that
environment and the header states the required setup; the in-file checks are
defence in depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): stop the first-parent check failing on SIGPIPE

Third review round. The blocker this time: `git rev-list --first-parent | grep
-qx` under `set -o pipefail`. grep exits at the first match, rev-list then dies
of SIGPIPE, and the pipeline returns 141, so a commit that IS on main is read
as "not on main" and every real publish is refused. It does not reproduce in a
shallow clone, where main's history fits in the pipe buffer; with the full
history this job checks out it always will. Count matches instead, which reads
the whole stream.

Also from this round:

- identify the CI run by workflow file and triggering event rather than by a
  check-run display name. Names are not unique, so any other workflow or app
  publishing a check called "Lint, Typecheck & Unit Tests" could stand in for a
  red version gate.
- fail the main-push version check when the push range is unusable instead of
  substituting HEAD^. A force push can replace many commits at once, so the
  previous commit is not the range that needs checking, and a green run here is
  what publishing depends on.
- correct the stated limitation. It claimed the package-directory input model
  was exact for dsl and storage; it is not. Their dist is whatever the
  lockfile's TypeScript emits, and dsl's shipped JSON schema comes from the
  lockfile's ts-json-schema-generator, so a toolchain bump can change any of the
  four tarballs with no diff under the package directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(packages): make a merged version bump the only release input

The tag trigger was incoherent. Its own documentation admitted that any
@openmaic/* tag republishes the whole family and that which packages actually
go out is decided by the manifests, so the tag name never participated in the
decision. It was a second entry point to one decision function, it bypassed
review entirely because a tag is not a diff and can be created by anyone with
write access on any commit, and it cannot be guarded: environment deployment
rules match GITHUB_REF, so refs/tags/* and a main-only rule are mutually
exclusive.

Drop it. A version bump that landed on main is now the only release input, and
`@openmaic/<name>@<version>` tags are an output written after a package
reaches the registry. Nothing is lost: a manual dispatch from main still
republishes without a new commit, and that path is inside the protected
environment.

Split the workflow so the token has a boundary. `validate` holds everything
that does not need NPM_TOKEN and runs from any ref, which keeps dry runs
useful to contributors. `publish` is the only job declaring the `release`
environment, so the token is never attached to a run that is merely
validating. It rebuilds rather than sharing state, which is the right trade
for a release.

Also add the `actions: read` permission the CI-conclusion query needs; it was
switched from the checks API to the Actions API without updating the scope,
which would have failed with 403 on every real publish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(contributing): explain when a package version must be bumped

CONTRIBUTING said nothing about versioning or releases, while this branch adds
a CI check that fails a contributor's PR with "publishable package inputs
changed but version did not increase". A gate that rejects work without
telling anyone the rule is the kind that ends up disabled.

State the rule, the exact failure message, which files are exempt, and how to
choose the number, with a specific warning for @openmaic/dsl: it is the
contract the other packages validate against, so narrowing what an existing
document may contain is breaking even when the diff is small.

Also state what contributors do NOT do: publishing is automatic once a bump
lands on main, and the release tag is a marker written afterwards rather than
something to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): run renderer's tests before publishing renderer

The pre-publish gate ran renderer's typecheck but never its test suite, so the
one package whose behaviour is hardest to typecheck was the one shipping
unexercised. It has 25 test files and 242 tests; they just were not in the
filter list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(packages): keep the git credential away from package code

Review finding: the publish job held `contents: write` while
actions/checkout had persisted that credential, and it then ran
`pnpm install` and the package builds. Any dependency or build script
executed with a credentialed git remote in reach and a write scope to use
it, which is a wider blast radius than publishing needs.

Separate the two capabilities so no job holds both:

- `validate` and `publish` run package code. Both now check out with
  `persist-credentials: false`, `validate` is pinned to `contents: read`,
  and `publish` drops write scope entirely, keeping only the `actions: read`
  it needs for the CI conclusion and `id-token: write` for provenance.
- a new `mark` job is the only holder of `contents: write` and a git
  credential, and it installs nothing and builds nothing.

`mark` also reconciles instead of only recording this run. A marker whose
push failed previously could never be repaired before, because the release
plan excludes versions already on the registry and the marker logic sat
behind that plan. It now marks any registry version whose tag is missing,
and leaves an existing tag alone rather than moving it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:54:23 -04:00
wyucandCodex e39c64cd9e feat(persistence): one-command server-backed stack (compose profile + embedded API + docs) (#982)
* feat(persistence): one-command server-backed stack — embedded API + compose postgres profile

docker compose --profile server-persistence up --build runs exactly two
containers: the app (persistence HTTP server embedded at /api/persistence,
enabled only when DATABASE_URL is set — unset keeps today's browser-only
behavior byte-for-byte) and PostgreSQL 16. Client bootstrap configures both
storage seams against the same-origin route when NEXT_PUBLIC_PERSISTENCE=1,
riding the existing lazy migrations so prior browser data reaches the
server per-course on first open. Dev auth (bearer token + client-asserted
learner header) lives in one overridable module and is loudly documented
as development-grade. Verified end-to-end against the real stack:
document PUT/GET and runtime session create land in Postgres; bad tokens 401.

Co-authored-by: Codex <codex@openai.com>

* fix(persistence): review round — structural bootstrap ordering, retryable init, honest failure surfaces

The bootstrap side-effect import moves into the two storage seam entry
modules, so any module graph that can resolve a store necessarily
evaluates it first — no client-entry ordering luck, and the two configure
calls are all-or-nothing. A failed server init clears its cached promise
(next request retries instead of permanent 500s). listStages rethrows
backend failures and the dashboard surfaces a persistence-unavailable
error instead of rendering an empty list that reads as data loss. Docs
state the build-time nature of NEXT_PUBLIC_PERSISTENCE, the real token
boundary (any visitor can extract it and impersonate any learner —
localhost/trusted-network only), and the correct postgres password
rotation. Dev-token compare is timing-safe; the handler singleton lives
on globalThis so HMR cannot leak pools.

Co-authored-by: Codex <codex@openai.com>

* fix(persistence): review response — no-confidentiality wording + adapter round-trip coverage

The token docs now state plainly that the NEXT_PUBLIC token is compiled
into the public bundle and provides no confidentiality or user isolation
(its only role is keeping unrelated scanners off a trusted network). The
Fetch/Node adapter — the route's most bug-prone code — gains a round-trip
integration test: PUT body streaming, 201 with multi-value headers, 204
empty body, and encoded path segments reaching the handler un-decoded.

---------

Co-authored-by: Codex <codex@openai.com>
2026-07-23 16:28:03 +08:00
wyucandClaude Fable 5 04acb17c1a release: v0.3.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:25:32 -04:00
d8a0081c7d feat(video-export): L1 Hyperframes emitter + browser collection + export ZIP (#865) (#931)
* feat(video-export): L1 Hyperframes emitter + browser collection + export ZIP (#865)

Consume the VideoTimeline IR (#864/#913) and produce a self-contained
Hyperframes composition project that `npx hyperframes render` turns into an
MP4 (render execution itself is #866).

- lib/video-export/emit-hyperframes: pure IR → Hyperframes project emitter
  (one flat composition, one paused GSAP timeline on window.__timelines,
  spotlight/laser overlays from lib/choreography descriptors). Stays under
  the purity boundary; emits HTML/JS strings only.
- lib/video-export/subtitles: SRT/VTT serialization of the IR subtitle track.
- lib/video-export-app: impure app glue (DI adapters over Dexie, slide-snapshot
  collection lifted from #849, streaming ZIP packaging, useExportVideo hook).
- Vendor GSAP locally (public/vendor/gsap.min.js) — determinism red-line.
- Export menu entry with resolution select + i18n across all 8 locales.

Verified end-to-end against the real hyperframes CLI: lint passes 0 errors on
a real classroom export, and a rendered slice produces a valid H.264+AAC MP4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(video-export): gate Export Video menu behind NEXT_PUBLIC_ENABLE_VIDEO_EXPORT flag

The video export UI is experimental until the render pipeline (#866) lands, so
hide the "Export Video" affordance behind an off-by-default feature flag.

- Add isVideoExportEnabled() to lib/config/feature-flags (NEXT_PUBLIC_, so it
  inlines at build time for the client-side export menu).
- Gate the Export Video block in header-controls on the flag.
- Cover the new flag in tests/config/feature-flags.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): ossKey fallback for evicted blobs + emitter dedup

Address code-review findings on #931.

- collect: resolveBytes() prefers the local Dexie blob and falls back to
  the record's CDN ossKey (audio) / ossKey+posterOssKey (media/poster) so a
  live-mode classroom whose local blobs were LRU-evicted still exports a
  self-contained ZIP. timeline-deps presence checks widened to
  `blob.size > 0 || !!ossKey` so the compiler keeps these entries instead of
  marking them absent; video-duration probe stays local-only.
- emit-hyperframes: extract one shared escapeHtml + sec into format.ts,
  dropping the two divergent escapers (attr vs escapeHtml) and the two sec()
  definitions with different rounding, plus the middle-man escapeAttr. Output
  is byte-identical (snapshot unchanged).
- Add tests/video-export/collect.test.ts (ossKey fallback, local-first,
  fetch 404/throw, video+poster).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): restore evicted generated media into base-frame snapshots

Address review blocker (P1) on #931: the ossKey fallback covered standalone
audio/video/image/poster asset entries but not the slide base-frame path.
resolveGeneratedMedia only accepted a non-empty local blob, so a live-mode
record with an evicted blob + valid ossKey had its image src cleared (and
video/poster not restored) before slideToPng snapshotted the slide — the frame
PNG lost the generated media even though the standalone asset was fetched.

- resolveGeneratedMedia now resolves bytes via resolveBytes (local blob first,
  then ossKey / posterOssKey) before creating the snapshot object URLs.
- Add frame-collection tests: evicted image restored via ossKey, evicted
  video+poster restored, and image with no ossKey still cleared. The renderer
  snapshot is mocked so the frame path runs in plain Node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-07-15 18:22:59 +08:00
wyucand杨慎 ebb12b5fc1 feat(agent): add validated JSON Patch element edits (#927)
* feat(agent): add validated JSON Patch element edits

* fix(agent): preserve JSON Patch replace semantics

* fix(agent): close structured replace contract gaps

* fix(agent): preserve structured add sequencing

* fix(renderer): preserve existing image filter units

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-15 14:20:00 +08:00
Yanpeng Wangandwyuc 9670100363 feat(extraction): media (audio/video) extraction + AliDocMind provider (#887)
* feat(extraction): add AliDocMind provider + media extraction abstraction

Adds AliDocMind (Aliyun Document Mind LLM version) as a new vendor
alongside unpdf/MinerU, and introduces the media (audio/video) extraction
layer that mirrors the document extraction one.

Document side (file: pdf/docx/pptx/xlsx/images):
- PDF_PROVIDERS gains an `alidocmind` entry; parseWithAliDocMind() maps
  layouts[] -> ParsedPdfContent. Flows through the existing document
  extractor registry, so AliDocMind is selectable anywhere MinerU is.
- PDFParserConfig / DocumentExtractorConfig gain accessKeyId/accessKeySecret
  (AliDocMind uses AK/SK, not a single apiKey); env fallback via
  ALIDOCMIND_ACCESS_KEY_ID / ALIDOCMIND_ACCESS_KEY_SECRET.

Media side (mp4/mp3/wav/... -> MediaArtifact):
- New lib/media-parse/ domain mirroring lib/pdf/ (types/constants/providers).
  parseMedia() maps AliDocMind segments[]/audio_frames/video_frames ->
  MediaArtifact (transcript + keyframes).
- MediaExtractorProvider interface + media registry + extractMedia() entry,
  symmetric to DocumentExtractorProvider / extractDocument().
- MediaArtifact and the ExtractionResult/Artifact/Error/Job envelope live in
  lib/document/types.ts (re-exported from @/lib/document).

Shared AliDocMind SDK wrapper (lib/pdf/alidocmind-client.ts) handles the
submit -> poll -> get flow for both sides via @alicloud/docmind-api20220711.

Tests: env-gated smoke test (tests/document/alidocmind.smoke.test.ts) drives
a real PDF and a real video through AliDocMind; media-artifact type test;
extractor-registry test updated for the new provider.

Part of #621 (MAIC ETL). Media extraction is the sibling to the document
extraction landed in #741.

* feat(extraction): wire AliDocMind AK/SK through settings UI + routes

Surfaces AliDocMind in the (post-#837) Document Parsing settings panel as a
peer of unpdf/MinerU — one panel, one credential entry, more supported
formats. Threads Aliyun AccessKey ID/Secret from the store through the
extraction routes.

- Store: pdfProvidersConfig + setPDFProviderConfig gain accessKeyId/accessKeySecret;
  default alidocmind entry added.
- pdf-settings.tsx: AliDocMind branch renders AccessKey ID + Secret inputs
  (secret masked with show/hide) and a Test Connection button; request-URL
  preview shows the DocMind endpoint. Provider auto-appears in the panel via
  PDF_PROVIDERS; supported-format badges come from #837's registry
  (ALIDOCMIND_MIMES added to lib/document/mime.ts).
- verify-pdf-provider route: alidocmind branch verifies AK/SK via a lightweight
  authenticated probe (verifyAliDocMindCredentials) — auth-level errors fail,
  anything else passes.
- extract-document route + generation flow (app/page.tsx, generation-preview):
  accessKeyId/accessKeySecret carried through session → FormData → config.
- i18n: alidocmindAccessKeyId / alidocmindAccessKeySecret in 8 locales.
- Icon: reuse /logos/bailian.svg (Aliyun family) instead of a missing asset.

Verified end-to-end against the running app: AliDocMind panel renders,
Test Connection returns "连接成功" through the real Aliyun API.

Part of #886.

* feat(extraction): route audio/video uploads through extractMedia() (reuse document path)

Media uploads now flow through the same upload picker, /api/extract-document
route, and generation pipeline as documents — no separate upload area. Only
the extraction differs: media mimes dispatch to extractMedia() -> MediaArtifact,
which is flattened to the text shape the generation pipeline already consumes.

- mime.ts: register audio/video formats in DOCUMENT_FORMATS (accept string,
  extension map, badges resolve for them); add MEDIA_PROVIDER_SUPPORTED_MIME_TYPES
  + SUPPORTED_MEDIA_MIME_TYPES, kept separate from PROVIDER_SUPPORTED_MIME_TYPES
  so the document drift-guard stays document-only. mimesForProviders() folds in
  a provider's media mimes, so the existing upload helpers
  (getAcceptStringForProviders / isMimeSupportedByProviders / format badges)
  cover media automatically when the provider supports it.
- extract-document route: media mimes dispatch to extractMedia(); MediaArtifact
  flattened to timestamped text (synopsis + transcript + keyframes).
- Tests: 6 media cases in mime.test.ts (accept/validation/badges/normalization).

Verified: uploading a real video through the route returns synopsis +
timestamped transcript/keyframes as text (curl, real AliDocMind key).

Part of #886.

* feat(extraction): use Alibaba Cloud icon for AliDocMind provider

Replace the placeholder bailian.svg with a dedicated Alibaba Cloud icon mark
(the square symbol from the official wordmark, text removed) so the provider
reads as Aliyun rather than Bailian. Square aspect matches the other provider
icons. Source: Alibaba Cloud / Alibaba Group brand assets.

* style: prettier format AliDocMind + media extraction files

* fix(extraction): harden AliDocMind — SSRF guard, cred verify, env gate, table text

Addresses review findings on the AliDocMind provider:

- SSRF (high): the media branch of /api/extract-document now runs the same
  validateUrlForSSRF check on a client-supplied baseUrl as the document branch,
  so an audio/video upload can't point the server's Aliyun SDK at an internal
  host.
- Credential verify (high): verifyAliDocMindCredentials now whitelists success
  signals instead of blacklisting auth errors. Probed against the real API:
  valid creds + bogus job returns a no-throw "BizIdNotExistOrResultExpired"
  body; invalid creds throw InvalidAccessKeyId.NotFound. Only a no-throw
  response or a "biz-not-found" business error counts as valid — an unreachable
  endpoint, a localized error, or throttling now correctly reports failure
  instead of a false "connection successful".
- Env-fallback gate (med): resolveCredentials no longer reads
  ALIDOCMIND_ACCESS_KEY_ID/SECRET unconditionally. Env fallback is opt-in via
  allowEnvFallback, which the route sets only for a server-managed provider, so
  an unauthenticated client request can't silently run on the server account.
- getDocParserResult error body (med): fetchResult throws on a non-200 result
  envelope instead of returning {} (empty text presented as success).
- Media pagination (med): stop after the first page when segments[] are present
  — layoutNum/layoutStepSize address layout blocks, not media segments, so
  re-requesting would loop over the same segments to the safety cap.
- Table content (med): tables/charts carry content in llmResult, not
  markdownContent; the layouts→text mapping now prefers llmResult for those
  types so table content isn't dropped (tables: true was advertised).
- Dead code: remove getCurrentMediaParseConfig (referenced non-existent store
  fields; the single-panel UI reuses pdfProvidersConfig).
- Dedup: media MIME list lives only in lib/document/mime.ts
  (ALIDOCMIND_MEDIA_MIMES); the media registry imports it.
- Tests: smoke test asserts durationMs is in ms (>1000 for a ~52s clip) to
  guard a ms/s unit mismatch; uses allowEnvFallback for the env-cred path.

Verified with the real key: PDF + video smoke tests pass; verify classifies
valid vs invalid creds correctly.

Part of #886.

* feat(extraction): extract AliDocMind images to base64 (parity with unpdf/MinerU)

AliDocMind embeds figure/picture image URLs inside each layout's
markdownContent (markdown `![](oss-url)`), not a dedicated field, and the URLs
are short-lived OSS signed links. Previously we emitted `images: []` and left
the expiring URLs inside the extracted text.

Now, for figure/picture layouts we:
- parse the OSS image URL out of markdownContent,
- fetch it at extraction time (before the signature expires) and re-encode to
  PNG base64 via sharp — the same base64 `images[]` contract unpdf/MinerU
  produce, so downstream storeImages → IndexedDB → slide works unchanged,
- populate metadata.pdfImages + imageMapping (the generation flow prefers
  pdfImages),
- strip the remote-URL markdown from the emitted text so expiring links don't
  leak into the prompt.

Downloads run concurrently; a failed/`sharp` image is dropped, never failing
the whole parse. Also fixes the prior over-broad table handling: only `table`
blocks read llmResult; `figure` is treated as an image (chart-figure llmResult
still kept in text).

Verified with the real key: a sample PDF yields 24 base64 images in both
images[] and metadata.pdfImages, and no oss-cn-hangzhou URLs remain in text.

Part of #886.

* docs(test): note AliDocMind video smoke test is non-deterministic server-side

* fix(extraction): correct AliDocMind pageCount (pageNum is 0-based)

Verified against a real response: AliDocMind reports pageNum 0..13 and
pageCountEstimate 13 for a 14-page document — both are 0-based. The metadata
pageCount previously used pageCountEstimate directly, undercounting by one.
Use the already-1-based maxPage, falling back to pageCountEstimate+1 only when
no blocks were seen. Document the 0-based convention at the normalization site.

* fix(extraction): address AliDocMind review — verify/SSRF/config/media (P1+P2)

Resolves all P1/P2 findings from the cross-review on #887.

P1 (blocking):
1. verifyAliDocMindCredentials now inspects the no-throw response body.code.
   An OSS-only key returns NoPermission without throwing; previously that was
   green-lit as "connection successful" then failed at extraction. Only a
   success/200 or the job-not-found probe code is accepted. Deterministic
   mocked test added (tests/document/alidocmind-verify.test.ts).
2. verify route trust boundary: managed → server-owned AK/SK + default endpoint
   only (ignore client values); unmanaged → client creds only, never env
   fallback, and the client endpoint is SSRF-validated before signing.
3. image fetch hardened: restricted to Aliyun OSS hosts, redirects disallowed,
   per-image byte cap, image-count cap, bounded concurrency (was unbounded
   Promise.all over provider-returned URLs).
4. AliDocMind is now selectable in the generation toolbar — availability
   recognizes the AK/SK pair, not just apiKey.

P2 (correctness):
5. Explicit server-config for the AK/SK pair (applyAliDocMindFallback +
   resolveManagedAliDocMindCredentials); verify and extract now resolve
   managed/env identically instead of verify-uses-env / extract-rejects.
6. Poll loop checks body.code before status, so a body-level error (e.g.
   NoPermission) fails fast instead of retrying for the full 15 min.
7. Image page numbers preserved through fetch/filter — no longer hard-coded to
   page 1, so multi-page image→page association is correct.
8. Empty media extraction (no synopsis/transcript/keyframes) returns 422
   PARSE_FAILED instead of HTTP 200 with empty text.
9. Format matrix trimmed to the official contract: images JPG/JPEG/PNG/BMP/GIF
   (dropped WebP/JP2), media MP4/MKV/AVI/MOV/WMV/MP3/WAV/AAC (dropped M4A).
10. A document-only provider (unpdf/mineru) uploaded with a media file now
    returns a clear 4xx instead of an opaque 500.

P3: credential-verify failures return INVALID_CREDENTIALS 4xx (not
INTERNAL_ERROR 500); formatTimestamp emits HH:MM:SS past one hour.

Verified with the real key: verify classifies valid→ok and invalid→fail; PDF
(24 images, correct page numbers) and MP4 extraction pass end-to-end.

* fix(extraction): make AliDocMind selectable + correct analysis label for media

Two UI/UX fixes found while manually testing the AliDocMind flow end-to-end:

- Persisted-state backfill: add ensureBuiltInPDFProviders so a PDF/document
  provider added after a user's settings were persisted (AliDocMind) is
  backfilled into pdfProvidersConfig on rehydrate. Without it the provider
  never appeared in the store, so it couldn't be selected and never picked up
  its server-configured flag. Wired into both persist migrate() and merge(),
  mirroring the existing image/video/web-search backfills.

- Analysis step label: getGenerationStepText showed "解析 document 文件" for
  audio/video (the type map fell through to the literal "document"). Documents
  keep their precise token (PDF/DOCX/PPTX/XLSX/images); audio/video now use a
  dedicated, locale-correct string (generation.analyzingMediaMaterial, added to
  all 8 locales) instead of forcing a format token into the "{{type}} 文件"
  template.

Manually verified end-to-end (real key): PDF, MP4, and MP3 (audio extracted
from the sample video) each extract and drive full course generation; a
DocMind-restricted AK/SK (OSS-only) now correctly fails verification with
NoPermission (400) instead of a false "connection successful".

* fix(extraction): address 2nd-round AliDocMind review (managed creds, stream cap, empty verify)

Resolves the three follow-up findings on #887:

1. [P1] YAML-managed AliDocMind creds now reach extraction. Both extract paths
   (document + media) previously cleared the managed AK/SK and relied on an
   env-only fallback, so a YAML-only deployment verified but failed to extract.
   They now resolve server-owned creds via the shared
   resolveManagedAliDocMindCredentials() (env OR YAML), matching the verifier.
   Regression test added for YAML-only creds with no ALIDOCMIND_* env.

2. [P1] Image download is now size-capped while streaming. Instead of buffering
   the whole response and checking length afterward, the body is read chunk by
   chunk with a cumulative byte count that aborts the moment it exceeds the cap
   — a missing/false Content-Length can no longer exhaust memory. Host allowlist
   tightened to oss-*.aliyuncs.com. Tests: non-OSS host refusal, no-Content-Length
   overflow abort, declared-oversize rejection.

3. [P2] Empty verification body no longer counts as success. Removed the
   codeStr === '' branch from the positive-signal whitelist (a working key
   always returns the job-not-found business code for the bogus probe id).
   Regression test added for empty and absent bodies.

Rebased onto main (package.json: kept @openmaic/storage + AliCloud deps).
Verified with the real key: PDF + MP4 extraction still pass end-to-end.

* fix(extraction): merge AliDocMind AK/SK into a baseUrl-configured YAML entry

Follow-up to the YAML-managed credential fix. When a YAML `pdf.alidocmind`
entry also specifies `baseUrl`, the generic loadEnvSection() (pdf requires a
baseUrl) creates the pdf.alidocmind entry copying only apiKey/baseUrl/models/
proxy — never AK/SK. applyAliDocMindFallback() then returned early because the
entry already existed, so the provider was "managed" but had no usable
credentials, and resolveManagedAliDocMindCredentials() returned undefined
(verify + extract both silently lost the creds).

Merge the AK/SK into the existing entry instead of returning early. Added a
regression test with baseUrl + accessKeyId + accessKeySecret together (the
previous test omitted baseUrl, so the generic loader skipped the entry and the
bug was masked).

* fix(extraction): don't mark AliDocMind managed without AK/SK; align poll code check

Two edge cases from a follow-up adversarial pass:

- [MED] A YAML `pdf.alidocmind` entry with `baseUrl` but no AK/SK (and no env
  AK/SK) made the generic loader create the entry → isServerConfigured=true
  (managed) → but resolveManagedAliDocMindCredentials() returned undefined, so
  the provider was locked out AND client-entered AK/SK were silently dropped.
  applyAliDocMindFallback now deletes a credential-less entry so the provider
  stays UNMANAGED (clients supply their own creds). Regression test added.

- [LOW] verify accepted a `code: "success"` body but the extraction poll loop
  threw on any non-"200" code — a success-shaped status would pass verification
  then fail extraction. The poll loop now treats "200"/"success" as benign,
  matching verifyAliDocMindCredentials.

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-07-14 18:12:31 +08:00
3b63710376 feat(ai): add Azure OpenAI provider (#916)
* feat(ai): add Azure OpenAI provider

Add deployment-based Azure OpenAI configuration for both client and server-managed setups, normalize Azure portal endpoints, and expose the provider in settings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: b0d3a94f-089a-470e-9987-608f1506ca6f

* style: format Azure provider type

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: b0d3a94f-089a-470e-9987-608f1506ca6f

---------

Co-authored-by: MarshellOnMoon <121332425+MarshellOnMoon@users.noreply.github.com>
2026-07-13 23:12:23 +08:00
wyucand杨慎 c8a638a101 feat: support GPT-5.6 model family (#907)
* feat: support GPT-5.6 model family

* test: cover GPT-5.6 SDK validation

* fix: canonicalize GPT-5.6 Sol alias

* fix: complete GPT-5.6 alias lookups

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-13 18:19:40 +08:00
wyucandClaude Fable 5 5627b99156 feat(pbl): runtime-event outbox + dual-write into the RuntimeStore (#869 Part C, step 2) (#893)
* feat(pbl): emit typed runtime events from every learner-state mutation (#869)

The outbox half of the runtime split: pure operations append typed
PBLRuntimeEvents to project.runtimeEvents (ring-capped at 500, dedup by
id) for every learner-state mutation — messages, submissions,
evaluations, status changes across project/milestone/microtask/uiPhase,
handover and task-completion gates, and proficiency updates. Emission
stays inside the pure mutators (no I/O), so events cross the stateless
server boundary inside the project; a client-side drainer (next commit)
copies them into the RuntimeStore. Zero read-side changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pbl): drain the runtime-event outbox into the RuntimeStore (#869)

Client-only drainer: after each persisted project change, copy new
runtimeEvents into the learner's active 'pbl' RuntimeSession (created
lazily), watermarked per stage through the device-scoped KV. Mid-drain
failures persist the last-success watermark and retry next tick; a
watermark evicted from the ring-capped ledger redrains visible events
(documented at-least-once — downstream folds dedup by event id). The
whole drain is bounded and never throws into the save path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pbl): dual-write engagement events; end-to-end outbox coverage (#869)

The drainer now copies both in-project ledgers — runtimeEvents and
engagementEvents — into the learner's pbl RuntimeSession, with
independent watermarks that advance separately (a failure in one ledger
never loses the other's progress). End-to-end test drives real
operations through the reducers and drains into a real
BrowserRuntimeStore over fake-indexeddb, asserting the full ordered
event trail with correct anchors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): harden the outbox drain per cross-review (#869)

- Watermarks scope to (stageId, sceneId, learnerKey): multi-scene stages
  and multi-learner devices no longer cross-contaminate or redrain.
- First-session creation is race-free: deterministic session id
  (pbl-<stageId>-<learnerKey>) with already-exists fallback plus an
  in-flight memo, so concurrent drains cannot split one history.
- A corrupt watermark value degrades to a fresh redrain and is repaired
  on the next persist, instead of permanently blocking the drain.
- Stage deletion clears its drain watermarks alongside the runtime rows.
- resetProjectProgress emits a project_reset epoch marker so folds never
  resurrect pre-reset submissions/evaluations from the preserved ledger.
- Normalization repairs use deterministic event ids, collapsing echoes
  from cloned-copy re-normalization under id-dedup.
- Docs: ring-overflow latent-loss window (dual-write accepts it; the
  read-flip backfills from a projectV2 snapshot) and the fold-baseline
  contract (status 'active' / uiPhase 'hero' from design-time defaults).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): carry server-minted events across the advance patch (#869)

Two holes on the server-patch path: applyAdvanceProjectPatch computed
status-change 'from' values after Object.assign had already replaced the
milestone snapshot (suppressing every real Instructor-advance event as
from===to), and events minted server-side during advanceMicrotask (e.g.
handover_staged) lived only on the transient server copy — the advance
patch carried no runtimeEvents, so the client ledger never drained them.
Patches now carry the operation's event delta; the client appends them
first, and both sides mint deterministic patch:* ids for the same
transition so echoes collapse under id-dedup. Server-side facts are
authoritative; client emissions remain as a compatibility fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): scope deterministic event ids to the reset epoch (#869)

Deterministic norm:/patch: status-event ids now lead with the count of
project_reset events visible in the ledger, so a learner repeating the
same transition after a reset is recorded instead of deduped against the
pre-reset event. The epoch is derived from the same visible window the
id-dedup scans, so ring-buffer eviction can never desynchronize the two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(store): drain debounced saves before teardown (#869)

setStageAgents schedules debouncedSave + debouncedSaveAgents; tests that
finished without flushing left timers firing after environment teardown,
lazily importing the settings/providers chain into a torn-down runtime
(deterministic on CI once this branch grew the stage-storage import
graph). Store tests now run fake timers and assert zero pending timers
at teardown; the same latent race in the insert-scene-after and
generation-complete suites is fixed in the same style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): address review — durable epoch, id-set deltas, ordered drain, post-save signal (#869)

- runtimeResetEpoch is a monotonic project field incremented on reset,
  never derived from the ring buffer, so evicting an old reset marker
  can no longer collapse post-reset transitions into pre-reset ids.
- Advance patches capture their event delta by id-set difference, not
  array index, so cap eviction mid-operation cannot drop server events.
- The drainer merges both ledgers into one timestamp-ordered stream
  (stable within each ledger) before appending, so RuntimeStore seq
  preserves global chronology instead of ledger-grouped order.
- Draining now subscribes to a post-save signal emitted after
  saveToStorage commits, instead of firing on the in-memory update —
  the dual-write can no longer outrun the source-of-truth persistence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): drain the persisted snapshot, decoupled from scene mounting (#869)

The post-save signal now carries the PBL scenes exactly as persisted;
a module-level singleton subscription drains them, replacing the
component-ref hook. Navigating away before the debounced save fires no
longer strands the saved scene's outbox, and an in-flight save can no
longer drain a newer unsaved snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:12:53 +08:00
wyuc 667f3b0c51 feat(runtime): device-anonymous learner identity + RuntimeStore app bootstrap (#869 Part C, step 1) (#885)
* feat(runtime): device-anonymous learnerKey + app RuntimeStore singleton (#869)

* feat(runtime): cascade stage deletion into the runtime store (#869)

deleteStageWithRelatedData now cascades into the runtime layer after its
Dexie transaction completes. The runtime data lives in a separate
IndexedDB database (maic-runtime), so it cannot join the transaction, and
the cascade goes through deleteStageRuntimeSafely — a helper that never
throws (warns with context instead): a broken or hung runtime DB must not
brick stage deletion in the main app DB. deleteStageRuntime is idempotent,
so a failed cascade can simply be retried.

Covered at the helper seam with a stub RuntimeStore (success + throwing);
the repo has no Dexie-in-node harness for database.ts and this change
does not invent one.

* fix(runtime): wire the live deletion path, serialize learner-key minting (#869)

Cross-review fixes on the C1 bootstrap:

- The runtime cascade was wired into deleteStageWithRelatedData, which has
  zero callers; the UI classroom-deletion flow goes through deleteStageData
  in stage-storage. Wire deleteStageRuntimeSafely into that live path too
  (after its Dexie work, same isolation rationale), and cover the wiring by
  running the real deleteStageData with its module deps mocked — the
  repo's established pattern for database-touching code.
- getLearnerKey minted twice under concurrency. Same-bundle callers now
  share one in-flight promise on the default path (failures are not
  cached), and every path re-reads after writing and returns the PERSISTED
  key, so a cross-tab race converges on the stored winner instead of
  keeping an orphaned local mint.
- Guard crypto.randomUUID with the house fallback pattern.
- Honest docs: the cascade comment no longer claims a retry path exists
  (orphaned rows are inert today; a startup sweep is deferred to Part C2),
  and both lib/runtime modules note they are client-only.

* fix(runtime): cross-tab mint lock and bounded deletion cascade (#869)

- Read-after-write alone still let a tab keep an orphaned learner key
  when its re-read landed before the other tab's write. Minting now runs
  under the Web Locks API ('maic:learner-key') where available: grants
  are mutually exclusive across tabs, the loser re-reads the winner's key
  inside its grant, and an existing key is never overwritten (so the
  per-tab memo stays safe). Without navigator.locks (older browsers,
  non-window contexts) the memo + read-after-write behavior remains, with
  the residual race named in a comment and accepted — it merely splits
  one anonymous learner's local history.
- deleteStageRuntimeSafely awaited without a bound, so a hung runtime
  IndexedDB could block the live deletion path — the exact failure the
  helper exists to isolate. The cascade now races a 5s timeout: on
  timeout it warns and resolves (orphaned rows stay inert), and the still
  -pending cascade carries a swallow handler so a late rejection cannot
  become an unhandled rejection.

* fix(runtime): probe for the runtime DB before cascading a stage deletion (#869)

deleteStageRuntimeSafely reached openDb() unconditionally, and opening
CREATES the maic-runtime database — so deleting a classroom on a device
that never wrote runtime data paid an open-or-create of a second
IndexedDB DB, and in degraded environments burned the full 5s bound for
zero cleanup value.

Probe first without creating: where indexedDB.databases() is available,
a missing maic-runtime entry returns immediately; where the probe API is
unavailable (older Firefox), fall through to the bounded cascade —
skipping there would strand real cleanup once Part C2 adds writers. The
probe shares the existing try/catch + timeout envelope, so a hanging
databases() cannot brick deletion either. The DB name is a module const
passed explicitly to BrowserRuntimeStore so probe and store can never
drift.
2026-07-09 14:54:21 +08:00
wyucandClaude Opus 4.8 04b70f0359 feat(storage): scaffold @openmaic/storage — KV + asset primitives (browser) (#857) (#858)
* feat(storage): scaffold @openmaic/storage — KV + asset primitives (browser) (#857)

First executable slice of the @openmaic/storage RFC (#779, Part 1): a pure,
app-agnostic persistence package depending only on @openmaic/dsl.

- Add the DSL-owned `StorageProvider` asset seam to @openmaic/dsl
  (put(blob)->ref / resolve(ref)->url / remove), typed against a structural
  `BinaryBlob` so the pure DSL keeps `lib: ES2022` (no DOM).
- `KVStore` (device/account scopes) + `BrowserKVStore` over localStorage.
- `BrowserAssetProvider`: content-addressed (sha256) bytes in IndexedDB,
  resolved to object URLs; identical bytes de-duplicate.
- `kvPersistStorage`: adapt a KVStore into a zustand `persist` storage
  (pure util; app store wiring is a follow-up).
- Implementation-agnostic contract suites (KV + StorageProvider), run against
  the browser backends so future backends prove equivalence.
- Machine-enforce the package import boundary in eslint (no `@/...`),
  mirroring the @openmaic/renderer boundary.

Backends take their Storage/IDBFactory by injection, so the package is
testable without a browser. Deferred to later Part-1 steps: wiring the app's
zustand stores + ad-hoc localStorage through KVStore (needs a legacy-key
compat migration), DocumentStore/RuntimeStore, and the HTTP backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(storage): address cross-review findings

- BrowserAssetProvider: resolve writes on transaction.oncomplete (not
  request.onsuccess) so a commit-time abort (e.g. QuotaExceeded) can't be
  reported as durable success.
- BrowserAssetProvider.openDb: don't memoize a rejected open — a transient IDB
  failure no longer bricks the provider; the next call retries.
- BrowserAssetProvider.resolve: memoize the resolution per ref so concurrent
  resolves share one object URL instead of orphaning a second one.
- BrowserKVStore.set: treat a value JSON can't represent (undefined / function
  / symbol) as a removal, instead of writing a literal "undefined" that throws
  on the next get (and would reject zustand rehydration).
- Tests: build contract Blobs from strings (a Uint8Array BlobPart fails the
  root tsconfig typecheck under TS 5.7+ typed-array generics); reset the
  object-URL registry per test; add regression tests for set(undefined),
  concurrent-resolve, and actual de-dup (one stored row).
- Drop @openmaic/storage from the root postinstall build chain: nothing imports
  it yet, so building it on every install added latency and coupled its build
  failures to importer/renderer/sync. It re-joins when the app consumes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(storage): don't cache a rejected resolve promise

Second cross-review pass caught that the per-ref resolve memoization
reintroduced the same rejected-promise-caching bug just fixed in openDb: a
transient IndexedDB failure inside resolve() left the rejected promise in the
`urls` map, so every later resolve(ref) replayed the rejection and never
retried. Evict the entry on rejection (mirroring the null-miss path), and add
a regression test that fails a resolve's IDB open once and asserts the next
resolve recovers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(storage): review — coherent re-put contentType + restore bootstrap build

Addresses @cosarah's review on #858:

- BrowserAssetProvider.put: invalidate (revoke + drop) any cached object URL
  for the ref after a write, so a re-put of the same bytes with a corrected
  contentType is reflected by resolve() instead of a stale, cache-warmth-
  dependent MIME type. remove() reuses the same invalidateUrl helper. Adds a
  regression test (put type="" -> resolve -> put same bytes type="image/png"
  -> resolve now reports image/png).
- Restore @openmaic/storage to the root postinstall build chain: the package
  publishes dist/*, so a clean install must build it or workspace consumers
  resolving it via exports would fail until a manual --filter build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:12:10 +08:00
wyuc da0b394b81 release: v0.3.0 2026-06-28 22:34:18 -04:00
杨慎andzlnn23 8f77d61fa4 test: add PBL v2 planner eval harness (#803)
Co-authored-by: zlnn23 <zlnn23@mails.tsinghua.edu.cn>
2026-06-26 13:56:40 +08:00
wyuc 7cb129183d chore(packages): publish the @openmaic/* SDK family to npm (#778) (#780)
* chore(packages): publish the @openmaic/* SDK family to npm (#778)

Prepares the @openmaic/{dsl,renderer,importer} family for its first npm
publish, and moves the SDK packages onto the @openmaic scope.

Why the scope move: the @maic org name is unavailable on npm (an unscoped
`maic` package already holds the name), so @maic/* is not claimable. @openmaic
matches the project name, the scope is free, and the repo already ships an
@openmaic/docs package — so the SDK family now lines up with that convention.

- rename @maic/{dsl,renderer,importer} -> @openmaic/* across packages, the
  workspace glob, the package dir, and all import sites; lockfile regenerated
- renderer: add publishConfig (public, registry.npmjs.org) — was missing, so
  a scoped publish would default to the wrong registry / restricted access
- importer: add a files allowlist (dist, README, LICENSE) and drop the
  fragile .npmignore blacklist that shipped src; add an exports map so ESM
  consumers resolve dist/index.js instead of falling back to the .cjs main
- all three: add a prepublishOnly build (+ test/typecheck) guard so a publish
  can never ship a stale or empty dist
- add a tag-triggered publish workflow with npm provenance, pinned by name to
  the three @openmaic packages so the vendored forks (mathml2omml, pptxgenjs)
  are never published

Refs #778, #720 (Phase 1).

* fix(packages): address cross-review on the @openmaic publish prep

Cross-review (Claude /code-review + codex) on this PR surfaced:

- renderer's advertised CJS entry was broken: it keeps @openmaic/dsl external
  and imports a runtime enum from it, but dsl is ESM-only (no `require`
  condition), so `require('@openmaic/renderer')` would throw
  ERR_PACKAGE_PATH_NOT_EXPORTED. Make renderer ESM-only: drop the `.cjs`
  rollup output, `main` now points at the ESM build, and the `require`
  conditions are removed from `exports`. (importer is unaffected — it bundles
  dsl, so its CJS build still works.)
- prepublishOnly re-ran the test suite during `pnpm -r publish`, so a flaky
  test after dsl had already published gave a non-atomic partial release.
  Reduce prepublishOnly to a build-only guard (never ship stale/empty dist)
  and move the real test/typecheck gate into the workflow, before any publish.
- document that an @openmaic/* tag publishes the whole family via `pnpm -r`
  (pnpm skips already-published versions); the tag is a release marker, not a
  per-package gate.

Verified: dsl + renderer + importer build; renderer emits ESM only (0 .cjs),
all exports entries resolve; `npm pack` ships dist + README + LICENSE with no
src leak; frozen-lockfile passes.

Refs #778.

* style: reflow @openmaic/dsl type imports past print-width after rename

The @maic -> @openmaic rename lengthened two single-line type imports past
prettier's 100-col width; prettier --check flagged them. Pure formatting.

Refs #778.

* docs(importer): mark @openmaic/importer browser-only (cr-loop accepted limitation)

codex cross-review flagged that the published @openmaic/importer throws
`XMLHttpRequest is not a constructor` when loaded in a pure Node process —
its rollup build is browser-targeted (`nodeResolve({browser:true})` + a
browser pdf.js build). The app only consumes it client-side ('use client'),
so this is by design. Document it as an accepted limitation: prominent
browser-only note in the README and a `browser` field in the manifest.

Refs #778.
2026-06-24 15:12:23 +08:00