@yandy0725/pi-memory 1.3.4 → 2.0.0

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/README.zh.md CHANGED
@@ -1,24 +1,33 @@
1
1
  # pi-memory
2
2
 
3
- 基于文件系统的持久化记忆层,为 pi 编程代理提供跨会话的项目记忆。事实、偏好、调试历史等知识以纯 Markdown 文件形式存储在 `~/.pi/memory/<项目哈希>/` 下。
3
+ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏好、调试历史)以纯 Markdown 文件的形式存在 `~/.pi/memory/<git|local>/<project>/` 下,跨会话可用。
4
4
 
5
- 对齐 Claude Code 的 auto memory 机制:每 topic 一行的 MEMORY.md 紧凑索引、基于相关性的 topic 文件自动注入、per-turn 记忆自动提取、类型化记忆分类。
5
+ 对齐 Claude Code 的自动记忆机制:**一条记忆 = 一个文件**、`MEMORY.md` 索引一行一条记忆、按相关性自动浮现、每轮自动提取、记忆分类。
6
+
7
+ > ## ⚠️ 2.0.0 是破坏性的存储变更
8
+ >
9
+ > - 索引现在**一行一条记忆**(1.x 是一行一个 topic 文件,文件里塞很多 `## entry`)。
10
+ > - 每条记忆**独占一个文件**,frontmatter 五个字段:`name`、`description`、`type`、`created`、`modified`(1.x 用的是 `updated`)。
11
+ > - 索引改为以 **system prompt section**(`memory_index`)注入,并在整个会话内冻结,而不是拼到 system prompt 字符串末尾。
12
+ >
13
+ > 升级后第一次 `session_start` 会**自动迁移**已有的 1.x 目录:先把整个目录快照到 `.backups/migrate-<ts>/`,把原 topic 文件复制到 `originals/`,再把每个 `## entry` 拆成独立文件、重建索引,**最后一步**才写 `.migrated` 标记。任一步失败就不写标记、保留备份,下次会话重试。详见[从 1.x 迁移](#从-1x-迁移)。
6
14
 
7
15
  ## 功能
8
16
 
9
- - **一个 `memory` 工具**,三种操作:`add`(追加条目)、`remove`(按标题删除条目)、`search`(查询记忆或会话历史)
10
- - **基于主题的文件组织**:每次 `memory add` 向指定名称的 `.md` 文件写入 `## 条目` 区块
11
- - **`MEMORY.md` 索引**:每个 topic 文件一行紧凑指针 `- [名称](文件.md) — 摘要`,同 topic 自动合并
12
- - **记忆类型系统**:四种分类 — `user`(用户)、`feedback`(反馈,默认)、`project`(项目)、`reference`(引用)— 存储在 topic 文件 frontmatter 中
13
- - **Auto-surfacing** ⭐:每次用户发消息时,side-query LLM 自动选出最多 N 个相关 topic 文件并将其内容注入 agent context。无需手动 `read` — 用内置 `read` 工具即可查看记忆文件。Session 内去重防止同一 topic 重复注入
14
- - **Extract memories** ⭐:每次 agent 运行结束后,异步无头子 agent 分析对话内容,用 `ls`/`read` 检查已有 topic 文件,再用 `memory_add` 写入新记忆 — 偏好、约定、调试心法等
15
- - **快照注入**:每个新会话启动时,MEMORY.md 索引追加到系统提示中
16
- - **`/dream` 命令**:四阶段(Orient → Gather → Consolidate → Prune)无头代理整理,去重、合并、重建全部记忆文件
17
- - **梦醒提醒**:经过 N 个会话或 N 小时后,温和通知建议运行 `/dream`
18
- - **`/memory` 命令**:查看状态、开关记忆、检查索引和主题文件
19
- - **会话搜索**:`memory search scope=sessions` 操作可检索过往对话历史
20
- - **分支安全**:记忆目录以 git 根目录为键,分叉仓库自然共享记忆
21
- - **路径穿越防护**:主题文件路径会验证是否逃逸记忆目录
17
+ - **一个 `memory` 工具,主 agent 五个 action**:`add`、`replace`、`remove`、`list`、`search`。另外两个 —— `rename` 与 `rebuild_index` —— **只存在于 `/dream` 自己的 headless 会话里**,绝不出现在主 agent 或 extract 的 schema 中。
18
+ - **一条记忆 = 一个文件** —— 不再有多条目 `## section` 块;`name` 是定位键,`add` 一个已存在的 `name` 就是幂等覆盖。
19
+ - **`MEMORY.md` 索引** —— 一行一条记忆:`- [Name](file.md) — description`。分隔符是 **em dash**(`—`,U+2014),两侧各一个半角空格。
20
+ - **`memory_index` section,会话内冻结** ⭐ —— 索引写进 `event.systemPromptOptions.sections["memory_index"]`,其值在**整个会话内不再变化**,只有 compaction 会从磁盘重读。pi 对 sections 做 diff,值没变就一条消息都不追加,于是 system prompt 逐轮逐字节相同,provider 的 prefix cache 一直命中。`resume` / `fork` / `reload` 用 transcript 里的**录制值**重放,而不是读磁盘,因此恢复会话不会改写它的头部。
21
+ - **注入净化** —— 所有注入内容(索引行、浮现的 entry 正文与 name)都会剥离不可见/bidi 字符并转义 `<` `>`,因此记忆无法伪造 `</relevant_memories>`、`<system>`、`<project_instructions>`、`<active_agent …>` 或 `<memory_index>`。净化**只发生在注入时**:磁盘上的文件永远不会被改写(保持可读、可手工编辑)。
22
+ - **自动浮现(auto-surfacing)** ⭐ —— 每个用户回合由一次轻量侧查询挑出至多 `maxFiles` 条 **entry**(只看 `description`),把正文注入 `<relevant_memories>`。同一会话内按文件名去重;清单来自进程内的 `mtime` 缓存,每回合只付一次 `readdir` + 每文件一次 `stat`。子 agent 中不启用。
23
+ - **自动提取(extract memories)** ⭐ —— 每轮结束后一个异步 headless agent 拿到的是**整轮对话的结构化渲染**(user 消息全文、assistant 文本与 tool_call、tool_result 及其错误标记),而不是两条消息。它经同一套 `memory` 原语写入,并且**从不排队等锁**:dream 或迁移正在整轮持锁时,本回合直接跳过。
24
+ - **`/dream`** —— headless 整理 agent(Orient → Gather Signal → Consolidate → Prune & Index),合并重复、消解矛盾、改名、重建索引。它**没有裸文件权限**:只有七个 `memory` action,整轮持有逻辑锁,进入时先对整个目录拍一次快照。
25
+ - **Dream 提醒** —— 距上次 dream 超过 N 个会话或 N 小时后提示 `/dream`。
26
+ - **`/memory`** —— 完整状态(开关、目录、索引容量、entry 数、上次 dream、迁移状态、锁状态含持有者),以及 `on` / `off` / `unlock`。
27
+ - **两级锁** —— 进程内逻辑锁承担**逻辑作用域**(单次原语,或 dream / 迁移的整轮);跨进程 `.lock` **只持毫秒**且**永不自动回收**。没有 TTL、没有心跳、没有接管,所以互斥是硬保证;代价是崩溃遗留的锁必须**人工**清除(`/memory unlock`)。
28
+ - **快照** —— 每次写入都在 `.backups/<ts>-<label>/` 留下回滚点,保留最近 `lock.snapshotKeep` 份(迁移备份以 `migrate-` 开头,永不裁剪)。`/dream` 是例外:它**进入时只对整个目录拍一次**快照,该轮内部的原语会跳过逐文件快照(一轮只留一个回滚点)。
29
+ - **会话检索** —— `memory search scope=sessions` 查历史会话。
30
+ - **可读、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,因此有独立目录)。
22
31
 
