Skip to content
dsh.fish
Bundle

dsh-learn-wiki

边做边学知识库:CRAG 式纠错检索(L1 Markdown wiki + L2 Hindsight)→ 未命中后台经 L3 限流联网补料 → 蒸馏落 staged → 两段式 commit 升入 L1。

Source
Dayi-Z
stars
1 stars
License
MIT
Updated
Updated yesterday

Readme

# dsh-learn-wiki

DSH 的「边做边学」知识库插件:**工作前自动检索 → 卡住时后台限流联网补料 → 蒸馏落暂存 → 两段式 commit 升入知识库 → 下次自动命中**。

它把 CRAG(Corrective RAG)的纠错回路从"单次问答"搬到 agent 循环上:知识库检索不到就自己去学,学到的东西沉淀下来,下次就不用再学。

## 它解决什么问题

绝大多数"记忆插件"是**静态管道**——检索不到就检索不到,不会去学。结果就是同一个知识缺口在项目里反复踩,每次都要重新上网查。

本插件的增量只有一个,但是关键的那个:**"反复撞同一堵墙"是一个可观测的信号,会触发补料并沉淀。**

> ★ 触发器是**挣扎**,不是"检索未命中"。原设计确实用未命中,但实测那个信号太廉价
> ——任何新话题都会未命中,于是系统为聊到的每件新鲜事都去联网。未命中仍可用
> (`gapTrigger: 'miss' | 'both'`),只是**默认关**。

## 三层知识架构

| 层 | 载体 | 角色 | 谁写 |
|---|---|---|---|
| **L1** | 独立 Markdown 仓库 | 精选知识页,强命中直接注入 | 本插件蒸馏 + 人工编辑 |
| **L2** | Hindsight | 原始情景记忆,语义召回 | Hindsight(由既有 hindsight 插件负责) |
| **L3** | 网络 | 兜底补料,限流 | 本插件经 `ctx.web` |

> **不重复实现 L2。** 既有 hindsight 插件已经负责每轮情景记忆召回;本插件只管 L1、未命中判定和 L3 补料,避免两套注入互相打架。

## 三条设计铁律

1. **自动的不阻塞,阻塞的必须显式。**
   自动注入走 `agent/pre-step`(每轮第一步);自动补料走 `turn/end` 之后的后台 worker;
   真正"现在就要这个事实"时由模型显式调 `wiki_recall` —— 那一次阻塞天经地义。
   中途阻塞 5–30 秒联网会拖垮轮次、打断工具链,而同一个缺口通常后面几轮还会出现——
   所以"后台学、下轮用"才是"边做边学"的正确形态。

2. **`staged/` 永不参与召回。**
   自动产出必须经 `commit` 才升入 L1。投毒面被限制在暂存区。

3. **无 `sources` 不 commit。**
   每条知识必须可溯源到 URL / 文件。`commitReadiness()` 强制这条。

## 安装

```powershell
# 从 GitHub 安装
dsh plugin --profile web add github:Dayi-Z/dsh-learn-wiki

# 本地开发(直接链接到源码目录)
dsh plugin --profile web add link:D:/Harness/dsh-learn-wiki

# 重启 DSH 生效
```

**零构建。** 插件是纯 JS:host 半是 ESM,client 半是手写 CJS(由 `window.__ModuleLoader__` 装载)。
没有打包步骤,所以从源码安装不需要 `allowBuilds` 授权,也不需要预先构建产物。

依赖只声明为 `peerDependencies`(`@deepseek-ai/dsh-tools` / `dsh-llm`)——
它们由宿主提供,**不要**装第二份:同一进程里存在两份实现会让 `import` 解析到插件自带的那份。

## 配置

配置来源(后者覆盖前者):代码内默认值 → `<wikiRoot>/wiki.config.json` → `apply(ctx, config)` 第二参数。

刻意不使用 Cordis 的 Config schema:rc 阶段要能容忍未知键,严格 schema 会把用户多写的一个键变成加载失败。

关键项(完整列表见 `lib/config.js`):

