@yandy0725/pi-memory 2.1.0 → 2.2.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.md CHANGED
@@ -6,6 +6,10 @@ Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `
6
6
 
7
7
  > ## ⚠️ Breaking changes
8
8
  >
9
+ > **In 2.2.0:**
10
+ >
11
+ > - **`extractMemories.enabled` now defaults to `false`.** Per-turn extraction is opt-in: once enabled, every turn ends with a headless model call. Configs that already set `"extractMemories": { "enabled": true }` are unaffected.
12
+ >
9
13
  > **In 2.1.0:**
10
14
  >
11
15
  > - **Models must be configured explicitly.** There is no shipped default and no parent-model fallback: `defaults.model` (or a per-task `model`) must exist and be resolvable, or `session_start` reports a config error and initialises **nothing**. See [Model configuration](#model-configuration).
@@ -26,7 +30,7 @@ Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `
26
30
  - **`memory_index` prompt section, frozen per session** ⭐ — the index goes into `event.systemPromptOptions.sections["memory_index"]` and its value **does not change for the rest of the session**; only compaction re-reads it from disk. Because pi diffs sections and appends nothing when they are unchanged, the system prompt stays byte-identical turn after turn and the provider's prefix cache keeps hitting. `resume` / `fork` / `reload` replay the **recorded** value from the transcript instead of reading disk, so restoring a session does not rewrite its head.
27
31
  - **Injection sanitising** — everything injected (index lines, surfaced entry bodies and names) has invisible/bidi characters stripped and `<` `>` escaped, so a memory can never forge `</relevant_memories>`, `<system>`, `<project_instructions>`, `<active_agent …>` or `<memory_index>`. Sanitising happens **at injection time only**: your files on disk are never rewritten (they stay readable and hand-editable).
28
32
  - **Auto-surfacing** ⭐ — on every user turn a lightweight side query selects up to `maxFiles` **entries** (selected from `description` alone) and injects their bodies inside `<relevant_memories>`. Already-injected files are deduplicated per session; the manifest is served from an in-process `mtime` cache, so a turn costs one `readdir` plus one `stat` per file. Disabled inside subagents.
29
- - **Extract memories** ⭐ — after each run an async headless agent receives a **structured rendering of the whole conversation** (every user message in full, assistant text and tool calls, tool results with error flags), not just two messages. It writes through the same `memory` primitives, under a whole-round logical lock it never waits for: if a dream is running, that turn is simply skipped.
33
+ - **Extract memories** ⭐ — after each run an async headless agent receives a **structured rendering of the whole conversation** (every user message in full, assistant text and tool calls, tool results with error flags), not just two messages. It writes through the same `memory` primitives, under a whole-round logical lock it never waits for: if a dream is running, that turn is simply skipped. This feature is **off by default** — set `extractMemories.enabled: true` to turn it on.
30
34
  - **`/dream`** — a headless consolidation agent (Orient → Gather Signal → Consolidate → Prune & Index) that merges duplicates, resolves contradictions, renames entries and rebuilds the index. It has **no raw file access**: it only gets the seven `memory` actions, holds the logical lock for the whole round, and snapshots the entire directory on entry.
31
35
  - **Dream nudge** — after N sessions or N hours a notification suggests `/dream`.
32
36
  - **`/memory`** — full status (switch, directory, index capacity, entry count, last dream, lock state including the holder), plus `unlock`.
@@ -134,7 +138,7 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
134
138
  "maxInjectionBytes": 10240
135
139
  },
