# dsh-auto-title

[English](README.md) | 中文

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

> **关于语言**：本文件是中文说明，英文版见 [README.md](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 安装：

```powershell
$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 插入了自己的一行：

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

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

```yaml
# == @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` 也是同样下场——所以这里用通配映射，而不是每个语言写一条。

## 开发

```powershell
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
