wyzh0117/dsh-notebook

dsh-notebook

侧边栏记事本:带标题与正文的记事,可插入图片(拒收视频);点标题把正文复制到剪贴板;输入框里可用 @ 引用某条记事;可选在新会话时自动打开记事本。

未分類deepseek-harnessdsh-plugin-ecosystemnotebook

dsh-notebook

A notebook in DSH's official right sidebar.

+ a note → title + body → paste images → Done files it under its title. Click a title to copy the body · type @ to reference a note · Edit reuses the same container.

dsh-plugin · deepseek-harness · notebook · notes · sidebar

English · 中文

What's new

  • v0.3.0 — the page now lives in DSH's official right sidebar and nowhere else: the dsh-better-sidebar carrier and the plugin's own fallback shell are gone, openOnStart is honoured by the official sidebar, and the peer ranges had to move to 0.2.0-rc.2 before the bundle would load at all.
  • v0.2.0 — the conversation can write notes too: select text for a floating “To notebook” action, or click the notebook icon at the end of any answer to save the whole reply. Both on by default.
  • v0.2.1 — the body box sizes itself: it grows as you type, shrinks as you delete, stops at 60 % of the window height.
  • v0.2.2 — no more native confirm prompts (a native modal can freeze an embedded host); every string follows the shell's language.
  • v0.2.3 — “Open the notebook for new sessions” now really opens the sidebar.

Jump to · Features · Install · Usage · Compatibility · Settings · Under the hood · Limitations · FAQ · Development

Suggested GitHub topics: dsh-plugin deepseek-harness notebook notes sidebar


What it is

dsh-notebook is a Web plugin for DSH (DeepSeek Harness) that puts a small notebook in DSH's own right sidebar — one tab registered through the official tab registry, no companion plugin involved.

It does one thing: jot down a note with a title, a body and a few images, then use it from the composer — click a title to copy the body, type @ to reference a note.

No rich-text editor, no cloud sync, no version history. Notes are global (not per conversation) and images are stored as real files on the host disk.

Features

FeatureDetail
+ creates an entryTop-right of the panel; the editor opens inside the panel — no new window, no second tab
Title + body + imagesSingle-line title, content-sized body, image thumbnail strip in one scrollable container
Images yes, videos noPaste, drag & drop and “Insert image” share one validation path: video/* and any known video extension (mp4/mov/webm/mkv/avi/m4v/ogv/mpg/mpeg/3gp/flv/wmv) are refused inline
“Done” files it under its titleThe list shows one row per note, newest first, with “time · N images” as secondary metadata
Click the title to copyCopies the body only — never the title — with images as [image: name] lines and a “body copied (N chars)” toast. It never writes into the composer
@-reference a noteTyping @ lists notes under the localized “Notebook” heading (title + snippet, newest first, max 8). Picking one inserts an atomic chip; only the body reaches the model
“Reference” row buttonSame as an @ pick, from the row itself; with no reachable composer it says so instead of pretending
Select text → “To notebook” (v0.2.0, on)A floating action beside the selection files it verbatim as a note titled 未命名1, 未命名2, … Selections inside the composer or in the panel itself are not offered
Every answer → “save to notebook” (v0.2.0, on)One icon at the end of the answer's action row saves the whole reply, titled with the session's own title
Both captures are switchableselectionToNotebook / messageToNotebook in settings; a change applies on the next render, no reload
Auto-open for new sessions (off by default)Opens the Notebook whenever a session becomes current — including the one a page load restores
“Edit” reuses the same containerThe DOM never holds more than one editor
Images land on diskUploaded as dataURL, decoded by the host into $DSH_HOME/storages/notebook-attachments/<noteId>/; deleting a note deletes them too
Atomic writes, no silent lossTemp file → fsync → .bak → rename, serialized by a mutex. If $DSH_HOME is not writable the host degrades to memory and every response carries degraded: true, which the UI shows as a banner
KeyboardCmd/Ctrl+Enter = Done, Esc = Cancel (a dirty draft asks first)

Longer, deeper notes on the design live in Under the hood.

Screenshots

No screenshots on this page right now. The images that used to sit here were captured on the pre-0.3 shells, which no longer exist, and the official right sidebar has not been re-photographed since the migration. Until it is, the Features table is the ground truth: the panel is the note list with + in its header, plus one reusable editor container holding title, body, image thumbnails and the inline video refusal.

Install

DSH>=0.2.0-rc.2
Node>=20
Package managerpnpm — this repo does not support npm
Companion pluginsnone — the page renders in the sidebar DSH ships

From the repository:

dsh plugin --profile web add github:wyzh0117/dsh-notebook

From source (development):

git clone https://github.com/wyzh0117/dsh-notebook.git
cd dsh-notebook
pnpm install
pnpm build            # emits lib/index.js, lib/client.js, lib/types/**

dsh plugin --profile web add "link:$PWD"

The manual equivalent — in ~/.dsh/profiles/web/package.json:

{
  "dependencies": { "dsh-notebook": "link:/abs/path/to/dsh-notebook" },
  "dsh": { "profile": { "bundles": [ /* … */, "dsh-notebook" ] } }
}

