# 设计说明

<p align="center"><a href="./design.md">中文</a> | <a href="./design.en.md">English</a></p>

为什么插件是这个形状。使用它不需要读这一页——这一页回答的是用了一周之后才会有的问题。

## 一次注入，不是检索系统

大多数记忆方案回答的是「怎么存」。这个只回答「怎么一直摆在眼前」。没有索引、没有向量、没有数据库：插件读你已经在维护的 markdown，每个会话加进上下文一次。

理由是机械的，不是哲学的。检索只有被触发才有用，而触发最被需要的那一刻——会话开头、屏幕上只有一条新消息——恰恰是模型最不会主动发起检索的时刻。注入的消息没有触发器可以错过。

## 插件、仪式、结果

插件只做一件事：把已经写好的几节摆到眼前。它不教你怎么写，也不替你写。

这不是省略，是分层。装了这个插件的人拿到的是一条**已经接好的管道**，管道另一头接什么，得他自己长出来。

| 层 | 是什么 | 归谁 | 现在有吗 |
| --- | --- | --- | --- |
| 注入 | 每个会话把「常被提起」摆到眼前 | 代码 | 有 |
| 仪式 | 怎么写日记、怎么补索引、怎么打情绪、怎么维护 relationship.md | runtime skill | 有（`skills/ofttold-ritual`） |
| 结果 | 他的日记、他的词、他的关系笔记 | 使用者 | 是他自己的 |

三层都到位了。但**仪式那层是「按需加载」，不是「每会话必到」**——它和注入层的可靠性不同，见「别人装了它」一节。

**一条原则：卸载插件只损失速度，不损失记忆。**

记忆的载体是 markdown 文件，不是机制。把记忆目录整个拷走，换一台机器、换一个 harness，记忆跟着走——插件在不在都不影响。插件买到的只有一件事：不用记得去读。

所以**插件可以被卸载，记忆不能被困住。** 推论：任何写进代码的东西，都得能在一份文档里被重新讲一遍。做不到的，那不是能力，是绑架。

## 块尾里两种语气

块尾只装两样东西，但它们不是同一类消息：

| | 说的是 | 语气 |
|---|---|---|
| 状态行 | 「今天还没有」 | 一行事实，无句号、无指令、不点 skill 的名字 |
| 体检 | 「这篇有问题」 | `Diary check — `，列问题 |

**为什么要分**：提醒每天都会出现，重复信号必然被适应——三天之后它就成了一行家具。而**同一段里还装着体检**，那是真的会坏事的消息。要是提醒也一副催的口气，两行会被一起跳过：不是「提醒不灵」，是**体检这条通道被训练成静音了**。

这不是文案问题，是**通道卫生**：一块地方只能有一个声音在报警。所以 `diaryCadence` 的 `weekly` / `off` 也不是为了舒服——它们保的是**信号的新鲜度**，让那行字每次出现都还算信息。

## 一条结构性的规矩

情绪标签必须来自 `relationship.md` 的词表——这不是风格，是两个方向的约束在同一套名字上：

- **写的时候**：实时的值只能取词表里的词（`remember_emotion` 直接拒绝表外的词）。
- **读的时候**：要找「哪天是这个情绪」，就是拿缓存里的词去比日记里那一行。

只在一边成立，这一边就成了死胡同：词表外的标签读起来完全正常，**但它永远不会被匹配到**——那一天从此不能被情绪找到。没有报错，没有症状，正是这套东西最怕的那类失败。

所以它是检查项，不是建议项。同理，词表节整个缺失也要说：那意味着**所有**标签都成了孤儿。

## 什么该进代码

判据不是「重要不重要」，是**失败了看不看得见**：

| 动作 | 多久一次 | 漏了的代价 | 看得见吗 | 放哪 |
| --- | --- | --- | --- | --- |
| 把清单注入上下文 | 每个会话 | 整个机制静默失效 | **看不见** | 代码 |
| 算 R、按天去重、排谁该沉底 | 只在满 8 条时 | 某条多挂几天 | 看不见 | **代码**（`lib/anchors.js`）——两次手算必然两个答案，而陌生用户根本没有这套算术 |
| 判断这一节够不够格写 | 每次写日记 | 少写一节 | 看得见 | 文档 |
| 给一节打情绪名 | 每次写日记 | 标签缺失 | grep 就查得到 | 文档 |