| 键 | 默认 | 说明 |
|---|---|---|
| `wikiRoot` | `D:\\Harness\\dsh-wiki` | L1 仓库根目录 |
| `autoContext` | `true` | 旋钮 A:每轮第一步自动注入 |
| `autoAcquire` | `true` | 旋钮 B2:后台补料(非阻塞)。**触发条件由 `gapTrigger` 决定** |
| `gapTrigger` | `'struggle'` | 什么触发补料:`struggle`(默认)\| `miss` \| `both` \| `off` |
| `gapTriggerSignals` | `['repeat-failure','recurring-error']` | 哪些挣扎信号适合联网(只认带**可搜错误文本**的那些) |
| `hitThreshold` | `0.20` | score ≥ 此值 → hit(注入正文) |
| `weakThreshold` | `0.13` | score ≥ 此值 → weak(只注入标题索引);低于 → miss |
| `injectOncePerSession` | `true` | 内容不变则只注入一次(KV cache 友好) |
| `maxAcquisitionsPerRun` | `2` | 单次后台补料最多处理的缺口数 |
| `webMaxResults` | `5` | 每次联网取多少条结果 |

## 工具

| 工具 | 作用 |
|---|---|
| `wiki_recall` | 显式检索 L1,返回三分桶判定与页面正文 |
| `wiki_learn` | 显式沉淀一条知识(默认落 staged) |
| `wiki_harvest` | **从一段对话里提炼**(当前会话,或带 `session` 提历史会话;默认落 staged,无 commit 参数) |
| `wiki_sessions` | **读历史会话**:`list` / `brief` 接手简报 / `walls` 跨会话反复撞的墙 / `show` 单个会话详情 |
| `wiki_review` | 查看 staged 暂存队列与 gaps 缺口队列 |
| `wiki_commit` | staged → pages(唯一升入 L1 的闸门) |

### 历史会话:这个仓库最被低估的资产

本地躺着几十个会话、完整的对话与工具调用记录。在此之前**没有任何路径读它**——
预注入、挣扎检测、`wiki_harvest` 全都只看得到"现在"。于是"上次我是怎么解决的"
这个问题在整个系统里没有位置,只能靠人去记。

`wiki_sessions` 补上这一块。它的 `brief` 是**接手简报**:

```
node -e "..."   # 或直接让 agent 调 wiki_sessions { action: "brief" }
```

实测输出(本仓库真实数据):

```
=== 跨会话反复撞的墙(前 8)===
  [8 会话 / 46 次]  Error: edit requires reading "<path>" first — read the file, then retry
  [5 会话 / 18 次]  Error: old_string was not found in "<path>"
  [4 会话 / 20 次]  Error: platform github unavailable (tried: github): GitHub returned no results
  [2 会话 / 18 次]  Error: invalid arguments: missing required property "file_path"
```

**"这个项目反复卡在哪"以前没有任何地方看得见,现在一句话就出来了。**

它怎么工作(`lib/session-store.js` + `lib/session-digest.js` + `lib/session-index.js`):

- **会话文件是多帧 zstd 拼接**(每次追加写一帧)。★ `zlib.createZstdDecompress()`
  **不能**用——它和 gzip 不一样,遇到第二帧就停(实测只解出第一帧的 170 字符)。
  必须自己按魔数 `28 B5 2F FD` 切帧,逐个解。
- **列会话只读第一帧**(头部里有 id / cwd / 创建时间 / 委托深度)。全量解码一个会话
  实测要解 11M 字符,列表动作用不起。
- **索引带缓存与预算**:摘要按文件 `(size, mtime)` 缓存进 `<wikiRoot>/.index/sessions.json`,
  每次调用最多新摘 8 个,**没摘完的如实报在 `notYetIndexed` 里**——假装全都索引了,
  等于让人以为历史就这么多。
- **按会话头里的 `cwd` 字段过滤,不靠目录名反推**(目录名是被 mangle 过的)。
  而且**过滤成空时会退回全部并说明**——"查不到"和"没有"必须分得开
  (见知识页 `scoped-registry-empty-result`:这个项目已经栽过一次)。

