Chuyển đến nội dung chính

dsh-ab-chart

Đã xác minh

dsh-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 — 12345678 reads 12,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 palette is configured, series take the Okabe–Ito set, which stays distinguishable under the common forms of color blindness. A deployment that names palette draws with its own colors instead; the card and the file always use the same set.
  • Translucent area fill. An area series 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.
  • step line. A line or area series drawn with step holds each value across its category — a staircase — which suits discrete or cumulative measures; when smooth and step are both named, step wins.

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 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 table and call readers. A browser bundle cannot reach this package without carrying its chart library, so src/client/parse.ts and src/client/format.ts mirror the host's parse.ts and format.ts; tests/option.test.mjs and tests/format.test.mjs feed one corpus to both and fail if they disagree. The option mapping itself is shared via src/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_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 thirteen 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

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.