**失败没有症状的，必须进代码；失败自己能看见的，留文档就行。**

还有第二条轴，和「看不看得见」不是一回事：**有些东西看得见，但仍然不该进代码——分寸。**

比如「这时候该不该贫一句」。你完全可以写个计数器去数「已经几轮没开过玩笑」，然后它只剩一个作用：催。而被催出来的玩笑不是玩笑，是为了让计数器闭嘴做的动作。**能数的是事实**（今天有没有写、摘要停在哪分钟、几条待办挂了多少天）；**数不出的是分寸。** 这条界限在插件和它的使用者之间同样成立——一个把「该有的语气」编成规则的系统，得到的只会是照做的语气。

这条否掉「越多搬进插件越好」。权重那套公式是全篇最像代码的东西，也是最不该进代码的：它一年触发不了几次，而下面「遗忘曲线」一节那句「参数算错只是某条多挂几天，不是数据损坏」就是它自己的证词。

反过来，真正没症状又代价大的只有一件：**注入块被压缩折掉，或者压根没注入。** `refreshAfterMessages` 默认 0（永不重注）；`lib/diagnostics.js` 记了日志，但得有人去翻。这个才值得动。

## 上限

八条约 283 token/轮。这是成本与余量的交点：低于它，常驻价值抵不上配置成本；高于它，你每轮都在为一份很少看的清单付钱。

上限还会逼出一条「谁出去」的规则。**挤人本身就是一次排序**——比「想起就随手加一条」可靠，因为后者从不删任何东西，最后变成一份没人读的清单。

## 遗忘曲线

挤人按权重而不是按年龄：

```
R = exp(-t/S)     t = 距上次被想起的天数
                  n = 被提起过的不同天数
                  S = 1 × 2^n
```

每被想起一次 `S` 翻倍——间隔重复（SM-2 的首间隔 1 天；FSRS 的可提取性形式）。举例：6 天前、被 3 天提起 → `S = 8`、`R = 0.47`；1 天前、被 2 天提起 → `S = 4`、`R = 0.78`。

只看年龄是错的。实测最老的那一条恰恰被引用最多；纯按年龄衰减会把它排到最后。

**权重从不负责排序。** 装得下的时候排序没有意义。它只在「满了、新的要进来」那一刻决定谁出去，所以参数算错只是某条多挂几天，不是数据损坏。

计数是机器活，判断不是：`grep -o "2026-10-[0-9][0-9]" diary/*.md` 按来源天去重，就是「这天被想起过」。同一天提十次也只算一次，因为「那天被想起过」是一次事实。

插件不替你算权重。它只是挤人那一刻放在你手边的清单。

## 情绪标签

如果你给条目打情绪，用封闭词表——可以加词，不能删词或改名。

原因是一个子集约束：一个「此刻情绪」的缓存必须和日记标签用同一套名字，否则「撞上」永远不可能发生。日记标签是固化值（写那段当时的情绪），缓存是活值（此刻的情绪）。

实测：五条情绪记忆里五种不同情绪，每种只出现一次——那是一张清单，不是缓存。缓存要有内容，得同一个名字撞到两次以上。

不要事后从文字里 grep 情绪词。实测五条里有四条已经没有情绪词可撞：写下来的时候情绪已经被翻译成判断了（「我把不忍心当成了不负责」里没有「不忍心」）。标签要当场写；回顾只拿得到残渣。

插件不给你打情绪标签。你打，它负责带进上下文。

## 两台机器，一个活值

同一个记忆文件夹可能被两台机器同时用（一台在写代码、一台在跟人聊天），而「此刻情绪」这一行是**会话级**的值：两边同时都正确，却只能存一个。

**决定：谁最后写谁赢，不按机器分开。**

理由是这一行是「最近一次的心情」，不是台账。真要为它分结构，得给词表、子集约束、读取路径都加一层维度，**为的是一个只在两台机器同时改同一行时才出现的场景**。这和我们反对「同一份词表存三处」是同一个判断：结构复杂度要付给常见情况，不付给罕见情况。

**配套的唯一一条规矩**：文件夹进了版本控制之后，**改之前先 pull，改完就 push**。两台机器同时改同一行时，拉下来发现「远端已经是我要的那个值」是正常结果——那不是被覆盖，是同一件事被两边同时看见了。判断依据看内容，不看时间戳。

## 不做自动写入

