Skip to content

dsh-auto-title

Verified

dsh-auto-title · v1.7.0 · MIT · Web UI

DSH plugin: model-generated session titles that follow the latest task, remember manual renames, persist their settings, and never fail silently.

Install

dsh plugin add dsh-auto-title

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

dsh-auto-title

English | 中文

给 DeepSeek Harness 用的、真正能跑起来的模型自动会话标题插件:跟随最新任务、尊重手动改名、并且会把自己的失败报出来。

关于语言:本文件是中文说明,英文版见 README.md。插件自身的文案跟随软件语言:设置页分区、页面消息、以及「设置 → 插件」里显示的名字,都来自向宿主 locale 服务注册的 { zh, en } 两套字典。下文引用界面文案时按它实际出现的语言引用,旁边给出另一种语言。

DSH 自带的自动标题只看首条人工消息(截前约五个词)。dsh-auto-title 换成从最近几条人工消息生成标题,会话在聊什么,标题就是什么;你手动改过的标题永不被覆盖;设置存在 profile 里;而且——不像一个「静默失败」的 provider——生成不出来时,它会把原始错误文本摆到设置页上给你看。


为什么需要它

DSH 自带 @deepseek-ai/dsh-session-title-first-prompt-llm(loader 条目 session-title-llm),它有三个容易踩空的地方:

  • 标题 provider 是单槽位。再注册一个会直接被拒:session-title provider "<id>" is already registered。
  • provider 需要一条模型路由。如果它只从会话里读路由,而会话还没记过 request header,路由就是空的。
  • provider 抛错是静默的。 宿主只写一行 session "<id>": automatic title generation failed: <error> 日志,兜底标题照旧显示——界面看上去有标题,其实那只是你第一句话的前五个词。

本插件照着宿主源码写成,专门堵这三个洞:主动接管槽位;模型路由按固定回落链解析,全空时报错会逐条列出试过哪些环节;最近一次失败的原文留在你能看到的地方。