23
32
  ## 安装
24
33
 
@@ -26,7 +35,7 @@
26
35
  pi install npm:@yandy0725/pi-memory
27
36
  ```
28
37
 
29
- 或在 `~/.pi/agent/settings.json` 中添加:
38
+ 或加入 `~/.pi/agent/settings.json`:
30
39
 
31
40
  ```json
32
41
  {
@@ -34,9 +43,71 @@ pi install npm:@yandy0725/pi-memory
34
43
  }
35
44
  ```
36
45
 
46
+ ## 存储布局
47
+
48
+ ```
49
+ ~/.pi/memory/git/github.com__owner__repo/
50
+ MEMORY.md — 索引:一行一条记忆(em dash 分隔)
51
+ SSH-port-on-staging.md
52
+ Test-command.md — 一条记忆一个文件
53
+ .lock — 跨进程写锁(只持毫秒,永不自动回收)
54
+ .backups/ — 回滚点:<ISO-ts>-<label>/,升级迁移是 migrate-<ts>/
55
+ .migrated — 1.x → 2.x 迁移的完成标记(最后一步才写)
56
+ .dream-meta.json — 上次 dream 的时间与会话数(提醒逻辑用它)
57
+ sessions/ — headless 会话落盘目录,仅在开启 sessionPersistence 时使用
58
+ ```
59
+
60
+ ### Entry 文件
61
+
62
+ ```yaml
63
+ ---
64
+ name: SSH port on staging
65
+ description: staging SSH listens on 2222, not 22; key at ~/.ssh/staging
66
+ type: project
67
+ created: 2026-07-13
68
+ modified: 2026-10-02T08:14:03.120Z
69
+ ---
70
+
71
+ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
72
+ ```
73
+
74
+ - `name` —— 唯一、可读的标题;`replace` / `remove` / `rename` 的定位键。再次 `add` 同名记忆是**覆盖**,不会产生第二条。
75
+ - `description` —— **一行自包含的说明**。侧查询判断相关性时**只看得到它**,所以它必须脱离正文也能读懂。反例:`Debugging tips`;正例:`staging SSH listens on 2222, not 22`。
76
+ - `type` —— `user` | `feedback`(默认)| `project` | `reference`。
77
+ - `created` —— `YYYY-MM-DD`,只在新建时写入,覆盖时保留。
78
+ - `modified` —— ISO 8601,**一律由 store 写入**,调用方传不进来。
79
+
80
+ 文件名由 `name` 派生(不安全字符替换、100 字节上限,只有**文件名**冲突时才追加 `-2` / `-3`)。任何 `name` 都写不到 `MEMORY.md` 上。
81
+
82
+ ### MEMORY.md 索引
83
+
84
+ ```
85
+ # Memory Index
86
+
87
+ - [SSH port on staging](SSH-port-on-staging.md) — staging SSH listens on 2222, not 22
88
+ - [Test command](Test-command.md) — run npm test, not npm run test
89
+ ```
90
+
91
+ 写入是**外科式**的:只改目标行,手写的标题、分组、注释逐字保留,行序稳定。唯一的例外是行尾:CRLF(以及单独的 CR)在解析前被归一为 LF,因此第一次写入 CRLF 文件会把它整体转成 LF。
92
+
93
+ ### 记忆类型
94
+
95
+ | 类型 | 含义 | 示例 |
96
+ |------|------|------|
97
+ | `user` | 用户角色、偏好、知识背景 | "用户是聚焦可观测性的数据科学家" |
98
+ | `feedback` | 经验、纠正、确认(默认) | "集成测试用真库不要 mock —— 上季度栽过" |
99
+ | `project` | 项目状态、时间点、事故 | "移动端 2026-03-05 起封版" |
100
+ | `reference` | 外部系统的指针 | "缺陷跟踪 = Linear 的 INGEST 项目" |
101
+
102
+ ### 容量:200 行索引 ≈ 199 条记忆
103
+
104
+ 索引上限是 `memIndexMaxLines`(200)个非空行与 `memIndexMaxBytes`(25600)字节。这 200 行是**索引行,不是记忆条数**:`rebuildIndex` 总会写一行 `# Memory Index` 头,手写的标题、分组、注释同样占额度。因此重建后的索引最多约 **199 条记忆**(每个项目目录;若保留手写标题则更少)。超限时写入**不会失败**:写入照样成功,工具把一条可操作的警告回给模型,让它去合并或删除条目(超出上限的部分下次加载时不可见)。
105
+
106
+ 这也是 `/dream` 不再是「可选的整理」而是**容量管理必需**的原因。在接近 199 条之前跑一次(或者接受提醒)。
107
+
37
108
  ## 配置
