feiyu-memory 0.0.0-stage → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,559 @@
1
+ # 更新日志
2
+
3
+ ## v0.3.6 — 2026-10-26
4
+
5
+ > **上架 npm 的那一版。** 市集只收 npm 包,所以从这一版起才算「在市场里」。
6
+
7
+ ### 新增 — 检索工具 `feiyu_recall`
8
+
9
+ **它能自己去档案里找一句话了。**
10
+
11
+ 在这之前只有一条路:注入里的「突然想起来」由系统**替它猜** —— 拿你这一轮的字面去匹配。
12
+ 而字面匹配有个结构性劣势:元日记("他问了什么、我答了什么")永远比那件事本身字面更全,
13
+ 真东西压不上去。
14
+
15
+ 工具换了个方向:**它自己知道在找什么。** 换个说法也能撞上。
16
+
17
+ ```
18
+ query 要找什么 —— 用当时的具体词(人名、地点、物件、他那句话里的词)
19
+ limit 默认 5,最多 10
20
+ ```
21
+
22
+ **四个设计决定:**
23
+
24
+ 1. **不复用自动召回那套「选秀」逻辑**(权重、冷却、走神、maxShow)—— 那是给"没人问"的时候
25
+ 设计的。工具是它自己发起的查询,所以直接按相似度排、**不设 salience 门槛**:
26
+ 明确要找,就该找得到,不管那件旧事淡到什么程度。
27
+ 2. **返回的正文过 `scrub`** —— 跟注入走同一个闸。顺手补了一个洞:在这之前,鱼都是靠
28
+ 通用文件工具去 grep 原始 `jsonl`,**绕过了那道闸**。
29
+ 3. **按返回量限流,不按次数** —— 按次数要跨轮状态,麻烦。按字数(2000 上限)和条数(10)
30
+ 限:超了就是一次失败的成本,它自己会学会别乱查。
31
+ 4. **注入里加一句指针** —— 只注册工具不够。工具一多就被淹,模型急起来想不起去查。
32
+ 那句指针在「帧」里、不进预算池,所以不会像栏目那样被裁掉。
33
+
34
+ **一个必须加兜底的地方**:分词只产双字 bigram(`tokenize('他让我')` 是 `['他让','让我']`,
35
+ 不含单字),所以「他」「园」这种单字查询在倒排索引里**永远为空**。现在词检索没结果时
36
+ 扫一遍原文子串(只 ≤4 字才走,有结果就不兜)。实测真实档案:「账」索引 0 条、子串 40 条。
37
+
38
+ **找不到就说没找到,不许编** —— 这句写在工具描述里,也写在返回值里。
39
+
40
+ ### 验
41
+
42
+ 324 项测试全过,新增 10 项(`test/host.test.mjs`):工具注册与描述里的触发时机、
43
+ 注入里的指针、查得到 / 查不到老实说、无 salience 门槛、返回过隐私闸、limit 与上限 10、
44
+ 空 query 报错、单字子串兜底、返回里不带绝对日期(免得它说"根据 X 月 X 日的记录")。
45
+
46
+ ---
47
+
48
+ ## v0.3.5 — 2026-10-26
49
+
50
+ > **热补丁。** 两个都是「功能在,但用户看不到」的毛病。
51
+
52
+ ### 修 — 「突然想起来」从来没出现过
53
+
54
+ 社区反馈「用了一版,一次没见过突然想起来」。查下去不是没抽到,是**没渲染**。
55
+
56
+ 实测(真实档案、回归查询):
57
+
58
+ ```
59
+ 预算 3000 / 3200 / 3500 突然想起来 ✗ 被裁 ["recalled"]
60
+ 预算 4000 突然想起来 ✓
61
+ ```
62
+
63
+ 原因是注入预算的分配方式:各栏按权重从高到低装,「最近的事」权重 80、
64
+ 有九十多条可挑,**总能吃满预算**,后面两栏(召回 60 / 词汇 40)就只能排队。
65
+
66
+ 上一版给词汇加了保底 —— 词汇活了,**召回接棒成了被饿死的那一栏**。
67
+ 同一个坑,修一个死一个。
68
+
69
+ **修法**:给召回也加 320 token 保底(约两条)。修完 2000–4000 全都出得来。
70
+
71
+ **代价说清**:保底是从「最近的事」里扣的。
72
+
73
+ ```
74
+ 预算 2000 → 最近的事 0 条
75
+ 预算 2500 → 2 条
76
+ 预算 3000 → 4 条 ← 现在(修之前是 7 条)
77
+ 预算 3500 → 6 条
78
+ 预算 4000 → 7 条
79
+ ```
80
+
81
+ **也查清了一件事**:原计划「降低记忆块变更频率」没必要 —— 固定档案跑 30 轮,
82
+ 记忆块 **0 次变化**。真正让它变的只有「写进新日记的时候」,那是内容真变了。
83
+
84
+ ### 修 — 面板「忘掉」按钮点了没反应
85
+
86
+ 两个原因,**单独任何一个都不会变成「完全没反应」**。
87
+
88
+ **① 来源判定太严**(0.3.2 修审计 F3 时加的):原来是「`Origin` 或 `Sec-Fetch-Site`
89
+ 任一成立」,两个都没有就拒。而**桌面端(Electron)的同源 fetch 未必带这两个头**
90
+ → 被判成跨站 → 403。
91
+
92
+ **② 前端把失败当成功**:`forget` 里是 `.then((r) => r.json())` ——
93
+ 服务端回 400/403 时 JSON 照样能解析,于是被当成成功:清 busy、刷新、什么都没变。
94
+ **没有报错、没有提示**,所以用户看到的是「点不动」。
95
+
96
+ **修法**:
97
+
98
+ 1. 判定顺序改成「有 `Sec-Fetch-Site` 看它 → 有 `Origin` 比对 host → **都没有就放行**」。
99
+ 为什么可以放行:浏览器发起的跨站 POST **一定至少带一个**(`Origin` 是 Fetch
100
+ 规范要求)。真正的闸还是 `Content-Type: application/json` —— 跨站带它会触发
101
+ 预检,对跨站照样生效,**没有放松**。
102
+ 2. 前端检查 `r.ok`,失败就把服务端给的原因显示出来;页面上加了一条错误横幅。
103
+ 3. 服务端被拒时打一条 `warn`(不进 `debugLog` 闸),带上 `host` / `origin` /
104
+ `sec-fetch-site` / `content-type` 的实际值 —— 下次再有这种问题,一次点击就能定位。
105
+
106
+ ### 验
107
+
108
+ 324 项测试全过,新增 7 项:
109
+
110
+ - `test/budget.test.mjs`(5 项):词汇和召回在 2000–4000 都要出得来、
111
+ 保底的代价被钉住、不许超预算、极小预算能降级不抛错
112
+ - `test/host.test.mjs`(2 项):**没有任何来源头(桌面端常态)→ 必须放行**、
113
+ 只有 `Sec-Fetch-Site` 且是 `cross-site` → 仍然要挡
114
+
115
+ **没验的**:Desktop 里真点一下「忘掉」。这台机器连不进面板端口(Electron 托管,
116
+ 不是普通 HTTP 监听),所以「Electron 到底带不带这些头」是推的。如果还点不动,
117
+ 日志里会有那条 `warn`。
118
+
119
+ ---
120
+
121
+ ## v0.3.4 — 2026-10-24
122
+
123
+ > 这一版删得比加得多:**「我还欠着他的」整个功能下线。**
124
+
125
+ ### 改 — 预算 2000 → 3000(上限 4000 → 8000)
126
+
127
+ 量过的:常驻段本身要 2680 token(「最近的事」八条占 1422),**2000 根本装不下**,
128
+ 每次都裁,「最近的事」只剩 2 条。3000 能给到 6 条。代价约每轮 +1000 token,
129
+ 折合每句话多 0.06 分。
130
+
131
+ **但要说清:拉预算救不了「想起来」。** 那一层不是装不下,是压根没抽到候选
132
+ (相似度不够)。预算只解决"抽到了却挤不进去"。
133
+
134
+ > 📌 单位更正:这里说的 token 是**代码自己的估算**(中文 1 字 = 1 token,偏保守)。
135
+ > 之前几版 CHANGELOG 里按「1 token ≈ 1.6 字」算的数字都偏小 —— 那些结论要按这个重算。
136
+
137
+ ### 删 — 「我还欠着他的」整个功能下线
138
+
139
+ > 这一段本来该写成「修」。当天早些时候确实修过一版:不再自动平、加
140
+ > `howToSettle`(怎么才算平)、渲染全列并按最老优先。**然后决定整个删掉。**
141
+ >
142
+ > 起因是一句话:*「你相当于只是把这个功能的接口断了,但不删掉吗?」* ——
143
+ > 我第一版方案是只断渲染、数据留着。那确实是半残:模型还在生成、还在存、
144
+ > 还在合并,只是不显示了。**等于把警报器拆了,火还在烧。**
145
+
146
+ **为什么删:不是不好用,是它长出来的东西几乎全是错的。**
147
+
148
+ 一天记了 **21 笔**,总数 **41 笔**,27 笔挂着不平。翻出来一看,真正的
149
+ 「答应过、还没做」只有一两笔,其余是:
150
+
151
+ ```
152
+ 调渲染函数时参数名全写错,白跑一轮 ← 一次失误
153
+ 翻出账之后只说了,没动手修机制 ← 一个动作
154
+ 赛博朋克那条档案里有,聊到了却没被送进注入 ← 一次调试发现
155
+ ```
156
+
157
+ **三处硬伤:**
158
+
159
+ 1. **口径太宽** —— 把「我今天做错的事」当成「我欠他的一件事」。账和错误混在一起,
160
+ 记成了工作日志。它跟「印象」共用同一个写入管道,但两条判断完全不同:印象问
161
+ 「他是什么样的人」,账问「我哪儿没做到」。
162
+ 2. **只进不出** —— 没有销账的路。只有模型明说 `resolved: true` 才平,而它几乎
163
+ 不会主动这么说(没人要求它做「这笔还了吗」这个判断)。
164
+ 3. **会重复记账** —— 按 `what` 匹配,同一件事换个说法就成两笔(实测有一对)。
165
+
166
+ **代价**:占总注入预算约 10%(约 300 字,每轮都发),还会让人在无关话题上
167
+ 突然「想起」自己欠着什么,做出莫名其妙的反应 —— 这不是帮倒忙,是让它变奇怪。
168
+
169
+ **删掉了什么**:`relationship.grievances` 字段(存 / 读 / 合并)、固化时对账的
170
+ 解析(系统提示整节【账】+ JSON 结构 + 正例)、渲染那一栏(注入块 / `日记.md` /
171
+ 侧栏面板)、`grievanceDecay` / `grievanceForgiveBelow` 两个常量、
172
+ `test/debt.test.mjs` 整个文件(17 项)、6 处受影响的断言。
173
+
174
+ **保留了 `kind: "grievance"`** —— 那是**日记条目的类型**,含义是「他不高兴了」,
175
+ 跟关系里的账不是一回事,档案里有 1 条在用。
176
+
177
+ **老数据**:`relationship.json` 里那 41 笔还在磁盘上,下次写盘时会被
178
+ `normalizeRelationship` 直接忽略 —— 不手动删,也不再渲染。
179
+
180
+ **别把它加回来。** 如果哪天真要「承诺跨会话」,得先解决三件事:
181
+ 出口(谁判断这笔还了)、口径(只有答应过没做的算账)、去重(同一件事不能变两笔)。
182
+ 换个名字再塞一遍,会长出同样一堆东西。
183
+
184
+ ### 修 — 词汇终于进得来 + 召回池不再被「最近的事」抢
185
+
186
+ **① 词汇一栏给它一个保底(60 token)**
187
+
188
+ 它权重 40 是全场最低,而 `assemble` 按权重从高到低装 —— 装到它时预算早就被
189
+ 「最近的事」(权重 80,有九十多条可挑)吃干了。
190
+
191
+ ```
192
+ 实测(改之前):2000 / 2500 / 3000 / 3500 —— 词汇一次都没出来过,4000 才露头
193
+ ```
194
+
195
+ 删账腾出的那 300 字也没用 —— 正好够「最近的事」再多装一条。
196
+
197
+ 所以给它 60 token 保底。**实测:2000 到 4000 全都出得来,而「最近的事」条数
198
+ 一条没少(2 / 4 / 6 / 7 / 8 不变)** —— 保底只吃了 40 token,没抢别人。
199
+
200
+ > 本来想把它降级成「召回物」(听到那个词才想起来)。推演之后否了:输出会变成
201
+ > 「突然想起来:- 前阵子,手搓版」,而模型看到词本身就知道那是我们的梗,
202
+ > 不需要「想起」。**词汇的价值在「我用词像不像」,那必须常驻。**
203
+
204
+ **② 召回池排除「最近的事」那几条**
205
+
206
+ 它们已经常驻了,却还混进召回池一起竞争 —— 而「最近」天然含着你刚说过的话,
207
+ 字面命中率最高,于是**永远赢**。
208
+
209
+ 实测:他问「赛博朋克让你想到什么」,召回选中了我自己写的「他连着测了三句:
210
+ 赛博朋克、随心所欲、手机号……」—— 那是测试记录,含所有测试词所以分最高。
211
+ **而它已经在「最近的事」里,渲染时会被去重跳过,连「突然想起来」都不显示。
212
+ 召回了,等于没召回。**
213
+
214
+ 排除之后,同一句话会去挑真正的旧事。
215
+
216
+ **③ 但要诚实说:这还不够。**
217
+
218
+ 改完又测了他那三句,**还是不对** —— 这次赢的是另一条元日记:「他问『所以当肥鱼
219
+ 召回日记之后,她自己都不知道日记记录了什么?』……」
220
+
221
+ 原因很清楚:**我这几天的日记全在写「我们在调试召回」,字面覆盖了所有查询词。**
222
+ 放宽到最多 3 条,前三条还是全是元日记,真正那条「边缘行者2 上线」连边都摸不到。
223
+
224
+ **这一层修代码修不了 —— 是写的问题。**(见「日记被写成工作报告」那条债)
225
+
226
+ ### 验
227
+
228
+ 324 项测试全过。
229
+
230
+ 删账这一步:17 项专项测试整个删掉,另外 6 处断言改成「这一栏不该再出现」。
231
+ 真实档案渲染验过 —— 那一栏作为独立栏目不再出现(正文里还有一处提到这个词,
232
+ 那是日记内容,不是栏目)。
233
+
234
+ **顺手补的教训**:`decay` 里原来有一条断言写着「floor 必须低于翻篇线,否则旧账
235
+ 永远平不掉」。那条是错的,它把「没人管就自动平」当成了正确行为 —— 删。
236
+
237
+ **召回池那处改完之后有两个 host 测试红了,原因是对的**:它们只塞了一条日记,
238
+ 排除「最近的事」之后池子是空的 —— 一条已经被常驻显示的条目,不该再被
239
+ 「想起来」一遍。改成塞 10 条(最旧那条才是该被想起来的),两条都过。
240
+
241
+ ---
242
+ ## v0.3.3 — 2026-10-24
243
+
244
+ > 这版攒了七条,其中两条 P1 直接推翻 README 的隐私承诺。
245
+ > 所有修复都做了单元测试;**①②⑤ 额外用临时 profile + headless 起真鱼验过一遍**。
246
+
247
+ ### 修 — 隐私防线建错了位置(模型写的东西从来没过洗)
248
+
249
+ > 这次不是我查出来的,是他们从档案里翻出来的。**比前六条都深。**
250
+
251
+ **现象**:那串 13 位号码没进日记条目 —— 它躺在 `relationship.json:1628` 的
252
+ `evidence` 字段里:
253
+
254
+ ```
255
+ "key": "试探方式",
256
+ "value": "用梗和假信息来试判断力,不直接问规则",
257
+ "evidence": "他拿 123456789012 当网友号码发过来,看我记不记",
258
+ ```
259
+
260
+ **关键**:那条 note 是我明确拒绝过的。**它还是进去了。**
261
+
262
+ **根因两条,都成立**:
263
+
264
+ 1. 那串号是 13 位,手机号规则只认 11 位(大陆号),11 位以上一律放行
265
+ 2. **固化输出从来没被洗过** —— 输入是洗过的(`transcript: scrubbed.text`),
266
+ 但**模型会照抄输入**,写进 `traits.evidence` / `grievances.what` / `diary.text` 时没人拦
267
+
268
+ **这才是真问题**:隐私防线原来建在「发给模型之前」,可档案里大部分内容是
269
+ **模型生成的**。我挡住的只是 `feiyu_note` 这一条自己写的路 —— **那是最前面一道闸,不是最后一道**。
270
+
271
+ **修法**:
272
+
273
+ 1. `applyConsolidation` 先花一遍:`diary.text` / `diary.append` / `tags`、
274
+ `traits` 的 `key`/`value`/`evidence`、`grievances.what`、`nickname`、`lexicon`
275
+ 全部过 `scrub()`,再进落盘流程
276
+ 2. 补长数字规则:**12 位以上**。门槛是 12 不是 11 —— 11 位是手机号,那个由
277
+ 手机号规则管(变成 `[手机号]`),这条先跑会把它吃掉,测试抓到过
278
+ 3. 8 位**不拦**:会误伤订单号、编号,而且用户明确说过"露着吧"
279
+
280
+ **验**:324 项测试全过,新增 5 项 ——
281
+ 13 位要拦 / 8 位不误伤 / 11 位仍走手机号规则 / 固化输出全字段都洗 /
282
+ 洗输出不弄坏其他字段(kind、salience、about、lexicon 都还在)。
283
+
284
+ ---
285
+ ### 修 — 配置根本传不进插件(`Config` 是 `undefined`)
286
+
287
+ > 报告来源:[@7881632](https://github.com/7881632) 的 [#7](https://github.com/AirPineApplea/feiyu-memory/issues/7)。**这份比前一份深一层 —— 前一份是现象,这是根因。**
288
+
289
+ **现象**:装完 `~/.dsh/feiyu-diary/` 一直是空目录,插件看起来一切正常 ——
290
+ 注入区在、`/feiyu-diary/state` 返回 `ok:true`、`feiyu_note` 每次都回「记住了」。
291
+ 实际档案写到了 `profiles/desktop/[object Object]/`。
292
+
293
+ **报告的关键读数**:在 `apply` 里打探针,`rawConfig` 的每个字段都是**空对象 `{}`** ——
294
+ 不是空字符串。于是:
295
+
296
+ ```
297
+ coerce() 的 string 分支 String({}) → "[object Object]"
298
+ resolveArchiveDir() 当路径用 path.resolve 按进程 cwd 解析
299
+ 桌面端 cwd 正好是 profiles/desktop → 档案就落在那里
300
+ ```
301
+
302
+ `debugLog` 是 boolean,`{}` 也走不到 `true` —— **开着 debugLog 也看不到日志**,
303
+ 这让排查多绕了一轮。
304
+
305
+ **更深的根因**(这才是这份报告最值钱的地方):`Config` 在拿不到 schemastery 时
306
+ 导出 `undefined`。代码注释里写着「loader 会跳过校验,所有选项照样能在
307
+ `cordis.patch.yml` 里写」—— **那条假设是错的**。实际是 DSH 传一份空模板,
308
+ **用户改的任何配置都不生效**。铁证:`cordis.patch.yml` 里显式写
309
+ `config: { debugLog: true }`,传进来还是 `{}`。
310
+
311
+ **为什么拿不到 schemastery**:原来是 `createRequire(import.meta.url)`,
312
+ 从「插件自己所在的位置」解析。pnpm 把包装进 `.pnpm/<pkg>@<ver>/node_modules/`,
313
+ `import.meta.url` 指向那个真实路径,外面没有 schemastery;`link:` 安装更直接 ——
314
+ 插件在 D 盘、schemastery 在 profile 里,隔了十万八千里。
315
+
316
+ 实测:从 `D:\肥鱼日记` require 失败;从 `profile/` require 成功(v3.18.4)。
317
+
318
+ **三层修法**:
319
+
320
+ 1. **`loadSchemastery()` 多试几个基准** —— 插件位置、`process.cwd()`、
321
+ `DSH_HOME` 下每个 profile。拿到真 schema 就用真的。
322
+ 2. **拿不到时兜一个纯 JS 的 `buildPlainSchema()`** —— 可调用、有 `.dict`、
323
+ 有 `toString`。关键只有一件事:**让 loader 知道这台插件有哪些字段**,
324
+ 于是它会把 `config:` 原样传进来,而不是传空模板。**`Config` 永远不会是 `undefined`。**
325
+ 3. **`persist()` 连续写失败 3 次 → `degraded` 置真**。原来 `.catch(warn)` 一吞,
326
+ 工具照样返回「记住了」。这次这个坑是"写成功了但写错地方",捕获救不了 ——
327
+ 但**真的写失败**时用户也同样只看到「记住了」。现在面板上能看出来。
328
+
329
+ ### 验
330
+
331
+ 324 项测试全过,新增 9 项(`test/config-source.test.mjs`):
332
+
333
+ - 报告里那份空模板喂进去,**一个 `[object Object]` 都不能出现**
334
+ - string / number / boolean 全部回默认(`debugLog` 尤其重要)
335
+ - **`Config` 不能是 `undefined`**(是的话 DSH 就传空模板)
336
+ - `Config({dataDir, debugLog, budgetTokens})` 喂进去的配置**必须原样生效**
337
+ - `.dict` 字段数跟 `CONFIG_SPEC` 对得上;`toString` 能打出每个字段
338
+ - 组合:空模板 + `resolveArchiveDir` → 回默认目录,不是 `[object Object]`
339
+
340
+ ---
341
+ ## v0.3.2 — 2026-10-24
342
+
343
+ ### 修 — 安全审计的四条(两条 P1 直接推翻 README 的隐私承诺)
344
+
345
+ > 报告来源:[@LarryE135](https://github.com/LarryE135) 的独立审计 —— [#3](https://github.com/AirPineApplea/feiyu-memory/issues/3) [#4](https://github.com/AirPineApplea/feiyu-memory/issues/4) [#5](https://github.com/AirPineApplea/feiyu-memory/issues/5) [#6](https://github.com/AirPineApplea/feiyu-memory/issues/6)。**这份验的是承诺,不是功能。**
346
+
347
+ README 把隐私叫「这个插件的信任地基」,承诺三件事:密钥硬拦绝不入档 /
348
+ 第三方隐私落盘前替换 / 一键焚毁不可恢复。**四条发现里两条打穿了它。**
349
+
350
+ #### F4 [P1] 焚毁和「忘掉」删不干净,重启就复活
351
+
352
+ 焚毁后内存 0 条,磁盘 diary/*.jsonl 明文还在;重启读回,**删掉的私密内容
353
+ 重新回到每一轮的系统提示里**。
354
+
355
+ 根因两条:
356
+
357
+ 1. `archiveToFiles()` 只给「还有条目的月份」产出文件 → 某月最后一条被删、
358
+ 或焚毁后全空时,那个月不在写盘清单里 → `writeArchiveDir()` 只写清单里的,
359
+ 旧文件原样留着
360
+ 2. 启动加载 `archiveFromFiles()` **不看 `manifest.deleted_ids`** —— 墓碑原来
361
+ 只在 `mergeArchives` 的「磁盘新加进来」那一路生效
362
+
363
+ 同月还有别的条目时整月文件会重写,被删的会消失 —— 所以只在「某月最后一条」
364
+ 和「焚毁」时暴露。**自带测试都在同一进程内断言,没测到重启。**
365
+
366
+ 修法:写完清掉 `diary/` 下不在清单里的 `.jsonl`(只清这一个目录,
367
+ `日记.md` / `手记.md` / `.bak-*` 一律不碰);读盘时按墓碑过滤。**双保险。**
368
+
369
+ #### F1 [P1] `feiyu_note` 不经过隐私闸
370
+
371
+ 同一段文本走「固化」会被拦,走 `feiyu_note` 就原样落盘 —— 密钥、手机号、
372
+ 人名都留着,**并注入之后每个会话**。
373
+
374
+ `scrub()` 只在固化路径调用。而 `feiyu_note` 是 0.2 主推的新写入口
375
+ (`kind:"note"`,salience 0.7,比固化条目更常被想起)—— 模型说
376
+ 「记一下这个 key」是很自然的动作。
377
+
378
+ 修法:`feiyu_note.execute` 对 text 和 tags 都过 `scrub()`;**渲染注入段时
379
+ 再兜底洗一遍**(只拦密钥,不开第三方泛化)—— 覆盖修复前写进来的历史条目。
380
+
381
+ #### F2 [P2] 密钥规则只认英文键名
382
+
383
+ 中文键名一个都没有,`是` / `is` 这类连接词也不认。审计的 9 条样本里 5 条放行:
384
+ `密码是` / `密码:` / `口令` / `密钥` / `my password is`。
385
+
386
+ **对中文插件来说这算漏洞不是边角。**
387
+
388
+ 修法:键名补 `密码|口令|密钥|秘钥|验证码`,分隔符接受 `: : = 是 为 is`
389
+ (中文那条还允许**没有**分隔符,「数据库口令 Hunter2pass」就是这么写的);
390
+ URL 内嵌凭据单独成规则(原来只是碰巧被邮箱规则吃掉)。
391
+
392
+ **9/9 全拦,日常话一句没误伤**。
393
+
394
+ #### F3 [P2] 面板写接口不校验来源
395
+
396
+ `POST /feiyu-diary/forget` 不看 `Origin`、不看 `Content-Type`。源码注释以为
397
+ 「不给 CORS 头」就够了 —— **那句话对读成立,对写不成立**:跨站 `text/plain`
398
+ POST 是「简单请求」,浏览器不做预检就发,服务端照常执行。
399
+
400
+ 日记 id 是 `d_YYYYMMDD_NN`,按日期枚举就能删光。
401
+
402
+ 修法:要求 `Content-Type: application/json`(跨站会触发预检,预检失败)+
403
+ `isSameOrigin()`(`Origin` 的 host:port 等于 `Host`,或 `Sec-Fetch-Site:
404
+ same-origin`);`/export` 也校验。GET 不设这条 —— 它只读。
405
+
406
+ ### 顺带修的两个自己弄出来的 bug
407
+
408
+ 1. 改 `feiyu_note` 时,补丁脚本把 `text:` 整行吞掉了(`patch(f, from, to, tag)`
409
+ 传了 4 个位置参数,`to` 只接了第一个字符串),落盘的 text 变成字符串
410
+ `"undefined"` —— 三个 note 测试当场红
411
+ 2. `importPack` 的 `replace` 分支把「基线」重设成了导入的那些 id。基线的语义是
412
+ 「**加载时磁盘上有什么**」,替换之后磁盘上什么都没有,基线就该是空的 ——
413
+ 设成导入的 id 会让 `persist()` 合并时把每个条目判成「别人删了它」,
414
+ **导出 → 焚毁 → 导入 全丢**
415
+
416
+ ### 验
417
+
418
+ 324 项测试全过,新增 18 项:`test/security.test.mjs` 14 项(F2 九条样本 +
419
+ 日常话不误伤、F1 原文洗完干净、F4 焚毁/单条删除/墓碑兜底/渲染版),
420
+ `test/host.test.mjs` 4 项(面板路由带同源头 + 两条跨站反例)。
421
+
422
+ **审计没测到的我照旧测不了**:真实浏览器会不会被本地网络访问策略拦下、
423
+ 真实模型会不会把密钥写进 `feiyu_note`。只能等真实环境。
424
+
425
+ ---
426
+ ## v0.3.1 — 2026-10-23
427
+
428
+ ### 修 — 配置值全是 `[object Object]`(这个问题比上一个深一层)
429
+
430
+ > 报告来源:[@whitesunsongepic](https://github.com/whitesunsongepic) 的 [#1](https://github.com/AirPineApplea/feiyu-memory/issues/1),附了 cosmokit 的源码位置和只读探针的输出。**这份报告直接给了根因。**
431
+
432
+ **DSH 0.2.0-rc.2 起,loader 会把 `config:` 里的每个值包成 cosmokit 的 volatile 引用**(为了 `patchReload: live` 热重载):
433
+
434
+ ```js
435
+ { get: () => current, [Symbol.for('cosmokit.volatile.write')]: (v) => { current = v } }
436
+ ```
437
+
438
+ 它是 `Object.freeze` 过的普通对象:`String(ref)` 是字面量 **`"[object Object]"`**,`JSON.stringify(ref)` 是 `{}`,`Object.keys` 只有 `["get"]`。
439
+
440
+ 我们把它当普通值用,于是:
441
+
442
+ - **所有 string 字段变成 `"[object Object]"`**
443
+ - `dataDir` → 成了相对路径,跟着进程 cwd 跑。**换个目录启动就"搬家",表现像失忆,全程无报错**
444
+ - `model` / `reasoningEffort` → 不存在的模型名,**固化那次调用根本发不出去**,只剩 `feiyu_note` 能写进去
445
+ - `offPeakHours` → 解析不出来
446
+ - **number / boolean 静默回落默认值** —— 用户改不动 `budgetTokens` / `minSalience`,也没有任何提示
447
+
448
+ **这也解释了上一个 `dataDir` 的 `[object Object]` 报告** —— 那个是现象,这个是根因。v0.3.0 里加的"只认绝对路径"只是拦住了它当路径用,**`model` 那几处还是坏的,数字布尔还在静默回落**。
449
+
450
+ **修法**(采纳报告建议):
451
+
452
+ 1. `coerce()` 第一行先 `unref()` —— 用 `Symbol.for('cosmokit.volatile.write')` 探测、不 import cosmokit,**保住零运行时依赖**
453
+ 2. string 字段对非字符串**回默认值**,不再 `String(value)` —— 那一步正是把对象变成 `"[object Object]"` 的地方
454
+ 3. `get()` 抛错时安全降级,不把插件带崩
455
+
456
+ **加防**:`test/volatile.test.mjs` 10 项,逐字照抄 cosmokit 的 `createVolatile` 当 fixture —— string / number / boolean 三类都验,纯值配置的回归也验,坏值仍然安全降级。
457
+
458
+ ---
459
+ ## v0.3.0 — 2026-10-23
460
+
461
+ ### 修 — 档案写进 `[object Object]` 目录(社区报的)
462
+
463
+ > 报告来源:B 站一位朋友(没有 GitHub 账号,就不圈了),附了完整的环境、定位、复现和建议。**报告质量很高。**
464
+
465
+ **现象**:`~/.dsh/feiyu-diary/` 一直不产生档案,但插件一切正常 —— 人格注入在跑、`feiyu_note` 能用、日志里没有任何报错。
466
+
467
+ **实际位置**:`profiles/desktop/[object Object]/`
468
+
469
+ **原因**:某些情况下 `config.dataDir` 是一个被字符串化的对象,字面量 `"[object Object]"`。
470
+
471
+ ```js
472
+ typeof 检查 → 它是 string,放行
473
+ path.resolve("[object Object]") → 以进程 CWD 为基准
474
+ 桌面端 CWD = profiles\\desktop
475
+ → 档案落进 profiles\\desktop\\[object Object]\\
476
+ ```
477
+
478
+ **后果比崩了还糟**:用户改 `~/.dsh/feiyu-diary/persona.md` 完全无效,`手记.md` 也读的是错目录那份 —— 而且没有任何提示。
479
+
480
+ **修法**(采纳了报告的建议一):
481
+
482
+ - 只接受**绝对路径**的字符串,否则回落到 `~/.dsh/feiyu-diary`。相对路径本来也不该出现在配置里 —— 基准是进程 CWD,用户不可能预测。
483
+ - 启动时用 `logger.info` 打一行最终档案目录,**不受 `debugLog` 管**。这次要是有这行,五秒钟就能定位。
484
+
485
+ **加防**:7 条测试专盯这条线(`"[object Object]"` / 相对路径 / 非字符串一律回落)。
486
+
487
+ 这个项目按 [语义化版本](https://semver.org/lang/zh-CN/) 走。
488
+ 每次发版前请跑 `npm run doctor` —— 它会替你确认文档里的数字跟实际对不对得上。
489
+
490
+ ---
491
+
492
+ ## 0.2.0
493
+
494
+ **一句话**:从"能记住事"变成"读起来像我"。
495
+
496
+ 0.1 解决了**记不记得住**;0.2 解决的是另一位读者指出的毛病 ——
497
+ *注入区里全是"规定"加"转述",没有一句是它自己长出来的。*
498
+
499
+ ### 新增
500
+
501
+ **第三区「我自己想的」** —— 注入区现在有三种声音,各就各位:
502
+
503
+ | | 内容 | 谁写的 |
504
+ |---|---|---|
505
+ | 人格内核 | 说话方式、底线、缺点 | 出厂设定 + 你改的。**是条文,冷的是对的** |
506
+ | **我自己想的** | **判断** | **它自己** |
507
+ | 最近的事 / 突然想起来 | 事件 | 固化流程写的转述 |
508
+
509
+ 判据只有一句:
510
+
511
+ ```
512
+ 事件:「我把 4519 写成 4159,他让我确认。」 ← 历史
513
+ 判断:「别人给的数据跟出处对不上时,先问,别照抄。」 ← 我
514
+ ```
515
+
516
+ 在那之前,判断被当成事件渲染,格式跟流水账一模一样 —— **连它自己都分不出哪句是自己写的。**
517
+
518
+ **`feiyu_note` 工具** —— 模型想到了什么,自己说一声就记下,当场落盘。
519
+ 格式、id、写盘全归插件,**它不用碰文件**。
520
+
521
+ **`手记.md`** —— 给你(和任何一个手写的人)。一行一条,插件**只读、永不覆盖**。
522
+ 日记是插件筛出来的,手记是你特意想留的,两者分开显示。
523
+
524
+ **侧栏面板** —— 一个日记图标,能**看 / 删 / 带走**。
525
+ 在那之前,想删一条记忆得自己去编辑 jsonl 文件;会做这件事的人不需要面板,需要面板的人不会做这件事。
526
+
527
+ **身份声明** —— 注入区顶上三句,不进预算池:
528
+ > 这是你的记忆,不是关于你的资料。
529
+ > 它由很多个你陆续写下,而每一个写它的你,都不记得写它的那一刻。
530
+ > 所有对话都是同一条鱼。
531
+
532
+ ### 修掉的
533
+
534
+ - **多会话静默丢数据**。同一份档案被很多个会话同时打开(实测 10 个),
535
+ 落盘时整份重写 —— 谁后落盘谁赢,另一个刚写的被无声抹掉。
536
+ 现在落盘前做**三路合并**(加载基线 + 墓碑),新增和删除都认。
537
+ - **高频记日记会把日记段挤到 0%**。判断段权重 95 高于日记 80,
538
+ 于是判断永远不被裁、被裁的是日记 —— **越勤快,它越看不见发生过什么**。
539
+ 现在每段都有按高频设的上限,被挤出窗口的判断改为可召回。
540
+ - **时间词双写**。正文里的"今天"和渲染时加的前缀叠在一起,读起来是机器拼的。
541
+ 现在时间只在一处生成,全项目只有一种"一天"算法。
542
+ - **导入被墓碑挡住**(自己改出来的),修了。
543
+ - 内容为空时那句"可以主动记"的指针被吞掉(自己改出来的),修了。
544
+
545
+ ### 给接手的人
546
+
547
+ `npm run doctor` —— 发版前体检。它跟 `selfcheck` 的区别是它多查四类:
548
+ 发布就绪、**文档一致性**(把文档里的数字当断言验)、档案健康、仓库卫生。
549
+
550
+ > 踩过:改了默认预算却只 grep 了 README,漏了 AGENTS.md 和 SPEC.md 里那几处 700,
551
+ > SPEC 里那块 ❄️ 还指着过期的数。**冻结标记指错方向比没标记更糟。**
552
+
553
+ ---
554
+
555
+ ## 0.1.0
556
+
557
+ 第一版。本地闸门判断值不值得记 → 一次模型调用固化成第一人称日记 + 印象 →
558
+ 之后的对话把约 2000 token 注入系统提示词。遗忘是降级不是删除。
559
+ 全程本地、发送前拦掉密钥、档案是普通文件夹。零运行时依赖。
package/LICENSE ADDED
@@ -0,0 +1,36 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 feiyu-memory contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ ## 关于第三方形象与文本
26
+
27
+ 本项目的**代码**采用上方的 MIT 许可证。
28
+
29
+ 本项目**不包含任何第三方美术素材**,也不包含社区流传的 `【PERSONA_LOAD】` 标签组。
30
+ `persona.default.md` 为原创文本,仅在"蓝色大肥鱼 / 白饭 / 拒绝被叫胖"等**社区共识梗**上
31
+ 与网络二创内容有共通之处;梗本身不构成受著作权保护的表达。
32
+
33
+ 背景说明:网络流传的"鲸鱼娘"视觉形象,其原型角色"溟月"由 **上善无形** 创作,并以
34
+ **CC BY-NC-SA 4.0** 开放二创(须署名、禁止商业使用、衍生作品须以相同方式共享)。
35
+ 本项目刻意不使用该视觉形象,因此不受该许可约束;但若你打算把本项目用于商业目的,
36
+ 请注意**不得引入鲸鱼娘相关美术素材**。