- Home
- Plugins
- Sessions & Messages
- dsh-auto-title
dsh-auto-title
whatcannotbesaid/dsh-auto-title
DSH plugin: model-generated session titles that follow the latest task, remember manual renames, persist their settings, and never fail silently.
Install
dsh plugin --profile web add github:whatcannotbesaid/dsh-auto-titleREADME
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", ordelegationDepth > 0get子会话 | <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→ themodelSelection/model/requestContextprojection →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-coreDSH core DSH 本体 the host itself: versions, packaging, dependency pinning, docs dsh-pluginDSH plugin DSH 插件 plugin build, packaging, publishing, triage dsh-gatewayDSH gateway DSH 网关 channel wiring: Web GUI, Telegram/Messenger gateway dsh-uiDSH UI DSH 界面 interface, sidebar, theme, appearance dsh-memoryDSH memory DSH 记忆 memory and knowledge: mnemon, documents, archives dsh-skillDSH 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 telegramTelegram a telegram/messengerconnector or entryankiAnki an ankiconnectorroamRoam a roamconnectorzoteroZotero a zoteroconnectorledger记账 a ledger/记账entrynetwork网络 proxy/clash/vpn/mihomo/sing-boxgithubGitHub a github/git-panel/gitlabentryobsidianObsidian an obsidianentrynotionNotion a notionconnector or entrydockerDocker docker/compose/containerBeyond those two tables a domain has exactly two ways in: environment detection (the table above, which creates a domain only on local evidence) and the settings page, where you write one yourself. The real vocabulary grows with how this machine is used: when the model writes a topic word nothing covers (say 小红书), the plugin files it as a candidate, and only after it appears in two different sessions does it become a domain — from the next title on it is in the prompt and its printed word is used. When the model spells an existing domain some other way (writing 自动标题插件 for
DSH plugin), that spelling is kept as an alias of the domain instead of a new domain, and aliases take part in the summary de-duplication too. The caps are both yours to set, with no ceiling: 24 active domains and 8 spellings per domain by default, any number at all from the settings page, and 0 means "offer no domain" or "learn no spelling"; candidate words must look like names (about 3–6 characters or one word — bare numbers and punctuated phrases are rejected). A spelling two domains both claim is listed on the page as a conflict, because containment matching then drops it for both of them; a domain the limit keeps out of the prompt is named there too, and a word that has earned a domain of its own says so when the limit stops it. The learning mode can be turned off in the settings page, and the whole vocabulary is just a piece of settings in the localdsh_auto_title.json— no telemetry, nothing leaves the machine. The vocabulary can also be edited by hand: every domain can be renamed, re-described and given or stripped of spellings, and a new one can be written on the settings page (a name the plugin ships cannot be changed — only spellings can be added to it). A rename never changes the key the domain is stored under, so aliases, conflicts and matching behave as before; the new name is refused outright when another domain already answers to it or when the word overlaps another domain's. A tool the probe never found simply does not exist here: when the model writesObsidianon a machine with no obsidian domain, that word belongs to nobody — the umbrella domain can take it as a spelling, or you can write an Obsidian domain of your own.When no domain fits, the model is told to drop the field and answer
type | summary; an object outside the list is discarded instead of printed, so the plugin never invents a domain. A summary that repeats the object word loses the echo:Telegram | 修复 | Telegram 网关修复becomesTelegram | 修复 | 网关修复. The date is no longer part of the title — the session list already shows it, and it used to move to a new day whenever a session was continued.The second field is the action the session is taking. It also comes from a closed list, but the list is a type scheme you choose instead of one fixed vocabulary. Two schemes ship, and the settings page has a Type scheme card to pick between them and to write your own.
The default is the 22-word scheme — one action per row, with the test that separates it from its neighbours. The order is the order the model is told to prefer:
model emits English 中文 the action is fixfix 修复 it used to work and now it does not testtest 检验 check whether it is true, usable, or up to standard reviewreview 评审 look over something finished and reach a verdict cleanclean 清理 take away what is not wanted makemake 制作 turn material, parts, or code into a finished thing writewrite 撰写 the finished thing is written text deliverdeliver 交付 it is done, and the job is to hand it to whoever receives it teachteach 教学 make a particular person or model able to do it learnlearn 学习 come to know something I did not: practice, drills, exam prep researchresearch 调研 go and find information, samples, or options outside analyzeanalyze 分析 work out what data already in hand means communicatecommunicate 沟通 get a message to a particular person discussdiscuss 讨论 open exchange, nothing settled yet decidedecide 决策 pick one of the options that exist planplan 规划 work out how, when, and with what optimizeoptimize 优化 it works; make it faster, cheaper, steadier, or shorter maintainmaintain 维护 keep something that works working organizeorganize 整理 rearrange so things can be found again storestore 存档 put it away for later convertconvert 转换 same content, another form, language, or unit monitormonitor 观察 watch it change, so as to know in time adminadmin 事务 paperwork: forms, bookings, payments, expenses The other built-in scheme is the original 10-word one, kept because it is what earlier versions wrote:
feature功能,fix修复,optimize优化,refactor重构,test测试,docs文档,release发布,config配置,explore探索,discuss讨论. It is the software-engineering half of the 22 words with the engineering-only ones kept (refactorsays whatoptimizemeans precisely,docswhatwritemeans,releasewhatdelivermeans,configwhatadminmeans on a machine), so it stays useful for a programming-only setup.The choice is one id in
dsh_auto_title.json(typeScheme), not a copy of the words: switching schemes writes one field, and a plain Save of any other setting never touches it. An empty, unknown, or deleted id falls back to the 22-word default. The switch takes effect on the next title — the prompt is built from the scheme in force at that moment, no restart — and titles already written are text in the session log and are never rewritten. The parser accepts a word from either built-in scheme whatever is in force, so switching schemes never turns an old title into a broken one.A scheme of your own takes 2–48 words. Each word is one token (no spaces: a multi-word entry could never match), carries a key and a 中文 label, and may carry the one-line criterion the settings page shows you and quotes to the model. Words must not repeat each other, and a word may not be a domain word or one of a domain's spellings — the two fields are matched from opposite ends, so a word claimed by both would stop working in both. A name is 1–48 characters, may not repeat another scheme's name or a built-in's, and up to 12 schemes can be stored. Editing a scheme keeps whichever one is in use selected; deleting the one in use falls back to the default. The settings page refuses with the reason:
invalid-name,reserved-name,duplicate-name,word-count,invalid-word,duplicate-word, ordomain-conflict.When nothing in the scheme fits, the model is told to drop the field and answer
object | summary— there is noother. A model answer that names no type is still used verbatim, so a custom prompt is never blocked by the vocabulary; a word from no scheme in force at all is dropped rather than glued into the summary. -
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(profilesdesktop,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
manualdomain 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. |
vocabulary-add | {expectedRevision, label, scope, spellings} | Creates a domain: the key is minted by the host (a page cannot know which are taken), the record is stored as source: "user", and a blank scope takes the default line for that name. The refusals above, plus domain-limit when the domain limit is reached. |
vocabulary-clear-candidates | — | Empties the candidate list. |
vocabulary-reset | — | Resets the whole vocabulary: everything grown is dropped, the six built-ins stay. |
type-scheme-select | {expectedRevision, scheme} | Switches the type scheme by id; only a built-in or a stored id is accepted → unknown-scheme. Writes typeScheme and nothing else. |
type-scheme-save | {expectedRevision, scheme} | Creates ({name, words}) or edits ({id, name, words}) a scheme and returns the snapshot. A new one becomes the scheme in use; an edit keeps whichever one was in use. Refusals: invalid-name, reserved-name, duplicate-name, word-count, invalid-word, duplicate-word, domain-conflict, scheme-limit, unknown-scheme. |
type-scheme-delete | {expectedRevision, id} | Deletes a scheme you wrote. A built-in → reserved-scheme; deleting the one in use falls back to the default. |
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") andlocale/zh.json("自动会话标题") declaremeta.title/meta.description, andexportsmaps./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.jsonis 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 leaveslocale/, throws and costs you the whole title; and a file that is present on disk but missing fromexportsdoes 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
Related plugins
dsh-web-ui (dsh-chat-recovery)
zhu1090093659/dsh-web-ui
billion-context
ranxianglei/billion-context
dsh-synapse
liangmianya/dsh-synapse
dsh-chat-import
nwflower/dsh-chat-import