38
109
 
39
- 在代理目录(`~/.pi/agent/memory.json`)或项目的 `.pi/` 目录(需受信任)中创建 `memory.json`:
110
+ 在 agent 目录(`~/.pi/agent/memory.json`)或项目 `.pi/` 目录(仅在项目被信任时)创建 `memory.json`:
40
111
 
41
112
  ```json
42
113
  {
@@ -44,202 +115,258 @@ pi install npm:@yandy0725/pi-memory
44
115
  "memoryDir": "~/.pi/memory",
45
116
  "memIndexMaxLines": 200,
46
117
  "memIndexMaxBytes": 25600,
47
- "defaults": {
48
- "model": "deepseek/deepseek-v4-flash",
49
- "sessionPersistence": { "enabled": false }
50
- },
51
- "dream": {
52
- "nudgeAfterSessions": 5,
53
- "nudgeAfterHours": 24,
54
- "model": "auto",
55
- "thinkLevel": "high",
56
- "sessionPersistence": { "enabled": false }
57
- },
58
- "sessionSearch": {
59
- "maxSessions": 10,
60
- "maxMatches": 5
61
- },
118
+ "memIndexInjectMaxLines": 200,
119
+ "memIndexInjectMaxBytes": 25600,
120
+ "lock": { "timeoutMs": 5000, "snapshotKeep": 5 },
121
+ "defaults": { "sessionPersistence": { "enabled": false } },
122
+ "dream": { "nudgeAfterSessions": 5, "nudgeAfterHours": 24, "thinkLevel": "high" },
123
+ "sessionSearch": { "maxSessions": 10, "maxMatches": 5 },
62
124
  "autoSurfacing": {
63
125
  "enabled": true,
64
- "model": "auto",
65
126
  "thinkLevel": "off",
66
- "maxFiles": 5,
67
- "maxTopicBytes": 4096,
68
- "maxInjectionBytes": 20480,
69
- "sessionPersistence": { "enabled": false }
127
+ "maxFiles": 3,
128
+ "maxEntryBytes": 3072,
129
+ "maxInjectionBytes": 10240
70
130
  },
71
131
  "extractMemories": {
72
132
  "enabled": true,
73
- "model": "auto",
74
133
  "thinkLevel": "high",
75
134
  "maxContextTokens": 2000,
76
- "sessionPersistence": { "enabled": false }
135
+ "maxToolResultChars": 500,
136
+ "maxAssistantChars": 2000
77
137
  }
78
138
  }
79
139
  ```
80
140
 
81
- | 配置项 | 默认值 | 说明 |
82
- |--------|--------|------|
83
- | `enabled` | `true` | 开关整个记忆系统 |
141
+ | 键 | 默认值 | 说明 |
142
+ |-----|---------|------|
143
+ | `enabled` | `true` | 整个记忆系统的总开关 |
84
144
  | `memoryDir` | `~/.pi/memory` | 所有记忆数据的根目录 |