136
140
  "extractMemories": {
137
- "enabled": true,
141
+ "enabled": false,
138
142
  "thinkLevel": "high",
139
143
  "maxContextTokens": 2000,
140
144
  "maxToolResultChars": 500,
@@ -172,7 +176,7 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
172
176
  | `autoSurfacing.maxEntryBytes` | `3072` | ⭐ Max bytes of a single injected entry body (truncated). Replaces 1.x's `maxTopicBytes`, which is ignored |
173
177
  | `autoSurfacing.maxInjectionBytes` | `10240` | ⭐ Max total bytes of injected content per turn |
174
178
  | `autoSurfacing.sessionPersistence.*` | inherits `defaults` | Persist side-query sessions to disk |
175
- | `extractMemories.enabled` | `true` | ⭐ Enable per-turn memory extraction |
179
+ | `extractMemories.enabled` | `false` | ⭐ Enable per-turn memory extraction. **Off by default** (opt-in): once enabled, every turn ends with a headless model call |
176
180
  | `extractMemories.model` | — | ⭐ Model for the extraction agent. Falls back to `defaults.model`; required unless `defaults.model` is set (must be resolvable, no parent-model fallback) |
177
181
  | `extractMemories.thinkLevel` | `"high"` | ⭐ Thinking effort for extraction |
178
182
  | `extractMemories.maxContextTokens` | `2000` | ⭐ Budget for the rendered conversation (`× 4` characters; the middle is trimmed first, head and tail are kept, user messages are dropped last) |
@@ -207,7 +211,7 @@ Fix `memory.json` and restart the session — the config is read once at session
207
211
  |---|---|
208
212
  | `session_start` | Load config → validate the required models (a failure means nothing is initialised) → resolve the memory directory → **pick the index value and freeze it** (disk for `startup`/`new`; the recorded transcript value for `resume`/`fork`/`reload`) → register the `memory` tool (once, five actions) → rebuild the manifest cache → dream nudge |
209
213
  | `before_agent_start` | Write the frozen value into `sections["memory_index"]` (**unconditionally, every turn**), then auto-surfacing (main session, not a subagent) |
210
- | `agent_end` | Fire the async extractor; notify `Extracted N memories.` when it wrote something, or `Extract failed: …` once per session |
214
+ | `agent_end` | With `extractMemories.enabled`, fire the async extractor; notify `Extracted N memories.` when it wrote something, or `Extract failed: …` once per session |
211
215
  | `session_compact` | Clear the injected-file set **and re-read the index from disk** — the only in-session refresh point |
212
216
  | `session_shutdown` | Wait for in-flight writes to finish (bounded by `lock.timeoutMs`) so a quit does not leave a stale `.lock` |
213
217
 
@@ -228,6 +232,8 @@ If the host pi is older than the sections API, pi-memory falls back to appending
228
232
 
229
233
  ### Extract memories
230
234
 
235
+ > **Off by default.** Set `"extractMemories": { "enabled": true }` in `memory.json` first (config is read once at `session_start`, so a restart is needed), and make sure `extractMemories.model` or `defaults.model` resolves.
236
+
231
237
  The extractor receives a structured rendering instead of a lossy two-message summary:
232
238
 
233
239
  ```
package/README.zh.md CHANGED
@@ -6,6 +6,10 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
6
6
 
7
7
  > ## ⚠️ 破坏性变更
8
8
  >
9
+ > **2.2.0:**
10
+ >
11
+ > - **`extractMemories.enabled` 默认改为 `false`。** 每轮自动提取现在是 opt-in:开启后每轮结束都会跑一次 headless 模型调用。已经显式写了 `"extractMemories": { "enabled": true }` 的配置不受影响。
12
+ >
9
13
  > **2.1.0:**
10
14
  >
11
15
  > - **模型必须显式配置。** 没有内置默认值,也没有父会话模型回退:`defaults.model`(或 per-task `model`)必须存在且可解析,否则 `session_start` 会报配置错误并且**什么都不初始化**。详见[模型配置](#模型配置)。
@@ -26,7 +30,7 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
26
30
  - **`memory_index` section,会话内冻结** ⭐ —— 索引写进 `event.systemPromptOptions.sections["memory_index"]`,其值在**整个会话内不再变化**,只有 compaction 会从磁盘重读。pi 对 sections 做 diff,值没变就一条消息都不追加,于是 system prompt 逐轮逐字节相同,provider 的 prefix cache 一直命中。`resume` / `fork` / `reload` 用 transcript 里的**录制值**重放,而不是读磁盘,因此恢复会话不会改写它的头部。
27
31
  - **注入净化** —— 所有注入内容(索引行、浮现的 entry 正文与 name)都会剥离不可见/bidi 字符并转义 `<` `>`,因此记忆无法伪造 `</relevant_memories>`、`<system>`、`<project_instructions>`、`<active_agent …>` 或 `<memory_index>`。净化**只发生在注入时**:磁盘上的文件永远不会被改写(保持可读、可手工编辑)。
28
32
  - **自动浮现(auto-surfacing)** ⭐ —— 每个用户回合由一次轻量侧查询挑出至多 `maxFiles` 条 **entry**(只看 `description`),把正文注入 `<relevant_memories>`。同一会话内按文件名去重;清单来自进程内的 `mtime` 缓存,每回合只付一次 `readdir` + 每文件一次 `stat`。子 agent 中不启用。
29
- - **自动提取(extract memories)** ⭐ —— 每轮结束后一个异步 headless agent 拿到的是**整轮对话的结构化渲染**(user 消息全文、assistant 文本与 tool_call、tool_result 及其错误标记),而不是两条消息。它经同一套 `memory` 原语写入,并且**从不排队等锁**:dream 正在整轮持锁时,本回合直接跳过。
33
+ - **自动提取(extract memories)** ⭐ —— 每轮结束后一个异步 headless agent 拿到的是**整轮对话的结构化渲染**(user 消息全文、assistant 文本与 tool_call、tool_result 及其错误标记),而不是两条消息。它经同一套 `memory` 原语写入,并且**从不排队等锁**:dream 正在整轮持锁时,本回合直接跳过。该功能**默认关闭**,需要显式设 `extractMemories.enabled: true`。
30
34
  - **`/dream`** —— headless 整理 agent(Orient → Gather Signal → Consolidate → Prune & Index),合并重复、消解矛盾、改名、重建索引。它**没有裸文件权限**:只有七个 `memory` action,整轮持有逻辑锁,进入时先对整个目录拍一次快照。
31
35
  - **Dream 提醒** —— 距上次 dream 超过 N 个会话或 N 小时后提示 `/dream`。
32
36
  - **`/memory`** —— 完整状态(开关、目录、索引容量、entry 数、上次 dream、锁状态含持有者),以及 `unlock`。
@@ -134,7 +138,7 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
134
138
  "maxInjectionBytes": 10240
135
139
  },
136
140
  "extractMemories": {
137
- "enabled": true,
141
+ "enabled": false,
138
142
  "thinkLevel": "high",
139
143
  "maxContextTokens": 2000,
140
144
  "maxToolResultChars": 500,
@@ -172,7 +176,7 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
172
176
  | `autoSurfacing.maxEntryBytes` | `3072` | ⭐ 单条 entry 正文的注入字节上限(超出截断)。取代 1.x 的 `maxTopicBytes`(旧键已失效) |
173
177
  | `autoSurfacing.maxInjectionBytes` | `10240` | ⭐ 每回合注入内容的总字节上限 |
174
178
  | `autoSurfacing.sessionPersistence.*` | 继承 `defaults` | 把侧查询会话落盘 |
175
- | `extractMemories.enabled` | `true` | ⭐ 开启每轮自动提取 |
179
+ | `extractMemories.enabled` | `false` | ⭐ 开启每轮自动提取。**默认关闭**(opt-in):开启后每轮结束都会跑一次 headless 模型调用 |
176
180
  | `extractMemories.model` | — | ⭐ 提取 agent 用的模型。回退 `defaults.model`;没有 `defaults.model` 时必填(必须可解析,不回退父会话模型) |
177
181
  | `extractMemories.thinkLevel` | `"high"` | ⭐ 提取的思考强度 |
178
182
  | `extractMemories.maxContextTokens` | `2000` | ⭐ 渲染后对话的预算(`× 4` 个字符;超出时先裁中段、首尾优先保留,user 消息最后才动) |
@@ -207,7 +211,7 @@ headless 会话默认落在 `<项目记忆目录>/sessions/` —— 在项目记
207
211
  |---|---|
208
212
  | `session_start` | 加载配置 → 校验必需模型(失败即什么都不初始化)→ 解析记忆目录 → **确定索引值并冻结**(`startup`/`new` 读磁盘;`resume`/`fork`/`reload` 重放 transcript 取录制值)→ 注册 `memory` 工具(仅首次,5 个 action)→ 重建清单缓存 → dream 提醒检查 |
209
213
  | `before_agent_start` | 把冻结值写进 `sections["memory_index"]`(**每一轮、无条件**),然后做 auto-surfacing(主会话且非子 agent) |
210
- | `agent_end` | 触发异步 extract;写入成功通知 `Extracted N memories.`,失败通知 `Extract failed: …`(每会话一次) |
214
+ | `agent_end` | `extractMemories.enabled` 开启时触发异步 extract;写入成功通知 `Extracted N memories.`,失败通知 `Extract failed: …`(每会话一次) |
211
215
  | `session_compact` | 清空已注入集合,**并从磁盘重读索引** —— 会话内唯一的刷新点 |
212
216
  | `session_shutdown` | 等在途写入收尾(上限 `lock.timeoutMs`),避免退出时留下 stale 的 `.lock` |
213
217
 
@@ -228,6 +232,8 @@ pi 用有序的 sections 构建 system prompt,只对**值发生变化**的 sec
228
232
 
229
233
  ### 自动提取
230
234
 
235
+ > **默认关闭。** 先在 `memory.json` 里设 `"extractMemories": { "enabled": true }`(配置只在 `session_start` 读一次,改动需重启会话),并确保 `extractMemories.model` 或 `defaults.model` 可解析。
236
+
231
237
  extract 拿到的是结构化渲染,而不是有损的两条消息摘要:
232
238
 
233
239
  ```
package/index.ts CHANGED
@@ -144,6 +144,9 @@ export default function (pi: ExtensionAPI) {
144
144
  */
145
145
  const inFlight = new Set<Promise<unknown>>();
146
146
 
147
+ /** `enabled: false` 时给用户的唯一动作。工具文案与 `/memory` 状态共用同一份,避免两处漂移。 */
148
+ const ENABLE_HINT = 'set "enabled": true in memory.json and restart';
149
+
147
150
  /**
148
151
  * 清空本 session 的运行时状态。三条早退路径(disabled / 配置错误 / 初始化失败)共用:
149
152
  * 残留上一 session 的 store 会让后续写入落到别的项目目录(Plan C ledger R51)。
@@ -183,7 +186,7 @@ export default function (pi: ExtensionAPI) {
183
186
  configError
184
187
  ? `Memory not initialized — ${configError.split("\n")[0]}; run /memory for details`
185
188
  : config?.enabled === false
186
- ? 'Memory is disabled — set "enabled": true in memory.json and restart'
189
+ ? `Memory is disabled — ${ENABLE_HINT}`
187
190
  : null,
188
191
  searchSessions,
189
192
  cwd: () => currentCwd,
@@ -239,14 +242,24 @@ export default function (pi: ExtensionAPI) {
239
242
  extractErrorNotified = false;
240
243
  configError = null;
241
244
  resetSessionState();
242
- config = await loadConfig(ctx);
245
+ // `loadConfig` 抛错(`getAgentDir` / `isProjectTrusted` 属宿主契约)与初始化失败同一处理:
246
+ // 转成配置错误态,不把裸 reject 冒给宿主(spec §2.4)。用局部变量承接是为了让后面的收窄
247
+ // 不受 `config` 这个工厂作用域 let 影响。(nudge 块仍可能抛错,属既有暴露面,不在本次范围。)
248
+ let loaded: MemoryConfig;
249
+ try {
250
+ loaded = await loadConfig(ctx);
251
+ } catch (e) {
252
+ failConfig([`Failed to load memory config: ${e instanceof Error ? e.message : String(e)}`], ctx);
253
+ return;
254
+ }
255
+ config = loaded;
243
256
  // 状态已经干净,disabled 直接早退即可。
244
- if (!config.enabled) return;
257
+ if (!loaded.enabled) return;
245
258
  try {
246
259
  // 启动校验(spec §2.3):模型键缺失 / 不可解析 → 本会话**完全不初始化**
247
260
  //(不解析目录、不建 store、不注册工具),错误态由 `/memory` 重复显示。
248
261
  // 校验本身抛错(registry 不合契约)也走同一个 catch:failConfig 自己会复位运行时。
249
- const errors = modelConfigErrors(config, (value) => resolveModel(value, ctx.modelRegistry) !== undefined);
262
+ const errors = modelConfigErrors(loaded, (value) => resolveModel(value, ctx.modelRegistry) !== undefined);
250
263
  if (errors.length > 0) {
251
264
  failConfig(errors, ctx);
252
265
  return;
@@ -492,7 +505,16 @@ export default function (pi: ExtensionAPI) {
492
505
  pi.registerCommand("memory", {
493
506
  description: "Show memory status or remove a stale lock",
494
507
  handler: async (args, ctx) => {
508
+ // 配置都读不出来(`loadConfig` 抛错)时也要报真实原因,而不是笼统的「未初始化」——
509
+ // `/memory` 是重读错误态的唯一入口。首次会话就失败时 `config` 仍是 null,所以这里先看错误态。
495
510
  if (!config) {
511
+ if (configError) {
512
+ ctx.ui.notify(
513
+ ["Memory: misconfigured", "Dir: not initialized", ...configError.split("\n").map((e) => `- ${e}`)].join("\n"),
514
+ "info",
515
+ );
516
+ return;
517
+ }
496
518
  ctx.ui.notify("Memory not initialized.", "info");
497
519
  return;
498
520
  }
@@ -524,7 +546,7 @@ export default function (pi: ExtensionAPI) {
524
546
  // 否则只可能是配置里 enabled 为假。
525
547
  const lines = configError
526
548
  ? ["Memory: misconfigured", "Dir: not initialized", ...configError.split("\n").map((e) => `- ${e}`)]
527
- : ["Memory: disabled", 'Dir: not initialized — set "enabled": true in memory.json and restart'];
549
+ : ["Memory: disabled", `Dir: not initialized — ${ENABLE_HINT}`];
528
550
  ctx.ui.notify(lines.join("\n"), "info");
529
551
  return;
530
552
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.1.0",
6
+ "version": "2.2.0",
7
7
  "description": "File-system driven persistent memory layer for pi coding agent",
8
8
  "license": "MIT",
9
9
  "repository": {
package/src/config.ts CHANGED
@@ -35,6 +35,7 @@ export interface AutoSurfacingConfig {
35
35
  }
36
36
 
37
37
  export interface ExtractMemoriesConfig {
38
+ /** 每轮自动提取。opt-in:默认 `false` —— 开启后每轮结束都会跑一次 headless 模型调用。 */
38
39
  enabled: boolean;
39
40
  model?: string;
40
41
  thinkLevel: ThinkLevel;
@@ -106,7 +107,8 @@ export const DEFAULT_CONFIG: MemoryConfig = {
106
107
  maxInjectionBytes: 10240,
107
108
  },
108
109
  extractMemories: {
109
- enabled: true,
110
+ // 每轮结束都要跑一次 headless 模型调用,代价必须由用户显式承担:默认关闭。
111
+ enabled: false,
110
112
  thinkLevel: "high",
111
113
  maxContextTokens: 2000,
112
114
  maxToolResultChars: 500,