0.21.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
08760a6:
<aparte-chat>now paints its own background (--aparte-bg) and text colour (--aparte-text), so a light or dark chat stays readable on any page. To let your page show through as before, set--aparte-bgto your page’s colour, or writeaparte-chat, [data-aparte-chat] { background: none; }.The palette sets a text colour and a ground together, and the chat used to paint only the text: its background was the host page’s. A dark chat on a white page wrote light text on white, and so did a chat on a light-only site seen from a dark system, where the default follows
prefers-color-schemewith no attribute at all; a light chat on a dark page went the other way. Three of those four combinations were unreadable. The rule applies to<aparte-chat>and to the[data-aparte-chat]root the React, Vue and Svelte wrappers render, at zero specificity, so a rule of yours wins whichever stylesheet loads last.coloris set too: what you render inside the chat — a tool card, a slot — now inherits the palette’s text colour instead of the page’s.A site that is light only can keep the chat light on a dark system with
data-aparte-theme="light"on<html>.@aparte/core -
7830394: New
@aparte/plugin-marked/streamingentry:setupMarkedStreamingProvider()formats a reply with marked while it streams and once it is done, so a finished reply no longer changes when it settles.tsimport { setupMarkedStreamingProvider } from "@aparte/plugin-marked/streaming"; setupMarkedStreamingProvider(); // in place of setupMarkedProvider() + setupStreamingMarkdownProvider()While a reply streams, the block being written is completed by Vercel’s
remendbefore marked parses it:**boldshows bold before its closing**arrives, a**with nothing after it yet is held back instead of shown as typed, a link shows as a link from its first character and becomes clickable once its URL is complete, and an image appears once its URL is. Only that block is drawn again at each chunk; the blocks above it keep their DOM, so a selection in them survives the stream. Every block goes through the config’s sanitizer, external links to their own tab included. The options are marked’s, as forsetupMarkedProvider, and apply to the streaming and the finished render alike.remendis a new dependency of the plugin (Apache-2.0, no dependencies of its own), imported by the/streamingentry only: the main entry’s graph is unchanged.@aparte/plugin-streaming-markdownstays the choice that only appends to the page.@aparte/plugin-marked -
34ca7d3: The plugin now parses with
@paulrichez/streaming-markdown, which installs with it: you no longer install a parser beside the plugin, and you can removestreaming-markdown. Your code does not change.bashnpm uninstall streaming-markdown@paulrichez/streaming-markdownis a maintained fork of Damian Tarnawski’sstreaming-markdown, which has had no release since 0.2.15 (May 2025). It is now a regular dependency rather than a peer: nothing in the plugin’s API exposes it and there is nothing to configure, so there was no version for you to choose. Same API, plus four fixes that show while a reply streams:- A bracket inside a link’s text no longer breaks the link:
[[1]](url)and[a [b] c](url)render as links before the reply settles, not only after. - Backticks in the middle of a line inside a code block no longer end the block early, as in
function_call(```). - A table or a blockquote inside a list item stays in the item, and a table’s rows no longer show their indentation as text.
- An underscore inside a word is no longer emphasis:
snake_case_nameand[read_me.md](url)stay as written.@aparte/plugin-streaming-markdown
- A bracket inside a link’s text no longer breaks the link:
Patch Changes
- @aparte/engine@0.21.0
@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/runner-ag-ui, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.22.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
5aa792c: A TanStack AI route now needs
relaxedRunFraming: true:createAgUiRunner({ endpoint: '/api/chat', relaxedRunFraming: true }). Without it the runner rejects TanStack AI’s streams, as AG-UI 1.0 asks a client to.The runner now follows AG-UI 1.0 and passes the protocol’s own conformance corpus — the 68 streams of
spec/1.0/conformance, vendored with their MIT license and replayed through it on every build. What changes:- It declares
protocolVersion: "1.0"on every request, and warns when an agent declares a newer version or one it cannot read. - It rejects the streams 1.0 tells a client to reject — a run not opened by
RUN_STARTED, an event afterRUN_FINISHED(or afterRUN_ERROR, but a new run), content for a message never opened, a message, tool call, step or sub-agent still open when the run finishes, a chunk stream that loses or changes its identity, a step or a sub-agent finished before it started, a known field missing or of the wrong type. What is open is tracked per owner — the agent, or each sub-agent — as 1.0 requires. The reply ends with an error naming the rule, below what was drawn. TanStack AI, 1.0 included, closes each model step with aRUN_FINISHEDand answers a resume before itsRUN_STARTED:relaxedRunFramingreads that framing and holds every other rule — what was opened must still be closed, by the end of the stream. - It drops what 1.0 does not describe — an unknown event type, an undescribed property, an unknown role or outcome — and reports it to the new
onWarningoption (console.warnby default). - It keeps the agent’s state.
STATE_SNAPSHOTandSTATE_DELTA(a JSON Patch, applied whole or not at all) are kept for the thread and sent back as the next run’sstate; every request used to sendstate: {}. - A
RUN_ERRORno longer cuts the stream: it is read to the end, as 1.0 asks, and the error lands below what was drawn. When the agent runs again in the same stream, the new run decides how the reply ends and the failure goes toonWarning. The error carriesdata: { agUi: 'run-error' }; a rejected stream carriesdata: { agUi: 'malformed' }. - Images go to the agent as AG-UI 1.0 content parts: a base64 data URL as a
datasource, anything else as aurlsource. They used to be left out. - An interrupt the page cannot answer —
input_required, or one naming notoolCallId— ends the reply without an error and is answeredcancelledwhen the thread resumes or runs again. It used to fail the reply. - A tool result in content parts keeps the parts as the row’s structured result; the model reads their text.
- Older agents: the new
peerProtocolVersionoption names an agent older than 1.0. Its stream is read by that version’s rules (the retiredTHINKING_*events are translated in any case; sub-agent events are dropped for 0.0.57 and older, images flattened to text for 0.0.39 and older), and the request leaves outprotocolVersion.@aparte/runner-ag-ui
- It declares
Patch Changes
- @aparte/engine@0.22.0
@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/locale-fr, @aparte/docs-mcp.
0.23.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
494cd79: The approval question reads “Allow {tool}?” (French “Autoriser {tool} ?”) and names the call as its row does — the label and what it targets — instead of the bare identifier.
The question substituted the tool’s identifier whatever the app had declared, while the row could read “Delete a file a.ts”.
{tool}is now the row’s own line: the declared label (else the identifier), then the summary’s target. The default wording changed from “Run {tool}?” to “Allow {tool}?”, which reads with a label that is an action (“Allow Write file CHANGELOG.md?”) as well as with an identifier; an app that set its ownapprovalAskkeeps it. The arguments block is unchanged.@aparte/core -
494cd79: Read an error segment’s machine code from
code: it is no longer indetails, and the alert no longer prints it.A failed turn carried its
AparteErrorCodeindetails, which the alert draws, so the person readUNKNOWN_ERRORorMAX_TURNS_EXCEEDEDunder a sentence that already said what happened. The code is the segment’scodenow, stamped on the alert asdata-error-codeand handed to a custom error renderer (setErrorRendererreceivescode).detailsstays for text an app chooses to show. The turn limit’s code is a new member,AparteErrorCode.MAX_TURNS_EXCEEDED.@aparte/core -
494cd79: A hidden tool call —
registerToolDisplay(…, { hidden: true }), or a renderer that draws nothing — now stays in the message and in the model’s history.A call whose renderer returned
''at tool-start was never added to the message, and the history a later turn sends is rebuilt from the message: from the next turn on, the model no longer saw a call it had made, nor its answer. A reload drew the default row for it instead. Every call is added now; a hidden one draws an emptyhiddenplaceholder, live and restored, and an approval it needs is still asked. A turn of nothing but hidden calls renders no chrome.isToolHidden(name)says which wins: a hide, unless a renderer is registered for the tool more specifically — a'*'hide leaves a tool with its own renderer (a plugin’s receipt) drawn.If you hid a tool with
registerToolRenderer(name, { render: () => '' }), that call is now saved and sent to the model on later turns, as every other call is.registerToolDisplay(name, { hidden: true })says the same thing more directly.@aparte/core -
494cd79: A settled reasoning block reads “Thought” (
thought) instead of “Thinking”; turn onshowDurationto read “Thought for 8s” (thoughtFor). A locale of your own needs these two keys translated.It read “Thinking” while the model reasoned and “Thinking” forever after, so a finished block looked like one still running. A custom locale that translated
thinkingand notthoughtnow shows the English “Thought” on settled blocks: addthought(andthoughtForif you show durations).The duration stays the app’s to show: core measures it in the browser, from the first reasoning delta to the last, which is the model’s thinking time only when the provider streams its reasoning — one that sends it in one piece would read “1s”, and a reload shows it only if the stored segment kept its
meta.setSegmentDefaults('thinking', { showDuration: true })turns it on for every block, read when the block is drawn so a restored one follows it too; a block’s ownshowDurationwins. The duration is formatted byIntlin the locale’stag(“8s”, “1m 12s”), with a one-second floor. An app’s ownlabelstill wins, and wrapping the renderer is still how a product writes its own format — the built-inupdatenow rewrites the label when the block settles, so a wrapper writes its own after delegating.@aparte/core -
494cd79: Describe how a tool’s calls are shown with
registerToolDisplay(name | RegExp | '*', { label, icon, summary, formatInput, hidden, open });registerToolRenderer,registerToolSummaryandregisterToolInputFormatteraccept the same patterns and'*'.'*'means every tool nothing more specific describes, and an MCP server’s dynamic names (mcp__github__…) can be described with a RegExp. One entry per matcher; calls merge into it; the entry that applies is the exact name’s, else the first pattern registered, else'*'’s — whole, never a mix. Registering a display tells the chats on the page, so a label reaches rows already drawn.On the row,
labelreplaces the identifier (in the chat’s font),iconis an icon-provider key (unknown keys fall back totool), and the identifier stays on the row asdata-tool-name. Without a display the row reads as it did: core never turns an identifier into words.registerToolSummaryandregisterToolInputFormatterare now thesummaryandformatInputfields of that entry;getToolSummaryandgetToolInputFormatterresolve patterns too.A display that sets or drops
hiddenoropenredraws the messages already on screen, so a “hide tool calls” setting reaches a restored conversation; a label, an icon or a summary only relabels them, keeping what the reader opened.iconoffers the icon names (AparteIconName) and takes any key registered withregisterIcons.A tool renderer’s stylesheet is injected once per renderer, not once per tool name.
@aparte/core -
494cd79: Open tool rows by default with
setSegmentDefaults('tool_call', { collapsed: false }), or one tool’s rows with its display’sopen: true.The row was collapsed always and read no field. It reads the segment’s
collapsednow, as reasoning does, else the tool’s display; closed stays the default, and an update never forces it.@aparte/core -
494cd79: The tool row says who refused a call and why it stopped, in the reader’s language; a refused row no longer shows the model’s sentence as its Output.
A note follows the state word: “You said: “…”” when the person refused and typed instead, “Refused by policy”, “Not run: an earlier call was refused”, and for a stop “Timed out”, “Not available here”, “No one could approve it” or “Turn limit reached”. A plain Stop needs no note. A refused row shows no Output: its
resultis the sentence written for the model — a policy’s reason included — and the person reads the note.The segment keeps it:
refusal?: { by, instruction? }andabortCause?sit besideresult, which stays the sentence the model read.toolRefusalis exported from@aparte/core. A hostapprovalResolverthat refuses with areasonand nobyis read as a policy, so its row says “Refused by policy”; returnby: 'person'if a person refused. New locale keys:toolRejectedSaid,toolRefusedByPolicy,toolNotRunAfterRefusal,toolTimedOut,toolUnavailable,toolNoApprover,toolTurnLimit. A segment saved before this keeps its old Output.@aparte/core -
494cd79:
tool-rejectednow says who refused (by) and carries the person’sinstruction;tool-abortedsays why (cause);toolRefusal()writes the refusal sentence.byis'person','policy'or'earlier-refusal'(a call not run because another call of the same step was refused).causeis'stopped','timeout','no-handler'or'no-resolver'. An approval resolver may returnby; without it areasonis read as a policy’s, as before.The sentence the model reads is unchanged —
reasonon the event and thetoolmessage are the same string they were.toolRefusal(decision)returns it withbyandinstruction, so a runner that answers an agent the loop does not run writes the same one; with no reason of its own, its sentence followsby(a policy’s refusal never says “rejected by the user”). A handler that rejects with an AbortError of its own — no Stop, no timeout — is aborted with nocause.@aparte/engine -
494cd79: French for the new tool-row notes, the reasoning label and duration, the turn-limit message, the ask-user declined line and the artifact “not created” state; the approval question reads « Autoriser {tool} ? ».
@aparte/locale-fr
Patch Changes
-
62c4abb: The scroll to a new message is no longer cut short when it starts while an earlier scroll is still running.
A send that lands during a glide re-targets it — a second smooth
scrollTo, continuous where an instant write is a cut — and the engine runs that new smooth scroll from where the view is, for as long as a first one takes. The window in which the viewport holds back instant writes still ended where the earlier glide’s did, so it closed in the middle of the re-targeted run and the next pin jumped the last few pixels (measured in CI on React and WebKit: a write 4 px short of the bottom, ~330 ms into the send). A re-target now opens a window of its own, and re-arms the pin that catches a glide that missed.@aparte/core -
494cd79: The turn-limit message follows the chat’s locale (
maxTurnsReached, with{n}the limit).It was a hard-coded English sentence (“Stopped after 3 tool calls to prevent an infinite loop.”) in a chat set to any language. The English default now reads “Stopped after 3 turns to avoid an endless loop.”, and the message is read from the chat’s own config, so a client on the global config answering a chat with its own locale writes it in the chat’s language.
@aparte/core -
494cd79: A tool’s Input and Output are coloured as JSON only when they are JSON, scroll past 300px, sit on the code surface, and each has a copy button; the row fits a phone.
Both parts went to the highlighter as
jsonwhatever they held, so a prose result came back tinted as an object; a part is JSON when it parses to an object or an array, and a JSON result is pretty-printed. A part’s body is capped by--aparte-tool-part-max-height(300px, the reasoning block’s cap), scrolls, and is a focusable, named region; its text is on the code surface at the small size whether or not a highlighter coloured it, and its label now sits in.aparte-tool-part-headbeside the copy button — the code block’s, now one shared helper. On a narrow screen the state word never yields: the note and the target give way first, then the name, cut with an ellipsis, and the note carries its full text as its title.@aparte/core -
494cd79: The waiting dots show again between a finished tool call and the model’s next words, and never while a call waits for approval.
They showed only until the first segment arrived, so after a tool row read “Done” the reply sat still while the model worked on its next turn. They show while the reply streams and its last segment is a call that has finished or that the app hides, and leave as soon as the next step writes — never while a call waits for a person’s approval, hidden or not.
@aparte/core -
494cd79: A refused AG-UI tool call’s row now says who refused and what they wrote; the agent receives the same sentence as before.
The runner wrote its own copy of the refusal sentence. It takes
toolRefusalfrom@aparte/corenow, so itstool-rejectedcarriesbyandinstructionlike the loop’s.@aparte/runner-ag-ui -
494cd79: A refused, stopped or failed
create_artifactshows “Not created” instead of a card with Download; one awaiting approval shows that it is waiting.The card drew from the model’s input whatever the call became, so the person was handed the result of an action they had just refused. A call that ran shows the card; a pending call keeps it (the handler answers at once); one waiting for a decision shows a quiet block; one that never ran shows its badge, its title and “Not created” (
artifactNotCreated), with no buttons.@aparte/plugin-artifacts -
494cd79: A declined question’s receipt line follows the locale (
questionDeclined), andreceipt: falsekeeps the call in the model’s history.The receipt printed
ASK_USER_DECLINED— “The user declined to answer.”, the sentence the model reads — under a question in any language; the constant keeps its value for the model.receipt: falseregistered a renderer that draws nothing, which kept the call out of the history; it declares the tool hidden throughregisterToolDisplaynow.@aparte/plugin-ask-user
Version-only bumps (no changes of their own): @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.
0.24.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
6b28491:
<aparte-conversation-list>shows what each conversation is doing: its title shimmers while an AI answers in it, and a mark appears while its run is paused on the page (a server run waiting on an approval, a question or a browser tool). With a conversation controller, nothing to write.setConversationStatus(id, 'running' | 'waiting' | null)andgetConversationStatus(id)on the list; the row carriesdata-status, a screen reader hears the state with the title (new locale keysconversationRunning,conversationWaiting), and the mark (.aparte-conv-item__status) is an empty slot until a state draws it, so the look is yours in CSS. The conversation controller sends the newaparte-conversation-statusevent onwindowas turns start (running), pause on the person (waiting) and end (null), always about the conversation the turn started in; every list applies it. New types:AparteConversationStatus,AparteConversationStatusDetail.A state is kept for the page, not per list: every list draws it, one that connects later (a drawer, a remount) included, keyed by conversation id. The controller follows the turn in flight by its message id, so a replaced turn’s end does not clear the new turn’s state, and a chat unbound mid-turn (a route change) leaves no row running.
@aparte/core -
010ed41:
AparteClientsends the newaparte-message-resumedwhen a paused turn goes on after the page answered its paused calls, and a turn that starts from a saved pause now sendsaparte-message-pausedright afteraparte-message-start.aparte-message-pausedhad no other half: a listener tracking a turn heard it stop to wait and never heard it run again untilaparte-message-done, and a turn resumed after a reload said “start” while it was asking. New type:AparteMessageResumedEventDetail;APARTE_DEFAULT_UI_EVENTSlists the event.resumeDeferredno longer starts a reply a turn is already running into: loading the open conversation again (a click on it) while its pause waited put the same question twice and started the same reply a second time.@aparte/core -
6b28491: New kit recipe
.aparte-shimmer: a light sweeping across a label while something runs.Put it on the span that holds the words of a step in progress (“Searching the web…”, “Thinking”). It is drawn through the text fill, so the element keeps a real colour; it holds still under
prefers-reduced-motion, and reads as plain text in forced colours and wherebackground-clip: textis not supported. New tokens:--aparte-shimmer-baseand--aparte-shimmer-shine(muted to full ink, which keeps its order in both themes) and--aparte-duration-shimmer(2s). The reasoning line, a quiet tool row and a running conversation’s title wear it.@aparte/core -
6b28491: The reasoning label now shimmers while the block streams. Nothing to change in your code; the light stops when the block settles.
“Thinking” was a still word above a block that was filling up; conversational chats sweep a light across it while the model is still reasoning, and AI Elements and assistant-ui do it by default. It follows the block’s own
isStreaming: true, so a block written by hand or restored from storage stays still, and it never moves underprefers-reduced-motion. The label is drawn through its text fill while it shimmers, so a colour you set on.aparte-thinking-labelshows once the block settles (recolour the sweep with--aparte-shimmer-base/--aparte-shimmer-shine). To keep the label still:css.aparte-thinking-label.aparte-shimmer { animation: none; background-image: none; -webkit-text-fill-color: currentColor; color: inherit; }The client now marks a streamed reasoning block
isStreaming: truewhile it arrives (it was left unset), and settles it at the model’s first tool call and at a new step as well as at its first answer token, so a block followed by a tool call no longer reads “Thinking” while the tool runs. Only the answer folds it: a tool call does not close a block the reader opened.@aparte/core -
140517e:
registerToolDisplaytakesdetails:falsefor the line alone, or a function that draws what is behind it in place of Input and Output.Behind a tool row’s line, core shows the arguments and the result, raw: a developer’s view, and it stays the default. A chat for people had no way out of it but hiding the call or rewriting the whole row.
details: falsekeeps core’s line with no disclosure at all, so nothing raw is one click away; on'*'it covers every tool nothing more specific describes. A function returns what stands in for Input and Output under core’s line, a list of sources for a search, say: an element, or a string inserted as HTML after the config’s HTML sanitizer (the one Markdown goes through), since what it shows comes from a tool’s result. It is called on every update of the call, and on a language switch, whose words are written into the nodes already there;''means nothing behind the line yet, and the row gets its disclosure once there is something. A function that throws costs the row its detail, never its line, and never brings the raw parts back.hiddenand a tool renderer keep their precedence. Registering or droppingdetailsredraws the rows already on screen, ashiddenandopendo. New type:AparteToolDetails.@aparte/core -
6b28491:
registerToolDisplaytakesquiet: the line a person reads, with a shimmer while the call runs and no “Done” once it is done.The default row is an agent’s: a spinner and “Running”, then ”✓ Done”, and it stays the default.
quiet: truedrops the spinner and the word while the call runs (the label shimmers instead), and shows nothing once it succeeded: the label’s tense says it, so give it one that reads the status (“Searching the web…” / “Searched the web” / a plain “Search the web” for the rest). A wait for approval, a refusal, a failure and a stop still show their word and note; the words a quiet row does not show are still read by a screen reader. The target is set in the chat’s font and the chevron moves to the end of the line; the row carriesdata-quiet. Registering or dropping it redraws the rows on screen.@aparte/core -
6b28491: French for the conversation list’s new states: « Réponse en cours », « Attend votre réponse ».
@aparte/locale-fr
Patch Changes
- @aparte/engine@0.24.0
@aparte/core
Version-only bumps (no changes of their own): @aparte/engine, @aparte/runner-ag-ui, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.
0.25.0
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
0d50cfd: Server-rendered pages now hydrate cleanly: an aparté element waits while it, or an ancestor, carries
defer-hydration, and draws itself whenreleaseDeferredHydration(root)removes the attribute.Core’s elements draw themselves when they connect (light DOM: their children, their attributes, a style on the chat’s root). On a server-rendered page that happened as soon as
@aparte/coreloaded in the browser — defining the elements upgrades the ones the server sent — so the framework then hydrated a DOM it had not rendered: React threw error 418 and discarded the server’s HTML, measured in a Next production build. A held element does nothing until it is released; its upgrade is then replayed as the browser would have run it (the observed attributes it has, thenconnectedCallback). An element removed while held is never torn down, and replays its attributes when it connects later. Removing the attribute any other way releases a microtask later — or at once, whenreleaseDeferredHydrationis then called on that root, as a wrapper does after a hydration that dropped the attribute.The wrappers render the attribute on their root and release it themselves. For server-rendered elements of your own, put
defer-hydrationaround them and callreleaseDeferredHydrationonce your framework has the page; an element class of yours that draws itself on connect honours it afterdeferHydrationOf(MyElement), called beforecustomElements.define. Without the attribute nothing changes. It is the Web Components Community Group’sdefer-hydration, the one Lit’s server rendering follows, widened from an element to the elements under it.@aparte/core -
02351e4:
AparteChatHost.takeEcho(list)tells a wrapper whether a list the parent hands back is the host’s own report coming back, or the parent’s word.Call it before applying the list a two-way binding gives you. It answers true for a report the parent has not handed back yet — however late it lands — and takes it with every older one; for any other list it answers false and clears them, so a list the parent restores later is applied. The four first-party wrappers use it; a wrapper of your own over
AparteChatHostgets the same rule.@aparte/core -
908340b: Draw a tool call or a segment with your own component:
defineMountedToolRenderer(mount)mounts it, updates it and unmounts it with the element, andaparteGlobalConfig.overrideToolRenderer(match, renderer)— a method on anyAparteConfig— returns the function that takes it off again.defineMountedToolRendereranddefineMountedSegmentRenderer(type, mount)turn a mount function into a complete renderer: core creates the host, calls the mount once when the host is in the page, forwards every change of the call to the returnedupdate(never a remount, so a component keeps its state when the result arrives) and callsdestroywhen the host leaves for good. A card receives oneAparteToolCallView(toToolCallView):id,name,input,status,result,structuredResult,deferred,refusal,abortCauseand the segment. New types:AparteToolCallView,AparteMountedView,AparteToolMount,AparteSegmentMount.The registries are live: registering, unregistering or overriding a tool renderer — or a segment renderer — redraws the calls (or segments) already on screen that it now draws, and only those; the renderer that drew them gets its
cleanup.config.overrideToolRendererand the standaloneoverrideSegmentRenderer(renderer, config?)stack: the newer declaration wins, taking off an older one leaves it, and taking off the last restores what was there before — core’s own renderer, for a built-in segment type likecode. The redraw happens in a microtask, gathered per config (ten components declaring cards in one render cost one pass; an override taken off in the same tick costs nothing), reaches a chat with its own config when the global registration it inherits changes, and follows a chat whose own config is attached after its first rows. A rendering-version bump — a Markdown or highlight provider arriving late — keeps the cards a mounted renderer holds mounted, and so does the end of a turn:setSegments()with the same segments (the host re-syncs the finished message) hands those cards the final state instead of drawing them again. A restore function does nothing after the config’sreset(), and leaves a registration made directly since in place.@aparte/core -
986df98: Unmount what you mounted in a segment or a tool call in
cleanup(element, segment): core now calls it, once, when the element leaves for good.AparteSegmentRenderer.cleanupwas declared and never called; it is called now, with the segment’s latest state, andAparteToolRenderergains the same hook. It runs when the element is replaced by a newrender(), when its segment is removed, when its message is redrawn in a new frame, and when its message leaves the page — but not forupdate(), not forrelabel(), and not for a message a framework only moved (a bubble taken out and put back before the next microtask keeps its elements). A bubble that comes back after a longer absence draws its segments again. A renderer that replaces its own element insideupdate()cleans up what it replaced; the bubble follows the new element.A tool call is updated, relabelled and cleaned up by the renderer that drew it, recorded when the row is set up — not by whichever renderer is registered at that moment, which could hand core’s own row, or another renderer’s markup, to an
update()that never drew it.@aparte/core -
de8732e: A runner of your own now receives the tools the request allows and its
toolChoice:input.tools(each aRunnerTool—name,description,inputSchema) andinput.toolChoice('auto','none'or{ name }).Core sends
toolswhen the model declaresfunction_calling, and the provider used to post the worker only the messages and the generation options, so a runner that emitstool_usehad to hard-code its tool list — offering a small local model tools the app never enabled, and unable to honourtoolChoice: 'none'or a forced tool. Both are absent when the request has none. The built-in runners take neither, and still warn when a turn needs a tool call they cannot carry. Closes #119.@aparte/provider-transformers -
7d9550d: Svelte 5 apps now need Svelte 5.20 or later (peer
^4.0.0 || ^5.20.0, on@aparte/svelteand on@aparte/plugin-model-selector’s optional Svelte peer); on Svelte 5, import from@aparte/svelte/runes;createAparteChatandcreateConversationManagerare deprecated, and 0.26.0 moves today’s package root to@aparte/svelte/legacy.@aparte/svelte/runesis the chat written in Svelte 5:<AparteChat>with snippets (emptyState,aboveComposer,composer,toolbar,bubble), a bindablemessages, callback props (onmessageSent,onaction,onmessagesChange,onmessageAppended,ontypingChange,onconversationCreated), a stable id from$props.id()and a server render that SvelteKit hydrates;<AparteUi>;createConversations()/AparteConversations, the saved conversations as reactive state;createAparteClient; and<AparteToolUI>/<AparteSegmentUI>. It needs Svelte 5.20 or later, and the peer range is now^4.0.0 || ^5.20.0for the whole package, the root entry included — and on@aparte/plugin-model-selector’s optional Svelte peer too, which follows the wrapper’s: an older Svelte 5 that is installed now failsnpm installwith ERESOLVE, even for an app that only uses the root component or the plugin.createAparteChat(usebind:messages) andcreateConversationManager(usecreateConversations()) are deprecated. In 0.26.0,@aparte/sveltebecomes the runes component and today’s — the Svelte 4 syntax component and these two helpers — moves to@aparte/svelte/legacy, where it keeps working on Svelte 4 and 5.@aparte/plugin-model-selector,@aparte/svelte -
cf0590e:
@aparte/angularinstalls on Angular 20 and 21, not only 19.The peer range is
^19.2.0 || ^20.0.0 || ^21.0.0. The package is still compiled on 19.2 in Angular’s partial mode, which Angular guarantees a newer application can link, so one build serves the three majors. What makes the range a fact rather than a hope: the specs run on 19 (with zone.js, zoneless, and in a server render), and the repo’s Angular example now runs on 21, zoneless, driven in a real browser by the end-to-end suite. Angular 19 is past its upstream support window and leaves the range at 1.0. Angular 22 joins once it is exercised the same way.@aparte/plugin-model-selectorfollows: its optional@angular/corepeer (for the./angularentry) takes the same range, since a narrower one would failnpm installon Angular 20 and 21 even for an app that never imports that entry.@aparte/angular -
c367c85: Bind
[conversations]on<aparte-conversation-list>with noCUSTOM_ELEMENTS_SCHEMA: its directive now has that input.The element directives were generated from the elements’ attributes only, so a member that exists only as a JS property — the list’s rows — had no input, and
[conversations]="…"did not compile unless the component switched template checking off for every unknown tag. The generator now also emits an input for each public property an element has no attribute for; it hands the value over as the property.<aparte-conversation-list>’sconversationsis the only such member today.@aparte/angular -
d6cf93b: Set
AparteChatComponentinputs from code withcomponentRef.setInput(): they are signal inputs now. Templates do not change, and the per-message template can be written as content:<ng-template aparteBubble let-message>.Every input keeps its public name (
[messages],placeholder,disabled,centerWhenEmpty,[bubbleTemplate]…), so a template binding works as before; what changes is the class member behind it, which is an AngularInputSignalrather than a writable property. Code that created the component and assignedinstance.config = …sets it withcomponentRef.setInput('config', …)instead, which is how Angular sets any input from code.AparteBubbleDirectiveis new:<ng-template aparteBubble let-message>written inside<aparte-chat>draws each message, the same as passing aTemplateRefto[bubbleTemplate], which still wins when both are given.[(messages)]two-way binding is supported and tested: the chat reports its own changes throughmessagesChange, and a write from the parent emits nothing back.The component renders on the server: the host that drives the custom elements is created after the first render in the browser only, and nothing touches
requestAnimationFrameduring a server render. It also runs under zoneless change detection (the default for a new app since Angular 21); both are covered by specs.@aparte/angular -
15254e4: Draw a tool call with your own component:
<AparteToolUI name="…">(React, Vue, Svelte 5) or<ng-template aparteToolUI="…">(Angular).The card renders from where you declare it, so it reads your app’s contexts — React context through a portal, Vue
injectthroughTeleport, SveltegetContext(the snippet is mounted with the declaration’s contexts), Angular DI (the view is created where the template is declared, and it works zoneless). It is mounted once per call and updated in place when the call changes, so a component keeps its local state when the result arrives. It is unmounted when the call leaves the page or when the declaration unmounts, and the call goes back to core’s row; a declaration made while calls are already on screen takes them over. Of two declarations for one tool the newer wins, and removing the older keeps it. A server render declares nothing and draws nothing: the cards appear in the browser.AparteSegmentUI(<ng-template aparteSegmentUI="…">withAparteSegmentUIDirectivein Angular) does the same for a segment type. In Svelte both are in@aparte/svelte/runes. Vue also exportsisAparteElement, the test to givecompilerOptions.isCustomElement: it accepts theaparte-*elements, and leaves the wrapper’s own component names to Vue, so a kebab-case<aparte-tool-ui>gets Vue’s warning instead of becoming an inert element (write it<AparteToolUI>).@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue
Patch Changes
-
9bc30ea: Stopping a client that never started no longer touches
window, so it is safe on a server.The constructor prepares the send handler without listening, and
stop()only returned early when there was no handler — so it always removed listeners fromwindow. On a server that is a ReferenceError; a SvelteKit page that started a client the documented way failed with a 500 (Svelte runsonDestroyat the end of a server render). A started client still removes its listeners onstop(), once.@aparte/core -
6fed78c:
<aparte-approval-mode>and<aparte-model-selector>now wait for a server-rendered chat to hydrate before they draw.Placed in the toolbar of a server-rendered
<AparteChat>, either tag drew itself as soon as its plugin was imported — before the framework had hydrated the page — so React found children it had not rendered and threw the server’s HTML away (error 418). Both now honourdefer-hydration, like core’s own elements, and draw when the wrapper releases the chat.<aparte-ask-user>already did, through the<aparte-elicitation>it extends. An element of your own that draws itself on connect can do the same: calldeferHydrationOf(YourElement)beforecustomElements.define.@aparte/plugin-approval,@aparte/plugin-model-selector -
cf0590e:
@aparte/angularinstalls on Angular 20 and 21, not only 19.The peer range is
^19.2.0 || ^20.0.0 || ^21.0.0. The package is still compiled on 19.2 in Angular’s partial mode, which Angular guarantees a newer application can link, so one build serves the three majors. What makes the range a fact rather than a hope: the specs run on 19 (with zone.js, zoneless, and in a server render), and the repo’s Angular example now runs on 21, zoneless, driven in a real browser by the end-to-end suite. Angular 19 is past its upstream support window and leaves the range at 1.0. Angular 22 joins once it is exercised the same way.@aparte/plugin-model-selectorfollows: its optional@angular/corepeer (for the./angularentry) takes the same range, since a narrower one would failnpm installon Angular 20 and 21 even for an app that never imports that entry.@aparte/plugin-model-selector -
77d7493:
<aparte-ui>no longer builds its element during a server render, so a hydrated Angular SSR page shows it once.It built the element in
ngAfterViewInit, which Angular also runs on a server: the element was serialized into the HTML without its props or listeners, hydration kept it, and the browser appended a configured one beside it — two model selectors, two counters. It is now built in the browser only, the way React, Vue and Svelte build theirs in an effect oronMount.@aparte/angular -
aa0a6d8: Under
[(messages)], a message the chat appends right after one of its own changes is no longer dropped when the parent’s field comes back through[messages].The chat reports a change (
messagesChange), the two-way binding writes it into the parent’s field, and the chat then appends without reporting — an optimistic assistant placeholder, or core’s append right after a flush of buffered chunks (an edit, a retry). At the next change detection the parent’s field came back through the input and reset the chat to the list it had reported, so the placeholder vanished and the streamed reply landed on the user’s message, or nowhere. The chat now recognises its own reports that have not come back yet and keeps its list when one does — the rule React, Vue and Svelte apply — while a list you restore later is still yours.@aparte/angular -
ef17513: A server-rendered
<AparteChat>now hydrates without a mismatch, in Next, Nuxt, SvelteKit and Angular SSR.The server’s HTML carries
defer-hydrationon the chat’s root, so core’s elements leave it alone until the framework has the page; the wrapper then releases them, before it builds the chat’s host. Before, the elements drew themselves as soon as@aparte/coreloaded and the framework found a DOM it had not rendered — React threw error 418 and discarded the server’s HTML. An app with no server render holds nothing, so a slot’s component can talk to the elements it renders from its own mount hook: React reads “am I hydrating?” throughuseSyncExternalStore, Svelte draws the attribute on the server only, Angular — where it is on the<aparte-chat>host itself — binds it only while hydrating a host the server sent with it, and Vue releases from the root’s ref. On a server-rendered page, Vue’s ref still releases before anyonMounted; in React, Svelte and Angular a slot’s component mounts before the chat does (its effect,onMountorngAfterViewInitruns first), so on that first load it finds the elements live only once the chat has mounted.@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
02351e4: A list you assign back to the chat — a reopened conversation, an undo — is now drawn, even when the chat reported it to you earlier.
Under a two-way binding every list the chat reports comes back through your prop, sometimes late — an earlier report after its next change — and applying that echo would roll back what the chat did since without reporting. To tell an echo from your own word, the wrappers remembered every list the chat had ever reported, so a list you kept and assigned back later was taken for an echo and ignored: the chat went on showing the other conversation, and its next report wrote that conversation’s messages into the one you had reopened. Only the reports you have not handed back yet count now; any list the chat did not report clears them. React, Vue, Svelte (both entries) and Angular apply the same rule.
@aparte/angular,@aparte/react,@aparte/svelte,@aparte/vue -
2051a75:
createAparteClient()no longer fails a server render, and a server render no longer leaves itskeyResolveron the global config.It stopped the client in
onDestroy, which Svelte runs at the end of a server render too, and stopping a client that never started readwindowthere and failed every SvelteKit page that followed the docs.stop()now touches nothing of the browser for a client that never started (@aparte/core), so the stop stays inonDestroy— the one hook a server render runs — and takes back the key source the client put on the config when it was constructed. Moved to a hook a server never runs, each request would have added one to the config the whole process shares, holding whatever that request’s resolver closed over.@aparte/svelte -
bf7de83:
AparteChatandAparteUiare typed for Svelte 5 as well as Svelte 4: props, events and slots check under either.The package is now built with Svelte 5’s tooling, which emits each component’s type in a form both majors read (a Svelte 4 class constructor and a Svelte 5 component function). Verified with the packed tarball installed in two fresh projects, Svelte 4.2 with
svelte-check3 and Svelte 5.57 withsvelte-check4, both withskipLibCheck: false: the documented usage checks clean, and an unknown prop or a wrong prop type is reported in both. Nothing changes in the components themselves.@aparte/svelte
Version-only bumps (no changes of their own): @aparte/engine, @aparte/runner-ag-ui, @aparte/provider-ai-sdk, @aparte/provider-openai-compat, @aparte/provider-scenario, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-compaction, @aparte/plugin-marked, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/locale-fr, @aparte/docs-mcp.
0.26.0 Latest
Every @aparte/* package ships at this version (they are released in lockstep).
Minor Changes
-
f0b38dd: A page that registers a compactor now has its sends wait for it, and a request the model refuses as too long is compacted and sent once more. Nothing happens without a compactor: with none registered (
setCompactor), every send, retry, edit and resume behaves as before. With@aparte/plugin-compactionset up, the composer shows Stop while a summary is being written before a send, and an overflow that used to end as an error card now ends as an answer, with the separator above the kept turns as the only sign.What is new, all additive:
- Before each request goes out, the client calls
compactor.beforeSend({ target, targetId, exclude, requestTokens })onceaparte-message-startis dispatched (so Stop is showing and the reply is waiting) and the request is built (files inlined).excludeis the user message and the reply of the turn in preparation;requestTokensis the estimate of the request. When it resolvestruethe client builds the request again, from the history as it now is. A Stop while it waits ends the turn as aborted, the way a Stop ends one today. A compactor that throws there is warned about and the request goes out as built. - When the model refuses a request with
USAGE_CONTEXT_EXCEEDEDbefore the reply has any text or tool call, the client callscompactor.compact({ force: true, reason: 'overflow', exclude })and, when it compacted, sends the turn once more into the same reply. Once per turn. If there is no compactor, the compaction skips or fails, or the reply already said something, the error stands exactly as before. - The reply records
contextEstimate, the estimate of the request it was sent with, before each send, so the context measure can calibrate the character heuristic against the usage the provider reports. A request that carries an image records none, since the heuristic cannot read it whole. - Both calls carry
signal, the turn’s abort signal, so a programmaticabort()orstop()reaches a compactor that does not listen onwindow. - A retry or an edit under a framework wrapper rebuilds its request from the live transcript, so it carries a checkpoint written while it waited; the wrapper’s root now offers
getMessage, as the viewport does. AparteFormatAdapter.classifyError?(status, body)lets a provider name a failure a status cannot (an overflowing prompt is a 400). Core’svendorErrorcalls it for a direct call;createAparteChatHandlercalls it on the server and forwards the code aserror.code, still without the vendor’s prose, and the backend transport readserror.codewhen it is anAparteErrorCodevalue. A vendor’s own code (context_length_exceeded) is not one and is ignored on the client.- The server loop keeps the overflow code too:
createAgentHandlersends the code an error carries in itsrun-errorline (AGENT_RUN_FAILEDonly when it carries none), andcreateAgentStreamRunnerthrows a code that is anAparteErrorCodevalue as the error’s own code, so a server-side overflow is compacted and sent again like a direct one. A code that is not one of ours staysPROVIDER_ERROR, with the raw value indata.code. createAparteChatHandlerno longer relays the prose of anerrorevent the vendor streamed after its 200. The browser getsThe vendor reported an error.and the code when it is anAparteErrorCodevalue; the original goes to the server’s log.- The stream
errorevent takes an optionalcode(AparteStreamEventMap), andAparteError.fromkeeps a valid.codeof the error it wraps, so a code a provider streamed after a 200 reaches the client.
Why. Compaction that only runs after a turn cannot help the turn that overflows, and a send that races a summary carries the history the summary is about to replace. Classification stays in the provider because what a server writes for an overflow is its wire format, not core’s to know.
@aparte/core - Before each request goes out, the client calls
-
37b82d9: Register what compacts the context with
aparteGlobalConfig.setCompactor(compactor | null), and read it back withgetCompactor(). The separator’s Retry and the gauge’s Compact now use it. Nothing for you to change.What is new, all additive:
AparteCompactor,AparteCompactorRequest,AparteCompactorOutcomeandAparteCompactorReason(types). A compactor hascompact(request)(never rejects: the outcome saysok),settled(targetId?), and optionallybeforeSend(request)andpreview(request). A request names the chat by its element (target) and, when it has one, itstargetId, because a chat inside a shadow root is not found by id;force,excludeandreason(manual,retry,overflow,auto) say how it is asked.- A chat reads the compactor of its own config first (
resolveConfig(chat).getCompactor()), then the global one.setCompactornotifies like every other registration, so a mounted gauge hears that Compact now became possible. - The separator’s Retry asks the compactor when one is registered, with
force: trueandreason: 'retry', so it runs even when automatic compaction has stopped after failures. With none registered it dispatchesaparte-compactas before.
Why. Core cannot import the compaction plugin, and an event answers nothing: the caller needs to know the summary is written before it goes on, and a button needs to know it is still working.
@aparte/plugin-compactionregisters itself here when it is set up; core never compacts on its own.@aparte/core -
39c3d7f: A message can now carry a checkpoint,
contextSummary: from it on, the model reads the summary in place of every message before it, and nothing before it is deleted. The client sends the checkpointed history on send, retry, edit and resume. Nothing changes for a conversation with no checkpoint.What is new, all additive:
AparteMessage.contextSummary?: AparteContextSummary({ summary, count, createdAt }, exported as a type). It sits on the first kept message, which is ausermessage, so the history still alternates its roles.AparteMessage.contextEstimate?: number, the estimate in tokens of the request that produced a reply. The client writes it on each reply when it sends (none when the request carries an image the estimate cannot count), and the context measure reads it back to calibrate itself.historyFromCheckpoint(path)returns{ summary?, messages }: the messages from the LAST checkpoint on the path, and its summary. A legacycompaction: truemessage before that checkpoint is dropped with the rest. With no checkpoint the path comes back unchanged. It is exported from both entries, so a server or ahistoryfunction can use it.applyRetentionkeeps a checkpoint the trim cuts past: the new first message inherits the latest checkpoint’scontextSummary(same summary and date,countraised by the messages cut after it), in the flat list and in the branch tree. Before, a retained conversation lost both the originals and their summary.
What a host that builds its own history should know. A
historyfunction still receives the full transcript, checkpoint and everything before it; to honour a checkpoint, run the messages throughhistoryFromCheckpointand send itssummaryfirst (the client sends it as ausermessage under the same preamble acompaction: truemessage gets). The default'viewport'history does this itself.Why. Compaction used to replace the summarised turns with a summary message, so the turns, their attachments and their branches left the screen and the store. A checkpoint turns compaction into a view over a kept history: only what goes to the model is shortened.
@aparte/plugin-compactionwrites the checkpoints, and the bubble draws a separator above the message that carries one.How the tree behaves, so a host is not surprised. A checkpoint is a node shared by every path through it. Editing the checkpoint message edits that node in place and keeps the field. Editing a message above it clears everything after the edited message, the checkpoint included, so the full history is sent again. A retry of a reply above the checkpoint forks before it and sends no summary: it would describe messages the model is also reading.
@aparte/core -
37b82d9:
auto-compacton<aparte-context>is deprecated. It still works, and your page keeps compacting as before, but it will be removed in a future major version:@aparte/plugin-compactionnow decides when to compact (after a turn, and before a send that would overflow), so the gauge no longer needs to ask. Remove the attribute once your page runs the plugin’s new release.The gauge is now a button that opens a panel with what fills the window, and it no longer goes blank after a compaction.
What you may have to change:
- After a compaction or a Restore, the gauge shows an estimate with a
~prefix (~12K / 128K) instead of going blank until the next turn. A reading you used to wait for is now there at once; an estimate is never drawn as if it were the provider’s number.aparte-context-thresholdfires on the estimate as it does on a reported number. - The gauge’s markup changed. The reading (
.aparte-context__text) is inside a<button class="aparte-context__toggle">, and a hidden panel (.aparte-context__panel) follows it. The meter keeps itsrole="meter"and its label. CSS that targeted the gauge’s direct children needs a look. - A turn the model refused as over its window (
aparte-message-errorwhose error code isUSAGE_CONTEXT_EXCEEDED, for this gauge’s chat) turns itdangerwhatever the number says, until the next reply reports.
What is new, all additive:
- A disclosure: the button has
aria-expandedandaria-controls; click, Enter and Space open it, Escape and a click outside close it, and nothing opens on hover. The panel lists the system prompt, the tools, the summary of a compacted history, the messages, “other”, what is free, and the total against the window. The parts are estimates and say so; they are scaled down for display so they never add up to more than the total. - With a compactor registered (
setCompactor) whosepreview()returns something, the panel says “Compacting now would free ~N tokens” and offers Compact now (compact({ force: true, reason: 'manual' })). While it runs the element hasdata-compact="running", after a failuredata-compact="failed"and Retry in the panel. Both come from theaparte-compact-start,-doneand-errorevents of the gauge’s own chat. The gauge reads the config, and so the compactor, the system prompt, the tools and the window, from the chat it watches (target, else the chat above it), not from where it stands, so a gauge in a header reads a chat that carries its own config. aparte-compactdetail gainsauto(set by the deprecatedauto-compact, so the plugin can tell it from a button) andwindow(the window the gauge measured against). TheAparteContext.measuregetter returns the reading drawn.- Nine optional locale keys:
contextSystem,contextTools,contextSummary,contextMessages,contextOther,contextFree,contextTotal,contextReclaim({n}) andcontextCompactNow. Retry reusesretry. A locale without them falls back to the built-in English.
Why. The gauge drew the one number the last turn reported, so a compaction left it blank or wrong, and it said nothing about what the number was made of. Reading
measureContextover the chat’s own transcript fixes the first; the panel answers the second, and puts the one action that helps (Compact now) next to the number that asks for it.@aparte/core - After a compaction or a Restore, the gauge shows an estimate with a
-
37b82d9: Use
measureContext({ messages, config, windowOverride })to read how full the model’s window is: the same measure the context gauge draws and the compaction plugin decides on. Nothing for you to change.What is new, all additive:
measureContextreturns{ window?, used, estimated, parts: { system, tools, summary, messages }, other }.usedis what the next request will carry while nothing the model reads has changed since the last reply: the provider’s reported usage, except for a reply that thought, where it is the reported input plus the answer as it goes back on the wire (a provider counts its reasoning tokens as output, and the reasoning is not sent again). After a message, a compaction or a Restore it is an estimate (estimated: true) of the request that would be sent now: the system prompts, the tool definitions, the checkpoint’s summary and the messages after it, scaled by how far the heuristic was off on the last reply (its input tokens overcontextEstimate, kept between 0.5 and 3; 1 when the reply recorded none, and 1 when it called tools, because the estimate is of the first request and the usage of the last call, which also read the tool results).partsare the raw, unscaled estimates;otheris what they cannot attribute (a server loop’s own prompt and tools, template tokens, the heuristic’s error), never below zero.estimateTokens(text)andestimateWireTokens(messages). The second counts what the wire sends for those messages with the serialisation the client builds its history with: thinking is not counted, text and tool calls with their results are, a failed turn or one still in flight sends nothing.AparteContextMeasureandAparteContextMeasureInput(types).
Why. The plugin kept its own copy of the character heuristic and the gauge kept the number the last turn happened to report, so after a compaction the gauge was blank or stale and the plugin decided on figures the page never showed. They now read one function over one serialisation. The serialisation itself moved out of
AparteClientinto pure functions (wireMessagesand friends inclient/wire-history.ts); the requests it builds are the same as before.@aparte/core -
5aa437f: A message that carries a checkpoint (
contextSummary) now shows a separator above it: “12 messages summarised for the model · Show summary · Restore”. The messages above it stay on screen and in storage. Restore takes the checkpoints off and the model reads the original messages again.What you may have to change:
- A message written by an older compaction (
compaction: true) is no longer drawn as a muted bubble. The bubble draws the separator in its place (“Earlier messages were summarised”, with Show summary, and no count or Restore), and its own row does not paint. If your CSS targetedaparte-chat-bubble[data-kind="compaction"]for the old dashed notice, those rules no longer match anything visible; thedata-kindattribute is still written. - A custom bubble renders its own separator. The separator belongs to
<aparte-chat-bubble>; arenderBubble(React),bubbleslot (Vue, Svelte) orbubbleTemplate(Angular) that draws its own markup must readmessage.contextSummaryitself.
What is new, all additive:
<aparte-chat-bubble>.setContextSummary(summary | null, { legacy? }), andSyncableBubble.setContextSummary, whichpopulateBubbleFromMessagecalls for every message (with none, too, so a reused bubble loses its separator). The separator sits outside.aparte-message, inside the bubble, so a frame redraw or a custom shell keeps it.- States, driven by the compaction events: while a summary is written the separator says “Summarising the context…” (the shimmer recipe); if it fails it says “Couldn’t summarise the context” with Retry, which asks the chat’s registered compactor (
force, reason'retry') and falls back to dispatchingaparte-compactwhen none is registered. The viewport (and, under a wrapper, the framework host) readsaparte-compact-start,-doneand-error, acts only on those that name its own chat and a message of its own transcript (an untargeted event, which every chat receives, is checked against the message), and setsdata-checkpoint="pending"or"failed"on the bubble whose id isdetail.messageId. A-donefor that message clears it; a skipped-done, another message’s id and another chat’s events are ignored; the next-startand a conversation switch clear it. The newdata-checkpointattribute is read-only for you. - Restore asks “Send the original messages to the model again?” first. It then removes
contextSummaryfrom every message of the active path (through the chat’s ownupdateMessage, so a framework’s list and your storage see it) and dispatchesaparte-compact-donewithrestored: true. - Event details gain optional fields:
aparte-compact-start{ messageId },aparte-compact-done{ messageId, count, restored },aparte-compact-error{ messageId }.@aparte/plugin-compactiondispatches them. - The conversation is saved when a compaction finishes, once the message named by
messageIdcarries its checkpoint, and again after a Restore. A host that still writes acompaction: truemessage keeps the old behaviour. - Ten optional locale keys:
checkpointCount({n}),checkpointCountOne,checkpointLegacy,checkpointGroup,checkpointShow,checkpointHide,checkpointRestore,checkpointRestoreConfirm,checkpointPending,checkpointFailed. Retry and Cancel reuse the existingretryandcancel. A locale without them falls back to the built-in English. - A new sheet,
styles/components/checkpoint.css(aparte-checkpoint…classes), wearing the divider, ghost button and shimmer recipes.
Why. The compaction plugin used to replace the summarised turns with a summary message, so the turns left the screen. With a checkpoint over a kept history the reader sees everything they wrote and one line saying what the model no longer reads; Restore is the way back.
Detail for the curious.
--aparte-message-content-text-userand-assistantwere declared only by the old notice’s rule; they are now declared asinitialon the root, which reads as unset exactly as before. The viewport’srenderablelist gainedcontextSummaryandcompaction; the framework host’supdateMessagenow reaches the bubble of a message that is not the last forcontent,segments,attachments,usage,finishReason,contextSummaryandcompaction, where it used to stay in the list until the next full sync.Also in the separator. Confirming Restore removes the separator while its button holds the keyboard; the focus moves to the message the checkpoint sat on rather than falling to the page. A second failure with a different reason redraws the separator with the new reason. The summary panel wears the prose rules (
aparte-segment-content), so lists, quotes, links and code in a Markdown summary are drawn like the rest of the transcript.Deprecated: the
compactionSummaryTitlelocale key. Nothing reads it any more — the separator speaks through thecheckpoint*keys — and it stays until a later minor so a locale that sets it keeps compiling.@aparte/core - A message written by an older compaction (
-
eb3c0b1: A reply cut off at the model’s length limit now says so, and the
donestream event, the stored message andaparte-message-donecarry a new optionalfinishReason.Nothing to change in your code: a provider that reports no reason behaves as before. To see the note, a provider has to send
finishReason: 'length'on itsdoneevent (@aparte/provider-openai-compatand@aparte/provider-ai-sdkdo; so does a scripted turn of@aparte/provider-scenario).AparteFinishReasonis'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other'. It rides onAparteStreamEventMap.done, onAparteMessage.finishReason(persisted, so a reload still shows the note) and onAparteMessageDoneEventDetail.finishReason. It is the reason of the LAST model call of the turn: a tool turn that ends on a text answer reports that answer’s reason, never the earlier'tool-calls'.- The bubble draws a small muted line after the content of a completed assistant reply whose reason is
'length', and nothing for any other reason or while the reply streams. The text is the new locale keyresponseCutOff, in.aparte-finish-note(norole="alert": it is a fact about the answer, not an error).bubble.setFinishReason()andpopulateBubbleFromMessagefeed it, so wrapper-managed bubbles get it with the call they already make. - The server-loop path needed no change:
createAgentStreamRunnerreplays eachrun-doneline whole andcreateAgentHandlerwrites it whole; a test pins that.@aparte/core
-
eb3c0b1:
runStreamAgentnow emitsfinishReasononrun-done: the reason the LAST model call of the run ended, when its provider reported one.A step that reports no reason does not inherit the one before it: the value is reset at the top of every model call, so a final answer that says nothing is not described by the
'tool-calls'of the turn before.StreamChatEvent’sdoneaccepts the field (StreamFinishReason, the same union as core’sAparteFinishReason). A run whose providers report nothing emitsrun-doneexactly as before.@aparte/engine -
f0b38dd: A stream
errorevent can now carry acode, and the errorrunStreamAgentthrows for it carries the samecode. Nothing for you to change: an event without a code throws the plainErrorit always did.StreamChatEvent’serrorvariant gainscode?: string(core’s event map gains the typedcode?: AparteErrorCode; this is its mirror). The caller reads it from the thrown error: core’sAparteError.fromkeeps it when it is one ofAparteErrorCode’s values, which is how an overflowing prompt a provider reported mid-stream (USAGE_CONTEXT_EXCEEDED) becomes something the client can act on instead of a sentence to show.@aparte/engine -
1f62ccc: The
doneevent now carriesfinishReason, mapped from the AI SDK’s finish reason, so a reply the model stopped at its length limit shows as cut off.'stop','length','content-filter','tool-calls','error'and'other'pass through; the SDK’s'unknown'becomes'other'. A value the SDK adds later is left off rather than guessed.@aparte/provider-ai-sdk -
f0b38dd: An overflowing prompt reported by the AI SDK now carries
code: 'USAGE_CONTEXT_EXCEEDED'on theerrorevent, so core can compact and send the turn again. Nothing for you to change; any other error is the event it was.The bridge matches the vendor’s own sentence in the API error’s message, or in its
responseBodywhen the message is generic (“maximum context length”, “context size has been exceeded”,exceed_context_size_error, “exceeds the available context size”, “prompt is too long”,context_length_exceeded; any case), the same readings@aparte/provider-openai-compatmakes on a wire body.@aparte/provider-ai-sdk -
f0b38dd: The provider now tells an overflowing prompt from a bad request: a 400 or 413 whose body says the prompt did not fit is
USAGE_CONTEXT_EXCEEDED, and so is an error the server streams after its 200. Nothing for you to change; with@aparte/plugin-compaction, core compacts and sends the turn again instead of showing the error.What is new, all additive:
provider.classifyError(status, body)(theAparteFormatAdaptermethod core’s direct transport andcreateAparteChatHandlercall):USAGE_CONTEXT_EXCEEDEDfor a 400 or 413 whoseerrorsayscontext_length_exceeded, or whose message, type or code matches “maximum context length”, “context length”, “context size has been exceeded”, “exceed_context_size_error”, “exceeds the available context size” or “prompt is too long” (any case). Every other status and body is left to the status.- The mid-stream
errorevent the provider emits for the same payloads carriescode: 'USAGE_CONTEXT_EXCEEDED'; so does the errorparseTextthrows for a 200 body that is an overflow (LM Studio). Any other error carries no code, as before.
Needs
@aparte/corewith the same release to be acted on; older cores ignore the code and show the error as they did.@aparte/provider-openai-compat -
45ed986: Errors a server sends mid-stream now surface as errors, a reply’s finish reason is reported, and the context window of LM Studio, Ollama, llama.cpp and vLLM models is read.
Three changes, none of which asks you to touch your code:
- Stream errors. A server that answers
200and then streamsdata: {"error": {...}}(LM Studio on a context overflow, OpenAI and vLLM mid-stream) used to end the turn on an emptydone: an empty reply marked completed. The line is now anerrorevent carrying the server’s message, and the stream ends there without adone. The non-streaming path throws the same message for a200body that haserrorand nochoices, where it returned''. - Finish reason. The last non-null
finish_reasontravels on everydoneasfinishReason:stop,length,tool-calls(tool_callsandfunction_call),content-filter, orotherfor anything else. A server that never says leaves it off. - Context window.
fetchModelsread onlycontext_length, so a local model had nocontextWindow: the gauge never showed and compaction never triggered while the server truncated silently. It now readscontext_length,max_model_len,context_windowormax_context_lengthfrom the/modelsentry, and the newserveroption ('lmstudio' | 'ollama' | 'llamacpp' | 'vllm') makes one extra request at the endpoint’s origin for the window the server actually serves: LM Studio’s/api/v1/models(a loaded model’scontext_length), Ollama’s/api/ps, llama.cpp’s/props. A failing probe leaves the models without a window, with one warning. AcontextWindowyou declare on a model inmodelswins over anything read.
The
LMSTUDIOandOLLAMApresets now setserver, and two presets are new:LLAMACPP(http://localhost:8080/v1) andVLLM(http://localhost:8000/v1).An unloaded LM Studio model, and an Ollama model not currently loaded, get no window rather than the trained maximum: that number is not what the server will run with, and a gauge drawn against it is worse than none.
@aparte/provider-openai-compat - Stream errors. A server that answers
-
1f62ccc: A scripted turn can end with
{ finishReason: 'length' }to setfinishReasonon itsdoneevent, so a demo can show the “cut off” note.A turn that declares none reports none, as before.
@aparte/provider-scenario -
d68ace6: The summary request is now bounded: streamed, capped by
maxTokens, refused without a known window, trimmed to fit, and a summary that is cut off, failed or no shorter than what it replaces is refused.What you may have to change:
- A model with no
contextWindowno longer compacts by default.setupCompaction()reports{ skipped: true, reason: 'no-window' }(newCompactionSkipReason) instead of sending everything but the last four messages in one request. Declare the window on the model, or size the request yourself: passingkeepWithoutWindow(the opt-in; it keeps today’s behaviour), your ownselectoror your ownsummarizeis enough. - A
summarizeof yours now receivesstream: trueandmaxTokenson the request, where it used to receivestream: falseand no limit. If it forwards the request to a model as is, it now gets a stream back; if it expects a string, setstream: falseon the request you forward.
What changed, and why. A live test against a local model with a 4096-token window showed the summary request was bounded by nothing:
- The request is streamed and drained strictly. An
errorevent fails the compaction with its message instead of applying the text received before it, and adonewhosefinishReasonis'length'fails with “The summary was cut off at the model’s length limit”. A plain string answer is still the whole summary. request.maxTokenskeeps the answer inside the window: a quarter of the window, at mostreservedGeneration(2000, the room every request keeps for a reply) and never under the room the window walk holds back for the summary;reservedGenerationwhen there is no window. It is deliberately looser than that reservation (about 118 tokens on a 4096-token model), which a reasoning model spends thinking before it writes a word.- With a known window and the default summariser, the instruction plus the transcript of the dropped turns must fit the window less
maxTokens, the reserved thinking and the safety margin. When they do not, the oldest turns that fit are summarised and the rest stay verbatim for the next compaction. If not even the oldest turn fits, the compaction fails with an error instead of sending it. - A summary whose estimate is not smaller than the turns it replaces fails with “The summary is no shorter than the turns it replaces”, and the transcript is left untouched. This one applies to a host’s
summarizetoo.@aparte/plugin-compaction
- A model with no
-
62c70d4: Compaction no longer deletes messages, and it is automatic once set up. A compaction now writes a checkpoint (
contextSummary) on the first message it keeps, and the history the client sends resumes from it; the messages above stay on the screen and in storage.setupCompaction()also compacts by itself: after a turn that ends at 80 % of the window, before a send whose request would not fit, and when the model refuses a request as over its window (the turn is sent once more). Passauto: falseto keep it manual only (the gauge’s Compact now, a separator’s Retry,compact(),aparte-compact): none of the three automatic attempts is made, the overflow retry included.What you may have to change:
- A page that only wanted the manual compaction passes
auto: false.autodefaults totrue, and is forced tofalsewithlisten: false.auto: falsemeans manual only:compact()answers an'auto'or'overflow'request with a skip ('nothing-to-drop'), so the model’s overflow error stands as it did before the plugin. It acts only when a summariser can run (a provider registered for the current model, or yoursummarize), and only when a window is known: the model’s, or the newcontextWindowoption. - A
CompactionTargetof your own needsupdateMessage(id, updates). It is the one write a compaction makes. Without it the compaction fails with an error that says so, before the model is paid; there is no fallback that rewrites the transcript.clearAllandappendMessageare no longer called (they stay optional on the type).<aparte-chat-viewport>already hasupdateMessage. DEFAULT_COMPACTION_PROMPThas a new value (the name is unchanged). It asks for fixed sections (the goal and constraints, the decisions and why, the open tasks, the facts and tool results still needed, the next step) in the language of the conversation, and keeps the clause that stops the model from answering the transcript. If you pass your ownprompt, nothing changes for you.summaryin the outcome and inaparte-compact-doneis what is stored: the model’s summary, then, on the first compaction of a conversation, the first user message verbatim (clipped at 1 500 characters) under a fixed English line. The conversation’s first request survives every compaction.- A
selectoryou pass receives the model’s view (the messages from the latest checkpoint on, less the turn being prepared), and the cut is moved so the first message kept is a user message: it may keep more than your selector asked for, or skip with'nothing-to-drop'when no user message can carry the checkpoint. - The “notice” message is gone from new compactions: no
compaction: truemessage is written, and thecompactionSummaryTitlelocale key is no longer read (old stored conversations keep the title inside their summary message, and are drawn as a separator).
What is new:
- Options
auto,threshold(default0.8) andcontextWindow(a number, or a function, for a server that does not declare its window).thresholdis also what a compaction aims below: with the default selector it leaves the history the model reads at aboutthreshold − 0.2of the window, less the fixed costs (system prompt, tools, the reply’s room, the summary’s slot), so one compaction buys several turns. That holds for a person’s request and an overflow retry too, and aselectorof your own is left alone. Thewindowa context gauge sends with itsaparte-compactrequest is read as a last resort for one more release. - The controller is registered as the config’s compactor (
config.setCompactor) and unregistered bydispose(), so the gauge’s Compact now, a separator’s Retry, the client’s pre-flight and its overflow retry all call it.compact()takes the compactor request ({ target, targetId, force, exclude, reason }) as well as a chat id or nothing, resolves the chat from the element it is given (a chat in a shadow root is found), and a scoped setup refuses a chat outside its scope. Newsettled(),beforeSend()andpreview(). - A second compaction updates the first: the request carries “Summary so far:” and only the messages since, and asks for an updated summary. The no-gain refusal compares against what is replaced: the earlier summary plus the newly summarised messages.
- Two failures in a row for a chat stop the automatic attempts until something succeeds, a person asks (
reason: 'manual'or'retry', or a request withoutauto), or the conversation is switched; with automatic compaction on, an overflow is still compacted and resent. After a compaction that still leaves the context at the threshold, the after-turn trigger waits until a turn ends below it (a request that would not fit is still compacted before it goes out), and never the same range twice. A Restore suspends the automatic attempts for that chat until a person asks again. createCompactionSelectortakes an optionalestimate(message). The selectorsetupCompactionbuilds uses core’sestimateWireTokens, so it prices what the wire sends (a reply’s hidden thinking costs nothing). The selector it returns takes an optional second argument,CompactionLimits(scale,reserve,landAt,ceiling), which the default selector is called with on each compaction: the heuristic’s undercount as the context measure calibrates it (the selection, the summary request’s size and the pre-send comparison all use it), room for the turn being prepared, the share of the window to land at, and, after an overflow, a ceiling below what failed.- Chats compact independently: a summary being written for one chat no longer skips another’s pre-send compaction or overflow retry.
settled(),beforeSend()and an overflow wait for their own chat’s summary only;runningandabort()cover every chat of the setup. - A Restore cancels a summary still being written for that chat, and a summary is refused when the checkpoint it updates was taken off meanwhile, so a Restore is not undone seconds later.
- The compactor request’s
signal(andbeforeSend’s) cancels the chat’s summary: a Stop, or a programmaticstop(), no longer pays for a summary whose turn is gone, whether or not the setup listens onwindow. It is not counted as a failure. - The summary request alternates roles: adjacent turns of one role are merged and the ask joins a trailing user turn, so a strict chat template (Mistral, Gemma on llama.cpp or LM Studio) no longer refuses it. An inline
<think>…</think>block (or one left open, or a closing tag alone) is stripped from the summary before it is stored. CompactionOutcomeis core’sAparteCompactorOutcome;CompactionControllerextends core’sAparteCompactor. Both gainmessageIdandcount. Theaparte-compact-start,-doneand-errorevents carry themessageIdthe checkpoint sits on.
The checkpoint is the plugin’s half of core’s
contextSummary,historyFromCheckpointandsetCompactor; the separator that draws it, and the history that resumes from it, are core’s.@aparte/plugin-compaction - A page that only wanted the manual compaction passes
-
37b82d9: Adds the French text for the nine keys of the context gauge’s new panel, such as “Prompt système”, “Libre” and “Compacter maintenant”. Nothing to change in your code.
The keys are
contextSystem,contextTools,contextSummary,contextMessages,contextOther,contextFree,contextTotal,contextReclaimandcontextCompactNow. The panel is what the gauge opens: what fills the model’s window, and a button to compact the conversation now.@aparte/locale-fr -
5aa437f: Adds the French text for the ten keys of core’s new context separator, such as “12 messages résumés pour le modèle”, “Afficher le résumé” and “Restaurer”. Nothing to change in your code.
The keys are
checkpointCount,checkpointCountOne,checkpointLegacy,checkpointGroup,checkpointShow,checkpointHide,checkpointRestore,checkpointRestoreConfirm,checkpointPendingandcheckpointFailed. The separator is the line a chat draws above the first message the model still reads verbatim, once the messages before it were summarised.@aparte/locale-fr -
eb3c0b1: Adds the French text for core’s new
responseCutOffkey: “Coupée à la limite de longueur du modèle.”It is the note under a reply the model stopped at its length limit.
@aparte/locale-fr
Patch Changes
-
7a8eac4: Fix three problems after a compaction or a new chat: the model no longer receives two
usermessages in a row, the conversation title is no longer taken from the summary, and the context gauge no longer keeps the previous conversation’s reading.Nothing to change in your code.
- Two user turns in a row. After a compaction the summary is a
usermessage and the kept turns usually start with one; after a failed turn its user message stays while its error reply is not sent. Either gave the model adjacentusermessages, which strict chat templates (Gemma, Mistral, llama.cpp with--jinja, vLLM) reject, and every following turn failed. Send, retry, edit and the resume of a paused turn now merge adjacentusermessages into one on the final array (texts joined by a blank line, image parts kept). System, assistant and tool messages are never merged. - The title.
AparteConversationManagertook the title from the firstusermessage, which after a compaction is the summary notice (“Conversation summary The user requested…”). The three places that look for it skip acompaction: truemessage. - The gauge.
<aparte-context>listened to turns and compactions only, so after “New chat” the empty conversation still showed the last reading. It now clears onaparte-reset-doneand, for a stored conversation, restores the last reply’susageonaparte-conversation-loaded. A restored reading never dispatchesaparte-compact: auto-compact is now asked only at the end of a turn, so a gauge already indangerno longer asks again on a window or config change.@aparte/core
- Two user turns in a row. After a compaction the summary is a
-
b140b2b: Under a wrapper, a message the parent replaces with a new object is drawn again, whether or not it is the last one. A list you hand in (a controlled
messagesprop, av-model,bind:messages, an Angular input) that changes an earlier message, for example a store-backed compaction target writing a checkpoint onto the first kept message, now reaches that message’s bubble.Nothing to change in your code. An update that keeps the same message object is still skipped, so a list you rebuild with the same objects costs nothing more than before; a list that copies every message on every render makes every bubble redraw on every render.
Why.
AparteChatHost.syncBubblesdrew every message once and then only the last one on each render, so a change written through the parent’s list to an earlier message stayed in the list and never reached the screen. It was invisible with the host’s ownupdateMessage, which pokes the bubble itself, and it left the separator of a compaction missing on every framework-rendered transcript whose list was the app’s. The host now remembers the object each bubble was drawn from and compares it by reference, which an immutable update always changes. The same one change serves React, Vue, Svelte and Angular, since they share the host.@aparte/core -
8280572: A model listed without a context window gets it after its first reply: the client reads the provider’s model list again, once per model, so an LM Studio or Ollama model loaded on demand gets a gauge and compaction.
A local server that loads a model on its first request has no loaded window to report when the list is fetched at start-up, so the model came without one and the gauge and the compaction had nothing to measure against for the whole conversation. After a reply the model is loaded and the provider’s probe finds the window.
refreshProviderModels()now announces a change (aparte-config-change) when the current model’s window changes, and only then; and an empty answer — what a provider returns when its fetch fails — keeps the list already held instead of wiping the selected model’s capabilities.@aparte/core -
37b82d9:
estimateTokensnow comes from@aparte/core. The plugin re-exports it under the same name, so nothing changes for you.The gauge and the plugin used to keep their own copy of the character heuristic; core’s
measureContextreads one, and the plugin’s budget now reads that one too. The function and its result are the same:ceil(characters / 3.8).@aparte/plugin-compaction -
5c7407a: The published types keep
nullandundefinedagain:[conversationId]="id()"with astring | nullsignal now compiles understrictTemplates.Code written against the old, wrong types may need a null check: on the conversation service,
activeConversation()isAparteConversation | nullandactiveId()isstring | null; on the chat,bubbleDirective()can beundefined. The runtime always returned those.The package was built with ng-packagr’s built-in tsconfig, which sets no
strict, so the declarations were emitted withoutstrictNullChecksand every inferrednull/undefinedvanished:conversationId,typingText,config,containerClass,containerStyle, theconfigof both tool directives. The build now passes-c tsconfig.lib.json, which is ng-packagr’s own options plusstrict: the emitted JavaScript is byte for byte the same, only the declarations change. The Angular SSR fixture binds those inputs to nullable signals understrictTemplates, so a build that drops them again fails CI. Reported in #124.@aparte/angular
Version-only bumps (no changes of their own): @aparte/runner-ag-ui, @aparte/provider-transformers, @aparte/plugin-approval, @aparte/plugin-artifacts, @aparte/plugin-ask-user, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/plugin-streaming-markdown, @aparte/plugin-titler, @aparte/react, @aparte/svelte, @aparte/vue, @aparte/docs-mcp.