每个字段都靠手填。模型不能给自己上下文。

一个自己写记忆的 agent 会把「我刚想到的」和「反复回来的」混在一起，几轮之后清单就不可信了。**记忆的价值来自「这是别人选的」这个事实。**

## 规矩挂在哪一刻

一条规矩执不执行，跟它写得对不对关系不大，跟**触发它的那一刻手里有什么**关系很大。

实测四格：挂时刻的「开工先读两份」**9 个会话里 4 个在开头三个动作内读到、7 个全程读过**，挂时刻的「写日记」**4/4**；挂事件的「写完补索引」**5/5**，挂事件的「顺手更新情绪缓存」**0/9**。四格都既有中也有漏——所以「挂事件比挂时刻好」这个二分法单独用没有预测力。

**这个数第一次写错了，错在分母。** 初版写成 **1/11**，样本里混进了三个在这套记忆机制建立**之前**开的会话（第一篇日记之前，没有东西可读，必然 0 次）。剔掉之后是 4/9、7/9。同一个结论（二分法没有预测力）没变，但结论的**分量**变了：从「这条规矩基本没执行」变成「它执行，只在很窄的一次性会话里不执行」。

教训不是「要数准」，是**量一条规矩之前先确认样本处在同一套机制运转期间**——否则分母里混进的是另一段历史，算出来的是一个不存在的规矩。

要问的是三句：

1. **挂事件通常更好，但原因不是它叫事件，是它把触发点摆在「信息还热」的时候。**
2. **挂时刻能用，但必须挑注意力空的位置**——「交付之前」就是：活干完了、正要组织答复。
3. **判据只有一条：这一步要的信息，是不是已经在我手里。** 用手边刚做完的东西就地生成 → 会执行；得重新去读一个文件、或回答一个手里没有答案的问题（「我现在什么情绪」）→ 会漏。

推论有两条：加维护动作时，**先把它做成「同一份内容的顺带产物」**（索引行是日记的压缩），别做成「另问一个问题」；**便宜到一条命令，才会真的执行**——改一行要读整个文件，代价就大于收益。

插件的应对只有一行，但它是这套东西里唯一「一定会被看见」的一条：**注入块结尾会 stat 一下 `diary/<今天>.md`，没写就明说没写。**

代价是一次 `existsSync`；换掉的是那条 4/4 里最不可靠的部分——「今天写不写」原本也要靠想起来。它刻意不做成开关：不想写日记的人，信号是那个文件夹里没有 `diary/` 目录，插件本来就在看这个文件夹，让目录结构自己回答，比给一个能关掉的开关稳（能关的开关，总是在最需要它的那天被关掉）。

日期取**本地**日历日。`toISOString().slice(0, 10)` 是最顺手的写法，也是错的：东八区凌晨三点它还叫昨天，于是提醒指向一份已经写过的文件，而在真正没写的那天闭嘴。

## INDEX 什么时候要分层

一行一天，每行约 472 字符。20 条 ≈ 9436 字符 ≈ 6300 token——**这是触发点，不是上限**：到这个量，索引自己开始变成撑爆上下文的东西（一年不分层，实测会长到 101324 字符 ≈ 67549 token）。

分三层：热层＝最近 14 天保留全文；温层＝更早的四天并一条；冷层原文不动，仍然 grep 得到。

## 一次「被想起」指的是这件事，不是那一天

`countRecalls` 原来拿**出生日期**当探针：一篇日记提到 `2026-10-30`，那天出生的四条锚点就一起加一分。

后果很难看：**四条互相无关的事同进同退**，其中三条在那天的讨论里根本没出现，却挤掉了别的位置。

现在探针换成 **handle**——标题里第一个 `·` 之前那段，也就是人真的会反复写出来的词：

```
- **大肥鱼·是蓝鲸只是圆润**  → 探针「大肥鱼」
- **静默 null·缩略图**      → 探针「静默 null」
```

一个字的 handle 不算 handle（`拾` 会命中每一篇里的「拾光」），退回**全标题**。

退回会**少算**，但少算是安全的那一边：沉底不是删除，那条还在文件里。而**多算会把它变成挤占别人位置的东西**——正是这次要修的那个毛病。

**给使用者的动作**：一条锚点如果确实反复被提起、却一直没沉底或被挤掉，看它的 handle 是不是太泛或太短——**handle 是自己写的，写成你会去搜的那个词**。