85
- | `memIndexMaxLines` | `200` | `MEMORY.md` 最大行数 |
86
- | `memIndexMaxBytes` | `25600` | `MEMORY.md` 最大字节数 |
87
- | `defaults.model` | — | 所有子任务共享的模型回退值。per-task `model` 会覆盖 |
88
- | `defaults.sessionPersistence.enabled` | `false` | 共享的 session 持久化回退(默认不持久化)。per-task 可覆盖 |
89
- | `defaults.sessionPersistence.sessionDir` | `memoryDir/sessions/` | 自定义持久化目录 |
90
- | `dream.nudgeAfterSessions` | `5` | 触发提醒需经过的会话数 |
91
- | `dream.nudgeAfterHours` | `24` | 触发提醒需经过的小时数 |
92
- | `dream.model` | — | 整理使用的模型(`"provider/id"`)。回退链:per-task → `defaults.model` → 父模型 |
93
- | `dream.thinkLevel` | `"high"` | 整理子 agent 的思考深度:`"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
94
- | `dream.sessionPersistence.enabled` | `false` | 持久化 dream agent 的 session 到磁盘(调试/审计)。回退到 `defaults.sessionPersistence.enabled` |
95
- | `dream.sessionPersistence.sessionDir` | `memoryDir/sessions/` | dream session 自定义目录 |
96
- | `sessionSearch.maxSessions` | `10` | 搜索历史时最多扫描的会话数 |
97
- | `sessionSearch.maxMatches` | `5` | 历史搜索最多返回的匹配数 |
98
- | `autoSurfacing.enabled` | `true` | ⭐ 启用 per-turn topic 文件自动注入 |
99
- | `autoSurfacing.model` | — | ⭐ side-query 相关性选择模型。回退链:per-task → `defaults.model` → 父模型 |
100
- | `autoSurfacing.thinkLevel` | `"off"` | ⭐ side-query 思考深度(推荐 `"off"`,轻量选择任务) |
101
- | `autoSurfacing.sessionPersistence.enabled` | `false` | 持久化 side-query agent 的 session。回退到 `defaults.sessionPersistence.enabled` |
102
- | `autoSurfacing.maxFiles` | `5` | ⭐ 每轮最多注入的 topic 文件数 |
103
- | `autoSurfacing.maxTopicBytes` | `4096` | ⭐ 单个注入 topic 文件最大字节数(截断) |
104
- | `autoSurfacing.maxInjectionBytes` | `20480` | ⭐ 每轮注入内容总字节数上限 |
105
- | `extractMemories.enabled` | `true` | ⭐ 启用 per-turn 记忆自动提取 |
106
- | `extractMemories.model` | — | ⭐ 提取子 agent 使用的模型。回退链:per-task → `defaults.model` → 父模型 |
107
- | `extractMemories.thinkLevel` | `"high"` | ⭐ 提取的思考深度:`"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
108
- | `extractMemories.sessionPersistence.enabled` | `false` | 持久化 extract agent 的 session。回退到 `defaults.sessionPersistence.enabled` |
109
- | `extractMemories.maxContextTokens` | `2000` | ⭐ 分析对话的最大 token 数 |
110
-
111
- 项目级配置(`.pi/memory.json`)仅在项目受信任时加载。
145
+ | `memIndexMaxLines` | `200` | 写入口径:`MEMORY.md` 的最大非空行数(`# Memory Index` 头行与手写标题同样占额度,所以并不等于记忆条数) |
146
+ | `memIndexMaxBytes` | `25600` | 写入口径:`MEMORY.md` 的最大字节数 |
147
+ | `memIndexInjectMaxLines` | `200` | 注入口径:放进 `memory_index` section 的最大行数。**刻意与写入口径同量级** —— 预算更小会让「已经写成功」的记忆看不见 |
148
+ | `memIndexInjectMaxBytes` | `25600` | 注入口径:section 的最大字节数(超出则截断并带 `[truncated: …]` 标记) |
149
+ | `lock.timeoutMs` | `5000` | 单次原语等逻辑锁 / 等跨进程 `.lock` 的上限。迁移固定用 30s(它要在锁内重写整个目录)。同时也是 `session_shutdown` 等在途写入的上限 |
150
+ | `lock.snapshotKeep` | `5` | `.backups/` 保留的回滚点数量(`migrate-` 前缀的目录永不裁剪) |
151
+ | `defaults.model` | — | 所有子任务共享的模型回退;各任务自己的 `model` 优先,都没配则回退父会话模型 |
152
+ | `defaults.sessionPersistence.enabled` | `false` | 共享回退:headless 子会话(extract / dream / 侧查询)默认只在内存里跑 |
153
+ | `defaults.sessionPersistence.sessionDir` | `<项目记忆目录>/sessions/` | headless 会话的自定义落盘目录 |
154
+ | `dream.nudgeAfterSessions` | `5` | 距上次 dream 多少个会话后开始提醒 |
155
+ | `dream.nudgeAfterHours` | `24` | 距上次 dream 多少小时后开始提醒 |
156
+ | `dream.model` | — | dream 用的模型(`"provider/id"`)。回退 `defaults.model` → 父会话模型 |
157
+ | `dream.thinkLevel` | `"high"` | dream 的思考强度:`off` / `minimal` / `low` / `medium` / `high` / `xhigh` |
158
+ | `dream.sessionPersistence.*` | 继承 `defaults` | 把 dream 会话落盘(调试/审计用) |
159
+ | `sessionSearch.maxSessions` | `10` | `search scope=sessions` 扫描的最大会话数 |
160
+ | `sessionSearch.maxMatches` | `5` | 历史检索返回的最大命中数 |
161
+ | `autoSurfacing.enabled` | `true` | ⭐ 开启每回合的 entry 自动注入 |
162
+ | `autoSurfacing.model` | — | ⭐ 相关性侧查询用的模型。回退 `defaults.model` → 父会话模型 |
163
+ | `autoSurfacing.thinkLevel` | `"off"` | ⭐ 侧查询的思考强度(`"off"` 最省) |
164
+ | `autoSurfacing.maxFiles` | `3` | ⭐ 每回合最多注入几条 entry |
165
+ | `autoSurfacing.maxEntryBytes` | `3072` | ⭐ 单条 entry 正文的注入字节上限(超出截断)。取代 1.x 的 `maxTopicBytes`(旧键已失效) |
166
+ | `autoSurfacing.maxInjectionBytes` | `10240` | ⭐ 每回合注入内容的总字节上限 |
167
+ | `autoSurfacing.sessionPersistence.*` | 继承 `defaults` | 把侧查询会话落盘 |
168
+ | `extractMemories.enabled` | `true` | ⭐ 开启每轮自动提取 |
169
+ | `extractMemories.model` | — | ⭐ 提取 agent 用的模型。回退 `defaults.model` → 父会话模型 |
170
+ | `extractMemories.thinkLevel` | `"high"` | ⭐ 提取的思考强度 |
171
+ | `extractMemories.maxContextTokens` | `2000` | ⭐ 渲染后对话的预算(`× 4` 个字符;超出时先裁中段、首尾优先保留,user 消息最后才动) |
172
+ | `extractMemories.maxToolResultChars` | `500` | ⭐ 单条 `tool_result` 渲染的字符上限 |
173
+ | `extractMemories.maxAssistantChars` | `2000` | ⭐ 单条 assistant 文本渲染的字符上限(user 消息从不截断) |
174
+ | `extractMemories.sessionPersistence.*` | 继承 `defaults` | 把 extract 会话落盘 |
175
+
176
+ headless 会话默认落在 `<项目记忆目录>/sessions/` —— 在项目记忆目录里,不在你的工作副本里。
112
177
 
