Bundle
dsh-super-memory
跨压缩记忆:同一个超长会话被压缩多次后,早前问过/定过的事仍能被检索到并作为参考注入。压缩时入库(0 模型调用),提问时本地检索、命中才注入。
- Source
- kkwings
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 5 days ago
Readme
# dsh-super-memory(超级记忆)
**跨压缩记忆**插件:在**同一个超长会话**里被上下文压缩过几次之后,你早先问过 / 定过的事,模型**先看到**它,再**作为参考**结合这次的新条件综合回答——不会当新问题从零重来。
> 只做**同一个会话内、跨压缩**这一段。**跨会话记忆不在范围内**(那是另一类插件的事)。
> 插件自包含:不依赖任何其他记忆插件,**不访问互联网**,默认**一次模型都不调用**。
> **关于本插件(请先读这两句)**
>
> - 本插件**由 AI 协助开发与维护**,**可能存在缺陷**。请在重要场景**自行验证**后再依赖它;发现问题欢迎开 issue 或直接改(见下方「许可」)。
> - 它主要面向**学习与测试**用途,作者**不保证持续更新**。代码是 MIT 许可,**fork、修改、再发布都可以**。
> - **运行环境需要带 zstd 支持的 Node**:插件读会话日志要用 `node:zlib` 的 `zstdDecompressSync`,所以 `package.json` 的 `engines` 声明了 **`node >= 22.15`**。CI 实测 **Node 20.20.2 会在加载时直接报语法错**(`does not provide an export named 'zstdDecompressSync'`),**Node 24.x 正常**,22.x 由 CI 矩阵验证。DSH 本体读会话日志用的是同一个 API,所以下限一致。
## 1. 这是什么 / 解决什么问题
**一句话**:上下文压缩会把早先的对话从模型眼前拿走(原文仍在磁盘上),这个插件在被拿走的那一刻把它存到你本机,之后只在**相关**的时候把那几段递回给模型。
**一个真实场景**:
1. 同一件事在会话开头被讨论、定过一条结论,之后几轮里又被反复提起、修改,最后定稿。
2. 聊到几百万 token 之后,上下文被自动压缩了两次,那些轮次连同当时的结论一起从窗口里消失。
3. 你说:「按我们之前定下来的做法来吧」。
4. 没有这个插件:模型只能反问「什么做法?」或者猜一个。
有这个插件:本地检索命中那些旧结论,把它作为**参考**注入——模型看到「此前是 X」,再结合你这次说的新条件回答。
**它不做什么**:
- **不做跨会话记忆**。换一个会话就换一份记忆,互不可见(记忆按「会话所属工作区 + 会话 id」存放)。
- **不能替用户发消息**。插件拿到的会话句柄**没有 append 能力**(实测边界,不是自我限制)——它能做的只有「把资料排进你下一轮的上下文」(见第 4 节),不能凭空让主模型立刻重答,也不会往会话里插一条消息。
- **不改写你的对话、不碰 DSH 的原始会话日志**(只读)。
- **不读密钥、不自己连网**(要用模型时经 DSH 的模型服务)。
### 现在还做不到什么(以及为什么)
写这一节是因为:**它现在确实做不到"像人一样"地回忆。** 下面每条都是实测出来的边界,不是谦虚话。
1. **它只有"被压缩掉"的那部分内容。**
会话里**还没被压缩**的内容本来就在模型眼前,插件不管、也不需要管。实测:本会话 162 条提问里,只有 **71 条(43.8%)** 曾经落在压缩范围内——其余仍在窗口里。所以**用得越久(压缩次数越多),它覆盖的比例越高**;刚聊不久时它帮不上什么忙,是正常的。
2. **它按"词"找,不按"意思"找。**
你问的用词和当初聊的用词对不上,它就可能找不到。我们试过加本地的语义模型(把内容变成向量再比):**结果并没有更好**(在真实语料上还不如按词找),还多占几百 MB 磁盘——所以没上。换成"另一种说法"仍然要靠**可选的辅助大模型**(第 7 节)。
3. **"最早 / 第一次 / 上次 / 刚刚"这类话,它现在会认,也会按三类去偏。**
0.2.6 起,这类时间指代词会被识别出来;**加权只改排序,不改"谁能被注入"**(该不该注入只由词法分 + 证据门决定)。
指代词分三类、各配一种时间形状:**刚刚 / 刚才** → 越新越高;**最早 / 第一次 / 最初** → 越早越高;
**之前 / 上次 / 原来** → 中间偏新最高(钟形,波峰贴近当前,两端只是更低、不是归零)。
**一条消息里同时命中两类(例如"我们刚刚说的那个最早提过的设想")→ 这次不做任何时间重排**:
这句话真正要的是"**两头**"(最早一遍 + 刚刚一遍,现在问的是第三遍),而**自动检索这条路上没有模型可用**,
猜不出来 —— 所以口径是"**宁可不用,也不用错**"(固定优先级必然选错一边)。
幅度有上限(默认 0.7×–1.35×,可在设置文件里调 `timeBoostMin` / `timeBoostMax` 等**七个**键;
`both_ends` 的"中间凹陷深度"是新增的 `timeBothEndsDip`,默认 0.2)。
**"两头"由点 ✕ 那条路负责**:辅助模型判一个"时间意图"
(`earliest` / `recent` / `both_ends` / `none`),选 `both_ends` 时用**两端高、中间低**的形状重排;
这个意图**只重排、不改资格**,也**不新增任何模型调用**(第 7 节)。
**意图从哪来**:优先用「改写」那一次调用顺带输出的字段;**改写被跳过时(本地已强命中),
由「分页判读」那一次本来就要发生的调用顺带判**——所以"本地强命中、但可能选错"这种点 ✕ 的主场景
也拿得到意图,调用次数一次都不多(回执里 `timeIntent.via` 会写明来源是 `rewrite` 还是 `pageread`)。
但它**不会自己想**"这题该去哪一段时间翻"。那一步得由**模型(也就是跟你对话的那个我)先想清楚,再去用它**。
4. **它不能替你想,也不能替你发消息。**
它最多把几段原文**递到模型眼前**。平台限制:插件不能往会话里插一条消息、也不能让模型"立刻重答"——所以点 ✕ 找到的东西会排进你**下一轮**(第 4 节)。
5. **它不保证找得到,但会明说。**
找不到时会直接显示「未搜索到强相关内容」,**不会硬凑一段出来**。没命中就一分 token 都不花。
5.5 **它只能定位到"块",定位不到"块里哪一轮"。**
库里一个块往往装了**好几轮问答**(按字符数切,目标 900、上限 1500 字符)。所以它给你的是"**这几轮里有过这段话**",而不是"**第 N 轮说的**"。
实测代价:**同一次压缩写进去的所有块,时间戳完全相同**(真库里 223 块只有 **5 个不同的时间值**)、也没有轮次字段 → 时间排序只能按**块在文件里的前后**近似(**跨压缩事件是准的,同一次压缩内部是近似的**)。
要更细的序,得在入库时给每条块写轮次或块内偏移,那需要**重建历史库**——本轮没做(代价大于收益)。
5.6 **在 Linux/macOS 上,如果工作目录正好在插件目录里,工具结果会被静默跳过。**
入库时有一条防自我膨胀的规则:**工具结果如果读的是插件自己的源码,就整条丢掉**(否则插件会把自己的代码当记忆存起来、越滚越大)。这条规则用**当前平台的原生路径语义**判断"是不是在插件目录里"。
**后果**(Windows 上因为盘符天然在外部,遇不到):在 POSIX 上如果你的 `cwd`(或被测路径)落在**插件目录内部**,真实的工作文件也会被判成"插件自己的源码"→ **那条工具结果不入库**,而**唯一痕迹是诊断日志里的 `selfSourceSkipped` 计数**。
**触发条件很窄**:把插件仓库本身(或它的子目录)当工作区时才会踩到;正常把项目放在别处不受影响。**本轮没改运行时判据**(它对真实路径是对的),只记在这里。
6. **一句话定位:它是车,不是门禁卡。**
它擅长「**你问的事有明确说法、而且被压缩过**」;不擅长「**你像聊天一样随口问**」。后者需要**模型先理解你的意思、再决定怎么用它**——比如你说"我们最早聊控制面板的时候是怎么定的",正确开法是「先意识到这是'按时间找最早那条',再用它去找'控制面板'」;刷一下卡等门自己开,就会失败。
## 2. 两种检索方式(默认只用第一种)
| | ① 本地检索(默认) | ② 大模型辅助检索(可选) |
| --- | --- | --- |
| 什么时候用 | 每次提问都做 | 只有你**在面板 ⑦ 里选了档位**才启用 |
| 怎么找 | 本地词法打分:中文字符二元组 + 英文词,标题/关键词/正文分权重(BM25),过阈值才算命中 | ①压缩入库时让模型给每块补几个「你以后可能怎么问这块内容」的短词;②你点 ✕ 时让模型把你的问法改写成关键词,并**判定哪一条真的强相关** |
| 花什么 | **0 模型调用、0 token**;未命中**一分 token 都不花**(连注入文本都不产生) | 按次计费,走 DSH 的模型服务;压缩时每批一次调用,点一次 ✕ 一次调用(相同提问 30 天内不重复调用) |
| 延迟 | 提问热路径零延迟 | 压缩时的调用你在等压缩,感觉不到;点 ✕ 时**最多等 8 秒**(界面会显示「检索中」) |
| 命中率 | 词对得上就命中;「换个说法问」容易漏 | 明显更高(尤其是口语化、同义改写的问法) |
| 怎么切换 | 默认就是它 | 面板 ⑦「使用方式」三选一:**不调用大模型(默认)** / **调用主模型** / **调用指定模型** |
两条路可以同时存在,但**提问时的检索永远是本地的**:模型只在「压缩入库扩写」和「你点 ✕」这两个时机被调用,**绝不进提问热路径**。模型改出来的词也要自己过本地阈值——它没法凭一句话把不相关的内容塞进你的上下文。
## 3. 三件事怎么工作
| # | 时机 | 做什么 | 成本 |
| --- | --- | --- | --- |
| ① | **压缩时** | 把这次被压掉的内容**本地入库**:一层是 DSH 已经写好的摘要,按小节切开存(短、准、结论级);另一层是被压掉那段的**对话原文**(你问的 + 助手答的文字),外加**模型读过的文件 / 检索结果原文**(`read` / `grep` / `glob` / `web_fetch` / `history_read`)。 | **0 模型调用**(选了模型档位才会给这些块补关键词) |
| ② | **压缩后** | 注入一份**目录级「本会话此前脉络」**(话题 — 结论),让新窗口一开场就知道过去聊过哪几大块。挑不出值得留的就一点都不注入。 | ≤ **300 token / 次压缩**,自适应,**可为 0** |
| ③ | **提问时** | 先在本地比一次关联性,**命中才注入**参考。 | 命中 ≤ **700 token / 轮**(≤2 条、每条 ≤300 字符、每条至少 50 字符才值得注入);**未命中 0 token** |
几个刻意的取舍:
- **注入块抬头写着「历史只作参考,不作结论」**:如果这次的新条件和旧结论冲突,回答要以新条件为准,并明确说「此前是 X,这次因为 Y 改为 Z」,不许静默改口。
- **压缩时不做任何模型提炼**:摘要直接用 DSH 生成好的那份,L2 直接抄原文。想省钱的人不该被"入库也要调模型"绑架。
- **原文只在你要的时候才展开**:日常注入的是压缩过的一行行结论;要逐字原文请用 `history_read` 工具(只在用户明确要求时调用)。
## 4. 「✕」的完整语义
会话里每条助手回答下面都有一个 **✕**(只在这个会话**被压缩过**之后才出现;没压缩过就没什么可找的)。点它的完整行为是:
1. **立刻**在会话窗口里出现一行「**xxx(辅助模型)检索中,请稍后…**」——不是弹窗,就在对话里;这一行期间**没有按钮**(结果还没出来,不给操作)。
2. 插件用「你上一个问题」在**本会话**已压缩的历史里找候选,然后(选了辅助模型时)让模型把你的问法改写成关键词再找一遍,最后让模型**判定哪一条是"强相关"**。
- **强相关**的定义是「会改变答案的那种」,不是「有点关系」。模型只回一个编号,**回 0 = 都不强相关**——弱模型有"老好人"倾向,硬挑一条正是最危险的失败模式,所以这一条被明确允许。
- 同一次改写调用里,模型还会**顺带**判一个「**时间意图**」(`earliest` 最早那次 / `recent` 最近那次 / `both_ends` **两头**、最早与最近都相关而中间次要 / `none` 与时间无关)。选 `both_ends` 时,本地候选会按**两端高、中间低**的形状重排(默认 1.35× / 1.22× / 1.35×)。**这只是排序**:不改变"哪几条有资格被注入",也**不额外多花一次调用**(就是这一次调用多一个字段)。
- **本地已强命中、改写被跳过时**(下一节第一条),意图改由**分页判读**那一次(本来就要发生的调用)**顺带**判出来 —— 提问那一句会和候选清单一起给模型,它回"编号 + 一个意图词"即可。所以"本地字面撞词最多、但可能不是你要的那条"这种点 ✕ 的主场景,**也拿得到时间意图**了(旧版在这里一次都拿不到:改写整段被跳过,而意图挂在改写上)。**判读的调用次数一次都不多**,意图词只是同一条回答里多出来的一个词;判读本身仍然只做一件事,提示词里明说"不要为它改变你挑的那一条"。
3. 结果分两种:
- **有强相关** → 会话里显示「已用 xxx(辅助模型)搜索已压缩的历史:**找到相关内容**」,并给出两个按钮:
- **「打开原文」**:在右侧栏打开一份**逐字摘抄**的 Markdown——只有**强相关的那几段**、只含**提问与回答**(不含工具调用与思考),**纯代码生成、0 token**;
- **「知道了」**:把这一行收起来。
同时这段资料被**排进你下一轮的注入**:你**直接继续提问**(随口说一句也行)即可,主模型会带着它重新回答。
- **都不强相关** → 会话里如实显示「**未搜索到强相关内容**」,**不注入任何东西**,也**不给「打开原文」按钮**(没有相关段落就不产出文件)。
4. **必须说清楚的一点**:插件**不能**让主模型"立刻重答"。它没有往会话里发消息的能力,唯一能做的是把资料**排进你下一轮的上下文**。所以点完 ✕ 之后的效果是——**下一次提问**时模型带着这段历史回答。界面上的那句话(「资料已备好 —— 直接继续提问即可」)就是这个意思。
另外两个细节:
- 本地检索**已经强命中**(粗筛最高分 ≥ 阈值 ×1.5)时,会**跳过**这次查询改写,并如实告诉你「**本地已有强命中,这次省掉了「查询改写」那一步(判读照常做了,翻了几页见上),所以结果有可能不是你要的那条 —— 不合适就再点一次或换个问法。**」。注意省掉的**只是改写**:判读(辅助模型逐页挑"哪一条真的回答了你")**照常发生**——你点 ✕ 往往正是因为觉得本地找的不对,这句话不会替插件说"我觉得没问题"。分数线为什么是 1.5×(2026-10-08 按新分数尺度重标定:高于实测负样本上界 0.329 与擦边正样本上界 0.417)见文末「设计论证」。
- 检索调用**最多等 8 秒**(面板可调)。8 秒是上限不是每次都等满;真超时意味着这一次白点了——超时只写诊断日志,不影响你的正常提问。
## 5. 成本账
**单次口径**(全部是上限,不是配额):
| 项 | 数字 |
| --- | --- |
| 压缩后总览 | ≤ 300 token / 次压缩,可为 0(实测一次:**190 token / 8 行 / 747 字符**) |
| 单轮注入 | ≤ 700 token、≤ 2 条、每条 ≤ 300 字符(每条下限 50 字符)。**700 才真的放得下"两条满额中文块 + 抬头"**(2×≈255 + ≈105);设成 500 时预算会先砍掉第二条 = 实际只注入 1 条 |
| 实测一次注入 | **128 / 167 / 395 / 458 token**(视命中几条、正文多长) |
| 入库扩写 | 每块只送前 **600 字符**、每批 **8 块**、单次**输出上限 240 token**(格式不对整批丢弃,保留原词) |
| 查询改写 | 单次**输出上限 120 token** |
| 每日调用上限 | 默认 **0 = 不限**(想设就填,到上限停用到次日,面板会显示原因) |
**「已删除」的那条闸门**:早期版本有一个**会话累计注入上限**(默认窗口的 2%)。2026-10-07 按实测**删掉**了它——一天只有 19~21 次辅助调用,整场超长会话的插件总开销约 6 万 token,而主模型累积已用 5.6 亿 token(约 0.01%)。而累计闸门唯一稳定的效果是「命中率莫名其妙下降」:**用户不会想到是自己多点了几次 ✕ 把额度花完了**,只会觉得这插件后来不灵了。成本控制改由**单次**口径负责(上面那张表)。
**量级表(用于判断"到底贵不贵")**:
| 场景 | 辅助花费 |
| --- | --- |
| 5 次压缩 + 20 次 ✕(同一会话,估算) | ≈ **3.3 万 token** |
| 同一场会话里主模型的累积用量 | 数亿 token |
| 占比 | ≈ **0.01%** |
> 说明:3.3 万 token 来自 `scripts/measure-savings.mjs` 在**真实会话日志**上重放生产代码路径的实测(该脚本会把每次压缩都过一遍入库管线并模拟扩写调用);"数亿"是主模型侧的累计口径,两者不是同一把尺子的同一段,只用于看数量级。
> 上表的"实测一次注入 / 一次总览"数字来自 `scripts/inspect.mjs` 对同一份真实记忆库(**122 块:摘要 60 + 原文 62,共 116,747 字符**)的试检索输出,可用同一条命令复现。
**不想花这钱**:面板 ⑦ 选「不调用大模型」即可,插件的核心功能(压缩入库、检索、注入)完全不受影响,只是"换个说法问"更容易漏。
## 6. 架构与术语
三个层次(术语第一次出现时的解释):
- **L0 = DSH 原生会话日志**(`$DSH_HOME/sessions/**/session.v4.jsonl.zstd`,多帧 zstd 压缩包)。插件**只读**它,永不写入。
- **L1 = 摘要块**:压缩时 DSH 已经生成好的摘要文本,按 Markdown 小节切开存下来,短、准、结论级。
- **L2 = 对话原文块**:从"被压掉的那段"里整理出来的对话文字(你问的 + 助手答的),**带标题与关键词**,另有读类工具的结果原文。
**为什么 L2 不能直接用 L0 顶替**(这是刻意的冗余,不是重复劳动):
1. **L0 是压缩包 + 机器格式**:提问热路径要现场解多帧 zstd、再解析事件流,太慢——热路径必须以毫秒计。
2. **L0 里噪声是正文的 3.3 倍**:实测工具结果的体量是对话正文的 3.3 倍。直接拿 L0 检索,等于让文件内容、命令输出把真正的对话结论淹没掉。
3. **标题与关键词只存在于 L2**:检索打分靠「标题 + 关键词 + 正文」三种字段加权(标题权重 3、关键词权重 4、正文权重 1)。L0 里没有"这段在讲什么"的标题,也没有"你以后可能怎么问它"的关键词——那两样是入库时现算的。
配套机制(都是为了让"注入"这件事本身不产生额外开销):
- **命中证据门**(2026-10-08 新增,见下面「检索打分口径」):一条候选要命中 **≥ min(4, 提问去重 token 数)** 个**不同**的查询 token 才算候选。中文按字符 bigram 分词,1–2 个日常词("今天 / 比较 / 邮件")在窄领域小库里 IDF 反而高,单靠分数阈值挡不住(实测把阈值降到 0.01 也挡不住)。实测两个真实库:26 条无关问题命中 token 数 ≤2(全部被这道门挡住),相关提问 ≥4(0 误伤)。
- **不做"前几轮兜底"式召回**(2026-10-08 收紧):只有**当前这句本身太短**(去重 token 数 < 4,典型是"那这个呢""继续"这类指代型短问句)才会把"当前 + 前几轮"拼起来再检索一次。不收紧时,前几轮里那几条真实相关提问会把**任何**不相关的问题抬过阈值 —— 实测 12/12 条无关问题各注入 256 字符,命中的是前两轮那条提问。
- **指纹去重**:每块内容有一个 16 位内容指纹(sha1 前 16 位),同一个块永不重复注入;相似度 ≥0.6 的块也不再塞。
- **同话题冷却**:连续两轮话题高度相似(词集 Jaccard ≥0.35)时跳过评分。
- **参考块挂住不放**:命中过的参考块在本次压缩窗口内一直显示。撤掉它会让整份运行上下文快照的文本变化,DSH 会再追加一份(实测 1054 字符 ≈ 252 token)——挂着反而更省。
- **总览在窗口内保持稳定**:同理,文本不变就不会反复追加快照。
## 7. 可选:模型辅助(默认不调用)
**面板 ⑦「使用方式」三选一**:
| 档位 | 含义 |
| --- | --- |
| **不调用大模型**(默认) | 纯本地。提问零延迟、零 token;压缩后仍会注入总览与命中的参考。 |
| **调用主模型** | 用你当前会话的主模型补问法、换关键词;成本随主模型走。 |
| **调用指定模型** | 这两处改用你指定的模型(例如主对话用 pro、这里用更便宜的 flash)。 |
- **只在两个时机调用**:① 压缩入库时给块补关键词;② 你点 ✕ 时改写查询 + 判定强相关。**提问时的检索永远本地**。
- **✕ 的「时间意图」不额外花钱**:它要么是**那一次**改写调用里顺带输出的一个字段(`{"点":…, "关键词":[…], "时间意图":"both_ends"}`),要么(**改写被跳过时**)是分页判读那一次回答里多出来的一个词 —— 两条路都**不新增调用**,**调用次数一次都不多**。优先级:改写给了就用改写给的(它是专门为这句话判的),否则用判读顺带给的;回执里的 `timeIntent.via` 写明来源(`rewrite` / `pageread`)。旧格式(纯数组 / 没有该字段 / 判读只回了编号)照旧兼容:一律当 `none`(= 不重排),**不报错、也不重算**。
- **只有一个模型选择**:`llmMode`(off / main / custom)+ `llmProvider` + `llmModel`。选了「调用指定模型」后,入库与检索**都**用这一对;这一对为空就按「跟随主模型」处理(不会悄悄退回某个旧字段)。`off` 不清空你填过的提供方/型号——那是"上次选了什么"的记忆。
- **不指定、也不继承思考强度**:插件不给模型传思考强度参数,也不继承主对话的设置——想调就到「设置 → 模型」里对该型号设置。**插件里没有这个旋钮**。
- **失败一律静默降级**:提供方没注册、凭据缺失、限流、超时、输出不是合法 JSON,全部只写诊断日志,插件表现与"没这个功能"完全一致。
- **失败冷却**:限流 / 额度类暂停 **10 分钟**,提供方/凭据类暂停 **30 分钟**;按路径(入库 / 检索)分别记,互不牵连。
- **超时**:入库调用默认 8000ms;✕ 路径默认 **8000ms**(原为 4000ms,实测思考型模型 4 秒常常只吐思考、正文一个字都没有,那次点击就白费了)。都是上限,模型正常返回就立刻结束。
- **相同输入缓存**:同一个提问 + 同一个路由的改写结果缓存 **30 天**(最多 200 条)。
- **日调用上限**:默认 **0 = 不限**;填了才启用。
- **调用留痕**:只记录元数据(时间、路径、成败、耗时、估算 token),写在全局数据目录的 `dsh-super-memory.llm.jsonl`——**不含任何对话内容**。
## 8. 隐私与边界声明
本节逐条列出插件实际用到的主机能力与数据边界,全部可在仓库源码中核对。**没有列在这里的能力,插件不会使用。**
### 依赖
- **没有任何第三方 npm 运行时依赖**:`package.json` 里只有 `peerDependencies`,没有 `dependencies` / `devDependencies` / `optionalDependencies`。
- `peerDependencies` 声明的 `@deepseek-ai/*` 都是 **DSH 宿主自身**提供的包,由宿主在运行时注入。
- 通过 `ctx.inject` 拿到的服务:**必需** `tools`、`systemPrompt`、`webServer`;**可选** `llm`(拿不到时模型辅助功能自动消失,其余照常工作)。
### 读什么
- 只**读**会话日志 `$DSH_HOME/sessions/**`(多帧 zstd 逐帧解码):**绝不写入、绝不修改、绝不删除**。
- 只**读**会话标题投影缓存 `$DSH_HOME/storages/session_projcache/sessions/<会话id>.json`(用于在面板里显示与 DSH 侧栏一致的会话标题)。
- 状态查询对会话日志**只做 stat 取大小**,不读内容。
### 写什么
- 记忆本体只写**会话所属工作区**内的记忆目录,默认 `<工作区>\.dsh-compaction-memory\`:
```
<workspace>/.dsh-compaction-memory/
<sessionId>.jsonl 该会话的压缩记忆(一行一条块)
_trash/<时间>_<会话>/ 回收站(blocks.jsonl + manifest.json)
_audit.jsonl 删除 / 还原 / 清空的审计
_pairs.jsonl 未命中诊断的人工配对记录(不参与检索)
_readable/excerpts/*.md 「打开原文」生成的逐字摘抄(只在点 ✕ 时产生)
```
- **全局数据目录**(默认 `$DSH_HOME`,可用环境变量 `DSH_SUPER_MEMORY_HOME` 指到任意目录)里只有这几个小文件:
| 文件 | 内容 |
| --- | --- |
| `dsh-super-memory.settings.json` | 设置(跨工作区生效,含面板登记的已知工作区列表) |
| `dsh-super-memory.diag.jsonl` | 诊断日志(默认上限 4000 行,超了自动压缩) |
| `dsh-super-memory.llm-usage.json` | 今日调用次数与 token 估算 |
| `dsh-super-memory.llm-cache.json` | 查询改写缓存(30 天、最多 200 条) |
| `dsh-super-memory.llm.jsonl` | 模型调用留痕(**只有元数据**) |
- 除此之外不写任何地方。`storeDir` 可配置,但含 `..` 的配置会被拒绝并回落默认值。
- **会在工作区里写 `.gitignore`(仅在你自己点按钮时)**:面板 ④ 检测到「工作区是 git 仓库、而记忆目录还没被忽略」时会给出「帮我加忽略规则」按钮;点它,插件就往**该工作区**的 `.gitignore` 追加一行 `storeDir/`(文件不存在时顺带创建,并写一行说明注释)。**不会**自动改写:不点就一个字节都不写。追加动作会记进 `_audit.jsonl`。
- **读取查询(GET)不产生持久写副作用**(2026-10-08 起):面板打开时发的 `/session`、`/trash`、`/audit` 等只读接口,只会把"当前会话的 cwd"登记进**进程内存**;设置文件里的「已知工作区」名单**只在写操作(删除 / 还原 / 清空 / 改设置 / 加忽略规则)或会话内登记时**才落盘。此前每次 GET 都会改写设置文件,而那份名单只有 40 槽 —— 用户的真实工作区被挤出去之后,面板与 ✕ 会一律报"未知工作区"。现在容量提到 200,并按"最近使用"淘汰。
### 诊断日志里没有提问明文
- `dsh-super-memory.diag.jsonl` 的 `recall` 事件**只记提问的短哈希与字符数**(`queryHash` = sha256 前 8 位十六进制、`queryChars`),**不含提问文本**。自己机器上排障若确实需要明文,可设环境变量 `DSM_DIAG_QUERY_PLAINTEXT=1`(默认关;开了才会额外写 `queryPlain`)。
- **披露历史版本**:0.2.2 及更早的诊断日志里写的是 `queryHead`(提问前 60 字符**明文**)。因为这个文件在 `$DSH_HOME`(跨工作区汇聚成一个文件),等于把用户问过的话另抄了一份到工作区之外,所以现已改为哈希。旧日志文件里**已经写过的那几行明文不会自动消失**,要清就自行删除该文件(删掉只影响排障历史,不影响记忆本体)。
- 会话内「✕」用的「上一个问题」是**内存里的本地记账**(经 `/diagnostics` 回给面板),不落这个日志文件。
### 界面语言
- **面板 UI 目前只有中文**(`locale/zh.json`、`locale/en.json` 里的插件元数据是双语的,但设置面板本身的文案没有做 i18n,`README.en.md` 也说明了这一点)。
### 删什么
- 所有删除路径先经 `assertInside()` 校验——目标必须**严格位于**记忆目录之内(**等于根目录也拒绝**,`..`、绝对路径、符号链接逃逸一律拒绝)。
- 默认走**回收站语义**:条目移入 `_trash/` 可还原,并写审计日志。只有两种情况是永久删除:`trashEnabled` 显式设为 `false` 后的删除,以及面板的「清空回收站 / 彻底删除」——两者都写审计。
### 网络
- **不访问互联网**。代码里没有任何对公网地址的请求(无硬编码外部域名 / URL),`lib/` 不引入 `node:http(s)` / `net` / `dns` / `tls`。
- 面板(浏览器半边)里的 `fetch` **全部指向本机** DSH 的插件接口 `/api/dsh-super-memory/*`,同源同进程,不产生对外流量。
- 唯一"网外"行为是:你在 ⑦ 里主动选了模型档位之后,插件通过 DSH 的 `llm` 服务调用**你自己配置的**模型。
### 命令执行
- 全仓库只有**一处** `spawn`(`lib/routes.js` 的 `revealInFileManager()`),唯一用途是「在系统文件管理器里定位文件」(面板的「浏览」按钮):Windows 用 `explorer.exe /select,`,macOS 用 `open -R`,其它用 `xdg-open`。
- 该 `spawn` **不经过 shell**,不接受任何来自请求的字符串作为可执行文件名或参数;目标路径由服务端自己用记忆目录根 + 会话 id 拼出,越界工作区直接 **403**。
### 凭据
- **不读取、不存储、不转发任何密钥或凭据**。仓库内没有 `.env` 读取、没有 keyring / 凭据存储访问。
- 模型调用一律经由 DSH 的 `llm` 服务,插件侧只填 `provider` + `model` 两个**字符串名字**;真正的鉴权与密钥由 DSH 掌握。
### 失败边界
- 任何失败都**只写诊断日志**,不抛出到宿主、不阻塞对话:检索未命中、模型不可用、输出格式不合要求等全部在内部消化,最坏情况就是不注入参考,回答照常进行。
- 超时、冷却、缓存、日上限四道闸门之外,还有单次输出上限与每批块数上限——**模型多说几句的代价不会失控**(输出超限或格式不对就整批丢弃,保留原有词频,绝不写坏数据)。
### 关于 `locale/*.json` 与"是否需要构建"
- `locale/zh.json`、`locale/en.json` 是**手写源文件**,不是构建产物;随包分发并被 `exports` 的 `./locale/*.json` 导出。
- 本仓库**没有构建步骤**:不存在 build / prepare / prepublish 之类会产出运行产物的脚本。`npm test` 只是自检,**不产出任何运行产物**,也不被运行时依赖。
> 本节写法说明:以上只写能从本仓库代码与 `package.json` 核实的事实。若将来新增了本节未列出的能力,请同步更新本节。
## 9. 安装与使用
**环境**:DSH **0.1.7-rc.2 / 0.2.0-rc.1 及以上**(见 `package.json` 的 `peerDependencies`;已在 0.2.0-rc.2 客户端实测)。
**安装**(三种方式任选):
```
# ① 插件市场:搜 dsh-super-memory 直接装(若你的 DSH 版本带市场)
# ② 从本地目录装(<本机插件目录> 换成你 clone / 解压出来的路径)
plugin_manager action: install_bundle target: <本机插件目录>
# ③ 从 npm 装(已发布时)
plugin_manager action: install_bundle target: dsh-super-memory
```
装完**重启 DSH 客户端**:宿主插件代码在进程里缓存,不重启不生效。**成功标志**:设置里出现「超级记忆」这一节,工具列表里出现 `history_read`。
卸载:`plugin_manager action: remove_bundle target: dsh-super-memory`。
**卸载不会**自动删除工作区里的 `.dsh-compaction-memory` 与全局目录里的那几个文件,要手动清理(位置见第 8 节)。
**面板怎么用**(顶部三句话就够):
1. 它是干什么的:压缩时把被压掉的内容存到你本机,之后只把**相关的那几段**递回给模型;最多占 700 token/轮,**挑不出相关的就一点都不注入**。
2. 要不要额外调用辅助大模型:在 ⑦ 里三选一(**默认不调用**)。调用辅助大模型需要你**先在 DSH 官方模型接口里接入对应模型**;插件不存密钥、不改写你的对话,只读会话日志。
3. 平时不用管它——觉得它忘了本应提到过的历史内容,就点回答下面的 **✕** 让它再找一遍。
面板按"插件实际做的三件事"分板块(①②③),后面是 ④ 被保存的压缩内容、⑤ 回收站、⑥ 诊断与路径、⑦ 模型辅助。所有调参项收在顶部右侧的「**参数设置**」里(默认折叠):普通用户不展开也完全能用,想抠成本 / 命中率的人展开就能逐项调。**所有参数改完即时生效,不需要重启。**
**常见问题**:
- **插件刚重启时点 ✕ 会不会失败?** 一般不会。面板拿不到工作区时,宿主会从**会话日志里的 cwd** 兜底;只有连 cwd 都读不到(比如这个会话还没被插件见过)才会报「未知工作区」——那时随便发一条消息让它见到本会话,再点一次即可。
- **命中率低怎么办?** 三条路,按性价比排序:① 在 ⑦ 里选「调用指定模型」并挂一个便宜模型(命中率提升最明显);② 到「参数设置 → 检索参数」把「命中阈值」从 0.28 调低一点(更省 → 更容易命中,代价是可能注入不太相关的内容);③ 确认「入库」两项都开着(关了就真的没东西可检索)。
- **不想花 token 怎么关?** ⑦ 选「不调用大模型」= 0 模型调用;再想彻底不动上下文,把 ① 的「压缩后注入总览」和 ③ 的「提问时注入记忆」两个开关关掉(入库照旧,已有记忆仍可检索,只是不再注入)。总开关关掉 = 不写入本地、不注入任何内容。
- **记忆在哪儿?** 在**你的项目文件夹里**(`<工作区>\.dsh-compaction-memory\`)。如果那个工作区是 git 仓库,面板 ④ 会检测到并给一个「帮我加忽略规则」按钮——记忆里是对话原文,提交上去就收不回来了。**摘抄**(点 ✕ 的「打开原文」生成的逐字问答)如果早先被误提交过、已被 git 跟踪,删除后面板会提示你去执行 `git rm --cached <摘抄目录>`(`.gitignore` 对已跟踪文件无效,插件只删工作区里的文件,不会替你动 git 索引)。
- **界面语言?** 面板目前**只有中文**(插件标题/描述的元数据是中英双语,但设置面板文案没做 i18n)。
## 10. 开发者:设计与教训
### 这一轮修掉的真缺陷(都配了会变红的断言)
| 缺陷 | 后果 | 修法 |
| --- | --- | --- |
| **路径穿越写入** | 会话 id 来自请求体,`..\..\..` 之类会逃出记忆目录写到任意位置 | 文件名只留安全字符 + 落盘前断言目标仍在 root 之内(双重设防) |
| **✕ 的资料只在第一步可见** | 组装上下文每个 step 都跑一次,清空点写在那里 → 真正作答的那一步看不到它 | 清空点搬到 `turn/end`,且只在"跨轮"时清 |
| **跨会话串味注入** | 记忆库按工作区放,同工作区里躺着多个会话的块 → 模型说出你根本没说过的事 | `/diagnose` 的候选**只取目标会话**的记录,没有 `session` 字段的旧记录一律不匹配 |
| **回填 `NaN` 导致冷启动看不到块** | `const added = backfill(...)` 而内部是 `+= await …` → `added` 恒为 NaN → 索引永不刷新 | 去掉错误的 await 链,写出真实计数并在落盘后失效索引 |
| **`outChars` 恒 0** | 思考型模型只吐 reasoning 不吐正文 → 留痕里输出量永远是 0,"没输出还是没记账"分不清 | 按分片累加真实长度,并把"思考"与"正文"分开记 |
| **毫秒级陈旧判定** | 用 `Date.now()` 判断"结果是不是上一轮的",同一毫秒内发布的新结果被误判成陈旧整块不显示 | 改用**发布序号**(自增),不做时间比较 |
| **Hooks 顺序 / TDZ** | ① 提前 return 之前漏掉 hook → 组件卸载,✕ 按钮凭空消失;② `onClick` 引用了定义在它之后的 `const run` → 点击抛 TDZ 且被静默吞掉,表现是"点了没反应" | 所有 hook 提到提前 return 之前;`run` 保持函数声明(提升),并加源码级断言钉死 |
| **`npm test` 覆盖不到宿主半边** | `lib/host.js` 一度被写成 UTF-16 乱码而测试**全绿**——因为三套脚本只 import 了少数几个模块 | 新增 `scripts/host-smoke.mjs`:每个文件非空 / 无 BOM / 无 NUL + 逐个 `node --check` + 宿主入口与关键模块真的能 import + 导出契约在位 |
| **`apply(ctx)` 从没被真调用过** | 注册项被注释掉、注册到错的键、apply 中途抛错 —— 整块功能静默消失而四套自检全绿 | `host-smoke.mjs` 造一个假 `ctx` 真的 `await apply(fakeCtx)`,逐条断言"注册了 systemPrompt.context 提供者 / 订阅了 session/event / 注册了面板路由 / 注册了 history_read",并用"桩调用计数 > 0"证明真的执行到了;再触发一条真实形状的 `compaction/summary` |
| **`routes.js` 的请求链零执行** | HTTP 方法写错、路径字符串写错、写守卫被摘掉都测不出来 | 用合成 req/res 真打 handler:GET /overview 200、未知路径 404、POST 缺来源头 403、只支持 GET 的路径发 POST 落 catch-all 404、带参 `/session`、`/search` 200 |
| **面板桩丢弃依赖数组与 cleanup** | 桩里 effect 每渲染都跑、cleanup 从不执行 → "effect 依赖写错"在测试里完全不可见;控件绑错键也看不见 | 桩 `useEffect/useMemo/useCallback` 改为逐项 `Object.is` 比较依赖(不变不重跑、变了先 cleanup)、`useRef` 跨渲染复用;新增"控件 ↔ 设置键"绑定断言(8 个控件、种入不同取值) |
| **`load()` 不做上下界校验** | 手改设置文件塞 `maxRawCharsPerCompaction: 1e21`(巨量内存)/ `protectRecentDays: 1e15`(永远删不掉)被照单全收 | 抽出 `INTEGER_BOUNDS` 一张表,`normalizeSettings`(读盘)与 `validatePatch`(面板提交)共用;单元测试断言 1e21/1e15 读回后被夹到合法上界,并逐字段核对"表 = 面板数字框的 max" |
| **诊断日志含提问明文** | `recall` 事件写 `queryHead`(提问前 60 字符明文),而日志在 `$DSH_HOME`(跨工作区汇聚)→ 提问被另抄一份到工作区之外 | 改为 `queryHash`(sha256 前 8 位)+ `queryChars`;`host-smoke` 断言最后一条 recall 带哈希、与提问哈希一致、且整个文件里没有任何 `queryHead` |
| **GET 产生持久写副作用** | 每次 GET 都把会话 cwd 登记进 `knownWorkspaces` **并写盘**,40 槽被挤爆后用户真实工作区一律 403 | GET 只做内存登记(`resolveTarget(..., {persist:false})` 走 `resolveReadTarget`),持久化只在写操作时发生;容量 40→200 + 最近使用优先 |
| **工具结果入库越界(上限形同虚设)** | `toolRecords` 是"先切后拼":`text.slice(0, maxChars)` 之后才拼首行与截断说明 → 落盘长度 = 上限 + 60~70。夹具库实测 22/210 条越界,最大 4068(上限 4000);上限 200 时落盘 284 | 收口到一个纯函数(`clampToolText`/`toolRecordText`):先净化 → 再按**最终长度**切;并在 `rawRecords` 的合并出口**再收口一次**(两条工具出口都覆盖) |
| **自指噪声(插件源码进 L2)** | 读自己源码产生的工具结果正文就是插件代码,"什么词都有"→ 通用吸引子,让任何逐字写在自己源码里的探针提问必然命中 | 入库侧一条**窄规则**:工具参数里的目标路径落在**本插件目录**内就不入 L2(`selfSourcePath`),并在诊断里记 `selfSourceSkipped`;**不**排除用户项目里的代码文件 |
| **面板自检把注释当代码** | `usedKeys` 对整份 client.js 匹配 `effective('键')`,注释里写过的键也算"面板用到的键" → 统计虚高 1 个假阳性(`llmAssistEnabled`),且"注释与代码不一致"永远发现不了 | 扫描前先 `stripComments`(最小词法扫描:保住字符串/模板字面量),键、CSS 类、API 路径、板块编号四处都用剥注释后的源码 |
### 踩坑经验
- **不要用 PowerShell 做大段文本写入**:历史上把 `lib/host.js` 写成过 UTF-16 + NUL 的乱码(`npm test` 还全绿)。改文件一律用精确替换工具,改完 `node --check`。
- **并发编辑会互相覆盖**:同一文件被两个进程同时改写时,后写的整份覆盖前一份。多件事要改同一个文件时**串行做**。
- **桩渲染两大坑**:① `useRef` 必须像真 React 一样**跨渲染复用同一个对象**(每次新建会让"只在首次渲染记一个值"的组件每帧重置,于是测试里的陈旧判定和真机行为不一致);② 桩 `fetch` 要**按 URL 分派**(宿主就是多个接口),全都回同一个 payload 会让跨接口读取永远读到 `undefined`。
- **hook 槽位下标是数组下标**,不是"第几个 useState":种错槽位(比如把 `openLlm` 写成 6)会去改另一个状态,断言看着还在、其实形同虚设。
- **注入块必须稳定**:任何一节上下文变化都会让 DSH 追加**整份**运行上下文快照;"撤掉一个块"也要花钱。
## 11. 为什么插件自管设置(而不是用官方设置表单)
**现状**:本插件的所有参数由**插件自己**存(`$DSH_HOME/dsh-super-memory.settings.json`,见 [config.js](lib/config.js) 的 `SettingsStore`),界面是**自己注册的整页设置板块**([client.js](lib/client.js) 用 `ctx.slots.inject('settings.section', …)`)。官方 DSH 0.2 提供的是另一条路:插件导出 `export const Config = Schema.object({...})`,字段要进表单必须标 `.volatile()`,宿主读 `config.<字段>.get()`,变更走 `ctx.on('loader/volatile-update', …)`,值落在**当前 profile 的 `cordis.patch.yml`**(分 profile、**不分工作区**)。下面是一次**只读探针**的结论,说明为什么**不迁移**。
- **决定性事实:官方 0.2 的共享表单控件只有「文本框」与「密码框」**——**没有开关、没有下拉、没有复选框**(官方页面里那些开关/下拉都是各插件自己手绘的)。
- **下拉选项不能动态给**:schema 要序列化成 JSON 过线,函数会被拒——所以本插件的 provider 下拉(选项来自 `ctx.llm.listProviders()`)**不可能**由官方表单生成。
- **官方表单里放不了自定义控件**(按钮 / 列表 / 「测试连接」这类),要自定义就只能自己注册页面——也就是我们现在用的 `settings.section`(那是标准扩展点,不是绕过)。
- **因此迁移只能省下"存储 + 校验 + 重置 + 通知"这四件事,省不到界面本身**。而代价是实打实的:
- 可能**多出一个入口**(官方表单在插件页,面板在设置里,两边都能改同一件事);
- 整段 config 是**覆盖**而不是深合并,一个键写错可能连带重置别的;
- **忘标 `.volatile()` 就静默失效**——字段能写进文件,却不进表单、也不通知;
- 一条**未核实**的风险:外部插件在解析 `@deepseek-ai/*` 时,`app.asar` 内的 0.2.0-rc.2 与 `profiles\node_modules` 里那份 0.1.7-rc.2 谁生效(见下面的警告)。
- **结论:维持自管设置。** 本插件的所有读取点都盯着 `SettingsStore`,迁移要把它们全部改一遍,而收益只是省掉几百行存储代码——本插件**零老用户**,不值这个风险。
**已采纳的唯一一条**:探针里唯一"便宜且明确更好"的建议已经落地——「恢复默认设置」改为**逐键重置并保留模型档位**(`SettingsStore.reset()` + `RESET_KEEP_FIELDS`),不再删掉整个设置文件。原来的实现是 `unlinkSync(settingsPath)`,用户点一次就把 `llmMode`/`llmProvider`/`llmModel` 一起丢掉(实测踩过)。
> ⚠️ **给未来维护者的警告**:`C:\Users\Administrator\.dsh\profiles\node_modules\@deepseek-ai\*` 下的 junction 指向的是 **0.1.7-rc.2**,而桌面实际运行的是 **0.2.0-rc.2**(在 `app.asar` 内)。**去读那层 junction 会得到过期结论**(例如"官方没有这个能力"其实是"旧版的官方没有")。要判定官方能力,请在 `app.asar` 里读对应版本,或直接解包 `app.asar` 后核对。
## 12. 变更历史
| 版本 | 关键变化 |
| --- | --- |
| **0.1.0** | 三件事落地:压缩时入库(L1 摘要 + L2 只留对话文字)、压缩后注入 ≤300 token 总览、提问时本地检索命中才注入。记忆存在会话所属工作区。 |
| **0.1.1 → 0.1.2** | 修「本轮第一个请求看不到召回」:改从 `agent/inbox/spliced` 事件抓提问(它比运行上下文求值早几毫秒);新增 `stickyRecall`(命中块挂住不放,省一次快照追加)。 |
| **0.1.3 → 0.1.6** | 面板按"三件事"重构成板块;④ 拆出「被保存的压缩内容」+「回收站」,会话标题与 DSH 侧栏一致;全局数据目录可用 `DSH_SUPER_MEMORY_HOME` 搬离系统盘。 |
| **0.2.0** | 审计驱动的安全与正确性修复:路径穿越(连 root 本身也拒绝)、写操作 CSRF 守卫、`sessionLogBytes` 契约、改设置丢工作区名单、`?limit=abc` 传播成 `slice(0,-NaN)`、`history_read` 回落日志加 64MB 闸、`states` 加 LRU。新增 `scripts/unit.mjs`(失败即退出码 1)。 |
| **0.2.1** | 成本口径正式改为「用户选档位 + 单次严格控制」;**删除**思考强度设置键与继承路径(那是 bug:又慢又贵);工作区自动识别;新增宿主冒烟守卫 `host-smoke.mjs`。 |
| **0.2.2** | 辅助成本砍掉一半:**工具结果块不再送去扩写**(它靠文件名/标题就能检索到,而体量是对话正文的 3.3 倍)、每批 5→8 块、扩写输出上限 300→240;本地已强命中时**跳过查询改写**(省一次调用,结果不变);点 ✕ 立刻显示「检索中」、✕ 超时 4s→8s;留痕与用量开始记录 token 估算;修掉陈旧判定的毫秒比较;**删除会话累计上限**(默认日上限改 0 = 不限)。 |
| **0.2.2 之后(未改版本号)** | **① 注入行改为"答优先 / 结论句优先"**:以前那一行是"问题 + 截断的正文",现在先取**回答/结论句**,回答为空才退回问题开头(`lib/recall.js` 的 `itemText`)——用户看到的"想起了什么"从"又念了一遍我的问题"变成"它记得我当时得出的结论"。**② `spawn` 失败不再掀翻宿主**:可执行文件不存在走的是**异步 `error` 事件**,原来没有监听者 → 未捕获异常直接杀进程(面板点一次「浏览」就能把 DSH 打挂);现在唯一的子进程出口立刻挂 `error`、失败只写 `reveal-spawn-error` 诊断。**③ 摘抄跟着删除语义走**:`/delete`、`/trash/purge` 现在会一并删掉 `_readable/excerpts/` 里该会话的逐字摘抄(以前"删干净"是假的,可读原文还躺在磁盘上)。**④ 写路径串行化**:记忆库的 append 与读-改-写用 `withFileLock` 串起来,交错不再丢块。**⑤ `/diagnose` 的模型改写限流**:同一会话 60 秒最多 6 次(`RateLimiter`),挡掉"本机任意进程反复 POST 就等于反复花钱"(web server 本身不鉴权)。**⑥ `outTokensEst`**:留痕与用量按**真实输出**分片累加记账(思考型模型只吐 reasoning 时不再恒为 0)。**⑦ 本轮的接线层自检与隐私/一致性修复**见下一条。 |
| **本轮自检补强 = 接线层自检三件套(0.2.2 之后,未改版本号)** | 第三方只读审查点名三处**结构性盲区**,都补成了"能变红"的断言:**① `host-smoke.mjs` 真的调用 `apply(ctx)`**(以前只 import,从不执行 —— 注册项被注释掉、开关接错键、apply 中途抛错都能四套全绿),断言 `systemPrompt.context` 提供者 / `session/event` 订阅 / `webServer` 路由 / `history_read` 工具逐条在位,并真触发一条 `compaction/summary`;**② 用合成 req/res 真打一遍 `routes.js`**(请求链此前在 `npm test` 里零执行):GET 200 / 未知路径 404 / 写操作缺来源头 403 / 方法分发 / 带参路径 200;**③ 桩 React 改为尊重依赖数组并执行 cleanup**,另加"控件 ↔ 设置键"绑定断言(8 个控件含开关/数字框/文本框/下拉,种入与默认不同的值再逐项比对 —— 这就是抓"开关接到错的键"的守卫)。另外:**`load()` 与面板 API 共用同一张上下界表**(手改设置文件塞 `maxRawCharsPerCompaction: 1e21` 不再被照单全收)、**诊断日志不再写提问明文**(改哈希,见第 8 节)、**GET 不再产生持久写副作用**(已知工作区 40→200,按最近使用淘汰)、`harness.mjs` 临时目录用完即清(异常路径也清)、`titles.js` 读侧路径补 `safeSessionId`、删除摘抄被 git 跟踪时面板追加 `git rm --cached` 提示。 |
| **本轮(体积/自指/标定/面板自检)** | **① 工具结果入库体积收口**:`toolRecords` 的"先切后拼"会让落盘记录 = 上限 + 首行 + 截断说明(实测 4068/上限 4000、上限 200 时 284);现在把"上限"定义成**落盘记录文本长度**,收口在纯函数 `clampToolText`/`toolRecordText`,并在 `rawRecords` 合并出口**再收口一次**(两条工具出口都覆盖)。**② 自指噪声不入 L2**:读本插件自己目录内文件的工具结果不再入库(窄规则,只看工具参数里的目标路径是否落在插件目录内;**不**排除代码文件),跳过条数记进诊断 `selfSourceSkipped`。**③ `strongHitScore` 按新分数尺度重标定**:新归一化把同一份样本抬了 2.04×,用两个真实库 592 块 / 154 条历史提问实测(正样本 72 条 min=0.301 中位=0.430;负样本 82 条 max=0.329;擦边正样本 max=0.417),结论是**保持 1.5×** 并给出两条硬性质(高于负样本上界、高于擦边上界),数据表见文末「设计论证」。**④ `panel-check.mjs` 先剥注释再匹配**:`usedKeys` 不再把注释里的 `effective('键')` 算进去,报告数字从 41 回到真实的 40(假阳性是 `llmAssistEnabled`)。**⑤ 归一化不随查询长度漂移 + 命中证据门**:分母只累加**库里出现过**(`df>0`)的 token 的 IDF,粘一段无关长尾不再把相关提问挤下阈值(同一份样本实测 0.210 → 0.983);分层靠的是证据门(命中 ≥ `min(4, 提问去重 token 数)` 个**不同** token),阈值仍是 0.28。**⑥ 两条探针的数字(本轮实测,来源见第 13 节末尾「设计论证」与第 6 节)**:注入行改成"答 / 结论句优先"后,**问答块的答文本进入注入行的比例 1% → 100%**;反过来,无关问题被误注入从 **12/12 → 0/12**。**⑦ 诊断日志的提问头改哈希**:`recall` 事件从 `queryHead`(前 60 字符明文)改成 `queryHash` + `queryChars`(见第 8 节)。 |
| **0.2.6 · 时间指代词两轮改稿** | **第一轮(三类 ↔ 三形状 + 句级窗口)**:把指代词分成 near/mid/far 三类,各配一种时间形状(`new` 单调偏新 / `bell` 钟形偏新 / `old` 单调偏旧),窗口从"±60 字符"改成**句级**(旧口径在真实库 168 条含指代词提问里有 **17 条(28%)产出 0 窗口**),并确立**"加权只改排序、永不改资格"**(时间系数只重排已过证据门 + `minScore` 的那批;`candidates` 快照在重排之前固定);摘掉 `这就`/`这才`(实测有 2 条真实提问因此被误判成近指代)。**第二轮(本轮)**:**① 删掉"多类冲突取最左"那条固定优先级** —— 用户那句「我们刚刚说的那个**最早**提过的设想」同时命中 near 与 far,固定优先级必然选错一边(真正要的是**两头**)。新口径:**一条消息里命中 ≥2 个不同类 → 判"分不清" → 本次不做任何时间重排**(`shape = null`,与"不含指代词"走同一条路径,**逐位不变**);只有恰好一个类时才重排。**② 时间形状的判定作用域改成"整条消息"**(原先只看第一句含指代词的话 —— 那等于换个地方继续做那条被否决的固定优先级)。**③ ✕ 路径把"时间意图"交给辅助模型**:在那一次改写调用里**顺带**输出 `时间意图`(`earliest`/`recent`/`both_ends`/`none`),**不新增任何调用**;旧输出(纯数组 / 无该字段)一律按 `none`。**④ 新增第 4 种形状 `both_ends`(两端高、中间低)**:`1 - dip × 4p(1-p)`,默认 `dip = 0.2` → 位置 0/0.25/0.5/0.75/1 的系数是 **1.35 / 1.2525 / 1.22 / 1.2525 / 1.35**;新键 `timeBothEndsDip` 进 `DEFAULTS` + `NUMBER_FIELDS_READONLY`(界 `[0,1]`),面板不暴露(与其余时间形状参数同一条口径)。**⑤ 铁律没动**:意图**只影响 ✕ 内部的候选重排**,绝不影响"要不要注入";`found`/`boosting`/资料成员三种意图下逐位一致(有断言)。成本红线(2 条 / 300 字符 / 700 token / `minScore` 语义 / 证据门)与 ✕ 的调用次数一分未变。 |
| **0.2.7 之后(未改版本号)· 加 CI + 修 Linux 下的一条测试 + 记一条边界** | ① **加了 CI**(`.github/workflows/ci.yml`):`ubuntu-latest` / `windows-latest` × Node `22.x` / `24.x`,**没有任何安装步骤**(插件零依赖 ✓ 四套脚本只用 Node 内置模块 ✓)。`host-smoke` 负责回答"**真的装得上、真的调得动**"——它按宿主的方式注册插件,然后**真的执行一次工具处理函数**并断言结果 ✓。② **CI 自己抓出两个错**:**(a)** 我最初把 Node 钉在 20 ✗ —— 而仓库的 `engines` **本来就写着 `node >= 22.15`** ✓(`lib/zstd.js` 要 `node:zlib` 的 `zstdDecompressSync` ✓ Node 20 没有 ✓ 会**在 import 期直接报语法错** ✓);改成 22.x/24.x 后 Windows 两个 Node 版本都通过 ✓ **这也第一次实测证实了那个下限** ✓。**(b)** 接着 **Ubuntu 两个 Node 版本都挂** ✗(Windows 正常 ✓):第 30 节的夹具**写死了 Windows 盘符** ✓ 而 POSIX 上 `E:\...` **不是绝对路径** ✓(`E:` 只是文件名 ✓)→ 被解析进**插件目录内部** ✓ → 被"**别读自己源码**"那条规则**整条丢掉** ✓ → 记录数 0 ✓ → 取 `[0]` 抛 TypeError ✓。夹具改成从插件根用 `path.join` 推导 ✓ **两平台都落在插件目录之外** ✓;**断言一条没放宽** ✓,并把"先判条数再取 `[0]`"补上(失败时给可读的红 ✓ 而不是 TypeError 截断日志 ✓)。修完 **4 个 job 全绿** ✓✓。③ **新记一条已知限制**(第 1 节 5.6):**在 POSIX 上,`cwd` 落在插件目录内部时工具结果会被静默跳过**(Windows 遇不到 ✓ 触发条件很窄 ✓ 唯一痕迹是 `selfSourceSkipped` ✓ **运行时判据未改** ✓ 它对真实路径是对的 ✓)。 |
| **0.2.7 之后(未改版本号)· 清死代码 + 免责声明** | ① **删掉那条"永不执行"的 effect**:`MissAction` 里"新问题到达 → 清结果"依赖 `ready.lastQuery`,而 `ready` 只被一个同样只依赖 `[sessionId]` 的 effect 写一次、全文件无轮询 → 真机从不执行(它的判据函数只在测试桩里被直接调用 = 假绿)。连同只服务于它的两个 ref 与测试钩子一并删除;真正生效的 `noteCurrentTurn`(新轮 `turnTail` 挂载时广播清理)**保留不动**。② 断言随之**换成钉真机制**,**没有**为变绿放宽(过程中还修掉两处"把注释/散文当成代码残留"的假红判据)。③ README(中英)顶部新增两条声明:**由 AI 协助开发、可能存在缺陷**;**主要供学习测试、不保证持续更新**。 |
| **0.2.7 · ✕ 结果跨轮(真根因)+ 「打开原文」+ 新问题到达即关 + 时间意图盲区** | 用户第三次实测同一个回归,**前两次所谓的修好其实只是症状被盖住**。**① ✕ 的结果为什么每轮都跟着(真根因)**:槽位 `conversation.chat.turnTail` 给的 `turn` 是 **`TurnLocation` 对象**(官方 `node.location.turn` → `renderSlot(..., { turn, seq, openFile })`,逐字可证),而代码用 `String(stored.turn) === String(turn)` 比较 —— 两边都变成 `[object Object]`,**任意两轮恒等** → **每一个轮次的实例都认领同一条结果** → 用户看到“从点 ✕ 之后每一轮后面都跟着它”。修法:`turnKeyOf()` 归一化(对象取 `.turn`、数字/字符串原样),**并统一“槽位给对象 / 回执给数字”两把尺子**。**② 兜底分支曾是死代码**:`missResultFor` 第一行原为“拿不到 `turn` 就无条件返回结果”,于是后面的序号兜底**永远不可达**、且每个后挂载的实例都会显示旧结果。现在按序号兜底(早于本实例挂载 → 不显示),轮次对得上时仍以轮次为准。**③ 「打开原文」其实从没打开过文件**:`MissTail` 解构里漏了 `openFile`(用了但没解构)→ 它只能展开机器格式原文。已补解构。**④ 用户要的行为——新问题一到就关掉上一轮的提示**:原先依赖 `ready.lastQuery` 变化的那个 effect 是**死信号**(`ready` 只由同样只依赖 `[sessionId]` 的 effect 写一次、全文件无轮询 → 真机上一次都不会执行,`missActionDecide` 只在测试桩里被调用 = 假绿)。现在接到**真的会触发**的信号上:新轮 `turnTail` 挂载即广播清理,且只认“确实往后走”(防“点完 ✕ 结果立刻消失”)。**⑤ 时间意图不再有盲区**:它原先挂在**改写**那一步,而本地强命中时改写整段被跳过 —— 恰恰是“本地字面撞词最多、可能选错”的点 ✕ 主场景。现在**分页判读**(本来就要发生的调用)顺带判出来:只在**第一页**的用户消息里追加一问,`PAGE_PICK_MAX_TOKENS` 一个字没加;回执 `timeIntent.via` 写明来源。**⑥ 跳过改写时的文案改成说实话**:旧文案会让点 ✕ 的用户以为“插件觉得没问题”,现在说清“省掉的是改写那一步,判读照常做了”。**⑦ 测试桩的假绿机制一并修掉**(这才是同一 bug 复发三次的元凶):桩原先**自己手写组件 props**,不经官方 `renderEntry` 的合并顺序(`{...kit, ...injected, ...slotInjected.props, ...ownerProps}`)→“宿主 props 到不了组件”这类 bug 在桩里**永远绿**。现在桩按官方顺序构造,并**单独断言“透传”本身**。**顺带纠正一个错判**:`inject` 的第二个实参是 `store.actions`(本插件无 store → undefined),**不是宿主 props** —— 宿主 props 由 `renderSlot(key, owner)` 经 `ownerProps` 最后合并,**从未丢失**,所以 `inject: (sessionId) => ({ sessionId })` 本来就是对的(与官方 `message-feedback` 同槽位写法逐字一致)。**⑧ 顺带修好**:已发布的 0.2.6 在真实库上有一条既有红(`(过召回情形)注入量仍必须守单条上限`,召回块 653 字符 > 上限 300 + 头部)—— 现在绿了。 |
| **0.2.5** | **这一轮全是"把该给的信息给全",不是换机制**(换机制的路——本地嵌入、重切块、关键词分级、多路并集——都试过且**实测无收益或负收益**,见文末「设计论证」)。**① 答案的续行终于进得了注入行**:L2 是 `问:…\n答:…`,但只有回答的第一行带 `答:` 前缀,而旧的 `answerTextsOf` **只认带前缀的行** → 续行全落进一个"注入行取不到"的池子里。实测铁证:把每块的续行全删掉,注入行**逐字不变 70/70**;改成一个互补的 `qnaSegmentsOf` 之后,注入内容 **+58.7%(真实库)/ +180.9%(一问一答库)**,最差的块从 30 字符补到 300。**② 纯数字与短标识符不再被丢掉**:旧正则要求首字母,`252`、`0.28`、`stickyRecall`、`maxCharsPerItem` 这些**一个都检索不到**(三个库 × 12 种门配置实测 0 候选)——而本库最密的锚点恰恰是数字。现在数字/小数/标识符(kebab、snake、camel 会额外拆出结构片段,整串保留)都能匹配,唯一 token +3.4%~6.8%,单次检索 +0.04 ms。**③ 长消息不再被自己摊薄**:分母改成只累加 **IDF 最高的 12 个**库内词(标定:不封顶 13 条真实 miss 修 0 条;K=8 全修但长无关误报 4/16→5/16 过冲;**K=12 全修且误报一分不涨**)。**④ 一条消息里的多个话题不再互相挤掉**:改用**分段检索**(每段各自算分母)再按指纹合并——"注入上限是多少 + 按钮不见了"这类消息过去只能命中一个话题,现在两个都进候选。**⑤ "最早/第一次/上次"这类时间指代词会被识别并加权**(±60 字符窗口当额外一路,1.35×;同分时优先较早的块——**但只在出现这类词时**,不出现时逐位不变)。**⑥ ✕ 时辅助模型先定位"最关键的问题点"再提炼关键词**:1000 字长问题的关键词跨度从 **979 字符压到 9**,不相关的那半段一个词都没进。**⑦ 切句不再吞掉真问句**:`…问题:我们最早聊控制面板的时候是怎么定的?你可以尝试…` 以前会把问句粘在引导语后面又被"过短合并"吞掉;现在**含问号的片段独立成段**、冒号/顿号/分号是引导语边界。**⑧ 三条真实用户问题固化成永久回归用例**("第一次聊到控制面板"、"我最早对设置面板要求的原话"、反例"晚饭吃什么"必须 0 注入)。 |
### 已取消的功能(以及为什么)
| 曾计划 / 曾存在 | 现状 | 理由 |
| --- | --- | --- |
| **每次压缩生成一份"整会话可读 MD"** | **已取消**(连 0 token 也不做) | 会话窗口本来就能翻到历史——真正的问题是**模型记不住**,不是你看不到。为此多写一份文件不解决任何问题。保留下来的只有「点 ✕ 时的逐字摘抄」(短、按需、0 token)。 |
| **候选人工挑选界面** | **已取消** | 让用户从 10 条候选里挑一条,等于把检索算法的活派给用户;而且他挑的时候也未必知道哪条"会改变答案"。改成让辅助模型判强相关,判不出来就如实说没有。 |
| **「未命中诊断」界面** | **界面已取消** | "内容没进库 / 在库里没排上来 / 还在被压掉的原文里"是**我们的**排障信息(诊断日志里本来就有);用户真正的问题是"这段到底聊没聊过",而他点 ✕ 时辅助模型会直接回答这个问题。宿主侧的 `/diagnose` 路由保留给 ✕ 复用。 |
| **会话累计注入上限**(`sessionBudgetRatio`) | **已删除** | 见第 5 节:它唯一的稳定效果是"命中率莫名下降",因为用户不会把自己点 ✕ 的消耗归因到额度上。成本改由单次口径负责。 |
| **思考强度设置键**(`llmReasoningEffort`) | **已删除** | 插件不需要这个概念,也不该继承主对话的(继承会又慢又贵,实测确认是 bug)。要调就去 DSH 官方「设置 → 模型」。 |
| **入库用 / 检索用两套模型字段**(`llmIngestProvider`、`llmIngestModel`、`llmRecallProvider`、`llmRecallModel`) | **已删除** | 收敛为**唯一的一套**:`llmMode`(off / main / custom)+ `llmProvider` + `llmModel`——入库扩写与检索改写/重判用的是同一对(面板里只有一个「使用方式」和一对下拉)。旧设置文件里若还有这四个键,`load()` 会**一次性**把值搬进 `llmProvider`/`llmModel`,`save()` 再把旧键从盘上删掉(本插件尚无老用户,不保留任何其他兼容分支)。 |
**未实现 / 不打算做**:跨会话记忆;本地向量检索;把注入内容写回会话(平台没有 append 能力)。
## 13. 自检脚本
需要 **Node ≥ 22.15**(原始会话日志是多帧 zstd,用到 `zstdDecompressSync`;低版本会直接报"不支持 zstd")。DSH 自带的 node 在 `<DSH 安装目录>/resources/runtime/primary-runtime/dependencies/node/bin/node.exe`。
> ⚠️ **这些脚本只存在于源码仓库,不随 npm 包发布。** 发布包的 `files` 白名单只有
> `lib` 与 `locale`(运行时要用的东西),刻意**不含** `scripts/` —— 所以从 npm 安装的包
> 里跑 `npm test` 会报"找不到 scripts/unit.mjs",那是预期行为,不是包坏了:
> 自检脚本依赖仓库里的源码与测试夹具,只在 `git clone` 之后可用。
> (要自检请克隆仓库;不要把 `scripts` 加回 `files` —— 那会把测试夹具与开发脚本一起
> 塞进每个用户的 node_modules。)
`npm test` **会真的报错**(退出码非 0),它跑四套:① 单元测试(纯函数 + 历次真实 bug 的回归)、② 面板静态自检(设置键 / CSS 类名 / API 路径 / 板块齐全 / 数字框精度)、③ 面板渲染冒烟(用桩 React 真正渲染面板整棵树,并核对"控件 ↔ 设置键"的绑定)、④ **宿主半边冒烟**(每个文件非空无 BOM 无 NUL、逐个 `node --check`、宿主入口真能加载、导出契约在位,**并真的调用一次 `apply(ctx)` 与合成 req/res 打一遍面板请求链**)。
`scripts/harness.mjs` 与 `scripts/selftest.mjs` 的退出码同一口径:**自检失败 = 1**,**缺参数 / 日志路径读不到 = 2**(并打印用法,不是 TypeError 栈);不适用的段落打印 `SKIP + 原因`,**不算失败**。两个脚本建的临时目录都用完即清(异常路径也清)。
其余脚本用真实会话日志做端到端验证:
> **测试节奏(2026-10-12 约定,避免白跑最慢那套)**:改一处只跑**最快且相关**的
> `node scripts/unit.mjs`(纯函数、几秒);改到宿主 / 面板 / 路由再补跑**直接相关**的那一套
> (`host-smoke.mjs` 或 `panel-check.mjs` / `panel-render.mjs`);**只在交付前**才跑全量六套
> (含最慢的 `harness.mjs` —— 它要解 11MB 真实日志 + 读真库,且**只读**真库)。
```bash
# 0) 四套自检(不需要会话日志;`npm test` 就是这四条)
node scripts/unit.mjs
node scripts/panel-check.mjs
node scripts/panel-render.mjs
node scripts/host-smoke.mjs
# 1) 纯离线:真实日志跑通「压缩入库 → 检索 → 总览 → 注入样例」+ minScore 标定表
# **有真断言**(入库块数/L1+L2 都在/总览在预算内/命中样例能注入且不超单轮上限/
# 注入行带答案正文/无关提问 0 token),失败退出码 1;日志里没有压缩事件则整段 SKIP。
node scripts/selftest.mjs <session.v4.jsonl.zstd>
# 2) 宿主半边联调:假 cordis ctx + 真实日志,跑通入库/总览/命中/未命中/开关/面板 API/history_read
node scripts/harness.mjs <session.v4.jsonl.zstd> [临时工作区]
# 3) 验收证据导出:库条目统计、总览预览、试检索命中、最近打分日志
node scripts/inspect.mjs "<工作区>" "<一个旧话题>"
# 4) 省钱实测 + 阈值标定:扩写调用 / 输入 token 的 A/B,以及「强命中跳过改写」门槛
node scripts/measure-savings.mjs <session.v4.jsonl.zstd> [--json]
# 5) 时序排障:压缩事件、投影里还剩哪些用户消息、每份运行上下文快照的字符/token
node scripts/compactions.mjs <sessionId> [fromSeq toSeq | --snap | --raw <seq>]
# 6) L2 抽取完整度审核:日志里"应当收进来的文字"vs 插件实际入库字符数
node scripts/shadow-audit.mjs <sessionId> <fromSeq> <toSeq> [插件记账的rawChars]
```
会话日志位置:`$DSH_HOME/sessions/**/session.v4.jsonl.zstd`(多帧 zstd,脚本里按帧解码)。
### 设计论证(不写进界面,只留在这里)
#### 检索打分口径与阈值标定(2026-10-08,两个真实库)
**旧口径的缺陷(这是"长提问被系统性惩罚"的根因)**:分数 = `raw / ideal`,而 `ideal` 是查询里
**全部** token 的 IDF 之和 —— 库里**根本没出现过**的词照样进分母,而且它们 IDF 最高
(`df=0` 时 `idf≈log(1+2n)`)。于是"提问里有多少库里没见过的词"直接决定分数高低。
实测(真实库 122 块,同一条相关提问后面粘一段无关长尾):
| 同一条相关提问 | 原样(15 字符) | +1 段无关长尾(67 字符) | +3 段(169 字符) |
|---|---|---|---|
| 旧口径 `raw/全部 IDF` | 1.479(命中) | **0.210(掉出 0.28,漏检)** | 0.206(漏检) |
| 新口径 `raw/可匹配 IDF` | 1.479 | **0.983(仍命中)** | 0.983 |
**新口径**:分母只累加**在这个库里出现过(`df>0`)**的查询 token 的 IDF("可匹配 IDF 质量")。
同一查询内分母是常数,所以**文档排序完全不变**,变的只有"分数与阈值比较"这一层。
**但只改归一化不够(这是本次标定最重要的结论)**:同一份样本上把候选归一化换成
"可匹配 IDF 质量"后,正样本整体上移、负样本也整体上移,**两者仍然重叠**,没有任何单一阈值能分开:
| 库 / 口径 | 正样本 top-1(min / 中位 / max) | 正样本 ≥0.28 | 负样本 top-1(min / 中位 / max) | 负样本 ≥0.28 |
|---|---|---|---|---|
| 真实库 122 块 · 旧口径 | 0.059 / 0.123 / 1.479 | 3/8 | 0.000 / 0.074 / 0.255 | 0/26 |
| 真实库 122 块 · 新口径+证据门 | 0.151 / 0.278 / 1.479 | 4/8 | 0.000 / 0.000 / 0.000 | 0/26 |
| harness 库 350 块 · 旧口径 | 0.064 / 0.330 / 1.549 | 8/16 | 0.000 / 0.081 / 0.239 | 0/24 |
| harness 库 350 块 · 新口径+证据门 | 0.145 / 0.564 / 1.549 | 10/16 | 0.000 / 0.000 / 0.000 | 0/24 |
(正样本:从诊断日志 `hit:true` 事件用 `queryHash`/`queryHead` 反查会话日志还原明文的真实提问,
加上 harness 夹具里的历史提问,再加 8 条按库内容改写的同主题问句;负样本:24 条与本库无关的日常问题
+ harness 原有那 2 条。逐条明细与脚本见交接报告。)
**所以分开两类的是"命中证据门",不是阈值**:命中 **≥ min(4, 提问去重 token 数)** 个**不同**的查询
token。实测 26 条无关问题的命中 token 数 **≤2**(1 个 token 的占多数),而 16 条相关提问 **≥4**
(中位 12)—— 这是两个库上**唯一**能把两类分开的特征。
**`minScore` 因此保持 0.28**:新口径下它表示"库能提供的那部分被覆盖了多少",同一份样本上
把相关提问的召回从 3/8 提到 4/8(harness 库 8/16 → 10/16),而无关问题仍然是 0 条
(由证据门负责)。数据**不支持**再挪这个数:任何单一阈值都分不开两类(上表),
把阈值降到 0.01 也不会多注入一条无关问题(挡住它们的是证据门)。
**两条历史"误注入"探针的真实原因(不是阈值)**:harness 里长期报 ✗ 的
"明天北京天气预报怎么样 / 帮我写一首关于春天的五言绝句",在 **harness 库**上命中的是
`工具 read:scripts/harness.mjs` 这块 4 万字符的工具结果 —— **那两条探针字符串逐字写在本文件里**,
夹具会话读过本文件、工具结果已入库,所以"用户问的那句话逐字躺在一篇已入库文档里",
任何词法检索都必然命中(实测命中 8 / 13 个 token,分数 0.89)。在**真实库**(没有把本文件读进库)
上,同样两条探针是 0.000 / 0.073,**不注入**。这条诊断现在被钉成断言:harness ③ 段会断言
"库里确实有一条逐字包含它的记录",哪天入库口径变了,这条断言会红,提醒把它改回"必须为空"。
- **`minScore` 为什么是 0.28**:见上面「检索打分口径与阈值标定」。旧标定(真实会话 3.8 MB /
5388 事件 / 2 次压缩)的结论"相关 0.72–1.50 / 不相关 0.00–0.14 分得很开"只在**那一份**样本上成立;
2026-10-08 在两个真实库上重做后,结论是"阈值分不开、证据门才分得开",0.28 保持不变作为
可匹配 IDF 质量的操作点。跑几天后可以看诊断日志里 `hit:false reason=below-threshold` 的
`topScore` 分布,漏召多就往下调。
- **「强命中跳过改写」为什么是 1.5×(2026-10-08 按新分数尺度重标定)**:上一轮把归一化从"查询全部 token 的 IDF"改成"库内可匹配的 IDF 质量",同一份样本上分数整体上移(实测中位抬升 **2.04×**:正样本中位 0.211 → 0.430),所以旧尺度上的 1.5× 必须用新数据复核。实测口径与结果(两个真实库合计 592 块;154 条真实历史提问,逐字躺在库里的自污染提问已剔除;脚本:把 `lib/retrieval.js`+`lib/zstd.js` 直接 import 到 `%TEMP%` 下的标定脚本,只读日志与记忆库):
| 样本 | 条数 | top-1 min | p25 | 中位 | p75 | max |
|---|---|---|---|---|---|---|
| 正样本(本地确实可注入,`retrieveTwoTier.tier !== 'none'`) | 72 | 0.301 | 0.373 | 0.430 | 0.507 | 1.763 |
| 负样本(本地不可注入) | 82 | 0.000 | 0.217 | 0.254 | 0.290 | **0.329** |
| 擦边正样本(刚过 `minScore`) | 34 | 0.301 | — | — | — | **0.417** |
| 分数线 | 跳过改写 | 正样本被跳过 | 负样本被误判 | 证据不足(matched<4)却被跳过 |
|---|---|---|---|---|
| 1.5× = 0.420 | 38/154(25%) | 38/72(53%) | **0/82** | **0** |
| 2× = 0.560 | 15/154(10%) | 15/72(21%) | 0/82 | 0 |
| 3× = 0.840 | 6/154(4%) | 6/72 | 0/82 | 0 |
**结论:保持 1.5×**,依据换成新数据下的两条硬性质 —— ① 它高于负样本上界(0.42 > 0.329):
**没有一条"本来找不到"的提问会被跳过改写**;② 它高于全部擦边正样本(0.42 > 0.417):
**擦边命中不会被当成强命中**。取 2×/3× 只是少跳过几条"本来就找得到"的(多花改写钱),
换不来任何安全性,所以不动。口径仍是 `max(0, minScore, minScore × 1.5)`:即使有人把阈值
设成 0 或负数,也不会出现"任何候选都算强命中"(那会让改写永远不触发)。
`scripts/unit.mjs` §11 把上面两个上界(0.329 / 0.417)钉成常量断言:谁把倍率调到 ≤1.17
就会红,提醒他重做标定。
### 工具结果入库的两条硬规则(2026-10-08)
- **每条工具结果记录 ≤ `toolResultMaxChars`(默认 4000)**,**落盘记录文本**(含 `【工具 … 的结果 · …】`
首行与"已截断"说明)的长度上限。修复前是"先切后拼":`text.slice(0, maxChars)` + 首行 + 截断说明,
实测落盘 4068(上限 4000);上限为 200 时落盘 284。现在收口在一个纯函数
(`lib/ingest.js` 的 `toolRecordText` / `clampToolText`),并在 `rawRecords` 的**合并出口再收口一次**
(那条出口是唯一绕不过去的入库点;只有"工具结果、没有对话轮"的出口走同一条收口)。
上限被夹在 200–20000(见 `INTEGER_BOUNDS`),面板上那个数字框就是这条硬上限。
- **读本插件自己目录内文件的工具结果不入 L2**(窄规则,`selfSourcePath`):判据只看工具参数里的
目标路径(`file_path`/`filePath`/`path`,相对路径按会话 cwd 解析)是否落在插件目录内。
为什么需要它:这类块的正文就是插件源码,对"回忆历史决策"没有价值,却因为"什么词都有"变成
**通用吸引子**(实测它让任何逐字写在自己源码里的探针提问必然命中)。**不**排除代码文件本身 ——
用户项目里的 `.ts/.js` 照常入库。被跳过的条数记在诊断的 `selfSourceSkipped`(不是静默的)。
- **已存在的历史超大记录**:插件**不会**自动改写或删除用户记忆库。两种取舍留给你决定 ——
① **保留不动**(默认、零风险):它只影响检索噪声与 `avgdl`,不会让插件出错;想局部清理就用手动删除
(面板单条删除 / `/delete`,进回收站可还原)。② **下次压缩时自动瘦身**:可以做,但必须在"用户没有
主动要求"的前提下动他的数据,风险与收益不对等,本轮**不做**。
(本轮盘点:当前工作区真实库 `.dsh-compaction-memory/` 里 122 条记录**全是 summary/raw 对话块**,
没有任何 `src=tool` 记录,最大记录 1500 字符;夹具自检库里有 210 条工具记录,修复前 22 条越界、
最大 4068,修复后不再产生越界记录。)
## License
MIT
Install
dsh plugin --profile web add github:kkwings/dsh-super-memory#b10764487b5923ea55dc704c26f3701d4535bf0a
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-super-memory from the hub