Then pnpm install inside the profile directory. dsh.profile.bundles must contain dsh-notebook, otherwise the plugin is never loaded. If it is listed and the row still shows as disabled, the version gate skipped the bundle — see If the plugin does not load at all.

While developing, never restart the DSH instance you are working in (e.g. the Web GUI on port 3080 — restarting it kills the current session). For a real end-to-end check, start an isolated environment:

DSH_HOME=/tmp/dshnb-home npx -y --package @deepseek-ai/dsh dsh web --port 3099

Usage

  1. Expand the right sidebar and add Notebook from its + list — the row carries the plugin's title, description and icon. The sidebar keeps one surface per session, so a freshly created session starts without it; “Open the notebook for new sessions” does the picking for you.

  2. Click + in the top-right — the editor opens inside the panel.

  3. Write a title and a body. The body box follows its content (up to 60 % of the window height, then it scrolls inside). Add images by pasting, dropping, or “Insert image” — videos are refused; one image is capped at 10 MB and a note at 20 images (both adjustable).

  4. Click Done (or Cmd/Ctrl+Enter) — the note is filed under its title.

  5. Click the title text to copy the body to the clipboard.

  6. To let the model read a note, type @ in the composer and pick it, or click Reference at the end of its row. Only the body is sent; re-insert a reference after a page reload.

  7. Edit reloads a note into the same container; Delete asks in the panel's own dialog first.

  8. Let the conversation write notes for you (v0.2.0):

    • Select text in the session → click the floating “To notebook” action.
    • Click the notebook icon at the end of an answer → the whole reply is saved under the session's title.

    Both confirm with the same toast and can be switched off in settings.

Compatibility

DSH>=0.2.0-rc.2 — the version line whose official right sidebar (ctx.sidebarRightTabs) this plugin registers into
Node>=20
Companion pluginsNone. The host half only needs webServer, cordis and schemastery; nothing else has to be installed for the tab to appear
Optional DSH seamsWithout conversation, inputTriggers or sessions, the matching integration simply turns off and the plain notebook remains
Older shellsEvery feature registers through optional seams and degrades instead of breaking: a missing slot means the capture entry never appears, an unreadable locale falls back to the plugin's own zh dictionaries, and the panel keeps rendering without window.confirm or any dialog API

Where the page lives — the official right sidebar

There is one carrier: the sidebar DSH ships. The tab is registered in two phases, body first:

  1. the tab body into the keyed slot sidebar.right.pane.tab, under key: 'dsh-notebook';
  2. the tab type into ctx.sidebarRightTabs.register({ id: 'dsh-notebook', kind: 'dsh-notebook', priority: 'extension', title, guide: [{ id, order: 60, title, description, icon }] }).

