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