dsh-auto-title
English | 中文
Model-generated session titles for DeepSeek Harness that actually run, follow your latest task, and report their own failures.
Note on language. This file is the English documentation; the Chinese original is README.zh.md. The plugin's own copy follows the software language: the settings section, its messages and the name shown in Settings → Plugins are registered with the host locale service as a { zh, en } dictionary. Interface strings below are quoted in the language they appear in, with the equivalent in the other language beside them.
dsh-auto-title replaces DSH's built-in automatic session title with one that titles a session from its most recent human messages instead of only the first one, remembers when you rename a session by hand, keeps its settings in the profile, and — unlike a silently-failing provider — shows you the raw error text in its settings page when generation does not work.
Why this exists
DSH ships @deepseek-ai/dsh-session-title-first-prompt-llm, which derives a title from the first human message only, truncated to about five words. It is configured through the loader entry session-title-llm and it is easy to end up with a provider that never produces anything:
- the Host registers exactly one title provider and rejects a second registration with
session-title provider "<id>" is already registered;
- a provider needs a model route, and if it only reads the route from the conversation, a session that never logged a request header leaves it empty;
- a provider that throws is silent. The Host logs
session "<id>": automatic title generation failed: <error> to its own log and keeps the fallback title, so the UI still shows a plausible-looking title that is really just the first five words of your first message.
This plugin was written against the Host source to close all three holes: it takes the slot over deliberately, it resolves the model through a documented fallback chain and explains every link it tried when that chain comes up empty, and it keeps the last raw failure where you can read it.
What it does
-
Follows the latest task. Every eligible human message re-runs the title from the recent messages, so the title tracks what the session is about now.
-
Never overwrites your own title. The moment a session carries a title with source.kind === "user", this plugin stops touching it.
-
Labels sub-sessions instead of titling them. Sessions with parentSession, origin === "subagent", or delegationDepth > 0 get 子会话 | <parent title> (sub-session | <parent title> under the English locale) and no model call at all: their first human message is the prompt some parent agent wrote, so a generated title would cost one call per subagent run to say almost nothing, while skipping them leaves the Host's own fallback — that prompt cut to ~40 bytes — sitting in the session list. A sub-session whose parent cannot be read — or whose parent currently carries the Host's own fallback, prose cut to roughly forty bytes rather than a title — gets the bare marker, and a label the Host already shows is skipped instead of appended again. These are counted as skips, never reported as failures.
-
Resolves the model instead of guessing. Settings → the request route the Host supplies → the session's request/header → the modelSelection/model/requestContext projection → agentDefaultModel.currentSelection(). If every link is empty, the error names each one it tried.
-
Keeps one readable shape. object | type | summary. The first field is the domain the session belongs to, so the same kind of work always gets the same word and one domain lines up in the session list.
Six domains ship with every installation — the host and its own subsystems:
| model emits | English | 中文 | covers |
|---|
dsh-core | DSH core | DSH 本体 | the host itself: versions, packaging, dependency pinning, docs |
dsh-plugin | DSH plugin | DSH 插件 | plugin build, packaging, publishing, triage |
dsh-gateway | DSH gateway | DSH 网关 | channel wiring: Web GUI, Telegram/Messenger gateway |
dsh-ui | DSH UI | DSH 界面 | interface, sidebar, theme, appearance |
dsh-memory | DSH memory | DSH 记忆 | memory and knowledge: mnemon, documents, archives |
dsh-skill | DSH skill | DSH 技能 | skills, tools, prompts |
Those six are the whole shipped vocabulary. Beyond them the plugin only holds an environment table: it records each tool's name, its guidance line and the needles to look for, but no domain in that table exists — only when this machine really shows the evidence does the plugin create the record (name, guidance line, and the spellings it knows for that tool, all at once), and from then on it is an ordinary domain: renameable, spellable, removable. The needles come from an entry you installed, an MCP connector you have, a directory you recently worked in; the plugin reads those once at startup, locally:
| model emits | name | created by |
|---|
telegram | Telegram | a telegram / messenger connector or entry |
anki | Anki | an anki connector |
roam | Roam | a connector |
-
Persists its settings in the profile's own storage domain (dsh_auto_title.json), with compare-and-swap on a revision so a stale settings page cannot silently clobber a newer one.
-
Shows failures. The last failure's raw text, timestamp, and session id appear in the settings page and through the plugin's own RPC, with a button to clear them.
-
Can regenerate on demand for any single session, from the settings page or over RPC.
Requirements
- DSH
0.2.0-rc.2 (profiles desktop, web, headless, tui).
- Node.js ≥ 22, as bundled with DSH.
The @deepseek-ai/* packages this plugin imports (dsh-llm, dsh-session-title, dsh-storage-domain, dsh-timeout, dsh-util-values) are injected by the Host at runtime. They are not on npm and the Host owns their versions, so they are not dependencies — but they are listed in peerDependencies, because that is the list the Host consults for a plugin installed with link:. The ranges are * on purpose: the Host checks @deepseek-ai/dsh-* peers against its runtime version with semver and skips a bundle whose range it does not satisfy, so pinning the runtime version in a third-party plugin would disable it on the next DSH update.
Install
Add the package to the profile and install it with DSH's own pnpm:
$profile = "$env:USERPROFILE\.dsh\profiles\desktop"
Set-Location $profile
# add "dsh-auto-title" to dependencies and to dsh.profile.bundles in package.json, then:
& "$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\primary-runtime\dependencies\node\bin\node.exe" `
"$env:LOCALAPPDATA\Programs\DeepSeek Harness\resources\runtime\pnpm\bin\pnpm.mjs" install --trust-lockfile
Installing from a checkout (link:). The profile can point straight at a source tree instead of a tarball.
Two things then have to hold, both about the real directory the link resolves to: the plugin's own
dependency (zod) must be resolvable from it — run pnpm install inside the plugin, or place a node_modules
there — and the Host packages listed above must be in peerDependencies, because a linked plugin is only handed
the Host's copies of the packages its own manifest names.
Restart DSH. The plugin's own patch layer disables the built-in provider; because that patch lives inside this bundle, uninstalling or disabling the bundle restores the built-in provider automatically — no host file is edited, and there is no "slot owned by nobody" state to clean up.
Verify the composed tree before restarting:
dsh --profile <name> --dump-config
The entry added by this bundle should read:
# == dsh-auto-title
- id: auto-title
name: dsh-auto-title
and the Host's provider should be off:
# == @deepseek-ai/dsh-base, patched by dsh-auto-title
- id: session-title-llm
name: '@deepseek-ai/dsh-session-title-first-prompt-llm'
disabled: true
Settings
Open Settings → Auto title (or the plugin's page under Settings → Plugins).
| Setting | Meaning |
|---|
| Cadence | Every prompt re-titles on each eligible human message; First prompt only stops after this plugin has titled the session once. |
| Title language | Auto follows the messages, or force 中文 / English. |
| Provider / Model | Leave both blank to follow the session model. Fill both to pin one explicitly; filling only one is rejected. |
| Title prompt | Replaces the built-in instruction. The output contract is always appended, so the answer stays parseable. Blank restores the built-in. |
| Learning mode | How the vocabulary grows. Auto turns on what the local evidence and the conversation produce; Confirm only files candidates until you act on them; Off learns nothing (what was already learned stays). |
The same page lists recent sessions with their title source (manual / generated / fallback), a per-session Regenerate button, and the last failure with its raw error text and timestamp. Below that, the Domain vocabulary card is the vocabulary's console:
- Active domains, each row carrying a source badge — built-in / environment / learned / manual — plus the spellings it accepts; rows outside the built-in set have a delete button, and every row has an Edit button that opens it: the domain name (what the title's first field prints), the guidance line sent to the model with every title, and the spellings this machine keeps for it, one to add and one to drop at a time — a word still in the add box is saved with the row. Saving writes the whole row back; a save that is refused keeps your text in the box with the reason. A domain the plugin names (the six built-ins) shows its name read-only — only its spellings can change — and a name another domain answers to, a spelling it already owns, or a word overlapping another domain's are all refused. Next to the section heading, New domain opens the same form empty: a name, a guidance line (blank writes a one-line default) and spellings give you a
manual domain that can be edited and removed like any other. The button is disabled at the domain limit, and says why;
- Pending candidates, each offering merge-into-a-domain, create-a-domain, or hide (hidden locally only — nothing is deleted);
- the environment probe panel: the connectors it read, how many entries and working directories it scanned, what it switched on, any error, and Switch on the detected domains;
- the caps and Reset vocabulary at the foot (it asks once before running).
Below it, the Type scheme card is the second field's console:
- a picker listing both built-ins and every scheme you wrote, each with its word count, plus the name and
origin (
built-in / your own) of the one in force;
- the words of the scheme in force, with their criteria, and a one-line example of the title they produce;
- New (copy of the current), Edit and Delete for your own schemes — a scheme is edited as rows of
word | 中文 | criterion, one per line, and a save that is refused keeps your text in the box with the
reason (… is already taken by …) instead of closing the editor;
- a note that a switch applies to the next title, not to titles already written.
RPC
The settings page talks to the host half over GET/POST /api/dsh-auto-title. Every response is {ok:true,value} or {ok:false,error:{code,message}}.
| Action | Body | Notes |
|---|
| (none, GET) | — | Snapshot: settings, provider state, storage state, recent sessions, last failure/success. |
save | {expectedRevision, settings} | Compare-and-swap on revision; revision-conflict when stale, invalid-settings when rejected. |
reset-settings | {expectedRevision} | Restores the defaults; also compare-and-swap. |
clear-status | — | Clears the last failure and success. |
regenerate | {sessionId} | Forces a recompute and returns the new title, or the raw error. manual-title and skipped refuse on purpose. A sub-session can be regenerated too: that relabels it from the parent's current title. |
vocabulary-seed | — | Switches on the domains this environment probe detected; returns the new snapshot. |
vocabulary-promote | {key, mode, targetKey} | What a candidate can become: mode:"alias" merges it into targetKey (a known domain), mode:"domain" creates a domain. Over a cap → alias-limit / domain-limit; unknown candidate → unknown-candidate. |
vocabulary-forget | {key} | Switches a domain off, aliases included. The six built-ins are not vocabulary records, so they cannot be removed → unknown-domain. |
vocabulary-edit | {expectedRevision, key, label, scope, spellings} | Edits one domain: its name, its guidance line, and the whole list of spellings this machine keeps for it (spellings replaces the list; an empty array forgets them). A built-in's name and guidance line cannot change → fixed-domain. Other refusals: unknown-domain, invalid-label, invalid-scope, invalid-alias, alias-limit, label-conflict (another domain answers to that name, or the word overlaps another domain's), alias-conflict. |
Why a failure is never silent
A provider that throws is invisible in DSH by design. This plugin therefore treats "the last generation failed" as product state, not as a log line:
- every non-skip error is kept as
{at, message, sessionId} and persisted, so you can read the raw text after a restart;
- the settings page renders that raw text verbatim rather than paraphrasing it;
- deliberate skips are excluded — a sub-session or a manual rename is not a failure and never overwrites the failure display;
- if the provider slot cannot be claimed, the reason and the rollback outcome are recorded instead of being logged and forgotten.
Language and display name
- The Settings section, its copy, and every RPC message follow the software language: the client half is gated on
inject = ['slots', 'locale'] and registers its own namespace as a { zh, en } dictionary with the official locale service (ctx.locale.register + ctx.locale.bind) and repaints on a language switch.
- The name shown in Settings → Plugins follows the software language too.
locale/en.json (the English root, "Auto Session Title") and locale/zh.json ("自动会话标题") declare meta.title / meta.description, and exports maps ./locale/* straight through so every language file in that directory resolves through this package.
- Three rules the Host reader enforces, worth knowing before adding a language:
locale/en.json is the anchor — without it no sibling file is read at all; a file whose name (minus .json) is not a plain language id, or whose resolved path leaves locale/, throws and costs you the whole title; and a file that is present on disk but missing from exports does the same, which is why the mapping is a pattern rather than one entry per language.
Development
node --test # 51 unit tests over lib/title-format.js + one host smoke test
node <bundled-node> --check lib/index.js
lib/title-format.js is pure and side-effect free, which is what makes the prompt, the parser, and the composition testable without a Host.
The host half is covered by test/host-smoke.test.js. It boots the real lib/index.js with the five packages it imports from the Host stubbed out of test-support/stubs (resolved by test-support/hooks.mjs) and drives the real RPC handler with fake requests, so the endpoint registration, the settings file, the revision guard, all three type-scheme actions, the refusals the page shows, a restart, and the prompt and title the model is actually given are all checked against the code the Host runs — 14 checks, one test. It needs zod, the plugin's only real dependency: without it the test skips itself instead of failing, and DSH_AUTO_TITLE_ZOD=<path to a zod entry point> points the harness at one.
License
MIT © WhatCannotBeSaid