Three constraints the registry enforces and does not negotiate:

  • the slot's key must equal the stage-1 id — the seat dispatches a tab to the entry keyed by the type in force for its kind. A list-shaped { id, order } descriptor on a keyed slot is not a near miss, it is a failed registration, and the sidebar's answer is a Notebook tab that can never render;
  • a guide entry needs its own stable id, or the registry throws duplicate guide entry id;
  • body before type, so a half-registered host never shows a tab that cannot draw — a failed phase tears its siblings down with it.

The tab body reads its visibility through the slot's injected useTabInfo() hook, because the seat renders a tab with an empty owner share and tab.visible never arrives as a plain prop. That is what lets a hidden tab skip loading and polling.

src/client/hosts/controller.ts probes sidebarRightTabs and, when the service has not arrived yet (cordis serves plugins in dependency order, not sidebar-first), waits for it once through ctx.inject(['sidebarRightTabs'], …). There is no fallback shell: if the service never appears nothing registers, and the reason is logged. Every registration sits inside ctx.effect(() => { …; return dispose }), so HMR and disable are clean.

The open IS the reveal. ctx.sidebarRight.openTab(kind) expands the column as part of opening the tab, and throws while no Session is on screen. isExpanded() answers from the surface the seat bound at its last committed render — so it still says false inside the same synchronous block as a successful open — while toggleExpanded() flips the live value. Reading one and “correcting” with the other collapses the column the open just revealed; that was v0.2.2's bug, and nothing may follow the call.

If the plugin does not load at all

dsh 0.2.0-rc.2's dsh-app-boot reads every package.json peerDependency named @deepseek-ai/dsh or starting with @deepseek-ai/dsh- and runs semver.satisfies(runtimeVersion, range, { includePrerelease: true }). On any mismatch it skips the whole bundle: the module is never imported, the plugin row shows as disabled, and the log says

dsh: skipping profile bundle "dsh-notebook": … is incompatible with dsh 0.2.0-rc.2: peerDependencies { … }

Two things that follow from this and surprise people:

  • dsh.plugin.json's engines.dsh is not enforced in rc.2 — only the peer ranges decide, so a correct engines field does not save a stale peer;
  • the server half is not the problem: this plugin only ever needed webServer, cordis and schemastery. What stopped it loading on 0.2 was the version gate in package.json, not the code.

The escape hatch is dsh plugin allow-version, which writes an exact-version exemption into <profile>/compatibility.json; the matching disallow command reverses it. Use it to look at a mismatch, not to live with one — an unmet peer range normally means a real API difference.

Settings

One definition, one seat. The source of truth is the host's NotebookDoc.prefs — reads and writes always go through PATCH /notebook/api/prefs — and all eight switches are offered in DSH's global settings page, because the official sidebar has no per-tab settings page.

KeyTypeDefaultMeaning
sortOrder'updated' | 'created' | 'title''updated'List ordering
copyImagesAsNamebooleantrueRender images as a [image: <name>] line when copying
maxImagesPerNotenumber20Image cap per note (1–100)
confirmDeletebooleantrueAsk before deleting, in the panel's own dialog; off deletes on the click
openOnStartbooleanfalseExpand the sidebar and open Notebook when DSH starts
autoOpenOnNewSessionbooleanfalseOpen the Notebook whenever a session becomes current
selectionToNotebookbooleantrueShow the floating “to notebook” action over a text selection
messageToNotebookbooleantrueShow the “save to notebook” icon at the end of every answer

The section is registered by the sidebar host itself (hosts/settingsSeat.ts → settings.section, id dsh-notebook, order 100), and registering it is a no-op wherever the slot is not declared — a composition without a settings dialog is never an error. Both startup switches drive the same openTab gesture a click performs: openOnStart fires once the host registers and again when the preferences come back from the host, and is a harmlessly swallowed no-op while no session is on screen.

Under the hood

Everything below is for people reading or changing the code. Skip to Known limitations if you just want to use the plugin.

