dsh-ab-chart
Đã xác minhdsh-ab-chart · v0.1.12 · MIT · Giao diện web
Chart rendering tool with the browser card that draws its calls, in one publishable plugin package
Cài đặt
dsh plugin add dsh-ab-chart Xác nhận layer đã áp bằng dsh --profile default --dump-config — xem hướng dẫn cài plugin.
Mã nguồn
Phát hành lên npm mà không có repository công khai. Hãy kiểm tra nội dung package trước khi cài.
Thẻ
Tác giả
Readme
description: "The publishable dsh-ab-chart package: the chart tool, its scope-aware prompt section, and the browser card that draws its calls." kind: "package-reference"
dsh-ab-chart
Summary
One package holding both halves of the chart feature. The host half registers the
wire name on ctx.tools, contributes its routing section to ctx.systemPrompt, and
writes the chart files; the browser half registers the matching key in the
tool.call.toolview slot and draws the chart from the logged call and the persisted
result metadata. The manifest declares dsh.bundle.patch for the host row and
dsh.client for the card, so one install — and one patch row — carries both.
Built against the dsh 0.2.0 runtime (harness 0.2.0-rc.2; the @deepseek-ai/dsh-*
packages at 0.2.0-rc.2). The plugin installs from the npm registry only — never via
link: or file: — which is what keeps a fresh install on a real tarball rather than a
node_modules reparse point borrowed from a sibling checkout.
# <DSH_HOME>/profiles/web/cordis.patch.yml
- insert:
- id: chart
name: 'dsh-ab-chart'
// <DSH_HOME>/profiles/web/package.json (installed from the npm registry; see Mounting)
"dsh-ab-chart": "^0.2.0-rc.2"
The card is wired from the same package, so mounting that one row mounts both the tool and the card; nothing else reads the browser half.
Build, test, and publish
npm run build # tsc -p tsconfig.json && tsdown (lib/index.js and lib/client.js)
npm run verify # typecheck + build + test + packaging lint
Publishing is the dsh-plugin-release skill's step; its scripts take this package as an
argument. Write <skill> for that skill base directory, which the loader prints when it loads
the skill:
node <skill>/scripts/src/index.mjs check --package <this package>
node <skill>/scripts/src/index.mjs release --package <this package> --bump patch
node <skill>/scripts/src/index.mjs publish --package <this package> --registry <url>
What one call does
render_chart records; save_chart draws.
| Tool | Step | Detail |
|---|---|---|
render_chart |
Read | The table from data (inline, any accepted shape) or from a file named by from, through the mounted filesystem capability |
render_chart |
Validate | Non-empty trimmed title and axes, unique series names, one finite value per category, the deployment's title/series/point ceilings, and (for cartesian charts) a sane axis per series |
render_chart |
Record | <chartDir>/<slug>.<digest>.chart.json, skipped entirely when the file already holds this chart |
render_chart |
Report | One line naming the chart, its size, its axes, and the recorded description's path |
save_chart |
Read | The recorded description, rebuilt from the durable file rather than trusted |
save_chart |
Draw | One ECharts option, rendered to SVG with no browser and no canvas, at the deployment's size or the call's own |
save_chart |
Save | <chartDir>/<slug>.<digest>.svg, skipped entirely when the file already holds this drawing |
save_chart |
Report | One line naming the file and the size it was drawn at |
Separating the two is what keeps the picture the logged call. render_chart returns a bounded
description the card draws, so nothing about what the conversation shows depends on a render step
having run; the file is a second, explicit action taken only when somebody wants one, and the
table does not travel again to get it.
The families
Every family is one set of named numeric series over one shared list of category labels, which is why one data grammar feeds all of them. A pie, a funnel, a radar, and a gauge take the same table as a bar chart; only the drawing differs.
| Kind | Series | Shape |
|---|---|---|
bar, line, area, scatter |
many | Cartesian: categories on one axis, values on the other; an axis field splits series across a left and a right Y axis |
pie, funnel |
exactly one | The single series is the whole picture |
radar |
many | Each category is an axis of the web |
heatmap |
many | The series become the rows (the second, categorical axis); each [category, series] cell carries one value, shaded by a shared visualMap |
gauge |
exactly one | One dial per category from that single series; range sets the dial span |
Four families carry a shape rather than a category × series table: candlestick,
boxplot, sankey, and treemap. They share one parser — src/chart-data.ts — that
both the host and the card import, so a saved file and a replayed card cannot disagree
on their data the way the classic grammar (duplicated between the two surfaces by
necessity) could. A call names one of these four kinds and passes the shape in data
or in a file named by from, through the same size ceiling and filesystem read the
table uses.
| Kind | Shape | What the data carries |
|---|---|---|
candlestick |
K-line | One category (date) per row plus a four-number tuple open, close, low, high |
boxplot |
Five-number box | One category per row plus a five-number tuple min, Q1, median, Q3, max |
sankey |
Flow | source, target, value flows; nodes are the labels the flows name |
treemap |
Hierarchy | name, value, parent rows (parent blank for a root); the value may be absent on a pure container |
| Kind | Delimited example | JSON example |
|---|---|---|
candlestick |
category,open,close,low,high then Mon,10,12,9,13 |
{"categories":["Mon"],"ohlc":[[10,12,9,13]]} or {"categories":["Mon"],"open":[10],"close":[12],"low":[9],"high":[13]} |
boxplot |
category,min,Q1,median,Q3,max then G1,1,2,3,4,5 |
{"categories":["G1"],"boxes":[[1,2,3,4,5]]} or {"categories":["G1"],"min":[1],"q1":[2],"median":[3],"q3":[4],"max":[5]} |
sankey |
source,target,value then A,B,10 |
{"links":[{"source":"A","target":"B","value":10}],"nodes":[{"name":"A"},{"name":"B"}]} — nodes is optional; missing, it is the set of labels the links name |
treemap |
name,value,parent then root,100, / a,60,root |
{"nodes":[{"name":"root","value":100,"children":[{"name":"a","value":60},{"name":"b","value":40}]}]} — children nests; a node with no value is a pure container |
A candlestick or a boxplot keeps the shared category axis, so its JSON still names
categories; a sankey or a treemap carries no categories and the recorded description
stores nodes/links or tree instead. Every reader throws on an unusable input in
the same voice as the classic grammar — a header of the wrong width, a tuple that is
not the right number of finite numbers, a sankey value below zero, or a treemap that
names a parent box that is never listed.
The table grammar
data accepts one table in several shapes. The data argument is one short description rather than
one argument per format, so a new accepted shape costs nothing on requests that do not use it.
| Shape | Example |
|---|---|
| Delimited, columnar | Region,Q1 then North,10 — the header names the columns, the first column holds the labels |
| Delimited, wide | 1月,2月 then 销售额,120,150 — the header holds the category labels, each later row leads with its series name |
| Markdown table | A table pasted straight out of an earlier result, alignment row and all |
| JSON records | [{"month":"Jan","sales":120}, …] — the label column is the one named like one, else the first non-numeric one |
| JSON matrix | [["Jan","Feb"],["sales",120,150]] |
| JSON explicit | {"categories":[…],"series":[{"name":…,"data":[… ],"axis":1}]}, or series as a name-to-values record |
Numeric cells are read the way a spreadsheet export writes them: a currency mark, a thousands separator, a trailing percent, and parentheses for a negative are all the same number. Fields may be double-quoted, so a comma inside a label or a thousands separator inside a value survives. A row whose width matches neither accepted shape is rejected with the row number.
from reads the same grammar out of a file — .csv, .tsv, .json, .md, or .txt — through
ctx.fs, so the deployment's file policy and sandbox see the read. Pointing at a file the deployment
already has costs no output tokens at all.
A series may name the Y axis it is drawn on in the explicit JSON form:
{"categories":["a","b"],"series":[{"name":"s","data":[1,2]},{"name":"r","data":[3,4],"axis":1}]}.
axis is 0 (left, the default) or 1 (right); it applies to cartesian families only and is
rejected on any other family.
The switches
options is a comma-separated list, deliberately small and closed.
| Token | Applies to | Effect |
|---|---|---|
stack:normal |
bar, line, area | Stack series instead of placing them side by side |
stack:percent |
bar, line, area | Stack, with each value re-expressed as its share of the category |
smooth |
line, area | Curved line |
step |
line, area | Staircase line that holds each value across its category; wins when both smooth and step are named |
horizontal |
bar | Categories on the value axis |
labels |
bar, line, area, pie, scatter, funnel, heatmap | Print each point's (or, on a heatmap, each cell's) value beside it |
donut |
pie | Draw a ring instead of a disc |
range:lo-hi |
gauge | Span the dial from lo to hi (for example range:0-100); omitted, a gauge spans zero to just above its largest value |
axis:0 / axis:1 |
bar, line, area, scatter (per series, in the explicit JSON form) | Which Y axis the series is drawn on |
A switch that cannot apply to the call's family is rejected rather than ignored: donut on a bar
chart is a caller mistake, and a silent no-op would return a picture that does not match what was
asked for. range and axis apply to a gauge and to cartesian charts alone; naming either on any
other family is refused. stack:percent is computed by the renderer, so the recorded specification
keeps the values that were actually measured.
Drawing quality
The mapping is a closed, pure function of the specification, so the saved file and the on-screen card
draw the same picture; tests/option.test.mjs feeds one corpus to both and fails the moment they would
drift. Beyond the thirteen families, the drawing carries a set of professional touches:
- Locale-stable number formatting. Axis ticks, data labels, and tooltips are re-spelled by a
formatter that trims float noise to two decimals, drops the trailing zeros, and groups the integer
part by thousands —
12345678reads12,345,678. It is the same implementation on the host (Node) and the card (browser), so a saved file and a replayed chart agree on every glyph, and the chart's unit is named on the tooltip. - Color-blind-safe default palette. When no
paletteis configured, series take the Okabe–Ito set, which stays distinguishable under the common forms of color blindness. A deployment that namespalettedraws with its own colors instead; the card and the file always use the same set. - Translucent area fill. An
areaseries fills at 20% opacity over its series color, for a lighter wash than the library's opaque default. - Soft bar corners. Bars carry a small border radius, so a bar chart reads as a modern dashboard rather than a block chart.
stepline. A line or area series drawn withstepholds each value across its category — a staircase — which suits discrete or cumulative measures; whensmoothandstepare both named,stepwins.
Where the files go
<chartDir>/<slug>.<spec-digest>.chart.json the recorded description (render_chart)
<chartDir>/<slug>.<draw-digest>.svg the drawing (save_chart)
slug the chart title, made path-safe and bounded to 80 characters ("chart" when nothing survives)
spec-digest sha256 over the specification alone, first 12 hex characters
draw-digest sha256 over the specification and the drawing size, first 12 hex characters
Two files, two names, each covering the inputs its own bytes depend on: the description's bytes follow from the specification, and the drawing's from the specification and the size it was drawn at. Both are content-addressed the way this tree's artifact store names its files, so the same value always names the same file, a repeated call is one existence check, and a changed value cannot be mistaken for the previous one because it is not reachable under the old name.
The drawing carries its own white background, because it is read outside the page that drew it. The description carries none, because the card takes the page's background.
Writing under the session's sandbox policy
A deployment that mounts @deepseek-ai/dsh-fs-sandbox fences every write by a per-call sandbox
policy, and that policy — not the backend's own default — is the only thing naming the workspace the
calling session runs in. Both writes resolve ctx.sandboxPolicy for the call and stamp it onto the
write, so a description or a drawing lands inside the session's own workspace; a confining backend
that receives no policy falls back to the directory the server was launched from and refuses
everything outside it.
src/sandbox.ts owns that seam. callFence resolves the policy once per call and refuses a
composition that mounts a confining backend with no policy service, which cannot be caught at load
because that backend declares the service as its own injection and is never constructed without it.
callWorkspace prefers the fence's root over the session cwd and reads no process.cwd(), leaving a
call that named no workspace to the provider's own default. saveText is the one path every artifact
is written through, so a new call site cannot omit the fence; it restates a refusal with the path, the
mode, and the workspace to move under, keeping the FS_SANDBOX_DENIED code the backend raised.
The host half and the browser half
The host half lives in src/: index.ts is the composition root that wires the pure core
(core.ts) into ctx.tools and ctx.systemPrompt; core.ts holds the validation, recording, and
drawing path with no Cordis or Schemastery in sight, so a suite can drive the whole tool without a
registry; chart-option.ts is the single shared ECharts mapping every surface draws — the
tree-shaken echarts.use registration plus the family branches — so the host file renderer and the
browser card cannot drift apart; format.ts, options.ts, render.ts, store.ts, card.ts, and
types.ts are the data grammar, the switch grammar, the host-only background override, the file
naming, the bounded card payload, and the shared vocabulary.
The browser half lives in src/client/: option.ts re-exports the shared chartOption from
../chart-option.ts (no second copy of the mapping), parse.ts and format.ts are a second copy of
the host's table and call readers, index.ts registers the render_chart keyed tool view and the
chart dictionaries, FeatureRow.tsx draws it, and styles.ts carries the sheet. The card reads no
live state: it draws from the logged call slice, so a replayed session draws what the live call drew,
and it falls back to a text row when no source parses.
The card still carries its own copy of the table and call readers — a browser bundle cannot reach the
host package without carrying its chart library — but the option mapping is now shared; tests/option.test.mjs
feeds one corpus to both chartOption imports (host index.ts and the card's re-export) and fails
the moment the picture on screen could differ from the picture in a saved file.
Configuration
Nine bounds are required and have no default: a hidden one would decide for the deployment how much of a chart belongs in the log, the card, and the chart directory. Two drawing overrides are optional — stated only to change the saved file's look; the card always takes the page's background and the built-in palette, so a deployment that names neither draws exactly what shipped before these fields existed.
| Field | Group | Meaning |
|---|---|---|
chartDir |
store | Directory descriptions and drawings are written to; a relative value resolves against the session workspace |
width |
store | Width save_chart draws at unless the call names its own, in CSS pixels |
height |
store | Height save_chart draws at unless the call names its own, in CSS pixels |
maxSeries |
guard | Most named series one call may declare |
maxPoints |
guard | Most plotted points (series × categories) one call may declare |
maxTitleLength |
guard | Most characters in the chart title |
maxDataBytes |
guard | Largest table one call may read or accept inline, in bytes |
maxResultChars |
guard | Largest model-facing result one call may return, in UTF-8 bytes |
maxMetaBytes |
guard | Largest chart payload a call may persist on its result, in UTF-8 bytes |
background |
override | Background painted into the SVG save_chart writes, or undefined for the host default (white). Optional: only the saved file is repainted, never the on-screen card |
palette |
override | Series color palette save_chart paints with, or undefined for ECharts' built-in palette. Optional: only the saved file is repainted, never the on-screen card |
maxMetaBytes bounds a different value from maxResultChars, which is why it is a second field rather
than the same number. maxResultChars caps the confirmation line the model reads; maxMetaBytes caps
the chart payload the result persists, which rides the session log and is re-sent on every request,
and which the deployment's output budget never shrinks. The recorded specification is already bounded
by maxPoints and maxDataBytes, but those two together still allow a payload far larger than the
line the model sees.
A chart has no partial form — half a bar chart is not a smaller answer, it is a wrong one — so the
ceiling has one degradation rather than a truncating one: past maxMetaBytes the payload is withheld
whole, and the card falls back to the logged arguments, which carry the same table and which it
already reads for a call dispatched inside run_code. The persisted payload is therefore either a
whole chart or nothing.
Commands
npm run build # tsc -p tsconfig.json && tsdown
npm run test # node --test (build first)
npm run test runs against lib/, so build first.
Mounting
The package is out-of-tree and installs from the npm registry only — never as a link: or file:
dependency. The dsh_install_dsh_ab_plugin.py dispatcher runs dsh plugin add dsh-ab-chart, then
pnpm install in the web profile; there is no local/junction path. The package's own
dsh.bundle.patch (cordis.patch.yml, shipped in the package) already inserts the host and card half
under id: chart, so the profile needs only an id-only config row — never a second - insert:
row. The Config schema declares every bound .required() on purpose, so the deployment must state
these numbers; an id-only row feeds them into the plugin the bundle patch already loaded, rather than
registering it twice (which would warn tool "render_chart" is already registered).
## <DSH_HOME>/profiles/web/cordis.patch.yml
# id-only config row: the package loads itself via its own bundle patch.
# Add config ONLY here — a `- insert:` row with the same id would double-register.
# background / palette are OPTIONAL overrides of the saved file's look; omit them
# (then the host default white background and ECharts' built-in palette apply).
- id: chart
config:
chartDir: charts
width: 960
height: 540
maxSeries: 8
maxPoints: 2000
maxTitleLength: 120
maxDataBytes: 1048576
maxResultChars: 2000
maxMetaBytes: 262144
# Install (registry only) — no link:
python dsh_install_dsh_ab_plugin.py chart
# or, by hand: dsh plugin --profile web add dsh-ab-chart && pnpm -C .dsh/profiles/web install
The row needs no filesystem provider of its own: it reads and writes through whatever ctx.fs the
composition mounts. A recording call in a composition that mounts none still returns the chart — the
card draws it — and reports that it was not recorded, so save_chart has nothing to draw.
Model Experience
System-prompt section
tool:render_chart at order 2960, after the built-in tool sections. It says when a result reads
faster as a picture than as text, to name the axes and the unit and pass the table in data or in
from, that the chart is drawn in the conversation from that call, that save_chart writes the
recorded chart to a file whose path present can deliver, and not to hand-write a chart file. The
section is empty in any scope where the tool is not visible.
Tool schema
Two schemas. render_chart takes kind, title, xLabel, yLabel, unit, data, from, and
options. save_chart takes chartPath, width, and height — three short arguments, because the
table does not travel again.
Tool-call history and result
The render_chart call, whose arguments carry the table, then one line naming the chart, its series
and point counts, its axes, and the recorded description's path. A save_chart call and one line
naming the file and its drawing size.
Auto-invocation
The plugin does not wait for the model to notice data. An agent/pre-step listener scans the inbound
user message, and when it finds a markdown table carrying a number or an explicit chart-drawing intent
("画图" / "可视化" / "chart" / "plot"), it folds a priming instruction in front of the step so
render_chart fires deterministically rather than after a missed cue. The trigger is the pure
detectChartTriggers in src/trigger.ts; the listener gates on ctx.tools.get('render_chart'), so a
composition that cannot see the tool never primes, and it acts on the inbound user turn alone, never on
a tool result or its own prime. See
.dsh/skills/dsh-plugin-development/references/auto-invocation.md.
Evidence
| File | Proves |
|---|---|
| tests/format.test.mjs | Every accepted table shape, the cell decorations, and each rejection (host) |
| tests/client/format.test.mjs | The card's table grammar agrees with the host's over one corpus, on the shapes it reads and on the ones it refuses |
| tests/options.test.mjs | The switch grammar, its rejections, and the round-trip back to the compact spelling |
| tests/option.test.mjs | The card's option mapping is identical to the host's for every family and switch, including heatmap, gauge, and dual-axis |
| tests/render.test.mjs | Each family's option, percent stacking, and a standalone SVG for every kind including heatmap and gauge |
| tests/store.test.mjs | The slug rules, both digests' inputs, and that an existing file is not written again |
| tests/chart-data.test.mjs | The four structured families: STRUCTURED_KINDS, the isStructuredKind guard, and every JSON and delimited parse plus each rejection (host) |
| tests/index.test.mjs | The core through the built entry: every ceiling, the recording write, the drawing write, and both result lines |
| tests/parse.test.mjs | Both card readers accept a well-formed payload and decline every malformed one, including the new families |
| tests/presentation.test.mjs | The persisted card payload: a bounded chart rather than the canonical value, withheld whole past maxMetaBytes |
| tests/load-path.test.mjs | The module form through the real Loader: no default export, inject kept |
| tests/loader-composition.test.mjs | A real cordis.yml boots, the bounds are live, and a recording call followed by a drawing call writes both files |
| tests/hmr-safety.test.mjs | Both tools and the routing section leave with their contributing fiber |
| tests/client-hmr-safety.test.mjs | The dictionaries leave with the fiber, and the keyed view is registered under the wire name the host tool registers |
| tests/contract.test.mjs | The installability and wiring contract: manifest scope, built artifacts, routing order above the built-in band, and the output projections the card and the model read |
| tests/conventions.test.mjs | The source rules the skill scaffolds a package with |
| tests/trigger.test.mjs | The auto-invocation detector: trigger-free text yields [], a numeric table yields its block, an intent yields the note, and both appear without duplication |
./invariant
The package publishes none. An invariant companion is warranted only when independent observations of one owned relation can diverge, and here one process both derives a file's name and writes the file, from the same specification. There is no second reader whose view could disagree; the suites cover the relationships observable from outside instead.
Known Limitations and Deferred Work
- The card redraws from the arguments when a call ran inside another program, or when the payload was withheld. The core projects presentation metadata for root calls alone, and
maxMetaByteswithholds the payload whole rather than cutting a chart, so a chart dispatched insiderun_code— or one whose payload exceeded the ceiling — is drawn from its logged arguments. A chart that read its table from a file falls back to a text row. - The card keeps its own copy of the table and call readers. A browser bundle cannot reach this package without carrying its chart library, so
src/client/parse.tsandsrc/client/format.tsmirror the host'sparse.tsandformat.ts;tests/option.test.mjsandtests/format.test.mjsfeed one corpus to both and fail if they disagree. The option mapping itself is shared viasrc/chart-option.ts, so there is a single source of drawing truth. Keep the readers in step when adding a table shape. - A saved drawing is a second call. Keeping the picture as a file costs one
save_chartcall after the recording, and the two are separate entries in the transcript. - A recorded description is kept until the deployment removes it. Nothing sweeps
chartDir; a recording call skips the write when the description already exists, so repeats do not accumulate, but every distinct chart stays until an operator clears the directory. - A negative written as
-50and one written as(50)are the same value. Both are read; the recorded series carries no memory of which spelling arrived. - Percent stacking is a drawing decision, not data. The recorded specification keeps the measured values; the shares exist only in the option handed to ECharts.
- The host carries ECharts in its bundle. The tree-shaken import registers thirteen chart families, six components, and the SVG renderer, and only
save_chartuses it, but one package entry means one bundle: the built entry is about 1.5 MB. The card carries the same tree-shaken set. - No PNG. SVG needs no rasterizer and previews in the sidebar; a PNG would add a native dependency for no capability the deployment has asked for.
- A drawing is made at one size. The call may name
widthandheight, but the size is part of the drawing's name, so the same chart saved at two sizes is two files. - The section order is a literal because repository-owned tool sections allocate theirs centrally; a built-in tool would call
ctx.systemPrompt.getSectionOrder(name).
Dev Note
npm run verify runs tsc, tsdown, node --test, and the packaging linters over the built
lib/, so a green suite means the artifact the profile row resolves is the artifact that behaves.
The host draws nothing inside render_chart — that is what keeps the picture the logged call — and every
rendering path lives behind save_chart. The card re-exports the shared option mapping from
src/chart-option.ts, so there is one drawing implementation; it keeps its own copy of the table
grammar only because a browser bundle cannot reach this package without carrying its chart library, and
tests/option.test.mjs and tests/format.test.mjs feed one corpus to both copies and fail if they
disagree. When you add a family or a switch, add it to types.ts, src/chart-option.ts, and the card's
re-export together, then extend tests/render.test.mjs and tests/option.test.mjs.