#### 摘要有三条判据,每条都有实测依据

| 判据 | 为什么 |
|---|---|
| 外层 `run_code` 的失败是内层派发的**复述**,不重复计数 | 实测 124 次内层失败对应 122 次外层失败,几乎 1:1。两个都记 = 同一堵墙数两次,阈值全部失真。关联是精确的:`dispatch.rootCallId === tool/call.callId` |
| 退出码标记只对 **shell 工具**算数 | 759 条带非零退出标记的结果里 51 条来自非 shell 工具——最多的是 `job_output`(35 条,它返回的就是另一个进程的 stdout)。那些是回显,不是自己失败 |
| 没有**可描述症状**的失败不成"墙",但计入 `weakFailures` | 一个命令以非零退出、输出却全是 `PASS` 行时,"指纹"就是那堆无关日志(真实出现过的例子:指纹是 `ui: /learn-wiki 已注册 … PASS`)。既搜不出来也无从推理。**但不能静默丢**,所以计数照记 |

> 与 Hermes 生态的 `headroom learn` 同形:它挖历史会话的失败模式并与"最终成功的那次修正"
> 做关联,写回 agent 原生的记忆文件。**而它内置的 adapter 只有 ClaudeCode / Codex / Gemini
> —— 没有 DSH。** 这一块就是补那个缺口。

### 学习触发器

| 触发器 | 回答的问题 | 默认 |
|---|---|---|
| **挣扎**(连续失败 / 撞同一堵墙 / **改了还是不通**) | 我卡住了 | **开**(`gapTrigger: 'struggle'`) |
| 检索未命中 | 我不知道 | **关**(需显式设 `'miss'` 或 `'both'`) |
| 被纠正(用户说"不对") | 你说的不对 | 开,但**不联网** —— 答案来自用户,只记证据与日志 |
| **`wiki_harvest`** | **我们刚刚想清楚了一件事** | **显式** |

★ 默认是**挣扎**而不是未命中:未命中太廉价(任何新话题都会触发),
挣扎稀有、昂贵、且必须当场兑现。这个取舍有实测依据,见下面的两节。

#### 挣扎检测修过两次,两次都是"信号在撒谎"

**第一次:失败根本看不见。** 判"这次失败了"原来只看 `result.isError`,而那是
**harness 层**的语义(工具没找到、参数非法)。实测 15,868 条真实工具结果:

| | 条数 | 说明 |
|---|---|---|
| `isError: true` | 1,228 | **全部**是 `unknown tool "X"` 这类接口误用 |
| 正文带 `[exit code: N]`(N≠0) | 588 | **没有一条**置了 `isError` |

也就是说"改了 → 跑检查 → 失败 → 再改"这条**最典型的死胡同**对检测器完全不可见;
两个号称零误报的低噪信号只在模型用错工具接口时响。现在 `looksLikeFailure()`
把两类都算上,判据刻意保守(只认证据不认语气:正文里出现 "Error:" 但退出码为 0
**不算**失败)。

**第二次:edit-churn 把"努力"当成了"卡住"。** 43 条挣扎记录里 35 条是它,
全部来自正常迭代(同一个 `client.js` 改了十几次、每次都跑通);它登记的两条 gap,
一条被蒸馏器拒绝、一条沉淀出**关于另一个撞名项目**的内容。阈值从 4 提到 8 没用——
病不在阈值上:**"反复修改"是努力,"反复修改并且撞墙"才是死胡同**。
现在 `struggleEditChurnNeedsFailure`(默认 true)要求 churn 与失败共现,
信号里带上 `failCount` 便于事后核账;设成 false 可退回旧行为。

连带修掉一处**查询撒谎**:`symptomQuery()` 原来对 edit-churn 硬编码
"反复修改 X **仍不成功** 常见原因"——一个只是"改了很多次"的信号,跑到搜索引擎
那里声称自己"不成功"。现在只有真的有失败证据时才这么说。

前两个都是关于"我们自己失败"的信号,**发现不了第三种,而它往往最值钱**。