Architecture

                     ┌─────────────────────────── browser ───────────────────────────┐
                     │ lib/client.js  (CJS module-table factory, id = "dsh-notebook")│
                     │  controller: probe sidebarRightTabs → native host             │
                     │  NotebookView ─ NotebookEditor(one instance) ─ clipboard      │
                     │  capture (v0.2.0): selection pill + answer icon → capture.ts  │
                     │  body sizing (v0.2.1): textarea → autoGrow.ts                 │
                     │  dialogs (v0.2.2): in-panel prompts → locales.ts              │
                     └──────────────────────────────┬────────────────────────────────┘
                                     fetch JSON      │
                     ┌──────────────────────────────┴────────────────────────────────┐
                     │ lib/index.js   (cordis plugin, ctx.webServer prefix)           │
                     │  routes.ts ─ store.ts (atomic write + mutex) ─ attachments.ts  │
                     └──────────────────────────────┬────────────────────────────────┘
                                                    │
                            $DSH_HOME/storages/notebook.json
                            $DSH_HOME/storages/notebook.json.bak
                            $DSH_HOME/storages/notebook-attachments/<noteId>/<attachmentId>.<ext>
  • src/client/autoGrow.ts is the only place the body box's geometry is decided: it measures the <textarea> against its content and writes height / maxHeight / overflowY, while NotebookEditor decides when (mount, text change, width change, viewport-height change).
  • The tab body reads visibility from the slot's injected useTabInfo() hook (the seat hands it an empty owner share), so a hidden tab really does skip loading and polling.
  • Images are uploaded as dataURL (client-side FileReader) and decoded by the host — no multipart parser dependency.

Host HTTP API

Registered on ctx.webServer (kind: 'prefix', path: '/notebook/api'), JSON over HTTP, with Cache-Control: no-store on every response.

MethodPathRequestResponse
GET/notebook/api/state—{ doc, degraded, degradedReason? }
POST/notebook/api/notes{ title, body, attachments: [{ name, mime, size, dataUrl }] }{ note }
PATCH/notebook/api/notes/:id{ title?, body?, attachments? }{ note }
DELETE/notebook/api/notes/:id—{ ok: true }
PATCH/notebook/api/prefsPartial<NotebookPrefs>{ prefs }
GET/notebook/api/attachments/:noteId/:file—image bytes + Content-Type
GET/notebook/api/health—{ ok, version, degraded }
  • Security fence: only loopback requests are accepted (req.socket.remoteAddress ∈ 127.0.0.1/::1) and Origin/Host must be an allowed local origin, otherwise 403; path parameters are traversal-checked; request bodies are capped at 32 MB (413).
  • 400/403/404/413/415/500 all return a structured { error: { code, message } }.
  • The host re-checks for video (it does not trust the client) and answers 415.

On-disk layout

PathContents
$DSH_HOME/storages/notebook.jsonNotebookDoc (version: 1, notes[], prefs), written atomically
$DSH_HOME/storages/notebook.json.bakThe last known-good version; tried first when the JSON is corrupt
$DSH_HOME/storages/notebook-attachments/<noteId>/<attachmentId>.<ext>Image bytes

$DSH_HOME resolution order: explicit injection > process.env.DSH_HOME (whitespace-only counts as unset) > ~/.dsh. If .bak is corrupt too, the store starts from an empty document and renames the broken file to notebook.json.corrupt-<ts>.

Session integration (v1.1)

Every seam here is public and entirely optional: the client bundle may only require() the platform seed modules — today that is exactly react and react/jsx-runtime — and never imports a DSH UI package, so every member is duck-typed and every call is guarded.

@ references. The plugin registers a @ source named dsh-notebook through ctx.inputTriggers.registerSource (order: 20), so typing @ lists notes next to files and sessions. Candidates are filtered case-insensitively on title and body, newest first, at most 8 rows; an aborted query or a failed read yields no rows rather than breaking the menu. Picking a row inserts an atomic chip whose clipboard form is @[title](dsh-notebook:<noteId>). At send time the note's body only is serialized (the title is deliberately never sent), each image marker becoming one [image: <name>] line: a deleted note contributes nothing, while a real read failure blocks the send with a visible error instead of silently downgrading the mention.

