Skip to content

v1.0.46

Choose a tag to compare

@github-actions github-actions released this 01 Aug 23:08
· 4209 commits to main since this release

@veyyon/agent-core

Breaking Changes

  • AgentLoopConfig.transformToolCallArguments returns 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. execution reaches tool.execute and beforeToolCall; display is 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.pruneToolDescriptions accepts 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.ts takes Effort from @veyyon/catalog/effort and withAuth from @veyyon/ai/auth-retry, the modules that declare them, so its @veyyon/ai import is type-only and the file carries no runtime edge to the barrel at all.
  • The agent loop and the Agent class name the modules that declare the functions they call rather than the @veyyon/ai entry 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.ts went from 378 modules to 321 and agent.ts from 380 to 323, and compaction/utils.ts from 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.ts is span vocabulary, and it is used across this package by code that never calls a model, but it also held instrumentedCompleteSimple, 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 to instrumented-complete.ts and the remaining barrel imports were repointed at their owners, taking telemetry.ts from 366 modules to 9 and compaction/branch-summarization.ts from 394 to 333. instrumentedCompleteSimple is still exported under the same name from the package entry point, which is where callers already took it from.
  • proxy.ts takes EventStream from @veyyon/ai/utils/event-stream, the module that declares it, instead of from the @veyyon/ai entry 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. estimateTokens lived 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 to compaction/token-estimate.ts at 85 modules, and the engine re-exports the name, so shake.ts went from 398 modules to 88 and pruning.ts from 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.ts owns the whole compaction trigger now: the CompactionSettings shape, the reserve policy (effectiveReserveTokens, resolveBudgetReserveTokens), shouldCompact, and the three threshold wrappers. They were in compaction/compaction.ts, which is the module that RUNS a compaction and therefore imports the @veyyon/ai barrel, 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's config/settings.ts, the module 528 of its test files import, reached @veyyon/ai/stream.ts through this one edge. compaction.ts re-exports all of it, so no caller changed.
  • thinking.ts takes Effort from @veyyon/catalog/effort, its owner, which imports nothing, instead of from the @veyyon/ai barrel. A six-entry ladder and a clamp were carrying the streaming engine to every consumer of ThinkingLevel.
  • This package owns the session-entry vocabulary: SessionEntryBase and the fourteen entry interfaces over it, plus the SessionEntry union. @veyyon/coding-agent had a second copy of all of them and the copies had diverged, so SessionInitEntry here was missing the spawns and readSummarize fields the coding agent writes, and ThinkingLevelChangeEntry was missing configured. Those three fields are now on the shared declarations, and a consumer that persists its own entry kinds adds them through CustomCompactionSessionEntries rather than redeclaring the union.
  • The narrowing that answers "does this session entry carry a tool result" lives with the entry union it narrows, as getToolResultMessage in compaction/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. summary is told it continues in the SAME session, so the recent turns survive alongside it and must not be restated; handoff is 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 Blocked section, 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, and handoff-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-summary runs 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; handoff then returned just the deterministic <files> block, which reads as a real document while carrying no goal, no decisions, and no next step, and summary would 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 inside serializeConversation, one per rendering branch, so it applied to the summary strategy and to nothing else. Both strategies now route through it. Dropping a useless result also drops its paired toolCall, 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 with readToolSupersedeKey, which moved to compaction/utils.ts so the durable pruner and the request pruner share one definition of that rule.
  • Tool-result truncation is opt-out through truncateToolResults. summary keeps it; handoff turns 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.
  • generateHandoff accepts fileOps and appends the same deterministic <files> block the summary strategy 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 persisted compactionSummary and branchSummary roles remain
    unchanged. New context contains no private <summary> delimiters for a model to echo.
  • AgentLoopConfig.pauseGate can 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 completed aborted messages with their exact
    reason instead of escaping as rejected streams.
  • A turn that ends in an error builds its message with the shared errorMessage helper. The two tail branches were that helper written out by hand, and the local const errorMessage holding the result shadowed the import, so the hand-rolled copy was the only version reachable in that scope. The local is named failureMessage now.
  • 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/compact and 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, summary and handoff, 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, and compaction.v2RetainedMessageBudget settings, which existed only to gate that path. compaction.remoteEndpoint stays: it is a summarizer transport for the summary strategy and returns summary text.

