Skip to main content
W

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-title

README

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 emitsEnglish中文covers
    dsh-coreDSH coreDSH 本体the host itself: versions, packaging, dependency pinning, docs
    dsh-pluginDSH pluginDSH 插件plugin build, packaging, publishing, triage
    dsh-gatewayDSH gatewayDSH 网关channel wiring: Web GUI, Telegram/Messenger gateway
    dsh-uiDSH UIDSH 界面interface, sidebar, theme, appearance
    dsh-memoryDSH memoryDSH 记忆memory and knowledge: mnemon, documents, archives
    dsh-skillDSH skillDSH 技能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 emitsnamecreated by
    telegramTelegrama telegram / messenger connector or entry
    ankiAnkian anki connector
    roamRoama roam connector
    zoteroZoteroa zotero connector
    ledger记账a ledger / 记账 entry
    network网络proxy / clash / vpn / mihomo / sing-box
    githubGitHuba github / git-panel / gitlab entry
    obsidianObsidianan obsidian entry
    notionNotiona notion connector or entry
    dockerDockerdocker / compose / container

    Beyond 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 local dsh_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 writes Obsidian on 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 网关修复 becomes Telegram | 修复 | 网关修复. 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 emitsEnglish中文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 (refactor says what optimize means precisely, docs what write means, release what deliver means, config what admin means 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, or domain-conflict.

    When nothing in the scheme fits, the model is told to drop the field and answer object | summary — there is no other. 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 (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).

SettingMeaning
CadenceEvery prompt re-titles on each eligible human message; First prompt only stops after this plugin has titled the session once.
Title languageAuto follows the messages, or force 中文 / English.
Provider / ModelLeave both blank to follow the session model. Fill both to pin one explicitly; filling only one is rejected.
Title promptReplaces the built-in instruction. The output contract is always appended, so the answer stays parseable. Blank restores the built-in.
Learning modeHow 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}}.

ActionBodyNotes
(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") 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

Related plugins