Auto-open. The watcher fires when the current session becomes a different one; a re-published snapshot of the same session does not count, and neither does the session already current at activation. openTab can throw while the seat holds no binding, so the open is retried at 0 / 200 / 500 / 1200 / 2500 ms and abandoned the moment the session changes again. Since the plugin activates before the session list arrives, the selection a page load restores also counts as “becoming current”.

The composer attachment bridge is implemented, unit-tested and deliberately unwired. Clicking a title must only copy, never write into the composer, so its only entry point was removed (product decision, 2026-09-15). The seams are the expensive part and are already covered, so composer.ts and its tests are kept on purpose, waiting for an explicit action of their own. No UI can trigger it today.

Why the open is the reveal (the v0.2.2 bug, the v0.2.3 fix). sidebarRight.openTab(kind) expands the column as part of the open (its first planned store op is planSetExpanded(state, true)), while isExpanded() answers from the surface bound at the seat's last committed render — so a read in the same synchronous block as openTab() is one React commit behind. v0.2.2 read that stale false and then “corrected” it with toggleExpanded(), which flips the live value and collapsed the column the open had just revealed. DSH publishes no idempotent setExpanded(), so there is no safe correction from outside: the plugin now calls openTab alone, and test/auto-open.test.ts models the host's real semantics and asserts the panel ends expanded with zero isExpanded / toggleExpanded calls.

Capturing from the conversation (v0.2.0)

Two ways for the conversation to fill the notebook, both living in the shell rather than in the Notebook tab (so they are offered whether or not the tab is open) and both on by default. They share one write path — client/capture.ts — so title numbering, save ordering and the toast cannot drift apart.

selection ──► selectionAction.ts ─┐
                                  ├──► capture.ts ──► POST /notebook/api/notes ──► NotebookDoc.notes
assistant answer ──► answerAction.ts ─┘        (title minting · serialized writes · subscribers · toasts)
Select text → notebookAnswer → notebook
Entry pointA floating pill beside the selection (shell.overlay)One extra icon at the end of a finalized message's action row
PreferenceselectionToNotebookmessageToNotebook
TitleThe numbered default 未命名n — the smallest n no existing title uses, so deleting 未命名2 hands the next capture that slotThe session's own title; with none yet, the numbered default instead of a placeholder the user never chose
BodyThe selection verbatim (no trimming)Every text block in order, joined by a blank line; reasoning, tool-call and image blocks are left out
RefusesEmpty/whitespace-only selections; selections inside the composer, any editable control, or this plugin's own panel; selections outside the conversationNothing to offer without a readable snapshot, or on a row with no usable message id (an interrupted answer carries none)

Where the action lands in the row. The DSH slot renders inside the message row's extension band, between the hardcoded Copy and Branch buttons. order: 20 puts this plugin after the shipped good-response pair, so the icon is the last entry the slot can express — it cannot be placed after Branch.

Numbering is owned by the capture service. A title is chosen from the notes the host holds plus the titles this activation already minted, and is held until its request settles: consecutive captures never collide even when the note list is unreadable, while a failed save releases its number so a retry mints the same one. Saves are serialized, so that read-modify-write cannot interleave.

Selection scope, honestly. DSH exposes no selection service, so the feature watches the document itself and decides what counts as “in the session” from the shell's semantic DOM hooks ([data-chat-flow], [data-conversation-scroll], then [data-slot=…]). A future shell that renames them degrades to “anywhere outside the composer and outside our own panel” instead of silently never firing.

Known limitations

Deliberately out of scope for v1 / v1.1 / v0.2.0–v0.3.0.

  • No video / audio or other rich media — an explicit requirement; both the client and the host reject it.
  • No multi-user, cloud sync, sharing, live collaboration or AI auto-organising, and no version history (only the most recent content is kept).
  • Notes are **globa