dsh-ab-chart
Verifieddsh-ab-chart ยท v0.1.4 ยท MIT ยท Web UI
Chart rendering tool with the browser card that draws its calls, in one publishable plugin package
Install
dsh plugin add dsh-ab-chart Confirm the layer applied with dsh --profile default --dump-config โ see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
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.
# <DSH_HOME>/profiles/web/cordis.patch.yml
- insert:
- id: chart
name: 'dsh-ab-chart'
// <DSH_HOME>/profiles/web/package.json
"dsh-ab-chart": "link:<plugins root>/plugins/dsh-ab-chart"
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
pnpm run build # tsc -b both faces, then tsdown for lib/index.js and lib/client.js
pnpm test # the host suite, the card suite, and the package contract suite
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 |
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 |
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.
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; format.ts, options.ts, render.ts, store.ts, card.ts, and types.ts are the data
grammar, the switch grammar, the ECharts option, the file naming, the bounded card payload, and the
shared vocabulary.
The browser half lives in src/client/: option.ts is a second copy of the host's option mapping
(the tree-shaken ECharts registration plus the family branches), 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 duplication is deliberate โ a browser bundle cannot reach the host package without carrying its
chart library โ and tests/option.test.mjs and tests/format.test.mjs feed one corpus to both copies
and fail the moment the picture on screen could differ from the picture in a saved file.
Configuration
Every field is required and has 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.
| 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 |
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
pnpm build # tsc -p tsconfig.json && tsdown
pnpm test # node --test tests/ (build first)
pnpm test runs against lib/, so build first.
Mounting
The package is out-of-tree. Install it as a link: (or file:) dependency that resolves its manifest;
its 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.
- id: chart
config:
chartDir: charts
width: 960
height: 540
maxSeries: 8
maxPoints: 2000
maxTitleLength: 120
maxDataBytes: 1048576
maxResultChars: 2000
maxMetaBytes: 262144
// <DSH_HOME>/profiles/web/package.json
"dsh-ab-chart": "link:<plugins root>/plugins/dsh-ab-chart"
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.
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/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 |
./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 option mapping. A browser bundle cannot reach this package without carrying its chart library, so
src/client/option.tsmirrorssrc/render.ts;tests/option.test.mjsfeeds one corpus to both and fails if they disagree. Keep the two in step when adding a family or a switch. - 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 nine 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
pnpm build compiles with tsc and bundles with tsdown; pnpm test runs node --test over the built
lib/index.js, 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 keeps its own copy of the table grammar and of the
option mapping, because a browser bundle cannot reach this package without carrying its chart library;
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, render.ts, and src/client/option.ts
together, then extend tests/render.test.mjs and tests/option.test.mjs.