113
178
  ## 工作原理
114
179
 
115
- ### MEMORY.md 索引
116
-
117
- MEMORY.md 是一个**紧凑指针索引** — 每个 topic 文件一行,而非每个条目一行:
180
+ ### 会话生命周期
118
181
 
119
- ```
120
- - [Debugging](debugging.md) — SSH 使用 2222 端口;MySQL staging 上 30s 超时
121
- - [API Conventions](api.md) — REST handlers 在 src/api/handlers/;使用标准错误格式
122
- ```
182
+ | 事件 | pi-memory 做什么 |
183
+ |---|---|
184
+ | `session_start` | 加载配置 → 解析记忆目录 → 需要则执行 1.x 迁移 → **确定索引值并冻结**(`startup`/`new` 读磁盘;`resume`/`fork`/`reload` 重放 transcript 取录制值)→ 注册 `memory` 工具(仅首次,5 个 action)→ 重建清单缓存 → dream 提醒检查 |
185
+ | `before_agent_start` | 把冻结值写进 `sections["memory_index"]`(**每一轮、无条件**),然后做 auto-surfacing(主会话且非子 agent) |
186
+ | `agent_end` | 触发异步 extract;写入成功通知 `Extracted N memories.`,失败通知 `Extract failed: …`(每会话一次) |
187
+ | `session_compact` | 清空已注入集合,**并从磁盘重读索引** —— 会话内唯一的刷新点 |
188
+ | `session_shutdown` | 等在途写入收尾(上限 `lock.timeoutMs`),避免退出时留下 stale 的 `.lock` |
123
189
 
124
- 每次会话只有索引被注入系统提示(前 200 行 / 25KB)。Topic 文件内容**不会**在启动时加载 — 通过 auto-surfacing 按需注入或内置 `read` 工具显式加载。
190
+ ### 为什么索引要冻结
125
191
 
126
- ### Topic 文件格式
192
+ pi 用有序的 sections 构建 system prompt,只对**值发生变化**的 section 追加一条 patch 消息;全部没变时一条都不追加。自定义 section 插在**最后**,所以 `memory_index` 是 system prompt 的最后一段,其后紧跟整段对话。一旦它的值在会话中途变化,折叠后的头部就会被改写,其后的全部内容失去 prefix cache。因此:一个会话一个值,只有 compaction 才刷新(那时对话中段本来就要被重写)。
127
193
 
128
- 每个 topic 文件使用四字段 YAML frontmatter:
194
+ 有意接受的代价:**本会话内写入的记忆不会出现在本会话的索引里**。工具返回值已经确认写入,下一个会话立刻可见。
129
195
 
130
- ```yaml
131
- ---
132
- name: 调试技巧
133
- description: 常见调试模式、SSH 端口、MySQL 超时配置
134
- type: feedback
135
- updated: 2026-07-13
136
- ---
196
+ 如果宿主的 pi 还没有 sections API,pi-memory 回退为把索引拼到 system prompt 字符串末尾 —— 功能不变,只是缓存变差。
137
197
 
138
- ## SSH 踩坑
139
- staging 使用 2222 端口,密钥在 ~/.ssh/staging
198
+ ### 自动浮现
140
199
 
141
- ## MySQL 超时
142
- staging 上连接超时 30s
143
- ```
200
+ 1. 从 store 的 `mtime` 缓存构建清单:`[type] file.md — description`,按 `modified` 降序,最多 200 条、4000 字符(description 预算按条数均分,每条不低于 80 字符)。
201
+ 2. 侧查询(`maxTurns: 1`、零工具)从清单里返回至多 `maxFiles` 个文件名;不在清单里的一律丢弃。
202
+ 3. 选中 entry 的**正文**(不含 frontmatter)按 `maxEntryBytes` 截断、净化,作为一条 `display: false` 的 custom 消息注入,外面包 `<relevant_memories>`;总量到 `maxInjectionBytes` 就停。
203
+ 4. 注入过的文件名在本会话内记住,compaction 时清空。你会看到 `Recalled: N entries` 通知。
144
204
 
145
- `description` 字段至关重要 — auto-surfacing 的 side-query 根据它判断相关性。描述要具体。
205
+ ### 自动提取
146
206
 