实测(2026-09-11):一次会话里用户亲口说出的设计规则("位置表达状态是个陷阱")
一条都没有被沉淀 —— 因为它不经过任何一个自动触发器。而同一次会话自动闭环
产出的,是一页关于**另一个撞名项目**的内容。**信号选错,产出就是反的。**

`wiki_harvest` 补的就是这个入口。它读**本会话已经发生的内容**(只要人说的话与
助手的 text 段,不要注入块、不要 reasoning 草稿),提炼后落 staged,仍需人工固化。

> 与 Hermes Agent 的 `/learn` 同形:它也能拿"刚走完的这段对话"当输入
> (`/learn how I just deployed the staging server`)。一个有意的差异是我们学的是
> **知识页**(事实 + 教训),不是**技能**(可复用过程)—— 后者要引入一个新物种,
> 召回、证据账、界面全都要分叉,不在本期。

## 阈值标定(重要)

打分依赖语料规模,**阈值必须随语料重新标定**,不能跨语料复用:

```powershell
node scripts/calibrate.mjs
```

它会用一组标注查询实测分数分布并给出建议阈值。当前默认值来自 7 正例 / 5 负例的实测:
正例 0.152–0.541,负例 0.000–0.105。

### ★ 对**真实语料**标定(合成语料不够用)

```powershell
node scripts/calibrate-real.mjs
```

`calibrate.mjs` 用的是 **7 页写死的合成语料**。它标出来的东西在真实语料上会失效——
这不是猜测,是实测:那条「已知缺陷」负例在合成语料里得 **0.0000**,在真实语料里
跨过 `hitThreshold` 被判成 **hit**,把整页正文注进了提示词。

`calibrate-real.mjs` 跑在真实的 `D:\Harness\dsh-wiki` 上,用 13 条正例
(每条都指定期望命中的页)与 5 条负例,输出分数分布与两者之间的 **Gap**。

### 语料长大之后暴露的真问题:打分**不可分**,而不是阈值没调好

语料从 15 页涨到 19 页后,那条已知缺陷从 0.2141 涨到 0.2728。对真实语料重跑标定,
得到的第一个结论是:

    (修之前)正例最低 0.2811   负例最高 0.3737   Gap -0.093

**最好的负例比最差的正例还高。** 一条「Rust 的 borrow checker 报错怎么绕过」
拿到 0.3737,比 13 条真实正例里的 4 条还高。**单靠调阈值救不了。**

根因是分词器**没有停用词概念**:中文走字符二元组,而「的」出现在 15/19 页
(df/n≈0.79)且 tf 很高,却和内容词被同等对待。那条 Rust 查询真正命中的是
「的」(15)、「报错」(4)、「怎么」(4)、「绕过」(2)——**撑起分数的主要是「的」**。

修法是标准 IR 做法:出现比例过高的词不携带区分度,不计入覆盖度(`maxDfRatio`)。
扫了一遍取值:

| maxDfRatio | 正例最低 | 负例最高 | Gap | 正例 top1 |
|---|---|---|---|---|
| 基线(不过滤) | 0.2811 | 0.3737 | −0.093 | 12/13 |
| 0.5 | 0.2811 | 0.3136 | −0.033 | 12/13 |
| **0.2** | **0.2446** | **0.1969** | **+0.048** | 12/13 |

**0.2 是第一个让 Gap 转正的取值**,且正例 top1 正确率与基线**完全相同**——
没有为了分离开而牺牲命中。修完之后 5 条负例没有一条判成 hit,
那条已知缺陷从 0.2728 降到 0.1394。

> ★ **样本只有 19 页 / 18 条查询,间隔 +0.048 是薄的。语料显著增长后必须重扫。**
> 下限取 2 而不是 1:纯比例在小语料上会退化(n=2 时 floor(2×0.2)=0 → 所有词被滤掉
> → 永远返回空),而插件冷启动时正是那个状态。

### 一个踩过的坑

中文没有词边界,本插件用**字符二元组**分词,于是"协议的分帧"会产生 `议的` / `的分` / `帧和` 这类跨词边界的噪声二元组。而 `idf()` 对 `df=0` 的词返回的是**上界**(`ln(1+(N+0.5)/0.5)`,N=1 时约 1.386),语料内常见词只有约 0.288 —— **差 5 倍**。

