@yandy0725/pi-memory 2.0.0 → 2.1.1
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 +52 -55
- package/README.zh.md +52 -55
- package/index.ts +163 -96
- package/package.json +3 -3
- package/src/agent-runner.ts +8 -7
- package/src/config.ts +50 -0
- package/src/dream.ts +3 -5
- package/src/extract.ts +52 -15
- package/src/index-source.ts +57 -11
- package/src/inject.ts +9 -5
- package/src/memory-store.ts +12 -14
- package/src/memory-tool.ts +21 -4
- package/src/model-resolver.ts +1 -1
- package/src/process-lock.ts +1 -1
- package/src/migrate.ts +0 -303
package/README.md
CHANGED
|
@@ -4,13 +4,19 @@ File-system driven persistent memory layer for pi coding agent. Stores project k
|
|
|
4
4
|
|
|
5
5
|
Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `MEMORY.md` index with exactly one line per memory, relevance-based auto-surfacing, per-turn memory extraction, and typed memory categories.
|
|
6
6
|
|
|
7
|
-
> ## ⚠️
|
|
7
|
+
> ## ⚠️ Breaking changes
|
|
8
|
+
>
|
|
9
|
+
> **In 2.1.0:**
|
|
10
|
+
>
|
|
11
|
+
> - **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).
|
|
12
|
+
> - **`/memory on` and `/memory off` are gone.** `enabled` is a `memory.json` switch read once at session start — changing it needs a session restart.
|
|
13
|
+
> - **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).
|
|
14
|
+
>
|
|
15
|
+
> **In 2.0.0:**
|
|
8
16
|
>
|
|
9
17
|
> - The index is now **one line per memory** (1.x had one line per *topic file*, with many `## entries` inside it).
|
|
10
18
|
> - Each memory lives in **its own file** with five frontmatter fields: `name`, `description`, `type`, `created`, `modified` (1.x used `updated`).
|
|
11
19
|
> - The index is injected as a **system-prompt section** (`memory_index`) that is frozen for the whole session, instead of being appended to the system prompt string.
|
|
12
|
-
>
|
|
13
|
-
> The first `session_start` after the upgrade **migrates an existing 1.x directory automatically**: it snapshots the whole directory into `.backups/migrate-<ts>/`, copies the original topic files into `originals/`, splits every `## entry` into its own file, rebuilds the index, and only then writes the `.migrated` marker. If any step fails, the marker is not written, the backup stays, and the next session retries. See [Migration from 1.x](#migration-from-1x).
|
|
14
20
|
|
|
15
21
|
## Features
|
|
16
22
|
|
|
@@ -20,12 +26,12 @@ Aligned with Claude Code's auto memory mechanism: **one memory = one file**, a `
|
|
|
20
26
|
- **`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.
|
|
21
27
|
- **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).
|
|
22
28
|
- **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.
|
|
23
|
-
- **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
|
|
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.
|
|
24
30
|
- **`/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.
|
|
25
31
|
- **Dream nudge** — after N sessions or N hours a notification suggests `/dream`.
|
|
26
|
-
- **`/memory`** — full status (switch, directory, index capacity, entry count, last dream,
|
|
27
|
-
- **Two-level locking** — an in-process logical lock carries the *logical* scope (one primitive call, or a whole dream
|
|
28
|
-
- **Snapshots** — every write leaves a rollback point under `.backups/<ts>-<label>/`, keeping the last `lock.snapshotKeep` (
|
|
32
|
+
- **`/memory`** — full status (switch, directory, index capacity, entry count, last dream, lock state including the holder), plus `unlock`.
|
|
33
|
+
- **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`).
|
|
34
|
+
- **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).
|
|
29
35
|
- **Session search** — `memory search scope=sessions` queries past conversation history.
|
|
30
36
|
- **Readable, clone-safe layout** — memory lives under `~/.pi/memory/git/<host__owner__repo>/` for git repos with an http(s)/ssh/git remote (including scp-style and `git+ssh`/`git+https`), and `~/.pi/memory/local/<absolute-path>/` otherwise — clones and worktrees of the same repo share memory (a fork has its own remote, so it gets its own directory).
|
|
31
37
|
|
|
@@ -51,8 +57,7 @@ Or add to `~/.pi/agent/settings.json`:
|
|
|
51
57
|
SSH-port-on-staging.md
|
|
52
58
|
Test-command.md — one file per memory
|
|
53
59
|
.lock — cross-process write lock (held for milliseconds, never auto-reclaimed)
|
|
54
|
-
.backups/ — rollback points: <ISO-ts>-<label
|
|
55
|
-
.migrated — marker written as the last step of the 1.x → 2.x migration
|
|
60
|
+
.backups/ — rollback points: <ISO-ts>-<label>/ (plus migrate-<ts>/ whole-directory snapshots from earlier 1.x migrations)
|
|
56
61
|
.dream-meta.json — last dream timestamp + session count (drives the nudge)
|
|
57
62
|
sessions/ — persisted headless sessions, only when sessionPersistence is enabled
|
|
58
63
|
```
|
|
@@ -101,7 +106,7 @@ Writes are **surgical**: only the target line changes, hand-written headings, gr
|
|
|
101
106
|
|
|
102
107
|
### Capacity: 200 index lines ≈ 199 memories
|
|
103
108
|
|
|
104
|
-
The index holds at most `memIndexMaxLines` (200) non-empty lines and `memIndexMaxBytes` (25600) bytes. Those 200 lines are **index lines, not memories**: `rebuildIndex`
|
|
109
|
+
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).
|
|
105
110
|
|
|
106
111
|
This is why `/dream` is no longer optional housekeeping — it is **capacity management**. Run it (or accept the nudge) before you approach 199 memories.
|
|
107
112
|
|
|
@@ -118,7 +123,7 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
118
123
|
"memIndexInjectMaxLines": 200,
|
|
119
124
|
"memIndexInjectMaxBytes": 25600,
|
|
120
125
|
"lock": { "timeoutMs": 5000, "snapshotKeep": 5 },
|
|
121
|
-
"defaults": { "sessionPersistence": { "enabled": false } },
|
|
126
|
+
"defaults": { "model": "provider/model-id", "sessionPersistence": { "enabled": false } },
|
|
122
127
|
"dream": { "nudgeAfterSessions": 5, "nudgeAfterHours": 24, "thinkLevel": "high" },
|
|
123
128
|
"sessionSearch": { "maxSessions": 10, "maxMatches": 5 },
|
|
124
129
|
"autoSurfacing": {
|
|
@@ -138,35 +143,37 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
138
143
|
}
|
|
139
144
|
```
|
|
140
145
|
|
|
146
|
+
> Every `model` value must resolve in your registry — there is no default. A missing or unresolvable model makes `session_start` report a config error and initialise nothing. See [Model configuration](#model-configuration).
|
|
147
|
+
|
|
141
148
|
| Key | Default | Description |
|
|
142
149
|
|-----|---------|-------------|
|
|
143
|
-
| `enabled` | `true` | Toggle the entire memory system on/off |
|
|
150
|
+
| `enabled` | `true` | Toggle the entire memory system on/off. Read once at session start — changing it requires restarting the session |
|
|
144
151
|
| `memoryDir` | `~/.pi/memory` | Root directory for all memory data |
|
|
145
152
|
| `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) |
|
|
146
153
|
| `memIndexMaxBytes` | `25600` | Write capacity: max bytes of `MEMORY.md` |
|
|
147
154
|
| `memIndexInjectMaxLines` | `200` | Injection budget: max lines of the index put into the `memory_index` section. Same scale as the write capacity on purpose — a smaller budget would hide memories that were written successfully |
|
|
148
155
|
| `memIndexInjectMaxBytes` | `25600` | Injection budget: max bytes of the index section (truncated with a `[truncated: …]` marker) |
|
|
149
|
-
| `lock.timeoutMs` | `5000` | How long a write waits for the logical lock (single primitive) or the cross-process `.lock`.
|
|
150
|
-
| `lock.snapshotKeep` | `5` | Rollback points kept in `.backups/` (directories named `migrate-*` are never pruned) |
|
|
151
|
-
| `defaults.model` |
|
|
156
|
+
| `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 |
|
|
157
|
+
| `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) |
|
|
158
|
+
| `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 |
|
|
152
159
|
| `defaults.sessionPersistence.enabled` | `false` | Shared fallback: headless sub-sessions (extract / dream / side query) stay in memory by default |
|
|
153
160
|
| `defaults.sessionPersistence.sessionDir` | `<project memory dir>/sessions/` | Custom directory for persisted headless sessions |
|
|
154
161
|
| `dream.nudgeAfterSessions` | `5` | Sessions since the last dream before the nudge is shown |
|
|
155
162
|
| `dream.nudgeAfterHours` | `24` | Hours since the last dream before the nudge is shown |
|
|
156
|
-
| `dream.model` | — | Model for dream consolidation (`"provider/id"`). Falls back to `defaults.model`
|
|
163
|
+
| `dream.model` | — | Model for dream consolidation (`"provider/id"`). Falls back to `defaults.model`; required unless `defaults.model` is set (must be resolvable, no parent-model fallback) |
|
|
157
164
|
| `dream.thinkLevel` | `"high"` | Thinking effort for the dream agent: `off` / `minimal` / `low` / `medium` / `high` / `xhigh` |
|
|
158
165
|
| `dream.sessionPersistence.*` | inherits `defaults` | Persist dream sessions to disk (debug/audit) |
|
|
159
166
|
| `sessionSearch.maxSessions` | `10` | Max sessions to scan for `search scope=sessions` |
|
|
160
167
|
| `sessionSearch.maxMatches` | `5` | Max matches to return from history search |
|
|
161
168
|
| `autoSurfacing.enabled` | `true` | ⭐ Enable per-turn entry auto-injection |
|
|
162
|
-
| `autoSurfacing.model` | — | ⭐ Model for the relevance side query. Falls back to `defaults.model`
|
|
169
|
+
| `autoSurfacing.model` | — | ⭐ Model for the relevance side query. Falls back to `defaults.model`; required unless `defaults.model` is set (must be resolvable, no parent-model fallback) |
|
|
163
170
|
| `autoSurfacing.thinkLevel` | `"off"` | ⭐ Thinking effort for the side query (`"off"` keeps it cheap) |
|
|
164
171
|
| `autoSurfacing.maxFiles` | `3` | ⭐ Max entries to inject per turn |
|
|
165
172
|
| `autoSurfacing.maxEntryBytes` | `3072` | ⭐ Max bytes of a single injected entry body (truncated). Replaces 1.x's `maxTopicBytes`, which is ignored |
|
|
166
173
|
| `autoSurfacing.maxInjectionBytes` | `10240` | ⭐ Max total bytes of injected content per turn |
|
|
167
174
|
| `autoSurfacing.sessionPersistence.*` | inherits `defaults` | Persist side-query sessions to disk |
|
|
168
175
|
| `extractMemories.enabled` | `true` | ⭐ Enable per-turn memory extraction |
|
|
169
|
-
| `extractMemories.model` | — | ⭐ Model for the extraction agent. Falls back to `defaults.model`
|
|
176
|
+
| `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) |
|
|
170
177
|
| `extractMemories.thinkLevel` | `"high"` | ⭐ Thinking effort for extraction |
|
|
171
178
|
| `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) |
|
|
172
179
|
| `extractMemories.maxToolResultChars` | `500` | ⭐ Per-message cap for a rendered `tool_result` |
|
|
@@ -175,13 +182,30 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
175
182
|
|
|
176
183
|
Persisted headless sessions default to `<project memory dir>/sessions/` — inside the project's memory directory, not inside your working copy.
|
|
177
184
|
|
|
185
|
+
## Model configuration
|
|
186
|
+
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
| Task | Required when |
|
|
190
|
+
|------|---------------|
|
|
191
|
+
| `dream` | the memory system is enabled (`enabled: true`) — always required |
|
|
192
|
+
| `extractMemories` | `extractMemories.enabled` is true |
|
|
193
|
+
| `autoSurfacing` | `autoSurfacing.enabled` is true |
|
|
194
|
+
|
|
195
|
+
With `enabled: false` nothing runs — not even `/dream` or the nudge — so no model is required. 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:
|
|
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
|
+
Fix `memory.json` and restart the session — the config is read once at session start. In headless/print sessions a config error is silent (no notification is shown), so check `/memory` in an interactive session.
|
|
201
|
+
|
|
178
202
|
## How it works
|
|
179
203
|
|
|
180
204
|
### Session lifecycle
|
|
181
205
|
|
|
182
206
|
| Event | What pi-memory does |
|
|
183
207
|
|---|---|
|
|
184
|
-
| `session_start` | Load config →
|
|
208
|
+
| `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 |
|
|
185
209
|
| `before_agent_start` | Write the frozen value into `sections["memory_index"]` (**unconditionally, every turn**), then auto-surfacing (main session, not a subagent) |
|
|
186
210
|
| `agent_end` | Fire the async extractor; notify `Extracted N memories.` when it wrote something, or `Extract failed: …` once per session |
|
|
187
211
|
| `session_compact` | Clear the injected-file set **and re-read the index from disk** — the only in-session refresh point |
|
|
@@ -214,13 +238,13 @@ The extractor receives a structured rendering instead of a lossy two-message sum
|
|
|
214
238
|
[4] user: <correction>
|
|
215
239
|
```
|
|
216
240
|
|
|
217
|
-
It runs with the five main-agent actions (never `rename` / `rebuild_index`), no file tools, `maxTurns: 5`, and a 120s timeout. If the logical lock is busy (a dream
|
|
241
|
+
It runs with the five main-agent actions (never `rename` / `rebuild_index`), no file tools, `maxTurns: 5`, and a 120s timeout. If the logical lock is busy (a dream owns the round) it **skips the turn** rather than queueing — the next `agent_end` will come.
|
|
218
242
|
|
|
219
243
|
### Locking
|
|
220
244
|
|
|
221
245
|
| Level | Scope | Behaviour |
|
|
222
246
|
|---|---|---|
|
|
223
|
-
| In-process logical lock (per memory dir) | one primitive call; or a whole dream
|
|
247
|
+
| In-process logical lock (per memory dir) | one primitive call; or a whole dream round | Waits up to `lock.timeoutMs`, then throws a readable error naming the directory. `extract` uses the non-waiting form and skips the turn |
|
|
224
248
|
| Cross-process `.lock` | milliseconds, around the physical write | Acquired with `link` (atomic), **never reclaimed automatically**: no TTL, no heartbeat, no takeover |
|
|
225
249
|
|
|
226
250
|
A crash inside a write can therefore leave a `.lock` behind, and nothing will ever delete it for you — that is the deliberate price of a hard mutual-exclusion guarantee. The error names the pid, op and start time; `/memory unlock` is the one sanctioned way to clear it.
|
|
@@ -270,8 +294,6 @@ One line per memory: `- name (type, modified …) — description [file]`.
|
|
|
270
294
|
|
|
271
295
|
```
|
|
272
296
|
/memory — status
|
|
273
|
-
/memory on — enable
|
|
274
|
-
/memory off — disable
|
|
275
297
|
/memory unlock — remove a left-behind .lock (asks for confirmation first)
|
|
276
298
|
```
|
|
277
299
|
|
|
@@ -283,14 +305,13 @@ Dir: /home/you/.pi/memory/git/github.com__owner__repo
|
|
|
283
305
|
Index: 38/200 lines, 2841/25600 bytes, 1 unrecognized lines
|
|
284
306
|
Entries: 37
|
|
285
307
|
Last dream: 2026-10-01T22:10:04.882Z
|
|
286
|
-
Migration: migrated at 2026-09-30T09:12:44.120Z (18 entries from 4 files)
|
|
287
308
|
Lock: free
|
|
288
309
|
```
|
|
289
310
|
|
|
290
311
|
- `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.
|
|
291
|
-
- `
|
|
292
|
-
- `
|
|
293
|
-
-
|
|
312
|
+
- `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.
|
|
313
|
+
- 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.
|
|
314
|
+
- 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.
|
|
294
315
|
|
|
295
316
|
### `/dream`
|
|
296
317
|
|
|
@@ -303,31 +324,9 @@ Asks for confirmation, snapshots the whole directory, then runs a headless agent
|
|
|
303
324
|
|
|
304
325
|
It cannot touch files directly: it only has the seven `memory` actions. A summary notification arrives when it finishes, `Dream failed: …` when it does not. The model is configurable via `dream.model`.
|
|
305
326
|
|
|
306
|
-
##
|
|
307
|
-
|
|
308
|
-
Automatic, on the first `session_start` after the upgrade:
|
|
309
|
-
|
|
310
|
-
1. take the logical lock for the whole round (30s);
|
|
311
|
-
2. snapshot the directory into `.backups/migrate-<ts>/` and copy the original topic files into `.backups/migrate-<ts>/originals/`;
|
|
312
|
-
3. for every legacy file (one with ≥ 2 `## ` sections, or with an `updated` frontmatter field): split each `## entry` into its own file, carrying over `type` and turning `updated` into `created` / `modified`; names that collide across files get a ` (2)`, ` (3)` suffix;
|
|
313
|
-
4. `rebuildIndex()`;
|
|
314
|
-
5. delete the original topic files (they stay in `originals/`);
|
|
315
|
-
6. write `.migrated`;
|
|
316
|
-
7. notify `Migrated N memories from M topic files. Backup at <path>`.
|
|
317
|
-
|
|
318
|
-
A file whose frontmatter already has a `modified` field is **never** treated as a legacy topic file, even when its body contains several `## ` headings — that guard is what keeps a normal v2 entry from being split apart.
|
|
327
|
+
## 1.x data
|
|
319
328
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
**Manual rollback:**
|
|
323
|
-
|
|
324
|
-
```bash
|
|
325
|
-
cd ~/.pi/memory/git/github.com__owner__repo # the directory /memory prints
|
|
326
|
-
ls .backups/migrate-*/originals/ # pick the run you want to undo
|
|
327
|
-
cp .backups/migrate-<ts>/originals/*.md . # restore the 1.x topic files
|
|
328
|
-
rm .migrated # let the migration run again
|
|
329
|
-
rm <generated entry files> # the ones listed by /memory (Entries) and not in originals/
|
|
330
|
-
```
|
|
329
|
+
Automatic 1.x → 2.0 migration has been removed. Legacy topic files (frontmatter with `updated` and without `created`/`modified`, so they fail the five-field v2 frontmatter check) stay on disk untouched and are **invisible to the memory system** — `parseEntryFile` requires the five v2 frontmatter fields, so such files never appear in the index, injections, `list`/`read`/`search`, and `/dream` cannot see them either (dream only has the `memory` tool). To recover their content by hand, split each `## ` section into its own file with v2 frontmatter (`name`, `description`, `type`, `created`, `modified`). Directories created by an earlier migration are still never pruned: `.backups/migrate-*/originals/` holds the pre-2.0 topic files, and `.backups/migrate-*/MEMORY.md` the index as it was then.
|
|
331
330
|
|
|
332
331
|
## File layout
|
|
333
332
|
|
|
@@ -338,7 +337,7 @@ rm <generated entry files> # the ones listed by /memory
|
|
|
338
337
|
MEMORY.md — the index: one line per memory
|
|
339
338
|
SSH-port-on-staging.md
|
|
340
339
|
Test-command.md — one file per memory
|
|
341
|
-
.lock .backups/ .
|
|
340
|
+
.lock .backups/ .dream-meta.json
|
|
342
341
|
local/
|
|
343
342
|
home__yandy__workspace__scratch/ ← non-git directory /home/yandy/workspace/scratch
|
|
344
343
|
```
|
|
@@ -355,7 +354,7 @@ Directory names are derived as follows:
|
|
|
355
354
|
|
|
356
355
|
The mapping is not injective: underscores are kept as-is, so `/home/a__b` and `/home/a/b` both map to `home__a__b` (and share one memory directory). Changing or renaming a remote, adding a remote that sorts before the one currently in use, or moving a local directory changes the memory directory, orphaning the old one.
|
|
357
356
|
|
|
358
|
-
**Older legacy layout:** versions before 1.x stored memory under `~/.pi/memory/<12-char-sha256>/`; those directories are no longer read or written. To migrate a project manually, compute the old hash with `printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12` (use `$PWD` outside a git repo), then `mv` that directory to the new location (run `/memory` inside the project to see the new path) and
|
|
357
|
+
**Older legacy layout:** versions before 1.x stored memory under `~/.pi/memory/<12-char-sha256>/`; those directories are no longer read or written. To migrate a project manually, compute the old hash with `printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12` (use `$PWD` outside a git repo), then `mv` that directory to the new location (run `/memory` inside the project to see the new path) and split its topic files by hand (see [1.x data](#1x-data)).
|
|
359
358
|
|
|
360
359
|
## Notifications
|
|
361
360
|
|
|
@@ -365,8 +364,6 @@ The mapping is not injective: underscores are kept as-is, so `/home/a__b` and `/
|
|
|
365
364
|
| Auto-surfacing injected entries | `Recalled: <N> entries` |
|
|
366
365
|
| The extractor wrote memories | `Extracted <N> memory.` / `Extracted <N> memories.` |
|
|
367
366
|
| The extractor failed | `Extract failed: <message>` — at most once per session |
|
|
368
|
-
| The 1.x migration ran | `Migrated <N> memories from <M> topic files. Backup at <path>` |
|
|
369
|
-
| The migration failed | `Memory migration failed: <message>` |
|
|
370
367
|
| `/dream` finished / failed | the headless agent's summary / `Dream failed: <message>` |
|
|
371
368
|
|
|
372
369
|
Headless sessions (`hasUI === false`) never notify.
|
package/README.zh.md
CHANGED
|
@@ -4,13 +4,19 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
|
|
|
4
4
|
|
|
5
5
|
对齐 Claude Code 的自动记忆机制:**一条记忆 = 一个文件**、`MEMORY.md` 索引一行一条记忆、按相关性自动浮现、每轮自动提取、记忆分类。
|
|
6
6
|
|
|
7
|
-
> ## ⚠️
|
|
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 已包含:**
|
|
8
16
|
>
|
|
9
17
|
> - 索引现在**一行一条记忆**(1.x 是一行一个 topic 文件,文件里塞很多 `## entry`)。
|
|
10
18
|
> - 每条记忆**独占一个文件**,frontmatter 五个字段:`name`、`description`、`type`、`created`、`modified`(1.x 用的是 `updated`)。
|
|
11
19
|
> - 索引改为以 **system prompt section**(`memory_index`)注入,并在整个会话内冻结,而不是拼到 system prompt 字符串末尾。
|
|
12
|
-
>
|
|
13
|
-
> 升级后第一次 `session_start` 会**自动迁移**已有的 1.x 目录:先把整个目录快照到 `.backups/migrate-<ts>/`,把原 topic 文件复制到 `originals/`,再把每个 `## entry` 拆成独立文件、重建索引,**最后一步**才写 `.migrated` 标记。任一步失败就不写标记、保留备份,下次会话重试。详见[从 1.x 迁移](#从-1x-迁移)。
|
|
14
20
|
|
|
15
21
|
## 功能
|
|
16
22
|
|
|
@@ -20,12 +26,12 @@ pi coding agent 的文件系统持久记忆层。把项目知识(事实、偏
|
|
|
20
26
|
- **`memory_index` section,会话内冻结** ⭐ —— 索引写进 `event.systemPromptOptions.sections["memory_index"]`,其值在**整个会话内不再变化**,只有 compaction 会从磁盘重读。pi 对 sections 做 diff,值没变就一条消息都不追加,于是 system prompt 逐轮逐字节相同,provider 的 prefix cache 一直命中。`resume` / `fork` / `reload` 用 transcript 里的**录制值**重放,而不是读磁盘,因此恢复会话不会改写它的头部。
|
|
21
27
|
- **注入净化** —— 所有注入内容(索引行、浮现的 entry 正文与 name)都会剥离不可见/bidi 字符并转义 `<` `>`,因此记忆无法伪造 `</relevant_memories>`、`<system>`、`<project_instructions>`、`<active_agent …>` 或 `<memory_index>`。净化**只发生在注入时**:磁盘上的文件永远不会被改写(保持可读、可手工编辑)。
|
|
22
28
|
- **自动浮现(auto-surfacing)** ⭐ —— 每个用户回合由一次轻量侧查询挑出至多 `maxFiles` 条 **entry**(只看 `description`),把正文注入 `<relevant_memories>`。同一会话内按文件名去重;清单来自进程内的 `mtime` 缓存,每回合只付一次 `readdir` + 每文件一次 `stat`。子 agent 中不启用。
|
|
23
|
-
- **自动提取(extract memories)** ⭐ —— 每轮结束后一个异步 headless agent 拿到的是**整轮对话的结构化渲染**(user 消息全文、assistant 文本与 tool_call、tool_result 及其错误标记),而不是两条消息。它经同一套 `memory` 原语写入,并且**从不排队等锁**:dream
|
|
29
|
+
- **自动提取(extract memories)** ⭐ —— 每轮结束后一个异步 headless agent 拿到的是**整轮对话的结构化渲染**(user 消息全文、assistant 文本与 tool_call、tool_result 及其错误标记),而不是两条消息。它经同一套 `memory` 原语写入,并且**从不排队等锁**:dream 正在整轮持锁时,本回合直接跳过。
|
|
24
30
|
- **`/dream`** —— headless 整理 agent(Orient → Gather Signal → Consolidate → Prune & Index),合并重复、消解矛盾、改名、重建索引。它**没有裸文件权限**:只有七个 `memory` action,整轮持有逻辑锁,进入时先对整个目录拍一次快照。
|
|
25
31
|
- **Dream 提醒** —— 距上次 dream 超过 N 个会话或 N 小时后提示 `/dream`。
|
|
26
|
-
- **`/memory`** —— 完整状态(开关、目录、索引容量、entry 数、上次 dream
|
|
27
|
-
- **两级锁** —— 进程内逻辑锁承担**逻辑作用域**(单次原语,或 dream
|
|
28
|
-
- **快照** —— 每次写入都在 `.backups/<ts>-<label>/` 留下回滚点,保留最近 `lock.snapshotKeep`
|
|
32
|
+
- **`/memory`** —— 完整状态(开关、目录、索引容量、entry 数、上次 dream、锁状态含持有者),以及 `unlock`。
|
|
33
|
+
- **两级锁** —— 进程内逻辑锁承担**逻辑作用域**(单次原语,或 dream 的整轮);跨进程 `.lock` **只持毫秒**且**永不自动回收**。没有 TTL、没有心跳、没有接管,所以互斥是硬保证;代价是崩溃遗留的锁必须**人工**清除(`/memory unlock`)。
|
|
34
|
+
- **快照** —— 每次写入都在 `.backups/<ts>-<label>/` 留下回滚点,保留最近 `lock.snapshotKeep` 份(`migrate-` 开头的目录是旧版迁移留下的整目录快照,其 `originals/` 子目录里才是 2.0 之前的 topic 原文,永不裁剪)。`/dream` 是例外:它**进入时只对整个目录拍一次**快照,该轮内部的原语会跳过逐文件快照(一轮只留一个回滚点)。
|
|
29
35
|
- **会话检索** —— `memory search scope=sessions` 查历史会话。
|
|
30
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,因此有独立目录)。
|
|
31
37
|
|
|
@@ -51,8 +57,7 @@ pi install npm:@yandy0725/pi-memory
|
|
|
51
57
|
SSH-port-on-staging.md
|
|
52
58
|
Test-command.md — 一条记忆一个文件
|
|
53
59
|
.lock — 跨进程写锁(只持毫秒,永不自动回收)
|
|
54
|
-
.backups/ — 回滚点:<ISO-ts>-<label
|
|
55
|
-
.migrated — 1.x → 2.x 迁移的完成标记(最后一步才写)
|
|
60
|
+
.backups/ — 回滚点:<ISO-ts>-<label>/(以及旧版 1.x 迁移留下的 migrate-<ts>/ 整目录快照)
|
|
56
61
|
.dream-meta.json — 上次 dream 的时间与会话数(提醒逻辑用它)
|
|
57
62
|
sessions/ — headless 会话落盘目录,仅在开启 sessionPersistence 时使用
|
|
58
63
|
```
|
|
@@ -101,7 +106,7 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
101
106
|
|
|
102
107
|
### 容量:200 行索引 ≈ 199 条记忆
|
|
103
108
|
|
|
104
|
-
索引上限是 `memIndexMaxLines`(200)个非空行与 `memIndexMaxBytes`(25600)字节。这 200 行是**索引行,不是记忆条数**:`rebuildIndex`
|
|
109
|
+
索引上限是 `memIndexMaxLines`(200)个非空行与 `memIndexMaxBytes`(25600)字节。这 200 行是**索引行,不是记忆条数**:`rebuildIndex` 至少保证一行头部(已有手写头部时原样保留 —— 首个条目之前的末尾空行会被去掉;否则写 `# Memory Index`),手写的标题、分组、注释同样占额度。因此重建后的索引最多约 **199 条记忆**(每个项目目录;若保留手写标题则更少)。超限时写入**不会失败**:写入照样成功,工具把一条可操作的警告回给模型,让它去合并或删除条目(超出上限的部分下次加载时不可见)。
|
|
105
110
|
|
|
106
111
|
这也是 `/dream` 不再是「可选的整理」而是**容量管理必需**的原因。在接近 199 条之前跑一次(或者接受提醒)。
|
|
107
112
|
|
|
@@ -118,7 +123,7 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
118
123
|
"memIndexInjectMaxLines": 200,
|
|
119
124
|
"memIndexInjectMaxBytes": 25600,
|
|
120
125
|
"lock": { "timeoutMs": 5000, "snapshotKeep": 5 },
|
|
121
|
-
"defaults": { "sessionPersistence": { "enabled": false } },
|
|
126
|
+
"defaults": { "model": "provider/model-id", "sessionPersistence": { "enabled": false } },
|
|
122
127
|
"dream": { "nudgeAfterSessions": 5, "nudgeAfterHours": 24, "thinkLevel": "high" },
|
|
123
128
|
"sessionSearch": { "maxSessions": 10, "maxMatches": 5 },
|
|
124
129
|
"autoSurfacing": {
|
|
@@ -138,35 +143,37 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
138
143
|
}
|
|
139
144
|
```
|
|
140
145
|
|
|
146
|
+
> 每个 `model` 值都必须能在你的 registry 里解析 —— 没有默认值。缺失或解析不出的模型会让 `session_start` 报配置错误并且什么都不初始化。详见[模型配置](#模型配置)。
|
|
147
|
+
|
|
141
148
|
| 键 | 默认值 | 说明 |
|
|
142
149
|
|-----|---------|------|
|
|
143
|
-
| `enabled` | `true` |
|
|
150
|
+
| `enabled` | `true` | 整个记忆系统的开关。**启动时读一次**,改动需重启会话 |
|
|
144
151
|
| `memoryDir` | `~/.pi/memory` | 所有记忆数据的根目录 |
|
|
145
152
|
| `memIndexMaxLines` | `200` | 写入口径:`MEMORY.md` 的最大非空行数(`# Memory Index` 头行与手写标题同样占额度,所以并不等于记忆条数) |
|
|
146
153
|
| `memIndexMaxBytes` | `25600` | 写入口径:`MEMORY.md` 的最大字节数 |
|
|
147
154
|
| `memIndexInjectMaxLines` | `200` | 注入口径:放进 `memory_index` section 的最大行数。**刻意与写入口径同量级** —— 预算更小会让「已经写成功」的记忆看不见 |
|
|
148
155
|
| `memIndexInjectMaxBytes` | `25600` | 注入口径:section 的最大字节数(超出则截断并带 `[truncated: …]` 标记) |
|
|
149
|
-
| `lock.timeoutMs` | `5000` | 单次原语等逻辑锁 / 等跨进程 `.lock`
|
|
150
|
-
| `lock.snapshotKeep` | `5` | `.backups/` 保留的回滚点数量(`migrate-`
|
|
151
|
-
| `defaults.model` |
|
|
156
|
+
| `lock.timeoutMs` | `5000` | 单次原语等逻辑锁 / 等跨进程 `.lock` 的上限。同时也是 `session_shutdown` 等在途写入的上限 |
|
|
157
|
+
| `lock.snapshotKeep` | `5` | `.backups/` 保留的回滚点数量(`migrate-` 前缀的目录永不裁剪 —— 它们是旧版迁移留下的整目录快照,`originals/` 子目录里装着 2.0 之前的 topic 原文) |
|
|
158
|
+
| `defaults.model` | —(必需) | 三个子任务的共享模型。**没有默认值**:会执行的任务必须能解析出模型,否则启动失败(见[模型配置](#模型配置))。per-task 覆盖它 |
|
|
152
159
|
| `defaults.sessionPersistence.enabled` | `false` | 共享回退:headless 子会话(extract / dream / 侧查询)默认只在内存里跑 |
|
|
153
160
|
| `defaults.sessionPersistence.sessionDir` | `<项目记忆目录>/sessions/` | headless 会话的自定义落盘目录 |
|
|
154
161
|
| `dream.nudgeAfterSessions` | `5` | 距上次 dream 多少个会话后开始提醒 |
|
|
155
162
|
| `dream.nudgeAfterHours` | `24` | 距上次 dream 多少小时后开始提醒 |
|
|
156
|
-
| `dream.model` | — | dream 用的模型(`"provider/id"`)。回退 `defaults.model`
|
|
163
|
+
| `dream.model` | — | dream 用的模型(`"provider/id"`)。回退 `defaults.model`;没有 `defaults.model` 时必填(必须可解析,不回退父会话模型) |
|
|
157
164
|
| `dream.thinkLevel` | `"high"` | dream 的思考强度:`off` / `minimal` / `low` / `medium` / `high` / `xhigh` |
|
|
158
165
|
| `dream.sessionPersistence.*` | 继承 `defaults` | 把 dream 会话落盘(调试/审计用) |
|
|
159
166
|
| `sessionSearch.maxSessions` | `10` | `search scope=sessions` 扫描的最大会话数 |
|
|
160
167
|
| `sessionSearch.maxMatches` | `5` | 历史检索返回的最大命中数 |
|
|
161
168
|
| `autoSurfacing.enabled` | `true` | ⭐ 开启每回合的 entry 自动注入 |
|
|
162
|
-
| `autoSurfacing.model` | — | ⭐ 相关性侧查询用的模型。回退 `defaults.model`
|
|
169
|
+
| `autoSurfacing.model` | — | ⭐ 相关性侧查询用的模型。回退 `defaults.model`;没有 `defaults.model` 时必填(必须可解析,不回退父会话模型) |
|
|
163
170
|
| `autoSurfacing.thinkLevel` | `"off"` | ⭐ 侧查询的思考强度(`"off"` 最省) |
|
|
164
171
|
| `autoSurfacing.maxFiles` | `3` | ⭐ 每回合最多注入几条 entry |
|
|
165
172
|
| `autoSurfacing.maxEntryBytes` | `3072` | ⭐ 单条 entry 正文的注入字节上限(超出截断)。取代 1.x 的 `maxTopicBytes`(旧键已失效) |
|
|
166
173
|
| `autoSurfacing.maxInjectionBytes` | `10240` | ⭐ 每回合注入内容的总字节上限 |
|
|
167
174
|
| `autoSurfacing.sessionPersistence.*` | 继承 `defaults` | 把侧查询会话落盘 |
|
|
168
175
|
| `extractMemories.enabled` | `true` | ⭐ 开启每轮自动提取 |
|
|
169
|
-
| `extractMemories.model` | — | ⭐ 提取 agent 用的模型。回退 `defaults.model`
|
|
176
|
+
| `extractMemories.model` | — | ⭐ 提取 agent 用的模型。回退 `defaults.model`;没有 `defaults.model` 时必填(必须可解析,不回退父会话模型) |
|
|
170
177
|
| `extractMemories.thinkLevel` | `"high"` | ⭐ 提取的思考强度 |
|
|
171
178
|
| `extractMemories.maxContextTokens` | `2000` | ⭐ 渲染后对话的预算(`× 4` 个字符;超出时先裁中段、首尾优先保留,user 消息最后才动) |
|
|
172
179
|
| `extractMemories.maxToolResultChars` | `500` | ⭐ 单条 `tool_result` 渲染的字符上限 |
|
|
@@ -175,13 +182,30 @@ staging 的 SSH 用 2222 端口,密钥在 ~/.ssh/staging。
|
|
|
175
182
|
|
|
176
183
|
headless 会话默认落在 `<项目记忆目录>/sessions/` —— 在项目记忆目录里,不在你的工作副本里。
|
|
177
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` 确认。
|
|
201
|
+
|
|
178
202
|
## 工作原理
|
|
179
203
|
|
|
180
204
|
### 会话生命周期
|
|
181
205
|
|
|
182
206
|
| 事件 | pi-memory 做什么 |
|
|
183
207
|
|---|---|
|
|
184
|
-
| `session_start` | 加载配置 → 解析记忆目录 →
|
|
208
|
+
| `session_start` | 加载配置 → 校验必需模型(失败即什么都不初始化)→ 解析记忆目录 → **确定索引值并冻结**(`startup`/`new` 读磁盘;`resume`/`fork`/`reload` 重放 transcript 取录制值)→ 注册 `memory` 工具(仅首次,5 个 action)→ 重建清单缓存 → dream 提醒检查 |
|
|
185
209
|
| `before_agent_start` | 把冻结值写进 `sections["memory_index"]`(**每一轮、无条件**),然后做 auto-surfacing(主会话且非子 agent) |
|
|
186
210
|
| `agent_end` | 触发异步 extract;写入成功通知 `Extracted N memories.`,失败通知 `Extract failed: …`(每会话一次) |
|
|
187
211
|
| `session_compact` | 清空已注入集合,**并从磁盘重读索引** —— 会话内唯一的刷新点 |
|
|
@@ -214,13 +238,13 @@ extract 拿到的是结构化渲染,而不是有损的两条消息摘要:
|
|
|
214
238
|
[4] user: <纠正>
|
|
215
239
|
```
|
|
216
240
|
|
|
217
|
-
它只有主 agent 的五个 action(永远拿不到 `rename` / `rebuild_index`)、没有文件工具、`maxTurns: 5`、超时 120s。逻辑锁被占用时(dream
|
|
241
|
+
它只有主 agent 的五个 action(永远拿不到 `rename` / `rebuild_index`)、没有文件工具、`maxTurns: 5`、超时 120s。逻辑锁被占用时(dream 正在整轮持有)它**跳过本回合**而不是排队 —— 下一次 `agent_end` 还会来。
|
|
218
242
|
|
|
219
243
|
### 锁
|
|
220
244
|
|
|
221
245
|
| 层级 | 作用域 | 行为 |
|
|
222
246
|
|---|---|---|
|
|
223
|
-
| 进程内逻辑锁(按记忆目录分键) | 单次原语;或 dream
|
|
247
|
+
| 进程内逻辑锁(按记忆目录分键) | 单次原语;或 dream 的整轮 | 最多等 `lock.timeoutMs`,超时抛一条写明目录的可读错误。`extract` 用不等待的形态,直接跳过本回合 |
|
|
224
248
|
| 跨进程 `.lock` | 毫秒级,只包住物理写入 | 用 `link` 原子获取,**永不自动回收**:没有 TTL、没有心跳、没有接管 |
|
|
225
249
|
|
|
226
250
|
因此写入中途崩溃可能留下一个 `.lock`,而且**没有任何进程会替你删掉它** —— 这是「互斥是硬保证」的刻意代价。错误文案会写明 pid、op、开始时间与路径;`/memory unlock` 是唯一被认可的清除方式。
|
|
@@ -270,8 +294,6 @@ memory(action: "add" | "replace" | "remove" | "list" | "search",
|
|
|
270
294
|
|
|
271
295
|
```
|
|
272
296
|
/memory — 查看状态
|
|
273
|
-
/memory on — 开启
|
|
274
|
-
/memory off — 关闭
|
|
275
297
|
/memory unlock — 清除遗留的 .lock(会先要求确认)
|
|
276
298
|
```
|
|
277
299
|
|
|
@@ -283,14 +305,13 @@ Dir: /home/you/.pi/memory/git/github.com__owner__repo
|
|
|
283
305
|
Index: 38/200 lines, 2841/25600 bytes, 1 unrecognized lines
|
|
284
306
|
Entries: 37
|
|
285
307
|
Last dream: 2026-10-01T22:10:04.882Z
|
|
286
|
-
Migration: migrated at 2026-09-30T09:12:44.120Z (18 entries from 4 files)
|
|
287
308
|
Lock: free
|
|
288
309
|
```
|
|
289
310
|
|
|
290
311
|
- `Index` 用**写入**口径(`memIndexMax*`),并报告索引里有多少非空行解析不出(`# Memory Index` 头行与手写标题会计入)。CRLF(以及单独的 CR)行尾在解析前就被归一为 LF,下一次写入也一律输出 LF,因此被 Windows 编辑器改过行尾的 `MEMORY.md` **不会**推高这个计数。
|
|
291
|
-
- `
|
|
292
|
-
- `
|
|
293
|
-
-
|
|
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 通知出现。
|
|
294
315
|
|
|
295
316
|
### `/dream`
|
|
296
317
|
|
|
@@ -303,31 +324,9 @@ Lock: free
|
|
|
303
324
|
|
|
304
325
|
它碰不到文件:只有七个 `memory` action。完成时通知摘要,失败时通知 `Dream failed: …`。模型可用 `dream.model` 配置。
|
|
305
326
|
|
|
306
|
-
##
|
|
307
|
-
|
|
308
|
-
升级后第一次 `session_start` 自动执行:
|
|
309
|
-
|
|
310
|
-
1. 整轮取逻辑锁(30s);
|
|
311
|
-
2. 把整个目录快照到 `.backups/migrate-<ts>/`,并把原 topic 文件复制到 `.backups/migrate-<ts>/originals/`;
|
|
312
|
-
3. 对每个 legacy 文件(含 ≥ 2 个 `## ` 段,或 frontmatter 里有旧字段 `updated`):把每个 `## entry` 拆成独立文件,沿用 `type`,把 `updated` 归一成 `created` / `modified`;跨文件重名的第二条起追加 ` (2)`、` (3)`;
|
|
313
|
-
4. `rebuildIndex()`;
|
|
314
|
-
5. 删除原 topic 文件(它们仍在 `originals/` 里);
|
|
315
|
-
6. 写 `.migrated`;
|
|
316
|
-
7. 通知 `Migrated N memories from M topic files. Backup at <path>`。
|
|
317
|
-
|
|
318
|
-
frontmatter 里已含 `modified` 字段的文件**永远不**会被当成 legacy topic 文件(即使正文里有多个 `## ` 小标题)—— 这道守卫正是为了避免正常的 v2 记忆被再次拆碎。
|
|
327
|
+
## 1.x 数据
|
|
319
328
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
**手工回滚:**
|
|
323
|
-
|
|
324
|
-
```bash
|
|
325
|
-
cd ~/.pi/memory/git/github.com__owner__repo # /memory 打印的那个目录
|
|
326
|
-
ls .backups/migrate-*/originals/ # 找到要撤销的那一次
|
|
327
|
-
cp .backups/migrate-<ts>/originals/*.md . # 恢复 1.x 的 topic 文件
|
|
328
|
-
rm .migrated # 让迁移可以重新跑
|
|
329
|
-
rm <生成的 entry 文件> # 即 /memory 的 Entries 列出、但不在 originals/ 里的那些
|
|
330
|
-
```
|
|
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` 是当时的索引。
|
|
331
330
|
|
|
332
331
|
## 文件布局
|
|
333
332
|
|
|
@@ -338,7 +337,7 @@ rm <生成的 entry 文件> # 即 /memory 的 Entri
|
|
|
338
337
|
MEMORY.md — 索引:一行一条记忆
|
|
339
338
|
SSH-port-on-staging.md
|
|
340
339
|
Test-command.md — 一条记忆一个文件
|
|
341
|
-
.lock .backups/ .
|
|
340
|
+
.lock .backups/ .dream-meta.json
|
|
342
341
|
local/
|
|
343
342
|
home__yandy__workspace__scratch/ ← 非 git 目录 /home/yandy/workspace/scratch
|
|
344
343
|
```
|
|
@@ -355,7 +354,7 @@ rm <生成的 entry 文件> # 即 /memory 的 Entri
|
|
|
355
354
|
|
|
356
355
|
映射不是单射:下划线原样保留,所以 `/home/a__b` 与 `/home/a/b` 都映射到 `home__a__b`(共享同一个记忆目录)。改动或重命名 remote、新增一个排序更靠前的 remote、移动本地目录,都会改变记忆目录,旧目录会被孤立。
|
|
357
356
|
|
|
358
|
-
**更老的布局:** 1.x 之前的版本把记忆存在 `~/.pi/memory/<12-char-sha256>/`;这些目录不再被读写。要手工迁移,用 `printf '%s' "$(git rev-parse --show-toplevel)" | sha256sum | cut -c1-12` 算出旧 hash(不在 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-数据)手工拆分。
|
|
359
358
|
|
|
360
359
|
## 通知
|
|
361
360
|
|
|
@@ -365,8 +364,6 @@ rm <生成的 entry 文件> # 即 /memory 的 Entri
|
|
|
365
364
|
| 自动浮现注入了 entry | `Recalled: <N> entries` |
|
|
366
365
|
| extract 写入了记忆 | `Extracted <N> memory.` / `Extracted <N> memories.` |
|
|
367
366
|
| extract 失败 | `Extract failed: <message>` —— 每会话最多一次 |
|
|
368
|
-
| 1.x 迁移完成 | `Migrated <N> memories from <M> topic files. Backup at <path>` |
|
|
369
|
-
| 迁移失败 | `Memory migration failed: <message>` |
|
|
370
367
|
| `/dream` 结束 / 失败 | headless agent 的摘要 / `Dream failed: <message>` |
|
|
371
368
|
|
|
372
369
|
headless 会话(`hasUI === false`)不发任何通知。
|