147
- ### 记忆类型
207
+ extract 拿到的是结构化渲染,而不是有损的两条消息摘要:
148
208
 
149
- | 类型 | 含义 | 示例 |
150
- |------|------|------|
151
- | `user` | 用户角色、偏好、知识背景 | "用户是数据科学家,关注可观测性" |
152
- | `feedback` | 教训/纠正/确认(默认) | "用真实 DB 不用 mock—上次踩过坑" |
153
- | `project` | 项目状态、deadline、incident | "merge freeze 从 3 月 5 日开始" |
154
- | `reference` | 外部系统指针 | "bug tracker = Linear INGEST project" |
155
-
156
- ### Auto-surfacing
209
+ ```
210
+ === Conversation ===
211
+ [1] user: <全文>
212
+ [2] assistant: <文本> | tool_call: memory({"action":"list"})
213
+ [3] tool_result: <摘要,按 maxToolResultChars 截断>
214
+ [4] user: <纠正>
215
+ ```
157
216
 
158
- 每次用户发消息时(`before_agent_start` hook):
159
- 1. 扫描所有 topic 文件,提取 frontmatter 元数据
160
- 2. Side-query LLM 根据用户问题选出最多 `maxFiles` 个相关 topic 文件
161
- 3. 已注入过的 topic 跳过(session 内去重)
162
- 4. 选中文件内容注入 context — agent 自动看到相关记忆
217
+ 它只有主 agent 的五个 action(永远拿不到 `rename` / `rebuild_index`)、没有文件工具、`maxTurns: 5`、超时 120s。逻辑锁被占用时(dream 或迁移正在整轮持有)它**跳过本回合**而不是排队 —— 下一次 `agent_end` 还会来。
163
218
 
164
- ### Extract memories
219
+ ### 锁
165
220
 
166
- 每次 agent 运行结束后(`agent_end` hook):
167
- 1. 异步 fork 子 agent,传入本轮对话内容
168
- 2. 分析是否有值得持久化的 learnings
169
- 3. 如有,直接写入 memory 文件 — 偏好、约定、调试心法等
170
- 4. 子 agent 独立运行,结果为未来会话所用
221
+ | 层级 | 作用域 | 行为 |
222
+ |---|---|---|
223
+ | 进程内逻辑锁(按记忆目录分键) | 单次原语;或 dream / 迁移的整轮 | 最多等 `lock.timeoutMs`(迁移 30s),超时抛一条写明目录的可读错误。`extract` 用不等待的形态,直接跳过本回合 |
224
+ | 跨进程 `.lock` | 毫秒级,只包住物理写入 | 用 `link` 原子获取,**永不自动回收**:没有 TTL、没有心跳、没有接管 |
171
225
 
172
- 记忆提取有选择性:忽略一次性任务、可从项目推导的代码、已在 CLAUDE.md 中的内容。
226
+ 因此写入中途崩溃可能留下一个 `.lock`,而且**没有任何进程会替你删掉它** —— 这是「互斥是硬保证」的刻意代价。错误文案会写明 pid、op、开始时间与路径;`/memory unlock` 是唯一被认可的清除方式。
173
227
 
174
228
  ## 工具参考
175
229
 
176
230
  ```
177
- memory(action: "add" | "remove" | "search",
178
- content?, topic?, title?, type?,
179
- entry?, query?, scope?)
231
+ memory(action: "add" | "replace" | "remove" | "list" | "search",
232
+ name?, description?, content?, type?, query?, scope?)
180
233
  ```
181
234
 
235
+ `description` 是未来会话唯一能拿到的相关性信号 —— `add` 与 `replace` 请务必带上自包含的一行。
236
+
182
237
  ### `add`
183
238
 
184
- 向 topic 文件追加 `## 条目` 区块。若该 topic 已在索引中存在,则更新 MEMORY.md 的 hook 摘要。新建 topic 时在索引中新增一行。
239
+ 新建一条记忆,或**覆盖 `name` 完全相同的那一条**(幂等;`created` 保留)。
240
+
241
+ - `name`(必填)—— 唯一、可读的标题
242
+ - `content`(必填)—— 记忆正文,它会成为整个 entry 文件
243
+ - `description`(可选)—— 一行自包含说明;缺省取 `content` 的第一句
244
+ - `type`(可选)—— `user` / `feedback`(默认)/ `project` / `reference`
185
245
 
186
- - **`content`**(必填)— 要持久化的知识文本
187
- - **`topic`**(必填)— 目标文件名,如 `"debugging.md"`,不存在则自动创建
188
- - **`title`**(必填)— 描述性、自包含的条目标题。只有 MEMORY.md 索引行被注入未来 prompt(topic 文件内容不会),所以标题必须自成一体地传达信息
189
- - **`type`**(可选)— 记忆分类:`"user"`、`"feedback"`(默认)、`"project"`、`"reference"`
246
+ ### `replace`
247
+
248
+ 重写已有记忆的 `content` / `description` / `type`,按 `name` 定位。改名是 `rename`,dream 专属。
190
249
 
191
250
  ### `remove`
192
251
 
193
- 按标题删除条目。遍历所有 topic 文件查找匹配的 `##` 区块。更新受影响 topic 的 MEMORY.md hook。当 topic 文件中最后一条被删除后,topic 文件及其索引行均被清理。
252
+ 删除 `name` 匹配的记忆 —— 文件、索引行一起删。找不到条目或删不掉文件时**明确报错**,不会假装删成功。
253
+
254
+ ### `list`
194
255
 