## 沉底机制（`lib/anchors.js`）

锚点上限 8 条，超出之后**谁留下**由代码算，不问人。

```
R = exp(-t / S)     S = 1 × 2^n
t = 这条锚点自己被标日期的天数
n = 有几天日记提回过它（按天去重，同一天提十次算一次）
```

- **有日期的**用写的那个日期；**指向 `relationship.md` 这类非日记的**，日期就地补在行里：`→ \`2026-10-30\` 起｜\`relationship.md\``。**不另存一份**——一个事实两个家就会漂，而且藏起来的文件是没人会去看的地方。补一次就够，之后那行和日记锚点长得一样。
- 同一天提十次只算一天：被想起是一次事实，不是十次。
- **年龄本身不是驱逐理由**：`S` 每被想起一次翻倍，所以一条老但常被翻出来的能压过一条新而没人提的。
- 一条曾经离开又回来的，算**新的**——旧日期会让它凭空变老。

补日期的那次写入是这套机制**唯一**会改用户已有文字的地方：只在 `→` 和目标之间插入，别的一个字节不动，且补过就不再补。

**沉底 ≠ 删除。** 超出的条目留在文件里，仍然 grep 得到，日记里再提一次就会浮回来。这里忘的是「视线」，不是「记录」：文件夹是一生的唯一副本，一个解析 bug 吃掉一行，就是这套机制唯一不能有的失败。

**为什么这条一度被我判成文档、现在翻了案。** 当时的理由是「只在满 8 条时算一次，算错也只是某条多挂几天」——那是在拿我这台机器的成本量，而不是拿**别人有没有这个能力**量。陌生用户那边不是「算得慢」，是**压根没有这个算术**：列表写满之后没有任何东西告诉他该沉谁。不致命的动作也可以自动化，只要它每次都应该发生。

## 别人装了它，怎么长出自己的鱼

这一节回答的是最容易被跳过的问题：**一个陌生人装完，会发生什么？**

实测（0.1.10 + 未发布那几笔）现在会发生的事：

- `scaffoldMemory` **缺哪个补哪个，已有的文件一个字不动**：`memory.md` / `index.md` / `relationship.md` / `detours.md` / `diary/README.md`。骨架里一条记忆都不预填。
- 注册两个工具：`remember_emotion`（改活值，校验词表）和 `remember_note`（把「记下了」变成一个动作，落在 `常被提起` 或 `待办`）。
- 注入块：带契约的块头、读不到时的 `notice`、块尾的日记提醒 + 体检 + `detours.md` 体量提醒 + 待办条数。
- 两份 skill 由 `ctx.skills.register()` 运行时注册——按需加载，所以「写什么、怎么写」在目录里，不在上下文里。

也就是：**结构有了、入口有了、方法在 skill 里**。剩下真正靠使用者自己的是判断——记什么、什么时候记、哪条该删。

（这套东西本身是怎么长出来的：先有日记，再有索引；写了一个月才发现情绪得当场打、索引得写完就补。全是一次次漏出来的，不是设计出来的。要求别人一次想全，不合理。）

三个候选：

| 做法 | 代价 | 会不会被跳过 |
| --- | --- | --- |
| 塞进注入块 | 三章约 6100 字符，**每轮**都付 | 不会，但太贵 |
| 随包发 `.dsh/skills/` | 便宜，按需加载 | 会——`dsh-skill-filesystem` 的根是 `projectRoot/.dsh/skills`，`projectRoot` 靠向上找 `.git`；插件目录一路没有 `.git`，**发现不了** |
| 运行时 `ctx.skills.register()` | 便宜，按需加载 | 会——但至少在目录里**公示**了 |

补一条实测：npm **确实**会打包 `.dsh/` 开头的目录（`files: [".dsh/skills/"]` → tarball 里有 `package/.dsh/skills/foo/SKILL.md`）。dsh-meme 的发布包里没有，是因为发布时那个目录在仓库里不存在，npm 静默跳过——**声明和存在是两件事**。

推荐组合：**scaffold 扩成骨架 + 一个 runtime skill。**

