DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

Melody-cx /

Melody-cx/dsh-anima

Verified

DSH 助手人格与长期记忆插件:独立可用,不依赖其它插件。管 AI 自身的名字与角色、对用户的称呼、回复风格与自定义指令;人设支持全局 + 按工作区两级覆盖;长期记忆走三段式(候选捕获 → 待审缓冲区 → 用户裁决才生效),并支持一键导入其它 AI 的历史记忆。

★ 2 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@0d86b5a0

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)只做两件事:

  1. 记忆里写的 Windows 路径,现在还在不在(fs.existsSync)
  2. 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

脚本自带两条诚实边界:时间邻近不等于因果(它只能否掉「完全没用」,证不了「有用」), 以及「写了对账块」不等于「写对了」。

这个测量本身绕过三个坑才站住,每个坑都是我先踩了才发现的:

  1. 用原文跑触发判定 → 把反引号里的数字(`实际注入提醒 0 条`)当成断言。 钩子调的是 checkResponse,它先 stripCodeBlocks 再剥掉被引用的说法。 探针没走真实链路,读数就是假的。
  2. 用 checkResponse 判出的 R02 当「本该有块」的条件 → 循环论证。 R02 的定义就是「该有块却没有块」,这个集合里永远算不出「有块」,结果必然是 0%。 条件必须只用输入侧的事实(触发是否命中),与输出侧(写没写块)无关。
  3. 同一回答被多条提醒重复计数 → 分母被吹大。要先按回答去重。

结论:钩子在正确工作(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):

  1. 待审候选的集合变了(有新候选、或被裁决掉了一些)
  2. 体检 flagged 的集合变了(路径失效 / 复核点过期)
  3. 这次整理真的动了数据(去重或超限淘汰)

用「集合变没变」而不是「数量 > 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。

三条硬规矩:

  1. 只产线索,不写记忆库。 复盘不会往 memories 表里写任何一个字—— 那是你没说过的话。要不要记、记成 fact 还是 stance,由你在待审区点。
  2. 不管有没有线索,都写一条 meta.retrospect。 与巡夜同一条纪律: 只报有事的话,「沉默」会同时代表「扫过了没线索」和「根本没扫」。/api/anima/state 的 retrospectMeta 下发它;没扫成的轮次 files 是 null 而不是 0—— 写成 0 就变成「扫了 0 个会话」,与「扫过没事」长得一模一样。
  3. 默认关闭。 它要读磁盘上的会话日志,是新开的一个口子,所以默认 false, 在设置 → 灵台里打开。关着时接口明确回「已关闭」,不写任何记录—— 「被你关掉」和「跑了没成」是两种事实,混进同一条记录会让人以后看到「上次回扫失败」以为坏了。

凭据闸门

任何命中凭据形态的内容在进入缓冲区之前就被拒收(不落盘、不留痕), 覆盖私钥块、OpenAI/AWS/GitHub 令牌、通用 api_key / secret / 密码赋值、Bearer、 URL 内嵌凭据等 11 类模式。

理由:内容一旦落盘缓冲区,就处于「等待进入提示词」的状态,凭据不该走这条路。 凭据判定用的是 11 类正则的机械匹配,没有语义理解,会有误判——所以被拦下时界面会 把命中规则名一起说出来,用户可以删掉那串字符再粘贴。

一键导入外部 AI 的记忆

设置页记忆卡里有一个「导入外部 AI 的记忆」按钮,展开后是两步:

  1. 复制提示词——把一段整理画像的提示词发给你用得最多的那个 AI,让它把你的偏好 整理成清单。按钮只在真的写进剪贴板后才显示「已复制」;没有剪贴板 API 时会明说 「复制失败,请手动选中后复制」,不假装成功。
  2. 粘贴结果——粘贴框接受 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-0
  • peerDependencies 四包:@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 里涨起来的数字全是这两类机械原因。
—/ 5

No ratings yet

Verified DSH bundle

Commit 0d86b5a0041d

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout