dsh-obsidian-panel
在右侧栏集成Obsidian知识库及搜索大纲功能。 适合Obsidian重度用户,便于在对话时同屏浏览和检索本地笔记。
安裝
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:DedsecLemon/dsh-obsidian說明文件
閱讀完整 README ↗English · 中文
dsh-obsidian
An Obsidian vault inside the DSH right Sidebar. Install steps: INSTALL.md.
No build step — index.mjs and client.js are the running source.
git clone https://github.com/DedsecLemon/dsh-obsidian.git D:/skill/dsh-obsidian
# then in ~/.dsh/profiles//package.json
"dsh-obsidian-panel": "link:D:/skill/dsh-obsidian" # dependencies
"bundles": ["dsh-obsidian-panel"] # dsh.profile.bundles
Naming
The project is dsh-obsidian; the package name is dsh-obsidian-panel, and it is
not published to npm — install it from this repository. The name is not a choice: npm's
dsh-obsidian belongs to
a different DSH + Obsidian plugin by another
author, so that name could never be published — or installed — without getting that one
instead. The plugin's runtime identity is unchanged: routes are /dsh-obsidian/*, slot keys
are dsh-obsidian/*, and the app label is 知识库.
Compatibility
| DSH | ^0.2.0-rc.2 — verified on 0.2.0-rc.2 |
| Node.js | >=22.12.0 — only stable node: APIs; tested on 22.12.0 |
| Systems | Windows, macOS, Linux (os). Obsidian itself is found per platform — /Applications and ~/Applications on macOS, AppImage//usr/bin//usr/local/bin/snap/flatpak on Linux, the usual per-user and machine-wide installs on Windows — and DSH_OBSIDIAN_APP overrides every one of them. Vault detection reads Obsidian's own registry where that platform keeps it. test/platform-check.mjs asserts all three lists. Run on Windows so far: the macOS and Linux lists are asserted, and only the Windows list has been exercised against a real Obsidian |
| Profile | web (dsh.client.platform), i.e. any profile with a client half |
| Per-release | dsh.compatibility.dshReleases in package.json. Only 0.2.0-rc.2 is compatible; anything else is unknown until somebody runs it — that is what the field means |
| Install / start / uninstall | verified against the packed tarball in a disposable profile: docs/LIFECYCLE.md |
Re-run the compatibility matrix and the lifecycle evidence before claiming a new release:
node test/profile-lifecycle.mjs # install → start → uninstall, in a temp profile
node test/mount-check.cjs # the client half, mounted
node test/render-check.cjs # registration + the guards
node test/host-check.mjs # every HTTP route and the security boundary
What it touches, and what breaks when it cannot
A vault panel is not a toy: it reads your notes, writes the one you edit, and needs a place to keep three small state files. So this plugin declares its surfaces instead of leaving them to be discovered:
| Signal | What is actually true |
|---|---|
| files | Reads the vault folder you chose (and, only for the first-run guess, Obsidian's own registry %APPDATA%\obsidian\obsidian.json). Writes: the three state files under DSH_HOME/dsh-obsidian/ (vault.json, chat.json, diag.json, ≈0.3 KB) and the one note you press 保存 on. Every read is confined to the vault root — escapes through symlinks or junctions are refused (the host harness proves it) |
| network | No outbound requests at all. The panel fetches the host's own loopback routes (/dsh-obsidian/*), which is why the client half contains fetch. There is no telemetry and no third-party endpoint |
| commands | Opening Obsidian goes through the host's own subprocess service — the plugin never imports node:child_process and never builds a shell string. It happens only when you click 在 Obsidian 中打开 |
| credentials | None. The marketplace scanner flags any process.env read as a credentials signal; this plugin reads APPDATA, LOCALAPPDATA, ProgramFiles, ProgramFiles(x86), DSH_HOME and DSH_OBSIDIAN_APP — environment paths. No tokens, no API keys, no cookies, no OAuth |
| dependencies | No runtime dependencies (dependencies is empty). jsdom/react/react-dom are dev-only, for the harnesses |
| external services | The Obsidian desktop app — optional, only for "open in Obsidian" — and the DSH host it runs inside. Opening a note hands Obsidian's obsidian:// URI to the platform's own handler (start on Windows, open on macOS, xdg-open on Linux) |
Failure bounds — the plugin degrades in pieces rather than half-registering:
| Missing | What the reader sees |
|---|---|
slots, sidebarRightTabs, sidebarRight | the plugin does not activate at all — no entry in the left Sidebar, and nothing stays half-registered |
sessions, workspaces | the tree, the note pages and the outline work normally; 对话 says why it cannot open |
uiWorkspace | no folder picker and no conversation navigation; the vault path can still be set from the panel |
the host's webServer | no routes register, so the panel has no data to show |
| Obsidian is not installed | only "open in Obsidian" is unavailable (the obsidian:// protocol handler is still tried) |
| Obsidian is somewhere unusual | set DSH_OBSIDIAN_APP to its absolute path — that outranks every discovered location on every platform |
| the vault was moved or deleted | the tree shows the error and asks for a folder again — a failed read is never cached as "empty" |
Because files and network are genuinely used, a marketplace that grants automatic
installation only to capability-free plugins will keep this one user-reviewed. That is
the honest outcome, not a defect to engineer around: the alternative would be a note
browser that cannot read notes.
Which folder is the vault
Obsidian's registry is a guess, not an answer. On first run the panel shows a
chooser instead of a tree, offering the detected vault and a folder picker; nothing
is read until the reader agrees to a folder. The choice is stored under DSH_HOME
(dsh-obsidian/vault.json) and from then on outranks the registry for every read.
The header keeps a folder button, so changing it later is one click.
GET/POST /dsh-obsidian/vault is that state. The POST validates that the folder
exists, is a directory, and looks like a knowledge base — it either carries
Obsidian's own .obsidian folder or has at least one .md/.markdown/.canvas/
.txt file within two levels. "Exists and is a directory" was true of every folder
on the machine, so aiming the panel at one served it as if it held notes; the
refusal names the path and what was missing. The host-check probe backs the file
up and restores it —
like the chat Session id, it is live state, not a fixture, and a harness that
overwrote it would silently change which folder the user's plugin reads.
A DeepSeek Harness bundle that puts an Obsidian vault in the app's right
Sidebar: a 知识库 (Knowledge Base) tab with the vault tree and full-vault search, a note page
that renders Markdown the way Obsidian's reading view does and lets you edit the
file in place, and a conversation with an agent whose Session lives in the vault's
own Workspace — opened in the app's own conversation panel, so the sidebar keeps
its space.
It writes to the vault, and that is exactly one narrow path. POST /dsh-obsidian/note overwrites one existing note, and only when the path
resolves inside the vault, the target is a file this plugin already shows as a
note, and its current size fits the read cap — so a truncated view can never be
saved back over the whole file. Nothing else in this package writes into the
vault; the conversation Session id is DSH's own bookkeeping and lives under
DSH_HOME.
What it contributes
| Surface | Kind | Where |
|---|---|---|
| Panel body | Client slot | sidebar.right.pane.tab, keyed dsh-obsidian/notes |
| Conversation | Shell panel | opened with uiWorkspace.openSession(vault Session) — 对话 in the tree panel and on the note page |
| Conversation seat occupant | Client slot | dsh-obsidian/panel.conversation, and dsh-obsidian/note.conversation on the note page |
| Note page body | Client slot | sidebar.right.pane.tab, keyed dsh-obsidian/note — also declares its own conversation child |
| Note outline | Client UI | the note page's 大纲 panel, anchored to the dsh-obsidian-outline-* ids renderMarkdown puts on headings |
| Launcher row | Client slot | sidebar.footer.action (id dsh-obsidian) |
| Tab types | Client service | sidebarRightTabs.register({ id, kind, patterns, canOpen, title }) |
GET /dsh-obsidian/status | Host route | what the plugin resolved (app, vault, config) |
POST /dsh-obsidian/open | Host route | open/focus Obsidian, optional { "file": "笔记/x.md" } |
GET /dsh-obsidian/tree | Host route | one directory level, ?path= ('' = vault root) |
GET /dsh-obsidian/note | Host route | ?path= → capped text + stat (+ root) |
GET /dsh-obsidian/search | Host route | ?q=&limit= → matching lines |
GET /dsh-obsidian/resolve-link | Host route | ?name= → matching note paths |
GET/POST /dsh-obsidian/chat | Host route | the remembered conversation Session id |
GET/POST /dsh-obsidian/vault | Host route | the folder the reader agreed to |
GET/POST /dsh-obsidian/diag | Host route | the client half's own account of its registrations |
obsidian_open | Agent tool | open/focus Obsidian, optionally on one note |
Every route answers GET and POST; any other method is a 405, not a silent GET.
There is deliberately no sidebar.panellist or main registration: this plugin
used to add a second entry labelled 知识库 to the left sidebar, and the footer row
is the only way in.
Three identifiers have to agree, and each fails differently: the tab type's
title is the chip text, its kind is what openTab names, and its id is the
key the body registers under in sidebar.right.pane.tab.
What this deliberately does not do
It does not embed the Obsidian window. Obsidian is a native Electron app with no
addressable web surface, so no iframe, `` or browser tab can host it —
the shell's own browser tab loads URLs, and Obsidian is not a URL.
The asymmetry is worth stating because the reverse direction does work: the
dsh-harness Obsidian plugin embeds this app inside Obsidian. That succeeds only
because DSH is a web page. The mirror image has no equivalent.
So this plugin brings the vault's content into the right Sidebar, and leaves
the real Obsidian one click away through the obsidian_open route/tool.
The vault conversation
A real agent conversation is opened in the centre as an ordinary Session whose Workspace is the vault — not a tab of the shell's chat, and not on the "start a new Session" screen. It is one Session that is created on first use and reused afterwards, so a plan survives a reload and can be refined across days.
It has to belong to the vault's Workspace, not merely to its directory. This
is the part that is easy to get wrong, and getting it wrong is visible: a Session
created with only a cwd belongs to no Workspace, so the conversation opens on
the blank new-Session screen with a workspace picker, and the whole thing reads as
"just another new conversation". resolveVaultChatSession therefore asks
ctx.workspaces.create({ path }) first — documented as idempotently resolving
an existing path, so the vault's Workspace is never registered twice — and passes
the resulting workspaceId (plus cwd) to ctx.sessions.create.
Nothing about the conversation UI is reimplemented, and no seat is occupied. The
app already has one good place for a conversation and one panel that renders it: the
centre, whose main key conversation hosts the shipped Conversation. 对话 therefore
does not mount anything of ours — it selects the vault's Session through
uiWorkspace.openSession(sessionId) and the shell draws it:
navigator.openSession(sessionId) // ctx.get('uiWorkspace')
That is also why this plugin declares no slot children: an earlier version declared a
conversation seat on two of its own surfaces (and, before that, tried to occupy
sidebar.chat.conversation, which ui-subagent owns — a second declaration throws inside
apply and the shell rolls back every registration the plugin made). Declaring nothing
costs nothing and cannot collide.
Handoff is about files, not about the AI's plan. A note is "dropped into" a
conversation by inserting its @path mention at the caret —
inputActions.captureInsertion() + inputActions.insertText(' @… ', span), the
exact mechanism a drag-and-drop would produce. The mention is ABSOLUTE
(@D:/知识库/笔记/foo.md) because the receiving conversation's Workspace is not the
vault: a relative mention would resolve against the wrong root. The controls live
next to the selected note in the tree and in the note page's header.
The note page
A note opens in its own tab, not inline: full-height rendered Markdown with its
[[wikilink]]s clickable. Each link resolves against the vault via
/dsh-obsidian/resolve-link and opens the target as its own page — following a
link never loses the page you were reading. The tree stays put in the 知识库
panel; the note does not replace it.
The typography is Obsidian's, transcribed. The numbers in READ are the
default theme's own reading-view values — 16px at line-height 1.5, the heading
ladder (1.618em/1.462em/1.318em/1.188em/1.076em/1em) with its weights
(700 for h1, 600 for the rest) and line heights, 1rem between blocks, 2.5rem
above a heading that follows another block, 2.25em of list indent, a 2px
accent rule and 24px of padding on a quote, and an unadorned code surface.
render-check pins several of them, because drift here is exactly what makes the
page stop looking like Obsidian.
A GFM table renders as a table. Before that it rendered as a paragraph of pipes, which is most of what a note with a table looked like.
The outline. 大纲 in the note's header opens a floating list of its headings
down the right edge of the pane: clicking one scrolls the reading area to it, and
the row for the heading you are currently inside is the one marked. Depth is
indentation, so a ### under a ## reads as nested.
It is deliberately a view over the rendered page, not a second parse of the note.
renderMarkdown writes each heading's anchor (id) and its depth (data-outline)
as DOM attributes while it renders, and the panel reads them back with
querySelectorAll — one parse, so the list can never describe a page other than the
one on screen. That includes the capped case: a note longer than MAX_RENDER_LINES
renders only its head, and the outline lists exactly the headings that exist, with
nothing trailing off the end pointing at a heading that was never drawn. Anchors are
numbered per page, so two note tabs open at once cannot collide.
The panel closes with its own control, with the ×, and on entering edit mode — the
rendered page is gone, so there is nothing left to outline. mount-check drives
open → pick → jump → close against a note whose heading ladder is one/two/three/two,
and asserts the anchors, the indentation and the scroll target.
Editing. 编辑 swaps the rendered view for a plain-text editor over the same
file; 保存 posts the whole text to /dsh-obsidian/note. A note whose read was
truncated is refused editing outright — the partial view is not a safe base for
overwriting the file.
Quoting a note into the conversation. One control, because there is one place the
mention can land: 引用到对话 inserts the note's absolute @path at the caret of the
conversation the centre is s