@veyyon/ai

Added

  • Added one canonical session-telemetry policy for off, basic, rich, and ultra, 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/utils names 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, logger seventeen, errorMessage sixteen), 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.ts reaches 191 modules where it reached 253.
  • The Google OAuth flow holds its config in an ES # private field rather than behind a private
    keyword the compiler throws away. No public API changed.
  • The OpenAI reasoning-effort fallback reads the thinking ladder from @veyyon/catalog/effort instead of restating it three times. openai-reasoning-fallback.ts held 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. none stays declared locally as NO_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.ts owns CODE_FENCE, the bare markdown fence. gemini.ts called it FENCE and deepseek.ts called it CODE_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 from provider-env-keys.ts as AUTHENTICATED_API_KEY_SENTINEL. providers/amazon-bedrock.ts declared 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/catalog instead of declaring them. providers/cursor.ts re-exports CURSOR_API_URL from the owner rather than declaring it, and registry/oauth/google-oauth-shared.ts takes readonly 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/codex instead of each decoding the JWT themselves. The usage reader's copy passed an empty chatgpt_account_id through unchanged, which is worse than omitting the header it feeds. auth-credential-rows.ts had 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-rolled trim().toLowerCase().
  • Removed the deprecated decodeJwt export from oauth/openai-codex. It only forwarded to decodeJwtPayload in @veyyon/utils, which its own doc named as the replacement, and its last caller is gone; import decodeJwtPayload from @veyyon/utils instead.
  • The Devin provider and its OAuth flow take their hosts from @veyyon/catalog/provider-endpoints instead of each declaring DEVIN_API_URL for 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 in dialect/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 in glm.ts, hermes.ts and qwen3.ts and a fourth time in utils/validation.ts as SPILL_TOOL_CLOSE, the tool-response envelope existed as GLM constants, as inline text in rendering.ts, and as bare literals in seven rows of the owned-stream.ts detection table, and providers/anthropic.ts held 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.ts also 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_OPEN was <think> in six dialects and ```thinking in gemini.ts, where adding the shared name to the import list would have silently shadowed it. CALL_OPEN was <|tool_call> in gemma.ts and <call: in pi-native.ts. RESPONSE_OPEN was <|tool_response> in gemma.ts and the shared <tool_response> in glm.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_PATH is exported from registry/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.ts and gitlab-duo.ts each declared const CALLBACK_PATH = "/callback", as did the MCP flow in @veyyon/coding-agent, which imports OAuthCallbackFlow and 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, and SQLITE_NOW_EPOCH from @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. getEnvApiKey was split out of stream.ts for 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 off PROVIDER_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.ts owns those rules now at 23 modules, and registry/types.ts no longer declares an envKeys field, so a provider's env-key rule has exactly one home and one reader. env-api-key.ts 158 -> 65, and downstream in @veyyon/coding-agent, where eighteen web-search providers and the fetcher import it: web/parallel.ts 164 -> 72, tools/fetch.ts 368 -> 282, tools/read.ts 542 -> 468. Two duplicates went with it: KeyResolver was declared identically in registry/types.ts and in env-api-key.ts, and gitlab-duo-agent declared envKeys: "GITLAB_TOKEN" while the catalog already said envVars: ["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.ts into src/auth-storage-sqlite.ts, and the
    row types and row logic into src/auth-credential-rows.ts. One 7,800-line module used to hold three
    jobs: the credential types every consumer speaks, the AuthStorage class 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/utils barrel plus one. auth-storage.ts keeps the OAuth machinery and re-exports
    SqliteAuthCredentialStore, isSqliteBusyError, isRefreshFailureDisableCause and
    OAUTH_REFRESH_FAILURE_DISABLE_PREFIX, so @veyyon/ai/auth-storage and the @veyyon/ai barrel both
    remain working import paths and no caller changed.
    Import the store from @veyyon/ai/auth-storage-sqlite when you want persistence without the OAuth
    stack. veyyon's session/agent-storage.ts does, and fell from 213 modules to 84; because
    config/settings.ts imports that file, everything that reads a setting fell from 250 to 125.
    packages/ai/test/credential-store-is-not-the-oauth-machinery.test.ts pins 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.ts and its 285 modules: every provider transport, the model registry, the error taxonomy.
    veyyon's config/settings.ts did exactly that for one setter, and paid it into everything that reads
    a setting. stream.ts re-exports configureProviderMaxInFlightRequests and resolves its limits
    through the new module, so @veyyon/ai/stream remains 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.ts imported all eleven
    usage backends directly, so a module about storing credentials owned the table of how every
    provider reports its quota, and through usage/claude it reached the provider transports and the
    streaming engine. The table now lives in src/usage/defaults.ts and is read through
    src/usage/registry.ts, which cuts auth-storage.ts from 299 reachable modules to 209.
    Importing @veyyon/ai still wires it for you. If you import AuthStorage from 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 and getApiKey reads those, so throwing would stop a
    process selecting a credential over a feature it never asked for. To report no usage deliberately,
    pass usageProviderResolver and rankingStrategyResolver explicitly.
  • Every prompt this package sends is registered in src/prompts/registry.ts, and the fourteen .md files moved from src/dialect/ and src/providers/ to src/prompts/dialect/ and src/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, and veyyon prompt --prompts listed none of it. Each dialect definition now takes its guide from its registry row, and dialect-prompt-registry.test.ts asserts 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: readAuthCredentialById is 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 uses AUTOINCREMENT and 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, qwen3 and pi-native each declared a
    renderToolResults whose whole body forwarded to renderToolResponseResults, which is what the
    generic xml dialect 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 in dialect/rendering.ts. Three dialects speak it (anthropic, the generic xml, and minimax, 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-through renderThinking wrappers and the three per-model turn delimiters, which rendering.ts already 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: isEffort beside THINKING_EFFORTS in @veyyon/catalog, and isServiceTier beside SERVICE_TIERS in this package, with ServiceTier now 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. Their formatError wrappers, which only forwarded to formatOpenAiError, 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 asStrictBytes in @veyyon/utils rather than each defining it. It decides whether a Uint8Array has to be copied before crypto.subtle reads it, and crypto.subtle reads 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 fetchAvailableModels path 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.ts owns it beside the ToolResultMessage type 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_MS is exported from registry/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_LEGACY and TIER_STANDARD moved to registry/oauth/google-oauth-shared.ts, the module that already exists for the two Google providers: legacy-tier is 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.ts scans every packages/*/src rather 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 second const does), that each consumer imports from the catalog, and for the Codex host the half a name-based lock cannot see, that nobody writes https://chatgpt.com/backend-api as a literal outside its owner.
  • The cancelled-request error is RequestAbortError, not AbortError. It shared the name with two unrelated classes in @veyyon/utils (a cancelled operation and a killed child process), which made an instanceof check read as a question about cancellation when it was really a question about which layer raised it. name is 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 off therefore removes both families through the same closed policy as every other study field, and the inert analytics-rollup permission 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 developer for agent-owned text-block context
    instead of silently rewriting the array form as user. Responses and Codex split image-bearing
    developer context into developer text plus user image attachments, which keeps instruction
    provenance without sending input_image in a role those APIs reject.
  • Cancelling a request no longer turns into a crashed run. resolveApiKeyOnce checked the abort signal
    before it looked at the key at all, so an already-cancelled request threw signal.reason even 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. withAuth keeps 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. Every AuthStorage cache 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 corrupt auth.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 failed deleteCachePrefix is 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. encodeStream builds its terminal SSE frame from the stream's final assistant message, and when no done event arrived it asked the stream for its result and swallowed a rejection to null. Every reader after that treated the null as "there was no final message" rather than as an error, so the status became completed, 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 emits response.failed with the real reason, and the items that did stream stay attached so a truncated answer can still be inspected. An explicit error event 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. fetchMetadataToken answered a refused status and "not running on GCE at all" with the same undefined, 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, so If-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 response ETag, 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/models endpoint returning ambiguous bare model IDs when multiple providers register the same model name. Model IDs are now correctly advertised with their provider/ prefix (e.g., anthropic/shared-model) and duplicate entries from the resolver map are deduplicated.

argot

Added

  • GeneratedDict.breakEvenTurns says how many turns a dictionary has to survive before it pays for itself. estimatedSavings alone 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. breakEvenTurns divides the two, priced at DEFAULT_OUTPUT_TO_INPUT_PRICE_RATIO, and is Infinity when the dictionary is empty.
  • DEFAULT_TOOL_CALL_STRUCTURE_SHARE and DEFAULT_OUTPUT_TO_INPUT_PRICE_RATIO are exported, so a host that prices its own traffic can see what the defaults stand for instead of rediscovering them.

Changed

  • StreamDecoder and ArgotSession use ES # private fields instead of the private keyword,
    which TypeScript erases and so never actually hid anything at runtime. fork() reaches a sibling
    instance's fields through copy.#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 \ + n and 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, so emittedTokenCost blends them at DEFAULT_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. Pass toolCallStructureShare to generate if your own harness splits differently.
  • GENERATOR_REVISION is 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

  • DIALECTS is exported and Dialect is 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.
  • fetchOpenAICompatibleModels takes an onFailure callback and calls it with an OpenAICompatibleDiscoveryFailure before returning null. Discovery answered a refused connection, a 401, an HTML error page and an unrecognized payload with the same bare null, 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; stage separates the three fixes an operator would reach for, since request points at the network, status at credentials, and payload at whether the endpoint is OpenAI-compatible at all. An empty catalog is still [] and still silent.
  • Every discovery reader takes the same onFailure, and createModelManager takes onDiscoveryFailure and passes hooks to your fetchDynamicModels. fetchOpenAICompatibleModels could 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 bare null. Each lost the reason in a way that mattered. Codex walks two routes and Antigravity walks its fallback endpoints, and both continued past every failure, so an expired token and a retired route ended in the same null naming neither attempt. Cursor speaks HTTP/2 directly and had five separate ways to answer null with 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 one null can 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_PREFIX and normalizeDevinSessionToken are 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.
  • matchesKimiK27CodeFamily and hasBillableCost each 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. hasBillableCost lived 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 what costKnown is for.
  • Added isEffort, the guard for a thinking level, beside the THINKING_EFFORTS list 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.ts exports DEVIN_IDE_VERSION and DEVIN_EXTENSION_VERSION, and discovery/antigravity.ts exports FETCH_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.ts also owns OPENROUTER_API_ENDPOINT. The host was declared four times across two packages under three names, and it carries a /v1 path segment, so an API version bump had four declarations to find. @veyyon/mnemopi held 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.ts owns ANTHROPIC_WEB_SEARCH_TOOL, the server-side tool name that the search provider in @veyyon/coding-agent asks for and the Anthropic provider in @veyyon/ai matches 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.ts also 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 what compat/anthropic.ts compares 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.ts owns 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.ts owns 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/ai mints the JWT and @veyyon/coding-agent spends 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 free turbo model regardless of model_preference, so a Pro account gets free-tier answers with nothing saying why.
  • wire/codex.ts owns the Codex JWT claim namespaces and the reader for them: CODEX_JWT_AUTH_CLAIM, CODEX_JWT_PROFILE_CLAIM, readCodexTokenIdentity, readCodexClaimsFromPayload, getCodexAccountId and getCodexAccountEmail. Five modules across three packages each hand-rolled "decode a ChatGPT OAuth token and pull chatgpt_account_id out 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 returns undefined and 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 the chatgpt-account-id header and an empty header value makes the backend answer a malformed-account error instead of using the token's own account. JWT_CLAIM_PATH remains as an alias of CODEX_JWT_AUTH_CLAIM.
  • Devin's three hosts have three names in provider-endpoints.ts: DEVIN_CASCADE_ENDPOINT for the Cascade chat API, DEVIN_AUTH_ENDPOINT for the token API, and DEVIN_WEBAPP_URL for the login-approval page. Two of them were previously declared as DEVIN_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 under DEVIN_DEFAULT_BASE_URL.
  • The token limits assumed for an agent gateway that does not publish its own live in discovery/default-limits.ts as AGENT_GATEWAY_DEFAULT_CONTEXT_WINDOW and AGENT_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.ts declared 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.ts is the one place a provider base URL the code decides is written, and provider-models/google.ts and discovery/gitlab-duo-workflow.ts read 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.com was 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 from discovery/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, and discovery/antigravity.ts re-exports its two former names unchanged.
  • Every pure helper this package uses comes from the module that owns it rather than from the @veyyon/utils barrel: errorMessage and isRecord from @veyyon/utils/type-guards, trimTrailingSlashes and normalizeBaseUrl from /url, once from /abortable, fetchWithRetry from /fetch-retry, wrapFetchForExtraCa from /tls-fetch, decodeJwtPayload from /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-models is 62 modules instead of 118 and this package's barrel is 128 instead of 186. Nothing about the exports changed.

Fixed

  • fetchGitLabDuoWorkflowModels answers null when 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 labelled unhandled, 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. discoverGitLabDuoWorkflowRuntimeNamespace still 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)/try around response.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 reports context_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 remoteCompaction from 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 in models.json.

@veyyon/coding-agent

Added

  • Expanded session.instrumentation into a complete session-study record. basic adds lifecycle checkpoints, task transitions, tool and model timing, and effective model request parameters; rich adds context attribution, directional agent-message delivery, result weight, and model throughput; ultra adds compaction links, per-task transitions, routes, fingerprints, and provider provenance. veyyon session stats reports each available family. off adds 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, uses task only 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,
    and veyyon grievances push performs one explicit upload without changing the toggle.
  • The Subagents HUD, the /agents roster, 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 :level suffix 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 rm and /secret extend complete 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 list was 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 and load() throws on a malformed or key-missing vault, which would turn a bad
    vault into a dropdown that crashes as you type. extend completes to extend NAME with the cursor
    ready for --ttl while rm completes to a finished command, read off each subcommand's declared
    usage rather than naming extend a second time in the completion code. add is 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 SECRETS section 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 had GITHUB_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 (-Help on Windows) with their option list. sh install.sh --help used to print Unknown option: --help and 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 as curl … | sh or irm … | iex never shows anyone: there was no way to discover --source, --ref, --local or VEYYON_INSTALL_DIR short 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.ts runs the POSIX one for real and pins that both installers offer the same six options under their two spellings.
  • argot.autoload decides whether the project you launched in is loaded for the session, or every
    load is left to the agent's argot_load calls. 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 is true, 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 gets argot_load and argot_unload, and
    expansion stays unconditional, so a handle written after an agent-driven load still expands to
    exact bytes.
  • veyyon prompt --statements prints 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, because render ends in a format pass 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
    --section at 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 --statements charges 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_STATEMENTS had 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 carries arms/<arm>.statements.yml,
    validated before the run (unknown statement id, a value that is neither text nor null, malformed
    YAML), staged as statements/<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_STATEMENTS replaces 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 to null to 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. null and "" are
    different operations and both are pinned: null removes 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.ts lists 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 --prompts lists all of them. Two packages had none: @veyyon/ai shipped 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/metaharness shipped 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 to packages/ai/src/prompts/ and packages/metaharness/adapters/edit/prompts/, each with a registry beside them where the import is the registration. veyyon prompt --prompts now lists every id from all three product registries grouped by directory, and veyyon prompt --prompt <id> looks a prompt up in whichever one holds it, so dialect/gemma and compaction/summarization-system work 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 like 170000 showed as nothing selected. Custom values are validated and normalized on entry (92 stores as 92%, 170_000 as 170000), 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 legacy thresholdTokens/thresholdPercent fold-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 add with 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 invented SECRET_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 the task tool unless you raise
    subagent.maxNestedSpawnDepth. You can override the blanket limit for one agent through
    subagent.agents.<name>.maxNestedSpawnDepth or the Agents settings editor; -1 remains unlimited.
    Existing maxRecursionDepth values migrate to the equivalent nested-depth policy.
  • tui.scrollIsolation now 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 /copy to 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 /settings under Appearance, Display, or with
    veyyon config set tui.scrollIsolation true; nothing about its behaviour changed when it is on.
  • /secret list renders 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.scrollIsolation description 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.
    mapJsonStrings rewrites 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 in secrets/obfuscator.ts, which reaches 65 modules including an 18-module JSON Schema
    validator (the obfuscator redacts tool schemas). So provider-boundary.ts imported 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.ts reaches three where it reached 66, and tools/read.ts is 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 -p starts without loading the slash-command handlers. Text and ACP mode dispatch every
    message through executeAcpBuiltinSlashCommand, 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 isRecord instead of spelling the same three-clause check
    out inline, and commit/{shared-llm,changelog/generate,analysis/summary}.ts and
    secrets/obfuscator.ts import completeSimple, validateToolCall and toolWireSchema from the
    modules that declare them rather than from the @veyyon/ai entry point. That entry point
    re-exports the whole package, so taking one function from it costs 363 modules;
    commit/shared-llm.ts reaches 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.
    parseTurnBudget is at session/turn-budget.ts and the token accounting is at
    session/context-usage.ts. The /context grid 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 importing computeContextBreakdown,
    computeNonMessageTokens, computeNonMessageBreakdown, computeStoredMessagesTokens,
    estimateSkillsTokens or estimateToolSchemaTokens from
    @veyyon/coding-agent/modes/utils/context-usage should import them from
    @veyyon/coding-agent/session/context-usage; renderContextUsage stays 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 under tools/ import from modes/,
    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 wrote ctrl+o out as a literal, so rebinding app.tools.expand left 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 both ssh output 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, while app.message.followUp ships as ctrl+q and ctrl+enter and the
    handler has always accepted either, so a chord that really submits was missing from the row that
    lists them.
  • config/settings.ts stopped dragging the whole of @veyyon/ai. It is the most imported module in
    the package (528 test files, and every runtime consumer of Settings) 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/stream rather than from the caps
    themselves, THINKING_EFFORTS came from the @veyyon/ai barrel though @veyyon/catalog/effort owns
    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, with config/settings-schema.ts
    down from 371 to 106 and thinking.ts from 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, once packages/ai split the sqlite credential store out of the module that also owns the
    OAuth machinery. session/agent-storage.ts wanted 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-storage as types, which are erased. It fell from 213 to 84,
    and that carried session/session-manager.ts from 482 to 369 and session/session-context.ts from 472
    to 359. session/auth-storage.ts stayed at 215 and that is not slack: it forwards AuthStorage
    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.ts reached 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.ts used MCPManager
    as a type everywhere except one MCPManager.instance(), so reading a static slot cost the MCP client
    and its transports; internal-urls/skill-protocol.ts reads the active-skill snapshot from inside the
    skill loader; internal-urls/memory-protocol.ts wanted getMemoryRoot, a two-line path join, from the
    module that asks a model to summarise a session; and tui/status-line.ts wanted 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.ts and tools/tool-ui-status.ts. Each is re-exported
    from where it used to live, so MCPManager.instance(), getActiveSkills(), getMemoryRoot and
    formatStatusIcon all keep working from their old import paths. An empty slot still means what it
    meant, and still says so: the mcp:// handler reports "No MCP manager available. MCP servers may not be
    configured." with the available resources, and the skill:// handler names the active skills. Measured
    after: read 736, internal-urls 419 (was 911), tui/hyperlink 182 (was 609), tui/status-line 2
    (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. PROMPTS came from prompts/registry.ts, which imports all 143 prompt files by design, for
    one interjection template; and formatOutputNotice came from tools/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.
    wrapSteeringForModel now lives in session/steering-envelope.ts, the module that renders the prompt,
    and the notice wording, the metadata types and the three strippers live in tools/output-notice.ts.
    tools/output-meta.ts re-exports all of them, so no caller changed there; wrapSteeringForModel moved
    import path for its three callers. session/messages.ts is 100 modules,
    session/session-context.ts 107 (was 602) and session/session-manager.ts 155 (was 612), the last of
    which 206 test files import.
    The strippers moved WITH the wording on purpose. stripOutputNotice removes 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.ts is 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.
    getMarkdownTheme and setMarkdownMermaidRendering now live in modes/theme/markdown-theme.ts, and
    the memoised native highlighter both sides need lives in modes/theme/highlight.ts (17 modules,
    taking logger and errorMessage from the modules that own them rather than the @veyyon/utils
    barrel). theme.ts is 272 and still re-exports highlightCode, so that caller set did not change;
    it deliberately does not re-export getMarkdownTheme, 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/ai barrel 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, assistantTextBlocks and instrumentationRank
    are each defined in a module that reaches exactly one, against the barrel's 346, so
    modes/utils/copy-targets.ts, hindsight/transcript.ts and cli/session-stats.ts each fell from
    about 347 modules to 76 on one line; task/agents.ts went 520 to 253 and
    modes/components/settings-selector.ts 783 to 655. Twelve of the fixes did not change their own
    file's number, because those files also import completeSimple or streamSimple and 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.models and argot.disableAboveTokens are now argot.encode.models and
    argot.encode.disableAboveTokens. Those two are the only Argot settings that decide whether a model
    is taught to WRITE shorthand; enabled, autoload, tokenBudget and subagents decide 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 under encode the 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 the encode value 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.ts partitioned every gate variable it could find by regular expression
    over system-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 and subagent.maxConcurrency was 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 claiming renderMermaid.
  • prompt-gate-inputs.test.ts asserts 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 what subagent.maxRecursionDepth is 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 where tui.renderMermaid gates 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), and system-prompt.md no longer feeds any session. A single gated line such as
    an ast_grep preference, 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/whenAny hold 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", and not
    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 CONTRACT is five unconditional XML blocks and EXECUTION WORKFLOW six 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 RUNTIME section 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 the memory://root URL 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: format deletes 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 & Rules was landing directly on # Internal URLs with
    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 -1 that older configs stored to mean "unset" is named in one place. config/settings.ts
    declared its own constant for it beside the one in config/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 own SessionEntry union over them; twelve of the fifteen were identical and three had drifted, so compaction in the other package saw a SessionInitEntry without the spawns and readSummarize this one actually writes and a ThinkingLevelChangeEntry without configured. 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, not JsonValue. It is a deliberately laxer shape than the repository's JsonValue (@veyyon/utils), whose objects never hold undefined, and it needs to be: mapJsonStrings walks 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. JsonRecord is unchanged in shape.
  • veyyon prompt --prompts lists 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 nonEmptyTrimmed from @veyyon/utils. gh.ts wrote it twice, 145 lines apart, for a PR identifier list and for search-query fragments, and autoresearch/helpers.ts had 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. dedupeStrings now adds only uniqueness on top.
  • Host probing moved out of the prompt builder into utils/host-environment.ts. system-prompt.ts is about assembling a prompt, and roughly 280 of its lines were not: spawning lspci and wmic, 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.ts is 1201 lines to 912.
  • firstNonEmpty is in @veyyon/utils rather 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 drops 0, and neither trims. A TERM= exported blank now falls through to COLORTERM for 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 in prompt-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.ts keeps 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.ts use the shared once rather than a module-level let and 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.ts owned 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 now system-prompt-builder/banner-grammar.ts, a leaf that knows no prompt, so prompts/registry.ts no longer reaches into the system prompt's module to ask what a banner looks like. The remaining half is section-registry.ts, since a "block" in that subsystem already means an entry of the string[] buildSystemPrompt returns, and the file contained none. PROMPT_SECTIONS became SYSTEM_PROMPT_SECTIONS for the same reason: it lists the system prompt's sections, not every prompt's, and the PROMPT_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 shared errorMessage already 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-HEAD spelling 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/wire beside the header length it reads, and the WebCrypto byte coercion four packages needed, now asStrictBytes in @veyyon/utils. The token subcommand 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/utils with the function), the token subcommand described above, and the bootstrap veyyon bench and veyyon dry-balance share, 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/utils as readPipeText.
  • 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/julia and 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 -v per-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 docker Step 4/12 line, and every [ERROR]/[WARNING] maven line stay.
  • A read with a bounded line range now says on its last line what it padded: read file:1-3 answers 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 in docs/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 :raw stays byte-verbatim.
  • veyyon gc now lets you set how recently a file may have been written and still be left alone: gc.writeGraceMinutes in your config, or --write-grace-minutes for 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.ts now holds the text import, id and purpose of every prompt in one row each, nothing else may import a prompt file, and veyyon prompt --prompts lists 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, plus subagent/, plan-mode/, rules/, autolearn/, titles/, thinking/, requests/ and bench/. The largest directory outside tools/ is now 17 of 163. A prompt's id is its path, so the ids veyyon prompt --prompts lists moved with the files. The PROMPT_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 soft and /compact remote now 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 summary instead.
  • veyyon update now refuses to replace a binary that is a symlink, instead of silently destroying the link. If ~/.local/bin/vey points 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, or rm the 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.new when 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-5 looks 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, and veyyon config list showed 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 into config.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 -1 to mean "no value". The sentinel made -1 unreachable as a real value, and presencePenalty: -1 is a penalty providers accept. Choosing Default in /settings removes the key, and a config holding the old -1 on one of these keys has it dropped on load, so your effective settings do not change while -1 becomes settable. The seven affected settings are the six sampling knobs and compaction.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-in priority.json chain 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 blanket subagent.model, the definition's own model:, then inherit — resolve in one place with the deciding layer reported. priority.json still 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. /agents shows 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, where space cycles offered / not offered / blocked. An unoffered agent still runs when something names it outright, so /review keeps spawning reviewer; blocked refuses 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 hihg was 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, minimal through max, auto, and Inherit — 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.delegation is off, allowed (the default), preferred, or required, replacing task.eager. off removes the task tool 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 a scout it 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 bare 38% beside it was read as consumption by half its readers. Text presets show tokens on both sides of the slash (47K/170K) instead of 47.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 /context breakdown. 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.
  • /context reports the room left alongside what is used, formats its token counts (272K rather than 272000), 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 were argot.*, and its directory and veyyon's wiring modules were lexpack, so every reader had to learn the mapping. The directory is packages/argot and the modules are argot-wire.ts / argot-cache.ts / tools/argot.ts. Nothing you configure changed: the setting keys were already argot.*. Three things the mismatch had been hiding turned up with it: the handbook's Argot chapter was an eight-byte stub, because SUMMARY.md linked why/argot.md while the real chapter sat at why/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 read 59.3/s and 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 labelled ttft.
  • 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 ✗ failed header. 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 the Exit: N chip 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 undefined still 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 default in storage, interactive as setModel's role argument, and both spellings in scattered inline comparisons, and one line stored default while logging interactive for the same write, so a session-log entry could not be matched to the setting it changed. Callers pass either spelling and resolveModelSlot translates 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 ⚡ ◉ high looked 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). /fast keeps 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 list leaves it out, and config get/set still work but name the key that governs the behavior now. compaction.thresholdTokens, compaction.thresholdPercent, and defaultThinkingLevel are the first three.
  • Optional numeric settings share one definition of "unset". The -1 sentinel 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 every Default row 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.thresholdTokens and compaction.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 becomes threshold: 170000, the percent becomes threshold: 85%, and both retired keys are dropped — so the ambiguity leaves the file without moving your trigger; project configs and --config overlays, 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.
  • /compact subcommands are now the two compaction strategies, summary and handoff. The former soft and remote modes 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 (reasoning finds Default Effort, copy/clipboard finds 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-wide defaultThinkingLevel enum, 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 :level on the role's selector, then the model's row, then the * row, then the model's default) owned by config/effort-resolver.ts rather than written inline at each call site.
  • /thinking and its /effort alias 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.