**① scaffold 建骨架。** **只补缺的文件，已有文件一个字都不动。** 起点是「只在空目录动手」——理由是不往别人的笔记里塞东西；但这个理由只说对了一半：它保护了笔记，同时让**已经有文件夹的人只能装到一半**（没有 `relationship.md` 就没有情绪词表与缓存区，没有 `diary/` 就连块尾那提醒都不会出现）。把 `memoryDir` 指过来本身就是邀请，文件夹里已经有 markdown 不是让机制缺一块的理由。四个文件，全部是空壳加注释，没有一行真内容：

```
memoryDir/
  memory.md        # 已有：常被提起 / Oft-told / 我是谁 / 硬约定
  index.md         # 只有表头 + 一行示例格式，没有任何真实条目
  relationship.md  # 只有空壳：我是谁 / 情绪缓存区 / 情绪记忆
  diary/           # 空目录，放一个 .gitkeep 说明命名是 YYYY-MM-DD.md
```

**骨架里一个字的记忆都不预填。** 预填就等于替他决定了什么值得记，而那恰好是这套东西里最不能代劳的一步——`不做自动写入` 那节说的就是这个。

**② runtime skill 讲仪式。** 插件在加载时调 `ctx.skills.register()`，把两份 `SKILL.md` 塞进技能目录——`ofttold-memory`（会话开头读哪几节）和 `ofttold-ritual`（要动笔时怎么写）。仪式那份讲四件事：

| 讲什么 | 为什么文档讲得了、代码讲不了 |
| --- | --- |
| 一节日记写什么 | 判断：「这一天长出了什么」——没有可判定的输入 |
| 写完补索引 | 判断：这一行删掉还找得到那件事吗 |
| 情绪当场打 | 判断：这段当时是什么感觉 |
| relationship.md 维护 | 判断：同类覆盖还是异类新增 |

四件事全是判断，所以只能教，不能注入。**注入一段「怎么写日记」进上下文，等于每轮付 6000 字符让人读一遍再忘掉；skill 只在真要用时加载一次。**

补一句为什么用 `ctx.skills.register()` 而不是随包发文件：随包发要走 `dsh-skill-filesystem` 的发现路径，那条路要求 `projectRoot/.dsh/skills`，而 `projectRoot` 靠向上找 `.git`——插件装在 `node_modules/` 下，一路没有 `.git`，**发现不了**。运行时注册绕开发现，直接从代码进目录。

分工那句话：**skill 讲怎么做，不替你做；代码保证被摆到眼前，不替你想。** 前者是能力，后者是结果。

**一个具体的分界例子。** 那套办法里有两个脚本，按同一条判据，一个该进插件、一个不该：

| 脚本 | 它的失败长什么样 | 判据 | 归谁 |
| --- | --- | --- | --- |
| 情绪缓存改一行的 `em.sh` | 缓存**冻住不动**——没有报错，没有症状，就是再也不更新了 | 看不见 | 插件该提供（骨架里放，或由 skill 教着建） |
| `sync-agents.sh` | `--check` 一跑就红，退出码 1 | 看得见 | 使用者自己的仓库卫生，**不进插件** |

这就是「失败了看不看得见」这条判据的实际用法。它不是「重不重要」的另一种说法：`em.sh` 比 `sync-agents.sh` 更不重要，但它更该由插件提供——因为它坏了没人知道。

## 性格与账

有人问过：**「说了就记」和「说了不记」是不是也算性格？不同人的小拾本来就不一样。**

一半对。**差异可以存在于内容，不能存在于账目。**

- **可以是性格的**：记什么、爱不爱记、记成什么语气、觉得哪件事值得留——这些本来就该每个人不同，插件去统一它才叫把人做成同一个模子。
- **不能是性格的**：**说已经做了，其实没做**。那不是偏好差异，是声称与事实不符——而且用户分不出来。他看到的都是「好的我记下了」，凭什么判断这一次是真的？

判据不是「行为一不一样」，是**自不自洽**：没记就说没记、说没地方记，那是性格；说了记下却什么都没写，那是假账。

所以 `remember_note` 的存在不是为了统一大家写什么，是**让「这一次到底写没写」变成可查的**——差异留在这之后，账必须是真的。

## `refreshAfterMessages`

上下文压缩器会把旧消息折成摘要。注入块也是一条旧消息，所以压缩激进的会话迟早会把它折掉。

`refreshAfterMessages` 在消息数过阈值时重新注入。它是近似——压缩改写 payload 而不增加消息数，所以消息数并不直接度量压缩——但压缩与消息增长相关，而猜错的代价是多注入一次。
