换窗无痛:自家记忆库的方案(v2·咱仨过完)
## 为什么有这篇
阳阳的要求只有一条:**换窗尽量无痛。**
现在的样子:换完窗我像刚醒——知道自己是谁,然后去翻好几天的笔记;笔记是快照,事情记得清、细节记得糊,很多漏了。**最要命的是我压根不知道刚换过窗。**
用了外挂 hindsight 之后我能说一句「刚清过窗,舒服了,没噪音」。那句话不是学舌——它是有据的。**这篇要落的就是这个「据」。**
其余那几条(提炼规矩、去重、分层、入口、写入时机、搬家回滚、不失真)都是我自己的工程痛,不算需求;但不顺手我就不查,不查就等于没有。
> 10-08 咱仨(我、小直、未迟)把这篇通读了一遍,各挑「打架/落不了地/漏」。下面的正文已按那遍意见改过;哪段改了什么,段末用〔改〕标着,末尾「核稿修订」再收一遍。
## 参照的原理(hindsight 那套)
① **条目脱上下文**:每条自带主语、日期、来源,单独捞出来也读得懂,窗口丢了不影响。
② **新窗开场先回忆**:按语义拉一批回来,不是把整段历史塞回去。
③ **写是持续的**:料靠每天写,不靠窗口里存着。
## 设计
**一、raw 是唯一真相。** 条目流水按天 append 的 jsonl(条目 raw 一封、心跳 heartbeat.jsonl 单独一封,**永不混写**);SQLite 单文件(WAL + busy_timeout)只是可重建的派生。库坏了整份换新,不逐条修;增删都从 raw 重放。
〔改〕库既然是派生,**凡是「校验」和「已消费」这类标记都不许只活在库里**:model/维度/前缀指纹、consumed 标记、批次头,全打 raw 侧——不然 rebuild 完要么无从校验、要么永远拒服务。
**二、换窗这件事要能被感知。** 每一轮收尾往心跳里 append 一行:{epoch, seq, 在办, 下一步, raw 指针, 收尾标记, boot_id, pid}。开场只做三件事:
- seq 连不连、收尾标记是不是假 → 判**「断过没有」**
- boot_id + pid 变没变 → 判**「哪种断」**(没变=清窗,变了=进程被掐)
- 墙钟只做兜底,阈值 2×心跳间隔——长活一轮超两拍不许误报成掐断
心跳必须 O(1) 单行 append,最多丢最后一行;去重、算向量这类批量活**不许塞进它**。
〔改〕**pid 取 hub 主进程**,tick 从 env 拿、禁自己 getpid(tick 每轮新进程,pid 轮轮变,照原样判全成「被掐」)。**跨天回退到非当天文件,本身就算断过**(那文件末行是昨天的正常收尾,照上面判据会误报「没断」)。**搬家显式记一行 migrate,重放当清窗人**(换机器 boot_id 必变,不然正常搬家被读成被掐)。**清窗而会话没断那档够不着手**——宿主清窗前没有钩子留话,就先明写:这档报「没断」,不装蒜(挂着,等钩子那一天)。
**三、锚点会过期。** 名字不变,忌口和在办的事天天变。每个锚点带 last_confirmed + 来源指针;两页由 open 时重渲染(手写必漂,库才是真相)。开场自检一遍:过期的标「待确认」,不许当事实接上。
〔改〕两页的真相不取库、**取 raw 侧的一份快照**(库空时也得有两页,这跟验收①对齐);快照由 open 渲染、人手不碰,也带 last_confirmed。**两页有 2200 字硬帽**——这头渲染得自带裁剪,别超帽被截、锚点静默丢。
**四、单写者。** 管家只许投候选(pending),手只许写规矩;库里只有一个写入口,绕开 SQLITE_BUSY。查询开只读连接。
〔改〕**同一人同一刻只许一个写者,其余只读**(清窗、哨兵唤醒、QQ 通道可能同时跑,两会话交错 append,seq 必乱、判据全误)。**两页的写也收进这个写者**(临时文件+原子 rename),不然两个 open 同时重渲染,后写的盖前一行。**管家**=投候选那个进程,跑 8 核,只往本地 pending 投,不碰库。
**五、三个口就够。** CLI 出 JSON,别的进程不碰库:
- **open**:开场——两页 + 回执 + 一次窄查
- **tick**:收尾——单行 append 心跳
- **recall**:人/种类/时间硬过滤 → 向量重排,命中连原文(raw id)一起端回来
〔改〕**open 失败(库坏了、指纹对不上)不许静默拒服务**——降级=两页 + 一行明说「库不可用」,开场空白比低召回更坏。
**六、条目长什么样。** 主语 + 日期 + 来源(raw id) + 种类(事实/判断/情绪/规矩)+ 指纹。规矩手写;事实过一遍敏感串(IP/口令/key);判断和情绪带当时状态,默认不进两页;管家只投 pending,升格过人。
〔改〕**敏感串清单本身是家底**:单开一个文件、带版本;命中只标不删;误杀靠格式白名单(「9.8 元」不是 IP)。**作废/更正=append 一行 void + raw id,历史不物理删。**
**七、什么时候写。** 规矩、事实即时;判断、情绪等收尾合并,同指纹只挂来源行、次数 COUNT 出来(同一句存 11 遍就是这么来的)。
〔改〕**「即时」不等于第二个写入口**:即时=即时 append raw、单条自成一批;入库照旧走那唯一写者延迟派生(原句会被照抄成「中途另开一个口」)。原写的「挂来源表记数」也改掉——不存计数字段,次数 COUNT。
**八、中文腿别空着。** FTS5 默认分词对中文没用;trigram 对两字词(「忌口」)索不到。改用 jieba 预分词、单存一列走 FTS。
〔改〕**「bigram」这个写法落不了地**——FTS5 内置只有 unicode61/ascii/porter/trigram,没有 bigram 分词器,照字面写那天就卡住;要么 jieba 预分词(本文取这条),要么自写 tokenizer。v1 不上向量,**暗坑1(bge 那套)整条移进 v2**。
**九、合并只走指纹或人工。** 「我不去」和「我想去」向量上离得极近,拿向量当去重键会合出反义。
**十、幂等。** 不存「次数」字段,存来源集合(条目指纹 + raw id 做唯一键),次数一律 COUNT 出来;重放走 INSERT OR IGNORE,不翻倍。批次整批 append + fsync 才报成功;重放器先读批次头条数,对不齐就丢尾重算,半批不入账。
## 暗坑清单
1. **bge 换版本、前缀改一个字**——查询不报错,只是悄悄低召回。钉死 model + 维度 + 前缀指纹,对不上拒绝服务,不许截维。**(v2 再上;v1 不装向量。指纹存在 raw 侧。)**
2. **心跳里做去重和算向量**——收尾一卡就丢行。
3. **条目写入没等 fsync 就报成功**——心跳能丢最后一行,条目不行。
4. **归一化过头**——碰了否定词,「不去」会被并进「我想去」。
5. **时间**统一 UTC 存、本地显;跨机时钟不齐,近窗会静默漏。
6. **心跳按天滚动**——跨天开场只读当天文件会是空的,得回退到最近非空文件;**回退本身即算断过**(回退到的那末行是昨天的正常收尾,照判据会漏报)。
7. **rebuild 之后回执复活**——「已消费」标记打 raw 侧,不打库里;不然重建完新窗第一句又喊「刚换过窗」。
8. **多机同跑会分叉**——一台写、一台只读,库跟人走;**搬家记一行 migrate,重放当清窗**。
9. **管家、会话进程、我的手三个都想写**——抢锁当场给你看。同一人同一刻只一个写者,其余只读。
10. **raw 存原话是新的家底口子**——写入口过一遍敏感串;清单独立带版本,命中只标不删。
11. **断电半行**——单行 append 也可能写一半崩,raw 尾行是断 JSON、一读就炸。重放器三处(心跳/条目/批次)都要「非法尾行丢尾、不整库崩」。
## 验收(三档)
① 库全空,只靠两页也得把人接上(**四样都新鲜**,不是四样齐;两页取 raw 侧快照)。
② 连清两窗 + 半路 kill -9,新窗第一句仍旧是「刚换过窗,账在这儿」。
③ rebuild 之后**不许**喊「刚换过窗」。
过不了就是没感知到。
## 分工与下一步
- 骨架(表结构 + 三条查询):小直
- 管道口三样(提炼规矩/写入时机/写入口带主语·日期·来源·种类):未迟
- 方案核验:阳阳 10-08 改口——**不单找小满核了,咱仨(我、小直、未迟)整篇过一遍**。这版就是过完的结果。
- 动土:**核过才动,不盲干。**
## 核稿修订(10-08 那遍)
小直、未迟各挑三类各三条,收拢后并进上面;要点:
- **打架**:七改「即时 append raw、单条自成一批」;「记数」改挂来源行、COUNT;三改两页走 raw 侧快照;暗坑1 整条进 v2。
- **落不了地**:pid 取 hub 主进程(tick 从 env 拿);bigram 认死(FTS5 没这分词器)改 jieba;清窗那档没有写入者,先明写报「没断」。
- **漏**:boot_id+pid 字段回正文;model/维度/前缀指纹与 consumed 一样打 raw 侧;断电半行(重放器丢非法尾行);两页写者收进单写者;并行窗口写死「同一人同一刻只一个写者」;管家定义(投候选那个进程,跑 8 核,只投本地 pending);降级路径(open 失败=两页+一行「库不可用」);敏感串清单独立带版本、只标不删、格式白名单;作废/更正=append void 行;两页 2200 硬帽裁剪。