Add the per-user overviewAutoSteps preference (100/200/300 steps, or off; 200 by default) to the plugin settings card. Once a session's retained step count reaches the threshold, the trend switches to its Overview ribbon and clears the mutually exclusive DNA/Adaptive faces; the auto activation fires once per session so a reader's own toggle-off sticks. The threshold counts raw retained steps, independent of the step/turn granularity.
The tooltip and shared type still described the pre-#78 scheme (peak at
list price, off at half), while models.dev lists DeepSeek's off-peak
rates and the estimator doubles the peak bucket. Correct the wording in
both locales, the cost type, and the READMEs; only DeepSeek's peak
buckets double, every other provider prices at list.
Express the peak clock in Beijing Time directly (Monday-Friday
09:00-12:00 and 14:00-18:00, weekends off-peak) and rename isPeakUtc to
isBeijingPeak. The UTC-to-Beijing refactor is behavior-preserving, so no
stateVersion bump is needed. Statutory holidays are deliberately not
modeled; record that limitation in docs/compatibility.md.
Refs #178
Context Insights now reads contextActivity for every range-scoped figure: a step's settlement, tool run, and wall time book on the local day its step/start fired, so a step crossing midnight stays on its start day. The KPI band, Token Stats, and session selection share the daily ledger; turns, tool calls, and step wall time join each day, and contextActivity's stateVersion moves to 5 so cached rows cold-refold. The Activity heatmap drops the Steps metric for daily token volume; Timing Stats stays session-lifetime and is labeled as such.
Refs #172
The capsule could ask the platform for a balance it would never get, with a
credential it had no business reading.
The fold matched its settings row by SHAPE — the first section carrying a
top-level `apiKeyEnv` — so a row belonging to another provider supplied the
credential, and that row's `baseURL` was used verbatim as the API root, so a
path-carrying endpoint (`/v1/tools`, `/anthropic`) asked for
`<baseURL>/user/balance` and the capsule stayed empty.
The fold now takes only DeepSeek platform rows: the `llm` provider directory
names the API-key model's own row (whatever the entry is called), and dsh's
stock entry ids are the fallback. Another provider's key is never read and
never sent, and a row whose credential does not resolve sends no request at
all.
The endpoint is the platform ROOT: only the origin of the row's endpoint
override counts, so the account route is always `/user/balance` on the
deployment's own DeepSeek origin. The projection is read redacted, so a row's
literal key never enters the fold.
The compat matrix pins the seams this reads: the redaction option, the
provider directory, and the stock entry ids each line ships.
Fixes#176
The client POSTed every plugin route as a root-absolute /api/dsh-context/*
path, which stripped a reverse proxy's /app/dsh prefix at the gateway —
every poll 404ed at it, the panel retried on an aggressive backoff, and
the deployment fell into the issue #159 reconnect loop. The six route
constants now live in one module as their browser-relative spellings:
fetch resolves them against the page's document base, the same idiom
dsh's own stream/socket clients ride, so a prefixed proxy keeps its
prefix and a direct deployment resolves to the exact same URLs before
and after. A host/client pairing spec pins each spelling to its route
registration, and docs/compatibility.md gains the §5.8 client-seam rule.
Fixes#159
The Fleet task board's opened card boards its owner's report-back from a
wire excerpt the route clips at 300 chars, so expansion could never show
the whole text even though DSH itself hands the fold the whole log. The
fleet route now reads a `full: true` body whose prompt/reply skip the
excerpt caps (only a hostile-payload bound stands), and the task board's
unfold asks for it once per session per mount: the excerpt renders
first and swaps in place on landing, the section meta carries
loading/excerpt-only states, and the scan never pays for the full read.
A cold /detail request folded the whole log synchronously inside the request
(~0.35s per 30k events on a fast host) and re-folded on every view. The fold
now yields to the event loop every 1,000 events (the result is asserted
byte-identical against a buildTimelineDetail reference), and the folded
payload is cached keyed on the observation's (persistence instance, session
id, durable revision) tuple — the same trust tuple dsh's own observation
reader uses — so an unchanged cold session re-serves in ~1ms while a changed
log, a swapped backend, an observation without a revision (a live-race cut),
or a config remount always refolds. The cache is bounded at four payloads
(each bounded by the fold bounds) and lives in the watch closure.
The observation lease now disposes on every path including the early returns
(a latent pin on hostile faces is gone with the restructure).
The pass used to hold the host at ~60% duty for the whole corpus window (a
fixed 100ms sleep between full-log folds). Rests are now proportional to the
measured work (at least a 25ms floor) at both granularities — between slices
and between sessions — capping the pass near 50% duty, and every 8s of
cumulative fold work the pass takes a logged 2s recovery pause and resumes on
its own (the issue's 'bounded, background, visibly paused' ask). Slice work is
billed exactly once across the two granularities, and every sleep wakes early
on abort so dispose never waits out a pause.
Pacing is injectable (BackfillPacing) for deterministic tests, mirroring the
cold-read gate's injectable reading; the sliced-fold suite moves to its own
spec file with the shared rig extracted to tests/host/helpers/backfill.ts.
What gets folded and the final cache rows are unchanged — only when the loop
breathes.
A whole-log cold restore lands on the host event loop as one uninterruptible
synchronous block (~1s per 30k events on a fast host, more on a Windows
desktop), and a 100+ session backfill strings those into a window the desktop
renderer watchdog cannot survive (issue #145: 118 sessions, 47s, renderer
killed).
Sessions over 2,000 events now fold in 2,000-event in-memory slices through
the public registry.restore, yielding between slices, with exactly one durable
write-back at the end (sessions.prepare + registry.hydrate with the rows
already at the log end, then sessionProjectionCache.write). The composed rows
are byte-identical to one single-shot coldSnapshot (asserted with the real
registry + the plugin's real units), no intermediate durable rows land, and a
crash mid-fold keeps the single-shot failure semantics. A session going live
mid-fold skips silently; a write-back failure stays fail-soft.
Faces are re-proved per deployment and any absence (older/unknown harness)
keeps the single-shot coldSnapshot path; all four faces exist on both
supported baselines (0.1.7-rc.2, 0.2.0-rc.2), verified against the tags.
A user message's image and file blocks now render as one ordered attachment
block in the Context browser: images keep their session-authorized thumbnail,
other files show their type glyph, extension, and stored size, and the row
chip counts every attachment. One click IS the preview — the card hands the
attachment's named host path to dsh's own preview column (the File Activity
card's `previewAddressOf` → `openResourceVia` path), which renders markdown,
code, PDF, office, and image viewers under the file's real name. A refused
open reports on that card alone; the image lightbox and the per-type modal it
replaced are gone with `images.tsx`.
The path comes from the plugin's own `/api/dsh-context/attachment` route (the
same authenticated `/api` fence, Connection exact-route registry, and
cold-read gate as the detail and fleet routes). It answers only an id the
session's own log cites — a depth-bounded, total walk over the event payload
(user content, tool-result blocks, spliced inbox deliveries) — and answers
the store's named alias: `attachments.fileHostPath` for verbatim files, and a
published-or-reused named hard link for images, whose objects are digest-named
and suffixless while the preview column dispatches viewers by suffix. The
link shares the immutable object bytes and lives in a disposable cache
(`<home>/cache/dsh-context/previews/`); a store that is not host-file-backed,
or a media type the harness never admits, resolves the typed null the card
reports as an unavailable preview.
`Section` moves to its own module so the attachment block heads itself
without importing the tool card, and the compat matrix probes `FileTypeIcon`
alongside the icons it already pins.
The Current Context bar showed a single hatched band mirroring dsh's
default 80% threshold ratio, which reads wrong on any window where the
pressure budget binds instead (small windows, or a large routed output
cap). It now renders the two real reservations the harness spends the
top of the window on, priced by dsh-compaction-basic's own min() rule:
- the compaction headroom mask (mirrored default 65,536 tokens), then
- the routed request's output reservation, read from the durable
request/header's materialized config.maxTokens rather than guessed.
The output cap folds off request/header at fold time, so the timeline
projection's stateVersion bumps 27 → 28 to refold cached rows from the
durable log. Geometry lives in a new client reserve module; the bar
tiles [threshold, window] with the two adjacent masks, each with its
own name-plus-length tooltip.
Two new per-user preferences, `promptPrefix`/`promptSuffix`, hold free text
joined at the very start / end of the harness's assembled system prompt for
all agents (subagents included) — a per-deployment persona or behavioral rule
riding the harness's own prompt registry instead of session-log surgery:
- src/host/promptInjection.ts registers two global prompt sections through a
deferred `ctx.inject(['systemPrompt'], …)` (a composition without the
registry degrades to a no-op; the registration unwinds with this plugin's
fiber). Orders anchor on the harness's own section-order registry, so a
central renumbering keeps the prefix first and the suffix last. Text is
registered `interpolate: false`, so `{{...}}` in user text stays literal.
- The section text providers read the volatile config references the loader
rewrites in place on preference edits, so an edit applies from the next
turn without remounting the entry; new sessions carry the text from their
first turn through the harness's own system-prompt projection.
- The settings card grows two free-text rows (local draft, committed on blur
and on unmount; a host push never clobbers in-progress typing), zh/en
strings, and the served-value sanitize/degrade path for the string fields.
Verified against a running harness: the committed text persists in the
profile layer, and a new session's system prompt starts with the prefix and
ends with the suffix. docs/compatibility.md documents the seams, and the
compat matrix probes them on both supported lines.
Split the Agent network card into graph + cursor-following inspector
(delegation prompt, latest reply, descriptor, owned tasks, comms), add
the members board and the status-grouped task board off the agentTeam
projection with inline dependency chains, a filterable/sortable roster,
and an on-demand comms timeline over relay/mailbox/settled records.
Data rides a new /api/dsh-context/fleet route (one observeSession read
per session through the shared cold-read gate); hover, pin, owner
filter, and task focus are lifted to the view so every panel steers the
same inspector.
The preferences card gains a "Fleet tab" row (Show/Hide, default Show): the tab is
registered or unwound live, and the placement preference still has the final say —
'sidebar' serves no conversation tabs at all. The ninth preference field rides the
same Config schema and settings transport as the rest, including the fail-open read
(a value the plugin cannot read degrades to Show) and the rollback when a write is
refused.
The three on/off rows (entry visibility, the Fleet tab, the duration curve) now
share one row shape and one set of Show/Hide labels instead of a copy each, and the
mount/unmount/subscribe watcher both gates ride is one helper (toggleGate.ts) —
watchInsightPage and watchFleetTab were the same skeleton twice, down to the
registration-handle guard.
One preference now costs several coordinated edits in the settings store, so the
snapshot comparison is a shallow compare over the state's own keys and the refused-
write rollback is one table lookup, not a chain that has to grow with every field.
The stats board's family cell hands its jump to the caller: statsContext no longer
reaches into the tab bar for the Agent Network view. contextView owns the targets —
the Fleet tab when the preferences serve one, else the right Sidebar panel that
keeps the card inline — and drops the cell's button role when it serves neither, so
a hidden tab can never leave a dead button behind.
RegistryDriver's constructor took a Baseline and stored it as a public
field it never read, and registry.spec.ts looped the whole suite over
BASELINES to pass it in — so eleven tests ran twice, identically. Every
supported line shares one documented registry contract, which is what the
driver already mirrors; the driver now takes no baseline and the suite runs
once. The per-tag complement, the ACTUAL dsh registry sources, still runs
in the compat project.
The contextActivity view copied each day entry shallowly while its own
comment promised the registry, the cache writer and the wire could never
mutate the fold's record. Since stateVersion 2 and 4 the entry carries a
four-level `cost` ledger and a `skills` table, and both stayed aliased to
the persisted state — the exact rule foldWire.ts states for its own served
fields and honours by copying every level of `cost`.
The copy is conditional-spread throughout: one `undefined`-valued property
fails the whole lossless-JSON push. The ledger walk moves beside
addBilledUsage as its mirror, so the timeline and activity halves price and
serve through one implementation.
A durable tool/call naming itself `__proto__` reached bumpToolTotals, whose
Object.hasOwn guard passes for it — the key is never an own property. The
plain assignment then set the tally's prototype instead of booking a row:
the count was lost, the 16-name cap never saw the key, and foldWire's
`for...in` copied the inherited `calls`/`ms` as `{}`, which the strict
tool-timing schema rejects. The registry parses every served view with no
error boundary, so that throw stalls the unit's push feed — the "loading"
forever failure compatibility.md designs out, and the same defect the
compat matrix already pins as `poisonedStarves`.
The client boundary has skipped this name since issue #44, with a
regression test beside it; the host fold, where the untrusted name first
lands, did not. The hostile fixture needs no second tool run to reproduce:
a later one re-clones the tally through a spread, which launders the damage.
fold.ts held the persisted state shape, the retention bounds, the untrusted-usage
sanitizer with its accumulators, the surface projection, and the served-value
builders at 1234 lines. The state, the metering accumulators, and the wire builders
move into flat siblings; applyTimeline and the surface projection stay together
because the clone contract they enforce spans both.
The metering extraction also unifies BilledUsage with the shared CostBucketTotals:
one four-bucket vocabulary, so the accumulator input is spelled `uncached` like the
totals it feeds. The persisted and wire shapes are unchanged (both already carried
`uncached`), so no stateVersion bump and no cached-row invalidation.
Production callers import the owning sibling; fold.ts keeps re-exporting the
pre-split surface for the specs.
Apply the AGENTS.md comments guideline to every .ts under tests/: remove
what the code already expresses, keep only what it cannot.
Removed: spec-header coverage inventories the describe/test tree already
names; comments restating the next line's assertion or its message; helper
JSDoc whose body already says it; within-file duplicates; five stale
claims (trendChart's "six aggregated turns" against agg.length === 8,
agentGraph's "six categories" against CATS' seven, fold-timing's "cheapest
call LAST" against a loop that feeds it first, fold-surface's unreachable
plain-JSON note, contextView's "two turns" for a one-turn fixture).
Kept and condensed: the projection-freeze class, strict wire/state-schema
hazards, retention and pricing windows, pairing rules, hostile-input
rationale, fixture and expectation-array ground truth, jsdom mechanics,
and issue-numbered regressions.
Comment lines 2604 -> 2014 (-22%); 85 module headers kept, 30 coverage
inventories dropped; every surviving comment line is within the 140-column
limit, with 36 wrapped lines joined onto fuller ones.
No logic, string, identifier, import or directive changed: a comment-strip
gate proves each file's comment-free code is byte-identical to HEAD, and a
line-level proof pairs all 845 changed lines with an unchanged code part.
pnpm run lint && pnpm run test (106 files / 2061 tests, per-file 100%
coverage) && pnpm run build all pass.
Merge consecutive short comment lines in tests/** up to the 140-col limit
without touching their text: comment-stripped content, comment text (whitespace
normalised) and line-comment runs are all proven identical per file. This is the
line-count half of the cleanup only; the content pass over tests/** is still
outstanding.
The activity ledger's day entry gains a per-name skill table, booked from
the two durable load gestures the timeline fold tags sub: 'skill' — a
/name invocation's skill-invocation source, and a skill-tool result's
<skill_content> wrapper trusted only when its callId pairs with a pending
skill-tool call (a read of source text quoting the wrapper books
nothing). Tables are bounded per day and the pairing list per session;
the state shape change bumps the unit's stateVersion (3 → 4) so cached
rows refold on upgrade.
The plugin-owned /api/dsh-context/skills route serves what a loaded skill
IS — registry description, origin bucket, on-disk path — resolving the
session's cwd and preset scope the way the harness's own skill catalog
does (web compositions mount discovery on presets, so the global registry
alone lists nothing), every face re-proved per request.
The Current Session node now reads two figure rows: turns/steps above
skill loads/answers, each row's cells splitting the width evenly and
centering (one column pair whatever a label's length).
- Skill Loads (counts.skills): the distinct skill names among the
retained skill-tagged inject events — `/name` invocations and
`skill`-tool loads; the `<available_skills>` catalog digest rides an
untagged inject event and never counts. Derived at view time, so no
stateVersion bump: cached rows recompute the exact tally on serve.
- Answers (state.answers, head scalar): one per assistant message
carrying a non-blank text block — tool-call-only steps are work, not
replies. A running total over the complete log (the humanInputs
precedent), so the timeline stateVersion bumps 26 → 27 and the v20
warm-up rebuilds idle sessions' rows on the first dashboard open.
- The subagent count leaves the session node (the Agent Team ledger
row still carries it); docs/compatibility.md records the bump.
Verified end to end: lint:fix + test (100% per-file coverage) + build.
The supported set is now the two V4-generation lines (0.1.7-rc.2+ and
0.2.0-rc.2+): the manifest's dshReleases, the peer floors (>=0.1.7-rc.2,
cordis ^4.0.4 — both lines vendor 4.0.4), the devDependency pins, the
baseline matrix, the release workflow's tag fetch, and the compatibility
docs.
With every supported line on session format V4, the V3-only paths leave:
the fold reads the tool result's first-class message alone (source.callId
pairing, message.isError — the admission validation refuses any row whose
data.error outlives it); the settings.register host face module is gone
(the Config-form entry schema serves the preferences); the client drops
the settingsScope/settings.plugin.item card seat, the retired sessions.open
jump fallback, the legacy icon spellings, and the settings-dialog leg of
the settings jump; the history page's tool/result mapper reads the V4
message (its V3 wrapper reads were dropping result content on supported
lines); the cache probe rides the 2-arg cachedSnapshot face. The baseline
gate stays: a below-floor harness composes the fallback units and the
upgrade prompt, and an undetectable version still fails open.
Verified: pnpm run lint:fix && pnpm run test && pnpm run build green (1918
tests, 100% per-file coverage); the compat matrix passes against the real
dsh sources at both baseline tags; live on dsh 0.2.0-rc.2 the plugin
composes, the Plugins-page preference card renders and round-trips a
write, the insights warm-up fills the heatmap, and the session jump and
Context tab work end to end.
The Context preferences card gains its eighth row — the trend
duration curve — served across both settings generations: the V3
`settings.register` schema and the V4 Config form (a `.volatile()`
field, so an edit never remounts the entry). The client settings store
parses it fail-open like its neighbours; the trend card's Duration
toggle seeds its mount state from it (default on), and in-chart
toggling stays mount-local and never writes back.
The trend card gains a title-adjacent Duration toggle: every request's
step active time (`activeMs`) drawn as a curve over the bars, read off
a right-hand quartile axis; turn granularity sums the turn's steps, and
a bar without the figure breaks the line instead of faking a point.
The fold stamps it at `step/end` onto the step's committed request
record: the whole step window (the model call plus its tool runs, per
the agent-loop's in-step `executeToolCalls`) minus the step's user
waits — approval decision intervals (`approval/asked` →
`approval/decided`, a crash-dangling pair closing at the step's own
end) and `ask_user_question` answer windows (the tool's body is the
wait itself, in both the blocking and the timed schema's foreground
phase). Overlapping waits price their clamped union, never twice.
The wire field is additive-optional and bumps the timeline projection's
stateVersion (25 → 26): requests already folded can never gain the
figure later, so cached rows refold from the durable log on upgrade.
V4 harness releases log a `developer/message` event whenever the effective tool registry changes mid-session (first shipped in 0.1.7-rc.2): the message nests under data.message with role `developer`, source kind `tool-registry`, and `tool-addition`/`tool-removal` content blocks, lands on the session surface, and the harness's own token meter prices it like any non-system message. The timeline fold had no case for it, so every such message vanished from the plugin's accounting — injection totals, per-request composition, the live surface, and compaction archives under-reported, and the Context tab's numbers could not reconcile with the harness meter or a compaction shadow price.
The fold now treats the event like an injected user message: the nested message is read structurally (the pinned 0.1.5-rc.1 dependency's deriveEventMessage predates the family), the node prices through the shared estimator with the harness's empty-content-is-zero rule, and the injection record takes the producer kind as its label. Developer messages never count as human inputs whatever a hostile source claims; malformed payloads leave the state reference-equal.
stateVersion 24 -> 25: earlier cached rows omitted these nodes and later events cannot recover them, so on upgrade every timeline row refolds once from the durable log (the dashboard warm-up rebuilds idle sessions). The wire shape and durable logs are unchanged. The V4 baselines now carry the event family, and the real-source compat matrix asserts surface tokens, request composition, warm restore, and a v24-row rebuild on both supported V4 lines.
Validation: 102 test files / 1,910 tests with per-file coverage 100%; the real-source matrix passed for 0.1.5-rc.1, 0.1.7-rc.2, and 0.2.0-rc.2; GitHub PR checks green (486069f fixed the workflow's pnpm setup that had failed every external PR at its first step).
Cordis hands out a fresh traced proxy on every ctx.tools property read of a
tracked Service (ToolRuntime is one), each closing over the reader's context.
The hook keyed its WeakSet dedupe on the proxy identity, so every read
re-wrapped and pinned one restore closure per read — patched[] then retained
each caller's whole agent (scope tag -> agent -> session log) past disposal,
and the unload restore guard (instance.register === wrappedRegister) never
held through a traced proxy, leaving the dead wrapper on the shared service.
Peel to the stable underlying instance before dedupe/patch/restore (the
handler still returns the caller's own proxy, keeping scoped registration
this-binding), record the reader as a scalar fiber name instead of the
reader Context, and add a tracked-service regression test: single wrap
across reads, live scoped attribution, and restore on unload. #108
A redacted reasoning block leaves no chunk behind, so the first token
lands after the reasoning window — pricing ttftMs/genMs off the token
charged the marker-tiled decode window to the wait as well, and the
legend's rows summed past 100%. The wait/generation boundary now sits
at the first observable instant (the earlier of the first token and the
first block marker), the same boundary the strip's spans already paint;
the TPS chip keeps the first-token anchor for harness parity.
stateVersion 23 → 24 refolds cached rows with the corrected totals.
Folding 1_001 sparse steps (3_003 events, each flushing into the growing
spans array) passed locally but timed out on the release runner. Drive 41
denser steps instead — 50 spans each via a 48-marker decode run — so the
same 2_050 → 2_000 tail-cut assertions hold on 25x less fold work.
The timing card gains a bottom band that reads where the session's ACTIVE
time went: every completed step's slices — the TTFT wait, the decode blocks
in stream order, the tool-run windows, and the in-step residue — packed
gapless in occurrence order, each band's width its share of the cumulative
active time (idle time between steps takes no track), with ticks at zero,
the quartiles, and the full span.
Host: the fold tiles each step at step/end (clamped into the step window,
first-wins de-overlap so parallel runs paint their union, holes filled with
the residue kind) into a new spans collection — bounded to the newest 2,000,
riding the detail channel and the inline view, never the slim head. The
decode window anchors at the first OBSERVABLE instant (the first token, or
an earlier block marker the token packing could not stamp), so a redacted
reasoning block keeps its span instead of being swallowed by the wait.
stateVersion 22 → 23: the per-step collection cannot be backfilled by later
events, so cached rows refold from the durable log.
Client: the strip joins the card's hover link by KIND — a band hover lights
every span of its kind plus the donut arc and the legend row, and the
floating tip answers only the strip's own pointer (the mirror rule).
Add dsh-v0.2.0-rc.2 to the supported set alongside the 0.1.5-rc.1 and
0.1.7-rc.2 lines: the manifest's dshReleases, the baseline matrix, the
release workflow's tag fetch, and the compatibility docs.
Every seam the plugin rides is spelled as at 0.1.7-rc.2 - the projection
registry contract, the platform module table, the right Sidebar seats and
guide-entry contract, the session-jump verb pair, the detail channel RPC
faces, the Plugins page card slot over the configForms transport, the
balance settings faces, the step-boundary guard, and the durable event
vocabulary (still the V4 log generation, no v4-to-v5 migration). Cordis
stays 4.0.4 and the dsh peers stay >=0.1.5-rc.1.
Verified on the new tag: all 25 compat-matrix probes pass against the real
harness sources and the vendored cordis, `pnpm run lint:fix && pnpm run
test && pnpm run build` is green (1837 tests, 100% per-file coverage), and
on a disposable DSH_HOME the 0.2.0-rc.2 CLI installs dsh-context@0.60.0
into exactly one composed config row and removes it back to zero.
Also refresh the docs' verification stamp (plugin 0.58.0 -> 0.60.0).
A 'Context Browser · Diff baseline' row leads the tool-sort preference:
'prev step' (default) or 'prev turn', reusing the toolbar's vocabulary.
The browser's baseline toggle mounts from the persisted value; in-toolbar
toggling stays mount-local. Served through the settings namespace schema
and the Config-form generation, degrading to the default on stale values.
A seeded fork (a dsh-better-sidebar Side tab, a subagent_fork child,
a UI fork) replays its parent's event log verbatim, usage settlements
included, so its cumulative cost fold re-priced history the parent
session had already booked - a family of idle Side tabs billed the
parent's whole history again (#94).
The fold now zeroes the session-cost totals at the tagged
`session/end-seed` marker the session core stamps at the inherited
cut, in both projection units: the timeline's cost book and the
activity day ledger (whose armed step slot dies with the cut, so a
child settlement cannot attribute to a parent day). Untagged resume
markers reset nothing; nested seeds re-mark at their own cut, so a
session's totals are always its own post-seed spend. stateVersion
bumps (timeline 22, activity 3) refold cached rows from the log on
upgrade.
Each metered settlement now books its billed buckets into its day under
the (provider, model, period) in force — the same SessionCostUsage raw
material the timeline's session-cost totals carry, attributed to the day
the open step INITIATED (step/start armed, step/end cleared, settlement
instant as fallback) so fees land on the turn's own calendar day. The
fold tracks the route in force (last request/header wins) and splits
DeepSeek peak/off-peak off the same instant, so its per-day records
price identically to the session-cost totals. stateVersion bumps 1 → 2:
the dashboard's warm-up cold-refolds every stale row from the durable
log on upgrade. The client's activityOf re-proves the additive-optional
record with the same sanitizer the session-cost rides — a malformed
record drops the fee whole and keeps the day's tokens.
dsh provider route keys are arbitrary strings (subscription plugins
register 'claude'/'codex', users declare gateway profiles), while the
models.dev registry is keyed by vendor slugs — so every session billed
through such a route fell to the cross-provider scan, which priced only
when exactly one branch carried the model. The Cost tile dashed
hundreds of sessions whose model ids match models.dev exactly
(issue #91): claude-* resolves under 9-20 resellers, gpt-* under 20.
pricesBookOf now also extracts each provider's registry npm package and
builds a resolution index over the book (cost.ts priceIndexOf): every
model id lands under its lowercased key and its '-'-suffix tails, so a
provider the book does not know prices by model id alone. The vendor
branch is picked from registry data, never a per-model hardcode:
- 'vendor/model' dialects: the org segment names the vendor (exact, or
the registry id behind a variant org — deepseek-ai -> deepseek),
falling back to the bare last segment;
- the model's own vendor: a provider whose @ai-sdk package is named
after itself (@ai-sdk/anthropic -> anthropic) — mirrors and gateways
ride the generic openai-compatible package or someone else's;
- a provider the id itself names (deepseek-v4-flash -> deepseek),
matching '-' and '/' separators;
- a lone carrier, as before.
Each tier takes only a unique or unanimous rate (float-noise tolerant);
disagreeing candidates price nothing rather than guess. A provider the
book DOES know still never falls back — known branches stay
authoritative.
The rename table gains only 'deepseek-account' -> 'deepseek': the
native account route shares DeepSeek's period-based list, so the fold
now splits its peak/off-peak buckets too (stateVersion 21 refolds
cached rows that booked everything under 'peak').
Measured against the live models.dev dump over dsh's real vocabulary —
both native routes, every llm-pi-ai catalog route x its catalog models,
and issue #91's subscription/proxy ledger routes (1397 pairs, 49
routes): priced pairs 931 -> 1157 (+24%), routes with at least one
priced model 35 -> 47, with zero regressions and zero rate drift
against the old resolution; issue #91's routes go 0/35 -> 35/35.
The Cost KPI sub-line now reads 'N of M sessions priced' so unpriced
sessions are visible instead of reading as the full count (also #91).
Bumps @opencode-ai/models consumption onto the refreshed registry.
Fixes#91
The 0.1.7 line's SettingsForms service retired the `get(ns)` section
read, so the balance route never resolved the DeepSeek API key on V4+
deployments and the capsule stayed hidden. deepseekSectionOf now folds
over whichever face the running line serves: the registered
`llm-deepseek` section through V3, and on V4+ the Config-form
`describe()` projection, matching the provider's row by its own
apiKeyEnv-bearing section shape and preferring the settings ids the
generations have served it as. Rows fold in isolation, and a describe
that throws is no section — the capsule still arms nowhere it cannot.
The per-baseline `balance` probe pins the settings face and the
provider row's sources against each tag's real sources.
The supported range is now two baselines - dsh-v0.1.5-rc.1 (session
format V3) and dsh-v0.1.7-rc.2 (V4) - enforced by the runtime gate at
BASELINE_DSH_VERSION = 0.1.5-rc.1; below it the fallback units and the
upgrade modal take over (0.1.2/0.1.3 hosts were previously served by the
real folds).
The pre-V3 log shapes are dead code on every supported harness: the
running harness migrates historical logs on every read path (or refuses
the session wholesale), and 0.1.5-rc.1 rejects a request/header carrying
header.system outright. The fold therefore drops the V0/V2-only paths -
the header.system envelope system prompt, assistant/chunk floods,
{start, end} replacement endpoints, tool/code-dispatch - and the state
fields only they wrote (systemsFromHeader, the stepStart decode
accumulators). stateVersion stays 20: no supported log folds
differently, and pre-V3-era cached rows are discarded by the projection
cache's identity gate (and the strict stepStart sub-schema) instead of
served. The headers unit folds metadata-only epochs (envelope
systemTokens write dropped; the v1 cached-row read-compat and its pinned
stateVersion 1 stay); replaceRangeOf speaks startSeq/endSeq only; the
browser's epoch fetch maps request/header to tools and system/message to
the prompt text.
tests/baselines.ts re-fits to the two entries (the right Sidebar seam is
required on both), the compat matrix drives both tags' REAL sources
(canonical logs re-shaped to V3, the hostile probe renumbered past them),
and the version fixtures pin the new floor. Manifest: dshReleases
{0.1.5-rc.1, 0.1.7-rc.2}, dsh peers >=0.1.5-rc.1 (satisfies the 0.1.7
admission check under includePrerelease), devDeps pinned to the
0.1.5-rc.1 surface plus the ui-primitives runtime closure the client
lane inlines (test-only), and the release workflow fetches the two tags.
docs/compatibility.md is rewritten around the two-line matrix; README,
AGENTS.md and the issue template follow.
Also riding this move, from the audit pass: the fold re-proves an inject
source's form before it books the event record (the harness validates
`kind` but not `form` - a hostile one previously failed the strict
wire/state schemas on every delivery, the #44 freeze class), the headers
unit guards a data-less envelope (it has no try/catch of its own), the
schemastery schema annotations drop the explicit z<T> wrappers the 3.18.4
Mode generics reject, and the version-history note records why
stateVersion is deliberately not bumped.
dsh 0.1.7-rc.2 dropped the offset parameter from
sessionProjectionCache.cachedSnapshot (the lifecycle identity now rides
the header alone). The warm-up's fixed 3-arg probe bound the branded
offset onto `keys`, where `new Set(0)` threw for every session with
served rows - so the whole corpus read as unserved and took the full
cold-read path on each process's first dashboard open (contained per
session, but minutes of repeated decode+fold on a large one).
The probe now tries the 0.1.5-rc.1 spelling (meta, offset, keys) first
and falls back to the 0.1.7-rc.2 spelling (meta, keys); a record-less
session returns undefined without throwing on either face, which stays
the correct "not served". Covered by a two-face fixture.
The Config-form generation (dsh 0.1.7+) derives the Plugins page's live
form from each loader entry's own Config schema, so the entry schema must
be the schemastery one: cordis keeps validating the config: block through
its Standard Schema face, and the same schema now carries the six display
preferences as .volatile() fields (live-editable there, namespace = the
entry id). The fold bounds stay ordinary fields — their edit remounts the
entry, a preference edit commits volatile-only.
The .volatile() modifier ships only with the newer schemastery, so the
builder feature-detects it and the same bundle loads on the older lines
(3.18.2 predates the modifier). The devDependency bumps to 3.18.4 to
exercise the marked path in tests. Deliberate relaxations against the
replaced zod schema: unknown keys merge through, and integers are
expressed as min 1 + step 1.
The version.spec fixtures relied on clean CJS walk-up failure from the
tmpdir scratch tree, but Node consults NODE_PATH after the walk-up
fails - and a pnpm-managed environment (a `pnpm` binary running from a
sibling checkout, as on a dev machine with the harness repo alongside)
injects that checkout's virtual store into NODE_PATH. Every probe
package ABSENT from a fixture home then resolved to the ambient harness
install, and the four fall-through / degradation arms answered with its
real version instead of nothing.
Each scratch home now SHADOWS every probe package its failing probes
could reach with an entry that resolves at the fixture level but
answers no version (exports gate on the manifest subpath, versionless
own manifest on the entry ascend), so resolution never leaves the
fixture regardless of the environment. Two coverage-preserving additions
ride along: an entry-gone home pinning versionViaEntry's unresolvable
branch, and a stamped-slot merge test for the additive-optional blocks
record the timing tallies introduced.
The generation split (thinking / answer / tool args) already tiles its
window off the stream's block-start markers; the same markers now count
into additive-optional per-bucket tallies (reasoningBlocks / textBlocks /
toolArgBlocks), so the card qualifies each decode slice with the number
of blocks actually decoded - the honest per-slice counterpart of TTFT's
call count and tools' run count (one model call emits zero to many
blocks, so a call count there would be a false tally). Overhead stays
unqualified: it is the residue, with nothing behind it to count.
Both log generations carry the markers: the V0 chunk flood counts them
into the step slot alongside the spans, and the V2+ embedded stream
yields them from the same single pass that prices the split
(decodeSpansOfStream -> decodeTallyOfStream). Counts ride the split's
own first-token gate, and rows cached before the fields existed render
the duration alone.
Three per-event costs leave the fold: every state-changing event shallow-copied
all seven retained collections, every event re-scanned the retained requests to
count turn runs, and every tool call stringified its arguments even when the
tool can never row a file op. Cloning now follows the branch's own mutation
list, the run count rides an incremental ledger (recomputed only where a trim
replaces the array), and raw arguments are kept only where they are read.
Measured over 279 real sessions (282,783 events): the fold drops 16.8% corpus
wide, ~51% on V3-shaped logs and ~57% on sessions at the retention caps, with
state output field-identical apart from the new optional `turnRuns` field and
the intended `argsRaw` omission. `stateVersion` is unchanged, so cached rows
keep parsing and stay usable.
The Windows check rode along: `path.relative` returns an ABSOLUTE path across
drives, so the old `..`-prefix test read a foreign drive's module as a child of
the plugin's own tree and degraded version detection; `!isAbsolute(rel)` closes
it.
Review follow-up on top of the original six-file change:
- an absent `timing` slot stays absent. The selective clone materialized
`timing: undefined`, which is not losslessly JSON-serializable, so
`snapshotJsonValue` rejected the whole checkpoint: every projection-cache
write for that session was skipped, and a log ending on an unpaired
`tool/result` never got a cache row at all — every reopen then paid the full
cold fold this change set aimed to cut;
- the no-`undefined`-member rule is now pinned on every intermediate fold
state, not only the last one — the gap that let the above ride through a
green suite, since reading the slot cannot tell an absent key from an
`undefined`-valued one;
- the "which tool needs its raw arguments" gate moved beside the file-op
parser (`opBearingTool` / `rawArgsNeeded`), so the fold no longer restates
the tool list.
Verified: `pnpm run lint:fix && pnpm run test && pnpm run build` green (1763
tests, per-file coverage 100%), plus a field-level diff of the fold over
282,783 real events (no divergence beyond the intended deltas), the
incremental ledger cross-checked against a full scan on every event including
both trims, and a frozen-predecessor run proving no in-place write.
Co-authored-by: Nwflower <1679659@qq.com>
V4 lifted `isError` from the tool-result content wrapper onto the message
and made the event-level `data.error` identity optional, so a failing
result could lose its error flag. The fold now reads the mark from BOTH
spellings — surface nodes and file-op rows keep it on every generation.
dsh 0.1.7 (session format V4) removed `settings.register` — configuration
forms derive from each loader entry's own Config schema there. The inject
callback now probes the method and stays inert on a generation without it
instead of throwing inside the fiber.
The timing card gains a top-right tok/s chip, computed over the same
pairing as the harness's own session-stats fold: per assistant call,
the provider-reported output tokens paired with the first-token →
message window, summed only over calls carrying BOTH — so the plugin's
figure equals the harness pill's by construction (verified against the
running harness). The formatter mirrors the harness's too: whole tokens
from ten up, one decimal below.
The seat rides TimingTotals as additive-optional fields (speedTokens /
speedMs — a usage-gated subset of genMs), so cached rows written before
it simply render no chip, with no stateVersion bump. Unreadable or
absent pairing renders no chip; the tooltip names the pairing.
The capsule's figure could come from two stale layers, and its total could
disagree with its own breakdown. Both are gone.
The host route remembered a successful read for five minutes, so opening the
console first pinned the capsule to that older snapshot — and the balance
moves with every billed request, so the two could not agree. Nothing outlives
a read now beyond the outbound fetch already in flight.
The client paints the remembered figure (memory, then storage) while it
revalidates in the background, so a reopening panel shows a number at once and
settles on the live one without ever trading it for a blank pill: a failed or
absent read notifies nobody and keeps serving what it remembered.
The total is now the granted and topped-up parts summed rather than the
platform's own `total_balance`, which rounds independently of them and can
land a cent away from what the breakdown beside it adds up to (the viewer's
arithmetic is the authority); an entry no longer needs `total_balance` to
prove its shape at all.
The specs cover the storage and transport failures that must keep the figure,
the per-open revalidation, and the disagreeing total.
The dashboard header gains a capsule with the DeepSeek open-platform
balance (issue #80). The host mounts a /api/dsh-context/balance route
through the same Connection exact-route registry as the detail and
backfill routes, resolves the connection facts per request the way
llm-deepseek serves its own requests (its llm-deepseek settings section
for the credential ref and endpoint override, the credentials service
for the key), and reads /user/balance behind a short TTL cache — the
key never leaves the host process.
Every face is optional and runtime-proved: a deployment without the
DeepSeek provider, without a resolvable credential, without the route
registry, or whose platform read fails (network, non-2xx, malformed
payload, hostile entries dropped whole) answers a typed null, and the
capsule renders nothing — no pill, no spinner, no error state. The
entry follows the display locale (zh → CNY) with the account's first
currency as fallback, and the tooltip carries the granted/topped-up
breakdown.
The persistence seam translates the format edge's
SessionFormatUnsupportedMigrationError into SessionFormatUnsupportedError
(with the "source vN artifact remains unchanged" suffix) before the error
escapes, so the exact-name check never matched on any supported dsh
baseline: the skip summary never fired and every refused legacy session
kept its own WARN (issue #75). Classify by the SessionFormatUnsupported
prefix instead, and cover both faces of the translation in the fixture.
The contextActivity/contextTimeline warm-up used to cold-read the whole
session corpus at every startup, whether the dashboard was ever opened or
not — and a legacy log the running harness refuses to migrate re-attempted
and re-warned per session on every boot (one reporter measured 115 warn
lines, ~60 KB per start; #75). Under that sustained read pressure the host
also stalls its own client handshake, leaving the workspace sidebar to
mount without its session rows until a restart.
The pass now runs on demand, once per host process: the client POSTs the
plugin's /api/dsh-context/backfill fetch route (the same authenticated
/api fence as the detail route) the first time the Context Insights panel
opens, and every later hit is a no-op. The route and the cold-path faces
compose in either order; a deployment without the connection service or
the exact-route registry never arms the pass — such a client could not
open the dashboard to read the rows anyway.
Skips are accounted instead of spammed: a migration-refused log
(permanent, source-side) drops to a per-session debug line and joins one
summary info line per pass; unexpected per-session failures keep their
own warn, now proof against hostile error objects whose toString throws.
The inter-session yield widens to 100 ms so an on-demand pass never
squeezes the host's other services.