195
- - **`entry`**(必填)— 要删除的条目标题
256
+ 每条记忆一行:`- name (type, modified …) — description [file]`。
196
257
 
197
258
  ### `search`
198
259
 
199
- 查询记忆文件或会话历史。记忆搜索返回匹配条目的完整区块(整个 `##` 区域)。
260
+ - `query`(必填)
261
+ - `scope`(可选)—— `memory`(默认:在所有 entry 的 name / description / 正文里找)或 `sessions`(查历史会话)
200
262
 
201
- - **`query`**(必填)— 搜索关键词
202
- - **`scope`**(可选)— `"memory"`(默认,扫描 topic 文件)或 `"sessions"`(扫描会话历史)
263
+ ### dream 专属 action
264
+
265
+ `rename`(`name` + `new_name`;文件名与索引行跟着走,并保持索引行的位置)与 `rebuild_index`(从磁盘全量重建,保留手写头部)。它们**只**注册在 `/dream` 的 headless 会话里,主 agent 与 extract 都调不到。
203
266
 
204
267
  ## 命令
205
268
 
206
269
  ### `/memory`
207
270
 
208
- 显示记忆状态(启用/禁用、目录、索引行数、topic 文件、上次整理时间戳)。
271
+ ```
272
+ /memory — 查看状态
273
+ /memory on — 开启
274
+ /memory off — 关闭
275
+ /memory unlock — 清除遗留的 .lock(会先要求确认)
276
+ ```
277
+
278
+ 状态输出:
209
279
 
210
280
  ```
211
- /memory — 显示状态
212
- /memory on — 启用记忆
213
- /memory off — 禁用记忆
281
+ Memory: enabled
282
+ Dir: /home/you/.pi/memory/git/github.com__owner__repo
283
+ Index: 38/200 lines, 2841/25600 bytes, 1 unrecognized lines
284
+ Entries: 37
285
+ Last dream: 2026-10-01T22:10:04.882Z
286
+ Migration: migrated at 2026-09-30T09:12:44.120Z (18 entries from 4 files)
287
+ Lock: free
214
288
  ```
215
289
 
290
+ - `Index` 用**写入**口径(`memIndexMax*`),并报告索引里有多少非空行解析不出(`# Memory Index` 头行与手写标题会计入)。CRLF(以及单独的 CR)行尾在解析前就被归一为 LF,下一次写入也一律输出 LF,因此被 Windows 编辑器改过行尾的 `MEMORY.md` **不会**推高这个计数。
291
+ - `Migration` 有三种:`migrated at …`、`not needed`(标记显示当时没东西要迁)、`pending`(没有标记或标记读不懂 → 下次 `session_start` 重试)。
292
+ - `Lock` 有三种:`free`、`held by <op> (pid N, started <ISO>)`、`unreadable — run /memory unlock`。
293
+ - 以 `enabled: false` 启动的会话里,store 从未初始化,因此 `/memory on` 会回答 `Memory not initialized.`(并不会真的打开开关)—— 本 session 的 `/memory unlock` 也不可达。两者都需要重开会话(或重启 pi)后才可用。
294
+
216
295
  ### `/dream`
217
296
 
218
- 启动无头代理,四阶段整理全部记忆文件:
297
+ 先要求确认,然后对整个目录拍快照,再跑一个 headless agent 走完四个阶段:
298
+
299
+ 1. **Orient** —— `list`,读相关 entry
300
+ 2. **Gather Signal** —— 找重复(同一事实的多条 entry)、矛盾、过时内容
301
+ 3. **Consolidate** —— 用 `replace` + `remove` 合并,用 `rename` 改名
302
+ 4. **Prune & Index** —— 用 `rebuild_index` 兜底重建
303
+
304
+ 它碰不到文件:只有七个 `memory` action。完成时通知摘要,失败时通知 `Dream failed: …`。模型可用 `dream.model` 配置。
305
+
306
+ ## 从 1.x 迁移
307
+
308
+ 升级后第一次 `session_start` 自动执行:
219
309
 
220
- 1. **Orient** — 列出文件、读取 MEMORY.md、浏览 topic 文件
221
- 2. **Gather Signal** — 发现重复、矛盾、过时条目
222
- 3. **Consolidate** — 合并重复、解决矛盾、更新日期
223
- 4. **Prune & Index** — 重建 frontmatter、生成 hook、重建 MEMORY.md
310
+ 1. 整轮取逻辑锁(30s);
311
+ 2. 把整个目录快照到 `.backups/migrate-<ts>/`,并把原 topic 文件复制到 `.backups/migrate-<ts>/originals/`;
312
+ 3. 对每个 legacy 文件(含 ≥ 2 个 `## ` 段,或 frontmatter 里有旧字段 `updated`):把每个 `## entry` 拆成独立文件,沿用 `type`,把 `updated` 归一成 `created` / `modified`;跨文件重名的第二条起追加 ` (2)`、` (3)`;
313
+ 4. `rebuildIndex()`;
314
+ 5. 删除原 topic 文件(它们仍在 `originals/` 里);
315
+ 6. 写 `.migrated`;
316
+ 7. 通知 `Migrated N memories from M topic files. Backup at <path>`。
224
317
 
