@yandy0725/pi-memory 1.2.1 → 1.3.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 +28 -21
- package/README.zh.md +26 -19
- package/index.ts +22 -6
- package/package.json +1 -1
- package/src/agent-config.ts +2 -2
- package/src/agent-runner.ts +30 -14
- package/src/config.ts +29 -3
- package/src/dream.ts +3 -1
- package/src/extract.ts +19 -22
- package/src/inject.ts +4 -1
- package/src/memory-tool.ts +88 -62
- package/src/topic-file.ts +5 -0
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@ Aligned with Claude Code's auto memory mechanism: per-topic MEMORY.md index, aut
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **One `memory` tool**,
|
|
9
|
+
- **One `memory` tool**, three actions: `add` (append entry), `remove` (delete entry by title), `search` (query memory or session history)
|
|
10
10
|
- **Topic-based file organization**: each `memory add` writes a `## entry` block to a named `.md` file under the project's memory directory
|
|
11
11
|
- **`MEMORY.md` index** — one compact line per topic file with a relevance hook: `- [Name](file.md) — summary`
|
|
12
12
|
- **Memory types**: four categories — `user`, `feedback` (default), `project`, `reference` — stored in topic file frontmatter
|
|
13
|
-
- **Auto-surfacing** ⭐: on every user message, a side-query LLM selects up to N relevant topic files and injects their content into the agent's context. No manual `
|
|
13
|
+
- **Auto-surfacing** ⭐: on every user message, a side-query LLM selects up to N relevant topic files and injects their content into the agent's context. No manual `read` needed — use the built-in `read` tool to inspect memory files. Session-level deduplication prevents re-injecting the same topic.
|
|
14
14
|
- **Extract memories** ⭐: after each agent run, an async subagent analyzes the conversation and automatically writes learnings to memory — preferences, conventions, debugging insights
|
|
15
15
|
- **Snapshot injection**: on every new session, the MEMORY.md index is appended to the system prompt, keeping the agent aware of past work
|
|
16
16
|
- **`/dream` command**: launches a headless agent with a four-phase consolidation (Orient → Gather → Consolidate → Prune) to deduplicate, merge, and rebuild all memory files
|
|
@@ -44,11 +44,16 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
44
44
|
"memoryDir": "~/.pi/memory",
|
|
45
45
|
"memIndexMaxLines": 200,
|
|
46
46
|
"memIndexMaxBytes": 25600,
|
|
47
|
+
"defaults": {
|
|
48
|
+
"model": "deepseek/deepseek-v4-flash",
|
|
49
|
+
"sessionPersistence": { "enabled": false }
|
|
50
|
+
},
|
|
47
51
|
"dream": {
|
|
48
52
|
"nudgeAfterSessions": 5,
|
|
49
53
|
"nudgeAfterHours": 24,
|
|
50
54
|
"model": "auto",
|
|
51
|
-
"thinkLevel": "high"
|
|
55
|
+
"thinkLevel": "high",
|
|
56
|
+
"sessionPersistence": { "enabled": false }
|
|
52
57
|
},
|
|
53
58
|
"sessionSearch": {
|
|
54
59
|
"maxSessions": 10,
|
|
@@ -60,13 +65,15 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
60
65
|
"thinkLevel": "off",
|
|
61
66
|
"maxFiles": 5,
|
|
62
67
|
"maxTopicBytes": 4096,
|
|
63
|
-
"maxInjectionBytes": 20480
|
|
68
|
+
"maxInjectionBytes": 20480,
|
|
69
|
+
"sessionPersistence": { "enabled": false }
|
|
64
70
|
},
|
|
65
71
|
"extractMemories": {
|
|
66
72
|
"enabled": true,
|
|
67
73
|
"model": "auto",
|
|
68
74
|
"thinkLevel": "high",
|
|
69
|
-
"maxContextTokens": 2000
|
|
75
|
+
"maxContextTokens": 2000,
|
|
76
|
+
"sessionPersistence": { "enabled": false }
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
```
|
|
@@ -77,21 +84,28 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
77
84
|
| `memoryDir` | `~/.pi/memory` | Root directory for all memory data |
|
|
78
85
|
| `memIndexMaxLines` | `200` | Max lines in `MEMORY.md` before capacity errors |
|
|
79
86
|
| `memIndexMaxBytes` | `25600` | Max bytes in `MEMORY.md` before capacity errors |
|
|
87
|
+
| `defaults.model` | — | Shared model fallback for all sub-tasks. Per-task `model` overrides |
|
|
88
|
+
| `defaults.sessionPersistence.enabled` | `false` | Shared session persistence fallback (default: in-memory). Per-task overrides |
|
|
89
|
+
| `defaults.sessionPersistence.sessionDir` | `memoryDir/sessions/` | Custom session directory for persisted headless agent sessions |
|
|
80
90
|
| `dream.nudgeAfterSessions` | `5` | Sessions since last dream before nudge is shown |
|
|
81
91
|
| `dream.nudgeAfterHours` | `24` | Hours since last dream before nudge is shown |
|
|
82
|
-
| `dream.model` |
|
|
92
|
+
| `dream.model` | — | Model for dream consolidation (`"provider/id"`). Falls back to `defaults.model` → parent model |
|
|
83
93
|
| `dream.thinkLevel` | `"high"` | Thinking effort for dream subagent: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"` |
|
|
94
|
+
| `dream.sessionPersistence.enabled` | `false` | Persist dream agent sessions to disk (debug/audit). Falls back to `defaults.sessionPersistence.enabled` |
|
|
95
|
+
| `dream.sessionPersistence.sessionDir` | `memoryDir/sessions/` | Custom session directory for dream sessions |
|
|
84
96
|
| `sessionSearch.maxSessions` | `10` | Max sessions to scan when searching history |
|
|
85
97
|
| `sessionSearch.maxMatches` | `5` | Max matches to return from history search |
|
|
86
98
|
| `autoSurfacing.enabled` | `true` | ⭐ Enable per-turn topic file auto-injection |
|
|
87
|
-
| `autoSurfacing.model` |
|
|
99
|
+
| `autoSurfacing.model` | — | ⭐ Model for side-query relevance selection. Falls back to `defaults.model` → parent model |
|
|
88
100
|
| `autoSurfacing.thinkLevel` | `"off"` | ⭐ Thinking effort for side-query (recommended: `"off"` for lightweight selection) |
|
|
101
|
+
| `autoSurfacing.sessionPersistence.enabled` | `false` | Persist side-query agent sessions to disk. Falls back to `defaults.sessionPersistence.enabled` |
|
|
89
102
|
| `autoSurfacing.maxFiles` | `5` | ⭐ Max topic files to inject per turn |
|
|
90
103
|
| `autoSurfacing.maxTopicBytes` | `4096` | ⭐ Max bytes per injected topic file (truncated) |
|
|
91
104
|
| `autoSurfacing.maxInjectionBytes` | `20480` | ⭐ Max total bytes of injected content per turn |
|
|
92
105
|
| `extractMemories.enabled` | `true` | ⭐ Enable per-turn memory extraction |
|
|
93
|
-
| `extractMemories.model` |
|
|
106
|
+
| `extractMemories.model` | — | ⭐ Model for the extraction subagent. Falls back to `defaults.model` → parent model |
|
|
94
107
|
| `extractMemories.thinkLevel` | `"high"` | ⭐ Thinking effort for extraction: `"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
|
|
108
|
+
| `extractMemories.sessionPersistence.enabled` | `false` | Persist extract agent sessions to disk. Falls back to `defaults.sessionPersistence.enabled` |
|
|
95
109
|
| `extractMemories.maxContextTokens` | `2000` | ⭐ Max tokens of conversation to analyze |
|
|
96
110
|
|
|
97
111
|
Project-level config (`.pi/memory.json`) is only loaded when the project is trusted.
|
|
@@ -107,7 +121,7 @@ MEMORY.md is a compact **pointer index** — one line per topic file, not per en
|
|
|
107
121
|
- [API Conventions](api.md) — REST handlers in src/api/handlers/; standard error format
|
|
108
122
|
```
|
|
109
123
|
|
|
110
|
-
Only the index is injected into the system prompt on every session (first 200 lines / 25KB). Topic file content is **not** loaded at session start — it's surfaced on demand via auto-surfacing or
|
|
124
|
+
Only the index is injected into the system prompt on every session (first 200 lines / 25KB). Topic file content is **not** loaded at session start — it's surfaced on demand via auto-surfacing or the built-in `read` tool.
|
|
111
125
|
|
|
112
126
|
### Topic file format
|
|
113
127
|
|
|
@@ -150,9 +164,9 @@ On every user message (`before_agent_start` hook):
|
|
|
150
164
|
### Extract memories
|
|
151
165
|
|
|
152
166
|
After each agent run (`agent_end` hook):
|
|
153
|
-
1. An async subagent is
|
|
154
|
-
2. It
|
|
155
|
-
3. If
|
|
167
|
+
1. An async headless subagent is spawned with the conversation transcript
|
|
168
|
+
2. It uses `ls` and `read` to check existing topic files and MEMORY.md, then `memory_add` to persist learnings
|
|
169
|
+
3. If it finds learnings worth persisting, it writes them — preferences, conventions, debugging insights
|
|
156
170
|
4. The subagent runs independently; its results benefit future sessions
|
|
157
171
|
|
|
158
172
|
Memory extraction is selective: it ignores one-time tasks, code snippets derivable from the project, and anything already in CLAUDE.md.
|
|
@@ -160,7 +174,7 @@ Memory extraction is selective: it ignores one-time tasks, code snippets derivab
|
|
|
160
174
|
## Tool reference
|
|
161
175
|
|
|
162
176
|
```
|
|
163
|
-
memory(action: "add" | "remove" | "search"
|
|
177
|
+
memory(action: "add" | "remove" | "search",
|
|
164
178
|
content?, topic?, title?, type?,
|
|
165
179
|
entry?, query?, scope?)
|
|
166
180
|
```
|
|
@@ -180,13 +194,6 @@ Deletes an entry by title. Searches across all topic files for the matching `##`
|
|
|
180
194
|
|
|
181
195
|
- **`entry`** (required) — exact entry title to remove
|
|
182
196
|
|
|
183
|
-
### `read`
|
|
184
|
-
|
|
185
|
-
Loads memory content. Either an entire topic file or a single entry block.
|
|
186
|
-
|
|
187
|
-
- **`topic`** (optional) — topic name, e.g. `"debugging"` or `"debugging.md"`. Loads the entire topic file
|
|
188
|
-
- **`entry`** (optional) — entry title. Returns the specific `## Entry Title` block
|
|
189
|
-
|
|
190
197
|
### `search`
|
|
191
198
|
|
|
192
199
|
Queries either memory files or session history. Memory search returns the full entry block (entire `##` section) for each match.
|
|
@@ -237,4 +244,4 @@ The hash is derived from the project's git root (or absolute path), ensuring eac
|
|
|
237
244
|
|
|
238
245
|
On every `session_start`, the `MEMORY.md` index is read and appended to the system prompt via `before_agent_start`. If the index exceeds `memIndexMaxLines` or `memIndexMaxBytes`, it is truncated with a `[truncated]` marker — the agent still gets the most relevant portion. This snapshot is a static copy at the start of the session.
|
|
239
246
|
|
|
240
|
-
Topic file content is surfaced separately via **auto-surfacing** (automatic, per-turn, relevance-based) or
|
|
247
|
+
Topic file content is surfaced separately via **auto-surfacing** (automatic, per-turn, relevance-based) or the built-in `read` tool.
|
package/README.zh.md
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
|
-
- **一个 `memory`
|
|
9
|
+
- **一个 `memory` 工具**,三种操作:`add`(追加条目)、`remove`(按标题删除条目)、`search`(查询记忆或会话历史)
|
|
10
10
|
- **基于主题的文件组织**:每次 `memory add` 向指定名称的 `.md` 文件写入 `## 条目` 区块
|
|
11
11
|
- **`MEMORY.md` 索引**:每个 topic 文件一行紧凑指针 `- [名称](文件.md) — 摘要`,同 topic 自动合并
|
|
12
12
|
- **记忆类型系统**:四种分类 — `user`(用户)、`feedback`(反馈,默认)、`project`(项目)、`reference`(引用)— 存储在 topic 文件 frontmatter 中
|
|
13
|
-
- **Auto-surfacing** ⭐:每次用户发消息时,side-query LLM 自动选出最多 N 个相关 topic 文件并将其内容注入 agent context。无需手动 `
|
|
14
|
-
- **Extract memories** ⭐:每次 agent
|
|
13
|
+
- **Auto-surfacing** ⭐:每次用户发消息时,side-query LLM 自动选出最多 N 个相关 topic 文件并将其内容注入 agent context。无需手动 `read` — 用内置 `read` 工具即可查看记忆文件。Session 内去重防止同一 topic 重复注入
|
|
14
|
+
- **Extract memories** ⭐:每次 agent 运行结束后,异步无头子 agent 分析对话内容,用 `ls`/`read` 检查已有 topic 文件,再用 `memory_add` 写入新记忆 — 偏好、约定、调试心法等
|
|
15
15
|
- **快照注入**:每个新会话启动时,MEMORY.md 索引追加到系统提示中
|
|
16
16
|
- **`/dream` 命令**:四阶段(Orient → Gather → Consolidate → Prune)无头代理整理,去重、合并、重建全部记忆文件
|
|
17
17
|
- **梦醒提醒**:经过 N 个会话或 N 小时后,温和通知建议运行 `/dream`
|
|
@@ -44,11 +44,16 @@ pi install npm:@yandy0725/pi-memory
|
|
|
44
44
|
"memoryDir": "~/.pi/memory",
|
|
45
45
|
"memIndexMaxLines": 200,
|
|
46
46
|
"memIndexMaxBytes": 25600,
|
|
47
|
+
"defaults": {
|
|
48
|
+
"model": "deepseek/deepseek-v4-flash",
|
|
49
|
+
"sessionPersistence": { "enabled": false }
|
|
50
|
+
},
|
|
47
51
|
"dream": {
|
|
48
52
|
"nudgeAfterSessions": 5,
|
|
49
53
|
"nudgeAfterHours": 24,
|
|
50
54
|
"model": "auto",
|
|
51
|
-
"thinkLevel": "high"
|
|
55
|
+
"thinkLevel": "high",
|
|
56
|
+
"sessionPersistence": { "enabled": false }
|
|
52
57
|
},
|
|
53
58
|
"sessionSearch": {
|
|
54
59
|
"maxSessions": 10,
|
|
@@ -60,13 +65,15 @@ pi install npm:@yandy0725/pi-memory
|
|
|
60
65
|
"thinkLevel": "off",
|
|
61
66
|
"maxFiles": 5,
|
|
62
67
|
"maxTopicBytes": 4096,
|
|
63
|
-
"maxInjectionBytes": 20480
|
|
68
|
+
"maxInjectionBytes": 20480,
|
|
69
|
+
"sessionPersistence": { "enabled": false }
|
|
64
70
|
},
|
|
65
71
|
"extractMemories": {
|
|
66
72
|
"enabled": true,
|
|
67
73
|
"model": "auto",
|
|
68
74
|
"thinkLevel": "high",
|
|
69
|
-
"maxContextTokens": 2000
|
|
75
|
+
"maxContextTokens": 2000,
|
|
76
|
+
"sessionPersistence": { "enabled": false }
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
```
|
|
@@ -77,21 +84,28 @@ pi install npm:@yandy0725/pi-memory
|
|
|
77
84
|
| `memoryDir` | `~/.pi/memory` | 所有记忆数据的根目录 |
|
|
78
85
|
| `memIndexMaxLines` | `200` | `MEMORY.md` 最大行数 |
|
|
79
86
|
| `memIndexMaxBytes` | `25600` | `MEMORY.md` 最大字节数 |
|
|
87
|
+
| `defaults.model` | — | 所有子任务共享的模型回退值。per-task `model` 会覆盖 |
|
|
88
|
+
| `defaults.sessionPersistence.enabled` | `false` | 共享的 session 持久化回退(默认不持久化)。per-task 可覆盖 |
|
|
89
|
+
| `defaults.sessionPersistence.sessionDir` | `memoryDir/sessions/` | 自定义持久化目录 |
|
|
80
90
|
| `dream.nudgeAfterSessions` | `5` | 触发提醒需经过的会话数 |
|
|
81
91
|
| `dream.nudgeAfterHours` | `24` | 触发提醒需经过的小时数 |
|
|
82
|
-
| `dream.model` |
|
|
92
|
+
| `dream.model` | — | 整理使用的模型(`"provider/id"`)。回退链:per-task → `defaults.model` → 父模型 |
|
|
83
93
|
| `dream.thinkLevel` | `"high"` | 整理子 agent 的思考深度:`"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
|
|
94
|
+
| `dream.sessionPersistence.enabled` | `false` | 持久化 dream agent 的 session 到磁盘(调试/审计)。回退到 `defaults.sessionPersistence.enabled` |
|
|
95
|
+
| `dream.sessionPersistence.sessionDir` | `memoryDir/sessions/` | dream session 自定义目录 |
|
|
84
96
|
| `sessionSearch.maxSessions` | `10` | 搜索历史时最多扫描的会话数 |
|
|
85
97
|
| `sessionSearch.maxMatches` | `5` | 历史搜索最多返回的匹配数 |
|
|
86
98
|
| `autoSurfacing.enabled` | `true` | ⭐ 启用 per-turn topic 文件自动注入 |
|
|
87
|
-
| `autoSurfacing.model` |
|
|
99
|
+
| `autoSurfacing.model` | — | ⭐ side-query 相关性选择模型。回退链:per-task → `defaults.model` → 父模型 |
|
|
88
100
|
| `autoSurfacing.thinkLevel` | `"off"` | ⭐ side-query 思考深度(推荐 `"off"`,轻量选择任务) |
|
|
101
|
+
| `autoSurfacing.sessionPersistence.enabled` | `false` | 持久化 side-query agent 的 session。回退到 `defaults.sessionPersistence.enabled` |
|
|
89
102
|
| `autoSurfacing.maxFiles` | `5` | ⭐ 每轮最多注入的 topic 文件数 |
|
|
90
103
|
| `autoSurfacing.maxTopicBytes` | `4096` | ⭐ 单个注入 topic 文件最大字节数(截断) |
|
|
91
104
|
| `autoSurfacing.maxInjectionBytes` | `20480` | ⭐ 每轮注入内容总字节数上限 |
|
|
92
105
|
| `extractMemories.enabled` | `true` | ⭐ 启用 per-turn 记忆自动提取 |
|
|
93
|
-
| `extractMemories.model` |
|
|
106
|
+
| `extractMemories.model` | — | ⭐ 提取子 agent 使用的模型。回退链:per-task → `defaults.model` → 父模型 |
|
|
94
107
|
| `extractMemories.thinkLevel` | `"high"` | ⭐ 提取的思考深度:`"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
|
|
108
|
+
| `extractMemories.sessionPersistence.enabled` | `false` | 持久化 extract agent 的 session。回退到 `defaults.sessionPersistence.enabled` |
|
|
95
109
|
| `extractMemories.maxContextTokens` | `2000` | ⭐ 分析对话的最大 token 数 |
|
|
96
110
|
|
|
97
111
|
项目级配置(`.pi/memory.json`)仅在项目受信任时加载。
|
|
@@ -107,7 +121,7 @@ MEMORY.md 是一个**紧凑指针索引** — 每个 topic 文件一行,而非
|
|
|
107
121
|
- [API Conventions](api.md) — REST handlers 在 src/api/handlers/;使用标准错误格式
|
|
108
122
|
```
|
|
109
123
|
|
|
110
|
-
每次会话只有索引被注入系统提示(前 200 行 / 25KB)。Topic 文件内容**不会**在启动时加载 — 通过 auto-surfacing
|
|
124
|
+
每次会话只有索引被注入系统提示(前 200 行 / 25KB)。Topic 文件内容**不会**在启动时加载 — 通过 auto-surfacing 按需注入或内置 `read` 工具显式加载。
|
|
111
125
|
|
|
112
126
|
### Topic 文件格式
|
|
113
127
|
|
|
@@ -160,7 +174,7 @@ staging 上连接超时 30s
|
|
|
160
174
|
## 工具参考
|
|
161
175
|
|
|
162
176
|
```
|
|
163
|
-
memory(action: "add" | "remove" | "search"
|
|
177
|
+
memory(action: "add" | "remove" | "search",
|
|
164
178
|
content?, topic?, title?, type?,
|
|
165
179
|
entry?, query?, scope?)
|
|
166
180
|
```
|
|
@@ -180,13 +194,6 @@ memory(action: "add" | "remove" | "search" | "read",
|
|
|
180
194
|
|
|
181
195
|
- **`entry`**(必填)— 要删除的条目标题
|
|
182
196
|
|
|
183
|
-
### `read`
|
|
184
|
-
|
|
185
|
-
加载记忆内容。可以是整个 topic 文件或单个条目区块。
|
|
186
|
-
|
|
187
|
-
- **`topic`**(可选)— 主题名称,如 `"debugging"` 或 `"debugging.md"`。加载整个 topic 文件
|
|
188
|
-
- **`entry`**(可选)— 条目标题。返回对应的 `## 条目标题` 区块
|
|
189
|
-
|
|
190
197
|
### `search`
|
|
191
198
|
|
|
192
199
|
查询记忆文件或会话历史。记忆搜索返回匹配条目的完整区块(整个 `##` 区域)。
|
|
@@ -235,4 +242,4 @@ memory(action: "add" | "remove" | "search" | "read",
|
|
|
235
242
|
|
|
236
243
|
每次 `session_start` 读取 `MEMORY.md` 索引,通过 `before_agent_start` 追加到系统提示中。超限则截断并标记 `[truncated]`。此快照是会话开始时的静态副本。
|
|
237
244
|
|
|
238
|
-
Topic 文件内容通过 **auto-surfacing**(自动、per-turn
|
|
245
|
+
Topic 文件内容通过 **auto-surfacing**(自动、per-turn、基于相关性)或内置 `read` 工具按需加载。
|
package/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { loadConfig, type MemoryConfig } from "./src/config";
|
|
5
|
+
import { loadConfig, type MemoryConfig, type SessionPersistenceConfig } from "./src/config";
|
|
6
6
|
import { runDream } from "./src/dream";
|
|
7
7
|
import { runExtract } from "./src/extract";
|
|
8
8
|
import {
|
|
@@ -12,11 +12,19 @@ import {
|
|
|
12
12
|
runSideQuery,
|
|
13
13
|
scanTopics,
|
|
14
14
|
} from "./src/inject";
|
|
15
|
-
import { createMemoryTool } from "./src/memory-tool";
|
|
15
|
+
import { createMemoryTool, createMemoryTools } from "./src/memory-tool";
|
|
16
16
|
import { readDreamMeta, shouldNudge, writeDreamMeta } from "./src/nudge";
|
|
17
17
|
import { resolveMemoryDir } from "./src/paths";
|
|
18
18
|
import { searchSessions } from "./src/session-search";
|
|
19
19
|
|
|
20
|
+
function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "model"): string | undefined;
|
|
21
|
+
function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "sessionPersistence"): SessionPersistenceConfig | undefined;
|
|
22
|
+
function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "model" | "sessionPersistence"): string | SessionPersistenceConfig | undefined {
|
|
23
|
+
const perTask = cfg[task][key];
|
|
24
|
+
if (perTask !== undefined) return perTask;
|
|
25
|
+
return cfg.defaults?.[key];
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
export default function (pi: ExtensionAPI) {
|
|
21
29
|
let memoryDir: string | null = null;
|
|
22
30
|
let config: MemoryConfig | null = null;
|
|
@@ -54,7 +62,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
62
|
if (ok) {
|
|
55
63
|
// Fire-and-forget: does not block session_start. The headless
|
|
56
64
|
// dream agent runs independently; completion notifies the user.
|
|
57
|
-
const dreamModel = config
|
|
65
|
+
const dreamModel = resolveDefault(config, "dream", "model");
|
|
58
66
|
const dreamThinkLevel = config.dream.thinkLevel;
|
|
59
67
|
const dir = memoryDir;
|
|
60
68
|
ctx.ui.setStatus("dream", "Consolidating memory...");
|
|
@@ -64,6 +72,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
64
72
|
memoryDir: dir,
|
|
65
73
|
modelRegistry: ctx.modelRegistry,
|
|
66
74
|
parentModel: ctx.model,
|
|
75
|
+
sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
|
|
67
76
|
})
|
|
68
77
|
.then(async (summary) => {
|
|
69
78
|
await writeDreamMeta(dir, sessions);
|
|
@@ -103,10 +112,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
103
112
|
injectedTopics,
|
|
104
113
|
autoSurfacing.maxFiles,
|
|
105
114
|
autoSurfacing.thinkLevel,
|
|
106
|
-
autoSurfacing
|
|
115
|
+
resolveDefault(config, "autoSurfacing", "model"),
|
|
107
116
|
ctx.modelRegistry,
|
|
108
117
|
ctx.model,
|
|
109
118
|
memoryDir,
|
|
119
|
+
resolveDefault(config, "autoSurfacing", "sessionPersistence"),
|
|
110
120
|
);
|
|
111
121
|
if (selected.length > 0) {
|
|
112
122
|
const content = await injectSurfacedContent(
|
|
@@ -141,11 +151,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
141
151
|
if (!extractConfig?.enabled) return;
|
|
142
152
|
if (!event.messages || event.messages.length === 0) return;
|
|
143
153
|
runExtract({
|
|
144
|
-
model:
|
|
154
|
+
model: resolveDefault(config, "extractMemories", "model"),
|
|
145
155
|
thinkLevel: extractConfig.thinkLevel,
|
|
146
156
|
memoryDir,
|
|
147
157
|
modelRegistry: ctx.modelRegistry,
|
|
148
158
|
parentModel: ctx.model,
|
|
159
|
+
customTools: createMemoryTools(memoryDir, {
|
|
160
|
+
maxLines: config.memIndexMaxLines,
|
|
161
|
+
maxBytes: config.memIndexMaxBytes,
|
|
162
|
+
}),
|
|
163
|
+
sessionPersistence: resolveDefault(config, "extractMemories", "sessionPersistence"),
|
|
149
164
|
messages: event.messages.map((m) => ({
|
|
150
165
|
// biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
151
166
|
role: String((m as any).role ?? ""),
|
|
@@ -206,11 +221,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
221
|
const dir = memoryDir;
|
|
207
222
|
ctx.ui.setStatus("dream", "Consolidating memory...");
|
|
208
223
|
runDream({
|
|
209
|
-
model: config
|
|
224
|
+
model: resolveDefault(config, "dream", "model"),
|
|
210
225
|
thinkLevel: config.dream.thinkLevel,
|
|
211
226
|
memoryDir,
|
|
212
227
|
modelRegistry: ctx.modelRegistry,
|
|
213
228
|
parentModel: ctx.model,
|
|
229
|
+
sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
|
|
214
230
|
})
|
|
215
231
|
.then(async (summary) => {
|
|
216
232
|
const sessions = (await SessionManager.list(ctx.cwd)).length;
|
package/package.json
CHANGED
package/src/agent-config.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export const
|
|
1
|
+
/** Default built-in tools for headless agents (file I/O only, no bash). */
|
|
2
|
+
export const FILE_IO_TOOLS = ["read", "write", "edit", "ls"] as const;
|
package/src/agent-runner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import {
|
|
3
4
|
type AgentSession,
|
|
4
5
|
type AgentSessionEvent,
|
|
@@ -7,9 +8,10 @@ import {
|
|
|
7
8
|
getAgentDir,
|
|
8
9
|
SessionManager,
|
|
9
10
|
SettingsManager,
|
|
11
|
+
type ToolDefinition,
|
|
10
12
|
} from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import {
|
|
12
|
-
import type { ThinkLevel } from "./config";
|
|
13
|
+
import { FILE_IO_TOOLS } from "./agent-config";
|
|
14
|
+
import type { SessionPersistenceConfig, ThinkLevel } from "./config";
|
|
13
15
|
import { resolveModel } from "./model-resolver";
|
|
14
16
|
|
|
15
17
|
export interface HeadlessAgentOpts {
|
|
@@ -22,13 +24,20 @@ export interface HeadlessAgentOpts {
|
|
|
22
24
|
maxTurns?: number;
|
|
23
25
|
signal?: AbortSignal;
|
|
24
26
|
timeoutMs?: number;
|
|
27
|
+
/** Session persistence config. When enabled, sessions are written to disk. */
|
|
28
|
+
sessionPersistence?: SessionPersistenceConfig;
|
|
29
|
+
/** Built-in tool name allowlist. Defaults to FILE_IO_TOOLS. Pass [] for no built-in tools. */
|
|
30
|
+
tools?: string[];
|
|
31
|
+
/** Custom tool definitions. Defaults to []. */
|
|
32
|
+
customTools?: ToolDefinition[];
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
const GRACE_TURNS = 1;
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
|
-
* Run a headless memory-agent sub-session: create
|
|
31
|
-
*
|
|
38
|
+
* Run a headless memory-agent sub-session: create a session (in-memory by default,
|
|
39
|
+
* persisted to disk when sessionPersistence.enabled is true), drive the turn loop,
|
|
40
|
+
* collect the assistant response text, and dispose.
|
|
32
41
|
*
|
|
33
42
|
* Does NOT call bindExtensions — no extension hooks fire in the sub-session,
|
|
34
43
|
* so pi-memory's own before_agent_start cannot recurse.
|
|
@@ -53,25 +62,32 @@ export async function runHeadlessAgent(opts: HeadlessAgentOpts): Promise<string>
|
|
|
53
62
|
});
|
|
54
63
|
await loader.reload();
|
|
55
64
|
|
|
56
|
-
// 3.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
// 3. Create session (in-memory or persisted based on config)
|
|
66
|
+
const sessionManager = opts.sessionPersistence?.enabled
|
|
67
|
+
? SessionManager.create(
|
|
68
|
+
opts.cwd,
|
|
69
|
+
opts.sessionPersistence.sessionDir ?? join(opts.cwd, "sessions"),
|
|
70
|
+
)
|
|
71
|
+
: SessionManager.inMemory(opts.cwd);
|
|
62
72
|
|
|
63
|
-
// 4. Create the in-memory session (no bindExtensions)
|
|
64
73
|
const created = await createAgentSession({
|
|
65
74
|
cwd: opts.cwd,
|
|
66
|
-
tools: [...
|
|
75
|
+
tools: opts.tools ?? [...FILE_IO_TOOLS],
|
|
76
|
+
customTools: opts.customTools ?? [],
|
|
67
77
|
model: resolvedModel as any,
|
|
68
78
|
thinkingLevel: opts.thinkLevel as any,
|
|
69
79
|
modelRegistry: opts.modelRegistry,
|
|
70
|
-
sessionManager
|
|
80
|
+
sessionManager,
|
|
71
81
|
settingsManager,
|
|
72
82
|
resourceLoader: loader,
|
|
73
83
|
});
|
|
74
|
-
|
|
84
|
+
|
|
85
|
+
// 4. Forward abort signal after session exists (avoid listener leak if creation throws)
|
|
86
|
+
let session: AgentSession | undefined = created.session as AgentSession;
|
|
87
|
+
const onAbort = (): void => {
|
|
88
|
+
void session?.abort();
|
|
89
|
+
};
|
|
90
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
75
91
|
|
|
76
92
|
// 5. Collect response text + enforce turn limits
|
|
77
93
|
let text = "";
|
package/src/config.ts
CHANGED
|
@@ -5,6 +5,20 @@ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
5
5
|
|
|
6
6
|
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
7
7
|
|
|
8
|
+
/** Session persistence configuration for headless memory-agent sub-sessions. */
|
|
9
|
+
export interface SessionPersistenceConfig {
|
|
10
|
+
/** Enable disk persistence (default: false = in-memory). */
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
/** Custom session directory. Defaults to memoryDir/sessions/. */
|
|
13
|
+
sessionDir?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Shared defaults that per-task configs inherit. Per-task fields override these. */
|
|
17
|
+
export interface DefaultsConfig {
|
|
18
|
+
model?: string;
|
|
19
|
+
sessionPersistence?: SessionPersistenceConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export interface AutoSurfacingConfig {
|
|
9
23
|
enabled: boolean;
|
|
10
24
|
model?: string;
|
|
@@ -23,13 +37,25 @@ export interface ExtractMemoriesConfig {
|
|
|
23
37
|
|
|
24
38
|
export interface MemoryConfig {
|
|
25
39
|
enabled: boolean;
|
|
40
|
+
/** Shared defaults for model and sessionPersistence. Per-task configs override. */
|
|
41
|
+
defaults?: DefaultsConfig;
|
|
26
42
|
memoryDir: string;
|
|
27
43
|
memIndexMaxLines: number;
|
|
28
44
|
memIndexMaxBytes: number;
|
|
29
|
-
dream: {
|
|
45
|
+
dream: {
|
|
46
|
+
nudgeAfterSessions: number;
|
|
47
|
+
nudgeAfterHours: number;
|
|
48
|
+
model?: string;
|
|
49
|
+
thinkLevel: ThinkLevel;
|
|
50
|
+
sessionPersistence?: SessionPersistenceConfig;
|
|
51
|
+
};
|
|
30
52
|
sessionSearch: { maxSessions: number; maxMatches: number };
|
|
31
|
-
autoSurfacing: AutoSurfacingConfig
|
|
32
|
-
|
|
53
|
+
autoSurfacing: AutoSurfacingConfig & {
|
|
54
|
+
sessionPersistence?: SessionPersistenceConfig;
|
|
55
|
+
};
|
|
56
|
+
extractMemories: ExtractMemoriesConfig & {
|
|
57
|
+
sessionPersistence?: SessionPersistenceConfig;
|
|
58
|
+
};
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
export const DEFAULT_CONFIG: MemoryConfig = {
|
package/src/dream.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { runHeadlessAgent } from "./agent-runner";
|
|
4
|
-
import type { ThinkLevel } from "./config";
|
|
4
|
+
import type { SessionPersistenceConfig, ThinkLevel } from "./config";
|
|
5
5
|
|
|
6
6
|
/** Build dream consolidation task. (unchanged) */
|
|
7
7
|
export function buildDreamTask(memoryDir: string, maxLines: number): string {
|
|
@@ -56,6 +56,7 @@ export interface RunDreamOpts {
|
|
|
56
56
|
memoryDir: string;
|
|
57
57
|
modelRegistry: ModelRegistry;
|
|
58
58
|
parentModel?: Model<any>;
|
|
59
|
+
sessionPersistence?: SessionPersistenceConfig;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** Run dream consolidation via a headless agent. Fire-and-forget (caller decides await). */
|
|
@@ -70,5 +71,6 @@ export async function runDream(opts: RunDreamOpts): Promise<string> {
|
|
|
70
71
|
thinkLevel: opts.thinkLevel,
|
|
71
72
|
maxTurns: undefined,
|
|
72
73
|
timeoutMs: 600_000,
|
|
74
|
+
sessionPersistence: opts.sessionPersistence,
|
|
73
75
|
});
|
|
74
76
|
}
|
package/src/extract.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { runHeadlessAgent } from "./agent-runner";
|
|
4
|
-
import type { ThinkLevel } from "./config";
|
|
4
|
+
import type { SessionPersistenceConfig, ThinkLevel } from "./config";
|
|
5
5
|
|
|
6
6
|
export interface RunExtractOpts {
|
|
7
7
|
model?: string;
|
|
@@ -11,9 +11,11 @@ export interface RunExtractOpts {
|
|
|
11
11
|
maxContextTokens: number;
|
|
12
12
|
modelRegistry: ModelRegistry;
|
|
13
13
|
parentModel?: Model<any>;
|
|
14
|
+
sessionPersistence?: SessionPersistenceConfig;
|
|
15
|
+
customTools?: ToolDefinition[];
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
/** Build extraction task prompt.
|
|
18
|
+
/** Build extraction task prompt using memory tools instead of raw file I/O. */
|
|
17
19
|
export function buildExtractTask(
|
|
18
20
|
memoryDir: string,
|
|
19
21
|
messages: Array<{ role: string; content: string }>,
|
|
@@ -29,25 +31,16 @@ export function buildExtractTask(
|
|
|
29
31
|
const truncatedAssistant = assistantText.slice(0, maxChars / 2);
|
|
30
32
|
|
|
31
33
|
return [
|
|
32
|
-
`You are a memory extraction agent. Your
|
|
34
|
+
`You are a memory extraction agent. Your working directory is the memory directory at ${memoryDir}.`,
|
|
33
35
|
"",
|
|
34
|
-
"Analyze the conversation snippet below. If you find valuable learnings,
|
|
35
|
-
"The memory directory contains topic files with this frontmatter format:",
|
|
36
|
+
"Analyze the conversation snippet below. If you find valuable learnings, persist them using the memory tools.",
|
|
36
37
|
"",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"type: feedback # one of: user, feedback, project, reference",
|
|
42
|
-
"updated: 2026-07-13",
|
|
43
|
-
"---",
|
|
38
|
+
"You have these tools available:",
|
|
39
|
+
"- 'ls' and 'read': list files and read topic files in the memory directory to check for existing topics",
|
|
40
|
+
"- memory_search: search across all memory files for relevant existing entries",
|
|
41
|
+
"- memory_add: persist a new memory entry to a topic file (creates the topic if new)",
|
|
44
42
|
"",
|
|
45
|
-
"
|
|
46
|
-
"Entry content here.",
|
|
47
|
-
"```",
|
|
48
|
-
"",
|
|
49
|
-
"And MEMORY.md index:",
|
|
50
|
-
"- [Name](file.md) — one-line hook summary",
|
|
43
|
+
"Use 'ls' to list files and 'read' to inspect MEMORY.md and topic files. Use memory_search to find related entries. Use memory_add to write new memories. Do NOT use 'bash', 'write', 'edit', or any other tools.",
|
|
51
44
|
"",
|
|
52
45
|
"Worth remembering:",
|
|
53
46
|
"- User preferences, coding style choices, tooling preferences",
|
|
@@ -63,12 +56,12 @@ export function buildExtractTask(
|
|
|
63
56
|
"- Git history or recent changes",
|
|
64
57
|
"",
|
|
65
58
|
"When writing memories:",
|
|
59
|
+
"- Use 'ls' and 'read' first to check for existing topic files and MEMORY.md index",
|
|
60
|
+
"- Use memory_search to find overlapping or related memories before adding",
|
|
66
61
|
"- Use descriptive, self-contained entry titles (only index lines are injected into future sessions)",
|
|
67
|
-
|
|
68
|
-
'- Default type is "feedback"',
|
|
62
|
+
'- Choose the appropriate type: user, feedback, project, reference (default "feedback")',
|
|
69
63
|
"- Be concise but complete",
|
|
70
64
|
"- If unsure, do NOT write anything",
|
|
71
|
-
"- Use the write/edit tools to directly modify topic files and MEMORY.md",
|
|
72
65
|
"",
|
|
73
66
|
"=== Conversation ===",
|
|
74
67
|
`User: ${truncatedUser}`,
|
|
@@ -90,6 +83,10 @@ export async function runExtract(opts: RunExtractOpts): Promise<void> {
|
|
|
90
83
|
thinkLevel: opts.thinkLevel,
|
|
91
84
|
maxTurns: 5,
|
|
92
85
|
timeoutMs: 120_000,
|
|
86
|
+
|
|
87
|
+
tools: ["read", "ls"],
|
|
88
|
+
customTools: opts.customTools ?? [],
|
|
89
|
+
sessionPersistence: opts.sessionPersistence,
|
|
93
90
|
}).catch(() => {
|
|
94
91
|
/* silently ignore extract errors */
|
|
95
92
|
});
|
package/src/inject.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { Model } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { runHeadlessAgent } from "./agent-runner";
|
|
6
|
-
import type { ThinkLevel } from "./config";
|
|
6
|
+
import type { SessionPersistenceConfig, ThinkLevel } from "./config";
|
|
7
7
|
import { truncateForInjection } from "./index-file";
|
|
8
8
|
import { parseFrontmatter } from "./topic-file";
|
|
9
9
|
|
|
@@ -147,6 +147,7 @@ export async function runSideQuery(
|
|
|
147
147
|
modelRegistry: ModelRegistry,
|
|
148
148
|
parentModel: Model<any> | undefined,
|
|
149
149
|
memoryDir: string,
|
|
150
|
+
sessionPersistence?: SessionPersistenceConfig,
|
|
150
151
|
): Promise<string[]> {
|
|
151
152
|
const candidates = manifest.filter((t) => !injectedTopics.has(t.filename));
|
|
152
153
|
if (candidates.length === 0) return [];
|
|
@@ -162,6 +163,8 @@ export async function runSideQuery(
|
|
|
162
163
|
thinkLevel,
|
|
163
164
|
maxTurns: 1,
|
|
164
165
|
timeoutMs: 30_000,
|
|
166
|
+
tools: [],
|
|
167
|
+
sessionPersistence,
|
|
165
168
|
});
|
|
166
169
|
return parseSelectedFiles(result, candidates, maxFiles);
|
|
167
170
|
} catch {
|
package/src/memory-tool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { withFileMutationQueue, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
hasEntries,
|
|
22
22
|
parseEntries,
|
|
23
23
|
removeEntrySection,
|
|
24
|
+
replaceFrontmatterField,
|
|
24
25
|
updateFrontmatterDate,
|
|
25
26
|
} from "./topic-file";
|
|
26
27
|
|
|
@@ -35,15 +36,6 @@ export interface AddParams {
|
|
|
35
36
|
export interface RemoveParams {
|
|
36
37
|
entry: string;
|
|
37
38
|
}
|
|
38
|
-
export interface ReadParams {
|
|
39
|
-
topic?: string;
|
|
40
|
-
entry?: string;
|
|
41
|
-
}
|
|
42
|
-
export interface ReadResult {
|
|
43
|
-
ok: boolean;
|
|
44
|
-
error?: string;
|
|
45
|
-
content?: string;
|
|
46
|
-
}
|
|
47
39
|
export interface ActionResult {
|
|
48
40
|
ok: boolean;
|
|
49
41
|
error?: string;
|
|
@@ -102,18 +94,19 @@ export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResu
|
|
|
102
94
|
const topicContent = appendContent(fm, p.title, p.content);
|
|
103
95
|
await writeFile(topicPath, topicContent, "utf8");
|
|
104
96
|
} else {
|
|
105
|
-
// Existing topic: append entry, then regenerate hook from ALL entry titles
|
|
97
|
+
// Existing topic: append entry, then regenerate hook + description from ALL entry titles
|
|
106
98
|
const raw = await readFile(topicPath, "utf8");
|
|
107
99
|
const refreshed = updateFrontmatterDate(raw, today());
|
|
108
100
|
const topicContent = appendContent(refreshed, p.title, p.content);
|
|
109
|
-
await writeFile(topicPath, topicContent, "utf8");
|
|
110
101
|
|
|
111
|
-
// Build hook from all entries
|
|
102
|
+
// Build hook + description from all entries
|
|
112
103
|
const allEntries = parseEntries(topicContent);
|
|
113
104
|
const hook = allEntries
|
|
114
105
|
.map((e) => e.title)
|
|
115
106
|
.join("; ")
|
|
116
107
|
.slice(0, 150);
|
|
108
|
+
const withDesc = replaceFrontmatterField(topicContent, "description", hook);
|
|
109
|
+
|
|
117
110
|
next = updateHook(entries, topic, hook);
|
|
118
111
|
if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
|
|
119
112
|
return {
|
|
@@ -121,6 +114,7 @@ export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResu
|
|
|
121
114
|
error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}`,
|
|
122
115
|
};
|
|
123
116
|
}
|
|
117
|
+
await writeFile(topicPath, withDesc, "utf8");
|
|
124
118
|
}
|
|
125
119
|
|
|
126
120
|
// write index
|
|
@@ -165,12 +159,13 @@ export async function doRemove(memoryDir: string, p: RemoveParams): Promise<Acti
|
|
|
165
159
|
const afterRemoval = removeEntrySection(raw, p.entry);
|
|
166
160
|
|
|
167
161
|
if (hasEntries(afterRemoval)) {
|
|
168
|
-
// Still has entries: update hook
|
|
162
|
+
// Still has entries: update hook + description from remaining entries, refresh date
|
|
169
163
|
const remaining = parseEntries(afterRemoval);
|
|
170
|
-
const newHook = remaining.
|
|
164
|
+
const newHook = remaining.map((e) => e.title).join("; ").slice(0, 150);
|
|
171
165
|
const nextEntries = updateHook(entries, topicFile, newHook);
|
|
172
|
-
const
|
|
173
|
-
|
|
166
|
+
const withDate = updateFrontmatterDate(afterRemoval, today());
|
|
167
|
+
const withDesc = replaceFrontmatterField(withDate, "description", newHook);
|
|
168
|
+
await writeFile(topicPath, withDesc, "utf8");
|
|
174
169
|
await writeFile(join(memoryDir, MEMORY_MD), `${serializeIndex(nextEntries)}\n`, "utf8");
|
|
175
170
|
} else {
|
|
176
171
|
// Last entry removed: delete topic file and remove from index
|
|
@@ -188,38 +183,6 @@ export async function doRemove(memoryDir: string, p: RemoveParams): Promise<Acti
|
|
|
188
183
|
});
|
|
189
184
|
}
|
|
190
185
|
|
|
191
|
-
export async function doRead(memoryDir: string, p: ReadParams): Promise<ReadResult> {
|
|
192
|
-
if (p.topic) {
|
|
193
|
-
const topicName = p.topic.endsWith(".md") ? p.topic : `${p.topic}.md`;
|
|
194
|
-
let topicPath: string;
|
|
195
|
-
try {
|
|
196
|
-
topicPath = safeTopicPath(memoryDir, topicName);
|
|
197
|
-
// biome-ignore lint/suspicious/noExplicitAny: error catch
|
|
198
|
-
} catch (e: any) {
|
|
199
|
-
return { ok: false, error: e.message };
|
|
200
|
-
}
|
|
201
|
-
try {
|
|
202
|
-
const content = await readFile(topicPath, "utf8");
|
|
203
|
-
return { ok: true, content };
|
|
204
|
-
} catch {
|
|
205
|
-
return { ok: false, error: `Topic "${p.topic}" not found` };
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (p.entry) {
|
|
209
|
-
const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== MEMORY_MD);
|
|
210
|
-
for (const f of files) {
|
|
211
|
-
const raw = await readFile(join(memoryDir, f), "utf8").catch(() => "");
|
|
212
|
-
const entries = parseEntries(raw);
|
|
213
|
-
const found = entries.find((e) => e.title === p.entry);
|
|
214
|
-
if (found) {
|
|
215
|
-
return { ok: true, content: `## ${found.title}\n\n${found.content}` };
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return { ok: false, error: `Entry "${p.entry}" not found in any topic` };
|
|
219
|
-
}
|
|
220
|
-
return { ok: false, error: "Either topic or entry must be provided" };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
186
|
export async function searchMemory(memoryDir: string, query: string): Promise<string> {
|
|
224
187
|
const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== MEMORY_MD);
|
|
225
188
|
const q = query.toLowerCase();
|
|
@@ -248,23 +211,94 @@ export interface MemoryToolDeps {
|
|
|
248
211
|
cwd: () => string;
|
|
249
212
|
}
|
|
250
213
|
|
|
214
|
+
export function createMemoryTools(
|
|
215
|
+
memoryDir: string,
|
|
216
|
+
cfg: { maxLines: number; maxBytes: number },
|
|
217
|
+
): ToolDefinition[] {
|
|
218
|
+
return [
|
|
219
|
+
{
|
|
220
|
+
name: "memory_add",
|
|
221
|
+
label: "Memory Add",
|
|
222
|
+
description:
|
|
223
|
+
"Add a new memory entry to a topic file. Creates the topic if it doesn't exist. Use memory_search and the 'ls'/'read' tools to check for existing topics first.",
|
|
224
|
+
parameters: Type.Object({
|
|
225
|
+
content: Type.String({ description: "Knowledge text to store." }),
|
|
226
|
+
topic: Type.String({ description: "Target topic filename, e.g. 'debugging.md'." }),
|
|
227
|
+
title: Type.String({
|
|
228
|
+
description:
|
|
229
|
+
"Descriptive, self-contained title. Only index lines are injected into prompts — make titles self-descriptive.",
|
|
230
|
+
}),
|
|
231
|
+
type: Type.Optional(
|
|
232
|
+
StringEnum(["user", "feedback", "project", "reference"] as const),
|
|
233
|
+
),
|
|
234
|
+
}),
|
|
235
|
+
async execute(
|
|
236
|
+
_id: string,
|
|
237
|
+
params: any,
|
|
238
|
+
_signal: AbortSignal | undefined,
|
|
239
|
+
_onUpdate: any,
|
|
240
|
+
_ctx: any,
|
|
241
|
+
) {
|
|
242
|
+
if (!params.content) throw new Error("content is required");
|
|
243
|
+
if (!params.topic) throw new Error("topic is required");
|
|
244
|
+
if (!params.title) throw new Error("title is required");
|
|
245
|
+
const r = await doAdd(memoryDir, {
|
|
246
|
+
content: params.content,
|
|
247
|
+
topic: params.topic,
|
|
248
|
+
title: params.title,
|
|
249
|
+
type: params.type,
|
|
250
|
+
maxLines: cfg.maxLines,
|
|
251
|
+
maxBytes: cfg.maxBytes,
|
|
252
|
+
});
|
|
253
|
+
if (!r.ok) throw new Error(r.error);
|
|
254
|
+
return {
|
|
255
|
+
details: {},
|
|
256
|
+
content: [{
|
|
257
|
+
type: "text",
|
|
258
|
+
text: `Added "${params.title}" to ${params.topic}. Index has ${r.entries?.length ?? 0} entries.`,
|
|
259
|
+
}],
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "memory_search",
|
|
265
|
+
label: "Memory Search",
|
|
266
|
+
description:
|
|
267
|
+
"Search all memory topic files for entries matching a query. Case-insensitive. Use this to find related memories before adding new ones.",
|
|
268
|
+
parameters: Type.Object({
|
|
269
|
+
query: Type.String({ description: "Search query." }),
|
|
270
|
+
}),
|
|
271
|
+
async execute(
|
|
272
|
+
_id: string,
|
|
273
|
+
params: any,
|
|
274
|
+
_signal: AbortSignal | undefined,
|
|
275
|
+
_onUpdate: any,
|
|
276
|
+
_ctx: any,
|
|
277
|
+
) {
|
|
278
|
+
if (!params.query) throw new Error("query is required");
|
|
279
|
+
const text = await searchMemory(memoryDir, params.query);
|
|
280
|
+
return { details: {}, content: [{ type: "text", text }] };
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
|
|
251
286
|
export function createMemoryTool(deps: MemoryToolDeps) {
|
|
252
287
|
return {
|
|
253
288
|
name: "memory",
|
|
254
289
|
label: "Memory",
|
|
255
290
|
description:
|
|
256
|
-
"Read/write project memory across sessions. action 'add' appends content under a topic (auto-created) as an entry; 'remove' deletes an entry by title; '
|
|
291
|
+
"Read/write project memory across sessions. action 'add' appends content under a topic (auto-created) as an entry; 'remove' deletes an entry by title; 'search' queries memory files or history sessions. IMPORTANT: only MEMORY.md index lines are injected into system prompts — entry titles must be self-contained and descriptive (topic file content is NOT injected automatically — it is auto-surfaced for relevant queries). Use built-in 'read' and 'ls' tools to read topic files and MEMORY.md.",
|
|
257
292
|
promptSnippet:
|
|
258
|
-
"Read/write project memory across sessions (add/remove/search
|
|
293
|
+
"Read/write project memory across sessions (add/remove/search). Only index titles are injected — make titles self-descriptive.",
|
|
259
294
|
promptGuidelines: [
|
|
260
295
|
"Use memory to persist project facts, user preferences, and lessons learned across sessions.",
|
|
261
296
|
"Use memory action 'add' with an explicit topic filename and a descriptive, self-contained entry title — only the index line (title + topic) is injected into future prompts, NOT the topic file content. The title alone must convey what was learned.",
|
|
262
297
|
"Use memory action 'search' with scope='sessions' to find past work in history sessions.",
|
|
263
|
-
"Use
|
|
264
|
-
"Auto-surfacing: relevant topic files are automatically selected and their content injected into the conversation context. Use 'read' to load additional topics when needed — you don't need to read what's already been surfaced.",
|
|
298
|
+
"Auto-surfacing: relevant topic files are automatically selected and their content injected into the conversation context. Use built-in 'read' and 'ls' to load additional topics when needed — you don't need to read what's already been surfaced.",
|
|
265
299
|
],
|
|
266
300
|
parameters: Type.Object({
|
|
267
|
-
action: StringEnum(["add", "remove", "search"
|
|
301
|
+
action: StringEnum(["add", "remove", "search"] as const),
|
|
268
302
|
// add
|
|
269
303
|
content: Type.Optional(Type.String({ description: "Knowledge text to store (add)." })),
|
|
270
304
|
topic: Type.Optional(
|
|
@@ -342,14 +376,6 @@ export function createMemoryTool(deps: MemoryToolDeps) {
|
|
|
342
376
|
}
|
|
343
377
|
break;
|
|
344
378
|
}
|
|
345
|
-
case "read": {
|
|
346
|
-
if (!params.topic && !params.entry) throw new Error("topic or entry is required for read");
|
|
347
|
-
const r = await doRead(dir, { topic: params.topic, entry: params.entry });
|
|
348
|
-
if (!r.ok) throw new Error(r.error);
|
|
349
|
-
// biome-ignore lint/style/noNonNullAssertion: content assertion
|
|
350
|
-
text = r.content!;
|
|
351
|
-
break;
|
|
352
|
-
}
|
|
353
379
|
default:
|
|
354
380
|
throw new Error(`Unknown action: ${params.action}`);
|
|
355
381
|
}
|
package/src/topic-file.ts
CHANGED
|
@@ -45,6 +45,11 @@ export function updateFrontmatterDate(raw: string, date: string): string {
|
|
|
45
45
|
return raw.replace(/^(---\n(?:.*\n)*?)updated: .+(\n---)/m, `$1updated: ${date}$2`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export function replaceFrontmatterField(raw: string, field: string, value: string): string {
|
|
49
|
+
const regex = new RegExp(`^(---\n(?:.*\n)*?)${field}: .+(\n)`, "m");
|
|
50
|
+
return raw.replace(regex, (_full: string, prefix: string, nl: string) => `${prefix}${field}: ${value}${nl}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
export function removeEntrySection(raw: string, title: string): string {
|
|
49
54
|
const marker = `## ${title}`;
|
|
50
55
|
// find start of this entry block
|