v1.0.46
@veyyon/agent-core
Breaking Changes
AgentLoopConfig.transformToolCallArgumentsreturns two forms of the arguments,{ execution, display }, instead of one record. The two exist because argument expansions disagree about their audience: a codec handle must be expanded before a person reads it, and a secret placeholder must not be, because the expanded form is a live credential and a display or a session file is exactly where it must never appear. One shared form cannot satisfy both, so the transform states which form each audience gets and the loop routes them.executionreachestool.executeandbeforeToolCall;displayis what is shown, streamed, traced and recorded. A host that returned a single record returns it as both fields to keep the previous behavior.
Added
- Added backward-compatible session-entry sequencing and complete tool-result span capture. Tool results can now preserve millisecond timing, terminal status, batch scheduling, bounded result weight, usefulness, argument fingerprints, and abort state at the detail selected by the host.
Changed
AgentOptions.pruneToolDescriptionsaccepts a per-model resolver as well as the existing boolean. The agent resolves it for main and side requests, so a host can move descriptors between the prompt and native schemas when the active model changes without reconstructing the agent.compaction/compaction.tstakesEffortfrom@veyyon/catalog/effortandwithAuthfrom@veyyon/ai/auth-retry, the modules that declare them, so its@veyyon/aiimport is type-only and the file carries no runtime edge to the barrel at all.- The agent loop and the
Agentclass name the modules that declare the functions they call rather than the@veyyon/aientry point, which re-exports the model catalogue, every provider and the usage backends. Both stream, so both reach the streaming engine either way; what changed is that ten other names stopped arriving with the whole package attached.agent-loop.tswent from 378 modules to 321 andagent.tsfrom 380 to 323, andcompaction/utils.tsfrom 198 to 164 by taking the dialect factory from its own module instead of the dialect barrel. - Importing a span attribute no longer imports a model provider.
telemetry.tsis span vocabulary, and it is used across this package by code that never calls a model, but it also heldinstrumentedCompleteSimple, the one helper in it that runs a completion. That helper names the streaming engine, so an attribute constant cost the provider stack, the model catalogue and the error taxonomy: 281 of the file's 366 modules. The helper moved toinstrumented-complete.tsand the remaining barrel imports were repointed at their owners, takingtelemetry.tsfrom 366 modules to 9 andcompaction/branch-summarization.tsfrom 394 to 333.instrumentedCompleteSimpleis still exported under the same name from the package entry point, which is where callers already took it from. proxy.tstakesEventStreamfrom@veyyon/ai/utils/event-stream, the module that declares it, instead of from the@veyyon/aientry point. That entry point re-exports the streaming engine, every provider, the model catalogue and the usage backends, so a 42-module class was arriving with 363 modules behind it. The proxy went from 364 modules to 118. Its types still come from the barrel, which costs nothing because type imports are erased.- Asking what a message costs no longer loads the machinery that compacts one.
estimateTokenslived in the compaction engine, which reaches 395 modules for the summarizer, the cut-point search and the provider round trip; the estimate needs a tokenizer. It moved tocompaction/token-estimate.tsat 85 modules, and the engine re-exports the name, soshake.tswent from 398 modules to 88 andpruning.tsfrom 398 to 204. The estimate decides when compaction triggers, how pruning spends its budget and what the context meter reads, so keeping it cheap to import is what lets those callers share one implementation. compaction/threshold.tsowns the whole compaction trigger now: theCompactionSettingsshape, the reserve policy (effectiveReserveTokens,resolveBudgetReserveTokens),shouldCompact, and the three threshold wrappers. They were incompaction/compaction.ts, which is the module that RUNS a compaction and therefore imports the@veyyon/aibarrel, the provider dialects, the prompt registry and the tokenizer. Deciding whether a token count is over the trigger needs none of that, so every host that wanted only the trigger paid for the summarizer:@veyyon/coding-agent'sconfig/settings.ts, the module 528 of its test files import, reached@veyyon/ai/stream.tsthrough this one edge.compaction.tsre-exports all of it, so no caller changed.thinking.tstakesEffortfrom@veyyon/catalog/effort, its owner, which imports nothing, instead of from the@veyyon/aibarrel. A six-entry ladder and a clamp were carrying the streaming engine to every consumer ofThinkingLevel.- This package owns the session-entry vocabulary:
SessionEntryBaseand the fourteen entry interfaces over it, plus theSessionEntryunion.@veyyon/coding-agenthad a second copy of all of them and the copies had diverged, soSessionInitEntryhere was missing thespawnsandreadSummarizefields the coding agent writes, andThinkingLevelChangeEntrywas missingconfigured. Those three fields are now on the shared declarations, and a consumer that persists its own entry kinds adds them throughCustomCompactionSessionEntriesrather than redeclaring the union. - The narrowing that answers "does this session entry carry a tool result" lives with the entry union it narrows, as
getToolResultMessageincompaction/entries.ts. Both compaction passes, pruning and shake, had a byte-identical private copy, and a pass that recognised one message shape while its sibling recognised another would prune output the other still counted. - The two compaction strategies now state distinct contracts instead of asking for the same document.
summaryis told it continues in the SAME session, so the recent turns survive alongside it and must not be restated;handoffis told it starts a NEW session where nothing survives, so it must carry cold-restart state (working directory, branch, uncommitted files, toolchain, the exact next command). Both prompts now ask for verification evidence explicitly (commands run verbatim, pass/fail counts, durations, run IDs, exact error text), and the summary prompt states the precedence between brevity and evidence rather than leaving "be concise" one sentence away from "keep the command results". - The handoff prompt gained a
Blockedsection, which only the summary prompt had. Handoff is the strategy whose reader starts cold with nothing but the document, so it is the one that most needs to carry blockers; without the section they had nowhere to go. In practice it now records constraints that cannot be re-derived from the repository at all, such as an action requiring explicit approval or a repository-owner UI step. - Compaction prompts now separate the overarching goal from the current task, in one shape shared by
compaction-summary,compaction-update-summary, andhandoff-document. A single Goal field meant the model wrote whichever goal was most concrete, which is always the immediate task, so the standing objective went unrecorded from the first compaction onward.compaction-update-summaryruns on every later compaction and permits dropping anything no longer relevant; the overarching goal is now carved out of that permission. - Compaction prompts ask for the HEAD commit and whether anything was committed during the session, not just the branch. A branch name does not say where the work started or whether any of it is saved anywhere but the working tree. Repository state stays conditional on the work actually being version controlled, so a session outside a repository is not pushed into inventing one.
- Both compaction strategies now fail loudly when the model returns an empty document instead of accepting it. A provider can return
stopReason: "stop"with output tokens spent entirely on reasoning and no text content;handoffthen returned just the deterministic<files>block, which reads as a real document while carrying no goal, no decisions, and no next step, andsummarywould have stored an empty summary in place of the history it replaces. - Mechanical compaction-request pruning has one owner,
pruneMessagesForCompaction. It previously existed as two inline copies insideserializeConversation, one per rendering branch, so it applied to thesummarystrategy and to nothing else. Both strategies now route through it. Dropping a useless result also drops its pairedtoolCall, so no call is sent without a result; non-text content blocks (images) survive pruning; a byte-identical repeat is collapsed only when the back-reference is shorter than the text it replaces; and stale reads are recognized withreadToolSupersedeKey, which moved tocompaction/utils.tsso the durable pruner and the request pruner share one definition of that rule. - Tool-result truncation is opt-out through
truncateToolResults.summarykeeps it;handoffturns it off, because handoff seeds a new session where a truncated result is evidence deleted rather than shortened. Across two real sessions the lossless passes accounted for 0.0% and 1.6% of message bytes while truncation accounted for 60.4% and 35.3%, so the size win and the data loss are the same pass. generateHandoffacceptsfileOpsand appends the same deterministic<files>block thesummarystrategy has always emitted. The block is machine-generated and byte-identical across models, so withholding it made handoff strictly worse for free.
Fixed
- Compaction and branch summaries now enter the provider request as agent-owned developer context,
not as synthetic user turns. Their persistedcompactionSummaryandbranchSummaryroles remain
unchanged. New context contains no private<summary>delimiters for a model to echo. AgentLoopConfig.pauseGatecan scope pause state to one execution domain while retaining the
process-wide gate by default. Parallel and independently embedded loops no longer inherit another
domain's test or host pause, so aborts still become completedabortedmessages with their exact
reason instead of escaping as rejected streams.- A turn that ends in an error builds its message with the shared
errorMessagehelper. The two tail branches were that helper written out by hand, and the localconst errorMessageholding the result shadowed the import, so the hand-rolled copy was the only version reachable in that scope. The local is namedfailureMessagenow. - Compaction now protects hook output, memory context, turn prefixes, long and short summaries, local fallbacks, and remote summarizer bodies at the final provider boundary. Each physical attempt resolves the current secret transform after credential refresh, so an authentication retry cannot reuse text prepared with a stale runtime. Opaque provider replay state keeps its exact identity and is rejected rather than rewritten if it contains a live credential.
- Fixed compaction doing nothing when the newest turn alone exceeded the keep-recent budget. One very large tool result at the end of a session was enough: the cut-point search found no boundary at or after the entry that blew the budget and fell back to keeping the whole session, so compaction reported nothing to do while the context meter sat at the ceiling. It now cuts to the newest valid boundary, which never separates a tool call from its result.
- Fixed images in a user message counting as zero tokens. Every other message role already counted them, so a session of pasted screenshots under-reported its own size to the compaction trigger, the pruning budgets, and the context meter.
- Aborted compaction completions now raise the canonical cancellation error before empty or partial text can be stored as a summary or handoff. This applies to long, short, turn-prefix, and direct handoff generation.
- Compaction redacts message content and tool-argument keys and values before JSON escaping or the 2,000-character tool-result cutoff. Provider protocol roles and keys remain intact; signed, encrypted, and binary replay fields are preserved byte-for-byte and fail closed if a configured secret would require changing them.
- Legacy
<summary>presentation tags are removed only when they enclose the complete persisted summary. HTML, JSX, or XML<summary>elements embedded in source context remain unchanged.
Removed
- Removed provider-native remote compaction (OpenAI
/responses/compactand the Responses V2 streaming variant). It stored the durable history as an opaque provider blob that no other provider could replay, wrote a fixed placeholder string in place of the compaction summary, and re-sent the full context uncached on every call. Compaction now has exactly two strategies,summaryandhandoff, and no provider gets a private path. Sessions compacted by the old path still load: such an entry is treated as having no usable summary, so the original messages behind it are re-expanded and summarized locally. - Removed the
compaction.remoteEnabled,compaction.remoteStreamingV2Enabled, andcompaction.v2RetainedMessageBudgetsettings, which existed only to gate that path.compaction.remoteEndpointstays: it is a summarizer transport for thesummarystrategy and returns summary text.
@veyyon/ai
Added
- Added one canonical session-telemetry policy for
off,basic,rich, andultra, plus additive context-snapshot and tool-span types. Hosts can now gate lifecycle, task, context, agent-communication, tool, model-turn, and request data through one fail-closed contract without persisting raw arguments or message bodies.
Changed
- Every file in
src/imports the@veyyon/utilsnames it uses from the module that declares them.
Eighty-nine took them from the package barrel instead, mostly one or two at a time (isRecord
twenty-one times,loggerseventeen,errorMessagesixteen), and the barrel is eighty-one leaves,
so a predicate cost the whole utility package. That is what put the barrel on the coding agent's
file-reading module graph and turned a landed reach cut red. Nothing about behaviour changes;
providers/anthropic.tsreaches 191 modules where it reached 253. - The Google OAuth flow holds its config in an ES
#private field rather than behind aprivate
keyword the compiler throws away. No public API changed. - The OpenAI reasoning-effort fallback reads the thinking ladder from
@veyyon/catalog/effortinstead of restating it three times.openai-reasoning-fallback.tsheld the six levels as a list, as a lookup table and as a rank table, all written by hand. That module decides what to RETRY with after a server rejects an effort, so a table that had not learned about a new level could not offer it as a fallback: a model whose only allowed value was the new one fell through to no reasoning at all, and the request then succeeded.nonestays declared locally asNO_REASONING_VALUE, because it means do-not-reason and putting it on the ladder would make it a step the clamp helpers could stop at. - The Claude Code version this client identifies itself as comes from
@veyyon/catalog/wire/anthropic. Three modules build a user-agent from it and build deliberately different ones, so the version is the only part that has to agree, and it was declared in the Anthropic provider: 310 modules for a string. The OAuth controller went from 313 modules to 106 and the usage client from 313 to 127. A drift between the three was never an error, only three requests carrying fingerprints that disagree with each other. dialect/wire-tags.tsownsCODE_FENCE, the bare markdown fence.gemini.tscalled itFENCEanddeepseek.tscalled itCODE_FENCE, and both dialects SCAN for it rather than emit it: DeepSeek closes a tool call's arguments at the last fence in its raw-argument buffer, Gemini closes a code block at the first one. A copy that drifted would not raise anything, it would make one dialect stop finding the end of a block and swallow the rest of the stream as arguments, which surfaces as a tool call with garbage parameters. Fences that carry an info string stay with their dialect, because the string is the dialect's own convention rather than shared vocabulary.<authenticated>is exported fromprovider-env-keys.tsasAUTHENTICATED_API_KEY_SENTINEL.providers/amazon-bedrock.tsdeclared its own copy to recognise it, and it treats a match as "use the ambient AWS credential chain", so a miss would send the literal string<authenticated>as an API key.- Reads the Gemini developer API base, Anthropic's host, Cursor's host and Google's OAuth endpoints and scopes from
@veyyon/cataloginstead of declaring them.providers/cursor.tsre-exportsCURSOR_API_URLfrom the owner rather than declaring it, andregistry/oauth/google-oauth-shared.tstakesreadonly string[]scopes, since it only joins them into a request parameter. - The Perplexity login flow reads its client identity from
@veyyon/catalog/wire/perplexity. Its three OTP requests each spelled the same User-Agent and API version, and both values also existed in@veyyon/coding-agent, which spends the session this flow mints. - The Codex OAuth registry, the usage reader and the credential-row identity extractor all read a token's account id and email through
@veyyon/catalog/wire/codexinstead of each decoding the JWT themselves. The usage reader's copy passed an emptychatgpt_account_idthrough unchanged, which is worse than omitting the header it feeds.auth-credential-rows.tshad spelled both claim URIs as bare literals, the copy a grep for any constant name never finds, and the usage reader now uses the tree's one stored-email normalizer rather than a third hand-rolledtrim().toLowerCase(). - Removed the deprecated
decodeJwtexport fromoauth/openai-codex. It only forwarded todecodeJwtPayloadin@veyyon/utils, which its own doc named as the replacement, and its last caller is gone; importdecodeJwtPayloadfrom@veyyon/utilsinstead. - The Devin provider and its OAuth flow take their hosts from
@veyyon/catalog/provider-endpointsinstead of each declaringDEVIN_API_URLfor a different host. - The in-band tag vocabulary the ChatML-family dialects share (
<tool_call>,<tool_response>, GLM's<arg_key>/<arg_value>, and both thinking envelopes) is declared once indialect/wire-tags.ts. It was spread over 19 declarations in 8 modules under 15 names, plus 8 bare literals: the tool-call envelope was retyped inglm.ts,hermes.tsandqwen3.tsand a fourth time inutils/validation.tsasSPILL_TOOL_CLOSE, the tool-response envelope existed as GLM constants, as inline text inrendering.ts, and as bare literals in seven rows of theowned-stream.tsdetection table, andproviders/anthropic.tsheld a third name for the<thinking>pair it strips. Each tag is a contract between a prompt this repo writes and a parser this repo runs, and every failure mode is silent: a scanner that no longer matches an opener reports success and the tool call becomes visible text, and a detector that no longer matches the tool-response opener lets the model's invented continuation of a tool result into the transcript.hermes.tsalso spelled the envelope inline in its own renderer while keeping a named copy for its scanner, so the producer and the parser in one file were not using the same constant. - Three dialect tag names each meant two different byte sequences in sibling files, which is a latent bug rather than a style point.
THINK_OPENwas<think>in six dialects and```thinkingingemini.ts, where adding the shared name to the import list would have silently shadowed it.CALL_OPENwas<|tool_call>ingemma.tsand<call:inpi-native.ts.RESPONSE_OPENwas<|tool_response>ingemma.tsand the shared<tool_response>inglm.ts. Every dialect-specific tag is now prefixed with its dialect, and a scan over the dialect directory fails if any name takes two values again. DEFAULT_CALLBACK_PATHis exported fromregistry/oauth/callback-server.ts, and the providers that used to hand that class back the value of its own default now import it.anthropic.ts,devin.tsandgitlab-duo.tseach declaredconst CALLBACK_PATH = "/callback", as did the MCP flow in@veyyon/coding-agent, which importsOAuthCallbackFlowand then redeclared its default as a private fallback. Moving the served path would have left all four still advertising the old one, and the failure surfaces as a redirect-URI mismatch on the provider's own error page. The three provider-specific paths (/auth/callback,/oauth-callback,/oauth2callback) stay local, deliberately, because each is what that provider has registered.- Every Google and GitLab host comes from
@veyyon/catalog/provider-endpoints, andSQLITE_NOW_EPOCHfrom@veyyon/utils/sqlite. Six modules here declared one of those hosts and three declared the SQL timestamp expression; nothing about the requests or the schemas changed. - Asking which environment variable holds a provider's key no longer loads the provider registry.
getEnvApiKeywas split out ofstream.tsfor exactly this reason and still cost 158 modules, because the OVERRIDES hung on the provider definitions: three credential probes (Bedrock's five credential shapes, Vertex ADC, Anthropic's variable order under Foundry) plus a handful of string keys, read offPROVIDER_REGISTRY, which is 121 modules of login flows, transports and model lists and was 95 of them marginal on this lookup.src/provider-env-keys.tsowns those rules now at 23 modules, andregistry/types.tsno longer declares anenvKeysfield, so a provider's env-key rule has exactly one home and one reader.env-api-key.ts158 -> 65, and downstream in@veyyon/coding-agent, where eighteen web-search providers and the fetcher import it:web/parallel.ts164 -> 72,tools/fetch.ts368 -> 282,tools/read.ts542 -> 468. Two duplicates went with it:KeyResolverwas declared identically inregistry/types.tsand inenv-api-key.ts, andgitlab-duo-agentdeclaredenvKeys: "GITLAB_TOKEN"while the catalog already saidenvVars: ["GITLAB_TOKEN"]for the same id, with the override silently winning.test/provider-env-keys.test.ts(26 cases) drives every branch of every probe from the real environment, including the boundaries a rewrite gets wrong (an AWS access key with no secret, ADC credentials with no project or no location, the Foundry order with all three variables set), and ratchets both duplicates shut. - The sqlite credential store moved out of
auth-storage.tsintosrc/auth-storage-sqlite.ts, and the
row types and row logic intosrc/auth-credential-rows.ts. One 7,800-line module used to hold three
jobs: the credential types every consumer speaks, theAuthStorageclass that selects and refreshes
credentials, and the store that reads and writes rows. Reaching the store meant importing all of it,
and all of it is the provider registry with its 75 provider definitions, the OAuth flows, and the
error taxonomy: 213 modules to persist a credential. It is 83 now, and the row helpers are 75, which
is the@veyyon/utilsbarrel plus one.auth-storage.tskeeps the OAuth machinery and re-exports
SqliteAuthCredentialStore,isSqliteBusyError,isRefreshFailureDisableCauseand
OAUTH_REFRESH_FAILURE_DISABLE_PREFIX, so@veyyon/ai/auth-storageand the@veyyon/aibarrel both
remain working import paths and no caller changed.
Import the store from@veyyon/ai/auth-storage-sqlitewhen you want persistence without the OAuth
stack. veyyon'ssession/agent-storage.tsdoes, and fell from 213 modules to 84; because
config/settings.tsimports that file, everything that reads a setting fell from 250 to 125.
packages/ai/test/credential-store-is-not-the-oauth-machinery.test.tspins both the numbers and the
round trips through the store's own module. - The per-provider in-flight request caps moved out of the streaming engine into
src/provider-inflight-limits.ts, which imports nothing. The caps are WRITTEN by a harness when its
configuration changes and READ by the engine once per request, so reaching the setter meant importing
stream.tsand its 285 modules: every provider transport, the model registry, the error taxonomy.
veyyon'sconfig/settings.tsdid exactly that for one setter, and paid it into everything that reads
a setting.stream.tsre-exportsconfigureProviderMaxInFlightRequestsand resolves its limits
through the new module, so@veyyon/ai/streamremains a working import path for it and there is still
one owner of the record: two copies would drift, with the harness writing one and the engine reading
the other, and a configured cap would silently stop applying. - The usage-provider table moved out of the credential store.
auth-storage.tsimported all eleven
usage backends directly, so a module about storing credentials owned the table of how every
provider reports its quota, and throughusage/claudeit reached the provider transports and the
streaming engine. The table now lives insrc/usage/defaults.tsand is read through
src/usage/registry.ts, which cutsauth-storage.tsfrom 299 reachable modules to 209.
Importing@veyyon/aistill wires it for you. If you importAuthStoragefrom the subpath, add
import "@veyyon/ai/usage/defaults";once before you build the store. Leaving it out is reported
rather than a quiet loss of quota reporting: an unfilled registry warns once and names the import,
because an empty table answers "no backend" for every provider, which is indistinguishable from a
provider that genuinely reports nothing. It warns rather than refusing because the same registry
holds the credential-ranking strategies andgetApiKeyreads those, so throwing would stop a
process selecting a credential over a feature it never asked for. To report no usage deliberately,
passusageProviderResolverandrankingStrategyResolverexplicitly. - Every prompt this package sends is registered in
src/prompts/registry.ts, and the fourteen.mdfiles moved fromsrc/dialect/andsrc/providers/tosrc/prompts/dialect/andsrc/prompts/provider/. They were imported by relative path from the modules that used them, so the package could not say what text it puts in a model's system prompt without a glob, andveyyon prompt --promptslisted none of it. Each dialect definition now takes its guide from its registry row, anddialect-prompt-registry.test.tsasserts per dialect that the guide it ships is the row named for it and is no other dialect's, which is the failure a coverage check cannot see: handing the Gemma model GLM's syntax compiles, renders, and produces calls the scanner drops. - An OAuth refresh no longer trusts a credential row purely because it sits under the right id. The peer-rotation
check re-reads the row by a bare numeric id to see whether another process already rotated the token, and nothing
in that lookup said which provider the row belongs to:readAuthCredentialByIdis an optional method on the
credential-store interface, so the row comes from whichever store is plugged in, and the explicit-id insert paths
used by migration and import write ids chosen elsewhere. A row for a different provider is a live, unexpired OAuth
credential whose refresh token differs from the one held, which is exactly the shape the check is looking for, so
it would have been returned as your own rotated token and sent to the wrong provider. Such a row is now refused
and the refusal is logged with both provider names and the credential id, and with no part of either credential.
The shipped SQLite store usesAUTOINCREMENTand does not recycle ids on its own, so this is a check at the
store-interface boundary rather than a fix for a race in that store. - Three more pass-through wrappers are gone:
hermes,qwen3andpi-nativeeach declared a
renderToolResultswhose whole body forwarded torenderToolResponseResults, which is what the
genericxmldialect already referenced directly. A wrapper that adds nothing is a place a reader
has to visit to learn that nothing happens there. - Anthropic's
<invoke>tool-call syntax has one owner indialect/rendering.ts. Three dialects speak it (anthropic, the genericxml, andminimax, which wraps the same invokes in a tag of its own) and each had a byte-identical private copy of the invoke renderer, the invoke list, the single-call renderer and the transcript wrapper, with two of them also repeating the<function_results>block: one wire format written out three times. A change to the escaping or to the rule that emits a declared string argument verbatim would have left the other two dialects emitting a shape the model was never prompted for, and the symptom is not an error but a model that calls tools badly. Six dialects' pass-throughrenderThinkingwrappers and the three per-model turn delimiters, whichrendering.tsalready exported, went the same way. No output change: the shared renderers produce the same bytes, which the new suite asserts literally for all three dialects. - The reasoning-effort and service-tier guards come from the lists that own those values:
isEffortbesideTHINKING_EFFORTSin@veyyon/catalog, andisServiceTierbesideSERVICE_TIERSin this package, withServiceTiernow derived from the list instead of declared next to it. Both OpenAI-compatible servers hand-wrote the six effort levels and the five tiers as comparison chains, so adding a level to the ladder left every one of them silently rejecting it: a request naming the new effort was answered as if it had named none. TheirformatErrorwrappers, which only forwarded toformatOpenAiError, became re-exports. - The snapshot generation's entity-tag format has one owner,
auth-broker/generation-tag.ts, which both writes and reads it. The broker's client and server each had a private copy of the parser next to their own inline copy of the quoting, so one header format had four independent statements of itself, and both ends both write and read it. The failure mode is quiet either way: a tag the server cannot parse reads as no condition and returns a full snapshot the client already has, and a tag the client cannot parse leaves its generation unchanged so it asks again forever. See the Fixed note above for the defect the copies were hiding. - SigV4 signing and the auth-broker snapshot cache take their WebCrypto byte coercion from
asStrictBytesin@veyyon/utilsrather than each defining it. It decides whether aUint8Arrayhas to be copied beforecrypto.subtlereads it, andcrypto.subtlereads the whole backing buffer, so getting it wrong signs or decrypts bytes the caller did not name. Four packages had a private copy of the same three-line condition. No behaviour change. - Values that go on a provider's wire are declared once, in the catalog that owns them. Three had a second declaration in a package that consumes it, under the same name and with the same value, so the owner was being bypassed rather than read. Devin's IDE and extension versions are sent as request metadata by model discovery and by the chat provider, each from its own pair, so a bump would have left the two halves of one session identifying themselves as different clients. Antigravity's
fetchAvailableModelspath was spelled by discovery and by the usage reader; a path change would have 404ed whichever copy was not updated, and a usage reader that cannot reach its endpoint reports no quota information rather than a wrong URL. The Codex base URL is imported from the catalog by six modules and was respelled as a bare literal by web search, which would have kept posting to the old host under the user's real credentials after a move. - The placeholder an errored tool result carries when the tool produced no output is one sentence, and
packages/ai/src/types.tsowns it beside theToolResultMessagetype it belongs to. It was declared in the agent loop, which fills it in where an untyped tool result enters, and again in the Anthropic provider, which fills it in on the way to a wire that rejects an empty content array. Same event, same sentence, two copies: an edit to either produced a transcript wording one failure two ways depending on which layer noticed it first. - The credential-validation timeout and Google's Code Assist tier ids each have one owner.
VALIDATION_TIMEOUT_MSis exported fromregistry/api-key-validation.ts, and Xiaomi's regional key check imports it instead of holding its own budget under the same name, so the deadline that decides when a hung validation becomes a rejected key is one number.TIER_FREE,TIER_LEGACYandTIER_STANDARDmoved toregistry/oauth/google-oauth-shared.ts, the module that already exists for the two Google providers:legacy-tieris what both fall back to when a response names no tier, and a drift between their private copies would have put them on two different defaults while each file still read correctly on its own.
packages/catalog/test/a-wire-constant-has-one-declaration.test.tsscans everypackages/*/srcrather than one package, because a per-package lock is what all three slipped through: each copy was the only declaration in its own package. It asserts the real values, exactly one declaration per name across the workspace (a re-export or an import does not count, a secondconstdoes), that each consumer imports from the catalog, and for the Codex host the half a name-based lock cannot see, that nobody writeshttps://chatgpt.com/backend-apias a literal outside its owner. - The cancelled-request error is
RequestAbortError, notAbortError. It shared the name with two unrelated classes in@veyyon/utils(a cancelled operation and a killed child process), which made aninstanceofcheck read as a question about cancellation when it was really a question about which layer raised it.nameis still"AbortError"and the default message is still"Request was aborted", so name-based and text-based matchers, and the auth gateway's 499 classification, are unchanged.
Fixed
- Assistant-turn timing and effective request parameters now pass through the canonical session-telemetry policy at persistence time. A live downgrade to
offtherefore removes both families through the same closed policy as every other study field, and the inertanalytics-rolluppermission no longer claims to govern data no recorder emits. - Background OAuth refreshes and usage-report reads now cap aggregate credential concurrency instead
of opening one simultaneous provider request for every stored account. Large credential fleets
still attempt every eligible row, preserve report ordering, and retain per-credential
single-flight behavior. - OpenAI Responses and Chat Completions now preserve
developerfor agent-owned text-block context
instead of silently rewriting the array form asuser. Responses and Codex split image-bearing
developer context into developer text plus user image attachments, which keeps instruction
provenance without sendinginput_imagein a role those APIs reject. - Cancelling a request no longer turns into a crashed run.
resolveApiKeyOncechecked the abort signal
before it looked at the key at all, so an already-cancelled request threwsignal.reasoneven when
there was no key to resolve and nothing to cancel. Its caller in the agent loop resolves credentials
while preparing a request and then renders cancellation as an assistant message with
stopReason: "aborted"; the throw unwound past that, and a user's own interrupt surfaced as a bare
AbortError. A single resolve does no retrying and no waiting of its own, so it forwards the signal
to a resolver, which can cancel its own I/O, and leaves the decision about what a cancellation means
to whoever owns the signal.withAuthkeeps the check, because it owns a retry loop that would
otherwise keep minting credentials for a user who has left. - A cache that cannot be read or written now says so once instead of behaving like a permanently cold
cache. EveryAuthStoragecache method answered a database failure by acting as though the cache were
empty: a failed read is a miss, a failed write is a value not kept. That behaviour is right, since a
cache is an optimization and nothing should fail a request over it, but it was silent, so a read-only
or corruptauth.db, a full disk, or a locked file made every model-catalog and OAuth-metadata lookup
miss and every write vanish while the process looked merely slow. The first failure of each operation
now warns with the underlying error and later ones are debug, because a broken database fails on every
call and one warning per call would bury it. A faileddeleteCachePrefixis reported for a second
reason: it leaves stale rows the caller believes it invalidated. - The auth-gateway no longer reports a failed generation as a completed response.
encodeStreambuilds its terminal SSE frame from the stream's final assistant message, and when nodoneevent arrived it asked the stream for its result and swallowed a rejection tonull. Every reader after that treated the null as "there was no final message" rather than as an error, so the status becamecompleted, the output became whatever text had already streamed, and usage became null: a generation that failed partway through was announced to the client as a success carrying half an answer, which is the one failure a client cannot detect or retry. The path is reachable by design, since a stream that ends without a terminal value rejects with "Stream ended without a final result" -- what an upstream connection dropping mid-stream looks like from here. It now emitsresponse.failedwith the real reason, and the items that did stream stay attached so a truncated answer can still be inspected. An expliciterrorevent still fails with its own message, and a stream that delivers a final message still completes. - A GCE metadata server that refuses a token is reported.
fetchMetadataTokenanswered a refused status and "not running on GCE at all" with the sameundefined, and the error the caller raises offers "run on a GCE or Cloud Run instance with a service account" as one of three fixes, which is exactly the wrong advice for an instance whose metadata server answered 403. The status and URL are now warned where they are still known; not being on GCE stays silent, since that is every laptop. - Tool-call arguments that cannot be used are no longer dropped in silence. The DeepSeek, Harmony and Kimi dialects and the GitLab Duo provider each had their own copy of the same parse, and each answered a failure with an empty object, which is also what a call that takes no arguments produces. So a stream cut mid-arguments, or a model emitting a bare string or an array, ran the tool with nothing and nothing said so. There is one owner now, and it names the source, the tool, and an excerpt of what arrived; the empty object is still returned, because refusing the call belongs to the tool's own argument validation.
- Usage history that cannot be read is now reported instead of appearing as usage you never had. Both the history and the cost queries answered any database failure with an empty list, so an unreadable database presented as a clean slate and the cost totals read as zero.
- An empty entity tag on a broker snapshot request read as generation 0 instead of as no generation.
Number("")is 0, soIf-None-Match: "", or a header an intermediary blanked, matched a store that was still at its first generation: with?wait=set, the broker then long-polled for up to 30 seconds waiting for a change instead of immediately serving the snapshot the client did not have, on every poll. The same hole was in the client's reading of the responseETag, where a blanked tag reset its generation to 0 and made it re-download the snapshot it already held. An empty or whitespace-only tag is now no generation, which falls back to sending the snapshot in full. Found while collapsing the two copies of the parser below. - Auth Gateway Models: Fixed
/v1/modelsendpoint returning ambiguous bare model IDs when multiple providers register the same model name. Model IDs are now correctly advertised with theirprovider/prefix (e.g.,anthropic/shared-model) and duplicate entries from the resolver map are deduplicated.
argot
Added
GeneratedDict.breakEvenTurnssays how many turns a dictionary has to survive before it pays for itself.estimatedSavingsalone reads as free money and is not: the dictionary is INPUT, carried on every turn of the session, while the savings are OUTPUT produced once per emission. A dictionary that saves 3,202 output tokens while carrying 2.4M input tokens across a session is a 751:1 loss, and nothing in the old result said so.breakEvenTurnsdivides the two, priced atDEFAULT_OUTPUT_TO_INPUT_PRICE_RATIO, and isInfinitywhen the dictionary is empty.DEFAULT_TOOL_CALL_STRUCTURE_SHAREandDEFAULT_OUTPUT_TO_INPUT_PRICE_RATIOare exported, so a host that prices its own traffic can see what the defaults stand for instead of rediscovering them.
Changed
StreamDecoderandArgotSessionuse ES#private fields instead of theprivatekeyword,
which TypeScript erases and so never actually hid anything at runtime.fork()reaches a sibling
instance's fields throughcopy.#entries, which is the spelling a private name needs when the
receiver is another object of the same class. No public API changed.- A dictionary entry is now priced in the channel it is actually emitted in, which changes what the generator selects. Line structure (a newline plus its indentation) costs about one token in a plain message, but a tool call carries its arguments as JSON, where the same run arrives escaped as
\+nand each tab costs an escape of its own. The two prices differ by enough to flip whether a structure run is worth a handle at all, soemittedTokenCostblends them atDEFAULT_TOOL_CALL_STRUCTURE_SHARE, the measured share of structure runs emitted inside tool-call arguments: 41.76%, over 307 transcripts and 23,467 assistant turns. PasstoolCallStructureSharetogenerateif your own harness splits differently. GENERATOR_REVISIONis 3. It is part of the cache key, so the first run after upgrading regenerates every cached dictionary rather than serving one selected under the old prices.
@veyyon/catalog
Added
DIALECTSis exported andDialectis derived from it. The union was the only statement of the set, so nothing could enumerate dialects at runtime and a check that wanted to ask whether every dialect ships a format guide had to write the twelve names out a second time.fetchOpenAICompatibleModelstakes anonFailurecallback and calls it with anOpenAICompatibleDiscoveryFailurebefore returningnull. Discovery answered a refused connection, a 401, an HTML error page and an unrecognized payload with the same barenull, and the caller that keeps per-provider discovery state only reported a reason when discovery THREW, so a model you pay for disappeared from the picker with nothing anywhere explaining it. The reason travels back as a value rather than a log line, because no source file in this package logs and its callers already own the state they report from;stageseparates the three fixes an operator would reach for, sincerequestpoints at the network,statusat credentials, andpayloadat whether the endpoint is OpenAI-compatible at all. An empty catalog is still[]and still silent.- Every discovery reader takes the same
onFailure, andcreateModelManagertakesonDiscoveryFailureand passes hooks to yourfetchDynamicModels.fetchOpenAICompatibleModelscould report a reason but nothing carried one across the manager boundary, and the Codex, Cursor, Devin, Gemini, Antigravity and GitLab Duo readers had no channel at all: they returned a barenull. Each lost the reason in a way that mattered. Codex walks two routes and Antigravity walks its fallback endpoints, and bothcontinued past every failure, so an expired token and a retired route ended in the samenullnaming neither attempt. Cursor speaks HTTP/2 directly and had five separate ways to answernullwith nothing recorded, including the timeout and a non-2xx status. Gemini paginates, so a failure on a later page looked like a rejected key. GitLab Duo reaches a model list through a namespace lookup, a project lookup, a paginated group walk and two GraphQL queries, every one of them silent. Readers that try several endpoints report every attempt, so onenullcan carry several reasons, and a reason can be followed by a success when a later attempt works. A successful catalog reports nothing, including a success that lists no models. Gemini passes its key in the query string, so the reported URL is the keyless one. DEVIN_SESSION_TOKEN_PREFIXandnormalizeDevinSessionTokenare exported, and@veyyon/ai's Devin provider takes them from here instead of spelling both again. Two packages send that header, so one format had four statements across a package boundary; a disagreement would let model discovery authenticate while every completion 401s, which reads like a broken account rather than a mismatched header.matchesKimiK27CodeFamilyandhasBillableCosteach have one home. The Kimi K2.7 Code family test lived in both compat layers, id pattern and match, with the second copy documented as mirroring the first: one model-identity rule stated four times, and a drift between them would force thinking on only for whichever transport handled the request.hasBillableCostlived in the model generator and again in@veyyon/stats, where it decides whether to trust a bundled price, so two functions that only happened to agree were deciding money a user reads. Note what it does not answer: an all-zero cost cannot tell a free model from an unpriced one, which is whatcostKnownis for.- Added
isEffort, the guard for a thinking level, beside theTHINKING_EFFORTSlist that owns the values. Callers were spelling the six levels out again in comparison chains, which meant adding a level to the ladder left them rejecting it while the type system accepted it.
Changed
discovery/devin.tsexportsDEVIN_IDE_VERSIONandDEVIN_EXTENSION_VERSION, anddiscovery/antigravity.tsexportsFETCH_AVAILABLE_MODELS_PATH. All three go on the wire and all three had a second declaration in@veyyon/ai, so the catalog was being bypassed rather than read. Devin's two versions are request metadata sent by model discovery here and by the chat provider there, and the two halves of one session identifying themselves as different client builds is the kind of mismatch a provider notices before we do. The Antigravity path is spelled by discovery here and by the usage reader there, and a usage reader that 404s reports no quota information rather than a wrong URL.provider-endpoints.tsalso ownsOPENROUTER_API_ENDPOINT. The host was declared four times across two packages under three names, and it carries a/v1path segment, so an API version bump had four declarations to find.@veyyon/mnemopiheld three of them, and its embedding path and its extraction path pointing at different versions of the same host is a mismatch that shows up only as a request the endpoint rejects.wire/anthropic.tsownsANTHROPIC_WEB_SEARCH_TOOL, the server-side tool name that the search provider in@veyyon/coding-agentasks for and the Anthropic provider in@veyyon/aimatches in the response. A drift between them is a miss rather than an error: the search runs, the results come back, and nothing renders them.provider-endpoints.tsalso owns the Gemini developer API base, Anthropic's official host and Cursor's API host, each of which had a name per package. The Anthropic one is read for two jobs that must agree: it is the fallback base URL, and it is whatcompat/anthropic.tscompares a configured base URL against to decide whether it is talking to Anthropic itself, a check that is exact rather than a prefix test so a lookalike host cannot pass.wire/google-oauth.tsowns Google's OAuth authorize and token endpoints and the scopes both sign-in flows request. Three modules each had copies: the token endpoint appeared three times under two names, the authorize endpoint twice, and the cloud-platform scope three times. A wrong endpoint fails at once, but a wrong scope succeeds and the token simply lacks the permission, so the failure arrives later as a 403 naming the API rather than the scope that was never granted.wire/perplexity.tsowns the client identity veyyon presents to Perplexity's consumer endpoints: the web origin, the macOS bundle id, the app User-Agent, its API version, the request header names, and the header pair that says "I am the macOS app". Two packages are two halves of one Perplexity session,@veyyon/aimints the JWT and@veyyon/coding-agentspends it, and each had declared the identity itself under its own names. A mismatch between them is not an error: the ask endpoint answers 200 and serves the anonymous freeturbomodel regardless ofmodel_preference, so a Pro account gets free-tier answers with nothing saying why.wire/codex.tsowns the Codex JWT claim namespaces and the reader for them:CODEX_JWT_AUTH_CLAIM,CODEX_JWT_PROFILE_CLAIM,readCodexTokenIdentity,readCodexClaimsFromPayload,getCodexAccountIdandgetCodexAccountEmail. Five modules across three packages each hand-rolled "decode a ChatGPT OAuth token and pullchatgpt_account_idout of it", under three names for the auth claim plus a bare literal. A claim namespace is a lookup key, so a copy that drifts returnsundefinedand a valid token reads as one that carries no account rather than as an error. The empty-claim rule now has one statement: an empty or whitespace-only claim is reported as absent, because the account id becomes thechatgpt-account-idheader and an empty header value makes the backend answer a malformed-account error instead of using the token's own account.JWT_CLAIM_PATHremains as an alias ofCODEX_JWT_AUTH_CLAIM.- Devin's three hosts have three names in
provider-endpoints.ts:DEVIN_CASCADE_ENDPOINTfor the Cascade chat API,DEVIN_AUTH_ENDPOINTfor the token API, andDEVIN_WEBAPP_URLfor the login-approval page. Two of them were previously declared asDEVIN_API_URL, the chat host in@veyyon/ai's provider (exported) and the token host in its sibling OAuth flow, so anything reaching for "the Devin API URL" to authenticate would have got the chat host and failed against an endpoint that serves no tokens. The chat host had a third declaration here underDEVIN_DEFAULT_BASE_URL. - The token limits assumed for an agent gateway that does not publish its own live in
discovery/default-limits.tsasAGENT_GATEWAY_DEFAULT_CONTEXT_WINDOWandAGENT_GATEWAY_DEFAULT_MAX_TOKENS. Antigravity, Cursor and Devin each declared the same 200_000 / 64_000 pair, which is one decision restated three times: all three proxy Claude-class models and report their limits unreliably.codex.tsdeclared 272_000 / 128_000 under the SAME two names, so one name meant two values in one directory, and these numbers drive auto-compaction and the context panel. Codex's pair is now provider-prefixed. GitLab Duo Workflow keeps its own 200_000 on purpose, because its value has an independent reason recorded beside it. src/provider-endpoints.tsis the one place a provider base URL the code decides is written, andprovider-models/google.tsanddiscovery/gitlab-duo-workflow.tsread it. Google's Cloud Code host was declared in six modules under four names, the Antigravity daily host in six more under four names plus one bare literal inside a settings switch, and the ordered[daily, sandbox]fallback pair in four, once with the sandbox host inline beside its own named constant so a host rotation would have updated the name and missed the literal.https://gitlab.comwas in five modules across two packages under three names, which is worse than three copies of one name: a grep for any of the three finds nothing, so a reader cannot tell the value is shared. This package already exported two of the hosts fromdiscovery/antigravity.ts, but reaching that export costs arktype and the whole discovery machinery, which is exactly why the string kept being retyped instead of imported. The new module has NO imports, so taking a host from it costs one module, anddiscovery/antigravity.tsre-exports its two former names unchanged.- Every pure helper this package uses comes from the module that owns it rather than from the
@veyyon/utilsbarrel:errorMessageandisRecordfrom@veyyon/utils/type-guards,trimTrailingSlashesandnormalizeBaseUrlfrom/url,oncefrom/abortable,fetchWithRetryfrom/fetch-retry,wrapFetchForExtraCafrom/tls-fetch,decodeJwtPayloadfrom/jwt. Eleven files each took one or two names and paid 82 modules for them, which put the whole barrel on the graph of anything that read the provider table.provider-modelsis 62 modules instead of 118 and this package's barrel is 128 instead of 186. Nothing about the exports changed.
Fixed
fetchGitLabDuoWorkflowModelsanswersnullwhen no candidate namespace exposes Duo models, instead of throwing. It was the only reader that threw, and a throw reaches a manager's catch where it is labelledunhandled, which claims a bug in the reader when the real answer is that the token sees no namespace with Duo access. The sentence naming which env var to set now arrives as the reported reason.discoverGitLabDuoWorkflowRuntimeNamespacestill throws, because a runtime that cannot resolve a namespace has to stop.- Every step of the GitLab Duo handshake now makes its request through one function rather than spelling out its own
try/if (!response.ok)/tryaroundresponse.json(). Four copies of the same three-way decision meant four places for it to drift and four places a reason had to be added. - Fixed GPT-5.6 Codex SKUs (
gpt-5.6-{sol,terra,luna}) losing ~75K of usable context when the Codex discovery endpoint actively reportscontext_window: 272000: discovery now floors these SKUs at the 372K hard capacity instead of only substituting it when the field is absent, so the runtime dynamic value no longer overwrites the bundled pin (#6259).
Removed
- Removed
remoteCompactionfrom model and provider metadata, along with the Codex discovery constant that set it. It configured provider-native compaction, which no longer exists, so nothing has read it for some time while it was still declared on every model and shipped inmodels.json.
@veyyon/coding-agent
Added
- Expanded
session.instrumentationinto a complete session-study record.basicadds lifecycle checkpoints, task transitions, tool and model timing, and effective model request parameters;richadds context attribution, directional agent-message delivery, result weight, and model throughput;ultraadds compaction links, per-task transitions, routes, fingerprints, and provider provenance.veyyon session statsreports each available family.offadds no telemetry but still stores the normal conversation and tool history required to resume. - First-run setup now includes a Choose subagents step. Only the general
task
worker starts enabled; bundled specialists and user or project agent definitions
require an explicit grant there or in Settings → Subagents → Agents. Delegation
guidance now preserves each concrete agent role, usestaskonly as the
general-purpose fallback, keeps unmatched specialist work in the main session,
and collapses homogeneous triage fan-outs into one retrieval and classification operation.
The classifier uses the shared Unicode alphanumeric matcher, so non-ASCII labels follow the
same token boundaries as the rest of the CLI. - Auto QA can upload grievances to
https://veyyon.dev/api/grievances, where a Cloudflare Pages
Function validates the batch and stores it in D1. Upload is controlled by
Auto-upload Grievances in each profile and defaults to off. Local recording remains separate,
andveyyon grievances pushperforms one explicit upload without changing the toggle. - The Subagents HUD, the
/agentsroster, and the inline task widget now show the reasoning effort each agent is actually running at, including an effort it inherited. Previously the effort appeared only when a:levelsuffix had been typed into the model pattern, so every stock agent rendered as a bare model id and two agents running at different efforts looked identical. /secret rmand/secret extendcomplete the names of the credentials you have stored, so you no
longer have to recall an exact name with nothing on screen to recognise it by. That is a worse
position than any other command's arguments put you in, because the whole point of a stored secret
is that its value is never displayed, and a mistyped name is a silent no-op rather than something
the surface can correct:/secret listwas the only way to recover a name. The names come from the
running obfuscator rather than the vault on disk, because the vault means file I/O plus a decrypt on
every keystroke andload()throws on a malformed or key-missing vault, which would turn a bad
vault into a dropdown that crashes as you type.extendcompletes toextend NAMEwith the cursor
ready for--ttlwhilermcompletes to a finished command, read off each subcommand's declared
usage rather than namingextenda second time in the completion code.addis deliberately left
out, since the name you give it is one you are inventing and offering existing names there would
read as a list of things to overwrite. No secret VALUE reaches the dropdown in any field.- The model is told which credentials it can spend, in an
AVAILABLE SECRETSsection rebuilt from the
live secret runtime every time the base system prompt is built. Storing a secret told the model
about it in that turn and only that turn, so a session started the next day hadGITHUB_TOKEN
active and obfuscating while the model had no way to know it existed. Rebuilding from the runtime
rather than remembering from the conversation also fixes revocation and expiry structurally: a name
the runtime stops returning simply stops being rendered. Names only, sorted so the bytes are stable
for prompt caching, and the section is absent rather than empty when protection is off or nothing is
stored. - Both installers answer
--help(-Helpon Windows) with their option list.sh install.sh --helpused to printUnknown option: --helpand exit 1, and an unknown option printed the complaint and nothing else. The options were documented in a comment at the top of each script, which is precisely what an install run ascurl … | shorirm … | iexnever shows anyone: there was no way to discover--source,--ref,--localorVEYYON_INSTALL_DIRshort of opening the raw file on GitHub. Each script now has one usage printer, its header points at that printer rather than carrying a second list to go stale, and an unknown option prints the list on stderr alongside the complaint.scripts/installer-help-parity.test.tsruns the POSIX one for real and pins that both installers offer the same six options under their two spellings. argot.autoloaddecides whether the project you launched in is loaded for the session, or every
load is left to the agent'sargot_loadcalls. The startup load already existed and was
unconditional, and the handbook described the opposite behaviour ("veyyon does not guess which
project you mean: the agent decides"), so an operator could not predict whether their repository
would be walked as the session came up, and had no way to say no. The default istrue, which is
the behaviour that shipped. The decision has one owner,shouldAutoloadArgotAtStartup, rather than
the conditions spelled out inline at the SDK's call site, so a second startup path cannot honour
the setting on one route and ignore it on another. It changes WHEN a dictionary is built and
nothing else: the codec is still built, the model still getsargot_loadandargot_unload, and
expansion stays unconditional, so a handle written after an agent-driven load still expands to
exact bytes.veyyon prompt --statementsprints what each individual rule of the system prompt costs, with the
condition that decides whether it is in this prompt at all, and lists every rule this configuration
leaves out. The section breakdown could not answer the question an operator actually has: TOOL
POLICY is one row of it and 9KB of prompt, so the answer was "tool policy is large". The cost is
MARGINAL, meaning what the prompt would be shorter by without the rule rather than the length of the
rule's text, becauserenderends in aformatpass that normalizes whitespace across statement
boundaries and text lengths would therefore produce a breakdown whose parts exceed the whole. The
parts reconcile exactly instead: section bytes equal the banner plus the sum of the statement bytes
plus the one separator newline, measured and pinned rather than argued.veyyon prompt --statement <id>prints one rule's rendered text, which is the counterpart to
--sectionat the granularity a rule has and the next thing anyone wants after seeing a row in the
cost table they do not recognise. Rendered rather than the template behind it, so an interpolated rule
such as the personality block shows what the model receives. A rule that is not in this prompt reports
the condition that would include it and why it exists, and still exits 0, because a rule being off is
a configuration and not a failure; an unknown id exits non-zero and quotes the ids of the section it
named, since an empty stdout reads as an empty rule rather than as a typo. The printed text weighs
exactly what--statementscharges the rule, asserted, so the two surfaces cannot disagree about the
same rule.- The bench can run a per-rule prompt experiment.
VEYYON_EVAL_SYSTEM_PROMPT_STATEMENTShad no arm
vehicle when it landed, so the mechanism built for the harness could not be used by it: an operator
would have had to set the variable outside the runner, where the single-IV guard cannot see it and two
different ablation arms fingerprint identically. An arm now carriesarms/<arm>.statements.yml,
validated before the run (unknown statement id, a value that is neither text nornull, malformed
YAML), staged asstatements/<arm>.json, folded into the arm fingerprint, and mounted into the
container the same scoped way the section override is.arms/candidate-ablate-delegation-gates.*is
the worked example, checked through the builder's own validator so it is known to load.
The fingerprint folds the new field in only when it says something, so arms without one keep the
fingerprints already recorded in past results and a longitudinal diff does not report every arm as
changed; an EMPTY override canonicalizes to{}and counts as absent, so an arm cannot pass the
single-IV guard by carrying an empty file. VEYYON_EVAL_SYSTEM_PROMPT_STATEMENTSreplaces or removes ONE rule of the system prompt, which is
what makes an eval able to attribute a score change to a rule instead of to a section. A JSON object
of statement id to replacement text, or tonullto ablate the rule. Same instrument as the
per-section override, one level finer, and deliberately the same shape: environment variable only,
no config key and no CLI flag, because a config-reachable prompt override could silently contaminate
a production run and a contaminated eval reports a number that looks valid.nulland""are
different operations and both are pinned:nullremoves the row and the separation it carries,""
keeps the row present and drops only its words. Every way an override could do nothing is refused
loudly rather than ignored, including an unknown statement id, a value that is neither text nor
null, and malformed JSON. An override cannot resurrect a rule whose condition is false, since the
condition decides presence and the override decides text.system-prompt-builder/gate-registry.tslists every setting that changes the system prompt:
the setting path, the template variables it decides, what the model sees change, and whether a
mid-session flip reaches it. A settings-fed gate used to be declared in up to six places that
had to agree, and the one that failed quietly was the rebuild trigger. Frozen gates now say
why they are frozen, and the two reasons are kept apart, because "fixed at session start on
purpose" and "fixed because the read sits above the builder" call for different fixes.- Every package that ships prompts now has a prompt registry, and
veyyon prompt --promptslists all of them. Two packages had none:@veyyon/aishipped fourteen prompts (a tool-call format guide per dialect, plus the tool-catalog template) next to the fourteen modules that imported them by relative path, and@veyyon/metaharnessshipped the edit benchmark's three. That text goes into a model's system prompt, so "which prompts does veyyon send" had an answer that was short by seventeen, and the inspection command listed none of them. Prompts moved topackages/ai/src/prompts/andpackages/metaharness/adapters/edit/prompts/, each with a registry beside them where the import is the registration.veyyon prompt --promptsnow lists every id from all three product registries grouped by directory, andveyyon prompt --prompt <id>looks a prompt up in whichever one holds it, sodialect/gemmaandcompaction/summarization-systemwork like any coding-agent id. The benchmark harness's prompts stay out of the listing: they are asked by a measurement tool, not by the agent. - The auto-compaction threshold is now a two-level picker in
/settings: Auto-Compaction Threshold opens to three modes (Auto, Percent, Tokens) with a green check and the current amount on the active one, and each mode drills into its own presets plus a Custom entry. The flat list it replaces mixed all 19 auto/percent/token options in one list, so the three semantics were invisible until you read every description, and a hand-edited value like170000showed as nothing selected. Custom values are validated and normalized on entry (92stores as92%,170_000as170000), and a stored value the parser cannot read is shown as a warning with Auto in effect instead of presenting Auto as your choice. The stored value is unchanged (auto,85%,200000), so existing configs, the legacythresholdTokens/thresholdPercentfold-in, and the clamp warnings all keep working. - Added an Experimental settings tab: every experimental feature now lives in one place — Argot shorthand (five settings, moved from the Context tab's Experimental group), Tool Calling Mode, and Auto-Learn (moved from the Memory tab). The tab's name says "experimental" for everything on it, so labels no longer need an "(experimental)" suffix and the features stop pretending to be regular settings on three different tabs.
update: confirm 'Checksum verified' on a successful self-update.release: derive commit-history notes + gate the generator on CI.
Changed
/secret addwith no name now asks what to call the secret in a visible field before the masked one opens. One masked prompt had to carry both questions in its wording, and the wording lost: "Paste the secret" was read as "name the secret", and the name was stored as the credential under an inventedSECRET_1. Nothing downstream can catch that, because a name is a perfectly well-formed secret value and a shape check would refuse real credentials. The new field is optional, so leaving it empty still generates a name for you, and an unusable name fails before the masked field opens rather than after you have pasted a live credential.- Subagent nesting now defaults to parent-only spawning. Your main session can still spawn direct
subagents, but those children do not receive thetasktool unless you raise
subagent.maxNestedSpawnDepth. You can override the blanket limit for one agent through
subagent.agents.<name>.maxNestedSpawnDepthor the Agents settings editor;-1remains unlimited.
ExistingmaxRecursionDepthvalues migrate to the equivalent nested-depth policy. tui.scrollIsolationnow defaults to OFF. While it is on, veyyon holds the mouse in order to read
wheel events, which takes drag-to-select away from your terminal: selecting text becomes shift+drag,
or/copyto pick text and code out of the conversation without the mouse at all. That trade may be
worth making deliberately, but it was being made for everyone by default, and breaking the most
ordinary thing a terminal does is an opt-in rather than a default. With it off, the wheel, native
scrollback, drag-select and copy all belong to your terminal again, and the prompt still sits at the
bottom of the live view. Turn it back on in/settingsunder Appearance, Display, or with
veyyon config set tui.scrollIsolation true; nothing about its behaviour changed when it is on./secret listrenders as an aligned table with a header, wide-character-safe column widths, and a
status column that appears only when something is close to expiring. The near-expiry threshold now
has one owner shared with the warning sentences, so the marker in the list and the warning below it
cannot disagree.- The swallowed-drag hint, the
tui.scrollIsolationdescription and the gated tip no longer promise
that the mouse comes back on its own. A hold that released after a few seconds of quiet was tried
and removed: it unpinned the composer at unpredictable moments and made whether a plain drag
selected anything depend on how recently you had typed. The wording outlived the behaviour, which
is worse than saying nothing, because it sent you off to wait for a handback that never arrives.
All three now state plainly that veyyon holds the mouse while the setting is on and name the three
answers that actually work: shift+drag,/copy, and turning the setting off. - The bounded JSON walk moved out of the secret obfuscator into
src/json-transform.ts.
mapJsonStringsrewrites every string in a JSON value, keys included, and three callers want
three different rewrites: the obfuscator's placeholders, the argot token dictionary, and whatever
transform the session applies at the outbound provider seam. Only the first is about secrets, but
it lived insecrets/obfuscator.ts, which reaches 65 modules including an 18-module JSON Schema
validator (the obfuscator redacts tool schemas). Soprovider-boundary.tsimported one function
and got all of it, and since every module that can make an outbound request reaches that seam, so
did they: reading a local file loaded a schema validator. The walk now reaches two modules,
provider-boundary.tsreaches three where it reached 66, andtools/read.tsis 24 modules
lighter. Import it from@veyyon/coding-agent/json-transform; the obfuscator re-exports the same
function, so nothing that already worked stops working. veyyon -pstarts without loading the slash-command handlers. Text and ACP mode dispatch every
message throughexecuteAcpBuiltinSlashCommand, and that function imported the builtin registry
statically: 740 modules of handlers, and behind them the settings store, the MCP client and the
session store. Almost every message is a prompt rather than a command, so a plain
veyyon -p "hello"paid for the entire command surface to discover the text had no slash in it.
The registry loads inside the function now, after the parse has already said the text is a command.
A command still runs exactly as it did; what changed is when the handlers arrive. Print mode reaches
227 modules where it reached 960.- The MCP HTTP transport uses the shared
isRecordinstead of spelling the same three-clause check
out inline, andcommit/{shared-llm,changelog/generate,analysis/summary}.tsand
secrets/obfuscator.tsimportcompleteSimple,validateToolCallandtoolWireSchemafrom the
modules that declare them rather than from the@veyyon/aientry point. That entry point
re-exports the whole package, so taking one function from it costs 363 modules;
commit/shared-llm.tsreaches 184 where it reached 325. - Context accounting and the turn-budget directive moved out of the terminal UI. Both lived under
modes/because the surfaces that display them do, and the session engine imported them from
there, which is the wrong direction: the layering gate had to carry a standing exception for each.
parseTurnBudgetis atsession/turn-budget.tsand the token accounting is at
session/context-usage.ts. The/contextgrid stayed where it was, in
modes/utils/context-usage.ts, and imports only the shapes from the accounting module.
The category rows dropped their colour and glyph in the move. The panel owns that table now, keyed
on the category id, so the numbers carry no palette and another surface can report the same figures
without inheriting the grid's colours. Callers importingcomputeContextBreakdown,
computeNonMessageTokens,computeNonMessageBreakdown,computeStoredMessagesTokens,
estimateSkillsTokensorestimateToolSchemaTokensfrom
@veyyon/coding-agent/modes/utils/context-usageshould import them from
@veyyon/coding-agent/session/context-usage;renderContextUsagestays where it was. tools/may import the terminal UI only to draw, and only through named leaves. Unlike the session
engine a tool renders its own output block, so it cannot be forbidden the UI outright, and that
partial permission is how the boundary rots: thirty-two files undertools/import frommodes/,
each one obviously fine on its own. A gate now lists the ten modules they may reach and what each
is for, so an eleventh is a decision someone writes down rather than an import that slips in.- The Agent Control Center sizes itself to the roster. It used to take the whole terminal whatever was
in it, so a run with four agents drew four rows and then about twenty rows of empty bordered card
over the transcript you opened it to look past. It keeps room for eight rows so it does not resize
on every spawn, grows with the roster, and still takes the viewport and no more when the roster is
larger than the screen. The Comms stream keeps the full height, because a feed that resized its own
frame as messages arrived would be worse than the space it saves. With no agents running it also
stops offering the three keys that act on a selected row, since there is no row to select. - Every place that tells you which key expands a folded block now reads the key you have. Nine
surfaces wrotectrl+oout as a literal, so rebindingapp.tools.expandleft them naming a key
that no longer expands anything: the Agent Control Center's Comms chip and fold line, the
rule-injection notice, the shared execution footer, the bash block, and bothsshoutput hints.
The line count they carry is unchanged, and is still shown when the action is bound to nothing. - The hook editor's footer reads its chords too, and it now names both submit chords. It said
enter or ctrl+q submit, whileapp.message.followUpships asctrl+qandctrl+enterand the
handler has always accepted either, so a chord that really submits was missing from the row that
lists them. config/settings.tsstopped dragging the whole of@veyyon/ai. It is the most imported module in
the package (528 test files, and every runtime consumer ofSettings) and it reached 380 modules, 228
of them that package: the streaming engine, every provider transport, the model registry, the error
taxonomy. Three imports carried it, each naming a barrel or a re-export instead of the module that
owns the value: the in-flight caps setter came from@veyyon/ai/streamrather than from the caps
themselves,THINKING_EFFORTScame from the@veyyon/aibarrel though@veyyon/catalog/effortowns
it and imports nothing, and the sqlite credential store came through the barrel (345 modules) rather
than from@veyyon/ai/auth-storage(212), which defines it. Now 250, withconfig/settings-schema.ts
down from 371 to 106 andthinking.tsfrom 346 to 6. Nothing about behaviour changes; what changes is
that reading a setting no longer instantiates the streaming stack. Neither existing architecture gate
could see any of this, because both walk without resolving workspace packages and read this file as 36
modules, so the cut is held by a new gate that resolves them.
Then 125, oncepackages/aisplit the sqlite credential store out of the module that also owns the
OAuth machinery.session/agent-storage.tswanted the store and nothing else, so it now names
@veyyon/ai/auth-storage-sqlite(83 modules) and@veyyon/ai/auth-credential-rows(75), and takes
the credential types from@veyyon/ai/auth-storageas types, which are erased. It fell from 213 to 84,
and that carriedsession/session-manager.tsfrom 482 to 369 andsession/session-context.tsfrom 472
to 359.session/auth-storage.tsstayed at 215 and that is not slack: it forwardsAuthStorage
itself, and that class is the OAuth machinery.- Reading a local file no longer loads the MCP client, the skill loader or the memory consolidator.
tools/read.tsreached 972 modules through five hops, and each hop was a process-global slot or a pure
function living inside the heavy module that fills it.internal-urls/mcp-protocol.tsusedMCPManager
as a type everywhere except oneMCPManager.instance(), so reading a static slot cost the MCP client
and its transports;internal-urls/skill-protocol.tsreads the active-skill snapshot from inside the
skill loader;internal-urls/memory-protocol.tswantedgetMemoryRoot, a two-line path join, from the
module that asks a model to summarise a session; andtui/status-line.tswanted one status glyph from
the tool renderer.
Four modules now own those four things and import nothing:mcp/manager-instance.ts,
extensibility/active-skills.ts,memories/paths.tsandtools/tool-ui-status.ts. Each is re-exported
from where it used to live, soMCPManager.instance(),getActiveSkills(),getMemoryRootand
formatStatusIconall keep working from their old import paths. An empty slot still means what it
meant, and still says so: themcp://handler reports "No MCP manager available. MCP servers may not be
configured." with the available resources, and theskill://handler names the active skills. Measured
after:read736,internal-urls419 (was 911),tui/hyperlink182 (was 609),tui/status-line2
(was 168), and the three protocol handlers 76, 79 and 89 where they were 871, 369 and 571. - The session layer stopped carrying the prompt registry and the tool layer.
session/messages.ts
reached 356 modules, and 261 of them came through two imports that had nothing to do with message
shapes.PROMPTScame fromprompts/registry.ts, which imports all 143 prompt files by design, for
one interjection template; andformatOutputNoticecame fromtools/output-meta.ts, which owns the
fluent builder, the tool wrapper and the spill configuration on top of the notice text, and therefore
reaches settings, the streaming output sink and the artifact store.
wrapSteeringForModelnow lives insession/steering-envelope.ts, the module that renders the prompt,
and the notice wording, the metadata types and the three strippers live intools/output-notice.ts.
tools/output-meta.tsre-exports all of them, so no caller changed there;wrapSteeringForModelmoved
import path for its three callers.session/messages.tsis 100 modules,
session/session-context.ts107 (was 602) andsession/session-manager.ts155 (was 612), the last of
which 206 test files import.
The strippers moved WITH the wording on purpose.stripOutputNoticeremoves a notice by rebuilding it
and matching the tail of the text, so the writer and the remover are one contract: wording that
changed in one and not the other would leave the notice visible twice, once in the message body and
once as the styled warning. - Asking the theme engine for a colour no longer loads an ASCII diagram renderer.
modes/theme/theme.tsis the second most imported module in the package (291 test files, and every
component that paints) and it reached 307 modules. Thirty-six of them were mermaid:getMarkdownTheme
lived there, and it binds a diagram renderer to the palette, so every consumer of a colour paid for
the renderer whether or not anything on screen was a diagram. Nothing here was a barrel import, which
is why the earlier sweep did not find it: the function was simply in the wrong module.
getMarkdownThemeandsetMarkdownMermaidRenderingnow live inmodes/theme/markdown-theme.ts, and
the memoised native highlighter both sides need lives inmodes/theme/highlight.ts(17 modules,
takingloggeranderrorMessagefrom the modules that own them rather than the@veyyon/utils
barrel).theme.tsis 272 and still re-exportshighlightCode, so that caller set did not change;
it deliberately does not re-exportgetMarkdownTheme, because forwarding it would put the same 36
modules straight back.markdownMermaidRendering's test-reset hook moved with it, so the module that
owns the state owns its restore, and a suite that never loads the markdown adapter has no such state
to restore. - The same import mistake was found in twenty-six more places and the rule is now written down rather
than counted. A value defined in a cheap module gets imported through the@veyyon/aibarrel because
the barrel re-exports it and that is the first completion an editor offers; the names are identical
either way, so nothing ever fails.assistantText,assistantTextBlocksandinstrumentationRank
are each defined in a module that reaches exactly one, against the barrel's 346, so
modes/utils/copy-targets.ts,hindsight/transcript.tsandcli/session-stats.tseach fell from
about 347 modules to 76 on one line;task/agents.tswent 520 to 253 and
modes/components/settings-selector.ts783 to 655. Twelve of the fixes did not change their own
file's number, because those files also importcompleteSimpleorstreamSimpleand genuinely want
the streaming engine, and they were made anyway: a file whose graph is large for a good reason is not
a licence to name the wrong owner, and the day the expensive import moves out the wrong one is still
there. The gate holds it as a table of value, owner and the owner's reach, so a new entry costs one
line instead of a new ceiling. Type imports are out of scope on purpose, since they are erased. argot.modelsandargot.disableAboveTokensare nowargot.encode.modelsand
argot.encode.disableAboveTokens. Those two are the only Argot settings that decide whether a model
is taught to WRITE shorthand;enabled,autoload,tokenBudgetandsubagentsdecide whether the
feature runs, when a dictionary is built, how many tokens it may spend, and what a subagent starts
with. Flat, all six read as peers, and nothing in the names said that emptying the allowlist stops
the teaching while expansion carries on regardless, which is the distinction you need to predict what
turning it off does. Existing configs need no edit: both keys migrate underencodethe first time
the file is read, in either the nested or the dotted spelling, and the retired key is dropped the
next time the file is saved. A config carrying both spellings keeps theencodevalue and discards
the old one without reading it, so the result never depends on which key is visited first.- The two gate test suites stopped describing the prompt through a document no session reads.
prompt-gate-registry.test.tspartitioned every gate variable it could find by regular expression
oversystem-prompt.md; it now reads the statement rows and the statement text, which is what
reaches the model. That also closed a silent hole in the old check: the expression matched{{#if}},
{{#unless}},{{#each}},{{#ifAny}}and{{#has}}, so{{#when MAX_CONCURRENCY ">" 0}}was a
gate it could not see andsubagent.maxConcurrencywas partitioned over a set that omitted the one
variable it gates. A row's condition names its variable structurally, so that hole cannot exist on
this side, and the cross-check is now exact identifier membership rather than a substring match that
would accept{{#if renderMermaidSomethingElse}}as evidence for a row claimingrenderMermaid. prompt-gate-inputs.test.tsasserts which text each gate moves, instead of that 76KB of prompt
differs.expect(flipped).not.toBe(baseline)proved the flip reached the assembler, which was the
bug it was written for, and nothing more: it passes just as well if the flip changes the wrong text,
in the wrong section, or one byte of whitespace, and it could not be read, so nobody could tell from
the suite whatsubagent.maxRecursionDepthis supposed to do. Each gate now names the statement it
decides, with the signature DERIVED from that statement's own text rather than pasted into the test,
so the claim cannot rot into a quotation of prose that has since been reworded. Verified against
four mutations the old comparison passed, including one wheretui.renderMermaidgates a different
statement entirely.- The system prompt is now assembled from named statements in full. All six sections are converted,
68 rows in total (conventions 1, ROLE 2, RUNTIME 12, TOOL POLICY 34, EXECUTION WORKFLOW 13,
DELIVERY CONTRACT 6), andsystem-prompt.mdno longer feeds any session. A single gated line such as
anast_greppreference, a delegation rule or one contract block can now be named, asserted on,
priced in tokens and ablated in an eval without editing the prose around it. Two conditions were added for the shapes the
larger sections need:whenAll/whenAnyhold conditions rather than variable names so they nest,
which is what lets a row say "the task tool is active and this is not the Codex wording", andnot
covers a block-level{{else}}arm. Zero word-level differences across the gate matrix. - The granularity rule that decides how fine a statement is now admits units the prompt itself
delimits.DELIVERY CONTRACTis five unconditional XML blocks andEXECUTION WORKFLOWsix numbered
steps under headings; merging each set into one row would have been faithful to the old rule and
wrong, because those boundaries come from the document rather than the registry and an eval that
ablates one step needs it to have a name. The check allows adjacent unconditional rows only when the
second opens a heading or an XML block, so an arbitrary prose split is still reported. - The system prompt's
RUNTIMEsection is now assembled from twelve named statements instead of a
block of Handlebars conditionals, and the statements are what a session actually sends. Each one
has an id, a stated purpose and a condition drawn from a closed vocabulary, so a single gated line
such as thememory://rootURL or the MCP discovery notice can be named, asserted on and switched
off without editing prose around it. Not one word of the prompt changed. The spacing changed in
three gate combinations, deliberately:formatdeletes a run of two or more blank lines and keeps
a single one, and RUNTIME's template put unconditional blank lines between conditional blocks, so
with two of those blocks absent# Skills & Ruleswas landing directly on# Internal URLswith
no gap. A statement owns the separation that follows it, so the spacing no longer depends on which
unrelated blocks are missing. The three differences are enumerated with their measured deltas and
the list is asserted exhaustive in both directions. - The
-1that older configs stored to mean "unset" is named in one place.config/settings.ts
declared its own constant for it beside the one inconfig/optional-number.ts, so the module
that deletes the old sentinel and the module that translates it each had their own spelling of
the same number. No behaviour change; the point is that there is nothing left to keep in sync. - The session-entry types are declared once, in
@veyyon/agent-core, instead of twice. This package and the agent core each wrote out the same fifteen entry interfaces and their ownSessionEntryunion over them; twelve of the fifteen were identical and three had drifted, so compaction in the other package saw aSessionInitEntrywithout thespawnsandreadSummarizethis one actually writes and aThinkingLevelChangeEntrywithoutconfigured. The shared shapes now live in one file and are re-exported here under the same names, so every existing import keeps working, and the two entry kinds only this package persists reach the shared union through the declaration-merging hook that already existed for that purpose. - The secret obfuscator's JSON type is
JsonWithOptionalFields, notJsonValue. It is a deliberately laxer shape than the repository'sJsonValue(@veyyon/utils), whose objects never holdundefined, and it needs to be:mapJsonStringswalks tool-call arguments, and a TypeScript object with optional properties is not assignable to the strict shape, so the walker would refuse the values it exists to rewrite. Two exported types with one name and different contents is a bug waiting for an editor's auto-import, so the name now says what the difference is.JsonRecordis unchanged in shape. veyyon prompt --promptslists every prompt from all three product registries, grouped by the directory each lives in, and--prompt <id>resolves an id from any of them. It listed and looked up only this package's own, so the compaction prompts that rewrite a session's entire history and every dialect format guide were absent from a list that looked complete. An unknown id is now refused with the nearest registered id quoted back and the directory named, rather than a rule that no longer identifies one tree.- The "trim each, drop the blanks" loop is
nonEmptyTrimmedfrom@veyyon/utils.gh.tswrote it twice, 145 lines apart, for a PR identifier list and for search-query fragments, andautoresearch/helpers.tshad a third copy with deduplication folded in. Nothing was wrong with any of them, which is why it was worth naming: the next copy is the one that forgets the trim or decides a whitespace-only entry counts, and then two parts of the product disagree about whether" "is a value.dedupeStringsnow adds only uniqueness on top. - Host probing moved out of the prompt builder into
utils/host-environment.ts.system-prompt.tsis about assembling a prompt, and roughly 280 of its lines were not: spawninglspciandwmic, racing them against a deadline, draining a pipe an exited child left behind, caching the answer on disk, and reading/proc/cpuinfo. Burying a subsystem with its own failure modes inside a 1200-line file about something else is what let two of those failures be handled at different volumes without anyone noticing. The prompt builder now asks for what it actually wants — the CPU, the GPU and the finished rows — and passes its preparation budget in, so the probe's margin (it must outlive its own deadline long enough to write the null cache) lives with the probe instead of being derived from a constant in another file.system-prompt.tsis 1201 lines to 912. firstNonEmptyis in@veyyon/utilsrather than private to the prompt builder, which needed it in both halves of that split. It picks the first value that is set and not blank after trimming, which is the case??and||each get half of:??keeps an empty string,||drops one but also drops0, and neither trims. ATERM=exported blank now falls through toCOLORTERMfor the same reason it always should have.- The one parser that cuts a bannered prompt lives in
banner-grammar.ts, beside the grammar it parses. It was inprompt-sections.ts, whose header called it "section machinery for the default system-prompt template" while it served every prompt in the product, and that mislabelling is what let it close over the system prompt's banner table in the first place: handed the subagent prompt, same grammar, it recognised only the banners the two happen to share and folded the rest away without a word. The banner table is now a required argument, so there is no default to fall back to and no prompt the parser knows.prompt-sections.tskeeps what is genuinely about the system prompt: its section names, its table, and the reordering a harness profile asks for. - The three memoized derivations in
prompt-sections.tsuse the sharedoncerather than a module-levelletand a??=written out three times. Three copies of a caching pattern are three chances to get it wrong in a way only one of them shows:??=re-runs forever if its derivation ever yields an empty string or zero, which these do not today and nothing was checking. The regression test that keeps the reads deferred was also flagging the deferred spelling as if it were an eager one, so the honest fix failed the check that exists to encourage it; it now looks for a read nothing on the line defers, and proves on synthetic input that it still catches an eager read. - The prompt banner grammar is its own module, and the file that held it is named for what it contains.
prompt-blocks.tsowned two unrelated things: how a banner is written and recognised in EVERY prompt, and the system prompt's own list of sections. The universal half is nowsystem-prompt-builder/banner-grammar.ts, a leaf that knows no prompt, soprompts/registry.tsno longer reaches into the system prompt's module to ask what a banner looks like. The remaining half issection-registry.ts, since a "block" in that subsystem already means an entry of thestring[]buildSystemPromptreturns, and the file contained none.PROMPT_SECTIONSbecameSYSTEM_PROMPT_SECTIONSfor the same reason: it lists the system prompt's sections, not every prompt's, and thePROMPT_SECTIONS/override directory shares the old spelling. - Collapsed seventeen helpers that existed as byte-identical copies into one definition each: the project resolution that decides which launch daemon a directory uses (two copies, so the client and the presence file could have disagreed about a symlinked project), the "YAML if
.yaml, otherwise JSON" decision the LSP and DAP config readers each made privately, the provider-name rendering three user-visible surfaces each had their own version of, and the patch check that refuses two hashline sections resolving to one file, which now lives in the package that defines the section type. Three more followed: the DAP files' private error renderer, which the sharederrorMessagealready did better (an error with an empty message now shows its class name instead of nothing), the commit an experiment records, and the current-branch-or-HEADspelling two bundled commands each rolled themselves. Then four whose copies could disagree across a boundary: the diagnostic text sanitizer two rendering surfaces stated separately (a diagnostic containing a tab would have rendered differently depending on where you saw it), the browser tab id the supervisor and its worker each derived (a tab addressed under two ids takes commands on neither), the collab wire envelope the host and the browser guest each coded, which now lives in@veyyon/wirebeside the header length it reads, and the WebCrypto byte coercion four packages needed, nowasStrictBytesin@veyyon/utils. Thetokensubcommand followed, which the auth gateway and the auth broker offer identically and each implemented separately, down to the JSON shape it prints. The envelope is the one that fails most quietly: the payload still decrypts, because the room key is untouched, so a host and a guest that disagreed about the byte order would deliver a frame to the wrong peer without an error anywhere. Four more after that: the tree indentation three renderers drew (the JSON tree's copy had drifted to the OPPOSITE argument order, so the same nesting could draw different rules in two panes of one screen), the thenable guard the IPC and MCP stdio send paths each carried (the surviving copy's own comment justified the other as "battle-tested there", though only one of the two was tested at all, and those tests moved to@veyyon/utilswith the function), thetokensubcommand described above, and the bootstrapveyyon benchandveyyon dry-balanceshare, where the part that matters is the failure path: if settings or the extension providers throw, the credential store opened a line earlier is closed before the error propagates, or a SQLite handle leaks on every failed invocation. The last two: the log replay both worker supervisors performed (a worker has no logger of its own, so it ships the level with the message and the supervisor replays it, and a copy that mapped a level to the wrong method would move a class of worker diagnostics out of the log an operator is reading), and the runtime installer's pipe reader, which moved to@veyyon/utilsasreadPipeText. - Fixed two ways an eval kernel could be started twice for the same work. The key a retained kernel is stored under,
(session, cwd, interpreter), had three copies, one per managed runtime, and the Julia copy had drifted: it resolved the interpreter path without following symlinks, so reaching the same Julia through a link (/usr/local/bin/juliaand the versioned binary behind it) started a second kernel that shared no state with the first, and it joined the key's parts with::, a sequence that can occur inside a session id or a path. All three now use one builder that canonicalises the path and separates the parts with a byte that cannot appear in either. - Tool-output folding now recognises six more shapes an agent meets constantly:
python -m unittest -vper-test lines, cmake/make progress ([ 42%] Building C object ...), make's directory recursion, gradle tasks that did no work, docker layer ids, and maven artifact fetches. Measured on runs captured on a real machine rather than on fixtures: a 41-test unittest run goes from 2,473 to 170 characters (93.1% smaller) and a 41-file cmake build from 2,556 to 60 (97.7%), both keeping every diagnostic and the summary verbatim. Only the shapes that state no work was done are folded, so a gradle task that ran, a dockerStep 4/12line, and every[ERROR]/[WARNING]maven line stay. - A
readwith a bounded line range now says on its last line what it padded:read file:1-3answers with six lines and[Showing lines 1-6: you requested lines 1-3, plus 3 lines of trailing context]. The padding is deliberate, it saves the follow-up read that a one-line-off anchor needs, and it was documented only indocs/tools/read.md, where a reader looking at the result never saw it: the same read was reported as over-delivery twice, because the surprise happens where the result is, not where the docs are. The counts come from the range that was actually shown, so padding cut short by the end of the file reports the smaller number, an unpadded read carries no notice, and:rawstays byte-verbatim. veyyon gcnow lets you set how recently a file may have been written and still be left alone:gc.writeGraceMinutesin your config, or--write-grace-minutesfor one run. The window was a fixed five minutes while the retention knob beside it was already configurable, so you could tune how long sessions are kept but not how much slack GC leaves for live writes. One minute is the minimum, and a smaller value is raised to it with a message rather than honoured, because a shorter window would let GC delete a blob a running session wrote a moment ago. Breaking a stale GC lock keeps its own five-minute window, so a shorter grace no longer also makes one GC run steal another's lock.- Every prompt veyyon sends a model is now owned by one registry per package, and the import is the registration. Prompts were reached by ad-hoc relative path from wherever they were used: 160
import … with { type: "text" }specifiers across 85 files, 27 of them in one module. A registry beside them listed 23 of the 143 and recorded each one's location a SECOND time as a path string the compiler cannot check, so a prompt's home was written down twice in spellings nothing kept in agreement, and 120 prompts were written down nowhere.src/prompts/registry.tsnow holds the text import, id and purpose of every prompt in one row each, nothing else may import a prompt file, andveyyon prompt --promptslists all 163 with what each is for instead of 23. Prompts that lived beside their consumer (src/commit/prompts,src/commit/agentic/prompts,src/autoresearch,packages/agent/src/compaction/prompts) moved into their package's one prompts tree. - Prompt files are grouped by when they fire instead of piled in a
system/directory. Moving 163 prompts into one tree was not the same as organizing them:system/held 61 of them, 40% of the tree, with personalities, plan mode, rule violations, IRC, session titles, loop redirects, agent creation, memory and the main system prompt all as siblings, and six more sat loose at the root. Directories now say when a prompt reaches the model:session/for what defines a session,turn-control/for what interrupts or resumes a turn,side-channel/for turns that reuse the context but are not the task, plussubagent/,plan-mode/,rules/,autolearn/,titles/,thinking/,requests/andbench/. The largest directory outsidetools/is now 17 of 163. A prompt's id is its path, so the idsveyyon prompt --promptslists moved with the files. ThePROMPT_SECTIONS/names are unaffected: those are the banner sections inside the system prompt, not prompt files. - One splitter now cuts every bannered prompt, and both callers agree about a broken one. The product had two implementations of the same
NAME\n====grammar: the template slicer walked byte offsets and refused a missing or out-of-order banner, while the reorder and inspection path walked lines and silently folded an unrecognised banner into the section above it. Unifying the section definitions had been mistaken for the whole fix, so a renamed banner refused the build in one path and quietly merged two sections in the other, shipping a prompt with a region missing and reporting nothing. Strictness is now a caller's choice on one parser: the template slicer requires its sections and names the id, the banner and the document when one is absent, while a custom prompt with no banners is still read as one region rather than an error. - The context gauge is now the last thing on the footline. On the default status line it sat between the model and the session name, so the one number that changes every turn was wedged between two that never do, and the default and minimal presets disagreed about it. Standing state reads first, the gauge last. A gauge you place explicitly on the right side of the line stays where you put it.
- Tool cards now line up with everything else in the transcript. A card drew its frame at column 0 while the prompt glyph, assistant text and command blocks all sat two columns in, so every tool call broke the single left edge the eye follows down the screen. The card starts on that edge now, and keeps the same gap from the right.
/compact softand/compact remotenow say that those names are retired. Both were removed with the provider-native compaction path they used to steer, and typing one fell through to the plain focus-text path: veyyon compacted with your configured type, folded the word into the focus text, and reported success, so it looked like the type you asked for had run. It still compacts and still passes your text through exactly as typed, and now it tells you which name you used and to use/compact summaryinstead.veyyon updatenow refuses to replace a binary that is a symlink, instead of silently destroying the link. If~/.local/bin/veypoints at a checkout build, the update renamed a downloaded binary over that path: the checkout survived, nothing pointed at it any more, and the update reported success, so you kept editing a build that no longer ran. The refusal names the link, where it points, and both ways out (update that install directly, orrmthe link first). A hardlinked binary still updates.- An update that fails inside a directory you cannot write to now reports why it failed. The cleanup of the downloaded file failed too, and that error replaced the real one, so you were told veyyon could not delete
vey.newwhen it could not write into the directory at all. The download is left behind instead, and reclaimed by the next update. - A dotted key at the top level of a config file now works.
subagent.model: openai/gpt-5looks exactly like the nested form the docs show, and people write it, but it was parsed, merged, and then never read: values are looked up by walking nested keys, so the setting sat in the tree under a literal"subagent.model"key that nothing looked at, and it silently did nothing — no warning, andveyyon config listshowed the default. Every setting was affected. Flat keys naming a setting this build knows are expanded when the file is read, so either spelling works; a setting written both ways keeps the nested value and drops the flat one with a warning naming both; a key this build does not know is still preserved exactly as written. veyyon config reset <key>removes the key instead of writing the default back intoconfig.yml. Writing it made the reset value look explicitly configured, pinning a default that was meant to follow the app.- An optional numeric setting is now unset by having NO key, instead of storing
-1to mean "no value". The sentinel made-1unreachable as a real value, andpresencePenalty: -1is a penalty providers accept. ChoosingDefaultin/settingsremoves the key, and a config holding the old-1on one of these keys has it dropped on load, so your effective settings do not change while-1becomes settable. The seven affected settings are the six sampling knobs andcompaction.modelContextWindow. - Subagents now run the model you are working with. On a stock install they each ran a DIFFERENT model — scout and sonic on a small one, reviewer on a thinking one, designer on a third — and no subagent model setting could change it: the bundled agents carried role aliases (
@smol,@slow,@designer,@task) in their frontmatter, and an unset role expanded to a built-inpriority.jsonchain rather than reporting that the role names no model, so those aliases won before any choice of yours was consulted. Role expansion no longer has a chain (every role, advisor included, inherits the live main model when unset), no bundled agent pins a model, and the four layers that can name a subagent's model — that agent's row, the blanketsubagent.model, the definition's ownmodel:, then inherit — resolve in one place with the deciding layer reported.priority.jsonstill picks a fast or strong model on first run, where nothing has been chosen yet. - A configured subagent model that matches no available model now refuses the spawn and names the setting to fix. It used to fall silently through to the next layer, which is indistinguishable from your setting having no effect.
/agentsshows the pattern, the model it resolves to, and which layer decided, so an override that was outranked is visible rather than merely disappointing. - Only the general-purpose worker and agents you wrote yourself are offered to the model now. The five bundled specialists (
scout,reviewer,designer,librarian,sonic) ship unoffered: each agent type costs its description in every request of the session, and most sessions want a worker and nothing else. Enable the ones you want in the Subagents tab or with/agents, wherespacecycles offered / not offered / blocked. An unoffered agent still runs when something names it outright, so/reviewkeeps spawningreviewer;blockedrefuses even then. - Subagent effort is now picked from a list instead of typed, and a value that names no level is reported instead of ignored. "Subagent Effort" was a free-text field, so
hihgwas accepted, resolved to nothing, and read as "inherited" — a setting that looked configured and did nothing. Both effort surfaces (the blanket setting and the per-agent row) offer the same rows —off,minimalthroughmax,auto, andInherit— from one vocabulary, and an unrecognized value from a hand-written config is named alongside the levels that would have worked. It is still never rounded to a neighbouring effort. - Task delegation moved under the same area and gained a level:
subagent.delegationisoff,allowed(the default),preferred, orrequired, replacingtask.eager.offremoves thetasktool outright instead of describing a tool the prompt then forbids, and every delegation instruction is derived from what you have enabled — with only the worker offered, nothing tells the model to pick an agent type or to send research to ascoutit cannot spawn. - The context gauge now reports how much room is LEFT, and says so. It measures against whichever limit comes first — the auto-compaction trigger when auto-compaction is on, the model's window otherwise — and the quiet footline shows that as a draining 8-cell bar with a labelled percentage (
▰▰▰▰▰▰▱▱ 76% left), so the bar and the number cannot disagree. The bar used to grow as room ran out, which is a fuel gauge running backwards, and a bare38%beside it was read as consumption by half its readers. Text presets show tokens on both sides of the slash (47K/170K) instead of47.3%/200,000, which put a percent and a token count either side of a slash and was true under no reading. The percentage is a whole number: a tenth of a percent moved every turn and decided nothing. - Clicking the context gauge in the composer's footline opens the
/contextbreakdown. The footline has room for one number, and the question behind it needs the per-category split; a hover cannot serve it because the main screen tracks mouse buttons without motion reporting, so nothing is known about the pointer until a press. /contextreports the room left alongside what is used, formats its token counts (272Krather than272000), and, when the per-category breakdown cannot be computed, says that and why instead of printing three plain lines that look like a healthy narrow report.- Argot has one name. The package was published as
argot, its settings wereargot.*, and its directory and veyyon's wiring modules werelexpack, so every reader had to learn the mapping. The directory ispackages/argotand the modules areargot-wire.ts/argot-cache.ts/tools/argot.ts. Nothing you configure changed: the setting keys were alreadyargot.*. Three things the mismatch had been hiding turned up with it: the handbook's Argot chapter was an eight-byte stub, becauseSUMMARY.mdlinkedwhy/argot.mdwhile the real chapter sat atwhy/lexpack.md; a stale duplicate of the Argot blog post was still in the repo; and the dictionary-generation script for the DeepSWE bench imported a package name that does not exist, so it could not have run. - The per-turn receipt (
display.showTokenUsage) now reports how long the turn took. The total duration was read only to divide the output tokens by it, so the row published a rate and never the time behind it: you could read59.3/sand still not know whether the turn took four seconds or forty, which is exactly the number you want when comparing two models on the same prompt. The one time value it did show was time-to-first-token wearing the clock icon with nothing to say so, so a reader took it for the turn's length. The clock now means the turn's length, formatted the way the status line formats elapsed time, and TTFT is labelledttft. - A shell command that failed is now marked as failed, not just tinted. The bash block deliberately shows no title, since the frame would only repeat the
$line, and that suppressed the failure marker too:state: "error"reaches the border colour and nothing else, so with colour stripped — a monochrome terminal, a colour-blind reader, a transcript pasted into an issue — a failed command rendered byte-identically to a clean one. A failed run now carries its own✗ failedheader. This also covers failures that carry no exit code: a timeout, an abort, or a command that could not be spawned propagates as a thrown error whose result has no exit code to key theExit: Nchip on, so that whole class of failure previously showed no marker at all. - A rendering hook or message renderer that throws now says so in the transcript instead of being replaced without a word. Tools, extensions, and hooks can all supply their own renderer, and a throw was survivable but invisible: you saw the tool's name where its card should be, raw output where its diff should be, an empty box for a multi-file edit, or the built-in card in place of an extension's, with nothing but a log line behind it (and for custom messages, not even that). The substituted render now carries one line naming which renderer failed, why, and what you are looking at instead, marked with a glyph so it survives a monochrome terminal. Returning
undefinedstill declines quietly: that is how a renderer opts out for one call. - The model slot holding the model you are working with now has one name. It answered to
defaultin storage,interactiveassetModel's role argument, and both spellings in scattered inline comparisons, and one line storeddefaultwhile logginginteractivefor the same write, so a session-log entry could not be matched to the setting it changed. Callers pass either spelling andresolveModelSlottranslates once. - The priority service tier now reads as a serving tier rather than a fourth effort level. Its icon sat immediately before the thinking-level glyph in the same color, so
⚡ ◉ highlooked like one more rung on the effort scale. It now trails the effort as its own chip, in its own color, and names itself, which also makes the tier visible in symbol themes whose fast icon is empty (it used to render nothing there)./fastkeeps its name and now names what it changes ("Priority tier (fast mode) enabled") instead of describing the same state in a second vocabulary. - A setting replaced by another is now marked retired in the schema, so it stops advertising itself as a choice:
veyyon config listleaves it out, andconfig get/setstill work but name the key that governs the behavior now.compaction.thresholdTokens,compaction.thresholdPercent, anddefaultThinkingLevelare the first three. - Optional numeric settings share one definition of "unset". The
-1sentinel was written out by hand in thirteen schema entries, with two different submenu encodings and a list of paths maintained inside the settings selector; the selector now derives that set from the schema, and everyDefaultrow comes from one helper. - The auto-compaction trigger now has one setting,
compaction.threshold, whose unit is part of its value:auto(the model's window minus the reserve), a percent that moves with the model (85%), or an absolute token amount that is the same on every model (170000). It replaces two rows both labelled "Compaction Threshold" (compaction.thresholdTokensandcompaction.thresholdPercent) that wrote one axis with an invisible precedence, so picking the wrong one silently did nothing. Your global config is rewritten on load — the amount becomesthreshold: 170000, the percent becomesthreshold: 85%, and both retired keys are dropped — so the ambiguity leaves the file without moving your trigger; project configs and--configoverlays, which are never rewritten, are folded in at read time with the same precedence. The resolved threshold is now reported with its origin —170k (85% of 200k)— whenever it is capped for the current model, still coming from a retired key, or unparseable. - Handoff now ends with the same
<files>block a summary does, so a session started from a handoff gets the same map of what was read and modified. /compactsubcommands are now the two compaction strategies,summaryandhandoff. The formersoftandremotemodes existed only to steer provider-native remote compaction, which was removed; a stale/compact soft ...or/compact remote ...is read as focus text rather than erroring.- Settings search now ranks by field instead of one concatenated blob: the setting named for your query comes first, prose matches come last, and a setting can declare the words users actually type for it (
reasoningfinds Default Effort,copy/clipboardfinds scroll isolation). Searching no longer matches a setting by its current value or its enum values. - Thinking effort now has one persisted home:
defaultEffort, a per-profile list of model to effort rows edited at/settings→ Model → Default Effort. A row keyed by a model selector applies to that model, and a*row applies to every model without one. It replaces the profile-widedefaultThinkingLevelenum, which is still read so an existing config keeps working: with no*row, that value becomes it. Effort resolves in one documented order (session choice, then an explicit:levelon the role's selector, then the model's row, then the*row, then the model's default) owned byconfig/effort-resolver.tsrather than written inline at each call site. /thinkingand its/effortalias now change the current session only and print where the saved default lives. They used to rewrite the profile-wide default while the cycle keybinding did not, so the same change stuck or evaporated depending on how you made it, and there was no way to try an effort without keeping it.
Release notes were shortened from 437,986 characters to fit GitHub's 125,000-character body limit. Read the complete package changelogs and full commit range.