它会做什么

  • 跟随最新任务。 每条符合条件的人工消息都会用最近的消息重算标题。

  • 绝不覆盖你的标题。 会话标题一旦带上 source.kind === "user",本插件立刻停手。

  • 子会话只命名,不生成标题。 带 parentSession、origin === "subagent"、或 delegationDepth > 0 的会话一律拿到 子会话 | <父会话标题>(英文语言设置下是 sub-session | <父标题>),完全不调用模型:它那条"人工"消息其实是某个父代理写的提示词,硬生成只会为每次子代理运行花一次调用换来一句没信息量的标题;而直接跳过,列表里留下的就是宿主把那段提示词截成 40 来字节的兜底标题。父会话读不到、或父会话当前的标题本身就是宿主兜底的首句(那是被截到 40 来字节的散文,不是标题)时,只留标记本身;标签和宿主当前显示的一样就不再重复写事件。这类计入"跳过",不算失败。

  • 解析模型而不是瞎猜。 顺序:插件设置 → 宿主给的路由 → 会话 request/header → modelSelection/model/requestContext 投影 → agentDefaultModel.currentSelection()。全空时,报错里会逐个点名试过的环节。

  • 标题外形固定可读:对象 | 类型 | 摘要。第一格是这条会话属于哪个域,同一个域永远用同一个词,同一批会话在列表里能对齐。

    六个域每个安装都有,就是这个宿主和它自己的子系统:

    模型输出 English 中文 覆盖范围
    dsh-core DSH core DSH 本体 宿主本身:版本、打包、依赖钉版、文档
    dsh-plugin DSH plugin DSH 插件 插件的开发、打包、发布与排障
    dsh-gateway DSH gateway DSH 网关 渠道接入:Web GUI、Telegram/Messenger 通道
    dsh-ui DSH UI DSH 界面 界面、侧栏、主题、外观
    dsh-memory DSH memory DSH 记忆 记忆与知识库:mnemon、文档、归档
    dsh-skill DSH skill DSH 技能 技能、工具、提示词

    这六个就是随插件发布的全部词表。除此之外,插件只认一份环境探测表:它记着每种工具的名字、说明和探测线索,但探测表里的域一个都不存在——只有本机真的出现证据,插件才会把那个域建成一条记录(名字、说明,以及插件为它准备的几个写法一起进来),从此它和别的域一样能改名、能加写法、能删掉。线索来自你装的条目、你连的 MCP 连接器、你最近工作过的目录名,插件启动时读一遍(只在本机读):

    模型输出 中文名 本机出现什么才创建
    telegram Telegram telegram / messenger 连接器或条目
    anki Anki anki 连接器
    roam Roam roam 连接器
    zotero Zotero zotero 连接器
    ledger 记账 ledger / 记账 条目
    network 网络 proxy / clash / vpn / mihomo / sing-box
    github GitHub github / git-panel / gitlab 条目
    obsidian Obsidian obsidian 条目
    notion Notion notion 连接器或条目
    docker Docker docker / compose / container

    除这两张表之外,域还有两条来路:环境探测(上面那张表,本机有证据才建域)和你在设置页上手动新建。真正的词表按你本机的用法生长:模型在对话里写了一个词表里没有的主题词(比如「小红书」),插件先把它记成候选;等它在两个不同会话里独立出现之后才建域,从下一次起它进提示词、标题里也用印出来的那个词。模型把已有域写成别的说法(比如把 DSH 插件 写成「自动标题插件」)时,这个说法作为别名归到那个域上,不必新建域;别名同样参与摘要去重。两个上限都由你自己定、都不封顶:默认 24 个域、每域 8 个别名,设置页里可以填任意数字,填 0 就是「不列出任何域」或「不再记新写法」(约 3–6 个汉字或单词的候选词才收,纯数字、带标点的短语不收)。同一个写法被两个域同时认领时,设置页会把它列成冲突——因为包含匹配会因此对这两个域都失效;被上限挡在提示词外的域也会在那里列出来,而一个词够格建域却被上限挡住时,候选行会说明原因。学习模式可以在设置页上关掉;整份词表就是本机 dsh_auto_title.json 里的一段设置——没有遥测,没有任何东西离开这台机器。这份词表也能手动改:每个域都能在设置页上改名字与说明、加删本机写法,也能新建一个域(插件自带的名字改不了,只能给它加写法)。改名不会换掉域在文件里的 key,所以别名、冲突与匹配规则都照旧;改出来的名字不能是别的域已经认的词,也不能跟别的域的词重叠,否则当场拒绝并说明原因。没有探测到的工具就等于不存在:模型写 Obsidian 时,本机没有 obsidian 域,那个词就是没人认领的词——可以被「笔记软件」收作写法,也可以由你自己新建一个 Obsidian 域。

    词表里没有合适的域时,模型被要求整段省略、只答 类型 | 摘要;模型自己编的对象词会被丢弃而不是印出来——插件永远不会凭空造一个域。摘要若以对象词开头重复一遍,会把重复的部分剪掉:Telegram | 修复 | Telegram 网关修复 变成 Telegram | 修复 | 网关修复。日期不再是标题的一部分——列表本来就显示时间,而且续聊会让它跳到新的一天。

    第二格是这条会话在做的动作。它也出自一份封闭的词表,但这份词表是一套由你选的类型方案。内置两套,设置页上的类型方案卡片用来在它们之间切换,也可以自己写一套。

    默认是 22 词方案——一行一个动作,配一句把它和邻近动作分开的判据;下面的顺序就是模型被要求优先采用的顺序:

    模型输出 English 中文 在做的动作是
    fix fix 修复 原本正常,现在坏了,把它弄回正常
    test test 检验 验证真伪、可用、达标:抽检、试做、模拟考
    review review 评审 对已有成品或一段过程复核并给出结论
    clean clean 清理 移除不需要或多余的:大扫除、清缓存、清仓、退订
    make make 制作 把原料、零件、素材、代码变成成品
    write write 撰写 成品是以文字为主的:报告、文档、文章、论文
    deliver deliver 交付 东西已做好,动作是交给接收方:发货、上线、交作业
    teach teach 教学 让具体的人或模型学会:带徒弟、讲题、培训
    learn learn 学习 掌握自己原本不会的知识或技能:练习、刷题、备考
    research research 调研 到外部找信息、样本、候选选项
    analyze analyze 分析 判断手里的数据说明了什么:统计、核算、归因
    communicate communicate 沟通 把信息传达给具体对象:通知班组、联系物业、问老师
    discuss discuss 讨论 开放交流,还没有定论
    decide decide 决策 在现成的选项里选定一个
    plan plan 规划 排出怎么做、何时做、要多少资源
    optimize optimize 优化 能用,但想更快、更省、更稳、更短
    maintain maintain 维护 让一直正常的东西继续正常:保养、巡检、备份
    organize organize 整理 重排让东西更好找:清点、梳理、收纳
    store store 存档 收起来留着以后用:入库、备份、存钱
    convert convert 转换 内容不变,只换形式、语言或单位:翻译、换算
    monitor monitor 观察 持续盯着看变化,目的是及时知道
    admin admin 事务 跑流程办手续:填表、预约、缴费、报销、报工

    另一套内置方案是原来的 10 词方案,留着是因为早先的版本写的就是这些词:feature 功能、fix 修复、optimize 优化、refactor 重构、test 测试、docs 文档、release 发布、config 配置、explore 探索、discuss 讨论。它等于 22 词里的软件工程那一半、但保留了只对工程精确的那几个词(refactor 说的就是 optimize 的精确版,docs 之于 write、release 之于 deliver、config 之于机器上的 admin),所以只写程序的环境仍然好用。

    选择只存成 dsh_auto_title.json 里的一个 id(typeScheme),不是词表的副本:切换方案只写一个字段,普通保存任何别的设置都不会碰它。id 为空、不认识、或指向一套已删掉的方案时,一律回落到 22 词默认。切换从下一次生成标题起生效——提示词按当时生效的那套方案现拼,不用重启;已经写好的标题是会话日志里的文本,永远不回写。解析器对两套内置方案的词都认,不管当前生效的是哪一套,所以换方案不会把旧标题弄坏。

    自建方案 2–48 个词。每个词必须是单个 token(不能有空格,多词永远匹配不上),带一个 key 和一个中文标签,还可以带一句判据——设置页会显示它,也会把它写进给模型的提示词。词与词不能重复;一个词也不能是某个域的域词或它的任一写法——两格是从两端匹配的,被两边同时认领的词会让两边都失效。方案名 1–48 个字符,不能与别的方案名或内置名重复,最多存 12 套。编辑一套方案时,正在用的那套保持在用;删掉正在用的那套则回落到默认。设置页会带着原因拒绝:invalid-name、reserved-name、duplicate-name、word-count、invalid-word、duplicate-word、domain-conflict。

    方案里没有任何一个词合适时,模型被要求整段省略、只答 对象 | 摘要——没有 other 这个兜底词。模型没写类型词时,摘要原样使用——自定义提示词不会因为词表而被卡住;一个两套方案里都没有的词会被丢掉,而不是被拼进摘要。

  • 设置落盘在 profile 自己的存储域里(dsh_auto_title.json),带 revision 比较并交换,陈旧页面不会静默覆盖新设置。

  • 失败可见。 最近一次失败的原文、时间戳、会话 id 出现在设置页和插件自己的 RPC 上,并且可以一键清除。

  • 可手动重算。 任意单个会话都能强制重算,结果或错误原文直接返回。

