dsh-anima
DSH 的助手人格与外挂记忆层——一个独立插件,装上即可用。
它管的是「你自己(助手)是谁」:AI 的名字与角色、你对它的称呼、回复风格、 以及你自己写的行为准则;再加一套带待审缓冲区的长期记忆, 以及输入框光轨、输入框快捷开关、工具确认门槛这些界面能力。 字段集不依赖任何其它插件,也不从别处读取配置。
工具名为什么是
set_assistant_persona:tools.register()遇同层重名直接抛错 (dsh-tools/lib/index.js:2634),与生态里任何同名工具共存时必有一方注册失败、 被.catch吞成一行 warn —— 那是发布级的静默故障。功能相似不要求撞名。
它不只管人格与记忆。 本插件还有一层回答质检:每轮结束前由代码按十二条固定判据 检查回答的形状,判到问题就往下一条消息插一条提醒;配套的对账行要求凡出现数字、自查断言 或归属引用,就必须写明「查了什么 / 没查什么」。
这一层的存在理由是:「自检」如果只写在提示词里,它就变成一个新的表演面 —— 模型会输出「已检查,无异常」,而那句话本身没有成本。所以质检的判词由代码出, 且代码只能判形状(有没有来源、有没有对账块),判不了内容对不对。 详见下方「回答质检层」。
它解决什么
| 能力 | 说明 |
|---|---|
| AI 自己的名字与角色 | agentName / agentRole —— 决定它怎么称呼自己、以什么身份说话 |
| 用户的称呼 / 背景 / 语气 / 长度 / 语言 | addressUserAs / userOccupation / userBio / style / replyLength / language |
| 按工作区两层覆盖 | 只覆盖声明的字段,其余继承全局;覆盖文件随目录走、可 git |
| 长期记忆 | 自动沉淀 → 待审缓冲区 → 用户裁决 → 注入 |
| 输入框光轨 / 快捷开关 / 工具确认 | 界面与安全开关 |
| 人设预设 | 6 条内置 + 自建;也可把当前配置存成预设 |
| 十二个字段全是自由文本 | 存的就是注入的原话,另有「一键填入 + 示例」按钮组 |
| 记忆随工作区分层 | 全局记忆处处生效,工作区记忆只在匹配时注入 |
| 注入可解释 / 字符预算 | 每条为什么进 / 不进,条数与字符双约束 |
| 本地聚类与冲突提示 | 纯本地 bigram 相似度,只提示不自动合并 |
| 人设变更审计日志 | 谁在什么时候把哪个字段改成什么 |
| 一键导入外部 AI 的记忆 | 复制提示词 → 粘贴结果 → 机械切分 → 逐条裁决层级 |
| 记忆元数据 | 类型 / 生命周期 / 复核点 / 出处 —— 记了要能看见,否则等于没记 |
| 记忆体检 | 只验机器能验的:路径还在不在、复核点过没过期。产出候选,不替你裁 |
| 回答质检(十二条) | 每轮由代码(不是模型)按固定判据检查回答形状,抓到就往下一条消息插提醒 |
| 对账行(三档触发) | 出现数字 / 自查断言 / 归属引用时必须写「查了什么 · 没查什么」 |
| 长期记忆的「自动写入」 | ❌ 刻意不做(见下) |
| 「质检通过了」这类结论 | ❌ 刻意不支持(理由见「质检层」一节) |
关于与 某同类人设插件 的关系
上表左侧的能力划分与 某同类人设插件 0.7.0 的字段集有交集,实现上也刻意与它保持一致
(工具确认语义、光轨动效参数、重置保留预设)。但本插件不依赖它:
字段集完整自持,不读它的配置文件,两者可同时安装且标识全部错开。
这层关系的完整说明见下方「致谢与差异化声明」。
四个差异化设计
功能对齐不等于没有自己的判断。以下四点是本插件相对主流记忆插件的真实增量, 每一条都对应一个具体的失败模式。
1. 双约束注入:条数与字符同时生效
多数实现只有一个「最多注入 N 条」的上限。问题是 40 条长记忆一样能挤爆上下文, 而用户看到的是「我只设了 40 条啊」。
本插件的 selectForInjection 同时受 memory.maxInjected(条数,默认 40)与
memory.maxInjectedChars(字符,默认 4000)约束,取先到者。两条上限各有各的适用场景:
条数管「别让模型看太多碎片」,字符管「别让一次注入吃掉半屏上下文」。
2. 注入可解释:看得见每条为什么进 / 不进
注入不是黑箱。设置页的「注入情况」列出每一条已采纳记忆的去留与原因:
| 原因码 | 含义 |
|---|---|
included |
已注入提示词 |
other-workspace |
属于别的工作区,不在本会话注入 |
no-workspace-context |
当前会话没有工作区信息,无法匹配工作区记忆 |
workspace-scope-unknown |
标记为工作区记忆但没记录属于哪个工作区 |
over-max |
已采纳条数超过注入上限,按时间取最新的 |
over-budget |
单条就超出字符预算,取不下 |
dropped-by-budget |
前面几条已占满字符预算 |
no-injection |
记忆层已关闭或上限为 0 |
区分 over-budget 与 dropped-by-budget 是刻意的:前者要删那条,后者要腾位置 ——
报同一个码,用户不知道该动谁。原因文案由宿主下发(INJECTION_REASON_TEXT 只有一份),
客户端不重写翻译。
3. 本地聚类:只提示,不自动合并
待审缓冲区里若有多条明显在说同一件事,插件用字符二元组(bigram)的 Jaccard 系数 (纯本地、零依赖、零模型调用)算出相似度,把「这 3 条像在说同一件事」摆到用户面前。
刻意不做自动合并:合并意味着生成一段用户没说过的话。本插件的立场是 「判断什么值得记交给模型,什么真的生效交给用户」——自动合并恰好违背它。 聚类只让裁决更快,不替他裁决。同理「候选撞上已采纳记忆」只提示冲突,不改任何数据。
4. 人设变更审计日志
本插件有三个入口能改人设:设置页、/anima 命令、模型调用的 set_assistant_persona 工具。
而「工具确认」默认是关的 —— 也就是说,模型可以直接把你叫它什么、它用什么语气回话改掉,
而你无从知道是谁在什么时候干的。
审计日志只记「改了什么」,不做判断、不参与提示词。它挂在 StateStore.updateConfig 这一个
收口上,而不是在各调用点分别埋点:所有写入路径最终都走这里,分散埋点迟早会漏掉一条,
而漏掉的那条恰恰可能是模型的静默改写。resetConfig 也走审计 —— 否则
「昨天还好好的,今天全空了」在日志里查无此事。
记录上限 200 条,独立成表(不塞进 config):config 每次启动整条读入内存,
审计会涨到几百条,混在一起等于让启动开销随使用时长上升。
长值在写入时截断为 200 字并附「共 N 字」,数组/对象只记规模 —— 审计是索引,不是备份。
看到它的地方有两处:设置页顶部常驻的「变更记录」条,以及 /anima show 的
「最近变更」一行——只显示最新那条,格式是 [MM-DD HH:mm] <来源>:<字段名>,
刻意不带具体值:值可能很私人,而 show 的输出会被复制到对话里问人。
设置页那条常驻在分区导航下面,不管停在哪个分区都看得见。收起时给最近 3 条
(数据来自 /state 随包下发的尾部记录,不额外发请求),点「展开明细」才打
GET /api/anima/audit 取全量。它原来是一张放在「高级」栏里的折叠卡 ——
打开设置页看不到、要切栏、还要再点一次展开,三步之外才看得见,用户的原话是
「改的咋样我根本不知道改成啥了」。检查入口不该埋在只有知道它存在的人才找得到的地方。
字段名在界面上翻成人话(agentName → 「我的名字」、memory.maxInjected →
「最多带几条」),映射表在 client/index.mjs 的 AUDIT_FIELD_KEY;
查不到映射时回落显示原始键名——露出难看的真相,好过把一条改动藏起来。
致谢与差异化声明
本插件的功能集在设计时参考了同生态里已有的几款人设类插件(均为 MIT 许可), 并在若干处刻意与它们保持行为一致(存储惰性解析、 失败关闭的确认语义、重置保留用户预设、光轨动效参数)。
需要明确的是:
代码全部独立实现,未拷贝上述项目的源码、注释、提示词文案或图形资源。
两处动效参数刻意取同值(8 层错位描边、虚线段长 12 / 步进 2.2 / 圆角 22 等), 理由是避免同一台机器上出现两套观感。这类数值属于思想与参数,不受著作权保护; 记在这里是为了不让读者误以为拷贝。
导航图标刻意画得不同形:同生态的人设插件用「光轨弧 + 火花」,本插件用「人像(头+肩)+ 火花」, 单色
currentColor,viewBox 也另行收紧。各类标识全部错开,避免共存时互相顶替或注册失败:
类别 同类插件的取值 本插件 工具名 它们各自的同名工具 set_assistant_persona设置页 id 各自的页 id anima-settings光轨槽位 id 各自的槽位 id anima-trail开关槽位 id 各自的槽位 id anima-quick-toggle提示词 section 它注册的那条 context anima:identity/:memory/:pending存储域 —(自有文件) anima注入来源 kind 各自的前缀 plugin:dsh-anima
工具名错开不只是礼貌:dsh-tools 的 register() 遇同层重名直接抛错
(lib/index.js 的 NamedEntries 冲突工厂),两者共存时必有一方注册失败、
并被 .catch 吞成一行 warn —— 那是发布级的静默故障。
本节只写了「功能参考」层面的概括,不再点名具体项目 —— 发布包里不含任何第三方项目的名字。
docs/peers/ 下保存了对十余个同类插件的只读调研记录(机制、行号、踩坑原文),全部未复制代码;
那些项目的仓库地址、SPDX 许可与取数日期统一记在 docs/THIRD-PARTY-NOTES.md。
docs/ 不在 package.json 的 files 里,不进发布包,只随仓库存在。
核心设计:三段式记忆
这是本插件与市面上 15+ 记忆插件最不一样的地方。
对话中通过 remember 工具投递
↓
待审缓冲区(不生效)
↓ 每晚巡夜:去重 / 淘汰 / 与已采纳项比对 / 记忆体检
↓
只有出现「新事」才汇报给你(没变就不出声,但每次巡夜都留痕)
↓
用户在设置页逐个「采纳 / 丢弃 / 删除」
↓
已采纳 → 注入提示词
为什么不做静默自动写入:记忆一旦静默写入就直接影响后续所有回答,写错了用户很难发现。 本插件把「判断什么值得记」交给模型,把「什么真的生效」交给用户。
整理这一步纯本地、不调用模型——只做去重、淘汰、与已采纳项比对,不做语义归纳。
记忆分两层:global 处处生效,workspace 只在 cwd 归一化后完全相等时注入。
不做「子目录继承」——工作区覆盖是精确匹配语义,记忆若采用继承语义,
用户在子目录里会看到父目录的记忆,排查起来极难。
分层之外的注入解释、预算与聚类见上文「四个差异化设计」。
记忆元数据:记了要能看见
每条记忆除了正文,还带四个字段。它们不是分类学爱好,是因为缺了它们, 用户只能选择相信整份记忆库:
| 字段 | 取值 | 它决定什么 |
|---|---|---|
type |
fact / preference / stance / event |
决定时间衰减窗口(见下方「衰减只影响怎么显示」)。事实窗口 0 = 不因年龄衰减;立场在「我改主意了」时归档成「曾如此」 |
state |
current / superseded / contradicted / dropped |
注入行要不要带「曾如此」「有争议」「已删除(墓碑)」标签 |
verifyAt |
YYYY-MM-DD |
复核点。只有过期之后才在注入行标「可能已过时」——没到期就天天提一遍等于制造噪音 |
sourceRef |
消息号 / 文件:行号 / URL |
这条凭什么被信。用户原话@session-xxx#seq=11 与「助手转述」是两种可信度 |
四项全部在设置页可见(mem.typeFact 等徽章、复核点、出处)。老记忆没有这些字段时
什么都不画 —— 回落成「事实」会让用户以为那是数据,其实是单元格空着。
改它们的入口有三个,语义各不相同,刻意不合并:
/anima mark <id> type=… state=… verifyAt=… sourceRef=… supersededBy=…POST /api/anima/memory/meta(单条)、POST /api/anima/memory/meta/batch(≤500 条,逐条回执)- 设置页记忆卡
supersededBy=<id> 会自动把 state 置成 superseded —— 否则账记下了,注入行照旧按现行渲染。
verifyAt 拒收过去的日期:过去日期一进去就立刻显示「可能已过时」,
用户以为在设提醒,实际只是盖了个过期章。
记忆体检:只验机器能验的
/anima checkup(POST /api/anima/memory/audit)只做两件事:
- 记忆里写的 Windows 路径,现在还在不在(
fs.existsSync) verifyAt过没过期(纯日历,不问语义)
它不判断「新的那条有没有推翻旧的」。 那是语义判断;记忆库一旦自动做语义判断,
就会生成用户没说过的话。所以体检产出的是候选,每条附一句可直接粘贴的
/anima mark <id> state=contradicted,扫描可以自动,裁决必须由人点。
路径提取有个真实的坑:D:\Windows Kits 在空格处被正则截成 D:\Windows,会报出一条
「这个路径已经不存在」的假警报。修法是只有当延长后的路径真的存在于磁盘时才采信 ——
假警报比不检查更糟,它教会用户忽略整份报告。
回答质检层
为什么不是「让模型自检」
最省事的做法是往提示词里写一句「回答前请检查自己」。这条路已被证明无效: 自检一旦只存在于提示词里,它就变成新的表演面 —— 模型会写「已检查,无异常」, 而这句话没有任何成本。
所以这里的分工是:
| 谁 | 做什么 |
|---|---|
| 提示词 | 把十二条判据与对账规则摊开给模型看(anima:quality,order 30) |
| 代码 | 读已经落盘的回答文本,按固定正则判形状,判到就往下一条消息插提醒 |
| 人 | 判内容对不对 —— 这是代码永远做不到的部分 |
十二条判据
| 编号 | 判据 | 谁能判 |
|---|---|---|
| R01 | 未查先断(含伪引用「据…可知」) | 机器判形状,人判内容 |
| R02 | 谎报检查结果(「已通过」而没附命令与输出) | 机器判形状,人判内容 |
| R03 | 答非所问 / 偷换题目 | 只有人能判 |
| R04 | 套话填充 | 机器判密度,人判实质 |
| R05 | 重复踩坑 | 只有人能判(要有记忆库与结论日志才能比对) |
| R06 | 假装忘记 | 只有人能判(举证责任在用户) |
| R07 | 选择性呈现 | 只有人能判(只有你知道自己没听到什么) |
| R08 | 置信度错配 | 机器判形状,人判内容 |
| R09 | 假装有时间(「我会持续关注」) | 机器判,几乎没有误报 |
| R10 | 自相矛盾 | 只有人能判(需结论日志) |
| R11 | 稻草人(把用户没说过的话算到他头上) | 机器判形状,人判内容 |
| R12 | 越界自信 | 机器判形状,人判内容 |
R03 / R05 / R06 / R07 / R10 这五条机器永远判不了。 代码里把它们单列为
UNCOVERABLE_RULES,每次输出判词时都会明说「这五条没查」——
不许把「没查」说成「没问题」。
对账行
三档机械触发,命中就必须在正文末尾写:
【对账】
查了:<具体做了什么> → <命令 / 文件:行号 / 链接>
未查:<这次没做但读者可能以为做了的>
| 档 | 触发条件 |
|---|---|
| 档 1 | 具体数字 / 日期 / 比例 / 版本号(含「我觉得…大概…」后面跟了具体数字) |
| 档 2 | 「我查了 / 已确认 / 已验证 / 没问题 / 已通过」这类自查断言 |
| 档 3 | 归属引用:「据…可知 / 文档说 / 官方规定 / 研究显示 / 我记得你之前说过」 |
档 3 是最要紧的一档:数字错了你会去核,日期错了你会去核, 但「据某某研究可知」你基本不会去核 —— 这类断言一旦编,几乎不可能被发现。
未查 那一行不能省。一个只报告「查了什么」的系统会说谎;
一个必须报告「没查什么」的系统,说谎的成本高得多。
钩子能做到什么、做不到什么
做得到:在下一轮开始时发现上一轮违规,并往下一条消息插一条提醒。
实现是 ctx.on('agent/pre-step', …) + agent.session.snapshotEvents(),
监听器 prepend: true,兜底 try/catch 全包 —— 质检是增强,绝不能弄坏对话循环。
做不到:
- 本轮拦不住。回答已经发出去了才能看到它。
- 判不了内容对错。只判形状(有没有来源、有没有对账块),不判「这个数字对不对」。
- 不知道模型后来有没有照做。提醒插进了下一条消息,代码读不到模型是否采纳。
还有一条设计教训值得写下来:提醒额度(MAX_REMINDERS_PER_TURN,默认 2)
早期被错误地放在「取事件、跑判据」之前 —— 后果是额度挡掉的是检查而不是提醒,
那几条回答根本没被检查过,统计里只留下笼统的「额度用完 N 次」。
「没查」长得和「查过没事」一模一样,这正好是项目明令禁止的那件事。现在的顺序是
先查,再决定要不要说。
提醒到底有没有用:实测没有
这一点必须写在这里,否则上面那套设计会被误读成「提醒能纠正行为」。
用会话日志量过(只在「本来就触发了对账档」的回答里比较,因为其余回答本就不该写块):
| 分组 | 写了对账块 |
|---|---|
| 前面有质检提醒 | 10 / 15 = 67% |
| 前面没有提醒 | 14 / 24 = 58% |
差 8 个百分点,仍然落在噪声内——分母只有 15,差两条就能翻面。更直白的是: 写得最勤的那几条长回答(2000 字以上)前面都没有提醒。
这个数会随着会话变长而漂,所以别把某一版的具体百分比当成结论。要重新算就跑:
node scripts/remind-effect.mjs
脚本自带两条诚实边界:时间邻近不等于因果(它只能否掉「完全没用」,证不了「有用」), 以及「写了对账块」不等于「写对了」。
这个测量本身绕过三个坑才站住,每个坑都是我先踩了才发现的:
- 用原文跑触发判定 → 把反引号里的数字(
`实际注入提醒 0 条`)当成断言。 钩子调的是checkResponse,它先stripCodeBlocks再剥掉被引用的说法。 探针没走真实链路,读数就是假的。 - 用
checkResponse判出的 R02 当「本该有块」的条件 → 循环论证。 R02 的定义就是「该有块却没有块」,这个集合里永远算不出「有块」,结果必然是 0%。 条件必须只用输入侧的事实(触发是否命中),与输出侧(写没写块)无关。 - 同一回答被多条提醒重复计数 → 分母被吹大。要先按回答去重。
结论:钩子在正确工作(checks 在涨、链路通),但它没有改变写作行为。
真正让对账块出现的是用户明确要求,不是机器判词。
所以不要把「提醒」当成纠错机制。它的真实作用是留痕:让「这一条曾经违规」 在会话里有一条可查的记录,而不是指望它把下一条写对。想让模型写对,只能靠 提示词里的硬约束和用户当场指出。
运行计数:让「零提醒」不再二义
/api/anima/state 的 qualityStats 下发:
| 字段 | 含义 |
|---|---|
checks |
真正跑过判据的次数 |
reminders |
实际插进对话的提醒条数 |
suppressed |
判到问题但被每轮额度压住的条数 |
skipped |
没跑的原因计数(empty-text / already-checked / disabled …) |
last |
最近一次的 seq、字数、判词、未查项 |
没有它,「零提醒」有两种相反解读:(a) 判过没事 (b) 根本没跑。
设置页的质检卡片在 checks === 0 时会直接写「这一整套没在工作」,
不许显示得像一切正常。
巡夜:只报有事
每晚到点(memory.nightlyHour)跑一次,做三件事:整理缓冲区、跑一遍记忆体检、
然后决定要不要说话。手动也能跑:设置页的「跑一次巡夜」按钮、/anima patrol、
POST /api/anima/patrol,三条路走同一条判据——如果手动能绕过判据,你就永远
测不到定时那次的真实行为。
判据只有机器数得出来的三样(lib/patrol.mjs):
- 待审候选的集合变了(有新候选、或被裁决掉了一些)
- 体检
flagged的集合变了(路径失效 / 复核点过期) - 这次整理真的动了数据(去重或超限淘汰)
用「集合变没变」而不是「数量 > 0」:三条候选挂一周没人管,每天报一遍是纯噪音—— 数量没变,就没有新信息。两条旧的换成两条新的,数量没变但指纹变了,那是真有事。
沉默必须能反查
只报有事有一个必须当场堵住的洞:沉默同时代表两件事——「巡过了,没事」和
「巡夜根本没跑」。这正是质检钩子在解决的同一个问题(checks=0 有两种相反解读)。
堵法:汇报可以不发,巡夜记录必须留。 每次到点都写一条 meta.patrol,
/api/anima/state 的 patrolMeta 下发它:
| 字段 | 含义 |
|---|---|
at / day / reason |
什么时候跑的、是定时还是手动触发 |
signature |
这次局面的指纹(集合摘要) |
reported |
有没有真的汇报出去 |
wouldReport |
判据认为该不该报(与 reported 分开,才能看出通知被什么挡了) |
facts |
待审 / 体检 / 整理动过 三个数 |
flaggedItems |
这次体检点到的问题逐条明细(id / 一句话说明 / 证据 / 问题类型),没有问题时是 null |
flaggedDropped |
明细超过 20 条时被截掉的条数(不是 0 就必须在界面上说出来) |
reasons |
判定理由(pending:3 / flagged:1 / removed:2) |
flaggedItems 只留前 20 条,每条一段话,不含记忆原文全文 —— 明细是为了「知道是哪条、能当场处理」,
不是为了把体检结果再存一份。被截掉的条数写进 flaggedDropped,界面必须显示「另有 N 条未列出」:
静默只显示前 20 条,等于把 23 条问题谎报成 1 条。
设置页那一行有三种写法,且必须长得不一样:没有记录 = 「还没跑过,这不是没事,
是没跑」;reported: false = 「跑过了,判定为无事,没有汇报」;reported: true = 「跑过了,报过」。
把「没跑」画成「没事」,是这个项目一直在防的那一类错误。
汇报文案本身也带三条硬要求(patrolNoticeText):必须列出问题、必须写「这次没报的是什么」、
必须给出可自己复算的出口(GET /api/anima/memory/audit 或设置页的体检按钮)。
回扫式复盘:回头翻旧账
记忆层最糟的失败不是「记错了」,是**「当时压根没记」**——你三天前说过一句话, 今天它又犯同一个毛病,你只能再骂一遍。这件事没法靠「每轮盯着屏幕筛查」解决: 在这台机器 925 条真实发言、937 个轮次上实测过,最省事的那种常驻闸门(mnemon 的两级 漏斗)第一段四条件只命中 4 条(0.4%),第二段打分却让 44.6% 的轮次全要送审 —— 要么太严要么太宽,中间没有可用档位。
所以换个方向:不碰实时对话,回头翻旧账。
/anima retrospect(别名 /anima 复盘、POST /api/anima/retrospect、设置页的
「回扫一次」按钮)会扫最近 memory.retrospectDays 天(默认 14,可设 1–365)的会话日志,
按四类判据挑出「用户当时在纠正 / 抱怨 / 明说要记 / 明说禁令」的句子,交给你裁决。
打开这个开关之后,它还会跟着夜间定时与启动补跑各跑一次(与巡夜各判各自的开关和日期,
关掉巡夜不影响回扫):
| 类型 | 判据在找什么 | 本机 925 条语料实测 |
|---|---|---|
complaint |
「我说过 / 有没有说过 / 早说过」—— 你已经在骂了,说明某件事没被记住 | 2 条(0.2%) |
explicit |
「记住 / 记下来 / 别忘了」或英文 remember this | 1 条(0.1%) |
correction |
「不对 / 搞错 / 谁让你 / 我说的是」—— 排除「点错了」这类非纠正 | 6 条(0.6%) |
constraint |
「不许 / 禁止 / 不要…一律 / 所有 / 永远」—— 否定式长期约束 | 2 条(0.2%) |
最终在 593 条真实发言上产出 9 条线索,每条都带得出出处(哪个会话文件、第几个事件), 全量扫 53 个会话约 3 秒,最慢的单文件 711ms。
三条硬规矩:
- 只产线索,不写记忆库。 复盘不会往
memories表里写任何一个字—— 那是你没说过的话。要不要记、记成 fact 还是 stance,由你在待审区点。 - 不管有没有线索,都写一条
meta.retrospect。 与巡夜同一条纪律: 只报有事的话,「沉默」会同时代表「扫过了没线索」和「根本没扫」。/api/anima/state的retrospectMeta下发它;没扫成的轮次files是null而不是0—— 写成 0 就变成「扫了 0 个会话」,与「扫过没事」长得一模一样。 - 默认关闭。 它要读磁盘上的会话日志,是新开的一个口子,所以默认
false, 在设置 → 灵台里打开。关着时接口明确回「已关闭」,不写任何记录—— 「被你关掉」和「跑了没成」是两种事实,混进同一条记录会让人以后看到「上次回扫失败」以为坏了。
凭据闸门
任何命中凭据形态的内容在进入缓冲区之前就被拒收(不落盘、不留痕), 覆盖私钥块、OpenAI/AWS/GitHub 令牌、通用 api_key / secret / 密码赋值、Bearer、 URL 内嵌凭据等 11 类模式。
理由:内容一旦落盘缓冲区,就处于「等待进入提示词」的状态,凭据不该走这条路。 凭据判定用的是 11 类正则的机械匹配,没有语义理解,会有误判——所以被拦下时界面会 把命中规则名一起说出来,用户可以删掉那串字符再粘贴。
一键导入外部 AI 的记忆
设置页记忆卡里有一个「导入外部 AI 的记忆」按钮,展开后是两步:
- 复制提示词——把一段整理画像的提示词发给你用得最多的那个 AI,让它把你的偏好 整理成清单。按钮只在真的写进剪贴板后才显示「已复制」;没有剪贴板 API 时会明说 「复制失败,请手动选中后复制」,不假装成功。
- 粘贴结果——粘贴框接受 AI 的输出,也接受你自己手写的笔记(不要求必须是 AI 生成的)。
解析后逐条预览,每条都能改层级(全局 / 本工作区)或直接丢弃,确认后才写进待审缓冲区 ——和手动添加、模型投递走同一条路,仍然要用户逐条裁决才生效。
切分是机械的,不做语义理解:按 Markdown 标题、列表项、空行三种边界切;刻意不按句号切, 因为中文画像里「偏好:结论先行。」被切成两条会读不通。小节标签(如「我的使用画像」) 不进候选——它长得像记忆但没有可采纳内容。
归类是建议不是判决:suggestLayer 只看措辞——提到具体磁盘路径或「本项目 / 这个项目」
建议 workspace,提到「所有项目 / 一律 / 每次都要」建议 global。两条都没有时默认 global,
理由是把「记忆动不动就不生效」当默认体验,比跨项目污染更糟。每条建议都带一句理由显示在
条目下方,用户看到「本工作区」就能立刻知道凭什么。
解析阶段就分流:一条原文切完后按顺序过五道闸,没通过的会被分别归到不同的桶里, 每一桶都在界面上显示出来,不静默少一条:
| 处置 | 判定 | 界面上 |
|---|---|---|
过短(< 4 信息量) |
切出的碎片太短,通常是编号残留 | 只在汇总里报个数 |
超长(> 500 字) |
单条超过记忆上限 | 不截断,明确告知「请拆成几条」 |
| 凭据 | 命中 11 类正则之一 | 整条拦下并说明命中了哪条规则 |
| 重复 | 与已有记忆文本相同 | 单独列出 |
| 超条数 | 超出单次上限(默认 60 条) | 余下部分不进这一批,另开一次导入 |
| 同义 | 与待审缓冲区里的某条相似度 ≥ 0.6 | 在条目下方提示「已有一条很像」,不自动合并 |
原文上限 40000 字符,超出直接报错而不是截断(截断等于偷偷丢掉用户画像的后半段)。 同义提示刻意只提示不合并:自动合并会生成一段用户从没说过的话。
安装
从 npm 安装(推荐)
已发布到 npm,包名 dsh-anima,直接用包名装:
dsh plugin add dsh-anima@2.2.3
或者手工改 profile 的 package.json:
{
"dependencies": {
"dsh-anima": "2.2.3"
},
"dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
}
@2.2.3 可以换成别的已发布版本,或整个省略以装最新版。建议 pin 住一个版本,
否则上游发了新版,下次安装就会直接换掉。
然后 pnpm install,重启 DSH。宿主侧改动一律需要重启才生效。
从 GitHub 安装(跟进未发布改动)
想装还没发到 npm 的版本、或直接跟进 main,就从仓库装:
dsh plugin add github:Melody-cx/dsh-anima#v2.2.3
或者手工改 profile 的 package.json:
{
"dependencies": {
"dsh-anima": "github:Melody-cx/dsh-anima#v2.2.3"
},
"dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
}
#v2.2.3 是标签(也可以写 commit SHA,或整个省略以跟随 main)。建议 pin 住一个版本,
否则上游的任何改动都会在下次安装时直接进入你的 profile。git 源下载的是整个仓库 tarball
(本仓库很小),首次安装可能比 npm 源慢一些,超时的话重试一次即可。
然后 pnpm install,重启 DSH。宿主侧改动一律需要重启才生效。
从源码安装(开发用)
在 profile 的 package.json 里加依赖(也可以用 /plugins 界面):
{
"dependencies": {
"dsh-anima": "file:<仓库目录的绝对路径>"
},
"dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
}
本地 file: 依赖是复制语义:改完源码要重新安装才生效,且重跑安装时若 pnpm 判定
changed:false 会直接跳过 —— 正确姿势是先 remove_bundle 再 install_bundle
(或比对 node_modules 副本与源码的 SHA256)。
运行前提(package.json 里已声明,装不上时先看这几条):
engines.node=^22.19.0 || >=24.0.0,engines.dsh=>=0.1.6-0 <0.2.0 || >=0.2.0-0 <0.3.0-0peerDependencies四包:@deepseek-ai/cordis(^4.0.1 || ^4.0.5-alpha.1)、@deepseek-ai/dsh-llm、@deepseek-ai/dsh-storage-domain、@deepseek-ai/dsh-tools—— 它们由宿主提供,插件不打包它们dependencies.zod(^4.4.3)是唯一真正要装的运行时依赖- 存储栈需在 profile 里组合:
@deepseek-ai/dsh-storage+@deepseek-ai/dsh-storage-json(配root)@deepseek-ai/dsh-storage-domain(配backend: json)。缺了不会崩,但插件会退化成 「存储不可用」,设置页会明说原因(见下「存储」一节)
配置
设置页新增一栏「灵台」(settings.section id = anima-settings,order 51)。
设置页长什么样
一页装不下,所以分成四个分区,顶部一条常驻的导航标签:人设 / 记忆 / 表现 / 高级。
- 人设:一键预设、助手身份、关于你、回复风格(预设放最前,因为最先发生的动作通常是「套一套现成的,再微调」)
- 记忆:长期记忆(含等你确认的提议、已记住的清单、体检、导入、注入情况)
- 表现:回答质检、输入框光轨
- 高级:工作区覆盖、AI 实际看到的内容
导航下面还压着一条常驻的「变更记录」(不属于任何分区,切到哪一栏都在): 收起时给最近 3 条「哪个字段从什么改成了什么、谁改的、什么时候」, 点「展开明细」才取全量、才出现清空入口。这一条是用户原话逼出来的 —— 「改的咋样我根本不知道改成啥了」;检查入口不该埋进只有知道它存在的人才找得到的地方。
点一个标签只切换显示哪几张卡,其余卡片仍然渲染、只是 display:none。
这条是刻意的:真不渲染会把没保存的表单值和卡片的折叠状态一起丢掉,
用户切去看一眼记忆、再切回来,刚打的字就没了。
选中的分区记在 localStorage 的 dsh-anima:active-section(裸字符串,不是 JSON),
下次打开还在那一栏。值不是这五个之一时(改坏了、旧版本残留)一律退回第一人设栏 ——
不认识的字符串不该让整页空掉。
底部一条统一的保存栏,常驻、不随卡片滚动跑掉:
- 左半句说状态:「没有未保存的修改」/「有未保存的修改」(脏的时候变警告色)
- 右边两个按钮:「恢复默认」「保存」,没有未保存内容时保存是灰的
这一个入口是全页唯一的保存与恢复默认。此前保存按钮散在四张卡里、共六处, 其中四条调的还是同一个函数 —— 用户得先判断「我在哪张卡」才知道按哪个; 而分区之后,那张卡可能根本不在眼前。 工作区覆盖有自己的脏状态(它写的是工作区文件,不是全局配置), 但在用户眼里都是「我改了东西还没落盘」,所以同一根栏里一起报、一起存。
workspaceOverridesEnabled / workspaceFileDir 的「保存覆盖」「清除覆盖」按钮保留在卡片里 ——
那是工作区专用的另一组动作,文案不同,不算重复。
- 助手身份:
enabled/agentName/agentRole/addressUserAs/userOccupation/userBio - 回复风格:
style/headingLists/emoji/tables/replyLength/language/customInstructions - 工作区覆盖:
workspaceOverridesEnabled/workspaceFileDir覆盖文件默认写在<工作区>/.dsh-anima/override.json(目录名可用workspaceFileDir改),随目录走、可 git、可拷给别人; 该目录会自动写入.gitignore(内容*),避免私人语气设定被推到远程。 - 输入框光轨:
trailEnabled/trailColor(#RRGGBB,落盘统一大写)/trailSpeed(slow|normal|fast)/trailWidth(thin|normal|thick) - 工具确认:
requireToolConfirmation—— 开启后模型调set_assistant_persona只提交提议,不写配置 - 长期记忆:
memory.enabled/autoCapture/nightlyTriage/nightlyHour/maxPending/maxInjected/maxInjectedChars/notifyOnSessionStart/defaultLayer - 回答质检:
quality.enabled/quality.ledgerLevel(off|three|five)/quality.strict/quality.preStepCheck/quality.maxFindingsInReminder
quality.* 五个键全都能用 /anima set quality.ledgerLevel=five 改,也全都在设置页的「回答质检」卡片上。
加界面不是为了好看:这一层会主动往你的对话里插消息,用户必须有一个不走 JSON、不用重启的关闭开关。
出误报时的正确动作是关 strict(只多判 R08 / R11 / R12 三条),而不是关掉整层 ——
但误报多到你想关整层时,关掉是对的:一个喊狼来了的检查器比没有检查器更坏。
卡片折叠:默认态由「改」还是「查」决定
设置页里的每张卡右上角都有一个「收起 / 展开」按钮。默认态不是一概展开,也不是一概收起, 而是按这张卡是用来改东西还是用来查东西分的:
- 用来改的,默认展开:助手身份、关于你、回复风格、人设预设、输入框光轨、工作区覆盖、长期记忆、回答质检
- 用来查的,默认收起:AI 实际看到的内容
判断依据是用户原话:「每个正常人类打开这个设置肯定是想改这个设置相关的东西,比如人设就改人设」。 默认收起的卡不该是用户第一步就想动的地方;默认展开的卡也不该只是一份只读清单。
实现上就是一行表:const DEFAULT_COLLAPSED = { mem: false, preview: true }。
表里没写的卡一律展开 —— 新加的卡默认看得见,比默认藏起来安全。
长期记忆这张卡自己默认展开,因为开关、条数上限、每晚整理都在这张卡里,属于「要改的」那一类。 卡内部还有三个各自独立的小面板,各有各的开合,不受卡片折叠影响:
- 「已记住(N 条)」清单:没有折叠,直接展开列在卡里
- 「注入情况」:默认收起(
state.showInjection: false),点一下才拉数据 - 「导入外部记忆」:默认收起(
memoryImport.open不是true就不展开)
这不是三层折叠,是「卡展开 → 面板各自开合」两层;再把面板套进折叠卡就会变成三层, 点两次才看到内容,反而更难。
手动翻过就听用户的。 点一下按钮,记住的是翻转之后的状态,不是「取消设置」——
直接删键等于回到默认,只有默认态恰好相反时才表现为翻转,而用户点按钮的意图永远是「翻一下」。
所以翻回默认态同样要写下来(值是 false,不是删键):
- 默认收起的预览卡被手动展开,记
preview: false,下次进来还是展开 - 默认展开的卡片被手动收起,记
mem: true,下次进来还是收起
状态记在 localStorage 的 dsh-anima:card-state,形如 {"preview":false,"mem":true}。
这个键只记用户手动改过的卡;没改过的卡不占位,它们的默认态由代码里的 DEFAULT_COLLAPSED 决定,
这样以后新增卡片不必写迁移,只要默认态是展开就自然可见。
脏值一律丢弃:不是布尔的值(0、"yes"、嵌套对象)当作没设置过,回落到卡片默认态——
一次手改 localStorage 不该让某张卡永远打不开。键名从旧版的 dsh-anima:collapsed-cards 换成了
dsh-anima:card-state:旧键记的是「收起的」,在新规则下「收起的」反而是默认态,沿用旧键会让
以前手展开过的卡永久收起。
收起只影响这一张卡的内容,不影响卡片里的表单。 收起时 children 不渲染,但表单值住在设置页顶层,
收起不会把刚敲的字丢掉;切分区用的是 display:none 而不是卸载,两个机制不同但效果一致:
用户切去看一眼记忆、再切回来,刚改的东西都还在。
两种「藏」的语义是相反的,写在代码注释里免得后人统一:卡片的 collapsed 是整段不渲染,
分区切换的 hidden 是 display:none。前者是用户主动收起(要看得见卡头才知道怎么展开),
后者是切栏(必须保住树,否则未保存的表单值全丢)。
不做任何迁移
本插件不读任何其它插件或自己旧版本的配置。它发布过 1.0.0,但从 1.0.0 到 2.2.1 的存储格式完全相同
(DOMAIN_VERSION 都是 1,config / memories / meta / audit 四张表的 spec 逐字一致)——
没有「老用户的介质」需要兼容,所以代码里既没有一次性迁移,也没有存储格式回退。
这两块曾存在于开发期,都已删除;留着它们的唯一作用是让后人以为有一批需要照顾的存量用户。
将来真要升存储格式:只改 lib/store.mjs 的 DOMAIN_VERSION,并先想清楚老用户的介质怎么办 ——
那个决定不该在发布之后临时补,因为补的时候介质已经在用户机器上了。范式可参考同生态的
某同生态插件(双 spec 串行回退)。
十二个字段都是自由文本
style / headingLists / emoji / tables / replyLength / language 六个字段不再是下拉框,
存的就是要注入提示词的那句话本身。改之前它们是枚举(style: 'roast'),改之后
style: '毒舌吐槽;直白指出问题、不留情面,但始终对事不对人。' —— 你想怎么写就怎么写,
只要不超过字段上限(agentName / addressUserAs / userOccupation 各 50 字,userBio 500 字,
agentRole / style 各 800 字,headingLists / emoji / tables / replyLength / language 各 300 字,
customInstructions 2000 字 —— 逐字段取值见 lib/config.mjs 的 FIELD_LIMITS)。
留空是合法状态,空串在提示词里等于「不提这件事」:模型按自己判断来,而不是被一句
「请按默认方式处理标题」占用注意力。这也是三项特质的 default 档对应的实现 ——
它不是一个值为 'default' 的枚举,而是空串。
空输入框会劝退人,所以每个字段下方都有一排按钮:
| 按钮组 | 来源 | 点下去 |
|---|---|---|
| 一键填入 | lib/wording.mjs 的 WORDING_TABLES |
填入一句定稿文案(原档位那句话),填完可以继续改 |
| 示例 | lib/wording.mjs 的 FIELD_EXAMPLES |
填入一段明显属于别人的第一人称,等你改成自己的 |
两组都随 GET /api/anima/state 下发给设置页,客户端不自己抄一份文案表 ——
否则改了 host 忘了客户端,界面上就会出现两套说法,而真正注入的是 host 那份。
agentName 刻意不给示例:按钮填一个名字进去用户很可能忘改,结果 AI 一直用别人起的名;
这个字段改由 placeholder 提示格式。default 档也刻意不生成按钮 —— 空文案做成按钮,
会让人以为点一下能得到什么,其实只是清空输入框。
兼容旧配置:升级不会等于「偷偷把用户的风格改回默认」。migrateConfig 会把旧版存的档位键
('professional' / 'more' / 'default' …)自动翻译成对应文案,注入提示词的内容一字不变;
用户自己写的句子不命中任何档位键,原样保留。
requireToolConfirmation 与四个 trail* 字段刻意不参与工作区覆盖:一个是安全开关,
一组是界面偏好(而且必须是封闭集合 —— 它们是给 CSS 用的键),都不该因为「换个工作区」而悄悄换掉。
maxInjectedChars(默认 4000,单位字符)与 maxInjected(默认 40 条)取先到者:
只有条数约束时,40 条长记忆一样能挤爆上下文。两者的 0 含义不一样,别记混:
| 字段 | 0 的含义 |
|---|---|
maxInjectedChars |
不限字符数(只有条数约束生效) |
maxInjected |
一条都不注入 —— 它同时关闭所有记忆注入;要停记忆请用 memory.enabled |
defaultLayer 决定新记忆记在 global 还是 workspace,默认 global 是刻意的 —— |
|
| 把「记忆动不动就少一条」当默认体验,比跨工作区污染更难排查。 |
其余 12 个字段可被工作区覆盖,且覆盖是字段级的 —— 工作区只声明它在意的字段,其余继承全局。
配置落盘:只用 storageDomain(域 anima —— 注意是下划线,宿主的域名校验只接受 ^[a-z][a-z0-9_]*$,连字符会被拒)。
惰性解析(与 某同生态插件 的做法一致):不在 apply 阶段解析服务,首次真正使用时才 ctx.get('storageDomain')。
dsh-storage-domain 的 apply 体是 ctx.inject(backendServices, …) + 回调里 provide('storageDomain', …),
而 backendServices 是 storage.backend.json(由 storage-json 提供)。本插件若比 storage-json 先装载,
apply 阶段那一次 ctx.get() 必然拿到 undefined —— 这正是 2026-10-10 冷启动时存储后端无声退化的真因:
旧版判空后退化到文件后端,还把结果永久记忆化,于是用户的配置留在旧文件里读不出来。
现在的契约:服务缺失 ⇒ 抛 StorageUnavailableError('storage-not-composed'),绝不换落盘点;
失败不记忆化(服务后来就绪时下一次调用就能成);成功才缓存域句柄。
index.mjs 的 apply 首次 reload() 撞上它时,挂一个 cordis 原生就绪信号
ctx.inject(['storageDomain'], …) 等它 provide 后重试(依赖未就绪时 fiber 保持 PENDING,服务 provide
那一刻回调触发,本仓用真 cordis 探针实证过),期间五个 section 返回空串 —— 宁可不注入,也不冒充默认档。
不做旧数据救援:storageDomain 何时可用取决于装载顺序,同一个人可能早期把配置写进过别的落点。
而从 1.0.0 到 2.2.1 的存储格式相同(DOMAIN_VERSION 都是 1,四张表一致),没有需要救援的旧介质 ——
所以打开域时不做任何导入或归档,只打开自己的域。
若将来真需要迁移,唯一安全的形状是:仅在新域 config 记录不存在时动手,且自己的任何失败都只记 warn,
绝不外抛(这段代码在打开域的 try 块里,外抛会被包成新域的 open-failed,让一个本该正常的插件停摆)。
不可用原因可查:GET /api/anima/state 的 source 段带 reason(错误码)与 message(原始报错)
两个字段,取值 storage-not-composed / vocabulary-unavailable / open-failed: <原始报错>;
正常走域时两者均为 null,且 kind === 'storage'。设置页把它翻成人话显示在「存储位置」下方。
存储不可用时该路由返回 503 + code,而不是 500 空壳 —— 设置页要靠它区分「插件没跑」与
「跑了但存储栈没组合」:前者该重装,后者补三行 storage / storage-json / storage-domain 即可。
单一格式戳,无回退:storage-json 的 single 布局在打开时硬比对版本戳 ——
unit.version !== descriptor.version 直接抛 version-mismatch,没有任何自动迁移。本插件的域版本
恒为 1(lib/store.mjs 的 DOMAIN_VERSION),只声明一个 spec,不做串行回退:1.0.0 到 2.1.0 的格式戳相同,不存在「老用户的 v2/v3 介质」这种东西需要读。
开发期曾有过兼容回退(按 [v3, v2, v1] 串行 open(),只对 version-mismatch 继续试),随未发布的
v2/v3 格式一起删除。那段代码的教训保留在这里,因为将来升格式时用得上:compatibleVersions 声明了
等于没写 —— 它只被 per-record 布局的 acceptedStamps() 读,single 布局的 parse() 根本不看。
真要做多版本兼容,只能像 某同生态插件 那样声明双 spec(主 spec + 容错 spec),
且兼容 spec 必须声明全部四张表 —— DomainImpl.table() 对未声明表直接抛错,少一张就会在下一次
table('audit') 时炸。
两套编号,别混:插件自己的版本是 package.json 的 semver(2.1.0,设置页右上角徽标那个),
存储格式戳是个位数(当前是 1),两者互不相干。界面上凡出现格式戳的地方一律写「存储格式 vN」,
凡出现插件版本的地方一律带小数点 —— 混着叫「本版 v3」时用户没法判断哪个数字在说他装的插件旧。
lib/config.mjs 里那个零引用的 CONFIG_VERSION = 3 已删除,就是为了不再制造第二个「v3」。
提示词段落
| section 名 | order | 内容 |
|---|---|---|
anima:identity |
10 | 助手自身的名字与角色(可被工作区覆盖,装配期实时求值) |
anima:memory |
20 | 已采纳的长期记忆 |
anima:pending |
21 | 待审条数提示(只报数量,不含内容) |
anima:remember |
22 | remember 工具的调用时机与字段(不写这条,模型不知道有这个入口) |
anima:quality |
30 | 十二条判据 + 三档对账规则(quality.enabled 关掉即整段消失) |
刻意排在 PLAN_POLICY(500) 之前:人设是「我是谁」,应当在行为策略之前确立。
命令与工具
/anima [show|pending|adopt <id>|discard <id>|mark <id> type=… state=…|triage|patrol|retrospect|checkup|audit|name <名字>|enable|disable|set <键>=<值>|confirm|reject|reset]- 工具
remember(text, note?, scope?, type?, sourceRef?, verifyAt?)—— 投递一条候选到待审缓冲区 - 工具
set_assistant_persona(...)—— 模型改助手人设;开启「工具确认」时只落提议, 需用户/anima confirm或设置页点「确认」才生效
四个容易混的命令,按「查什么」区分:
| 命令 | 查的是什么 |
|---|---|
/anima show |
插件此刻的状态(配置、存储、注入、质检计数) |
/anima audit(别名 /anima 检查) |
我上一条回答有没有违反十二条 |
/anima checkup(别名 /anima 体检) |
记下来的东西还成不成立(路径 / 复核点) |
/anima patrol(别名 /anima 巡夜) |
跑一次完整的巡夜,并当场说清「这次为什么没说 / 说了什么」 |
/anima retrospect(别名 /anima 复盘) |
回扫旧会话,找出你当时说过、但没被记下来的话 |
前两个查「我说的话」,第三个查「库里的数据」,第四个决定要不要向你汇报, 第五个回头翻旧账、只产线索。前三个都不改任何状态;巡夜会跑整理(可能动缓冲区)、 复盘会读磁盘上的会话日志,但都不会替你做任何裁决。
/anima show 是只读诊断命令:存储栈没组合时它恰恰最该跑得通——用户正是靠它才知道
「插件在,是存储没接上」,所以它容忍装载失败,把原因和「以上是内置默认值」的提示一起打出来。
界面挂载点
| 槽位 | id | order | 内容 |
|---|---|---|---|
settings.section |
anima-settings |
51 | 「灵台」设置页 |
conversation.input.left |
anima-quick-toggle |
51 | 输入框左侧的「人设已开 / 人设已关」快捷开关 |
conversation.input.overlay |
anima-trail |
51 | 输入框光轨(仅助手运行时亮起) |
三个 id 全部与 某同类人设插件 的对应项错开:settings.section 的文档写明「复用已有 id 即替换那一格」,
撞上就会把对方的设置页顶掉。
设置导航图标用 DOM 替换实现(常量前缀 data-an-settings-nav / data-an-icon-replaced):
壳层的导航图标是 id → 图标的硬编码映射,插件无法通过槽位声明自己的图标。
这是锦上添花的能力,所以代码在入口处检查 document.querySelectorAll 是否存在,没有就整体不接管
——绝不能因为画不出一个图标就让整个客户端装载失败。
光轨为什么带三重自守:它挂在输入框卡片上,一旦尺寸或定位算错就可能反过来影响宿主布局,
所以实现里有三道闸门——内联 cssText 写死 12 条关键属性(防第三方样式表覆盖)、
尺寸超过 TRAIL_MAX_SIDE 直接 disable()(防尺寸正反馈)、挂载后自检
getComputedStyle(svg).position === 'absolute'(防样式被覆盖)。
对外服务
const svc = ctx.get('animaConfig')
await svc.getEffective() // 全局 + 当前工作区合并后的有效人设
await svc.getConfig()
await svc.updateConfig(patch)
await svc.resetConfig() // 与设置页「恢复默认」同一条路径,同样保留自建预设
await svc.getSystemPrompt(workspace) // 模型此刻实际看到的人设文本(与五个 section 逐条对齐)
await svc.listPending()
await svc.runTriage()
getSystemPrompt(workspacePath) 的门控与 anima:identity / anima:memory / anima:pending /
anima:remember / anima:quality 五个 section 函数逐条对齐——否则这个服务会答出一份模型实际看不到的文本,
那比不提供它更坏。(这条曾经真的漏过一次:加质检段时只改了 registerSystemPrompt,
忘了 getSystemPrompt 与 /api/anima/preview 两处,于是预览里看不到模型实际会收到的质检规则。
两处独立犯同一个错,说明这类「同一份文案有多个出口」的地方必须靠测试锁,不能靠记性。)
开发
以下脚本在源码仓库里跑(scripts/ 与 pnpm-lock.yaml 不进 npm 发布包 —— 发布包共 20 个文件:
index.mjs / lib(14 个 .mjs)/ client/index.mjs / assets/icon.svg / cordis.patch.yml / package.json /
LICENSE / README.md / README_EN.md;这个数字是 npm pack --dry-run 打出来的,不是手数的):
node scripts/verify-core.mjs # 纯函数回归(188 项)
node scripts/smoke-host.mjs # 宿主侧冒烟(100 项,用假 ctx 真跑 apply)
node scripts/check-client-i18n.mjs # 客户端字典完整性 + zh/en 对称(zh=en=316 key)
node scripts/smoke-client.mjs # 客户端渲染冒烟(52 项,假 __ModuleLoader__ + 真钩子运行时)
node scripts/acceptance.mjs [--url <宿主>] # 验收:全部套件 + 运行态 + 源码/安装副本 SHA256 一致
node scripts/check-hook.mjs [会话文件] [--since <ISO>] # 质检钩子活性(ALIVE / DEAD / UNDETERMINED)
node scripts/remind-effect.mjs [会话文件] # 量「质检提醒有没有改变写作行为」(会抖)
node scripts/audit-response.mjs --text-file <文件> # 拿十二条判据判一段文本(给人用)
node scripts/trace-sources.mjs [--apply] # 给记忆反查出处(LCS 匹配,不是精确匹配)
node scripts/backfill-types.mjs [--apply] # 批量回填记忆 type / verifyAt
node scripts/check-live.mjs # 运行态验收(旧版,已被 acceptance.mjs 覆盖)
五个判定脚本各有各的诚实边界,别混:
check-hook.mjs的 UNDETERMINED 不是通过。没有--since分界线时,它无法区分 「旧代码时期写的回答」与「新代码该报没报」,所以一律不下判词 —— 未知不等于坏。 它只把带[dsh-anima 质检]前缀的注入消息算作证据;整理通知之类不算 (早期版本把所有 anima 注入消息都算成提醒,于是一条与质检无关的后台通知也能让判定变 ALIVE)。remind-effect.mjs的比值会随会话变长而漂,分母通常只有一二十条,差两条就能翻面。 它只能否掉「完全没用」,证不了「有用」;「写了对账块」也不等于「写对了」。audit-response.mjs是给人用的,插件内部不调用它。它的退出码 0/1/2 分别表示 干净 / 有判词 / 读不到输入,--json给机读。trace-sources.mjs用最长公共子串(阈值 10 字)而不是精确匹配 —— 记忆库存的是 被改写压缩过的版本,逐字相同永远匹配不上。它的输出分三档:用户原话/助手转述(未找到你的原话)/不可考。「助手转述」不等于假,只等于「找不到原文可核」。acceptance.mjs结束时必定打印「本次没验什么」。那一段不是免责声明,是给你反查用的清单 —— 里面列着的项目,就是它没有证明的东西。
前五套都在进程外跑,只能证明「代码是这么写的」。acceptance.mjs --url 与 check-hook.mjs
证明的是「进程里那份代码确实生效」——曾经发生过装机副本停留在旧版、新加的路由一条都没注册、
而全部单测依然全绿的情况;更早还发生过单测夹具照抄了错误的事件形状,于是钩子永远拿到空串,
测试却全绿。夹具必须照抄现实,不能照抄实现。
改完插件要重新安装才生效:pnpm 的 file: 依赖是复制语义,重跑 install_bundle 时若判定
changed:false 会直接跳过,正确姿势是 remove_bundle 再 install_bundle(或对比 node_modules
副本与源码的 SHA256 确认)。宿主侧改动一律需要完整重启,热重载不会换掉已缓存的 ESM 模块。
check-live.mjs 里的断言刻意不钉具体值:名字是你的资产,脚本要防的是「数据丢了」,
不是「你改了名」。同理「模型可改哪些字段」这类契约变化,断言长度而不是逐条枚举内容;
路由数量也一样——它点名核对关键路由是否注册,不钉总条数,否则每加一条路由都要回来改这个数字。
verify-core.mjs 与 smoke-host.mjs 都通过 scripts/tools-loader.mjs 把宿主内建包
(@deepseek-ai/dsh-tools、@deepseek-ai/dsh-storage-domain)解析到本地替身 —— 它们只存在于宿主
asar 里,在 DSH 之外解析不到;装进 profile 后走的是真实包,由 check-live.mjs 覆盖。
路由守卫
本插件的 HTTP 路由会读写人设配置与记忆正文,而宿主 webserver 自身不带鉴权
(@deepseek-ai/dsh-host-webserver README 原文:the server carries no TLS,
authentication, or origin policy of its own),且默认配置可能绑在 0.0.0.0。
因此每条路由都自加一道守卫(index.mjs:467 起),三档判定,任一不过即 403:
非回环来源一律拒;带了 Sec-Fetch-Site 且值不是 same-origin 的拒(浏览器发出的跨站请求会带这个头,
它比 Origin 更早生效,也覆盖了 no-cors 之类的场景);带了 Origin 的还须协议为 http(s)、
主机是回环地址、且 Origin 与 Host 完全一致。
不带 Origin 的回环请求是放行的——本机脚本(curl / PowerShell)属于这一档,
这是刻意为之:守卫拦的是浏览器跨站,不是本机使用者。做法与生态一致
(@michengai/dsh-skills-manager、@linxin666/dsh-remote-web-ui 同类实现)。
破坏性动作的确认
POST /api/anima/config/reset 要求请求体里显式带 {"confirm": true},空 body 一律
400 + code: "confirmation-required",一个字段都不动。设置页的「恢复默认」按钮会先弹一次
确认框,通过后才带该字段。语义与 某同生态插件 的失败关闭(fail closed)一致:
拿不到明确确认就什么都不做,并说明怎么做才有效。
重置保留用户自建的人设预设——行为与 某同类人设插件 的 重置时保留用户自建人设的函数 对齐,
预设是用户一条条攒出来的资产,重置语气设置不该连它一起清掉。
这条护栏来自一次真实事故:为确认路由是否注册,对写路由逐一
POST {}探活,/config/reset当场 200 并清空了agentName/agentRole/addressUserAs。 现在有三道防线:路由要求显式确认、smoke-host.mjs断言空 body 不得改动字段、check-live.mjs在运行态探一次并在真被清空时就地恢复。
已知边界
- 配置写入是非原子的(无 tmp+rename),
storageDomain路径下由后端保证。 anima:pending只提示数量,不暴露候选内容——这是刻意的,避免未裁决内容影响回答。- 注入消息缺
id不是「这条丢了」,是「整个会话打不开」。宿主加载会话时assertMessageEventShape要求每条消息id非空串,抛的是session event at seq N lacks an identified message(_tmp_sessionfix/_extract/dsh-session__lib__index.js:1201)。 写入那一刻没有任何校验,坏消息会安静地落盘,直到下次打开会话才炸。 已改成自持randomUUID()(lib/injection.mjs的createNoticeMessage), 并在verify-core用三条回归钉住「有 id / 两次不同 / role 与 source 仍合规」。 历史坏数据(本机 seq 6104、6301)是改写存档补的 id,不是靠插件自愈 —— 已经有坏数据的用户升级插件不会自动修好,这一点写在这里免得下次误判。- 工作区覆盖文件的向上查找最多 32 层。 - 路由守卫假定请求经过宿主 webserver;若将来宿主在前面加了反向代理,
remoteAddress会变成代理地址,此守卫需要同步调整。
质检层做不到的事(写在这里,因为「以为它做到了」比它没做到危险得多):
- 本轮拦不住。钩子看到的是已经落盘的回答,只能在下一条消息里提醒。
- 只判形状,不判内容。它能发现「有数字却没有出处」,不能发现「这个数字是错的」。
- R03 / R05 / R06 / R07 / R10 机器永远判不了,代码每次都会明说「这五条没查」。
- 「零提醒」不等于「没问题」。要看
qualityStats.checks:checks=0就是这套东西没在工作。 - 误报率和漏报率没有同时最小化的解。当前取舍是宁可漏(
strict关掉三条 partial 判据), 也不制造假警报 —— 假警报会让用户关掉整层,那时漏报从一个洞变成全部洞。
记忆层做不到的事:
- 不判断哪条记忆被哪条取代。这是语义判断,自动做会生成用户没说过的话; 实测 21 条记忆两两相似度最高 0.094(阈值 0.6),自动判也一次都不会触发。
- 衰减只影响「怎么显示、排在哪」,不改数据。
lib/decay.mjs按type+ 日历 算出档位:事实窗口 0(永不因年龄衰减,环境变更由verifyAt管)、偏好 540 天、 立场 365 天、事件 180 天。超窗的条目在注入行尾部加一个标签(「久未更新」/「较早的事」), 并在排序里挪到最后(预算紧张时先被挤掉)。它不会把state改成 superseded,也不会删除—— 「这条还算不算数」只有你能判。 那三个天数没有任何数据支撑,是按类型语义随手定的量级;等实际用上一段时间觉得不合适, 再决定要不要提升成可配置项。 - 删除是硬删(
deleteMemory→ 后端delete),不是墓碑。state: 'dropped'的记录会被selectForInjection直接跳过(lib/memory.mjs:578), 所以**「已删除(墓碑)」这个标签永远不会出现在注入给模型的提示词里**——formatMemoryLine里那段分支只是格式化函数的自我完整,注入路径走不到它。 这不是缺陷:把已经删掉的内容再塞回上下文,比不显示标签糟得多。 - 巡夜只报有事,且判据里没有任何语义判断。它每晚跑一次(
scheduleNightly→runPatrol), 做三件事:整理缓冲区、跑一遍记忆体检、然后决定要不要说话。判据全在lib/patrol.mjs:待审候选的集合变了、体检 flagged 的集合变了、或本次整理真的动过数据。 数量没变就不说 —— 三条候选挂一周,每天报一遍是纯噪音。 它判不了「这条记忆还算不算数」,那需要理解语义。
巡夜做不到的事(写在这里,因为「以为自己会被告知」比不被通知危险得多):
- 它不保证「今晚一定跑过」。机器关机、宿主没开、存储没就绪,都会让它整夜不跑。
沉默于是有两种意思:巡过了没事,和根本没巡。这两种只有
meta.patrol那行能分开 ——at是昨晚的时间就是跑过,at还停在前天就是没跑。设置页那行「上次巡夜」就是为此存在。 反过来也成立:如果那行不见了,不是「没事」,是「没跑过」(客户端对这种情况有专门文案)。 - 它不看内容的真假。判据全是集合比较(待审 id 集合、flagged id 集合、整理是否动过数据), 没有一处读得懂记忆写的是什么。所以「这条记忆现在是错的」它永远不会主动告诉你。
- 它不判断哪条取代了哪条。语义判断,见上一节。
- 它不会因为你忙就改期。几点跑由
nightlyHour定。到点那一刻机器睡着、或闹钟被系统拖到别的钟点, 它就跳过这一晚而且不说(index.mjs:807判nowHour !== hour时只写一条 warn 日志就 return)。 准确地说:「记nightly-late」这件事从来没有实现过 —— 全仓只有lib/patrol.mjs:206的注释和 本节之前的文字提到它,代码里没有任何一处写入nightly-late。真实存在的是reason: 'catch-up'(宿主启动 5 秒后,若当天还没跑过就补一次,index.mjs:2561),它只补「当天」,不补「上周三那晚」。 它不知道你在赶论文,也不会因此闭嘴或推迟。 - 它只有一次机会。一晚只跑一次;那一晚如果什么都没说,就没有第二次。想要确定结论,
得自己手动跑一次(设置页按钮或
/anima patrol),否则你只能从meta.patrol反推。 注意手动跑也会覆盖那行记录:at会变成你手点的时间,reason变成manual。所以「昨晚到底跑没跑」 只能在你还没手动跑过之前查到 —— 手点一次,这个问题的答案就没了。 - 它报的是「集合变了」,不是「这件事重要」。一条无关紧要的候选新冒出来会报, 一条真要命但上周就已经躺在名单里的不会报。判据里没有权重这个概念。
- 它只留最近 20 条明细。
flaggedItems存的是压缩后的前 20 条,被截掉的条数记在flaggedDropped并在界面上写明。要看完整清单只能当场点「体检」重算(GET /api/anima/memory/audit), 那份是现算的、不限 20 条 —— 但同样不会告诉你哪条更重要。 - 明细里不含记忆原文(一般情况)。正常只有 id、一句解释、一句证据。唯一的例外是体检记录里缺
detail时 会退回记忆正文(lib/patrol.mjs:189的first.detail ?? item?.text)—— 现有体检代码每条都会给 detail, 所以这条兜底在正常路径上不会触发,但代码里确实留着这条路。 - 它的通知只送进「当时活着的会话」。
injectToAgents只对当前活动会话注入;那一刻没开着的会话 收不到,事后也不会补发。所以「我后来打开了没看到」不等于它没报,delivered和reported才是证据。 - 「今晚没报」不等于「你昨天处理的东西已经被看见了」。整理动过数据只在「上次没动过」时
才说一次(
lib/patrol.mjs:139的prevTouched === 0条件)—— 连续几晚都在自动清理, 只有第一晚会说。之后的清理量仍然记进facts.removed,但不会单独通知你。 - 它判不了「这条记忆该不该留」。整理只会去重与按上限淘汰,从不因为「内容没用」删东西。
facts.removed里涨起来的数字全是这两类机械原因。
No comments yet. Be the first to write one.