Align progressive tool disclosure with the discard-on-compaction model:
compaction no longer rebuilds loaded dynamic tool schemas. The boundary
announcement re-lists every loadable name, the model re-selects what it
still needs, and a from-memory call to a no-longer-loaded tool is
rejected by preflight with select guidance.
This removes the keep-all rebuild and its half-trigger budget heuristics
entirely: the post-compaction floor is back to users + summary, which is
structurally outside the auto-compaction trigger band, and the guard
baseline degenerates to summary + reinjected reminders. Every downstream
mechanism already treated the empty loaded set as its consistent base
state (ledger scan, pending clear at the compaction boundary, deferred
extras, preflight wording), so this is a strict simplification.
Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
* fix(agent-core): report EXIF-rotated image dimensions and raise edge cap to 3000px
Image compression now reports original dimensions in the decoded
(EXIF-rotated) space, matching the coordinate system of the sent image
and of ReadMediaFile region readback; previously portrait JPEGs
(orientation 5-8) got swapped width/height in captions. The longest-edge
downscale cap rises from 2000px to 3000px, and the default jimp resize
path is documented as the anti-aliased area-average one so it is not
accidentally switched to a point-sampled interpolation mode.
* test: shrink oversized image fixtures to fit CI timeouts
The 3600x3600 fixtures introduced for the 3000px edge cap nearly doubled
the pixel area jimp has to decode and deflate, pushing the slowest
compression tests past the 5s vitest timeout on CI runners. 3600x1800
keeps every fixture over the cap while restoring roughly the workload of
the old 2600x2600 fixtures that CI handled comfortably.
* test: pin anti-aliased downscale quality with executable guards
A 1px checkerboard probe pins the compressor to full-coverage averaging
at integer and fractional ratios, with jimp's point-sampled BILINEAR
mode kept as the executable aliasing counter-example (it collapses the
50%-gray pattern to solid black at 4:1). Also guards the other classic
downscale bugs: transparent-pixel color bleed, mean-brightness drift,
iterative recompression degradation, and zero-size collapse on extreme
aspect ratios.
* fix(agent-core): report decoded EXIF-rotated dimensions in ReadMediaFile notes
The media note derived its original-dimensions line from the header
sniff, which reports pre-rotation values for EXIF orientation 5-8
JPEGs. The sent image and region readback both live in the decoded
(rotated) space, so portrait photos got axis-swapped coordinate
guidance. Once a decode has happened — compression or crop — its
dimensions now overwrite the sniffed ones.
* fix(agent-core): improve handling of EXIF orientation in image dimensions and metadata
* fix(agent-core): sniff EXIF orientation and step budget fallback through 2000px
Two follow-ups to the EXIF and 3000px-cap changes:
sniffImageDimensions now reads the JPEG EXIF Orientation tag (pure
header parse, both byte orders) and reports display-space dimensions
for orientations 5-8. Passthrough images — never decoded — previously
kept the pre-rotation header size in compression results and media
read notes, disagreeing with the decoded space that region readback
uses.
encodeWithinBudget steps the over-budget fallback through 2000px
before the 1000px last resort. Raising the cap to 3000px had left a
regression window: an image whose 2000px encode fits the byte budget
was sent at 1000px where the old 2000px cap used to send it at
2000px.
* fix(kimi-code): record pasted image dimensions in display space
The TUI paste path recorded attachment and original dimensions from its
raw header parser, which ignores EXIF orientation. For a portrait JPEG
the submit-time caption then contradicted the sent image's aspect and
region readback coordinates were axis-swapped. Dimensions now come from
the compression result, which reports display space on both the
compressed and passthrough paths; parseImageMeta remains only the
format/mime gate.
* feat(agent-core): add image compression and crop telemetry
Every image ingestion path now reports an image_compress event —
outcome (compressed / passthrough fast, guard, unsupported, unhelpful,
error), input/output formats, byte and pixel sizes, EXIF transposition,
and duration — and region readback reports an image_crop event with a
failure classification and the region's share of the original area.
Wiring is per call site via a new CompressImageOptions.telemetry
option, so the outcome split and timing are measured inside the
compressor while each caller only names its source: ReadMediaFile
(tool construction, like GrepTool), MCP tool results (McpOutputOptions),
server prompt ingestion (ICoreProcessService now exposes the host
telemetry client), ACP prompts (session track adapter), and TUI paste
(host.track adapter). Properties are numeric/enum only — never paths
or content — and a throwing client can never affect the compression
result.
* fix(agent-core): run the full JPEG quality ladder at fallback sizes
The fallback rescales encoded only at quality 20, so a JPEG whose
ladder failed at the fitted size collapsed straight to the lowest
quality even when the smaller size left budget headroom for a higher
rung (the realistic window is the 1000px step, where the 4x pixel
drop pays for q80/q60). Each fallback edge now walks the same
q80-to-q20 ladder as the fitted size.
* test: shrink heavy JPEG fixtures and add explicit timeouts
The fallback-ladder test runs ~11 pure-JS JPEG encodes and the EXIF
paste test decodes, rotates, and re-encodes a 6.5MP frame; both sat at
the edge of the 5s vitest timeout on CI runners. Narrower fixtures cut
the pixel area (the ladder test keeps its width above 2000px so the
full fallback chain still runs) and explicit 15s timeouts absorb runner
variance.
* fix(server): scope prompt image compression telemetry to the session
The prompt-ingestion image_compress events were emitted with the bare
host telemetry client, while every agent-side source inherits a
session-scoped client — so prompt_inline/prompt_file events could not
be correlated with their session. The route now wraps the client with
withTelemetryContext({ sessionId }) like rpc/core-impl does for
session telemetry.
* chore(changeset): consolidate image compression changesets
One entry covering the cap raise and the EXIF dimension fix, listed
for both the CLI and the SDK so the SDK changelog's compression
description (previously pinned at 2000px) stays accurate.
* refactor(web): migrate icons to unplugin-icons
Replace the hand-written gen-icon-data.mjs + @iconify/utils runtime
rendering with unplugin-icons build-time imports. The public API
(<Icon name>, iconSvg, IconName, SIZE_PX, NAME_TO_REMIX) is unchanged;
127+ call sites are untouched.
- add unplugin-icons@^23.0.0 (devDep) + Vite Icons() plugin (compiler: vue3)
- rewrite src/lib/icons.ts: static ~icons/ri/* imports (component + ?raw)
for 56 distinct Remix icons across 59 IconName entries
- Icon.vue renders <component :is> with unknown-name fallback
- append ICON_GROUPS export for DesignSystemView catalog
- DesignSystemView: v-for catalog, remove legacy-script references
- delete gen-icon-data.mjs, gen-icon-catalog.mjs, icon-data.ts,
gen:icons script
- remove @iconify/vue and @iconify/utils from dependencies; move
@iconify-json/ri to devDependencies
- drop Icon.vue from check-style ICON_EXEMPT (no hand-written <svg>)
* refactor(web): drop unused NAME_TO_REMIX icon mapping
NAME_TO_REMIX was a Record<IconName, string> table introduced to map
internal icon names to their ri: ids. After the unplugin-icons migration
it has no production consumers — only icons.test.ts imported it (for two
drift tests) and DesignSystemView mentioned it in descriptive copy. The
ICONS table already conveys the same ri: id via each entry's paired
component + ?raw imports (e.g. RiFolderOpenLine / RawFolderOpenLine).
- remove NAME_TO_REMIX const from src/lib/icons.ts (-63 lines)
- remove NAME_TO_REMIX import + describe block from icons.test.ts
- update DesignSystemView §02 copy: describe the import-pair idiom and
stop claiming ICON_GROUPS is sourced from NAME_TO_REMIX
* chore: add changeset for web icon migration
* chore(nix): bump pnpmDeps hash for unplugin-icons
Adding unplugin-icons changed pnpm-lock.yaml, so the fixed-output
pnpmDeps derivation hash is stale. Update to the hash reported by the
Nix Build CI run.
Pass windowsHide:true when spawning the hook process so a visible console
no longer flashes and steals focus on Windows. The Bash-tool path was
already hardened (KAOS buildLocalSpawnOptions); the hook runner missed the
flag even though its own taskkill helper already set it.
Extract the spawn options into a pure builder and add a regression test
asserting windowsHide, mirroring the existing KAOS spawn-options test.
Relates to #1298.
* feat(kosong): honor explicit anthropic max output override
Add claude-opus-4-8 output ceiling and treat explicit max_output_size/KIMI_MODEL_MAX_OUTPUT_SIZE as the final Anthropic max_tokens value. Sync configuration and environment variable docs.
* feat(kosong): drop claude-opus-4-8 ceiling
Revert the newly added claude-opus-4-8 default output ceiling while keeping explicit max_output_size overrides for Anthropic.
* fix(kosong): fall back to nearest lower catalogued minor for Claude max tokens
An uncatalogued Claude minor version (e.g. claude-opus-4-8) previously
dropped straight to the family/major baseline ceiling, so Opus 4.8
resolved to 32k max output tokens instead of the 128k it supports.
Walk the minor version down to the nearest catalogued entry before
using the family baseline, so a newer minor inherits its predecessor's
documented ceiling.
* fix(kosong): catalogue Opus 4.8's documented 128k output ceiling
Known models should resolve from explicit table entries; the
nearest-lower-minor fallback now only covers minors that are not yet
catalogued. Fable 5 already has an explicit entry.
PR #1448 records a per-request trace in wire.jsonl for debugging, with
no user-facing behavior change. Downgrade the CLI bump from minor to
patch, drop the unpublished private internal packages (agent-core,
kosong), and trim the entry to a single honest sentence.
* fix(web): activate slash skills on the new-session screen
The composer now lists workspace skills before a session exists, but
activating one from the empty-session composer silently did nothing:
activateSkill() short-circuits when there is no active session id, so
the command was cleared with no turn started.
Mirror the first-prompt path: when a slash skill is activated with no
active session (but a workspace is active), create the session first and
activate on the new session id. Extract the shared session-creation
block out of startSessionAndSendPrompt into createDraftSession and add a
startSessionAndActivateSkill counterpart; activateSkill now also accepts
an explicit session id so a concurrent session switch can't redirect it.
Unrecognized '/cmd' text still falls through to a plain-text message as
before.
* fix(web): persist draft modes when a new session is opened via skill
The skill-activate request carries only `args`, so for a skill launched
from the new-session composer createDraftSession's local plan/swarm maps
never reached the daemon, and the first skill turn ran at default modes
while the UI showed them enabled.
Persist the draft plan/swarm modes to the new session's profile inside
createDraftSession (by the new session id), and teach persistSessionProfile
to take an explicit session id so a concurrent session switch during the
snapshot load can't write the patch to the wrong session. Plain prompts
already send planMode/swarmMode on the prompt request itself, so this is
redundant but harmless on the first-prompt path. Goal mode is a one-shot
flag consumed per send, not a profile field, so there is nothing to
persist for it.
Add coverage that the profile is persisted before activation when draft
plan/swarm modes are enabled.
* fix(web): await draft-mode profile POST before activating a skill
The previous fix wrote the draft plan/swarm modes to the new session's
profile but fire-and-forget: persistSessionProfile returned immediately
after starting POST /profile, so startSessionAndActivateSkill sent
:activate without waiting. Since skill activation carries only args, the
daemon could process :activate (and start the turn) before
applyAgentState from /profile finished, running the first skill turn at
default modes while the UI showed them enabled.
Make persistSessionProfile return the update promise and await it inside
createDraftSession, so any origin that follows (the skill activation, or
a plain prompt) only starts after the profile is applied. Existing
callers still fire-and-forget via `void persistSessionProfile(...)`.
Test coverage now blocks activation behind a deferred profile POST and
asserts it is issued only after the profile resolves.
* refactor(web): persist draft modes on the skill path only
Move the awaited plan/swarm profile write out of createDraftSession and
into startSessionAndActivateSkill. The create path now only builds the
session and mirrors draft modes into the per-session maps, matching the
old first-prompt behavior: plain prompts already send planMode/swarmMode
on the prompt request itself, so a /profile write there was redundant.
The profile write stays on the one path that needs it: skill activation
carries only args, so the draft modes must be stored on the new session
and applied before :activate is sent.
* fix(web): persist draft permission and thinking for new-session skill
/auto, /yolo, and /thinking on the new-session composer only update
rawState (there is no session to persist to yet). A plain first prompt
still honors them because submitPromptInternal sends permissionMode and
thinking on the request, but skill activation carries only args, so the
first skill turn was running at daemon defaults.
Include permissionMode and thinking in the awaited profile patch alongside
planMode/swarmMode before activating, so the skill turn matches the
controls the UI shows.
* fix(web): start /goal from the new-session composer
createGoal() short-circuited when there was no active session, so
`/goal <objective>` from the empty-session composer silently cleared and
ran nothing — the same bug class as the slash-skill activation fixed
earlier in this PR.
Mirror startSessionAndSendPrompt: when no session exists but a workspace
is active, create one first then target it with the goal profile update
and the objective prompt. Send via submitPromptInternal with the explicit
sid so a concurrent session switch during creation can't redirect it.
Plain prompts already carry their own permissionMode / thinking / plan /
swarm, so no profile fallback is needed here.
* fix(web): use active-workspace fallback for empty-composer /goal
On a fresh-booted empty workspace, load() never writes
rawState.activeWorkspaceId (no most-recent session to anchor it). The
UI still has a usable workspace via the client-wide activeWorkspaceId
computed, which falls back to the first sidebar-visible workspace — but
createGoal read the raw value directly and silently no-op'd when it was
null.
Normal first prompts and skill activations didn't hit this because
App.vue passes the computed activeWorkspaceId in. Make createGoal use
the same fallback so a first-session `/goal <objective>` works in empty
workspaces too.
* fix(web): start /btw from the new-session composer
openSideChat() reads rawState.activeSessionId directly, so `/btw
[<question>]` from the empty-session composer silently no-oped — it
still set detailTarget to 'btw', leaving the side chat panel open but
empty.
Add startSessionAndOpenSideChat(workspaceId, prompt?) that creates the
parent session first, then calls a new sideChat.openSideChatOn(sid,
prompt) which targets the explicit parent session id (race-safe against
a concurrent session switch, like the skill activation case). Route
through it from the empty-composer branch in openSideChatTab.
Side-chat prompts now also carry model / thinking / permissionMode /
plan / swarm (via a shared sendSideChatPromptOn), so a BTW first turn
matches the UI even when the parent /profile is still in flight. Unlike
skill activation, this means the BTW path needs no profile fallback.
Tests: startSessionAndOpenSideChat creates a session then opens BTW on
the new id, works without an initial question, and is a no-op for an
unknown workspace; sendSideChatPromptOn carries the runtime controls
on the submitted prompt.
* fix(web): preserve send queue when creating goals
Switching createGoal from sendPrompt to submitPromptInternal avoided the
activeSessionId race during the empty-composer create window, but it also
bypassed sendPrompt's queue guard: when a goal is created against an
already-active session that is running another turn, the prompt posted
immediately instead of being locally queued.
Restore the guard for the overwhelmingly common case (the goal still
targets the active session): route through sendPrompt when activeSessionId
still matches the resolved sid, which enqueues when the session is
running or a prompt is already in flight. Only fall back to the explicit-
session submitPromptInternal(sid) when activeSessionId moved during the
create window, so a concurrent session switch can't redirect the goal
prompt. The newly-created session is idle+not-in-flight in that branch,
so the explicit submit does not race another turn.
Add a regression test: createGoal against a running existing session
enqueues instead of submits.
* fix(web): coerce thinking for skill/BTW and handle goal creation failures
Three follow-ups from review:
- createGoal now wraps createDraftSession in a try/catch: App.vue invokes
it fire-and-forget, so a rejection from session creation previously
leaked as an unhandled rejection with no operation failure surfaced.
Mirrors the skill / BTW / first-prompt paths that already wrap it.
- startSessionAndActivateSkill coerces the draft thinking level against
the new session's model before persisting the profile (via
coercePromptThinking), matching what the first-prompt path submits.
A value carried over from another/default model (e.g. 'max' from an
effort model) would otherwise be persisted verbatim and the first
skill turn would run at a level the UI wouldn't send for this model.
- sendSideChatPromptOn coerces thinking against the parent session's
model the same way normal prompts do (coerceThinkingForModel against
the model catalog). Model catalog threaded in via a new 'models' dep
on useSideChat. Same reasoning: stale rawState.thinking must not be
submitted raw into a BTW first turn.
* fix(web): clear staged goal mode when submitting explicit /goal
When the empty composer has goal mode staged (e.g. the user runs bare
`/goal`, then `/goal <objective>`) and an explicit objective is then
submitted, createDraftSession copies draftModes.goalMode into
goalModeBySession[sid]. createGoal then updateSession(goalObjective) for
the explicit goal, and sendPrompt(trimmed) re-enters submitPromptInternal
which sees goalModeBySession[sid] still set and POSTs a second
goalObjective. The daemon rejects that as an existing goal, the catch in
submitPromptInternal rolls back the optimistic message, and the user's
objective prompt never lands.
Clear the staged goalModeBySession[sid] flag right after the explicit
update, since `/goal <objective>` has exactly the same effect as the
flag's consumption.
* feat(agent-core): record llm request trace in wire.jsonl
Add three observability record types so every request sent to the model
can be reconstructed from the wire log at the logical-request level:
- llm.tools_snapshot: content-addressed snapshot of the top-level tools
table as sent (post deferred-strip), written once per unique table
- llm.request: one record per outbound request (retries, strict resends,
and compaction rounds included) carrying the effective request params
and hash links to the system prompt and tools snapshot
- mcp.tools_discovered: the server's verbatim tools/list result plus the
agent's gating (allow-list, collisions), deduplicated by content hash
Observability records never feed state rebuild; replay only restores the
write-dedup cursors. The records/types.ts contract now documents the two
record classes explicitly (persisted is not the same as replayed).
Recording happens at the single Agent.generate choke point. The
LLMRequestLogFields side channel gains kind/projection/maxTokens/
droppedCount, chatWithRetry preserves caller-set fields, and compaction
tags its requests. The vis wire view renders the new record kinds.
* fix(agent-core): record the provider-clamped completion cap in the request trace
The llm.request trace recorded the client-requested budget cap, but
chat-completions providers tighten the actual wire value inside
withMaxCompletionTokens (remaining-context sizing, transport ceilings,
model-default resolution) — with the default budget the clamp is active
on nearly every non-empty-context request, so the recorded value did not
match what was sent.
Providers now expose the effective cap they computed as a readonly
maxCompletionTokens field on the clone, and the recorder reads it from
the effective provider at the Agent.generate choke point. This replaces
the side-channel recomputation, which is removed along with the
appliedCompletionBudgetCap helper.
* fix(agent-core): park pre-replay MCP discovery records and hash the collision outcome
Two wire-hygiene fixes for the mcp.tools_discovered trace:
Parking: the real Session ordering connects MCP servers concurrently with
agent construction, so ToolManager can observe a connected server before
agent.resume() has replayed the wire. Recording at that point bypassed
the restored dedup cursor (duplicating a 1-50KB record on every resume)
and appended a stray metadata record ahead of replay. AgentRecords now
exposes a one-shot opened latch — set when replay completes (after the
migration rewrite flushes) or when the first live record is logged — and
ToolManager parks discoveries until then, re-running the dedup check at
drain time. A frozen range-limited replay never opens; those agents are
transient previews.
Collision hashing: the dedup hash now covers the collision outcome, not
just the raw list and allow-list. Collisions depend on which other
servers hold a sanitized qualified name at registration time, so a
server can re-register with identical tools but a flipped outcome; that
gating change must produce a new record instead of being suppressed.
* fix(agent-core): skip the request trace for pre-flight-aborted calls
Mirror kosong generate()'s pre-flight abort check at the Agent.generate
choke point: a call whose signal is already aborted never reaches the
wire (generate throws before dispatching), so it must not leave an
llm.request/llm.tools_snapshot trace or a diagnostic log line claiming a
request was sent. Recording stays before dispatch for every call that
passes the gate, preserving the crash-safety of the trace.
* chore(agent-core): remove a leftover adaptive-thinking override hook
The adaptiveThinkingOverride option was a temporary local hook explicitly
marked for removal before commit. Nothing passes it, so resolution falls
back to the alias-level adaptiveThinking value in all cases; drop the
option and the dead indirection.
* fix(kosong): derive the exposed completion cap from generation kwargs
maxCompletionTokens was a field stored only by withMaxCompletionTokens,
so caps that reach the wire through other paths were invisible to the
request trace: with completion budgeting disabled via env, Anthropic
still sends the constructor-resolved max_tokens (required by the
Messages API), and constructor-level kwargs like OpenAILegacyOptions
maxTokens were likewise unreported.
Replace the stored field with a getter derived from each provider's
generation kwargs — the single source the request body reads — covering
constructor defaults, direct withGenerationKwargs configuration, and
budget application in one place. Kimi mirrors its request-time legacy
max_tokens alias normalization; openai-legacy reuses the same
normalizeGenerationKwargs the request path uses.
* feat(agent-core): add thinkingKeep passthrough for Kimi providers and update tests
The interactive shell entry point dropped opts.skillsDirs when building the harness, so --skills-dir only took effect in prompt mode. Forward it so the flag works in the TUI as documented.
- drop the session-wide absolute drain deadline that gated print-mode turn holds
- hold the turn until background subagents reach a terminal state, bounded by each subagent's own timeout
- fixes late or long-running subagents being abandoned (results suppressed) in long `kimi -p` runs
* fix: stop rendering <system> notes from tool results in the terminal and web UIs
Tool results carry <system> blocks as side-channel notes for the model (ReadMediaFile summaries, Read status, MCP image captions, error/empty sentinels). Keep them in history for the model, but strip them at every core-to-UI boundary so they no longer render as plain text. vis is intentionally left untouched to preserve the model's-eye view for debugging.
* fix: keep error/empty status text visible when stripping tool-result <system> tags
Unwrap the tool error/empty sentinels (<system>ERROR: ...</system>, <system>Tool output is empty.</system>) instead of deleting them: keep the human-readable text and drop only the tags. Otherwise a failed or empty tool result rendered as a blank output, indistinguishable from a rendering bug. The model still reads the wrapped form in history.
* refactor: move tool-result metadata into a structured note side channel
Tool-produced model-facing metadata (ReadMediaFile summaries, Read status
lines, MCP image-compression captions) was baked into tool output as
<system> text, so every UI had to strip it back out and three copies of
the model-view normalization had silently drifted apart.
- ExecutableToolResult gains `note`: content rendered to the model but
never to UIs; records and history now store the raw output plus the
structured isError/note fields
- the model view is rendered exactly once at the LLM projection boundary
by renderToolResultForModel; the transcript and vis hand-copies are
deleted (vis now calls the same function for its model view, fixing
their drifted empty-output checks)
- ReadMediaFile / Read / MCP captions write `note`; tool outputs stay
pure data, and text-only results keep a single text part (note joined
with a newline) so provider tool content stays a plain string
- all UI-side <system> stripping is removed; failed tools show their own
error text with the structured isError flag
- wire protocol 1.4 -> 1.5 migrates existing records' tool-produced
<system> blocks into `note` on resume
* fix: provider-neutral wording, no wire migration, direct optional fields
- "The attached image was downsampled" replaces directional wording that
depended on provider serialization order (inline media vs flatten-and-
re-attach)
- drop the 1.4 -> 1.5 wire migration: legacy records replay verbatim, so
the model view of old sessions stays byte-identical to what the model
originally saw and UIs show the legacy <system> text as-is; this also
removes the risk of the migration misclassifying user data that quotes
tool metadata, and the additive note field needs no version bump
- pass optional result fields as undefined instead of conditional spreads
(repo convention)
* fix: enforce the note contract at the trust boundary; narrow the TUI system-tag guard
- normalizeToolResult now keeps a note only when it is a non-empty string:
tools and finalize hooks are arbitrary JS, and a malformed note (null,
number, object) would previously persist into the record and crash every
subsequent LLM projection of the session. Everything downstream now
trusts note to be string | undefined.
- the TUI tool body suppression matches the full <system-reminder> tag
instead of any <system prefix: reminder piggy-backing stays hidden,
while real output that merely starts with a literal <system> tag (file
contents, MCP text) stays visible, covered through the real
ToolCallComponent path.
* fix: return MCP compression captions as data instead of extracting them from text
compressImageContentParts now returns { parts, captions } — captions come
back from the compressor as structured data and are never inserted into
the parts, so the MCP pipeline no longer pattern-matches text to move
them into the note side channel. Tool output that merely quotes a
caption (a doc, a log, a test fixture) stays verbatim in the output.
Also corrects the stale claim that prompt ingestion uses this helper
(it compresses per image while constructing the part).
* docs: correct the image-compression re-export comment; export CompressedContentParts
The package-root comment still described compressImageContentParts as the
input-stage helper every ingestion site calls; prompt ingestion compresses
per image with compressBase64ForModel / compressImageForModel, and the MCP
pipeline is the walker's only caller. Also export the walker's
CompressedContentParts return type so public-API consumers can name it.
* feat: wrap tool status sentinels in <system> so the model can tell harness verdicts from tool output
The error/empty status text is model-only after the note refactor (UIs
render the raw output and style failures via the structured isError
flag), so the earlier plain-text wording served no remaining audience.
Wrapping the statuses in <system> gives every piece of system-generated
text inside a tool result the same marker:
- failed calls get '<system>ERROR: Tool execution failed.</system>'
unconditionally — the ERROR:-prefix guard is removed, so the harness
verdict can no longer be confused with tool output that happens to
start with error-like text
- empty outputs render as '<system>Tool output is empty.</system>'; the
plain placeholder the loop layer bakes into records is still
recognized and upgraded at projection time
* style: collapse an internal helper docstring per the services subtree convention
* feat(agent-core): enable Preserved Thinking by default on the Anthropic provider
Default thinking.keep to "all" for the Anthropic provider (Claude and Kimi in Anthropic-compatible mode) while Thinking is on, via a context_management clear_thinking_20251015 edit, mirroring the Kimi default. Reuses [thinking] keep and KIMI_MODEL_THINKING_KEEP (env > config > default "all"); off-values disable it.
* feat(kosong): route Anthropic Preserved Thinking through the beta Messages API
Force the beta endpoint (client.beta.messages.create) when thinking.keep is enabled, since clear_thinking_20251015 is only honored there. Also prepend clear_thinking to any existing context-management edits (for example clear_tool_uses) instead of replacing them, keeping it first as Anthropic requires when combining edits.
* docs: clarify Anthropic beta endpoint and compaction keep behavior
Note in code comments and bilingual docs that enabling Anthropic Preserved Thinking routes requests to the beta Messages API (client.beta.messages.create), with keep=off as the escape hatch back to the standard endpoint. Correct the resolveThinkingKeep comment to reflect that compaction shares ConfigState.provider and intentionally carries the same keep.
* test(kosong): cover Anthropic beta endpoint (streaming and forced betaApi)
Add a streaming beta-endpoint capture and a test that withThinkingKeep forces the beta endpoint even when constructed with betaApi: false, pinning down the documented behavior.
* fix(web): clarify archive session confirmation copy
* fix(web): drop delete mention from archive confirmation copy
* feat(web): add archived-sessions restore entry to mobile settings
Mobile used a separate settings bottom-sheet with no archived-sessions
restore surface, so the archive confirmation pointing users to Settings
to restore was untrue on mobile. Add an Archived sessions sub-view to
the mobile settings sheet with search, sort and restore, mirroring the
desktop Settings tab.
* fix(web): refresh mobile archived list and use Input primitive
Refresh the archived-sessions list each time the mobile sub-view opens so
sessions archived from the mobile switcher show up without remounting the
sheet, and replace the hand-rolled search input plus inline svg with the
shared Input primitive.
* fix(web): use Button primitive for mobile archived restore
Render the mobile Archived sessions restore action with the shared Button
primitive instead of a hand-rolled button and scoped CSS, matching the
desktop archived list and the app's design-system rule.
* feat(kimi-web): render cron fire notices as in-transcript cards
Show scheduled-reminder fires as distinct notice cards in the web chat,
both live and after a reload, instead of hiding them. Each card carries
a humanized schedule, a dimmed job id, and a collapsible prompt body.
* fix(kimi-web): isolate cron notice prompt id and route prefixed events
- Give synthesized cron messages a fresh promptId so a fire mid-turn
cannot be reconciled into the optimistic user echo and hide it.
- Add cron.fired to KNOWN_AGENT_CORE_TYPES so event.-prefixed frames
reach the projector and render live too.
* fix(kimi-web): truncate long one-line cron prompts when collapsed
Slice the first line to the collapse limit with an ellipsis when a
single-line prompt exceeds it, so the collapsed state actually truncates
and the expand toggle is not a no-op for common one-line reminders.
* fix(kimi-web): keep cron notices from reconciling into optimistic user echoes
Skip optimistic-echo reconciliation for user messages whose origin is
cron_job or cron_missed: their prompt text can coincide with a still-
optimistic user message, and the loose content match would otherwise
replace the user's turn with the cron notice instead of appending.
* fix(kimi-web): keep in-turn cron injections from breaking tool results
A cron injection steered into an active turn lands inside that turn's
message sequence, between a tool use and its result. Treating it as a
hard user-turn boundary flushed the pending assistant group, so the next
tool result had no group to fold into and the tool rendered without
output. Embed such in-turn cron notices as a block inside the assistant
group instead, and only render a cron at a turn boundary as its own turn.
* fix(kimi-web): only embed cron notices while a tool is in flight
Embedding whenever a group was pending was too broad: on REST snapshots
without prompt ids the whole transcript shares one group, so an idle cron
fire merged into the previous assistant answer and its own reply kept
going in that group. Embed only while the group has a running tool (a
cron sandwiched between a tool use and its result); flush to its own
turn otherwise.
* fix(kimi-web): omit synthetic prompt id on cron notices
The synthesized cron message carried a cron_pr_ promptId that the web
client caches into promptIdBySession for Stop/abort. Because it is not a
real daemon prompt id, it clobbered the active promptId, so Stop first
aborted a nonexistent prompt and only recovered via the error fallback.
Omit the promptId; the reducer already skips optimistic-echo
reconciliation for cron-origin messages, so it is not needed for de-dup.
* fix(kimi-web): keep tool components from jumping on expand or collapse
Also show the scroll-to-bottom button whenever scrolled up, and render a
fallback icon for tools without a dedicated glyph.
* fix(kimi-web): preserve bottom follow during content-only resizes
Late-loading media can grow after scrollKey has run; keep chasing the
bottom on content growth, and only suppress follow during the pinned
expand/collapse window.
Set hyphens: none (with the -webkit- prefix) on body so chat and markdown text never gain a hyphen glyph at a line break; components still control where lines wrap via word-break/overflow-wrap.
Disable the ligatures that coding fonts enable by default (liga/calt/ss01) on native code elements so code renders literally, e.g. != stays as two characters.
* feat(web): render AgentSwarm as an inline tool card
Replace the bottom SwarmCard footer and the messagesToTurns live-skip
with one dedicated inline tool card for AgentSwarm. The card shows a
phase overview plus a per-subagent accordion: live progress while it
runs, parsed aggregated result once it completes (and after a refresh
that has already dropped the live tasks).
Refresh and resync keep member identity metadata (swarmIndex,
parentToolCallId, subagentType, runInBackground) stable across skeleton
task replacement in the reducer, and the .content-wrap flex layout is
hardened against the overflow compression that previously displaced the
footer.
* fix(web): handle swarm review feedback
- SwarmTool: when AgentSwarm fails before producing a structured
agent_swarm_result (e.g. argument validation), render the raw tool
output instead of the "waiting for subagents" placeholder so the
failure cause is visible.
- resolveSwarmMembers: source live members from the AppTask store keyed
by parentToolCallId rather than buildSwarmGroups, which filters out
single-member groups. A resume-only AgentSwarm now streams its live
progress before the final result arrives. The badge counter still
relies on buildSwarmGroups's filter.
* fix(web): carry streamed subagent text into swarm rows
Swarm subagents that stream normal assistant output accumulate it on
AppTask.text (text-kind taskProgress), not outputLines. The new live
member map was dropping `text`, so a still-composing subagent rendered
an empty / stale row until the structured result arrived.
- Add `text` to SwarmMember and thread it through buildSwarmGroups and
swarmMembersByToolCall.
- SwarmTool: prefer member.text for both the row activity preview and
the expanded body; fall back to outputLines / summary.
- Tests cover text propagation through both helpers.
* fix(web): merge swarm result rows and fall back to raw output
Address the two latest swarm review comments:
- Rows: when a parsed agent_swarm_result coexists with live AppTasks
(which the detail panel also depends on), the inline card previously
only rendered the live members. Interrupted swarms can carry
state="not_started" / outcome="aborted" result entries for items that
never spawned a task; those rows were dropped until a refresh cleared
the live tasks. Extract the row model into buildSwarmCardRows and
merge result-only aborted/not-started rows with the live member rows.
- Fallback: when the tool is no longer running but produced no
structured result (argument validation, parser miss, or legacy
legacy transcript), render the raw tool output instead of
"Waiting for subagents…" so the final text / failure cause is
visible to the user.
* fix(web): parse swarm result subagent bodies defensively
Producer writes subagent body unescaped, so a subagent that analyzes or
emits an AgentSwarm snippet can include a literal "</subagent>" inside
its body. The non-greedy regex treated that as the row close and truncated
the body in the result-only path (post-refresh where the AppTask store is
gone).
Rewrite the parser to scan opening tags, then resolve each row's body as
everything up to the last "</subagent>" before the next row's opening tag
(or document end), preserving embedded close-tag strings. Add tests for a
literal "</subagent>" within a single body and across sibling rows.
* fix(web): only count top-level subagent result tags
A subagent body that contains a literal `<subagent ...>` tag — for
example emitting an AgentSwarm/XML snippet — was being pre-collected as
another result row, splitting the real body and producing duplicate /
bogus subagents after refresh where the AppTask store is gone.
Rewrite parseSubagents with a depth-tracking tokenizer: scan every
`<subagent ...>` / `</subagent>` token in order, push a real row frame
only at the outermost level, and treat openings / closings while nested
inside another body as body text. Drop the now-inaccurate "literal
</subagent> without matching open" regression tests; replace with tests
that verify a balanced nested snippet stays inside the parent body and
does not register as a separate row.
The desktop composer toolbar renders every control on one row and relies on
overflow:hidden to fit, so between the mobile breakpoint and a wide window it
clipped its own content. Shed secondary ink below 980px (the context readout
moves into the ring tooltip, the model name truncates earlier, permission is
capped) and keep the context ring visible on phones too, instead of sending it
to the settings sheet.
* fix(web): reconcile session from snapshot on reopen
* fix(web): discard stale snapshot when a newer prompt races reopen
* fix(web): harden reopen snapshot against first-open and optimistic-send races
* fix(web): keep evicted reopens subscribed when a snapshot races
* fix(web): let resync snapshots bypass the reopen staleness guard
* fix(web): force-apply the snapshot after an undo
* fix(web): gate session reopen on durable seq instead of updatedAt
* refactor(web): always rebuild reopened sessions from a snapshot
* fix(web): sharpen reopen snapshot discard and skip rebuilds mid-stream
* refactor(web): unconditionally apply session snapshots, drop the staleness guard
* fix(web): preserve loaded older messages when reopen snapshots apply
* feat(server): support restoring and listing archived sessions
- add a `:restore` session action that clears the archived flag in state.json and returns the restored session
- add an `archived_only` list query param, mutually exclusive with `include_archive`, that post-filters to archived sessions
- keep the implementation in the server layer as a temporary measure until agent-core exposes restore natively
* fix(server): paginate archived-only sessions before response
* feat(web): add archived sessions page in Settings
Browse, search, filter by workspace, sort, and restore archived sessions
from a new Archived tab in Settings, backed by the server archived_only
list and :restore action.
* fix(web): keep archived Load more visible when a page filters to empty
When a search or workspace filter empties the loaded archived page, the
Load more button was hidden inside the non-empty branch, so users could
not fetch older pages to find a match. Move the button out so it stays
available whenever more archived pages exist.
* fix(server): preserve after_id bound while draining archived pages
Draining an archived_only request that starts from after_id would switch
to before_id and cross the pivot, reintroducing the pivot and older
sessions. Take a single filtered page for after_id instead of draining
past the lower bound.
* fix(server): drain archived_only within the after_id bound
An archived_only request starting from after_id now keeps paging toward
older sessions until it reaches the pivot, instead of treating the first
page as exhaustive. The loop stops as soon as it encounters the pivot
session itself, so it never reintroduces the pivot or anything older.
* feat(web): drain all archived pages for global search and sort
When the user searches, sorts, or changes the workspace filter in the
Archived settings page, fetch every remaining archived page first so the
client-side filter and sort run over the full set rather than only the
pages loaded so far.
* refactor(web): load all archived sessions upfront instead of paginating
Fetch every archived session once when the Archived settings tab opens and
drop frontend pagination entirely. Search, sort and workspace filter now run
over the full set, removing the empty-page and cursor bookkeeping that
previously caused bugs.
---------
Co-authored-by: qer <wbxl2000@outlook.com>
* fix(kimi-web): keep composer caret visible when input is empty
The composer textarea coloured its empty state with `--faint`, and with no caret-color set the caret inherited that faint colour and nearly vanished until the first character was typed. Pin the caret to --color-text so it stays readable regardless of the placeholder state.
* fix(kimi-web): use currentColor for done todo strikethrough
TodoCard pinned the done-state strikethrough to --color-line-strong, which is lighter than the faint text in light theme and darker in dark theme, so the line looked washed-out and broken against the text. Drop the override so the line inherits the text colour, matching TasksPane and the design-system examples.
* fix(kimi-web): do not reserve width for hidden workspace row actions
The workspace header's more/add buttons were hidden with opacity: 0, which kept them in the flex layout and permanently reserved ~60px on the right, so the workspace name (flex:1) truncated long before the row was full. Hide them with display: none and restore display: inline-flex on hover/focus/open, so the name fills the row and only truncates once the buttons actually appear.
The same actions remain reachable via the right-click menu and the section kebab, so removing them from the tab order when hidden is acceptable.
* fix(kimi-web): keep workspace header row height stable on hover
Lock .gh-top to the sm IconButton height (26px) so revealing the hover actions no longer grows the row and nudges the path line and the groups below it. Follow-up to the display: none change: that freed the horizontal space but let the row height collapse when the buttons were hidden.
* chore: add changeset for kimi-web UI fixes
* fix(kimi-web): restore keyboard access to workspace actions
Revert the display: none change (and the min-height that accompanied it) for the workspace row's hover actions, going back to opacity-hidden buttons.
The display: none approach removed the buttons from the tab order, and the 'create in workspace' add button has no keyboard-accessible alternative in the right-click or section menus, so keyboard users lost that action. Restoring opacity keeps the buttons focusable again, at the cost of the workspace name truncating a little earlier to reserve their space.
Addresses the P2 review on PR #1423.
Keep the command preview in the body after the result lands so a multi-line command with short output no longer collapses the card. Render the command in textDim with a shellMode $ and the result one shade dimmer in textMuted, and simplify the header to "Running a command" / "Ran a command".
* feat(agent-core): feed AskUserQuestion answers back as question text and option labels
The flattened answers record the model receives was keyed by synthesized
ids (q_0 / opt_0_1), forcing a cross-message positional lookup against the
original tool call to understand what the user picked — both unreadable in
transcripts and a real model-misreads-the-choice badcase.
- toAgentCoreResponse now takes the original broker request and translates
wire ids back to question text (keys) and option labels (values);
unknown ids are kept verbatim, missing request falls back to raw ids
- wire protocol unchanged: clients still answer with option ids; the
resolve route reads the pending request before settling it
- question texts must be unique per call and option labels unique per
question, enforced in the tool execution path (AJV cannot express the
zod refine) and mirrored on the exported schemas
- web transcript card resolves both the new label form and legacy id
transcripts; TUI and ACP paths already produced the text form
* fix(agent-core): align multi-select answer join across clients and harden question schema
- Join multi-select labels with ', ' in the server translator, matching
what the TUI reverse-RPC path already emits, so the model sees one
format regardless of which client answered
- Trim segments in the web transcript resolver before label matching:
TUI-answered multi-select transcripts (', '-joined) previously lost
their highlight to a spurious leading-space Other row
- Move the question-text/legacy-q_<i> answer lookup out of the SFC into
askUserToolParse as answerFor(), per that module's testability intent
- Require non-empty question text and option labels (.min(1)) so empty
strings are rejected by AJV at the tool boundary instead of failing
deeper in the protocol layer
* fix(agent-core): resolve option ids only within the answered question
The translator's option-id lookup was a single flat map across all
questions, so a stale or malformed response pairing one question with
another question's option id (q_1 + opt_0_0) was silently translated
into a label that was never offered for that question. Scope the lookup
to the answered question's own options; cross-question and unknown ids
now both pass through verbatim, staying diagnosable.
* feat(agent-core): progressive tool disclosure via select_tools
Keep MCP tool schemas out of the immutable top-level tools[] and let the
model load them on demand, preserving the provider prompt cache:
- kosong: Message.tools (append-only load primitive, serialized as Kimi
messages[].tools with type:function wrapping and no content),
Tool.deferred (stripped once in generate() so loaded tools stay
executable without re-entering the top level), select_tools capability
bit (UNKNOWN/catalog default false).
- select_tools builtin: load-by-exact-name, three-branch semantics
settled per name (Loaded / Already available / Unknown), schemas read
from the live registry, injection-origin schema messages survive undo.
- ToolsDiffInjector: <tools_added>/<tools_removed> announcements at turn
boundaries and post-compaction, folded from history (undo/compaction/
resume self-heal), appended only when the loadable set changes.
- Loaded-tools ledger = history scan + defer-window pending set (cleared
on /clear); loop re-reads the executable table per step so a selected
tool dispatches on the next step of the same turn; preflight
distinguishes not-loaded from loaded-but-disconnected.
- Cross-cuts: projection strips protocol context for non-select_tools
models (lossless mid-session model switch both ways), compaction
filters it from the summarizer input and rebuilds loaded schemas
keep-all after folding, token estimation counts message.tools, request
logging reflects the post-strip wire tools.
- Three-condition gate: capability.select_tools x capability.tool_use x
tool-select experimental flag (KIMI_CODE_EXPERIMENTAL_TOOL_SELECT).
Any gate closed reproduces the inline request byte-for-byte; all
current models keep the capability off, so behavior is unchanged
until a supporting model is catalogued. The SDK catalog-to-alias
mapping forwards the capability so catalog-driven setups can enable it.
* feat(kosong): skip tool-declaration-only messages in non-Kimi providers
Message-level tool declarations (messages[].tools) are a Kimi wire
feature. The other providers' explicit field construction already keeps
the tools field off the wire, but the content-free leftover message
would be rejected (OpenAI: system message without content) or serialize
as a garbage <system></system> turn (Anthropic/Google system-to-user
wrapping). Skip such messages entirely via a shared predicate; a message
that also carries content only loses the tools field, as before.
Unreachable in kimi-code (the projection gate strips dynamic-tool
context for models without the select_tools capability before any
provider sees it) — defense-in-depth for direct kosong consumers.
* fix(agent-core): survive runtime flag flips and align tool table with post-compaction state
Two fixes from PR review:
- Register select_tools unconditionally and gate only its exposure in
loopTools. The tool-select flag can flip at runtime (config reload
calls setConfigOverrides on the live resolver) without
initializeBuiltinTools re-running; previously the disclosure shape
activated while the tool itself was unregistered, cutting the session
off from MCP entirely until a model/cwd change rebuilt the builtins.
A profile listing the name explicitly still never surfaces it in
inline mode, and execution guards the flip race defensively.
- Resolve the per-step tool table AFTER beforeStep, next to
buildMessages. beforeStep can run full compaction, which trims loaded
schemas and rewrites the ledger; a table captured before it could
still dispatch a tool whose schema the model no longer has. The
executable table and the request messages now always reflect the same
state, so a trimmed tool is rejected with select guidance instead of
executed.
* fix(agent-core): drop unused Tool import in dynamic-tools
* fix(agent-core): baseline compaction guard after post-compaction reinjection
The reinjected reminders (loadable-tools manifest, goal) are re-appended
after every compaction, but the nothing-new-since-compaction baseline was
captured before injectAfterCompaction. With a large manifest the guard
could re-trigger auto-compaction against a floor that cannot shrink.
Raise the baseline to the true post-compaction floor once reinjection
completes; the earlier capture stays as a fallback when reinjection
throws.
---------
Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
Default `thinking.keep` to "all" when Thinking is on so prior `reasoning_content` is kept across turns. Add `[thinking] keep` to config.toml and keep `KIMI_MODEL_THINKING_KEEP` as an override (env > config > default); off-values disable it.
* fix(tui): keep input anchored after slash command menu closes
Force a full re-render when the slash command menu closes, but only when the session content already overflows one screen; skipped under tmux. Detect the close edge from a render frame so asynchronous closes (Backspace deleting the leading slash) are covered too. Apply the same overflow/tmux gating when restoring the editor from selector panels.
* fix(tui): measure overflow against restored editor tree
Address Codex review: the overflow probe ran before the editor container was swapped back, so it counted the tall replacement panel and forced a full clear/home even when the restored content fit on one screen, yanking the editor to the top. Measure after the editor is mounted instead.
* fix(tui): redraw when content exactly fills one screen
Address Codex review: the guard skipped the forced redraw when the post-close layout is exactly one screen, leaving the editor shifted up because the differential renderer keeps the old viewport offset after a shrink. An exact fill is safe to clear (no blank tail), so redraw when content fills or overflows the viewport, and cover it with a test.
This package only ever contained a package.json with no sources, dependencies, or scripts, and nothing in the repo imports it (the CLI uses @moonshot-ai/migration-legacy instead). Remove the directory and drop its entries from flake.nix and the changeset config, then refresh the lockfile.
* fix(tui): complete @ file mentions across additional workspace roots with fd
When additional workspace directories are added via /add-dir, @ file completion fell back to a readdir-based scanner capped at 2000 entries, so deeply nested files in large projects never appeared. Route @ completion through fd across every root instead, keeping the query pushed down to fd and deduplicating by absolute path. The readdir fallback remains for when fd is unavailable.
* fix(tui): preserve per-root full-path fallback for @ mentions
Address review feedback: decide the scoped-versus-full-path fallback per root instead of globally. When one root has the scoped directory but another does not, the latter still runs a whole-tree --full-path search with the original query, so a match that only exists under that root is not hidden just because a sibling root happens to contain the prefix directory.
* fix(tui): fall back to filesystem when fd binary is not executable
Address review: when fdPath is non-null but the binary cannot be spawned (managed fd removed or lost execute permission), @ completion returned null because pi-tui swallows the spawn error into an empty result, so the catch never ran. Probe fd with accessSync(X_OK) before delegating and use the filesystem fallback when it is not executable, while still returning null for genuine no-match results.
* fix(tui): trust bare fd command names when probing executability
Address review: when fd is discovered on the system PATH, detectSystemFdPath returns the bare name (fd/fdfind). accessSync checked that literal string relative to cwd and never searched PATH, so a valid system fd was treated as unavailable and @ completion fell back to the capped scanner. Trust bare names (spawn resolves them via PATH) and only probe absolute/relative paths, which is how the managed fd is referenced and which can go stale.
* chore: remove accidentally committed plan files
* test(pi-tui): stabilize paste-burst test by freezing the clock
The paste-burst heuristic uses an 8ms inter-character interval that a slow or busy CI runner can exceed between synchronous handleInput calls, which resets the burst and lets Enter submit. Freeze Date so the synchronous keystrokes always register as one burst, making the assertion deterministic.
* chore: ignore top-level plan directory
A top-level Teleport in the sidebar template made the component multi-root, so v-show could not apply display:none and the collapsed sidebar stayed mounted at the rail width, squeezing the conversation. Move the teleport inside the aside so the sidebar is single-root again.
* feat(web): render AskUserQuestion result as an option list
Parse the AskUserQuestion tool result and echo the full option list, highlighting the chosen option(s) and dimming the rest, instead of dumping raw JSON. Handles single/multi select, free-text Other answers, and the dismissed state. Answers are zipped back to the input questions by index since the input carries no ids.
* fix(web): drop the stray indent in the tool-call card body
The expanded body of every tool-call card carried a hard-coded 36px left indent (a magic number meant to align with the header name). Drop it so expanded content aligns with the header's 11px padding instead. The design-system preview page mirrored the same rule and is updated to match.
* fix(web): align markdown diff block with the design system
Restyle the local ```diff renderer in chat Markdown to match the ~/diff panel: code text keeps the normal ink colour, the +/- sign carries the add/del colour, and rows use a soft background with an inset accent bar instead of dyeing the text green/red. The header and copy button now match the standard code-block chrome (height, hover, focus ring).
* fix(web): match the markdown diff block chrome to code blocks
Make the local markdown diff renderer's shell identical to a regular code block: swap the text copy button for the same icon button, inherit the header's text-xs sizing, and keep the container / header / code-area padding, background, radius and shadow in lockstep with .code-block-container and .code-block-header. The diff-specific row tinting (soft background, inset accent bar, coloured sign) is kept since that is what makes it a diff.
* fix(web): fall back to raw output for non-answer AskUserQuestion results
Background launches and error cases return plain-text tool output (task_id/status lines, or a failure reason), not the { answers } JSON the card expects. The card used to render an empty, fully-unselected option list in those cases, hiding the task id or error. Now the card only renders the option list when the output parses as the answer payload; otherwise it shows the raw output. Addresses review feedback (P2).
* fix(web): localize AskUserQuestion result labels
The card hard-coded user-visible strings (Dismissed, answer/answers, Answered, the (+N more) summary), so Chinese-locale transcripts mixed in English. Move them into the en/zh tools locale files and read them via t(). Addresses review feedback (P2).