Skip to content

dsh-ab-chart

Verified

dsh-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 maxMetaBytes withholds the payload whole rather than cutting a chart, so a chart dispatched inside run_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.ts mirrors src/render.ts; tests/option.test.mjs feeds 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_chart call 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 -50 and 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_chart uses 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 width and height, 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.