dsh-plugin-notebook-tools
Cell-aware Jupyter notebook tools for DSH agents. A zero-dependency Node ESM
plugin that ports the OpenClaw notebook-tools plugin (9 tools) onto the DSH
host.
The core read / write / edit tools treat .ipynb files as opaque JSON,
which is error-prone, token-hungry, and easy to corrupt. This plugin provides a
structured, cell-aware API instead: stable cell ids, output-aware truncation,
stale-edit guards, and format-preserving atomic writes.
Install
dsh plugin --profile desktop add --config.minimumReleaseAge=0 F:\path\to\dsh-plugin-notebook-tools
dsh plugin forwards to the pnpm bundled with DSH, and --profile is
required. The --config.minimumReleaseAge=0 override is needed while the
target profile's existing lockfile entries are newer than pnpm's default
minimumReleaseAge cutoff (pnpm 11.7.0 enforces that default even when the
profile's pnpm-workspace.yaml lists the same offenders under
minimumReleaseAgeExclude). Without it you get
ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION naming packages you did not touch.
On success the package is appended to both dependencies and
dsh.profile.bundles in <DSH_HOME>\profiles\<profile>\package.json — no
manual edit needed. A failed run can still leave a link in node_modules, so
treat the package.json entry, not node_modules, as proof.
The plugin ships a cordis.patch.yml that inserts it under the id
notebook-tools. DSH composes that row at boot, so restart the DSH app for
the tools to appear in an already-running session.
No dependencies are installed: pnpm install is unnecessary, and the package
imports only node: builtins.
Companion skill
skill/notebook-tools/SKILL.md is the matching DSH skill: it tells the agent
when to reach for these tools, which of them can mutate a notebook, and how
stable a cell id really is. Copy it into the skills directory:
Copy-Item -Recurse skill\notebook-tools "$env:USERPROFILE\.dsh\skills\notebook-tools" -Force
DSH hot-reloads the skills directory, so the skill shows up without a restart — unlike the plugin itself, which is composed at boot.
Tools
| Tool | Writes | Purpose |
|---|---|---|
notebook_create |
yes | Create a notebook from structured cells |
notebook_read |
no | Structured read, optionally with outputs |
notebook_list_cells |
no | Cheap index: id, type, preview, counts |
notebook_search |
no | Find cells by text or regex |
notebook_validate |
no | Validate structure and report facts |
notebook_edit_cell |
yes | Replace a cell's source / type |
notebook_insert_cell |
yes | Insert a new cell at a position |
notebook_delete_cell |
yes | Delete a cell by id or index |
notebook_clear_outputs |
yes | Clear outputs (all or one cell) |
Guarantees
- Writes preserve the file's own formatting. The plugin probes the layout a
notebook already uses — JSON indent (0/1/2/4), CRLF vs LF, trailing newline —
and writes it back unchanged, so a notebook produced by JupyterLab,
nbformat.writes, or a text editor is not reformatted. A cell whosesourceis a plain string stays a string, and keys that were absent (outputs,execution_count,metadata) are not invented. Read-then-write with no edit is byte-identical. Not preserved: tab indentation,\uXXXX-escaped non-ASCII (Python'sensure_ascii=True), and more than one trailing newline. - Atomic saves. Mutations serialize to a sibling temp file
(
.‹basename›.tmp-‹pid›-‹counter›-‹random›) in the same directory,fsyncit, thenrenameover the target. A killed process can never leave a half-written notebook, and no temp file ever lands in the system temp directory. - Validation before every write. A notebook that fails the deep audit — cell
ids outside
^[A-Za-z0-9_-]{1,64}$or duplicated, non-objectmetadata, non-arrayoutputs, non-stringsourceelements — is refused by the writers and reported bynotebook_validate, instead of being silently rewritten. - Cell ids over indexes.
notebook_read,notebook_list_cells, andnotebook_searchalways return anid. A notebook too old to store ids (nbformat < 4.5) gets ids derived fromsha256("v1|<ordinal>|<source>"), so the same cell keeps the same id across loads and processes; the derived id is persisted only once the file's format supports ids.nbformat_minoris never bumped. Indexes shift after every insert/delete — prefer ids. - Editing a code cell clears
outputsandexecution_count, and the response text says so. - Three stale-edit guards on every mutating tool.
expected_file_sha256catches any change to the bytes on disk — including a rewrite inside the same millisecond, whichexpected_file_mtime_mscannot see;expected_source_sha256catches a change to the one cell an operation depends on (the anchor cell for an insert, the target cell for an edit, delete, or clear); the mtime check is the cheap one and tolerates sub-millisecond jitter. All of them fail before any write.notebook_readandnotebook_list_cellsreturn bothmtime_msandfile_sha256; onlynotebook_readreturns per-cellsource_sha256. - nbformat 4 only. v3 and earlier are rejected.
- No kernel execution, ever.
- Failures throw a
NotebookErrorsubclass carrying a stablecode(NOTEBOOK_NOT_FOUND,STALE_NOTEBOOK,CELL_NOT_FOUND, …). There is noisErrorfield on the DSH tool result contract.
Usage
notebook_list_cells { path: "~/analysis.ipynb" }
notebook_read { path: "~/analysis.ipynb", cell_ids: ["a1b2c3d4"] }
notebook_search { path: "~/analysis.ipynb", query: "train_test_split" }
notebook_edit_cell { path: "~/analysis.ipynb", cell_id: "a1b2c3d4",
new_source: "model.fit(X, y)\n",
expected_source_sha256: "…",
expected_file_sha256: "…" }
notebook_validate { path: "~/analysis.ipynb" }
Pass the guards straight back from the read that produced them — file_sha256
comes from notebook_read or notebook_list_cells, source_sha256 only from
notebook_read.
Development
node --test # from this directory
Tests use node:test + node:assert/strict and require Node >= 22 (verified
on Node 24).
Releasing
Releases are one command, run from a clean main that already contains the
version bump:
npm run release -- --title "v0.2.1 - short one-line summary"
It refuses to run unless CHANGELOG.md carries a non-empty ## [<version>]
section and a [<version>]: link reference for the version in package.json,
the working tree is clean, and HEAD is the commit that carries the bump. Then it
runs node --test, pushes the release branch, creates the annotated tag
v<version>, pushes it, and
creates the GitHub Release: the tag annotation is the release title and the
CHANGELOG section is its body. Credentials come from GITHUB_TOKEN / GH_TOKEN,
falling back to the stored git credential for github.com (nothing is ever
printed); the GitHub CLI is not required.
Other flags: --dry-run (print the plan, change nothing), --skip-tests,
--branch <name> (default main), --remote <name> (default origin).
Layout
lib/index.js composition only — registers the 9 tools on ctx.tools
lib/nb/*.js pure domain; node: builtins only, no host SDK imports
lib/tools/*.js adapters: JSON Schema params, error mapping, response text
scripts/release.mjs the release command behind "npm run release"
test/*.test.js node:test suites
cordis.patch.yml the composition row DSH merges at boot
skill/notebook-tools/ the companion skill (SKILL.md)
The domain layer (lib/nb/) knows nothing about DSH — it only knows about
notebooks, so it stays unit-testable in isolation.
Credits and license
MIT. This is a port, not a fork: the domain layer and all nine tool adapters were re-implemented for the DSH plugin host from the original plugin's documented behavior.
- Upstream:
Lightbridge-KS/openclaw-notebook-toolsby Kittipos Sirivongrungson (MIT). The tool surface, the behavioral spec in itsdocs/Notebook-Tools-Spec.md, and the companion skill all originate there. - This port: the DSH packaging (
cordis.patch.yml, thedsh.bundle.patchhook), the zero-dependency plain-object tool descriptors, and the test suites.
LICENSE carries both copyright notices.
No comments yet. Be the first to write one.