运行要求

  • DSH 0.2.0-rc.2(desktop / web / headless / tui profile)。
  • Node.js ≥ 22(用 DSH 自带的那份即可)。

本插件 import 的 @deepseek-ai/*(dsh-llm、dsh-session-title、dsh-storage-domain、dsh-timeout、dsh-util-values)全部由宿主在运行时注入:它们不在 npm 上、版本由宿主掌握,所以不写进 dependencies;但它们写进了 peerDependencies,因为宿主正是拿这份名单来决定「软链进来的插件可以借用哪些宿主自带副本」。版本范围刻意用 *:宿主会用 semver 校验 @deepseek-ai/dsh-* 这类 peer,范围不满足就直接跳过整个 bundle,第三方插件写死运行时版本会在下次 DSH 升级时被禁用。

安装

把包加进 profile,并用 DSH 自带的 pnpm 安装:

$profile = "$env:USERPROFILE\.dsh\profiles\desktop"
Set-Location $profile
# 先在 package.json 的 dependencies 里加 "dsh-auto-title",
# 再把它追加到 dsh.profile.bundles,然后:
& "$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

从源码目录装(link:)。 profile 可以直接指向工作树而不是 tarball。这时有两件事必须成立,且都落在链接真实指向的那个目录上:插件自己的依赖(zod)必须能从那里解析到——在该目录跑 pnpm install,或给它一个 node_modules;以及上面那批宿主包必须在 peerDependencies 里,因为宿主只把「插件自己的 package.json 点过名」的那些包交给软链插件使用。

然后重启 DSH。本插件自带的 patch 层会禁掉宿主自带的 provider;因为这个 patch 属于本 bundle,卸载或停用本 bundle 时它随之消失,宿主自带的 provider 会自动恢复——不改宿主任何文件,也不会留下「槽位没人管」的状态。

重启前可以先核对组合树:

dsh --profile <name> --dump-config

应当看到本 bundle 插入了自己的一行:

# == dsh-auto-title
- id: auto-title
  name: dsh-auto-title

以及宿主自带的 provider 被关掉:

# == @deepseek-ai/dsh-base, patched by dsh-auto-title
- id: session-title-llm
  name: '@deepseek-ai/dsh-session-title-first-prompt-llm'
  disabled: true

设置页

打开 设置 → 自动标题(或 设置 → 插件 里本插件那一页)。

设置 含义
生成时机 「每条提示词」=每条符合条件的人工消息都重算;「仅首条提示词」=本插件给该会话生成过一次后就停。
标题语言 「跟随消息」自动判断,也可强制 中文 / English。
Provider / Model 两个都留空=跟随当前会话模型;两个都填=显式钉死;只填一个会被拒绝。
标题提示词 替换内置指令。输出格式约定始终附加,所以结果一定可解析。留空即恢复内置。
学习模式 词表怎么生长:「自动」=本机证据与对话里长出来的词直接启用;「需确认」=只进候选列表,等你在下面点;「关闭」=完全不学(已学到的保留)。

同一页还会列出最近会话及其标题来源(手动 / 自动生成 / 宿主兜底)、每行一个重新生成按钮,以及上次失败的原始错误文本与时间戳;最下面的「域词表」区就是词表的操作台:

  • 已启用的域,每行带来源徽章——内置 / 环境探测 / 会话生长 / 手动——以及它接受的写法;可删的行(当前域之外的)右边有删除按钮;每行还有一个「编辑」按钮,打开这一行的编辑器:域名(标题第一格印的就是它)、说明(每次生成都会送给模型的那一行),以及本机为它记下的写法(加一个、删一个都行,打字后直接保存也会算进去)。保存会把这一行整套写回;被拒时编辑器不关,你输入的字留在框里并附上原因。名字由插件决定的域(内置六域)在编辑器里名字是只读的,只能改写法;重名、撞到别的域已有的写法、以及会跟别的域的词重叠的词都会被拒绝。这一节的标题旁边还有「新建域」:填名字、说明(留空自动写一行)和写法,就得到一个来源为「手动」的域,和探测到的域一样能改能删;域数到上限时这个按钮会禁用并说明原因;
  • 待处理的候选,每个词给「并入「某域」」/「新建域」/「暂不显示」(只在本地隐藏,不动数据);
  • 环境探测面板:读过的连接器、条目与工作目录数量、已启用项、失败原因,以及「启用探测到的域」;
  • 底部是上限与重置词表(会问一次再执行)。

再下面「类型方案」区是第二格的操作台:

  • 一个选择器列出两套内置方案和你写的每一套(带词数),并显示正在用的那套的名字与来源(内置 / 自己写的);
  • 正在用的那套的词与判据,以及一行「标题里会显示成」的示例;
  • 自建方案的新建(复制当前)、编辑、删除——方案按 词 | 中文 | 判据 一行一个编辑;保存被拒时编辑器不关,你的输入留在框里并附上原因(… is already taken by …);
  • 一句说明:切换只影响下一次生成,已写好的标题不会变。

RPC

设置页通过 GET/POST /api/dsh-auto-title 与宿主半通信。响应统一是 {ok:true,value} 或 {ok:false,error:{code,message}}。

action 请求体 说明
(无,GET) — 快照:设置、provider 状态、存储状态、最近会话、上次失败/成功。
save {expectedRevision, settings} 对 revision 做比较并交换;陈旧 → revision-conflict,非法 → invalid-settings。
reset-settings {expectedRevision} 恢复默认值,同样走比较并交换。
clear-status — 清空上次失败与成功。
regenerate {sessionId} 强制重算并返回新标题或原始错误;manual-title / skipped 是故意拒绝。子会话也可以重算——那会用父会话当前的标题重新命名它。
vocabulary-seed — 启用这次环境探测到的域;返回新快照。
vocabulary-promote {key, mode, targetKey} 候选词的出口:mode:"alias" 并入 targetKey(必须是已知域),mode:"domain" 建成新域。超限 → alias-limit / domain-limit,未知候选 → unknown-candidate。
vocabulary-forget {key} 关掉一个域(连同它的别名)。内置的六个不在词表记录里,删不掉 → unknown-domain。
vocabulary-edit {expectedRevision, key, label, scope, spellings} 改一个域:名字、说明,以及本机为它记下的全部写法(spellings 是整套替换,空数组=忘掉全部)。内置域的名字与说明改不了 → fixed-domain;其余拒绝码:unknown-domain、invalid-label、invalid-scope、invalid-alias、alias-limit、label-conflict(名字被别的域占用,或与别的域的词重叠)、alias-conflict。
vocabulary-add {expectedRevision, label, scope, spellings} 新建一个域:key 由宿主铸造(页面猜不到哪些已占用),记录写成 source: "user",scope 留空则取该名字的默认说明。拒绝码同上,另有 domain-limit(域数已达上限)。
vocabulary-clear-candidates — 清空候选列表。
vocabulary-reset — 重置整份词表:去掉一切生长出来的东西,只留内置六域。
type-scheme-select {expectedRevision, scheme} 按 id 切换类型方案;只接受内置或已存的 id,否则 unknown-scheme。只写 typeScheme 这一个字段。
type-scheme-save {expectedRevision, scheme} 新建({name, words})或编辑({id, name, words})一套方案并返回新快照。新建的那套立刻成为正在用的;编辑则保持原来在用的那套。拒绝码:invalid-name、reserved-name、duplicate-name、word-count、invalid-word、duplicate-word、domain-conflict、scheme-limit、unknown-scheme。
type-scheme-delete {expectedRevision, id} 删掉一套自建方案。内置的 → reserved-scheme;删掉正在用的那套则回落到默认。

失败为什么不会静默

在 DSH 里,provider 抛错是看不见的——这是设计如此。所以本插件把「上次生成失败」当成产品状态而不是日志行:

  • 每一个非跳过错误都以 {at, message, sessionId} 记下并落盘,重启后仍能读到原文;
  • 设置页原样渲染这段原文,不做转述;
  • 有意跳过不含在内——子会话、手动改名不是失败,永远不会污染失败显示;
  • 抢不到槽位时,原因与回滚结果同样记录在案,而不是写条日志就算了。

语言与显示名称

  • 设置页分区、页面文案、以及全部 RPC 消息都跟随软件语言:客户端半 inject = ['slots', 'locale'],用自己的命名空间向官方 locale 服务注册 { zh, en } 两套字典(ctx.locale.register + ctx.locale.bind),切换语言时按需重画。
  • 「设置 → 插件」里显示的名字同样跟随软件语言。 locale/en.json(英文根,Auto Session Title)与 locale/zh.json(自动会话标题)声明 meta.title / meta.description,exports 用 ./locale/* 原样映射,目录里每个语言文件都能按本包解析。
  • 宿主读取器有三条硬规则,加语言前值得知道:locale/en.json 是锚点——没有它,同目录其它文件一个都不会被读;文件名去掉 .json 之后不是纯语言 id、或解析出的路径跑出 locale/,都会抛错并连带丢掉整个标题;文件在磁盘上但没写进 exports 也是同样下场——所以这里用通配映射,而不是每个语言写一条。

开发

node --test                        # 51 个单元测试(覆盖 lib/title-format.js)+ 1 个宿主冒烟测试
node <bundled-node> --check lib/index.js

lib/title-format.js 是纯函数、无副作用,所以提示词、解析器、拼装逻辑都能脱离宿主直接测。

宿主半由 test/host-smoke.test.js 覆盖:把 lib/index.js 真的启起来(它从宿主导入的五个包用 test-support/stubs 里的桩替身,由 test-support/hooks.mjs 解析),再用假请求驱动真实的 RPC handler——于是端点注册、设置文件、revision 守卫、三个类型方案动作、页面会看到的那些拒绝码、重启后重新加载,以及模型真正拿到的契约与最终拼出的标题,全部是对宿主会跑的那份代码做的断言(14 项检查,1 个测试)。它需要 zod(本插件唯一的真实依赖):没有就会自我跳过而不是失败,也可以用 DSH_AUTO_TITLE_ZOD=<zod 入口路径> 指给它。

许可

MIT © WhatCannotBeSaid