结果:这些永远不可能命中的噪声词反而主导了分母,把每个自然语言查询的分数压到接近 0,几乎每轮都判 miss、反复触发联网。修复是给 `df=0` 的词**中性权重**(语料内词的平均 IDF),而不是最大 IDF。实测把"Widget 协议的分帧和魔数是什么"从 0.094(误判 miss)拉回 0.32(hit)。

## 界面

侧边栏底部的 **learn-wiki** 入口打开一个面板,三个页签:

| 页签 | 回答的问题 |
|---|---|
| **能力** | 71 个工具里哪些在每轮都收费、我该裁哪个;11 个技能的常驻目录成本各是多少 |
| **知识** | 哪些知识被确认过、哪些有反证、哪些被隔离;每条可以就地展开读全文 |
| **补料** | 挣扎信号分布、缺口队列、最近的判定 |

### 一条贯穿界面的规矩:**位置不表达状态**

上一版把"裁掉的工具"顶到工具表最前面,于是你点一下方框,这一行就从指针底下
消失——连点第二下都做不到。知识页签当时也有同一个病:按"需关注度"排
(隔离 > 有嫌疑 > 未确认 > 其余),而这几档全部由证据决定,证据每 8 秒轮询刷新
一次,所以你展开一行读正文,下一轮询它就可能换位置。

现在两处都按**不随证据变化的键**排(工具按 族→名字,知识按 id),
"我该先看哪些"改由**筛选器**回答(已确认 / 未确认 / 有反证 / 已隔离,都带计数)。

### 乐观更新

勾选工具、固化暂存页都是**先动界面再发请求**:失败则回滚,并明确写出"这一格已回滚"。
固化那条不会假装完成——它以「固化中…」的标记躺在表里,真值一到自动换成真实分类
(靠取差集,不靠"记得在某处删掉它")。

### 键盘

页签是 `role="tab"` 且可方向键移动;展开控件是**真的 `<button>`**(带
`aria-expanded`),不是"给 `<tr>` 挂个 onClick"——键盘用户 Tab 不到 `<tr>`。
面板是模态的,焦点被圈在里面,`Esc` 关闭并把焦点还给入口。

## 界面自检与预览

**agent 看不到这个界面**(没有浏览器运行时,而且当前模型可能不接受图像输入)。
所以有两个脚本把界面变成可读的东西:

```powershell
npm run verify:render                  # 结构级渲染测试(真 React + 自写的原语替身)
npm run ui:probe                       # ★ 从真实浏览器排版量出**文本**:列宽/行高/滚动长度/截断
npm run ui:snapshot                    # 截成 PNG + 写出 HTML(给看得见图的模型或人)
node scripts/ui-probe.mjs --only knowledge --json .snapshots/probe.json
```

- **`ui:probe` 是给 agent 用的那条路。** 它把同一份页面交给无头 Chrome 真排版,
  再用 CDP 把几何取出来翻成文本:面板/表体矩形、"内容高 vs 可视高 → 要滚几屏"、
  每列真实像素宽度、哪些单元格的文字被 CSS 截断了。装不了 playwright 也能跑——
  直接用系统里的 Chrome/Edge,通过 Node 自带的 WebSocket 说 CDP。
- **`verify:render` 抓的是崩溃类 bug。** 用真 React 把组件树渲成静态 HTML,
  断言"不崩 + 分组正确 + 行数与顺序对"。它第一次跑就抓到一个真 bug:
  工具表族标题的计数用的是 `out[out.length - 1].n++`,而循环体已经 push 过行了,
  于是计数加到了**行**对象上,**每个族的标题恒显示 1**——界面上一直这么显示着。

> **两个脚本共用 `scripts/lib/ui-harness.mjs`,所以"测到的"和"看到的"是同一个东西。**
> 夹具也从那里来:想量真实数据,把 `/learn-wiki/api/state` 的响应存成 JSON,
> 用 `--state` 传进来。

