dsh-session-search
Manifest validA DeepSeek Harness plugin: global search across session content, ordered by content time.
dsh-session-search
English | 中文
A DeepSeek Harness plugin that searches the content of every session — not just session titles — and orders hits by the time of the matched record, newest first.
It adds a first-class search destination to the sidebar (a panel icon beside Plugins and
Schedules, plus a full-width results page) and a Ctrl/Cmd+Shift+F shortcut. Clicking a hit
opens its session.
┌─ sidebar ─┐ ┌─ main ─────────────────────────────────────────────────────┐
│ Plugins │ │ Search session content │
│ ▸ Search │ │ [ 会话 ] │
│ Schedules│ │ All · My prompts · Replies · Tool output ☐ subagents │
│ │ │ 43 hits · 8 sessions · 4 ms │
│ │ │ ────────────────────────────────────────────────────────── │
│ │ │ 2026-09-11 01:55 user 修改 FreeCAD MCP 服务端口 │
│ │ │ 我在使用这个 mcp 服务[插件],但是端口被占用了… │
└───────────┘ └────────────────────────────────────────────────────────────┘
Why this plugin exists
Stock DSH can already search sessions — the sidebar search box is wired to a real content-search pipeline:
WorkspaceBrowser → sessions.search(query) → ctx.remote.session.search
→ SessionController.search → ctx.sessionQuery.searchSessions(…)
It only ever finds titles because three independent things are true at once:
| # | Fact | Where |
|---|---|---|
| 1 | The content backend is switched off in the stock profile — openAt: never. The shipped comment says it plainly: "the Web sidebar search matches titles and workspace names only." | the session-query-sqlite row in dsh-base/cordis.patch.yml |
| 2 | That backend tokenizes with FTS5 unicode61, which treats an entire CJK run as a single token. Measured on a real machine: 光速太慢,星际文明无法成为整体 yields exactly two tokens, 光速太慢 and 星际文明无法成为整体 — so MATCH '光速' and MATCH '星际' both return nothing. The trigram tokenizer is no help either: it rejects 2-character queries. | verified locally against SQLite 3.53.4 |
| 3 | Even with the backend enabled, the wire shape is { items: [{ sessionId, snippet }] } — each hit's time is dropped at that layer, and ranking is relevance-first (time is only a tie-breaker). | dsh-api-session-controller/lib/index.js |
So "order results by content time" is not achievable through any shipped public remote, and
for CJK content the shipped engine is the wrong tool regardless. This plugin therefore builds its
own index on the host: it incrementally parses every session transcript, keeps each record's text
together with that record's own time, matches by substring (correct for both CJK and Latin
text), and sorts by time. It needs no profile configuration changes.
Where the search entry lives — and why
DSH's Web/desktop UI has no global top bar and no global command palette (/ commands are
the composer's slash menu and are bound to the current session). Only three global seats are
available:
| Seat | Verdict |
|---|---|
sidebar.panellist icon + its matching main panel | ✅ Used here. This is DSH's first-class "global panel" mechanism: the sidebar owns the button, the label and the selected state, and the list id maps directly to the main panel key. The shipped Plugins (order 0) and Schedules (order 10) panels work exactly this way; this plugin slots in between at order 5. replaceRisk: none — it cannot be clobbered by an upgrade. |
sidebar.footer.action (next to Settings) + a shell.overlay dialog | ⭕ Viable alternative, better suited to a "spotlight" quick-search. Costs you hand-built overlay, focus and Escape handling, and leaves little room for snippets. |
sidebar.workspaces (where the existing search box lives) | ❌ Not used. It is a single seat occupied wholesale by ui-workspace, with replaceRisk: shadows-shipped-ui — taking it means replacing the entire shipped session browser and breaking on the next DSH release. |
A Ctrl/Cmd+Shift+F shortcut (registered through dsh-client-shortcuts) selects the panel from
anywhere. The combination is not on the reserved list, and web:linux is deliberately left
unbound: the Web allowance there admits only a few two-modifier combos, and a shortcut definition
is validated across all six profiles atomically.
Install
This package is a standard DSH bundle (dsh.bundle.patch + dsh.client in its manifest).
Install it with the plugin manager:
plugin_manager action=install_bundle target="<absolute path to this repo>"
That writes the package into the profile's dependencies and dsh.profile.bundles and links it
into the profile's node_modules. You do not need to hand-edit the profile's package.json or
cordis.patch.yml.
Disable or remove it again:
plugin_manager action=set_bundle target=dsh-session-search enabled=false
plugin_manager action=remove_bundle target=dsh-session-search
Mounting it manually instead
Add a row to the profile's own cordis.patch.yml (the name may be a bare package specifier, a
path relative to the patch file, or an absolute path):
- insert:
- id: session-search
name: 'dsh-session-search'
A note on reloading. The Harness loads plugin rows through Node's ESM loader, which caches both the resolved package entry and the module itself for the lifetime of the Host process. Re-enabling a row that previously failed re-runs the cached module, so a fix on disk is not observed until the app restarts —
dsh-hmrcan activate a new row, but it cannot swap a JavaScript module generation for an already-loaded package.lib/entry.jsexists to work around this during development: it re-importslib/host.jsbehind an mtime-keyed query, so host-side edits take effect on the next activation. In normal use it is simply the entry point.
Usage
- Click the magnifier icon in the sidebar (between Plugins and Schedules), or press
Ctrl/Cmd+Shift+F. - Type a query. Matching is substring, case-insensitive, and works for CJK (a 2-character query matches inside a longer run, which the shipped FTS5 engine cannot do).
- Narrow the results with the chips — All / My prompts / Replies / Tool output — and the Include subagent sessions toggle (off by default; subagent sessions routinely outnumber your own).
- Click any hit to open its session.
Queries are debounced by 220 ms and each superseded query is aborted. Esc clears the box.
Configuration
The plugin exports no Config schema on purpose: bare specifiers such as
@deepseek-ai/schemastery do not resolve from a plugin that lives outside the profile tree, and
Cordis passes a row's config: through untouched when a plugin has no Config
(if (!runtime.Config) return config in cordis/lib/index.js). So every key is optional:
- id: session-search
name: 'dsh-session-search'
config:
root: 'C:/Users/me/.dsh/sessions' # sessions root; defaults to <DSH_HOME>/sessions
maxHits: 300 # cap on returned hits
maxCharsPerRecord: 65536 # per-record text ceiling, guards against one huge tool result
includeSubagents: false # exclude subagent sessions by default
warmup: true # build the index in the background after startup
warmupDelayMs: 3000
A patch row replaces the whole config of its target id, so write every value you want.
How it works
package.json bundle manifest: dsh.bundle.patch + dsh.client
cordis.patch.yml bundle patch: the insert row
icon.svg icon for the Plugins page
locale/{en,zh}.json title/description for the Plugins page
lib/entry.js loader entry: exports name/inject, loads the implementation
lib/host.js host half: index lifecycle, HTTP channel, sessionContentSearch service
lib/core/session-index.js pure Node index and query engine (no DSH dependency)
lib/client.js browser half: panel icon + search page + shortcut
test/host.test.mjs host integration tests
test/client.test.mjs browser-half tests
Transcript format
Each session.v<N>.jsonl.zstd is a sequence of independent Zstandard frames, not one stream.
Node's createZstdDecompress / zstdDecompressSync decode only the first frame, so the naive
reader silently gets just the header line. This plugin scans for the zstd magic number and decodes
frame by frame, and records a resume offset for incremental passes: when a file grows it decodes
only the new frames, and when it shrinks (compaction rewrites it) it starts over.
Every frame decompresses to JSONL lines:
{"type":"session","version":4,"id":"…","createdAt":1792130873584,"cwd":"D:\\Code\\demo","origin":"subagent"}
{"type":"user/message","seq":9,"time":1792130873619,"data":{"content":[{"type":"text","text":"…"}]}}
{"type":"assistant/message","seq":16,"time":1792130875479,"data":{"message":{"content":[{"type":"text","text":"…"},{"type":"tool-call","name":"read","arguments":"…"}]}}}
{"type":"tool/result","seq":17,"time":…,"data":{"message":{"content":[{"type":"tool-result","content":[{"type":"text","text":"…"}]}]}}}
{"type":"session/title","seq":14,"time":…,"data":{"title":"…"}}
Only user/message, assistant/message, tool/call, tool/result and session/title are
indexed. system/message (system prompts) and reasoning-chunks (chain of thought) are excluded by
design — otherwise they match nearly everything.
Measured on a real home directory: 178 sessions / 22k records / 21.4 MB of searchable text, a cold index build of ~1.2 s, then 3–26 ms per query (9 ms for an incremental refresh).
Host ↔ browser transport
The browser half calls ctx.connection.rpc.call('/session-search', 'search', {…}, signal).
The host registers a prefix route on that path, speaks the standard Connection RPC envelope
({type:'client-request', rpcId, method, payload} → {type:'server-response', rpcId, result:{ok,value}}),
and reuses the Connection service's admit() admission fence, so unauthenticated callers get a 401.
⚠️ Upstream defect.
ctx.connection.rpc.handle()looks like the intended API for this, but it cannot be used from a plugin at all: itsget rpc()façade callsregister(owner, …)withowner = this.ctx— the Connection plugin's own context — which injects onlycredentialsand therefore throwscannot get property "webServer" without injecton every call. Precisely because no shipped plugin uses it, the defect is not visible from the outside. This plugin registers the route on its own context (which does injectwebServer) instead. Seedsh-client-connection/lib/index.js, theregister()andget rpc()methods.
The host also does ctx.provide('sessionContentSearch', …), exposing the same index so other plugins
(or a human debugging a profile) can reuse it.
Verification status
npm test→ 18/18 passing.- Host (12): CJK substring hits against the real corpus, time-descending order, subagent filtering, and a full HTTP route → RPC envelope → query → payload round trip, plus 405 / 404 / 400 / mismatched-envelope failure paths.
- Browser half (6): the bundle is loaded the way the page loads it (
window.__ModuleLoader__+ factory + a stubbedrequire), asserting that the module id equals the package name, that it requires nothing butreact, that the icon and panel register under matchingid/key, that the shortcut definition is valid across all six profiles, and rendering the panel to an element tree for the empty, result and highlight cases.
- Install result:
application: "applied",warnings: []. - Host:
POST /session-search/searchreturns 401 (the route exists and is behind the admission fence), while an unknown channel returns 405. - Client: live slot inspection shows
session-searchregistered and active in bothsidebar.panellist(order 5) andmain(same key).
Not verified: the author had no browser control in the environment where this was built, so
there was no visual verification — clicking the icon, typing a query and seeing rendered results
was not performed. Per cordis-plugin-development/references/verification.md, claims stop at
"syntax + manifest + live slot registration + components render". Please confirm the look yourself.
Known limitations
- Opening a hit takes you to the session, not to the matching record. DSH exposes no public
"scroll to seq" API; the machinery (
ChatNavigation.navigateToTurn,input.loadThrough(seq)) is private to the chat panel. - The index lives in memory for the lifetime of the Host process; it is not persisted to disk (a cold build is ~1.2 s, normally invisible).
- Matching is substring, not tokenized and not semantic. Multiple whitespace-separated terms are ANDed.
- Text beyond
maxCharsPerRecord(default 64 KiB) in a single record is truncated. - The official SQLite FTS backend stays disabled. This plugin deliberately does not enable it: even enabled, it cannot match CJK substrings (see fact #2 above). If you would rather have the official backend with relevance ranking, that is a separate configuration change and not something a plugin can do.
Development
Plain ESM, no build step — lib/*.js is the shipped artefact. Node ≥ 22.15 (needs node:zlib
Zstandard support).
npm test # node --test test/host.test.mjs test/client.test.mjs
The host tests run against the real sessions in <DSH_HOME>/sessions; they skip cleanly when that
directory does not exist.
License
MIT © Victue
Comments
Loading…
Similar plugins
by RailgunHamster
A DeepSeek Harness plugin for selecting and routing web search providers.
★ 0
JavaScript
Sep 29, 2026
dsh plugin --profile web add dsh-web-searchby Li-0803
Full-text search for DeepSeek Harness sessions with precise message navigation.
★ 0
MIT
JavaScript
Oct 6, 2026
dsh plugin --profile web add dsh-session-searchby temidayoxyz
DeepSeek Harness (dsh) plugin: browse http(s) pages in the right sidebar, beside the conversation.
★ 0
MIT
TypeScript
Sep 30, 2026
dsh plugin --profile web add dsh-deep-browserby u9521
Custom Web Search plugin for DeepSeek Harness (DSH), replacing web-search-deepseek with multi-engine sequential fallback.
★ 0
MIT
TypeScript
Sep 29, 2026
dsh plugin --profile web add @local/dsh-cust-searchby 2275803244-cpu
In-session full-text search for the DeepSeek Harness web GUI: kind filters, multi-term AND matching, keyboard navigation, copy-to-clipboard, and jump-to-message.
★ 0
MIT
JavaScript
Oct 1, 2026
dsh plugin --profile web add dsh-client-ui-session-searchby n8guru
DSH hook plugin: a session may not stop the harness it runs inside
★ 0
MIT
JavaScript
Sep 2, 2026
dsh plugin --profile web add dsh-self-preserve