@yandy0725/pi-memory 2.2.0 → 2.4.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 +37 -14
- package/README.zh.md +37 -14
- package/index.ts +47 -27
- package/package.json +1 -1
- package/src/config.ts +15 -17
- package/src/entry-index.ts +2 -2
- package/src/inject.ts +136 -7
- package/src/memory-tool.ts +2 -2
package/README.md
CHANGED
|
@@ -6,6 +6,15 @@ Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `
|
|
|
6
6
|
|
|
7
7
|
> ## ⚠️ Breaking changes
|
|
8
8
|
>
|
|
9
|
+
> **In 2.4.0:**
|
|
10
|
+
>
|
|
11
|
+
> - **The package-level `enabled` switch is gone.** `memory.json` has no top-level `enabled` any more: a leftover key is ignored, and `{"enabled": false}` no longer disables anything — it used to skip model validation too, so a disabled config often had no model configured. Disable the extension the way you disable any pi package — by not loading it — see [Disabling the extension](#disabling-the-extension). `dream` is now a required task in every session.
|
|
12
|
+
> - **`/memory` no longer prints a `Memory: enabled|disabled` line.** The healthy status block is 7 lines starting with `Dir:`, and the old disabled state no longer exists — only *healthy* and *misconfigured* remain. Module switches (`autoSurfacing.enabled`, `extractMemories.enabled`) are unchanged.
|
|
13
|
+
>
|
|
14
|
+
> **In 2.3.0:**
|
|
15
|
+
>
|
|
16
|
+
> - **The injected index window is now the newest 50 lines / 16 KiB.** `memIndexInjectMaxLines` 200 → 50 and `memIndexInjectMaxBytes` 25600 → 16384. The write capacity is unchanged (200 lines / 25600 bytes), so memories older than the newest 50 index lines no longer reach the system prompt — they stay reachable through auto-surfacing and the `memory` tool. Configs that already set `memIndexInjectMax*` are unaffected.
|
|
17
|
+
>
|
|
9
18
|
> **In 2.2.0:**
|
|
10
19
|
>
|
|
11
20
|
> - **`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.
|
|
@@ -13,7 +22,7 @@ Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `
|
|
|
13
22
|
> **In 2.1.0:**
|
|
14
23
|
>
|
|
15
24
|
> - **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).
|
|
16
|
-
> - **`/memory on` and `/memory off` are gone.** `enabled` is a `memory.json` switch read once at session start — changing it needs a session restart.
|
|
25
|
+
> - **`/memory on` and `/memory off` are gone.** `enabled` is a `memory.json` switch read once at session start — changing it needs a session restart. **That key has since been removed** — it is ignored if present; see [Disabling the extension](#disabling-the-extension).
|
|
17
26
|
> - **Automatic 1.x → 2.0 migration has been removed.** Legacy topic files stay on disk untouched but are **invisible** to the memory system (they fail `parseEntryFile`'s five-field v2 frontmatter check). See [1.x data](#1x-data).
|
|
18
27
|
>
|
|
19
28
|
> **In 2.0.0:**
|
|
@@ -33,7 +42,7 @@ Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `
|
|
|
33
42
|
- **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.
|
|
34
43
|
- **`/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.
|
|
35
44
|
- **Dream nudge** — after N sessions or N hours a notification suggests `/dream`.
|
|
36
|
-
- **`/memory`** — full status (
|
|
45
|
+
- **`/memory`** — full status (directory, index capacity, entry count, last dream, lock state including the holder), plus `unlock`.
|
|
37
46
|
- **Two-level locking** — an in-process logical lock carries the *logical* scope (one primitive call, or a whole dream round); the cross-process `.lock` file is held for **milliseconds only** and is **never reclaimed automatically**. There is no TTL, no heartbeat and no takeover, so mutual exclusion is a hard guarantee; the price is that a lock left behind by a crashed process must be removed by a human (`/memory unlock`).
|
|
38
47
|
- **Snapshots** — every write leaves a rollback point under `.backups/<ts>-<label>/`, keeping the last `lock.snapshotKeep` (directories named `migrate-*` — whole-directory snapshots from an earlier 1.x migration, whose `originals/` subdirectory holds the pre-2.0 topic files — are never pruned). `/dream` is the exception: it snapshots the whole directory **once on entry**, and the primitives inside that round skip their per-file snapshots (one round, one rollback point).
|
|
39
48
|
- **Session search** — `memory search scope=sessions` queries past conversation history.
|
|
@@ -97,7 +106,7 @@ File names are derived from `name` (unsafe characters replaced, 100-byte cap, `-
|
|
|
97
106
|
- [Test command](Test-command.md) — run npm test, not npm run test
|
|
98
107
|
```
|
|
99
108
|
|
|
100
|
-
Writes are **surgical**: only the target line changes, hand-written headings, groups and comments are preserved byte-for-byte, and line order is stable. The one exception is line endings: CRLF (or lone CR) is normalised to LF before parsing, so the first write to a CRLF file rewrites it with LF.
|
|
109
|
+
Writes are **surgical**: only the target line changes, hand-written headings, groups and comments are preserved byte-for-byte, and line order is stable. The one exception is line endings: CRLF (or lone CR) is normalised to LF before parsing, so the first write to a CRLF file rewrites it with LF. The injected index uses the same normalisation — a CRLF file never sends `\r` into the system prompt.
|
|
101
110
|
|
|
102
111
|
### Memory types
|
|
103
112
|
|
|
@@ -112,7 +121,9 @@ Writes are **surgical**: only the target line changes, hand-written headings, gr
|
|
|
112
121
|
|
|
113
122
|
The index holds at most `memIndexMaxLines` (200) non-empty lines and `memIndexMaxBytes` (25600) bytes. Those 200 lines are **index lines, not memories**: `rebuildIndex` guarantees at least one header line — an existing hand-written header is kept verbatim (trailing blank lines before the first entry are dropped), otherwise it writes `# Memory Index` — and hand-written headings, groups and comments count too. A rebuilt index therefore holds at most about **199 memories per project directory** (fewer if you keep hand-written headings). Exceeding the limit does **not** fail the write: the write succeeds and the tool returns an actionable warning telling the model to merge or drop entries (everything past the limit is invisible on the next load).
|
|
114
123
|
|
|
115
|
-
|
|
124
|
+
**What reaches the model is a separate, smaller window:** `memIndexInjectMaxLines` / `memIndexInjectMaxBytes` (default 50 lines / 16384 bytes). The window is taken from the **newest** end of the index, i.e. from the **bottom of the file**: `memory(action="add")` appends, and `/dream`'s `rebuild_index` re-sorts by `modified`. Two exceptions matter — `memory(action="replace")` rewrites its line **in place**, so an edited older memory keeps its position (and can stay outside the window) until the next `/dream` re-sort; and hand-written headings, groups or notes at the top of the file are positional rather than chronological, so a curated top block is the first thing the window drops. A full index therefore injects the **50 newest memories** (the `# Memory Index` header and its blank line fall outside the window once it truncates); an index of 48 memories or fewer is injected whole. Omitted memories are **not lost**: auto-surfacing, `memory(action="search")` and `/dream` consolidation still see them. `/memory` prints both budgets apart: `Index:` is the write capacity (disk truth), `Inject:` is the window that goes into the system prompt.
|
|
125
|
+
|
|
126
|
+
This is why `/dream` is no longer optional housekeeping — it is **capacity management**. Two thresholds matter: prompt visibility ends at the injection window, so consolidate **before you pass ~48 memories** if you want every entry in the system prompt, while 199 is only the hard write limit past which the index itself has to shrink.
|
|
116
127
|
|
|
117
128
|
## Configuration
|
|
118
129
|
|
|
@@ -120,12 +131,11 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
120
131
|
|
|
121
132
|
```json
|
|
122
133
|
{
|
|
123
|
-
"enabled": true,
|
|
124
134
|
"memoryDir": "~/.pi/memory",
|
|
125
135
|
"memIndexMaxLines": 200,
|
|
126
136
|
"memIndexMaxBytes": 25600,
|
|
127
|
-
"memIndexInjectMaxLines":
|
|
128
|
-
"memIndexInjectMaxBytes":
|
|
137
|
+
"memIndexInjectMaxLines": 50,
|
|
138
|
+
"memIndexInjectMaxBytes": 16384,
|
|
129
139
|
"lock": { "timeoutMs": 5000, "snapshotKeep": 5 },
|
|
130
140
|
"defaults": { "model": "provider/model-id", "sessionPersistence": { "enabled": false } },
|
|
131
141
|
"dream": { "nudgeAfterSessions": 5, "nudgeAfterHours": 24, "thinkLevel": "high" },
|
|
@@ -151,12 +161,11 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
151
161
|
|
|
152
162
|
| Key | Default | Description |
|
|
153
163
|
|-----|---------|-------------|
|
|
154
|
-
| `enabled` | `true` | Toggle the entire memory system on/off. Read once at session start — changing it requires restarting the session |
|
|
155
164
|
| `memoryDir` | `~/.pi/memory` | Root directory for all memory data |
|
|
156
165
|
| `memIndexMaxLines` | `200` | Write capacity: max non-empty lines in `MEMORY.md` (the `# Memory Index` header and hand-written headings count too, so this is not exactly the memory count) |
|
|
157
166
|
| `memIndexMaxBytes` | `25600` | Write capacity: max bytes of `MEMORY.md` |
|
|
158
|
-
| `memIndexInjectMaxLines` | `
|
|
159
|
-
| `memIndexInjectMaxBytes` | `
|
|
167
|
+
| `memIndexInjectMaxLines` | `50` | Injection window: max lines of the index put into the `memory_index` section. The window keeps the **newest** lines and drops the **oldest** ones — the index is pure chronological order, so a smaller window never hides the memory you just wrote. **`0` (either key) injects no index at all** — the `memory_index` section stays empty |
|
|
168
|
+
| `memIndexInjectMaxBytes` | `16384` | Injection window: max bytes of the index section (older lines are dropped first, with a `[truncated: …]` marker at the **top**) |
|
|
160
169
|
| `lock.timeoutMs` | `5000` | How long a write waits for the logical lock (single primitive) or the cross-process `.lock`. Also the upper bound `session_shutdown` waits for in-flight writes |
|
|
161
170
|
| `lock.snapshotKeep` | `5` | Rollback points kept in `.backups/` (directories named `migrate-*` — whole-directory snapshots from an earlier 1.x migration, whose `originals/` subdirectory holds the pre-2.0 topic files — are never pruned) |
|
|
162
171
|
| `defaults.model` | `— (required)` | Shared model for dream / extract / side query. **No default**: every task that will run must resolve a model, otherwise `session_start` fails (see [Model configuration](#model-configuration)). A per-task `model` overrides it |
|
|
@@ -186,17 +195,29 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
186
195
|
|
|
187
196
|
Persisted headless sessions default to `<project memory dir>/sessions/` — inside the project's memory directory, not inside your working copy.
|
|
188
197
|
|
|
198
|
+
### Disabling the extension
|
|
199
|
+
|
|
200
|
+
pi-memory has no package-level switch of its own — the former top-level `enabled` key in `memory.json` is **gone** and is now ignored if present. Disable the extension the way you disable any pi package, by not loading it:
|
|
201
|
+
|
|
202
|
+
Project-only (`.pi/settings.json` in the project root — read only after project trust is granted):
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{ "packages": [{ "source": "npm:@yandy0725/pi-memory", "extensions": [] }] }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
A project entry replaces the personal entry, so the extension is not loaded in that project. Globally: `pi remove npm:@yandy0725/pi-memory`, or toggle the package's resources with `pi config` (project scope writes `autoload: false` plus `extensions: ["-index.ts"]`). Module switches (`autoSurfacing.enabled`, `extractMemories.enabled`) still turn off individual behaviors while the extension stays loaded.
|
|
209
|
+
|
|
189
210
|
## Model configuration
|
|
190
211
|
|
|
191
212
|
Every task that will run must resolve a model — **there is no shipped default and no parent-model fallback**. `defaults.model` satisfies all of them; a per-task `model` (`dream.model`, `extractMemories.model`, `autoSurfacing.model`) overrides it.
|
|
192
213
|
|
|
193
214
|
| Task | Required when |
|
|
194
215
|
|------|---------------|
|
|
195
|
-
| `dream` |
|
|
216
|
+
| `dream` | always required — every session validates it at startup |
|
|
196
217
|
| `extractMemories` | `extractMemories.enabled` is true |
|
|
197
218
|
| `autoSurfacing` | `autoSurfacing.enabled` is true |
|
|
198
219
|
|
|
199
|
-
|
|
220
|
+
At `session_start` pi-memory resolves every required model against the model registry. If one is missing or cannot be resolved, it initialises **nothing**: it shows an error notification `pi-memory config error:` followed by one `- <error>` line per problem, and `/memory` reports `Memory: misconfigured` and `Dir: not initialized`, followed by the same lines. The two possible messages are:
|
|
200
221
|
|
|
201
222
|
- `no model for <task> — set "<task>.model" or "defaults.model" in memory.json`
|
|
202
223
|
- `model "<value>" for <task> is not resolvable (unknown id or missing credentials)`
|
|
@@ -306,17 +327,19 @@ One line per memory: `- name (type, modified …) — description [file]`.
|
|
|
306
327
|
Status output:
|
|
307
328
|
|
|
308
329
|
```
|
|
309
|
-
Memory: enabled
|
|
310
330
|
Dir: /home/you/.pi/memory/git/github.com__owner__repo
|
|
311
331
|
Index: 38/200 lines, 2841/25600 bytes, 1 unrecognized lines
|
|
332
|
+
Inject: 39/50 lines, 2841/16384 bytes
|
|
312
333
|
Entries: 37
|
|
334
|
+
Modules: dream=on(provider/model-a) extractMemories=off autoSurfacing=on(provider/model-b)
|
|
313
335
|
Last dream: 2026-10-01T22:10:04.882Z
|
|
314
336
|
Lock: free
|
|
315
337
|
```
|
|
316
338
|
|
|
317
339
|
- `Index` uses the **write** capacity (`memIndexMax*`) and reports how many non-empty lines could not be parsed as index lines (the `# Memory Index` header and hand-written headings count). CRLF (or lone CR) line endings are normalised to LF before parsing, and the next write emits LF too, so a `MEMORY.md` re-saved by a Windows editor does **not** raise this count.
|
|
340
|
+
- `Inject` uses the **injection** window (`memIndexInjectMax*`) and counts the window's lines and bytes — the index text that goes into the `memory_index` section, taken from the **newest** end (the truncation marker itself is not counted). It is computed by the same window code that produces the injected value, so the two cannot drift. Note the two lines count different things: `Index` counts **non-empty** lines, `Inject` counts **every** line of the window, so on a canonical index (LF endings, trailing newline, one blank line after the header) `Inject` reports one more line than `Index` and the same byte count. The value in the system prompt is **frozen for the session** (see [Why the index is frozen](#why-the-index-is-frozen)): a memory written after `session_start` appears in `Index` immediately but in `Inject` only after compaction or in the next session.
|
|
341
|
+
- `Modules` reports the activation state of the three model-driven features as `on(<effective model>)` / `off`. The effective model is the task's own `model`, otherwise `defaults.model`. `dream` has no switch of its own — it is always available in a healthy session.
|
|
318
342
|
- `Lock` is `free`, `held by <op> (pid N on <hostname>, started <ISO>)`, or `unreadable — run /memory unlock`. `/memory unlock` shows the same holder line in its confirmation prompt.
|
|
319
|
-
- In a session started with `enabled: false`, nothing is initialized at boot: `/memory` reports `Memory: disabled` plus `Dir: not initialized — set "enabled": true in memory.json and restart`, there is no way to enable it mid-session, and `/memory unlock` still works without a store.
|
|
320
343
|
- If a required model is missing or cannot be resolved, nothing is initialized and `/memory` reports `Memory: misconfigured` and `Dir: not initialized`, followed by one `- <error>` line per problem. The same errors are shown as an error notification at session start.
|
|
321
344
|
|
|
322
345
|
### `/dream`
|
package/README.zh.md
CHANGED
|
@@ -6,6 +6,15 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
|
|
|
6
6
|
|
|
7
7
|
> ## ⚠️ 破坏性变更
|
|
8
8
|
>
|
|
9
|
+
> **2.4.0:**
|
|
10
|
+
>
|
|
11
|
+
> - **包级开关 `enabled` 已删除。** `memory.json` 不再有顶层 `enabled`:残留的键会被忽略,`{"enabled": false}` 不再能禁用任何东西 —— 它以前还会跳过模型校验,所以被禁用的配置往往也没配模型。要禁用整个扩展,请像禁用其他 pi package 一样「不加载它」,见[禁用本扩展](#禁用本扩展)。`dream` 现在是每个会话的必需任务。
|
|
12
|
+
> - **`/memory` 不再输出 `Memory: enabled|disabled` 行。** 健康态状态块为 7 行、以 `Dir:` 起头;旧的「禁用」态已不存在,只剩**健康**与**配置错误**两态。模块级开关(`autoSurfacing.enabled` / `extractMemories.enabled`)不变。
|
|
13
|
+
>
|
|
14
|
+
> **2.3.0:**
|
|
15
|
+
>
|
|
16
|
+
> - **注入的索引窗口改为「最新的 50 行 / 16 KiB」。** `memIndexInjectMaxLines` 200 → 50、`memIndexInjectMaxBytes` 25600 → 16384。写入口径不变(200 行 / 25600 字节),因此比「最新 50 行」更旧的记忆不再进 system prompt —— 它们仍可由 auto-surfacing 与 `memory` 工具检索。已经显式配置 `memIndexInjectMax*` 的用户不受影响。
|
|
17
|
+
>
|
|
9
18
|
> **2.2.0:**
|
|
10
19
|
>
|
|
11
20
|
> - **`extractMemories.enabled` 默认改为 `false`。** 每轮自动提取现在是 opt-in:开启后每轮结束都会跑一次 headless 模型调用。已经显式写了 `"extractMemories": { "enabled": true }` 的配置不受影响。
|
|
@@ -13,7 +22,7 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
|
|
|
13
22
|
> **2.1.0:**
|
|
14
23
|
>
|
|
15
24
|
> - **模型必须显式配置。** 没有内置默认值,也没有父会话模型回退:`defaults.model`(或 per-task `model`)必须存在且可解析,否则 `session_start` 会报配置错误并且**什么都不初始化**。详见[模型配置](#模型配置)。
|
|
16
|
-
> - **`/memory on` / `/memory off` 已删除。** `enabled` 只是 `memory.json`
|
|
25
|
+
> - **`/memory on` / `/memory off` 已删除。** `enabled` 只是 `memory.json` 里的开关,启动时读一次,改动需要重启会话。**该键现已移除** —— 写了也会被忽略,见[禁用本扩展](#禁用本扩展)。
|
|
17
26
|
> - **1.x → 2.0 的自动迁移已删除。** legacy topic 文件原样留在磁盘上,但对记忆系统**不可见**(过不了 `parseEntryFile` 的 v2 五字段校验)。详见 [1.x 数据](#1x-数据)。
|
|
18
27
|
>
|
|
19
28
|
> **2.0.0 已包含:**
|
|
@@ -33,7 +42,7 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
|
|
|
33
42
|
- **自动提取(extract memories)** ⭐ —— 每轮结束后一个异步 headless agent 拿到的是**整轮对话的结构化渲染**(user 消息全文、assistant 文本与 tool_call、tool_result 及其错误标记),而不是两条消息。它经同一套 `memory` 原语写入,并且**从不排队等锁**:dream 正在整轮持锁时,本回合直接跳过。该功能**默认关闭**,需要显式设 `extractMemories.enabled: true`。
|
|
34
43
|
- **`/dream`** —— headless 整理 agent(Orient → Gather Signal → Consolidate → Prune & Index),合并重复、消解矛盾、改名、重建索引。它**没有裸文件权限**:只有七个 `memory` action,整轮持有逻辑锁,进入时先对整个目录拍一次快照。
|
|
35
44
|
- **Dream 提醒** —— 距上次 dream 超过 N 个会话或 N 小时后提示 `/dream`。
|
|
36
|
-
- **`/memory`** ——
|
|
45
|
+
- **`/memory`** —— 完整状态(目录、索引容量、entry 数、上次 dream、锁状态含持有者),以及 `unlock`。
|
|
37
46
|
- **两级锁** —— 进程内逻辑锁承担**逻辑作用域**(单次原语,或 dream 的整轮);跨进程 `.lock` **只持毫秒**且**永不自动回收**。没有 TTL、没有心跳、没有接管,所以互斥是硬保证;代价是崩溃遗留的锁必须**人工**清除(`/memory unlock`)。
|
|
38
47
|
- **快照** —— 每次写入都在 `.backups/<ts>-<label>/` 留下回滚点,保留最近 `lock.snapshotKeep` 份(`migrate-` 开头的目录是旧版迁移留下的整目录快照,其 `originals/` 子目录里才是 2.0 之前的 topic 原文,永不裁剪)。`/dream` 是例外:它**进入时只对整个目录拍一次**快照,该轮内部的原语会跳过逐文件快照(一轮只留一个回滚点)。
|
|
39
48
|
- **会话检索** —— `memory search scope=sessions` 查历史会话。
|
|
@@ -112,7 +121,9 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
112
121
|
|
|
113
122
|
索引上限是 `memIndexMaxLines`(200)个非空行与 `memIndexMaxBytes`(25600)字节。这 200 行是**索引行,不是记忆条数**:`rebuildIndex` 至少保证一行头部(已有手写头部时原样保留 —— 首个条目之前的末尾空行会被去掉;否则写 `# Memory Index`),手写的标题、分组、注释同样占额度。因此重建后的索引最多约 **199 条记忆**(每个项目目录;若保留手写标题则更少)。超限时写入**不会失败**:写入照样成功,工具把一条可操作的警告回给模型,让它去合并或删除条目(超出上限的部分下次加载时不可见)。
|
|
114
123
|
|
|
115
|
-
|
|
124
|
+
**真正进模型的是另一个更小的窗口**:`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 的窗口。
|
|
125
|
+
|
|
126
|
+
这也是 `/dream` 不再是「可选的整理」而是**容量管理必需**的原因。两个阀值要分开看:prompt 可见性止于注入窗口,想让每条记忆都进 system prompt,就在**接近 48 条之前**整理;199 只是写入口径的硬上限,过了它索引自身就必须缩小。
|
|
116
127
|
|
|
117
128
|
## 配置
|
|
118
129
|
|
|
@@ -120,12 +131,11 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
120
131
|
|
|
121
132
|
```json
|
|
122
133
|
{
|
|
123
|
-
"enabled": true,
|
|
124
134
|
"memoryDir": "~/.pi/memory",
|
|
125
135
|
"memIndexMaxLines": 200,
|
|
126
136
|
"memIndexMaxBytes": 25600,
|
|
127
|
-
"memIndexInjectMaxLines":
|
|
128
|
-
"memIndexInjectMaxBytes":
|
|
137
|
+
"memIndexInjectMaxLines": 50,
|
|
138
|
+
"memIndexInjectMaxBytes": 16384,
|
|
129
139
|
"lock": { "timeoutMs": 5000, "snapshotKeep": 5 },
|
|
130
140
|
"defaults": { "model": "provider/model-id", "sessionPersistence": { "enabled": false } },
|
|
131
141
|
"dream": { "nudgeAfterSessions": 5, "nudgeAfterHours": 24, "thinkLevel": "high" },
|
|
@@ -151,12 +161,11 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
151
161
|
|
|
152
162
|
| 键 | 默认值 | 说明 |
|
|
153
163
|
|-----|---------|------|
|
|
154
|
-
| `enabled` | `true` | 整个记忆系统的开关。**启动时读一次**,改动需重启会话 |
|
|
155
164
|
| `memoryDir` | `~/.pi/memory` | 所有记忆数据的根目录 |
|
|
156
165
|
| `memIndexMaxLines` | `200` | 写入口径:`MEMORY.md` 的最大非空行数(`# Memory Index` 头行与手写标题同样占额度,所以并不等于记忆条数) |
|
|
157
166
|
| `memIndexMaxBytes` | `25600` | 写入口径:`MEMORY.md` 的最大字节数 |
|
|
158
|
-
| `memIndexInjectMaxLines` | `
|
|
159
|
-
| `memIndexInjectMaxBytes` | `
|
|
167
|
+
| `memIndexInjectMaxLines` | `50` | 注入口径:放进 `memory_index` section 的最大行数。窗口保留**最新**的行、丢弃**最旧**的行 —— 索引是纯时间序,窗口再小也不会藏住你刚写完的那条。**任一键写 `0` = 完全不注入索引**(section 保持空值) |
|
|
168
|
+
| `memIndexInjectMaxBytes` | `16384` | 注入口径:section 的最大字节数(优先丢最旧的行,截断标记在**开头**) |
|
|
160
169
|
| `lock.timeoutMs` | `5000` | 单次原语等逻辑锁 / 等跨进程 `.lock` 的上限。同时也是 `session_shutdown` 等在途写入的上限 |
|
|
161
170
|
| `lock.snapshotKeep` | `5` | `.backups/` 保留的回滚点数量(`migrate-` 前缀的目录永不裁剪 —— 它们是旧版迁移留下的整目录快照,`originals/` 子目录里装着 2.0 之前的 topic 原文) |
|
|
162
171
|
| `defaults.model` | —(必需) | 三个子任务的共享模型。**没有默认值**:会执行的任务必须能解析出模型,否则启动失败(见[模型配置](#模型配置))。per-task 覆盖它 |
|
|
@@ -186,17 +195,29 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
186
195
|
|
|
187
196
|
headless 会话默认落在 `<项目记忆目录>/sessions/` —— 在项目记忆目录里,不在你的工作副本里。
|
|
188
197
|
|
|
198
|
+
### 禁用本扩展
|
|
199
|
+
|
|
200
|
+
pi-memory 没有自己的包级开关 —— `memory.json` 里原来的顶层 `enabled` 键**已移除**,写了也会被忽略。禁用它与其他 pi package 一样,靠「不加载这个扩展」:
|
|
201
|
+
|
|
202
|
+
只在本项目禁用(项目根目录的 `.pi/settings.json`,仅在项目被信任后读取):
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{ "packages": [{ "source": "npm:@yandy0725/pi-memory", "extensions": [] }] }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
项目条目会替换个人条目,因此该项目不会加载本扩展。全局禁用:`pi remove npm:@yandy0725/pi-memory`,或用 `pi config` 关闭该包的资源(项目 scope 会写成 `autoload: false` + `extensions: ["-index.ts"]`)。扩展保持加载时,仍可用模块级开关(`autoSurfacing.enabled` / `extractMemories.enabled`)单独关掉某个行为。
|
|
209
|
+
|
|
189
210
|
## 模型配置
|
|
190
211
|
|
|
191
212
|
会执行的任务必须能解析出模型 —— **既没有随包默认值,也没有父会话模型回退**。`defaults.model` 可以满足全部任务;各任务自己的 `model`(`dream.model` / `extractMemories.model` / `autoSurfacing.model`)优先于它。
|
|
192
213
|
|
|
193
214
|
| 任务 | 何时必需 |
|
|
194
215
|
|------|---------|
|
|
195
|
-
| `dream` |
|
|
216
|
+
| `dream` | **恒**需要 —— 每个会话启动时都校验 |
|
|
196
217
|
| `extractMemories` | `extractMemories.enabled` 为真时 |
|
|
197
218
|
| `autoSurfacing` | `autoSurfacing.enabled` 为真时 |
|
|
198
219
|
|
|
199
|
-
`
|
|
220
|
+
`session_start` 会把每个必需模型拿到注册表里解析;只要有缺失或解析不出的,就**不初始化任何东西**:弹一条 error 通知 `pi-memory config error:` + 每个问题一行 `- <error>`,`/memory` 则报 `Memory: misconfigured` + `Dir: not initialized` + 同样的行。两条错误文案:
|
|
200
221
|
|
|
201
222
|
- `no model for <task> — set "<task>.model" or "defaults.model" in memory.json`
|
|
202
223
|
- `model "<value>" for <task> is not resolvable (unknown id or missing credentials)`
|
|
@@ -306,17 +327,19 @@ memory(action: "add" | "replace" | "remove" | "list" | "search",
|
|
|
306
327
|
状态输出:
|
|
307
328
|
|
|
308
329
|
```
|
|
309
|
-
Memory: enabled
|
|
310
330
|
Dir: /home/you/.pi/memory/git/github.com__owner__repo
|
|
311
331
|
Index: 38/200 lines, 2841/25600 bytes, 1 unrecognized lines
|
|
332
|
+
Inject: 39/50 lines, 2841/16384 bytes
|
|
312
333
|
Entries: 37
|
|
334
|
+
Modules: dream=on(provider/model-a) extractMemories=off autoSurfacing=on(provider/model-b)
|
|
313
335
|
Last dream: 2026-10-01T22:10:04.882Z
|
|
314
336
|
Lock: free
|
|
315
337
|
```
|
|
316
338
|
|
|
317
|
-
- `Index` 用**写入**口径(`memIndexMax*`),并报告索引里有多少非空行解析不出(`# Memory Index` 头行与手写标题会计入)。CRLF(以及单独的 CR)行尾在解析前就被归一为 LF,下一次写入也一律输出 LF,因此被 Windows 编辑器改过行尾的 `MEMORY.md`
|
|
339
|
+
- `Index` 用**写入**口径(`memIndexMax*`),并报告索引里有多少非空行解析不出(`# Memory Index` 头行与手写标题会计入)。CRLF(以及单独的 CR)行尾在解析前就被归一为 LF,下一次写入也一律输出 LF,因此被 Windows 编辑器改过行尾的 `MEMORY.md` **不会**推高这个计数。注入侧同样做归一:CRLF 文件不会把 `\r` 送进 system prompt。
|
|
340
|
+
- `Inject` 用**注入**口径(`memIndexInjectMax*`),统计窗口内的行数与字节数 —— 即真正会进 `memory_index` section 的索引文本(截断标记本身不计入)。它与真正注入的值由同一份窗口代码算出来,不可能漂移。注意两行的口径不同:`Index` 数的是**非空**行,`Inject` 数的是窗口内的**全部**行,所以规范索引(LF 行尾、以换行结尾、头部后有且仅有一个空行)下 `Inject` 会比 `Index` 多一行而字节数相同。system prompt 里的值是**会话内冻结**的(见[为什么索引是冻结的](#为什么索引是冻结的)):`session_start` 之后写入的记忆会立刻出现在 `Index`,但要等 compaction 或下一个会话才出现在 `Inject`。
|
|
341
|
+
- `Modules` 报三个模型驱动功能的激活状态:`on(<生效模型>)` / `off`。生效模型 = 该任务自己的 `model`,没有则用 `defaults.model`。`dream` 没有独立开关 —— 健康会话里它始终可用。
|
|
318
342
|
- `Lock` 有三种:`free`、`held by <op> (pid N on <hostname>, started <ISO>)`、`unreadable — run /memory unlock`。`/memory unlock` 的确认框会显示同一行持有者信息。
|
|
319
|
-
- 以 `enabled: false` 启动的会话在启动时不初始化任何东西:`/memory` 报两行(`Memory: disabled` + `Dir: not initialized — set "enabled": true in memory.json and restart`);会话中途无法开启;`/memory unlock` 不需要 store 也能用。
|
|
320
343
|
- 必需模型缺失或解析不出时不初始化任何东西,`/memory` 报 `Memory: misconfigured` + `Dir: not initialized` + 每行一条 `- <error>`;同样的错误在 session_start 时以 error 通知出现。
|
|
321
344
|
|
|
322
345
|
### `/dream`
|
package/index.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { unlink } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { loadConfig, modelConfigErrors, requiredModel, type MemoryConfig, type SessionPersistenceConfig } from "./src/config";
|
|
5
|
+
import { loadConfig, modelConfigErrors, requiredModel, taskModel, type MemoryConfig, type SessionPersistenceConfig } from "./src/config";
|
|
6
6
|
import { runDream } from "./src/dream";
|
|
7
7
|
import { indexCapacity, parseEntryIndex } from "./src/entry-index";
|
|
8
8
|
import { runExtract } from "./src/extract";
|
|
9
9
|
import { readLockStatus, type LockInfo } from "./src/fs-lock";
|
|
10
10
|
import { readRecordedMemoryIndex } from "./src/index-source";
|
|
11
|
-
import { applyIndexSection, buildIndexSection, buildInjection, injectSurfacedContent, runSideQuery, scanEntries } from "./src/inject";
|
|
11
|
+
import { applyIndexSection, buildIndexSection, buildInjection, indexInjectionCapacity, injectSurfacedContent, runSideQuery, scanEntries } from "./src/inject";
|
|
12
12
|
import {
|
|
13
13
|
createMemoryTool,
|
|
14
14
|
DREAM_ACTIONS,
|
|
@@ -68,6 +68,28 @@ function countInjectedBlocks(content: string): number {
|
|
|
68
68
|
return Math.max(0, content.split("\n## ").length - 1);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* `/memory` 的模块激活状态一行:`dream=on(model) extractMemories=off autoSurfacing=on(model)`。
|
|
73
|
+
*
|
|
74
|
+
* `dream` 没有独立开关 —— 健康会话里(能走到状态分支)它就可用;另外两个直接反映各自的
|
|
75
|
+
* `enabled`。模型是**生效值**(per-task 优先,其次 `defaults.model`),关闭的模块不显示模型:
|
|
76
|
+
* 用户问「为什么没生效」时答案在开关上,而不在模型上。
|
|
77
|
+
*
|
|
78
|
+
* 导出供测试直接覆盖:正常会话里「模型解析不出」那条路径走不到(`session_start` 会先拦成
|
|
79
|
+
* misconfigured),只能直接调纯函数。
|
|
80
|
+
*/
|
|
81
|
+
export function moduleStatusLine(cfg: MemoryConfig): string {
|
|
82
|
+
return (["dream", "extractMemories", "autoSurfacing"] as const)
|
|
83
|
+
.map((task) => {
|
|
84
|
+
// `?.` 是防御:`deepMerge` 会把用户写的 `"extractMemories": null` 原样带进来,
|
|
85
|
+
// 那时 `cfg[task].enabled` 会抛错,把整个 `/memory` 命令带崩。
|
|
86
|
+
const enabled = task === "dream" ? true : cfg[task]?.enabled;
|
|
87
|
+
if (!enabled) return `${task}=off`;
|
|
88
|
+
return `${task}=on(${taskModel(cfg, task) ?? "no model"})`;
|
|
89
|
+
})
|
|
90
|
+
.join(" ");
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
/** `<op> (pid N on <hostname>, started <ISO>)` —— `/memory` 的 Lock 行与 unlock 确认框共用同一份描述。 */
|
|
72
94
|
function describeHolder(holder: LockInfo): string {
|
|
73
95
|
return `${holder.op} (pid ${holder.pid} on ${holder.hostname}, started ${holder.startedAt})`;
|
|
@@ -144,11 +166,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
144
166
|
*/
|
|
145
167
|
const inFlight = new Set<Promise<unknown>>();
|
|
146
168
|
|
|
147
|
-
/** `enabled: false` 时给用户的唯一动作。工具文案与 `/memory` 状态共用同一份,避免两处漂移。 */
|
|
148
|
-
const ENABLE_HINT = 'set "enabled": true in memory.json and restart';
|
|
149
|
-
|
|
150
169
|
/**
|
|
151
|
-
* 清空本 session
|
|
170
|
+
* 清空本 session 的运行时状态。两条早退路径(配置错误 / 初始化失败)共用:
|
|
152
171
|
* 残留上一 session 的 store 会让后续写入落到别的项目目录(Plan C ledger R51)。
|
|
153
172
|
*/
|
|
154
173
|
function resetSessionState(): void {
|
|
@@ -185,9 +204,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
204
|
getUnavailableMessage: () =>
|
|
186
205
|
configError
|
|
187
206
|
? `Memory not initialized — ${configError.split("\n")[0]}; run /memory for details`
|
|
188
|
-
:
|
|
189
|
-
? `Memory is disabled — ${ENABLE_HINT}`
|
|
190
|
-
: null,
|
|
207
|
+
: null,
|
|
191
208
|
searchSessions,
|
|
192
209
|
cwd: () => currentCwd,
|
|
193
210
|
};
|
|
@@ -195,12 +212,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
195
212
|
/**
|
|
196
213
|
* 建立本 session 的记忆运行时:目录 → store → 索引来源 → 注册工具。
|
|
197
214
|
*
|
|
198
|
-
* 只在 `session_start`
|
|
215
|
+
* 只在 `session_start` 调用,且调用方已通过模型校验。
|
|
199
216
|
* 抛错 = 初始化失败,由调用方转成配置错误态(`configError`)。
|
|
200
217
|
* `reason` 只有 `session_start` 会传:resume / fork / reload 用 transcript 的录制值(D14)。
|
|
201
218
|
*/
|
|
202
219
|
async function initMemory(ctx: ExtensionContext, reason?: string): Promise<void> {
|
|
203
|
-
// biome-ignore lint/style/noNonNullAssertion:
|
|
220
|
+
// biome-ignore lint/style/noNonNullAssertion: 调用方已通过模型校验
|
|
204
221
|
const cfg = config!;
|
|
205
222
|
currentCwd = ctx.cwd;
|
|
206
223
|
const dir = await resolveMemoryDir(cfg, ctx.cwd);
|
|
@@ -234,12 +251,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
234
251
|
}
|
|
235
252
|
}
|
|
236
253
|
pi.on("session_start", async (event, ctx) => {
|
|
237
|
-
//
|
|
254
|
+
// 复位必须在**任何可能抛错的调用之前**跑完:冷启动与配置错误会话都要有干净的一次失败通知
|
|
238
255
|
// 配额、干净的错误态,以及**清空的运行时**。loadConfig(里面的 getAgentDir)与下面的
|
|
239
256
|
// modelConfigErrors(宿主给的 registry 可能既没有 getAvailable 也没有 getAll)都属于宿主契约
|
|
240
257
|
// 之外的部分:它们一旦抛出而复位还没跑,上一 session 的 store / memoryDir 就会留在**已经注册**
|
|
241
258
|
// 的 `memory` 工具背后 —— 项目 B 的 agent 能写进项目 A 的目录(Plan C ledger R51)。
|
|
242
259
|
extractErrorNotified = false;
|
|
260
|
+
// `configError = null` 是纯防御:所有把 store 置空的路径都会经 failConfig 覆写它,所以这次复位
|
|
261
|
+
// 本身不可观测 —— 没有测试钉它,删掉也不会让任何用例变红(2026-10-03 final review 的结论)。
|
|
243
262
|
configError = null;
|
|
244
263
|
resetSessionState();
|
|
245
264
|
// `loadConfig` 抛错(`getAgentDir` / `isProjectTrusted` 属宿主契约)与初始化失败同一处理:
|
|
@@ -253,8 +272,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
253
272
|
return;
|
|
254
273
|
}
|
|
255
274
|
config = loaded;
|
|
256
|
-
// 状态已经干净,disabled 直接早退即可。
|
|
257
|
-
if (!loaded.enabled) return;
|
|
258
275
|
try {
|
|
259
276
|
// 启动校验(spec §2.3):模型键缺失 / 不可解析 → 本会话**完全不初始化**
|
|
260
277
|
//(不解析目录、不建 store、不注册工具),错误态由 `/memory` 重复显示。
|
|
@@ -333,7 +350,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
333
350
|
lastSystemPrompt = event.systemPrompt;
|
|
334
351
|
// 先拷到 const:`store` 是工厂作用域的 let,在异步回调里 TS 不会保留它的外层收窄。
|
|
335
352
|
const activeStore = store;
|
|
336
|
-
if (!config
|
|
353
|
+
if (!config || !memoryDir || !activeStore) return;
|
|
337
354
|
|
|
338
355
|
// 索引 section:**每一轮无条件**写入冻结值(含 resume / fork / reload)。
|
|
339
356
|
// 省略这个键 = pi 的 diffSystemPromptSections 生成 { memory_index: null } = 把索引从
|
|
@@ -402,7 +419,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
402
419
|
// 的边际缓存损失最小,而长会话到这时候往往已经攒下了新记忆(spec §9.1(c) / §10)。
|
|
403
420
|
pi.on("session_compact", async () => {
|
|
404
421
|
const activeStore = store;
|
|
405
|
-
if (!config
|
|
422
|
+
if (!config || !activeStore) return;
|
|
406
423
|
// compaction 会把已注入的内容挤出上下文:不清空,这些 entry 本会话再也不会浮现。
|
|
407
424
|
injectedFiles.clear();
|
|
408
425
|
indexSnapshot = await buildIndexSection(
|
|
@@ -434,7 +451,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
434
451
|
// 先拷到 const:`store` / `memoryDir` 是工厂作用域的 let,在异步回调里 TS 不保留外层收窄。
|
|
435
452
|
const activeStore = store;
|
|
436
453
|
const dir = memoryDir;
|
|
437
|
-
if (!config
|
|
454
|
+
if (!config || !dir || !activeStore) return;
|
|
438
455
|
const extractConfig = config.extractMemories;
|
|
439
456
|
if (!extractConfig?.enabled) return;
|
|
440
457
|
if (!event.messages || event.messages.length === 0) return;
|
|
@@ -519,7 +536,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
519
536
|
return;
|
|
520
537
|
}
|
|
521
538
|
if (args === "unlock") {
|
|
522
|
-
// `unlock` 只需要**目录**、不需要 store
|
|
539
|
+
// `unlock` 只需要**目录**、不需要 store:以配置错误启动的会话也要能清锁
|
|
523
540
|
//(它是崩溃遗留 `.lock` 的唯一人工入口,spec §19)。
|
|
524
541
|
let dir = memoryDir;
|
|
525
542
|
if (!dir) {
|
|
@@ -542,24 +559,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
542
559
|
const activeStore = store;
|
|
543
560
|
const dir = memoryDir;
|
|
544
561
|
if (!dir || !activeStore) {
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
const lines =
|
|
548
|
-
|
|
549
|
-
: ["Memory: disabled", `Dir: not initialized — ${ENABLE_HINT}`];
|
|
562
|
+
// `config` 非 null 而 store 为 null 只可能来自 session_start 的两条早退路径,
|
|
563
|
+
// 因此 configError 在这里必非空;`/memory` 是用户重读错误态的唯一入口。
|
|
564
|
+
const lines = ["Memory: misconfigured", "Dir: not initialized"];
|
|
565
|
+
if (configError) lines.push(...configError.split("\n").map((e) => `- ${e}`));
|
|
550
566
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
551
567
|
return;
|
|
552
568
|
}
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
569
|
+
// 两套口径都要报:`Index:` 是写入口径(用户要知道「还能不能写」),`Inject:` 是注入口径
|
|
570
|
+
// —— 窗口只取索引**最新**的 50 行,与写入口径解耦,只报前者会让用户以为 Entries 全在
|
|
571
|
+
// prompt 里。`indexInjectionCapacity` 与真正注入共用同一个窗口核心,数字不可能漂移。
|
|
572
|
+
// unrecognized 是索引里非空但解析不了的行数 —— 手写标题/分组/被 Windows 编辑器改坏的行
|
|
573
|
+
// 都在这里露出来。
|
|
556
574
|
const indexRaw = await activeStore.readIndex();
|
|
557
575
|
const cap = indexCapacity(indexRaw, config.memIndexMaxLines, config.memIndexMaxBytes);
|
|
576
|
+
const inject = indexInjectionCapacity(indexRaw, config.memIndexInjectMaxLines, config.memIndexInjectMaxBytes);
|
|
558
577
|
const summary = [
|
|
559
|
-
`Memory: ${config.enabled ? "enabled" : "disabled"}`,
|
|
560
578
|
`Dir: ${dir}`,
|
|
561
579
|
`Index: ${cap.lineCount}/${config.memIndexMaxLines} lines, ${cap.byteLength}/${config.memIndexMaxBytes} bytes, ${parseEntryIndex(indexRaw).unrecognized} unrecognized lines`,
|
|
580
|
+
`Inject: ${inject.lineCount}/${config.memIndexInjectMaxLines} lines, ${inject.byteLength}/${config.memIndexInjectMaxBytes} bytes`,
|
|
562
581
|
`Entries: ${(await activeStore.listEntries()).length}`,
|
|
582
|
+
`Modules: ${moduleStatusLine(config)}`,
|
|
563
583
|
`Last dream: ${(await readDreamMeta(dir))?.lastDreamAt ?? "never"}`,
|
|
564
584
|
`Lock: ${await lockStatusLine(dir)}`,
|
|
565
585
|
].join("\n");
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -47,7 +47,6 @@ export interface ExtractMemoriesConfig {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface MemoryConfig {
|
|
50
|
-
enabled: boolean;
|
|
51
50
|
/** Shared defaults for model and sessionPersistence. Per-task configs override. */
|
|
52
51
|
defaults?: DefaultsConfig;
|
|
53
52
|
memoryDir: string;
|
|
@@ -56,12 +55,13 @@ export interface MemoryConfig {
|
|
|
56
55
|
/** Write capacity: max bytes of serialized MEMORY.md index. */
|
|
57
56
|
memIndexMaxBytes: number;
|
|
58
57
|
/**
|
|
59
|
-
* 注入截断:`memory_index` section
|
|
60
|
-
*
|
|
61
|
-
*
|
|
58
|
+
* 注入截断:`memory_index` section 最多带多少**行**索引(默认 50)。
|
|
59
|
+
* 窗口取索引**最新**的一段(索引是纯时间序,见 `truncateIndexForInjection`),所以被丢掉的
|
|
60
|
+
* 永远是**最旧**的记忆。写入口径(`memIndexMaxLines`)刻意**不随**它收紧:索引里能留更多条目,
|
|
61
|
+
* 超窗口的部分只靠 auto-surfacing / `memory` 工具检索,不进 system prompt。
|
|
62
62
|
*/
|
|
63
63
|
memIndexInjectMaxLines: number;
|
|
64
|
-
/** 注入截断:`memory_index` section
|
|
64
|
+
/** 注入截断:`memory_index` section 最多带多少**字节**(默认 16384 ≈ 50 条中文索引行的实测上界)。 */
|
|
65
65
|
memIndexInjectMaxBytes: number;
|
|
66
66
|
/**
|
|
67
67
|
* 两级锁的参数(spec §5.2)。结构与 `StoreConfig["lock"]` 逐字一致,因此可以原样传给
|
|
@@ -86,16 +86,16 @@ export interface MemoryConfig {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export const DEFAULT_CONFIG: MemoryConfig = {
|
|
89
|
-
enabled: true,
|
|
90
89
|
// headless 子会话默认只在内存里跑:extract / dream / 侧查询都不该往用户的 sessions 目录里落盘。
|
|
91
90
|
// **没有模型默认值**:model 必须由用户显式配置(defaults.model 或 per-task),否则 session_start 报错。
|
|
92
91
|
defaults: { sessionPersistence: { enabled: false } },
|
|
93
92
|
memoryDir: join(homedir(), CONFIG_DIR_NAME, "memory"),
|
|
94
93
|
memIndexMaxLines: 200,
|
|
95
94
|
memIndexMaxBytes: 25600,
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
// 注入口径独立于写入口径(v2.3.0 起):窗口只取索引**最新**的 50 行。
|
|
96
|
+
// 16384 B ≈ 50 条中文索引行的实测上界(本机样本 221–321 B/行),保证「行数」才是真正生效的上限。
|
|
97
|
+
memIndexInjectMaxLines: 50,
|
|
98
|
+
memIndexInjectMaxBytes: 16384,
|
|
99
99
|
lock: { timeoutMs: 5000, snapshotKeep: 5 },
|
|
100
100
|
dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24, thinkLevel: "high" },
|
|
101
101
|
sessionSearch: { maxSessions: 10, maxMatches: 5 },
|
|
@@ -119,17 +119,15 @@ export const DEFAULT_CONFIG: MemoryConfig = {
|
|
|
119
119
|
/** 需要显式模型的子任务。顺序固定:dream → extractMemories → autoSurfacing(校验信息按此顺序输出)。 */
|
|
120
120
|
export type ModelTask = "dream" | "extractMemories" | "autoSurfacing";
|
|
121
121
|
|
|
122
|
-
/** 某任务的模型值:per-task 优先,其次共享的 defaults.model
|
|
123
|
-
function taskModel(cfg: MemoryConfig, task: ModelTask): string | undefined {
|
|
124
|
-
|
|
122
|
+
/** 某任务的模型值:per-task 优先,其次共享的 defaults.model。`/memory` 的模块状态行也用它。 */
|
|
123
|
+
export function taskModel(cfg: MemoryConfig, task: ModelTask): string | undefined {
|
|
124
|
+
// `?.`:`deepMerge` 把用户写的 `"dream": null` 原样带进来 —— 那时应该报「没有模型」,
|
|
125
|
+
// 而不是抛 TypeError,把启动校验变成一句看不懂的初始化失败。
|
|
126
|
+
return cfg[task]?.model ?? cfg.defaults?.model;
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
/**
|
|
128
|
-
* 会执行的任务及其模型值。`enabled: false` 的会话不执行任何一个任务 —— 包括 dream,
|
|
129
|
-
* 因为 `/dream` 命令与 nudge 都被 `config.enabled` 挡住。
|
|
130
|
-
*/
|
|
129
|
+
/** 会执行的任务及其模型值。dream 恒在执行集合内(没有包级开关可以让它不跑)。 */
|
|
131
130
|
export function requiredModels(cfg: MemoryConfig): Array<{ task: ModelTask; value: string | undefined }> {
|
|
132
|
-
if (!cfg.enabled) return [];
|
|
133
131
|
const out: Array<{ task: ModelTask; value: string | undefined }> = [
|
|
134
132
|
{ task: "dream", value: taskModel(cfg, "dream") },
|
|
135
133
|
];
|
package/src/entry-index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface ParsedEntryIndex {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* 索引唯一的按行拆分入口(解析、写入与**注入**必须共用它,否则同一个 `lineNo` 在三处含义不同)。
|
|
21
21
|
*
|
|
22
22
|
* CRLF(以及老 Mac 的单独 CR)必须在这里归一:JS 的 `.` 不匹配 `\r`,且无 `m` 标志的 `$`
|
|
23
23
|
* 也无法在一个 `\r` 之前成立 —— `LINE_RE` 对 CRLF 行会**完全匹配不上**,于是每一行都被计成
|
|
@@ -25,7 +25,7 @@ export interface ParsedEntryIndex {
|
|
|
25
25
|
* 归一之后写回仍只输出 `\n`(`joinLines`),因此对 CRLF 文件的首次写入会把它转成 LF ——
|
|
26
26
|
* 这是有意的,它消除的是「混合行尾」这个更糟的中间态。
|
|
27
27
|
*/
|
|
28
|
-
function splitLines(raw: string): string[] {
|
|
28
|
+
export function splitLines(raw: string): string[] {
|
|
29
29
|
const text = raw.includes("\r") ? raw.replace(/\r\n?/g, "\n") : raw;
|
|
30
30
|
if (text.length === 0) return [];
|
|
31
31
|
const lines = text.split("\n");
|
package/src/inject.ts
CHANGED
|
@@ -2,13 +2,22 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { runHeadlessAgent } from "./agent-runner";
|
|
3
3
|
import type { SessionPersistenceConfig, ThinkLevel } from "./config";
|
|
4
4
|
import type { EntryType } from "./entry-file";
|
|
5
|
+
import { splitLines } from "./entry-index";
|
|
5
6
|
import { MEMORY_INDEX_SECTION } from "./index-source";
|
|
6
7
|
import type { MemoryStore } from "./memory-store";
|
|
7
8
|
import { sanitizeForInjection } from "./sanitize";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* entry 正文的截断标记。与 `INDEX_TRUNCATION_MARKER` **刻意分开**:这里截的是
|
|
12
|
+
* `<relevant_memories>` 里的一条正文,说「memory index」会指错对象。
|
|
13
|
+
*/
|
|
14
|
+
export const ENTRY_TRUNCATION_MARKER = "[truncated: memory entry exceeds injection limit]";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* entry 正文用的「按行 + 按字节」截断:**保留开头**,从尾部削。
|
|
18
|
+
*
|
|
19
|
+
* 索引不进这里 —— 它的窗口取**最新**的一段(`truncateIndexForInjection`),方向正好相反:
|
|
20
|
+
* 一起用会让窗口永远只保留最旧的记忆。
|
|
12
21
|
*/
|
|
13
22
|
export function truncateForInjection(
|
|
14
23
|
content: string,
|
|
@@ -23,15 +32,135 @@ export function truncateForInjection(
|
|
|
23
32
|
truncated = true;
|
|
24
33
|
}
|
|
25
34
|
if (Buffer.byteLength(out, "utf8") > maxBytes) {
|
|
26
|
-
|
|
27
|
-
while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
|
|
28
|
-
out = cut;
|
|
35
|
+
out = cutToBytes(out, maxBytes);
|
|
29
36
|
truncated = true;
|
|
30
37
|
}
|
|
31
|
-
if (truncated) out += `\n
|
|
38
|
+
if (truncated) out += `\n${ENTRY_TRUNCATION_MARKER}`;
|
|
32
39
|
return { ok: !truncated, content: out, truncated };
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
/**
|
|
43
|
+
* 按字符从尾部削到字节上限。
|
|
44
|
+
*
|
|
45
|
+
* 顺手丢掉被切一半的代理对:孤立高代理是**无效 UTF-16**,任何编码器都会把它变成 U+FFFD ——
|
|
46
|
+
* 宁可少一个字符,也不要给模型塞一个替换字符。
|
|
47
|
+
*/
|
|
48
|
+
function cutToBytes(text: string, maxBytes: number): string {
|
|
49
|
+
let cut = text;
|
|
50
|
+
while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
|
|
51
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
52
|
+
return cut.length > 0 && last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 注入截断标记。放在窗口**开头**:索引是纯时间序,被丢掉的永远是**最旧**的行 ——
|
|
57
|
+
* 标记若追加在末尾,读起来像「这条之后的都被切了」,而事实恰好相反。
|
|
58
|
+
*/
|
|
59
|
+
export const INDEX_TRUNCATION_MARKER = "[truncated: memory index exceeds injection limit; older entries omitted]";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 索引文本 → 窗口用的行数组。
|
|
63
|
+
*
|
|
64
|
+
* 行拆分**共用 `entry-index` 的 `splitLines`**(先做行尾归一):`/memory` 的 `Index:` 与
|
|
65
|
+
* `Inject:` 必须活在同一个「行」的定义上,否则 CRLF / 单独 CR 的文件上两行行数会互相矛盾。
|
|
66
|
+
* 额外摘掉**尾部全部空行**:`rebuildIndex` 把它们当噪音,但它们照样白吃窗口行数。
|
|
67
|
+
*/
|
|
68
|
+
function indexWindowLines(content: string): string[] {
|
|
69
|
+
const lines = splitLines(content);
|
|
70
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface IndexWindow {
|
|
75
|
+
/** 窗口内的行,保持文件原顺序(旧 → 新)。 */
|
|
76
|
+
lines: string[];
|
|
77
|
+
/** 窗口的字节数(行间换行算在内;标记与末尾换行不算)。 */
|
|
78
|
+
byteLength: number;
|
|
79
|
+
/** 是否有行被丢弃(含「只保留了最新一行的字节前缀」这种退化)。 */
|
|
80
|
+
truncated: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 从尾部(最新)向头部累积,直到行数或字节数触顶。`truncateIndexForInjection` 与
|
|
84
|
+
* `indexInjectionCapacity` 共用它 —— 两处口径不可能漂移。 */
|
|
85
|
+
function indexWindow(lines: string[], maxLines: number, maxBytes: number): IndexWindow {
|
|
86
|
+
const kept: string[] = [];
|
|
87
|
+
let byteLength = 0;
|
|
88
|
+
let degenerated = false;
|
|
89
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
90
|
+
const line = lines[i];
|
|
91
|
+
const lineBytes = Buffer.byteLength(line, "utf8");
|
|
92
|
+
const cost = kept.length === 0 ? lineBytes : lineBytes + 1;
|
|
93
|
+
if (kept.length >= maxLines || byteLength + cost > maxBytes) break;
|
|
94
|
+
kept.unshift(line);
|
|
95
|
+
byteLength += cost;
|
|
96
|
+
}
|
|
97
|
+
if (kept.length === 0 && lines.length > 0 && maxLines > 0 && maxBytes > 0) {
|
|
98
|
+
// 退化:最新一行自身就超预算 —— 保留它的字节前缀。空 section 比截断的一行更糟:
|
|
99
|
+
// 模型连「这里本来有索引」都看不到。两个非正守卫是内部防御(调用方已先挡掉非正预算)。
|
|
100
|
+
const prefix = cutToBytes(lines[lines.length - 1], maxBytes);
|
|
101
|
+
kept.push(prefix);
|
|
102
|
+
byteLength = Buffer.byteLength(prefix, "utf8");
|
|
103
|
+
// 行数没变(1 → 1),但内容确实被削过:必须算截断,否则 `truncateIndexForInjection`
|
|
104
|
+
// 会以为「没丢行」而返回未截断的原文,标记也不出现。
|
|
105
|
+
degenerated = true;
|
|
106
|
+
}
|
|
107
|
+
return { lines: kept, byteLength, truncated: degenerated || kept.length < lines.length };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 行数组 → 注入文本(行间与末尾都是 LF);空窗口不补换行,只留标记。 */
|
|
111
|
+
function joinWindowLines(lines: string[]): string {
|
|
112
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 索引的注入窗口:保留**最新**的 maxLines 行 / maxBytes 字节。
|
|
117
|
+
*
|
|
118
|
+
* - 窗口内保持文件原顺序(旧 → 新),**不反转**:形如「索引末尾的一段连续片段」;
|
|
119
|
+
* - 超出字节预算时继续丢窗口内**最旧**的一端;
|
|
120
|
+
* - 截断时在开头加 `INDEX_TRUNCATION_MARKER`;
|
|
121
|
+
* - 未超预算时**逐字节返回原文**(空索引也是)—— 小索引的注入值与改动前完全一致,
|
|
122
|
+
* 会话内冻结的值也不会因为这次改动而漂移;唯一的例外是 CRLF / 单独 CR 的文件:行拆分
|
|
123
|
+
* 先做归一,这时返回的是 LF 文本(`\r` 不进 system prompt)。
|
|
124
|
+
* - 预算非正(`memIndexInjectMaxLines/Bytes` 写 0)= **不注入**:返回空值而不是裸标记 ——
|
|
125
|
+
* 标记的含义是「索引被截断了」,与「按配置不注入」对模型的含义完全不同。
|
|
126
|
+
*/
|
|
127
|
+
export function truncateIndexForInjection(
|
|
128
|
+
content: string,
|
|
129
|
+
maxLines: number,
|
|
130
|
+
maxBytes: number,
|
|
131
|
+
): { ok: boolean; content: string; truncated: boolean } {
|
|
132
|
+
if (maxLines <= 0 || maxBytes <= 0) return { ok: true, content: "", truncated: false };
|
|
133
|
+
|
|
134
|
+
const window = indexWindow(indexWindowLines(content), maxLines, maxBytes);
|
|
135
|
+
if (!window.truncated) {
|
|
136
|
+
return { ok: true, content: content.includes("\r") ? joinWindowLines(window.lines) : content, truncated: false };
|
|
137
|
+
}
|
|
138
|
+
return { ok: false, content: `${INDEX_TRUNCATION_MARKER}\n${joinWindowLines(window.lines)}`, truncated: true };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `/memory` 的注入口径。复用 `indexWindow`,所以报出来的数字与实际注入的值不可能不一致。
|
|
143
|
+
*
|
|
144
|
+
* 口径:行数 = 窗口内的索引行(**不含**截断标记那一行);字节数 = 窗口正文的 UTF-8 字节
|
|
145
|
+
* (行间换行与末尾换行算在内,**不含**截断标记)。未截断且文件是 LF 时,它与 `Index:`
|
|
146
|
+
* (写入口径,同样用 `splitLines`)对同一份文件报的字节数相同;真实 section 在截断时还多
|
|
147
|
+
* 出「标记 + 一个换行」。
|
|
148
|
+
*/
|
|
149
|
+
export function indexInjectionCapacity(
|
|
150
|
+
content: string,
|
|
151
|
+
maxLines: number,
|
|
152
|
+
maxBytes: number,
|
|
153
|
+
): { lineCount: number; byteLength: number; truncated: boolean } {
|
|
154
|
+
if (maxLines <= 0 || maxBytes <= 0) return { lineCount: 0, byteLength: 0, truncated: false };
|
|
155
|
+
|
|
156
|
+
const window = indexWindow(indexWindowLines(content), maxLines, maxBytes);
|
|
157
|
+
return {
|
|
158
|
+
lineCount: window.lines.length,
|
|
159
|
+
byteLength: window.lines.length > 0 ? window.byteLength + 1 : 0,
|
|
160
|
+
truncated: window.truncated,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
35
164
|
export function buildInjection(systemPrompt: string, snapshot: string): string {
|
|
36
165
|
if (!snapshot) return systemPrompt;
|
|
37
166
|
return `${systemPrompt}\n\n${snapshot}`;
|
|
@@ -53,7 +182,7 @@ export function buildInjection(systemPrompt: string, snapshot: string): string {
|
|
|
53
182
|
*/
|
|
54
183
|
export async function buildIndexSection(store: MemoryStore, maxLines: number, maxBytes: number): Promise<string> {
|
|
55
184
|
const raw = await store.readIndex();
|
|
56
|
-
const { content } =
|
|
185
|
+
const { content } = truncateIndexForInjection(raw, maxLines, maxBytes);
|
|
57
186
|
return sanitizeForInjection(content);
|
|
58
187
|
}
|
|
59
188
|
|
package/src/memory-tool.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface MemoryToolDeps {
|
|
|
69
69
|
getConfig: () => MemoryToolConfig;
|
|
70
70
|
/**
|
|
71
71
|
* `getStore()` 为 null 时的完整可读原因(由 index.ts 依会话状态拼好),null = 还没 `session_start`。
|
|
72
|
-
*
|
|
72
|
+
* 两种状态:未启动 / 配置错误(模型校验或初始化失败)。
|
|
73
73
|
*/
|
|
74
74
|
getUnavailableMessage: () => string | null;
|
|
75
75
|
searchSessions: (cwd: string, query: string, cfg: { maxSessions: number; maxMatches: number }) => Promise<string>;
|
|
@@ -200,7 +200,7 @@ export function createMemoryTool(deps: MemoryToolDeps, options: MemoryToolOption
|
|
|
200
200
|
async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
|
|
201
201
|
const store = deps.getStore();
|
|
202
202
|
if (!store) {
|
|
203
|
-
//
|
|
203
|
+
// 健康会话之后,同一进程内可能又起了一个配置错误的会话(pi 没有 unregister API),
|
|
204
204
|
// 工具仍在注册表里:此时「no session_start yet」是假话。原因文案由 index.ts 依状态拼好 ——
|
|
205
205
|
// 工具只负责原样抛出,不猜状态。
|
|
206
206
|
throw new Error(deps.getUnavailableMessage() ?? "Memory not initialized (no session_start yet)");
|