> ⚠️ **原语是替身。** Pill / Button / StateDot / MarkdownText / 图标都是照
> `dsh-client-ui-primitives` 当前实现写的最小等价物(每个都带 `data-stub` 标记),
> **不是**真原语。所以:结构、密度、列宽、行数、截断是可信的;
> 胶囊的圆角、按钮的填充、状态点的形状**不代表真实应用**。
>
> 这件事有代价也有教训。替身图标一开始漏了 `width/height`,无尺寸的 `<svg>`
> 会拿到浏览器默认的 **300×150**,把 26px 宽的展开列撑到 105px 高——量出来的
> "行太胖"完全是替身自己的问题。**替身漏掉一个属性,量出来的"问题"就是替身的问题。**

## 自检

```powershell
node scripts/verify-core.mjs      # 解析 / 索引 / 打分 / 三分桶
node scripts/verify-loop.mjs      # 端到端闭环(含防投毒与 commit 闸门)
node scripts/verify-plugin.mjs    # 插件接线(mock ctx,无需重启 DSH)
node scripts/verify-ui-api.mjs    # UI 数据接口(含桌面 app:// 载体的 POST 形态)
node scripts/verify-render.mjs    # 组件树结构级渲染
node scripts/verify-subagent-guard.mjs  # 子代理不污染长期记忆(两个方向都测)
node scripts/verify-harvest.mjs   # 会话提炼:不把自己的注入物当成人说的话
```

`verify-plugin.mjs` 用 mock ctx 跑 `apply()`,能在不重启 DSH 的情况下抓出事件名拼错、
工具注册缺 `output` 声明这类错误——本插件开发中它实际抓到了一个阈值标定 bug。

## 状态

Phase 1(host 半)与 Phase 2(client 半)均已完成并验证:

- host:自动注入 / 三档判定(hit·weak·miss)/ 挣扎检测 / 后台限流补料 / 使用证据与强化因子
- client:能力(工具+技能,按族折叠)/ 知识(可展开、可筛选)/ **分拣**(回收站与已拒绝的恢复/删除)/ 补料 四个页签
- 界面可离线自检与预览(见上),**不需要开浏览器、不需要重启 DSH**

自检:**24 个套件,全部离线可跑**(`npm run verify:*`)。最大的那个是结构级渲染测试
——真 React + 自写的原语替身,把组件树渲染成静态 HTML 再断言结构与行序。

已完成(原来的 Phase 3 清单):

- `wiki_lint`:死链 / 来源失效 / 来源不像指针 / 疑似重复 / 主题重叠 / 过期。**只读**,
  且结果里会列出"**这次没查什么**"(URL 离线验证不了,如实说没查)
- `wiki_merge`:两页并一页。默认只出提案,`apply:true` 才写;合并稿落 `staged/`、
  被并入的页移进 `.rejected/` 并附 `> REJECTED:` 原因
- 命中率与补料收益度量(`wiki_review detail=metrics`):注入命中率 +
  缺口→暂存→固化→确认 的漏斗 + 知识库利用率
- 技能按 agent 粒度裁剪(默认**关**)
- 第三、四个触发器:被纠正 / 已是显式的 `wiki_harvest`
- 工具表按族折叠:`ui:probe` 实测内容高 **4093px ≈ 6.1 屏 → 672px(不需要滚动)**
- 宿主兼容性自检(`lib/compat.js`):启动时把"插件自带 vs 宿主实际"的版本
  与 8 项宿主 API 摆进日志

还没做:

- 技能表「来源」列偏窄:11 条来源路径里最长的一条被截掉 108px
- **彻底消除"两份实现"**:本地 `link:` 安装下插件仍会解析到自己那份
  `@deepseek-ai/dsh-tools`(用户从 npm/GitHub 安装则不会——已改为只声明
  `peerDependencies`)。这是 dev 环境的常态,不是缺陷,但值得知道

Install

dsh plugin --profile web add github:Dayi-Z/dsh-learn-wiki#a36d3adc1f6f244a4d1b0253c9e8e2340abf5c6c

Profile: web

Source