The four Connection fetch routes repeated the same block verbatim — extract
the registrar, guard it, register inside a refuse-quietly catch — and
balance.ts and skills.ts differed only in the route constant and the label.
registerPostRoute now owns the shape, the bind-at-extraction guard and the
catch, so each watcher states only its own handler.
Three of the four files also hand-copied the plain-object re-proof, and
backfill's copy had drifted: it accepted arrays, the other two did not.
They share one host-local helper now, at the stricter semantics. The
client's own asRecord is a different, deliberately permissive boundary with
dozens of callers, so it stays as it is.
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.
The matrix skipped itself on every PR: pr-checks.yml never fetched the
baseline tags and never set DSH_REPO, so skipReasons() always reported a
missing checkout and describe.skipIf dropped the whole per-baseline block —
the registry apply, the wire keys, the checkpoint JSON gate, cold restore
and every client seam probe. Only the release workflow fetched them, so
the compatibility proof compatibility.md claims for "every pnpm test" was
a no-op until a tag was cut.
Both workflows now fetch the tags and export DSH_REPO, and a skip under CI
fails the run instead of hiding behind green: a missing prerequisite there
is a broken gate, not an absent one. The two test runs collapse into one,
which also brings the lint gate the release workflow had and PRs did not.
The matrix's own vocabulary guard compared a hand-copied constant against
hand-copied baseline lists, so it could never see a drift in the fold — and
had drifted: approval/asked, approval/decided and session/end-seed are
folded but declared by no baseline. It now reads the fold's case labels out
of src/host/fold.ts, so a new fold case cannot go unprobed, and the history
probe pins the `records` envelope historyPage's rowsOf rejects without, not
just the remote's service name.
browser.spec.ts (2360), trendChart.spec.ts (1629), contextView.spec.ts (1504), and
overview.spec.ts (1065) exceeded the 1000-line limit. Each splits along its existing
describe seams into same-directory siblings, with the fixtures that more than one
sibling needs extracted once into a shared harness.
The move is verbatim: test titles, describe blocks, and every assertion line are
preserved exactly (2061 tests before and after, no duplicate titles, no skips), and
the suite reports the same coverage.
browser.tsx was 1252 lines and carried three separable concerns: the
producer-dependent tool-schema readers, the tool block (schema tables, call cards,
argument/result text), and the per-node rendering. They move into same-directory
siblings and makeContextBrowser keeps the file as its single public entry, so no
importer changes.
The split is a verbatim move: lib/client.js is byte-identical to the pre-split build
apart from the artifact banner.
FoldBounds restated six Config fields, DEFAULT_BOUNDS restated them again, and
resolveBounds listed them a third time. Deriving the type from the defaults leaves
one source for the bound vocabulary, and Config now composes Partial<FoldBounds>
with Partial<PluginSettings> — which also declares defaultDeltaBase, a field the
schema already carried while the interface omitted it.
DecodeSpans and DecodeCounts were byte-identical aliases of the same record shape.
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.
Remove browser.spec.ts's file header (a coverage inventory the describe blocks
already state) and reword trendChart.spec.ts's tooltip note that quoted the zh
UI string, keeping the assertion's intent in English.
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.
Drop the restated build narration in tsdown.config.ts and vitest.config.ts and
keep only the decisions a reader cannot recover from the code: the platform
module subset, the pinned native toolchain, the virtual-id suffix rule, the
unminified/typed host half, and the compat lane's preconditions. Stop claiming
to "mirror" the harness lists, which these are subsets of.
Apply the repo's comments guideline to src/client/**: delete comments that
restate JSX, Tailwind utilities or standard React behaviour, narrate history,
duplicate docs/, or describe what the next line does; keep only container-query
and containment traps, portal requirements, reference-stability rules with a
real re-render consequence, CSS<->TS constant parities, harness seat and payload
contracts. Align the page's name with the shipped UI label ("Context Insights",
not "Context Dashboard"), unify drifted phrasings, drop the last non-English
fragments, and rewrap survivors onto fewer, fuller lines within the 140-col limit.
Comment lines 5,183 -> 1,822. No code change: comment-stripped content is
identical per file; the client bundle differs only by dead Tailwind utilities
that existed because deleted comments contained those words.
Apply the repo's comments guideline to src/host/** and src/shared/**: delete
comments that restate the adjacent declaration, narrate development history,
duplicate docs/compatibility.md, or walk through a function body; keep only
critical decisions and facts the code cannot self-explain (harness and log-shape
contracts, retention invariants, untrusted-input traps, plain-JSON cache
preconditions). Rewrap the survivors onto fewer, fuller lines within the 140-col
lint limit, and drop the last non-English comment fragments.
Comment lines 4,020 -> 1,026. No code change: every file is proven identical
after comment stripping, and the built lib/index.js is byte-identical to the
previous build (modulo the build-time banner).
Every retained bar carried `animate-lc-bar-in`, so the chart's mount animated
each one — the host retains up to maxRequestSteps (1500) requests — and every
animating bar layerizes on its own. Only the columns a pane can reach are ever
seen: the chart mounts anchored to the newest bars, so the rise is now drawn by
the newest RISE_CAP (200) columns, the stagger counted from the window's edge
so those columns still cascade left to right. Bars appended to a live session
keep rising, and a log shorter than the window is unchanged.
Measured on the 218-step session, the per-bar animations cost 17ms of the worst
frame gap and 16ms of blocking on top of the strip fix. With both changes the
open measures 243 animations, a 133ms worst frame gap (was 250ms) and 98ms of
blocking (was 173ms); with every CSS animation disabled it measures the same
133ms, so what remains is plain mount cost. Three tests pin the window in
total, DNA and DNA+delta modes.
Opening the Context tab on the 218-step session started 1584 CSS animations at
once, 1324 of them the timing card's strip — one `animate-lc-stacked-in` per
band. Tracing the open showed 1582 animationstart / 1578 animationend
dispatches, 343 Layerize events (392ms of layer churn) and a 250ms worst frame
gap where the shell's own tab switch measures 67-83ms. Disabling just the
strip's per-band animations on the same build already cut that open to a 150ms
gap and 109ms of blocking (from 173ms).
The bands now ride one `.lc-tstrip-fill` wrapper carrying the single sweep, so
the strip wipes open on one composited transform over the same track. Packing,
colours, the kind hover link and the tip are untouched; the per-band ripple it
replaces was capped at eight stagger slots, so a packed strip already settled
as a sweep. A regression test pins the single wrapper and the absence of any
per-band animation class.
The backoff timer re-armed unconditionally after every failed read, so an
unreachable models.dev registry kept being polled for the rest of the page's
life even after the Insights page that subscribed had gone. Measured with
fake timers: 7 polls in the 20 minutes after the last unsubscribe, at the
capped 240s cadence, forever.
The timer callback now ends the cycle when no subscriber remains; the next
subscribe already kicks a fresh read. Measured after: 0 polls in the same
window, and the first subscribe still fills the book as before.
The per-session DetailStore map is page-lifetime and every store kept its
fetched collections forever, so the page's heap grew with each session ever
opened. Measured over 24 distinct visits with planted weak references: 9, 17
and then 25 live payloads (one per visit), ~194KB of JSON (~300KB of live
heap) each — nothing released them but a reload.
The newest DETAIL_STORES_MAX sessions now keep their payload for an instant
re-open; older idle ones are demoted (payload and ledger dropped) and read
again when opened. Measured after: a flat 8 live payloads at 8, 16 and 24
visits, the two oldest re-opens read once more, the newest re-opens read
nothing. An active store is never demoted, so the view on screen keeps its
data; rendering was byte-identical across the change.
The per-session DetailStore map is page-lifetime, so a store whose Context
tab, sidebar panel, or /context modal had gone kept trailing its own read
schedule: a broken detail route was re-POSTed every 2.4s for the rest of the
page's life with nobody reading the answer (measured on a real session: 2
extra POSTs in the 3s after unmount, unbounded).
The last unsubscribe now parks the store: the armed window is cleared and no
read is armed while nobody subscribes. The cached payload is kept, so a
re-open still renders instantly (measured: identical rendering digest and no
refetch on remount); a resume with nothing cached clears the ledger and the
old backoff, so the mount's own request(rev) reads again instead of sticking
on the retry note.
The Less→More key reused the grid's own 20px cells, so the key read as a
second row of cells competing with the grid. Its swatches now render at half
size and keep the grid's depth classes, so the key stays a caption while
never drifting from the colors it explains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the timing-stats text row from 模型输出/Answer to 模型回复/Answers to match the Answer wording already used by the stats card, and update the affected assertions.
The notice trailed the version inside the value box, whose ellipsis cuts
the line last-in-first-out, so at the card's 240px floor it was always the
part lost. Measured in the live harness with English labels, the box
overflowed by 29-44px and ~15px of a 52px chip survived, at every version
string tried. A block-level chip drops below instead, leaving the version
its own ellipsis on the first line.
The Insights page's range group gains a calendar picker: one panel that
takes BOTH ends, in either order. A backwards pick — end before start —
is sorted on the second click, so `orderedDayRange` hands every reader
downstream (the scope window, the ledger-day predicate, the chip that
names it) a pair with `from <= to`; `dayRangeWindow` orders it again at
the seam, so a hand-built pair can never filter the page to nothing.
The trigger IS the scope: one pill carrying the start and the end, fed
the LIVE window — a picked range when one owns the scope, the preset's
own window read as days otherwise, so "last 30 days" prints the thirty
days it covers and the two controls can never disagree. Only a picked
range takes the chip's brand voice and grows the way back, and the panel
keeps no second copy of the two dates: the chip above it is already
showing them, live, while the pick runs.
Underneath, the range model moves from a start-only floor to a two-sided
`RangeWindow`, which closes the top of a picked scope: the ledger-day
predicate gains the ceiling it never had, so a day beyond the range no
longer leaks into the skill card, and both picked days count whole
(midnight through the last millisecond). `days.ts` gains the calendar
arithmetic this needs — a day's epoch bounds, its weekday, month
stepping, and one month laid out as Sunday-first weeks — with the
round-trip key validation its siblings already used.
The panel portals to `<body>` and seats itself off its trigger's rect,
the containment escape the page's hover tips already take: the page is
the `lc-ov` query container, so an in-tree popover would be both
captured and clipped by the one scroller. It measures itself before
paint, clamps inside the viewport, flips above the trigger when it would
fall off the bottom, and retracts on any scroll, resize, outside press,
or Escape.
Closes#128
The Insights page's first range option read "Last 24 hours" while the
ledger it scopes is day-grained, so the rolling window and the day floor
underneath it disagreed: past midnight the 24h window still admitted
yesterday's ledger day whole, and before 1am it admitted only part of it.
"Today" is the local calendar day from midnight — the window the day-
granular heatmap beside it already draws.
`rangeStartOf` opens the window on the local midnight by date-field
arithmetic (the harness's own `setHours(0, 0, 0, 0)` idiom, as in
`startOfLocalDay`), which stays exact across DST-short and DST-long days
where an epoch-ms subtract drifts into the neighbouring day. The harness
helper itself is only reachable through a deep
`@deepseek-ai/dsh-client-ui-chat/src/*` path — a package this plugin does
not depend on — so the idiom is mirrored at its one call site instead of
taking the dependency.
Both locales carry the shorter 今天 / Today; the deprecated
`ov.range.24h` key is gone rather than aliased.
The specs that anchored time on raw epoch offsets silently assumed UTC
(`11 * 86_400_000`), and the panel fixture at `NOW - 1h` would have
failed whenever the suite runs in the first hour after midnight; every
boundary is now anchored on a local `Date`, so the day floor is proved
identically in every timezone.
A case-insensitive substring filter rides the skill card's totals row,
narrowing the rows, the summary counts, and the overflow line; the
input stays mounted on an empty match, next to a zeroed summary and a
dedicated no-match note. The bars keep the scope's heaviest tally as
their denominator so they hold still while the filter narrows.
A pin the filter hides now falls back to the detached detail block with
its head row — previously the head rendered only for pins outside the
scope, leaving a filter-hidden (or render-bound-hidden) pin unnamed.
@deepseek-ai/dsh-client-store declares both under devDependencies
while its published lib/index.js imports them, so the client lane's
inlined harness bundle needs them hoisted at the root exactly like
`diff` in the previous commit. A clean-room install of the lockfile now
resolves the whole runtime import graph from the checkout alone:
zustand, immer, diff, shiki, katex, anser, clsx, simple-icons and the
micromark/mdast set.
@deepseek-ai/dsh-client-ui-primitives declares `diff` under
devDependencies while its published lib/index.js imports it, so the bare
import only resolves when the consumer's workspace hoists it — exactly
how shiki, katex, anser, clsx and the micromark/mdast set are already
carried here. `diff` was the one name missing, which failed the release
workflow's `pnpm test` with "Failed to resolve import \"diff\"" across 47
client suites while passing on any tree with a hoisted copy above it.
The host half runs in Node straight off disk, where parse time is irrelevant
and no sourcemap ships beside it, so minifying only cost stack-trace
readability. The browser half stays minified: it is the artifact the browser
downloads and parses.
v0.65.0: lib/index.js 56.87 -> 99.11 kB (gzip 16.85 -> 24.21), comments
already stripped.
Render both halves with `comments: { legal: true, annotation: false, jsdoc:
false }` (rolldown's OutputOptions). The `/* @__PURE__ */` and `/* v8 ignore
*/` markers that survive the minifier are build-time input whose only
remaining effect in the output is bytes, and plain source comments are noise
in a shipped artifact; legal comments stay for licenses, while `index.d.ts`
keeps the JSDoc that documents the integration contract.
v0.65.0: lib/client.js 355.18 -> 336.46 kB (gzip 94.09 -> 91.32), lib/index.js
57.33 -> 56.87 kB.
The client bundle is a classic script the shell loads into its module
table, never a Node module, so its target must not fall back to
engines.node — that fallback also tripped tsdown's legacy-CJS warning on
every build. deps.onlyBundle turns the "everything else inlines" rule
into an enforced allowlist: any other node_modules package inlined into
the client bundle fails the build.
The Context Insights page's second insight card lists every skill loaded
within the page's scope (the range floor, or the heatmap's pinned day):
a color-dotted row per name with its load tally, a proportional share
bar scaled to the heaviest row, and the last load's relative time,
ordered by the title's loads / last-used toggle. A row click pins the
session list to that skill's loaders; the pinned row wraps with its
detail into one ringed unit — the description first, then a fact grid
(origin, usage, exact last load, the full path as a click-to-copy
value). A structured hover card answers the same in a floating register.
The catalog read joins description, origin, and path onto the tallies by
name and degrades to bare tallies when the route cannot serve.
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 bare "Settings" / "设置" label read as the shell's own settings next
to the plugin's; both the page-heading chip and the plugin-info row now
name the plugin's own page.
The Context Insights page's preferences entry sat as a full-width row
under the activity heatmap, at the bottom of the insight column and easy
to miss. It now rides the page heading as a quiet chip directly right of
the title, cut like the balance capsule beside it, with the long
"open plugin settings" hint carried on the hover title.
The shell's own panel rows draw their glyphs with the icon set's one-unit
stroke in a 16-unit box — 6.25% of the icon edge — while the sheet's bars
measure 98-101 units of its 1024 artboard (9.6-9.9%), so the row read half
again as heavy as the Plugins and Automation tasks rows beside it; at that
weight antialiasing also filled the strokes enough to look darker than
neighbours that ride the very same `--dsw-alias-label-primary` currentColor
in both themes.
The mono seat now derives the thin glyph from the SAME single artwork
instead of shipping a second SVG: the sheet masks down to the harness
weight by 17 units a side (98 -> 64) as the intersection of the drawing
with its own copies shifted along each axis — a white flood minus one
black shift per direction, every shift painted through the drawing's
complement mask, which stays the only copy of the artwork on the page.
Compositing, not a filter: `feMorphology` rounds its radius to whole
device pixels, so it measured as a no-op at 1x and an over-thin at 2x.
The polychrome identity seats (tab chip, /context entry, guide capsule)
are untouched.
Verified: live strokes measure 17px against the clock's 17px and the
pinwheel's 19px at 256px; screenshots in light and dark; lint:fix + test
(100% per-file coverage) + build.
The session node's second-row cells (Skill Loads / Answers) become
keyboard-accessible jumps (role=button + Enter/Space, the team card's
idiom): clicking scrolls the Context Browser card into view and opens
the matching section on the live surface — skill loads expand Skill
Injections with the row lens cleared; answers expand Assistant Messages
with the Answer kind chip picked, so tool-call-only steps filter out.
Hover/focus carry the team's affordance (soft wash, brightened label,
brand ring).
The browser gains a one-shot `catFocus` prop mirroring `nodeFocus`:
select live, open the named category, pick the optional kind chip,
clear the lens, report handled so the same cell can fire again. The
cells stay inert where no browser mounts (no handler, no affordance);
tooltips name the destination in both locales.
Verified end to end: lint:fix + test (100% per-file coverage) + build.
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.