Skip to content

磁盘格式

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 文件,每个条目一个 ## 小节:

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 对;识别的键:ownersource (映射为 RecallItem.source_conv)、ts(ISO-8601 UTC)。
  • 所有字段可选;全空时整条注释省略。
  • 因为 | 是分隔符,owner/source 值里的字面 | 在写入时被替换为 /

读取宽容度(欢迎手改)

读取刻意宽松 —— 下面这些是保证,不是巧合:

你干了这个wikimem 这样处理
手写条目、没带元数据注释没问题 —— owner/source_conv/tsNone
重复了同名 ## 标题最后一个为准;下次写该 RecallFile 时收敛
第一个 ## 之前留了文字忽略(文件标题/前言不属于任何条目)
元数据注释写坏了当普通内容对待,不报错
改名/删除了链接目标链接悬空:展开时跳过,记入 unresolved_links

写入是严格的一侧:每次变更校验名字、整文件重写(临时文件 + 原子 os.replace)、追加一行 journal。删除 RecallFile 最后一条时,文件一并删除。

进程外修改

手改不会递增 store 的 revision 计数器 —— 运行中的 MemoryIndex 要等你调用 rebuild() 才能看见(或者重启进程;索引在内存里,启动时本来就会重建)。

日记文件(diary/

diary/ 下每个一个 markdown 文件,每个事件一个 ## HH:MM 小节 —— 和 RecallFile 同一套块形状,只是标题是时间、文件按日期分组而不是按主题:

markdown
# 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/tsNone)。

条目内容里的 [[file:name]]。RecallFile 取到第一个冒号为止; 两侧都不能含 []: 或换行;首尾空白会被去掉;残缺链接被解析器忽略。 动机与行为:Wiki-links

journal.jsonl

一行一个 JSON 对象,每次变更追加 —— tail -f journal.jsonl 就是 "我的记忆发生了什么"的实时答案:

json
{"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(追加)
RecallFileitemwiki 动作动到了哪个 RecallFile + 条目
datetimediary 动作追加到了哪天文件 + HH:MM 标题
ownersource_convdetail提供时溯源 / 自由备注

非 ASCII 原样存储(ensure_ascii=False)—— journal 是给 pager 直接读的, 不是给人解码的。

向量缓存([embed] extra)

派生状态,但有一点不同:向量重算要花 embedding API 的钱,所以不像 BM25 索引那样每次重建,而是持久缓存 —— 即便如此它也永远不是事实源, 两个文件随时删都安全。

vectors.keys.jsonl

纯文本,让"谁对应谁"始终可读:

json
{"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 重建。损坏数据从不被信任。

基于 Apache-2.0 许可发布