@yandy0725/pi-memory2.5.1
File-system driven persistent memory layer for pi coding agent
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-memory
pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏好、调试历史)以纯 Markdown 文件的形式存在 ~/.pi/memory/<git|local>/<project>/ 下,跨会话可用。
对齐 Claude Code 的自动记忆机制:一条记忆 = 一个文件、MEMORY.md 索引一行一条记忆、按相关性自动浮现、每轮自动提取、记忆分类。
⚠️ 破坏性变更
2.5.0:
- Windows 上的目录名形态变了。
local/<项目>的 key 现在按\与/一起分段,因此 Windows 项目目录是一个可读的单分量名(C_3a__Users__you__proj),而不是嵌套的一棵树(C_3a/Users/you/proj)。旧嵌套布局下的记忆会变成孤儿目录:在项目里跑一次/memory读出新的Dir:路径,再建好那个目录并把旧项目目录里的内容搬过去(PowerShell 里先New-Item -ItemType Directory -Force "<新目录>",再Move-Item "<旧项目目录>\*" "<新目录>");留下的空中间目录可以不管。git/<host>__<owner>__<repo>在 POSIX 上形态不变;Windows 上同样不变,除非 key 的首段是保留设备名、或 key 含反斜杠、或以点或空格结尾 —— 这类少数名字现在会被收尾(见 Windows)。POSIX 输出与之前逐字节一致。- stem 是 Windows 保留设备名的 entry 文件现在会加
_前缀(name: "CON"→_CON.md),所有平台都这样,这样名字在共享的git/目录里两边都安全。只影响新建文件 —— 既有 entry 保留原名。2.4.0:
- 包级开关
enabled已删除。memory.json不再有顶层enabled:残留的键会被忽略,{"enabled": false}不再能禁用任何东西 —— 它以前还会跳过模型校验,所以被禁用的配置往往也没配模型。要禁用整个扩展,请像禁用其他 pi package 一样「不加载它」,见禁用本扩展。dream现在是每个会话的必需任务。/memory不再输出Memory: enabled|disabled行。 健康态状态块为 7 行、以Dir:起头;旧的「禁用」态已不存在,只剩健康与配置错误两态。模块级开关(autoSurfacing.enabled/extractMemories.enabled)不变。2.3.0:
- 注入的索引窗口改为「最新的 50 行 / 16 KiB」。
memIndexInjectMaxLines200 → 50、memIndexInjectMaxBytes25600 → 16384。写入口径不变(200 行 / 25600 字节),因此比「最新 50 行」更旧的记忆不再进 system prompt —— 它们仍可由 auto-surfacing 与memory工具检索。已经显式配置memIndexInjectMax*的用户不受影响。2.2.0:
extractMemories.enabled默认改为false。 每轮自动提取现在是 opt-in:开启后每轮结束都会跑一次 headless 模型调用。已经显式写了"extractMemories": { "enabled": true }的配置不受影响。2.1.0:
- 模型必须显式配置。 没有内置默认值,也没有父会话模型回退:
defaults.model(或 per-taskmodel)必须存在且可解析,否则session_start会报配置错误并且什么都不初始化。详见模型配置。/memory on//memory off已删除。enabled只是memory.json里的开关,启动时读一次,改动需要重启会话。该键现已移除 —— 写了也会被忽略,见禁用本扩展。- 1.x → 2.0 的自动迁移已删除。 legacy topic 文件原样留在磁盘上,但对记忆系统不可见(过不了
parseEntryFile的 v2 五字段校验)。详见 1.x 数据。2.0.0 已包含:
- 索引现在一行一条记忆(1.x 是一行一个 topic 文件,文件里塞很多
## entry)。- 每条记忆独占一个文件,frontmatter 五个字段:
name、description、type、created、modified(1.x 用的是updated)。- 索引改为以 system prompt section(
memory_index)注入,并在整个会话内冻结,而不是拼到 system prompt 字符串末尾。
功能
- 一个
memory工具,主 agent 五个 action:add、replace、remove、list、search。另外两个 ——rename与rebuild_index—— 只存在于/dream自己的 headless 会话里,绝不出现在主 agent 或 extract 的 schema 中。 - 一条记忆 = 一个文件 —— 不再有多条目
## section块;name是定位键,add一个已存在的name就是幂等覆盖。 MEMORY.md索引 —— 一行一条记忆:- [Name](file.md) — description。分隔符是 em dash(—,U+2014),两侧各一个半角空格。memory_indexsection,会话内冻结 ⭐ —— 索引写进event.systemPromptOptions.sections["memory_index"],其值在整个会话内不再变化,只有 compaction 会从磁盘重读。pi 对 sections 做 diff,值没变就一条消息都不追加,于是 system prompt 逐轮逐字节相同,provider 的 prefix cache 一直命中。resume/fork/reload用 transcript 里的录制值重放,而不是读磁盘,因此恢复会话不会改写它的头部。- 注入净化 —— 所有注入内容(索引行、浮现的 entry 正文与 name)都会剥离不可见/bidi 字符并转义
<>,因此记忆无法伪造</relevant_memories>、<system>、<project_instructions>、<active_agent …>或<memory_index>。净化只发生在注入时:磁盘上的文件永远不会被改写(保持可读、可手工编辑)。 - 自动浮现(auto-surfacing) ⭐ —— 每个用户回合由一次轻量侧查询挑出至多
maxFiles条 entry(只看description),把正文注入<relevant_memories>。同一会话内按文件名去重;清单来自进程内的mtime缓存,每回合只付一次readdir+ 每文件一次stat。子 agent 中不启用。 - 自动提取(extract memories) ⭐ —— 每轮结束后一个异步 headless agent 拿到的是整轮对话的结构化渲染(user 消息全文、assistant 文本与 tool_call、tool_result 及其错误标记),而不是两条消息。它经同一套
memory原语写入,并且从不排队等锁:dream 正在整轮持锁时,本回合直接跳过。该功能默认关闭,需要显式设extractMemories.enabled: true。 /dream—— headless 整理 agent(Orient → Gather Signal → Consolidate → Prune & Index),合并重复、消解矛盾、改名、重建索引。它没有裸文件权限:只有七个memoryaction,整轮持有逻辑锁,进入时先对整个目录拍一次快照。- Dream 提醒 —— 距上次 dream 超过 N 个会话或 N 小时后提示
/dream。 /memory—— 完整状态(目录、索引容量、entry 数、上次 dream、锁状态含持有者),以及unlock。- 两级锁 —— 进程内逻辑锁承担逻辑作用域(单次原语,或 dream 的整轮);跨进程
.lock只持毫秒且永不自动回收。没有 TTL、没有心跳、没有接管,所以互斥是硬保证;代价是崩溃遗留的锁必须人工清除(/memory unlock)。 - 快照 —— 每次写入都在
.backups/<ts>-<label>/留下回滚点,保留最近lock.snapshotKeep份(migrate-开头的目录是旧版迁移留下的整目录快照,其originals/子目录里才是 2.0 之前的 topic 原文,永不裁剪)。/dream是例外:它进入时只对整个目录拍一次快照,该轮内部的原语会跳过逐文件快照(一轮只留一个回滚点)。 - 会话检索 ——
memory search scope=sessions查历史会话。 - 可读、clone 友好的布局 —— git 仓库(http(s)/ssh/git remote,含 scp 写法与
git+ssh/git+https)存在~/.pi/memory/git/<host__owner__repo>/,其余存在~/.pi/memory/local/<absolute-path>/;同一仓库的 clone 与 worktree 共享记忆(fork 有自己的 remote,因此有独立目录)。
安装
pi install npm:@yandy0725/pi-memory
或加入 ~/.pi/agent/settings.json:
{
"packages": ["npm:@yandy0725/pi-memory"]
}
存储布局
~/.pi/memory/git/github.com__owner__repo/
MEMORY.md — 索引:一行一条记忆(em dash 分隔)
SSH-port-on-staging.md
Test-command.md — 一条记忆一个文件
.lock — 跨进程写锁(只持毫秒,永不自动回收)
.backups/ — 回滚点:<ISO-ts>-<label>/(以及旧版 1.x 迁移留下的 migrate-<ts>/ 整目录快照)
.dream-meta.json — 上次 dream 的时间与会话数(提醒逻辑用它)
sessions/ — headless 会话落盘目录,仅在开启 sessionPersistence 时使用
Entry 文件
---
name: SSH port on staging
description: staging SSH listens on 2222, not 22; key at ~/.ssh/staging
type: project
created: 2026-07-13
modified: 2026-10-02T08:14:03.120Z
---
staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
name—— 唯一、可读的标题;replace/remove/rename的定位键。再次add同名记忆是覆盖,不会产生第二条。description—— 一行自包含的说明。侧查询判断相关性时只看得到它,所以它必须脱离正文也能读懂。反例:Debugging tips;正例:staging SSH listens on 2222, not 22。type——user|feedback(默认)|project|reference。created——YYYY-MM-DD,只在新建时写入,覆盖时保留。modified—— ISO 8601,一律由 store 写入,调用方传不进来。
文件名由 name 派生(不安全字符替换、100 字节上限,只有文件名冲突时才追加 -2 / -3)。任何 name 都写不到 MEMORY.md 上。
MEMORY.md 索引
# Memory Index
- [SSH port on staging](SSH-port-on-staging.md) — staging SSH listens on 2222, not 22
- [Test command](Test-command.md) — run npm test, not npm run test
写入是外科式的:只改目标行,手写的标题、分组、注释逐字保留,行序稳定。唯一的例外是行尾:CRLF(以及单独的 CR)在解析前被归一为 LF,因此第一次写入 CRLF 文件会把它整体转成 LF。
记忆类型
| 类型 | 含义 | 示例 |
|---|---|---|
user |
用户角色、偏好、知识背景 | "用户是聚焦可观测性的数据科学家" |
feedback |
经验、纠正、确认(默认) | "集成测试用真库不要 mock —— 上季度栽过" |
project |
项目状态、时间点、事故 | "移动端 2026-03-05 起封版" |
reference |
外部系统的指针 | "缺陷跟踪 = Linear 的 INGEST 项目" |
容量:200 行索引 ≈ 199 条记忆
索引上限是 memIndexMaxLines(200)个非空行与 memIndexMaxBytes(25600)字节。这 200 行是索引行,不是记忆条数:rebuildIndex 至少保证一行头部(已有手写头部时原样保留 —— 首个条目之前的末尾空行会被去掉;否则写 # Memory Index),手写的标题、分组、注释同样占额度。因此重建后的索引最多约 199 条记忆(每个项目目录;若保留手写标题则更少)。超限时写入不会失败:写入照样成功,工具把一条可操作的警告回给模型,让它去合并或删除条目(超出上限的部分下次加载时不可见)。
真正进模型的是另一个更小的窗口:memIndexInjectMaxLines / memIndexInjectMaxBytes(默认 50 行 / 16384 字节)。窗口取索引的最新一端 —— 即文件底部:memory(action="add") 追加到末尾、/dream 的 rebuild_index 按 modified 重排。两种例外值得知道:memory(action="replace") 是原地重写那一行,被改写的老记忆会保持原位(可能就留在窗口外)直到下次 /dream 重排;而文件顶部的手写标题/分组/注释是位置语义而不是时间语义,索引一旦超过 50 行,先被丢出窗口的正是这块手工整理的内容。因此写满的索引恰好注入最新的 50 条记忆(窗口一旦截断,# Memory Index 头行与它下面的空行就落在窗口外);48 条及以下则整份注入。被略过的记忆没有丢:auto-surfacing、memory(action="search") 与 /dream 整理都还能看到它们。/memory 用两行区分两套口径:Index: 是写入口径(磁盘真相),Inject: 是进 system prompt 的窗口。
这也是 /dream 不再是「可选的整理」而是容量管理必需的原因。两个阀值要分开看:prompt 可见性止于注入窗口,想让每条记忆都进 system prompt,就在接近 48 条之前整理;199 只是写入口径的硬上限,过了它索引自身就必须缩小。
配置
在 agent 目录(~/.pi/agent/memory.json)或项目 .pi/ 目录(仅在项目被信任时)创建 memory.json:
{
"memoryDir": "~/.pi/memory",
"memIndexMaxLines": 200,
"memIndexMaxBytes": 25600,
"memIndexInjectMaxLines": 50,
"memIndexInjectMaxBytes": 16384,
"lock": { "timeoutMs": 5000, "snapshotKeep": 5 },
"defaults": { "model": "provider/model-id", "sessionPersistence": { "enabled": false } },
"dream": { "nudgeAfterSessions": 5, "nudgeAfterHours": 24, "thinkLevel": "high" },
"sessionSearch": { "maxSessions": 10, "maxMatches": 5 },
"autoSurfacing": {
"enabled": true,
"thinkLevel": "off",
"maxFiles": 3,
"maxEntryBytes": 3072,
"maxInjectionBytes": 10240
},
"extractMemories": {
"enabled": false,
"thinkLevel": "high",
"maxContextTokens": 2000,
"maxToolResultChars": 500,
"maxAssistantChars": 2000
}
}
每个
model值都必须能在你的 registry 里解析 —— 没有默认值。缺失或解析不出的模型会让session_start报配置错误并且什么都不初始化。详见模型配置。
| 键 | 默认值 | 说明 |
|---|---|---|
memoryDir |
~/.pi/memory |
所有记忆数据的根目录。~、~/ 与(Windows 上)~\ 会展开;相对路径按工作目录解析为绝对路径 |
memIndexMaxLines |
200 |
写入口径:MEMORY.md 的最大非空行数(# Memory Index 头行与手写标题同样占额度,所以并不等于记忆条数) |
memIndexMaxBytes |
25600 |
写入口径:MEMORY.md 的最大字节数 |
memIndexInjectMaxLines |
50 |
注入口径:放进 memory_index section 的最大行数。窗口保留最新的行、丢弃最旧的行 —— 索引是纯时间序,窗口再小也不会藏住你刚写完的那条。任一键写 0 = 完全不注入索引(section 保持空值) |
memIndexInjectMaxBytes |
16384 |
注入口径:section 的最大字节数(优先丢最旧的行,截断标记在开头) |
lock.timeoutMs |
5000 |
单次原语等逻辑锁 / 等跨进程 .lock 的上限。同时也是 session_shutdown 等在途写入的上限 |
lock.snapshotKeep |
5 |
.backups/ 保留的回滚点数量(migrate- 前缀的目录永不裁剪 —— 它们是旧版迁移留下的整目录快照,originals/ 子目录里装着 2.0 之前的 topic 原文) |
defaults.model |
—(必需) | 三个子任务的共享模型。没有默认值:会执行的任务必须能解析出模型,否则启动失败(见模型配置)。per-task 覆盖它 |
defaults.sessionPersistence.enabled |
false |
共享回退:headless 子会话(extract / dream / 侧查询)默认只在内存里跑 |
defaults.sessionPersistence.sessionDir |
<项目记忆目录>/sessions/ |
headless 会话的自定义落盘目录 |
dream.nudgeAfterSessions |
5 |
距上次 dream 多少个会话后开始提醒 |
dream.nudgeAfterHours |
24 |
距上次 dream 多少小时后开始提醒 |
dream.model |
— | dream 用的模型("provider/id")。回退 defaults.model;没有 defaults.model 时必填(必须可解析,不回退父会话模型) |
dream.thinkLevel |
"high" |
dream 的思考强度:off / minimal / low / medium / high / xhigh |
dream.sessionPersistence.* |
继承 defaults |
把 dream 会话落盘(调试/审计用) |
sessionSearch.maxSessions |
10 |
search scope=sessions 扫描的最大会话数 |
sessionSearch.maxMatches |
5 |
历史检索返回的最大命中数 |
autoSurfacing.enabled |
true |
⭐ 开启每回合的 entry 自动注入 |
autoSurfacing.model |
— | ⭐ 相关性侧查询用的模型。回退 defaults.model;没有 defaults.model 时必填(必须可解析,不回退父会话模型) |
autoSurfacing.thinkLevel |
"off" |
⭐ 侧查询的思考强度("off" 最省) |
autoSurfacing.maxFiles |
3 |
⭐ 每回合最多注入几条 entry |
autoSurfacing.maxEntryBytes |
3072 |
⭐ 单条 entry 正文的注入字节上限(超出截断)。取代 1.x 的 maxTopicBytes(旧键已失效) |
autoSurfacing.maxInjectionBytes |
10240 |
⭐ 每回合注入内容的总字节上限 |
autoSurfacing.sessionPersistence.* |
继承 defaults |
把侧查询会话落盘 |
extractMemories.enabled |
false |
⭐ 开启每轮自动提取。默认关闭(opt-in):开启后每轮结束都会跑一次 headless 模型调用 |
extractMemories.model |
— | ⭐ 提取 agent 用的模型。回退 defaults.model;没有 defaults.model 时必填(必须可解析,不回退父会话模型) |
extractMemories.thinkLevel |
"high" |
⭐ 提取的思考强度 |
extractMemories.maxContextTokens |
2000 |
⭐ 渲染后对话的预算(× 4 个字符;超出时先裁中段、首尾优先保留,user 消息最后才动) |
extractMemories.maxToolResultChars |
500 |
⭐ 单条 tool_result 渲染的字符上限 |
extractMemories.maxAssistantChars |
2000 |
⭐ 单条 assistant 文本渲染的字符上限(user 消息从不截断) |
extractMemories.sessionPersistence.* |
继承 defaults |
把 extract 会话落盘 |
headless 会话默认落在 <项目记忆目录>/sessions/ —— 在项目记忆目录里,不在你的工作副本里。
禁用本扩展
pi-memory 没有自己的包级开关 —— memory.json 里原来的顶层 enabled 键已移除,写了也会被忽略。禁用它与其他 pi package 一样,靠「不加载这个扩展」:
只在本项目禁用(项目根目录的 .pi/settings.json,仅在项目被信任后读取):
{ "packages": [{ "source": "npm:@yandy0725/pi-memory", "extensions": [] }] }
项目条目会替换个人条目,因此该项目不会加载本扩展。全局禁用:pi remove npm:@yandy0725/pi-memory,或用 pi config 关闭该包的资源(项目 scope 会写成 autoload: false + extensions: ["-index.ts"])。扩展保持加载时,仍可用模块级开关(autoSurfacing.enabled / extractMemories.enabled)单独关掉某个行为。
模型配置
会执行的任务必须能解析出模型 —— 既没有随包默认值,也没有父会话模型回退。defaults.model 可以满足全部任务;各任务自己的 model(dream.model / extractMemories.model / autoSurfacing.model)优先于它。
| 任务 | 何时必需 |
|---|---|
dream |
恒需要 —— 每个会话启动时都校验 |
extractMemories |
extractMemories.enabled 为真时 |
autoSurfacing |
autoSurfacing.enabled 为真时 |
session_start 会把每个必需模型拿到注册表里解析;只要有缺失或解析不出的,就不初始化任何东西:弹一条 error 通知 pi-memory config error: + 每个问题一行 - <error>,/memory 则报 Memory: misconfigured + Dir: not initialized + 同样的行。两条错误文案:
no model for <task> — set "<task>.model" or "defaults.model" in memory.jsonmodel "<value>" for <task> is not resolvable (unknown id or missing credentials)
改好 memory.json 后重启会话 —— 配置在启动时只读一次。headless / print 会话里配置错误是静默的(不会弹任何通知),所以要在交互式会话里用 /memory 确认。
工作原理
会话生命周期
| 事件 | pi-memory 做什么 |
|---|---|
session_start |
加载配置 → 校验必需模型(失败即什么都不初始化)→ 解析记忆目录 → 确定索引值并冻结(startup/new 读磁盘;resume/fork/reload 重放 transcript 取录制值)→ 注册 memory 工具(仅首次,5 个 action)→ 重建清单缓存 → dream 提醒检查 |
before_agent_start |
把冻结值写进 sections["memory_index"](每一轮、无条件),然后做 auto-surfacing(主会话且非子 agent) |
agent_end |
extractMemories.enabled 开启时触发异步 extract;写入成功通知 Extracted N memories.,失败通知 Extract failed: …(每会话一次) |
session_compact |
清空已注入集合,并从磁盘重读索引 —— 会话内唯一的刷新点 |
session_shutdown |
等在途写入收尾(上限 lock.timeoutMs),避免退出时留下 stale 的 .lock |
为什么索引要冻结
pi 用有序的 sections 构建 system prompt,只对值发生变化的 section 追加一条 patch 消息;全部没变时一条都不追加。自定义 section 插在最后,所以 memory_index 是 system prompt 的最后一段,其后紧跟整段对话。一旦它的值在会话中途变化,折叠后的头部就会被改写,其后的全部内容失去 prefix cache。因此:一个会话一个值,只有 compaction 才刷新(那时对话中段本来就要被重写)。
有意接受的代价:本会话内写入的记忆不会出现在本会话的索引里。工具返回值已经确认写入,下一个会话立刻可见。
如果宿主的 pi 还没有 sections API,pi-memory 回退为把索引拼到 system prompt 字符串末尾 —— 功能不变,只是缓存变差。
自动浮现
- 从 store 的
mtime缓存构建清单:[type] file.md — description,按modified降序,最多 200 条、4000 字符(description 预算按条数均分,每条不低于 80 字符)。 - 侧查询(
maxTurns: 1、零工具)从清单里返回至多maxFiles个文件名;不在清单里的一律丢弃。 - 选中 entry 的正文(不含 frontmatter)按
maxEntryBytes截断、净化,作为一条display: false的 custom 消息注入,外面包<relevant_memories>;总量到maxInjectionBytes就停。 - 注入过的文件名在本会话内记住,compaction 时清空。你会看到
Recalled: N entries通知。
自动提取
默认关闭。 先在
memory.json里设"extractMemories": { "enabled": true }(配置只在session_start读一次,改动需重启会话),并确保extractMemories.model或defaults.model可解析。
extract 拿到的是结构化渲染,而不是有损的两条消息摘要:
=== Conversation ===
[1] user: <全文>
[2] assistant: <文本> | tool_call: memory({"action":"list"})
[3] tool_result: <摘要,按 maxToolResultChars 截断>
[4] user: <纠正>
它只有主 agent 的五个 action(永远拿不到 rename / rebuild_index)、没有文件工具、maxTurns: 5、超时 120s。逻辑锁被占用时(dream 正在整轮持有)它跳过本回合而不是排队 —— 下一次 agent_end 还会来。
锁
| 层级 | 作用域 | 行为 |
|---|---|---|
| 进程内逻辑锁(按记忆目录分键) | 单次原语;或 dream 的整轮 | 最多等 lock.timeoutMs,超时抛一条写明目录的可读错误。extract 用不等待的形态,直接跳过本回合 |
跨进程 .lock |
每次重试调用毫秒级,只包住物理写入(重试风暴下可持有数秒) | 用 open(…, "wx")(`O_CREAT |
因此写入中途崩溃可能留下一个 .lock,而且没有任何进程会替你删掉它 —— 这是「互斥是硬保证」的刻意代价。错误文案会写明 pid、op、开始时间与路径;/memory unlock 是唯一被认可的清除方式。
工具参考
memory(action: "add" | "replace" | "remove" | "list" | "search",
name?, description?, content?, type?, query?, scope?)
description 是未来会话唯一能拿到的相关性信号 —— add 与 replace 请务必带上自包含的一行。
add
新建一条记忆,或覆盖 name 完全相同的那一条(幂等;created 保留)。
name(必填)—— 唯一、可读的标题content(必填)—— 记忆正文,它会成为整个 entry 文件description(可选)—— 一行自包含说明;缺省取content的第一句type(可选)——user/feedback(默认)/project/reference
replace
重写已有记忆的 content / description / type,按 name 定位。改名是 rename,dream 专属。
remove
删除 name 匹配的记忆 —— 文件、索引行一起删。找不到条目或删不掉文件时明确报错,不会假装删成功。
list
每条记忆一行:- name (type, modified …) — description [file]。
search
query(必填)scope(可选)——memory(默认:在所有 entry 的 name / description / 正文里找)或sessions(查历史会话)
dream 专属 action
rename(name + new_name;文件名与索引行跟着走,并保持索引行的位置)与 rebuild_index(从磁盘全量重建,保留手写头部)。它们只注册在 /dream 的 headless 会话里,主 agent 与 extract 都调不到。
命令
/memory
/memory — 查看状态
/memory unlock — 清除遗留的 .lock(会先要求确认)
状态输出:
Dir: /home/you/.pi/memory/git/github.com__owner__repo
Index: 38/200 lines, 2841/25600 bytes, 1 unrecognized lines
Inject: 39/50 lines, 2841/16384 bytes
Entries: 37
Modules: dream=on(provider/model-a) extractMemories=off autoSurfacing=on(provider/model-b)
Last dream: 2026-10-01T22:10:04.882Z
Lock: free
Index用写入口径(memIndexMax*),并报告索引里有多少非空行解析不出(# Memory Index头行与手写标题会计入)。CRLF(以及单独的 CR)行尾在解析前就被归一为 LF,下一次写入也一律输出 LF,因此被 Windows 编辑器改过行尾的MEMORY.md不会推高这个计数。注入侧同样做归一:CRLF 文件不会把\r送进 system prompt。Inject用注入口径(memIndexInjectMax*),统计窗口内的行数与字节数 —— 即真正会进memory_indexsection 的索引文本(截断标记本身不计入)。它与真正注入的值由同一份窗口代码算出来,不可能漂移。注意两行的口径不同:Index数的是非空行,Inject数的是窗口内的全部行,所以规范索引(LF 行尾、以换行结尾、头部后有且仅有一个空行)下Inject会比Index多一行而字节数相同。system prompt 里的值是会话内冻结的(见为什么索引是冻结的):session_start之后写入的记忆会立刻出现在Index,但要等 compaction 或下一个会话才出现在Inject。Modules报三个模型驱动功能的激活状态:on(<生效模型>)/off。生效模型 = 该任务自己的model,没有则用defaults.model。dream没有独立开关 —— 健康会话里它始终可用。Lock有三种:free、held by <op> (pid N on <hostname>, started <ISO>)、unreadable — run /memory unlock。/memory unlock的确认框会显示同一行持有者信息。- 必需模型缺失或解析不出时不初始化任何东西,
/memory报Memory: misconfigured+Dir: not initialized+ 每行一条- <error>;同样的错误在 session_start 时以 error 通知出现。
/dream
先要求确认,然后对整个目录拍快照,再跑一个 headless agent 走完四个阶段:
- Orient ——
list,读相关 entry - Gather Signal —— 找重复(同一事实的多条 entry)、矛盾、过时内容
- Consolidate —— 用
replace+remove合并,用rename改名 - Prune & Index —— 用
rebuild_index兜底重建
它碰不到文件:只有七个 memory action。完成时通知摘要,失败时通知 Dream failed: …。模型可用 dream.model 配置。
1.x 数据
1.x → 2.0 的自动迁移已被删除。legacy topic 文件(frontmatter 带 updated 而缺 created/modified,过不了 v2 的五字段 frontmatter 校验)原样留在磁盘上,且对记忆系统不可见 —— parseEntryFile 要求 v2 的五个 frontmatter 字段,所以这类文件不会出现在索引、注入、list/read/search 里,/dream 也看不到它们(dream 只有 memory 工具)。要人工恢复内容,把每个 ## 段拆成带 v2 frontmatter(name、description、type、created、modified)的独立文件。旧版迁移建过的目录仍然永不被裁剪:.backups/migrate-*/originals/ 里是 2.0 之前的 topic 原文,.backups/migrate-*/MEMORY.md 是当时的索引。
文件布局
~/.pi/memory/
git/
github.com__yandy__pi-packages/ ← https://github.com/yandy/pi-packages.git
MEMORY.md — 索引:一行一条记忆
SSH-port-on-staging.md
Test-command.md — 一条记忆一个文件
.lock .backups/ .dream-meta.json
local/
home__yandy__workspace__scratch/ ← 非 git 目录 /home/yandy/workspace/scratch
目录名的推导规则:
- remote 是 http(s)、ssh(含 scp 写法
[user@]host:owner/repo,user 可省略)或git://,以及git+ssh:///git+https://别名的 git 仓库 →git/<host>__<owner>__<repo>;端口、凭据、结尾的/与.git会被剥掉,host 转小写 - remote URL 从原始 git config 读取(
remote.<name>.url;先origin,再按字母序,第一个可用的胜出),所以url.*.insteadOf重写不会影响映射 - scheme 形式走 WHATWG URL 规范化(IDN host 转 punycode,百分号编码与
./..折叠生效,凭据/query/fragment 被丢弃),scp 形式保留原样路径 —— 等价但写法不同的 remote 可能映射到不同目录 - 其余情况 —— 非 git 目录、没有 remote 的 git 仓库、
file://、UNC(\\server\share\repo.git)、相对路径与本地路径 remote →local/<absolute-path>(git 仓库用仓库根)。Windows 盘符形式的 remote(Z:\repos\foo.git)在 Windows 上算本地路径,与 git 在那里的一致;同一串在 POSIX 上是 scp 写法(盘符字母是 host),仍映射到git/目录名,也与 git 一致 /变成__;文件名里不可移植的字符(<>:"|?*、控制字符)变成_XX十六进制转义- 超过 120 UTF-8 字节的名字在码点边界截断到 100 字节,再加
__<hash8>后缀 - 名字是平台感知的:Windows 上反斜杠是分隔符,并避开保留设备名与结尾点/空格;POSIX 上反斜杠仍是普通字符,上述 Windows 规则不适用。唯一在所有平台都生效的规则是「stem 形如设备名的 entry 文件名加
_前缀」(见 Windows)
映射不是单射:下划线原样保留,所以 /home/a__b 与 /home/a/b 都映射到 home__a__b(共享同一个记忆目录)。改动或重命名 remote、新增一个排序更靠前的 remote、移动本地目录,都会改变记忆目录,旧目录会被孤立。
更老的布局: 1.x 之前的版本把记忆存在 ~/.pi/memory/<12-char-sha256>/;这些目录不再被读写。要手工迁移,用 printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12 算出旧 hash(不在 git 仓库里就用 $PWD),把那个目录 mv 到新位置(在项目里跑 /memory 可以看到新路径),其 topic 文件需要按 1.x 数据手工拆分。
Windows
pi-memory 在 Windows 上原生可用,不需要 WSL。
- 目录名。 由项目 key 派生的名字一定是单个、合法的 Windows 分量:
\被当作分隔符(C:\Users\you\proj→C_3a__Users__you__proj),结尾的点或空格被十六进制转义(proj.→proj_2e),而「第一个.之前的部分」是保留设备名的名字会加_前缀(nul→_nul)。普通 remote 的git/目录名在所有平台上一致(github.com__owner__repo),因此共享memoryDir时同一仓库从 Linux 与 Windows 打开都会落到同一个目录。首段与保留设备名撞名(如aux.example.com/...)、含反斜杠、或以点或空格结尾的 key,其目录名在两端仍可能不同。 - 仓库根。 根路径取自
git rev-parse --show-toplevel:从仓库子目录启动与从仓库根启动映射到同一个记忆目录。注意该路径是按 git 的输出原样使用的,必须是原生 Windows 形态 —— Git for Windows 会打印C:/...;若 PATH 上的git是 cygwin/MSYS 构建,它会打印/cygdrive/c/...这类 POSIX 形态路径,记忆目录会落到错前缀下。装了多个 git 时,把 Git for Windows 的git.exe放到 PATH 前面。 memoryDir。~、~/与(Windows 上)~\都会展开;相对路径会被解析成绝对路径。Windows 路径与 UNC 共享都可用。- 锁。 跨进程锁用
open(…, "wx")(CREATE_NEW)建立。在网络共享上这个原语在共享的单一命名空间里是原子的,因此锁能排除使用同一共享的所有机器上的进程 ——memoryDir放在非 NTFS 卷上也能用。在同步客户端目录(OneDrive、Dropbox 等)里没有单一命名空间:每台机器各留一份副本,锁只能排除同一台机器上的进程 —— 不要用这种方式在多台机器间共享memoryDir;需要跨机互斥时用网络共享。杀软、编辑器或索引器造成的瞬时EPERM/EACCES/EBUSY会做短退避重试;持续失败仍按原始错误报出。 - 换行符。 读取记忆文件时一律容忍 CRLF 与孤立 CR(解析前归一为 LF),写入一律输出 LF。被记事本等 Windows 编辑器重新保存过的
MEMORY.md或 entry 文件既不会从索引里消失,也不会推高「无法识别的行」计数。 - 已知限制。 名字是保留设备名的文件(例如
con.md,由手工创建或旧版本在别的平台上创建)在 Windows 上会被跳过而不是被读取 —— 按该名字打开会到达控制台设备而不是文件。请改名(需用\\?\路径)或换名字重新写入这条记忆。在它存在期间,若再添加一条会派生到该名字的记忆(CON现在派生_CON.md),会得到第二个文件与第二条同名索引行;下一次rebuild_index(dream 会定期调用)会清掉那条陈旧索引行。
通知
| 时机 | 通知 |
|---|---|
memory add 成功(有 UI 的会话) |
Saved: <name> |
| 自动浮现注入了 entry | Recalled: <N> entries |
| extract 写入了记忆 | Extracted <N> memory. / Extracted <N> memories. |
| extract 失败 | Extract failed: <message> —— 每会话最多一次 |
/dream 结束 / 失败 |
headless agent 的摘要 / Dream failed: <message> |
headless 会话(hasUI === false)不发任何通知。