225
- 使用的模型通过 `memory.json` 的 `dream.model` 配置。开始前弹出确认对话框,完成时通知摘要。
318
+ frontmatter 里已含 `modified` 字段的文件**永远不**会被当成 legacy topic 文件(即使正文里有多个 `## ` 小标题)—— 这道守卫正是为了避免正常的 v2 记忆被再次拆碎。
319
+
320
+ 重跑是安全的:标记只在最后写,而**同名且同正文**的条目会被复用而不是复制一份。任一步失败就不写标记、保留备份、上报错误 —— 下次会话重试。
321
+
322
+ **手工回滚:**
323
+
324
+ ```bash
325
+ cd ~/.pi/memory/git/github.com__owner__repo # /memory 打印的那个目录
326
+ ls .backups/migrate-*/originals/ # 找到要撤销的那一次
327
+ cp .backups/migrate-<ts>/originals/*.md . # 恢复 1.x 的 topic 文件
328
+ rm .migrated # 让迁移可以重新跑
329
+ rm <生成的 entry 文件> # 即 /memory 的 Entries 列出、但不在 originals/ 里的那些
330
+ ```
226
331
 
227
332
  ## 文件布局
228
333
 
229
334
  ```
230
335
  ~/.pi/memory/
231
- <12位sha256哈希>/
232
- MEMORY.md — 紧凑索引:每个 topic 文件一行
233
- .dream-meta.json — 上次整理的时间戳和会话计数
234
- debugging.md — topic 文件(frontmatter + ## 条目)
235
- preferences.md
236
- ...
336
+ git/
337
+ github.com__yandy__pi-packages/ ← https://github.com/yandy/pi-packages.git
338
+ MEMORY.md — 索引:一行一条记忆
339
+ SSH-port-on-staging.md
340
+ Test-command.md — 一条记忆一个文件
341
+ .lock .backups/ .migrated .dream-meta.json
342
+ local/
343
+ home__yandy__workspace__scratch/ ← 非 git 目录 /home/yandy/workspace/scratch
237
344
  ```
238
345
 
239
- 哈希值由项目的 git 根目录(或绝对路径)派生,每个项目拥有独立记忆命名空间。
346
+ 目录名的推导规则:
347
+
348
+ - remote 是 http(s)、ssh(含 scp 写法 `[user@]host:owner/repo`,user 可省略)或 `git://`,以及 `git+ssh://` / `git+https://` 别名的 git 仓库 → `git/<host>__<owner>__<repo>`;端口、凭据、结尾的 `/` 与 `.git` 会被剥掉,host 转小写
349
+ - remote URL 从原始 git config 读取(`remote.<name>.url`;先 `origin`,再按字母序,第一个可用的胜出),所以 `url.*.insteadOf` 重写不会影响映射
350
+ - scheme 形式走 WHATWG URL 规范化(IDN host 转 punycode,百分号编码与 `.`/`..` 折叠生效,凭据/query/fragment 被丢弃),scp 形式保留原样路径 —— 等价但写法不同的 remote 可能映射到不同目录
351
+ - 其余情况 —— 非 git 目录、没有 remote 的 git 仓库、`file://` 或本地路径 remote → `local/<absolute-path>`(git 仓库用仓库根;Windows 盘符形式的 remote 如 `C:/repos/foo.git` 在 POSIX 上按 scp 写法处理,与 git 一致)
352
+ - `/` 变成 `__`;文件名里不可移植的字符(`<>:"|?*`、控制字符)变成 `_XX` 十六进制转义
353
+ - 超过 120 UTF-8 字节的名字在码点边界截断到 100 字节,再加 `__<hash8>` 后缀
354
+ - 名字面向 POSIX 文件系统:反斜杠是普通字符,不做 Windows 设备名与结尾句点处理
355
+
356
+ 映射不是单射:下划线原样保留,所以 `/home/a__b` 与 `/home/a/b` 都映射到 `home__a__b`(共享同一个记忆目录)。改动或重命名 remote、新增一个排序更靠前的 remote、移动本地目录,都会改变记忆目录,旧目录会被孤立。
357
+
358
+ **更老的布局:** 1.x 之前的版本把记忆存在 `~/.pi/memory/<12-char-sha256>/`;这些目录不再被读写。要手工迁移,用 `printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12` 算出旧 hash(不在 git 仓库里就用 `$PWD`),把那个目录 `mv` 到新位置(在项目里跑 `/memory` 可以看到新路径),然后让 1.x → 2.x 的迁移去拆它的 topic 文件。
240
359
 
241
- ## 快照语义
360
+ ## 通知
242
361
 
243
- 每次 `session_start` 读取 `MEMORY.md` 索引,通过 `before_agent_start` 追加到系统提示中。超限则截断并标记 `[truncated]`。此快照是会话开始时的静态副本。
362
+ | 时机 | 通知 |
363
+ |---|---|
364
+ | `memory add` 成功(有 UI 的会话) | `Saved: <name>` |
365
+ | 自动浮现注入了 entry | `Recalled: <N> entries` |
366
+ | extract 写入了记忆 | `Extracted <N> memory.` / `Extracted <N> memories.` |
367
+ | extract 失败 | `Extract failed: <message>` —— 每会话最多一次 |
368
+ | 1.x 迁移完成 | `Migrated <N> memories from <M> topic files. Backup at <path>` |
369
+ | 迁移失败 | `Memory migration failed: <message>` |
370
+ | `/dream` 结束 / 失败 | headless agent 的摘要 / `Dream failed: <message>` |
244
371
 
245
- Topic 文件内容通过 **auto-surfacing**(自动、per-turn、基于相关性)或内置 `read` 工具按需加载。
372
+ headless 会话(`hasUI === false`)不发任何通知。