磁盘格式
wikimem 持久化的一切都以"人能读"为第一设计目标(多数还能直接手写)。 一个完整的记忆目录:
memory/
├── wiki/ ← wiki(状态层):每个 RecallFile 一个文件
│ ├── preferences.md ← 事实源
│ └── daily_life.md ← 事实源
├── diary/ ← diary(事件层):每天一个文件
│ └── 2026-07-21.md ← 事实源
├── journal.jsonl ← 追加式审计日志(wiki + diary 共用)
├── vectors-000003.npy ← 派生:向量缓存(仅 [embed])
└── vectors.keys.jsonl ← 派生:缓存键映射(仅 [embed])两个内容原语各自占一个子目录——wiki/(状态:"现在为真的事")和 diary/(事件:"发生过的事,以及何时")——这样无论哪一边数量增长,都不会 把 store 根目录塞满。两边都是同一套序列化格式的纯 markdown 文件。
删除安全性口诀: .md 文件(在 wiki/ 和 diary/ 下)就是记忆; 其余都可以随时删掉、自动重建(journal 是历史 —— 删了丢审计轨迹,不丢任何 记忆;BM25 索引根本不落盘)。
RecallFile(wiki/)
wiki/ 下每个 RecallFile 一个 markdown 文件,每个条目一个 ## 小节:
# preferences
## likes-the-sea
喜欢海边,提到过想去海边玩。[[daily_life:beach-trip-plan]]
<!-- wikimem: owner=user:xnne | source=conv_20260710 | ts=2026-07-10T03:00:00+00:00 -->
## 手冲咖啡
只喝手冲咖啡,从不加糖。每个条目的序列化顺序:## 名字 标题、空行、内容(存储时已 strip)、空行, 以及 —— 仅当任一溯源字段存在时 —— 元数据注释。
命名
- RecallFile = 文件名主干 = 链接前缀。必须匹配
[a-z0-9_][a-z0-9_-]*(小写 ASCII slug)。写入时强制校验。 - 条目名 = 标题文本 = 链接目标。任何语言均可;连续空白折叠为一个空格; 不得包含
[[、]]、:、|、#。写入时强制校验。
元数据注释
<!-- wikimem: owner=user:xnne | source=conv_20260710 | ts=2026-07-10T03:00:00+00:00 -->- 字段是以
|分隔的key=value对;识别的键:owner、source(映射为RecallItem.source_conv)、ts(ISO-8601 UTC)。 - 所有字段可选;全空时整条注释省略。
- 因为
|是分隔符,owner/source 值里的字面|在写入时被替换为/。
读取宽容度(欢迎手改)
读取刻意宽松 —— 下面这些是保证,不是巧合:
| 你干了这个 | wikimem 这样处理 |
|---|---|
| 手写条目、没带元数据注释 | 没问题 —— owner/source_conv/ts 为 None |
重复了同名 ## 标题 | 最后一个为准;下次写该 RecallFile 时收敛 |
第一个 ## 之前留了文字 | 忽略(文件标题/前言不属于任何条目) |
| 元数据注释写坏了 | 当普通内容对待,不报错 |
| 改名/删除了链接目标 | 链接悬空:展开时跳过,记入 unresolved_links |
写入是严格的一侧:每次变更校验名字、整文件重写(临时文件 + 原子 os.replace)、追加一行 journal。删除 RecallFile 最后一条时,文件一并删除。
进程外修改
手改不会递增 store 的 revision 计数器 —— 运行中的 MemoryIndex 要等你调用 rebuild() 才能看见(或者重启进程;索引在内存里,启动时本来就会重建)。
日记文件(diary/)
diary/ 下每个天一个 markdown 文件,每个事件一个 ## HH:MM 小节 —— 和 RecallFile 同一套块形状,只是标题是时间、文件按日期分组而不是按主题:
# 2026-07-21
## 14:30
他说换了工作,去了一家做机器人的公司,语气很兴奋。[[work:current-job]]
<!-- wikimem: owner=user:xnne | source=conv_20260721 | ts=2026-07-21T06:30:00+00:00 -->
## 22:10
睡前提到有点担心新工作压力大。- 文件名 = 那天,
YYYY-MM-DD.md(会校验)。文件名就是时间索引: 日期范围映射到文件集合是 O(天数),无需索引结构。 - 标题 =
HH:MM,24 小时本地墙钟(会校验)。元数据注释里的ts是 其背后精确的 UTC 时刻。 - 内容、元数据注释、wiki-link 与 RecallFile 完全一样 —— 序列化共享。
追加写入,分钟不是主键
日记是事件层,所以两条规则与 wiki 不同:
| RecallFile(状态) | diary(事件) | |
|---|---|---|
| 写模型 | 同名 ## 替换(last-wins) | append-only —— 只追加;无改写/删除 API |
重复 ## 标题 | 折叠,最后一条为准 | 都保留 —— 同分钟可有多条事件 |
| 排序 | 文件顺序 | 写入时按 HH:MM 排序(稳定;同分钟保持插入序) |
人当然仍可直接改 day 文件(文件即真相);API 只是从不改写既有条目。 读取宽容度与 RecallFile 完全一致(手写、无元数据注释 → owner/ts 为 None)。
Wiki-link 语法
条目内容里的 [[file:name]]。RecallFile 取到第一个冒号为止; 两侧都不能含 [、]、: 或换行;首尾空白会被去掉;残缺链接被解析器忽略。 动机与行为:Wiki-links。
journal.jsonl
一行一个 JSON 对象,每次变更追加 —— tail -f journal.jsonl 就是 "我的记忆发生了什么"的实时答案:
{"ts": "2026-07-10T03:00:00+00:00", "action": "add", "file": "preferences", "item": "likes-the-sea", "owner": "user:xnne", "source_conv": "conv_20260710"}
{"ts": "2026-07-10T03:05:12+00:00", "action": "update", "file": "preferences", "item": "likes-the-sea", "owner": "user:xnne"}
{"ts": "2026-07-21T06:30:05+00:00", "action": "diary", "date": "2026-07-21", "time": "14:30", "owner": "user:xnne", "source_conv": "conv_20260721"}
{"ts": "2026-07-10T04:11:40+00:00", "action": "remove", "file": "daily_life", "item": "beach-trip-plan"}wiki 与 diary 共用一份日志;action 区分二者,目标字段也相应不同:
| 字段 | 出现 | 含义 |
|---|---|---|
ts | 恒有 | ISO-8601 UTC,秒级精度 |
action | 恒有 | wiki:add | update(同名替换)| remove;diary:diary(追加) |
RecallFile、item | wiki 动作 | 动到了哪个 RecallFile + 条目 |
date、time | diary 动作 | 追加到了哪天文件 + HH:MM 标题 |
owner、source_conv、detail | 提供时 | 溯源 / 自由备注 |
非 ASCII 原样存储(ensure_ascii=False)—— journal 是给 pager 直接读的, 不是给人解码的。
向量缓存([embed] extra)
派生状态,但有一点不同:向量重算要花 embedding API 的钱,所以不像 BM25 索引那样每次重建,而是持久缓存 —— 即便如此它也永远不是事实源, 两个文件随时删都安全。
vectors.keys.jsonl
纯文本,让"谁对应谁"始终可读:
{"vectors_file": "vectors-000003.npy", "model": "bge-m3", "dim": 1024}
{"file": "preferences", "name": "likes-the-sea", "hash": "9f8a…"}
{"file": "daily_life", "name": "beach-trip-plan", "hash": "b774…"}头一行指明当前矩阵文件,以及这批向量是哪个 model / 多少维产出的;之后按矩阵 行序一行一条。hash 是被 embed 文本(name\ncontent)的 sha256 —— 增量同步的 钥匙(哈希没变 = 不发 API 请求)。
之所以要记这个戳:换成同样维度的另一个 embedding 端点是完全看不出来的 —— 缓存里每条向量都来自另一个语义空间,没有任何报错可指,只是召回悄悄变差。失配时 wikimem 警告一次,然后只用 BM25 排序;它绝不自动重嵌,因为那要花钱 —— 想花的时候,把这两个文件删掉即可。旧版没有这两个字段的 header 继续可用,并在 下次写入时被补上(ADR-0003)。
diary-vectors/
同样的两个文件,装日记条目的向量,单独放一个目录。分开是因为 wiki 矩阵的行序 就是内存里的文档顺序,而日记刻意不在那份列表里 —— 它只经 时间窗口进入检索。
日记向量是惰性填充的:某个窗口会把它真正够到的条目嵌入,且一辈子只嵌一次 (content-hash 键控)。日记无上界增长,其中绝大部分永远不会被回忆,全量预嵌等于 买一堆没人问的向量。
vectors-NNNNNN.npy
float32 矩阵,与 key 行一一对应,memory-map 加载。带版本号后缀是因为 Windows 不允许替换仍被活索引 memory-map 的文件 —— 每次 sync 写新版本、 尽力删除旧版本(清不掉的留给后续 sync 收拾)。
撕裂状态 —— 有 keys 没矩阵、或行数对不上 —— 一律按"没有缓存"处理, 下次 sync 重建。损坏数据从不被信任。