feat(client): price a file attachment by the handle the model receives

A file block never reaches a model as bytes: dsh projects every one to
`fileHandleText` at request assembly, and the token meter prices that handle in
place of the block's structural JSON price. An attachment therefore costs a
fixed ~60 tokens whatever its size, so the card now shows that price beside the
size — computed from the three facts a log carries (name, byte size, digest
prefix) and priced with the meter's own text arm, now shared with the fold's
message price instead of restated there.

The handle also names the stored copy's read path, which only the live
environment resolves and no log holds, so the row prices the pathless arm and
reads as a lower bound (`≥`) rather than an `≈`. The fold keeps the meter's
fixed structural price for the same block, exactly as the harness does: one
figure answers the request, the other the surface.
This commit is contained in:
bowenliang123 committed 2026-10-13 23:15:39 +08:00
1 parent da43fe48c7
commit dd05bbda4e
9 files changed
+129 -14

No files matched your search

+2 -2
View File
@@ -72,7 +72,7 @@ The plugin lives off data it does not own: the durable session log (event shapes
## Low-level parity
The plugin's low-level logic mirrors the newest supported dsh implementation: token estimation re-fits `dsh-token-meter`'s `estimateSystemMessage` (`estimateSystemTokens` / `estimateSystemContent`), and the TPS chip's first-token rule matches `dsh-llm`'s `assistantStreamFirstTokenTime`. The Context card's occupancy percentage is the composer ring's own derivation — `@deepseek-ai/dsh-client-ui-conversation`'s `contextOccupancy` (`Math.min(100, Math.round(used / window * 100))`, `used` = `contextPressure.projectedTokens ?? pressureTokens`) — read off the same token-meter projection and printed on a 0.1 grid, so the card and the ring can never disagree on the value and only the printed precision differs (the ring reads an integer, the card one decimal). The kernel's helper is internal to its browser bundle with no package export, so the plugin mirrors the formula (`occupancyPercent` in `src/client/headline.ts`) instead of importing it; where the meter's projection key is absent altogether (an older line), the card falls back to its own derived anchor. The Current Context bar's two auto-compaction reserve masks mirror `dsh-compaction-basic`'s own pressure trigger the same way (`src/client/reserve.ts`): `thresholdTokens = floor(min(contextWindow × thresholdRatio, contextWindow − reservedOutput − headroomTokens))`, with the session's durable window (`request/context`'s `contextWindow`) and routed output cap (`request/header`'s materialized `config.maxTokens` — the envelope value the backend's own `reservedCompletionTokens` reads) as the inputs, and that package's two shipped defaults (`thresholdRatio` 0.8, `headroomTokens` 65,536) as the policy: the web composition mounts the backend inside each agent preset's isolated realm, so no root-level plugin can read a deployment's configured policy, and only those defaults are mirrored — and because the backend REFUSES (throws `TargetPressureConfigError`, disabling pressure compaction) rather than clamps when `contextWindow − reservedOutput − headroomTokens` is non-positive, the bar reserves nothing and paints no masks for such a window instead of implying a trigger at zero. The timing card's wait/generation boundary deliberately diverges from that rule: it opens at the first OBSERVABLE instant — the first token, or an earlier `block-start` marker the token packing could not stamp (a redacted reasoning block leaves no chunk behind) — so the marker-tiled decode buckets and the wait/generation split read one window and the legend's shares never double-count it. When a supported line moves these implementations, the plugin's copies are updated to the newest one — the fold's statistics are differentially verified against the harness's own projection values on real logs (see "What each check means").
The plugin's low-level logic mirrors the newest supported dsh implementation: token estimation re-fits `dsh-token-meter`'s `estimateSystemMessage` (`estimateSystemTokens` / `estimateSystemContent`) and its per-block text arm (`estimateTextTokens`, shared with the fold's message price), a file attachment's price re-fits `dsh-llm`'s `fileHandleText` as that request projection prices it (see the attachment-card rule), and the TPS chip's first-token rule matches `dsh-llm`'s `assistantStreamFirstTokenTime`. The Context card's occupancy percentage is the composer ring's own derivation — `@deepseek-ai/dsh-client-ui-conversation`'s `contextOccupancy` (`Math.min(100, Math.round(used / window * 100))`, `used` = `contextPressure.projectedTokens ?? pressureTokens`) — read off the same token-meter projection and printed on a 0.1 grid, so the card and the ring can never disagree on the value and only the printed precision differs (the ring reads an integer, the card one decimal). The kernel's helper is internal to its browser bundle with no package export, so the plugin mirrors the formula (`occupancyPercent` in `src/client/headline.ts`) instead of importing it; where the meter's projection key is absent altogether (an older line), the card falls back to its own derived anchor. The Current Context bar's two auto-compaction reserve masks mirror `dsh-compaction-basic`'s own pressure trigger the same way (`src/client/reserve.ts`): `thresholdTokens = floor(min(contextWindow × thresholdRatio, contextWindow − reservedOutput − headroomTokens))`, with the session's durable window (`request/context`'s `contextWindow`) and routed output cap (`request/header`'s materialized `config.maxTokens` — the envelope value the backend's own `reservedCompletionTokens` reads) as the inputs, and that package's two shipped defaults (`thresholdRatio` 0.8, `headroomTokens` 65,536) as the policy: the web composition mounts the backend inside each agent preset's isolated realm, so no root-level plugin can read a deployment's configured policy, and only those defaults are mirrored — and because the backend REFUSES (throws `TargetPressureConfigError`, disabling pressure compaction) rather than clamps when `contextWindow − reservedOutput − headroomTokens` is non-positive, the bar reserves nothing and paints no masks for such a window instead of implying a trigger at zero. The timing card's wait/generation boundary deliberately diverges from that rule: it opens at the first OBSERVABLE instant — the first token, or an earlier `block-start` marker the token packing could not stamp (a redacted reasoning block leaves no chunk behind) — so the marker-tiled decode buckets and the wait/generation split read one window and the legend's shares never double-count it. When a supported line moves these implementations, the plugin's copies are updated to the newest one — the fold's statistics are differentially verified against the harness's own projection values on real logs (see "What each check means").
## Web client seams
@@ -92,7 +92,7 @@ The guide capsule carries `order`, `title`, `description`, and `icon` (the `guid
The File Activity card's file-name affordance is an optional-column story: the plugin opens the file's preview tab through `ctx.sidebarRight.openResource` (the built-in Files sidebar's own path), building the `dsh-resource://file/…` address with the harness's browser-safe `fileAddressFor` (inlined by the client bundle, exactly as the Sidebar's own file types inline it). Where the preview face is absent (or refuses the address), the name keeps its original system-open behavior; the `sidebar.nav` probe pins the navigation face's spelling.
The Context browser's attachment cards are another optional-column story. A message's `image` and `file` blocks render as one ordered card list, and a click hands the attachment to the harness's preview column through the same `previewAddressOf` → `openResourceVia` path the File Activity card rides. The path itself comes from the plugin's own `/api/dsh-context/attachment` fetch route (same authenticated `/api` fence, Connection exact-route registry, and cold-read gate as the detail and fleet routes): the route answers only an id the SESSION'S OWN log cites (a payload walk bounded in depth and total over hostile input, so an id no logged content block carries — the harness's own gateway is stricter still, reading declared fields per event type — resolves to nothing) and answers the store's named alias. A verbatim file already has one (`attachments.fileHostPath` → `<home>/attachments/v1/files/<xx>/<digest>/<name>`); a normalized image does not — its object is digest-named and suffixless, while the preview column dispatches its viewer by suffix — so the route publishes (or reuses) a named hard link under `<home>/cache/dsh-context/previews/<xx>/<digest>/<name>` and answers that. The link shares the immutable, digest-verified object bytes; the cache is disposable, and a deployment whose attachment store is not host-file-backed, or whose session log cannot be read, answers a typed `null` and the card reports "preview unavailable" instead. Image thumbnails keep riding the harness's own session-authorized `uiConversation.imageUrl` loader; the file-type glyph resolves defensively off the primitives module (`FileTypeGlyph`), and every card degrades to its metadata when a face is absent.
The Context browser's attachment cards are another optional-column story. A message's `image` and `file` blocks render as one ordered card list, and a click hands the attachment to the harness's preview column through the same `previewAddressOf` → `openResourceVia` path the File Activity card rides. The path itself comes from the plugin's own `/api/dsh-context/attachment` fetch route (same authenticated `/api` fence, Connection exact-route registry, and cold-read gate as the detail and fleet routes): the route answers only an id the SESSION'S OWN log cites (a payload walk bounded in depth and total over hostile input, so an id no logged content block carries — the harness's own gateway is stricter still, reading declared fields per event type — resolves to nothing) and answers the store's named alias. A verbatim file already has one (`attachments.fileHostPath` → `<home>/attachments/v1/files/<xx>/<digest>/<name>`); a normalized image does not — its object is digest-named and suffixless, while the preview column dispatches its viewer by suffix — so the route publishes (or reuses) a named hard link under `<home>/cache/dsh-context/previews/<xx>/<digest>/<name>` and answers that. The link shares the immutable, digest-verified object bytes; the cache is disposable, and a deployment whose attachment store is not host-file-backed, or whose session log cannot be read, answers a typed `null` and the card reports "preview unavailable" instead. Image thumbnails keep riding the harness's own session-authorized `uiConversation.imageUrl` loader; the file-type glyph resolves defensively off the primitives module (`FileTypeGlyph`), and every card degrades to its metadata when a face is absent. A card's token row is the REQUEST's price, never the attachment's size: an image row is DeepSeek's own vision calculator on the sent dimensions, and a file row is the handle the model actually receives. dsh projects every `file` block to `fileHandleText` at request assembly (`dsh-llm/content.ts` — no provider takes a file block natively), and the token meter prices that text in place of the block's structural JSON price (`dsh-token-meter/route-pricing.ts`), so a file costs a fixed handle whatever its byte size. The plugin mirrors that sentence verbatim and prices it with the meter's own text arm (`estimateTextTokens`), from the three facts a log already carries: name, byte size, and the digest's first eight characters. The handle's stored-copy path is execution-world state no log carries (`llm.fileRequestText` resolves it live through the attachment store and the fs mapping), so the card prices the pathless arm — the one the harness emits when the environment cannot read the copy — the floor the path-bearing handle only grows from, and the row therefore reads as a lower bound (`≥`) rather than an `≈`. A reference the log proves no name or no byte size for prices nothing and shows no row, exactly like its size row. The fold keeps the meter's FIXED structural price for that same block, which is what `estimateMessage` charges without a route projection; the card answers the request's question and the fold the surface's, so the two figures differ by design, as they do in the harness.
The session jump — a Context Insights session card or an Agent network node bringing its session forward — rides the harness's own selection verb (`openSessionVia` in `src/client/services.ts`): `uiWorkspace.openSession`, the verb the built-in sidebar's row click uses on every supported line. The face is re-proved per call and a deployment serving none (or a hostile face) swallows silently — the verb's own navigation returns the center column to the conversation (`selectPanel(null)`), so a dead jump simply stays on the page. Issue #90 was exactly the retired-spelling trap: the plugin once called the sessions service's own `open(id)`, which the 0.1.6 selection refactor removed from the service (the click then did nothing on `0.1.7-rc.2`), so the jump follows the view owner and the per-baseline `sessionNav` probe pins the verb's signature and the retired spelling's absence.