@yandy0725/pi-memory 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/README.zh.md +143 -0
- package/index.ts +105 -0
- package/package.json +43 -0
- package/src/config.ts +74 -0
- package/src/dream.ts +111 -0
- package/src/index-file.ts +60 -0
- package/src/inject.ts +18 -0
- package/src/memory-tool.ts +243 -0
- package/src/nudge.ts +45 -0
- package/src/paths.ts +41 -0
- package/src/session-search.ts +50 -0
- package/src/topic-file.ts +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# pi-memory
|
|
2
|
+
|
|
3
|
+
File-system driven persistent memory layer for pi coding agent. Stores project knowledge across sessions — facts, preferences, debugging history — in plain Markdown files under `~/.pi/memory/<project-hash>/`.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **One `memory` tool**, four actions: `add` (store knowledge), `replace` (edit existing), `remove` (delete), `search` (query memory or session history)
|
|
8
|
+
- **Topic-based file organization**: each `memory add` writes to a named `.md` file under the project's memory directory
|
|
9
|
+
- **`MEMORY.md` index** — auto-generated table of contents with line/byte capacity limits
|
|
10
|
+
- **Snapshot injection**: on every new session, the memory index is appended to the system prompt, keeping the agent aware of past work
|
|
11
|
+
- **`/dream` command**: launches a headless agent to deduplicate, merge, and consolidate all memory files
|
|
12
|
+
- **Dream nudge**: after N sessions or N hours, a gentle notification suggests running `/dream`
|
|
13
|
+
- **`/memory` command**: show status, toggle on/off, inspect index and topic files
|
|
14
|
+
- **Session search**: the `memory search scope=sessions` action queries past conversation history
|
|
15
|
+
- **Branch-safe**: memory directory is keyed by the git root (or absolute path), so forks share memory naturally
|
|
16
|
+
- **Path traversal protection**: topic files are validated against escaping the memory directory
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:@yandy0725/pi-memory
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or add to `~/.pi/agent/settings.json`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"packages": ["npm:@yandy0725/pi-memory"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Create `pi-memory.json` in the agent directory (`~/.pi/agent/pi-memory.json`) or the project `.pi/` directory (if trusted):
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"enabled": true,
|
|
39
|
+
"memoryDir": "~/.pi/memory",
|
|
40
|
+
"memIndexMaxLines": 200,
|
|
41
|
+
"memIndexMaxBytes": 25600,
|
|
42
|
+
"dream": {
|
|
43
|
+
"nudgeAfterSessions": 5,
|
|
44
|
+
"nudgeAfterHours": 24,
|
|
45
|
+
"model": "auto"
|
|
46
|
+
},
|
|
47
|
+
"sessionSearch": {
|
|
48
|
+
"maxSessions": 10,
|
|
49
|
+
"maxMatches": 5
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Key | Default | Description |
|
|
55
|
+
|-----|---------|-------------|
|
|
56
|
+
| `enabled` | `true` | Toggle the entire memory system on/off |
|
|
57
|
+
| `memoryDir` | `~/.pi/memory` | Root directory for all memory data |
|
|
58
|
+
| `memIndexMaxLines` | `200` | Max lines in `MEMORY.md` before capacity errors |
|
|
59
|
+
| `memIndexMaxBytes` | `25600` | Max bytes in `MEMORY.md` before capacity errors |
|
|
60
|
+
| `dream.nudgeAfterSessions` | `5` | Sessions since last dream before nudge is shown |
|
|
61
|
+
| `dream.nudgeAfterHours` | `24` | Hours since last dream before nudge is shown |
|
|
62
|
+
| `dream.model` | `"auto"` | Model for dream consolidation (`"auto"` = same as current, or `"provider/id"`) |
|
|
63
|
+
| `sessionSearch.maxSessions` | `10` | Max sessions to scan when searching history |
|
|
64
|
+
| `sessionSearch.maxMatches` | `5` | Max matches to return from history search |
|
|
65
|
+
|
|
66
|
+
Project-level config (`.pi/pi-memory.json`) is only loaded when the project is trusted.
|
|
67
|
+
|
|
68
|
+
## Tool reference
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
memory(action: "add" | "replace" | "remove" | "search",
|
|
72
|
+
content?, topic?, title?, description?,
|
|
73
|
+
old_text?, query?, scope?)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `add`
|
|
77
|
+
|
|
78
|
+
Stores content under a topic file and upserts the MEMORY.md index.
|
|
79
|
+
|
|
80
|
+
- **`content`** (required) — knowledge text to persist
|
|
81
|
+
- **`topic`** (required) — target filename, e.g. `"debugging.md"`. Auto-created if new
|
|
82
|
+
- **`title`** (optional) — short title for the index line (defaults to topic stem)
|
|
83
|
+
- **`description`** (optional) — one-line description (defaults to first ~80 chars of content)
|
|
84
|
+
|
|
85
|
+
### `replace`
|
|
86
|
+
|
|
87
|
+
Locates `old_text` as a substring and replaces it with `content`.
|
|
88
|
+
|
|
89
|
+
- **`old_text`** (required) — substring to find
|
|
90
|
+
- **`content`** (required) — replacement text
|
|
91
|
+
- **`topic`** (optional) — narrow search to a specific file; required when text appears in multiple locations
|
|
92
|
+
|
|
93
|
+
### `remove`
|
|
94
|
+
|
|
95
|
+
Locates `old_text` and deletes it. When the last content in a topic file is removed, the file and its index entry are cleaned up.
|
|
96
|
+
|
|
97
|
+
- **`old_text`** (required) — substring to delete
|
|
98
|
+
- **`topic`** (optional) — narrow search to a specific file
|
|
99
|
+
|
|
100
|
+
### `search`
|
|
101
|
+
|
|
102
|
+
Queries either memory files or session history.
|
|
103
|
+
|
|
104
|
+
- **`query`** (required) — search keyword
|
|
105
|
+
- **`scope`** (optional) — `"memory"` (default, scans topic files) or `"sessions"` (scans session history)
|
|
106
|
+
|
|
107
|
+
## Commands
|
|
108
|
+
|
|
109
|
+
### `/memory`
|
|
110
|
+
|
|
111
|
+
Show memory status (enabled/disabled, directory, index line count, topic files, last dream timestamp).
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
/memory — show status
|
|
115
|
+
/memory on — enable memory
|
|
116
|
+
/memory off — disable memory
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `/dream`
|
|
120
|
+
|
|
121
|
+
Launch a headless agent that reads all memory files, deduplicates entries, merges contradictions, updates outdated info, and reorganizes `MEMORY.md` to be concise. The model used can be configured via `dream.model` in `pi-memory.json` (`"auto"` uses the current conversation model; `"provider/id"` picks a specific model).
|
|
122
|
+
|
|
123
|
+
A confirmation dialog is shown before the consolidation begins. The result summary is shown as a notification when done.
|
|
124
|
+
|
|
125
|
+
Dream meta (timestamp, session count at dream) is persisted in `.dream-meta.json` inside the memory directory.
|
|
126
|
+
|
|
127
|
+
## File layout
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
~/.pi/memory/
|
|
131
|
+
<12-char-sha256>/
|
|
132
|
+
MEMORY.md — index: one line per topic file
|
|
133
|
+
.dream-meta.json — last dream timestamp + session count
|
|
134
|
+
debugging.md — user-created topic files
|
|
135
|
+
preferences.md
|
|
136
|
+
...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The hash is derived from the project's git root (or absolute path), ensuring each project gets its own memory namespace.
|
|
140
|
+
|
|
141
|
+
## Snapshot semantics
|
|
142
|
+
|
|
143
|
+
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; changes made via the `memory` tool during a session do not update the snapshot for that session, but take effect on the next one.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# pi-memory
|
|
2
|
+
|
|
3
|
+
基于文件系统的持久化记忆层,为 pi 编程代理提供跨会话的项目记忆。事实、偏好、调试历史等知识以纯 Markdown 文件形式存储在 `~/.pi/memory/<项目哈希>/` 下。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **一个 `memory` 工具**,四种操作:`add`(存储知识)、`replace`(编辑现有内容)、`remove`(删除)、`search`(查询记忆或会话历史)
|
|
8
|
+
- **基于主题的文件组织**:每次 `memory add` 都会向项目记忆目录下指定名称的 `.md` 文件写入内容
|
|
9
|
+
- **`MEMORY.md` 索引**:自动生成的目录,带有行数/字节容量限制
|
|
10
|
+
- **快照注入**:每个新会话启动时,记忆索引会追加到系统提示中,让代理始终感知过往工作
|
|
11
|
+
- **`/dream` 命令**:启动无头代理,对记忆文件进行去重、合并和整理
|
|
12
|
+
- **梦醒提醒**:经过 N 个会话或 N 小时后,温和通知建议运行 `/dream`
|
|
13
|
+
- **`/memory` 命令**:查看状态、开关记忆、检查索引和主题文件
|
|
14
|
+
- **会话搜索**:`memory search scope=sessions` 操作可检索过往对话历史
|
|
15
|
+
- **分支安全**:记忆目录以 git 根目录(或绝对路径)为键,分叉仓库自然共享记忆
|
|
16
|
+
- **路径穿越防护**:主题文件路径会验证是否逃逸记忆目录
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:@yandy0725/pi-memory
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
或在 `~/.pi/agent/settings.json` 中添加:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"packages": ["npm:@yandy0725/pi-memory"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 配置
|
|
33
|
+
|
|
34
|
+
在代理目录(`~/.pi/agent/pi-memory.json`)或项目的 `.pi/` 目录(需受信任)中创建 `pi-memory.json`:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"enabled": true,
|
|
39
|
+
"memoryDir": "~/.pi/memory",
|
|
40
|
+
"memIndexMaxLines": 200,
|
|
41
|
+
"memIndexMaxBytes": 25600,
|
|
42
|
+
"dream": {
|
|
43
|
+
"nudgeAfterSessions": 5,
|
|
44
|
+
"nudgeAfterHours": 24,
|
|
45
|
+
"model": "auto"
|
|
46
|
+
},
|
|
47
|
+
"sessionSearch": {
|
|
48
|
+
"maxSessions": 10,
|
|
49
|
+
"maxMatches": 5
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| 配置项 | 默认值 | 说明 |
|
|
55
|
+
|--------|--------|------|
|
|
56
|
+
| `enabled` | `true` | 开关整个记忆系统 |
|
|
57
|
+
| `memoryDir` | `~/.pi/memory` | 所有记忆数据的根目录 |
|
|
58
|
+
| `memIndexMaxLines` | `200` | `MEMORY.md` 的最大行数,超限报容量错误 |
|
|
59
|
+
| `memIndexMaxBytes` | `25600` | `MEMORY.md` 的最大字节数,超限报容量错误 |
|
|
60
|
+
| `dream.nudgeAfterSessions` | `5` | 距离上次整理经过的会话数,达到后展示提醒 |
|
|
61
|
+
| `dream.nudgeAfterHours` | `24` | 距离上次整理经过的小时数,达到后展示提醒 |
|
|
62
|
+
| `dream.model` | `"auto"` | 整理使用的模型(`"auto"` 使用当前模型,或 `"provider/id"` 指定模型) |
|
|
63
|
+
| `sessionSearch.maxSessions` | `10` | 搜索历史会话时最多扫描的会话数 |
|
|
64
|
+
| `sessionSearch.maxMatches` | `5` | 历史搜索最多返回的匹配数 |
|
|
65
|
+
|
|
66
|
+
项目级配置(`.pi/pi-memory.json`)仅在项目受信任时加载。
|
|
67
|
+
|
|
68
|
+
## 工具参考
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
memory(action: "add" | "replace" | "remove" | "search",
|
|
72
|
+
content?, topic?, title?, description?,
|
|
73
|
+
old_text?, query?, scope?)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `add`
|
|
77
|
+
|
|
78
|
+
将内容存储到主题文件并更新 MEMORY.md 索引。
|
|
79
|
+
|
|
80
|
+
- **`content`**(必填)— 要持久化的知识文本
|
|
81
|
+
- **`topic`**(必填)— 目标文件名,如 `"debugging.md"`,不存在则自动创建
|
|
82
|
+
- **`title`**(可选)— 索引行的短标题(默认取 topic 的文件名部分)
|
|
83
|
+
- **`description`**(可选)— 一行描述(默认取内容前 ~80 字符)
|
|
84
|
+
|
|
85
|
+
### `replace`
|
|
86
|
+
|
|
87
|
+
定位 `old_text` 子串并替换为 `content`。
|
|
88
|
+
|
|
89
|
+
- **`old_text`**(必填)— 要查找的子串
|
|
90
|
+
- **`content`**(必填)— 替换文本
|
|
91
|
+
- **`topic`**(可选)— 限定搜索范围到指定文件;当文本在多个位置出现时必须提供
|
|
92
|
+
|
|
93
|
+
### `remove`
|
|
94
|
+
|
|
95
|
+
定位 `old_text` 并删除。当主题文件的全部内容被删除后,文件及其索引条目会被自动清理。
|
|
96
|
+
|
|
97
|
+
- **`old_text`**(必填)— 要删除的子串
|
|
98
|
+
- **`topic`**(可选)— 限定搜索范围到指定文件
|
|
99
|
+
|
|
100
|
+
### `search`
|
|
101
|
+
|
|
102
|
+
查询记忆文件或会话历史。
|
|
103
|
+
|
|
104
|
+
- **`query`**(必填)— 搜索关键词
|
|
105
|
+
- **`scope`**(可选)— `"memory"`(默认,扫描主题文件)或 `"sessions"`(扫描会话历史)
|
|
106
|
+
|
|
107
|
+
## 命令
|
|
108
|
+
|
|
109
|
+
### `/memory`
|
|
110
|
+
|
|
111
|
+
显示记忆状态(启用/禁用、目录、索引行数、主题文件、上次整理时间戳)。
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
/memory — 显示状态
|
|
115
|
+
/memory on — 启用记忆
|
|
116
|
+
/memory off — 禁用记忆
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `/dream`
|
|
120
|
+
|
|
121
|
+
启动无头代理,读取所有记忆文件,去重、合并矛盾、更新过时信息、重组 `MEMORY.md`。使用的模型可通过 `pi-memory.json` 的 `dream.model` 配置(`"auto"` 使用当前会话模型;`"provider/id"` 指定特定模型)。
|
|
122
|
+
|
|
123
|
+
开始整理前会弹出确认对话框。完成时在通知中显示结果摘要。
|
|
124
|
+
|
|
125
|
+
整理元数据(时间戳、整理时会话计数)持久化在记忆目录下的 `.dream-meta.json` 中。
|
|
126
|
+
|
|
127
|
+
## 文件布局
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
~/.pi/memory/
|
|
131
|
+
<12位sha256哈希>/
|
|
132
|
+
MEMORY.md — 索引:每个主题文件一行
|
|
133
|
+
.dream-meta.json — 上次整理的时间戳和会话计数
|
|
134
|
+
debugging.md — 用户创建的主题文件
|
|
135
|
+
preferences.md
|
|
136
|
+
...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
哈希值由项目的 git 根目录(或绝对路径)派生,确保每个项目拥有独立的记忆命名空间。
|
|
140
|
+
|
|
141
|
+
## 快照语义
|
|
142
|
+
|
|
143
|
+
每次 `session_start` 时读取 `MEMORY.md` 索引,通过 `before_agent_start` 追加到系统提示中。如果索引超过 `memIndexMaxLines` 或 `memIndexMaxBytes`,将被截断并添加 `[truncated]` 标记——代理仍能获取到最相关的部分。此快照是会话开始时的静态副本;会话中通过 `memory` 工具所做的变更不会影响当次会话的快照,而是在下次会话中生效。
|
package/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig, type MemoryConfig } from "./src/config";
|
|
3
|
+
import { resolveMemoryDir } from "./src/paths";
|
|
4
|
+
import { loadIndexSnapshot, buildInjection } from "./src/inject";
|
|
5
|
+
import { createMemoryTool } from "./src/memory-tool";
|
|
6
|
+
import { searchSessions } from "./src/session-search";
|
|
7
|
+
import { runDream, resolveDreamModel } from "./src/dream";
|
|
8
|
+
import { shouldNudge, writeDreamMeta, readDreamMeta } from "./src/nudge";
|
|
9
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export default function (pi: ExtensionAPI) {
|
|
14
|
+
let memoryDir: string | null = null;
|
|
15
|
+
let config: MemoryConfig | null = null;
|
|
16
|
+
let indexSnapshot = "";
|
|
17
|
+
let toolRegistered = false;
|
|
18
|
+
|
|
19
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
20
|
+
config = await loadConfig(ctx);
|
|
21
|
+
if (!config.enabled) return;
|
|
22
|
+
memoryDir = await resolveMemoryDir(config, ctx.cwd);
|
|
23
|
+
indexSnapshot = await loadIndexSnapshot(memoryDir, config.memIndexMaxLines, config.memIndexMaxBytes);
|
|
24
|
+
|
|
25
|
+
// register memory tool once
|
|
26
|
+
if (!toolRegistered) {
|
|
27
|
+
pi.registerTool(
|
|
28
|
+
createMemoryTool({
|
|
29
|
+
getMemoryDir: () => memoryDir,
|
|
30
|
+
getConfig: () => config!,
|
|
31
|
+
getEnabled: () => config?.enabled ?? false,
|
|
32
|
+
searchSessions,
|
|
33
|
+
cwd: () => ctx.cwd,
|
|
34
|
+
}) as any,
|
|
35
|
+
);
|
|
36
|
+
toolRegistered = true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// nudge
|
|
40
|
+
if (ctx.hasUI) {
|
|
41
|
+
const { nudge, message } = await shouldNudge(memoryDir, config, ctx.cwd);
|
|
42
|
+
if (nudge) ctx.ui.notify(message, "info");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("before_agent_start", async (event) => {
|
|
47
|
+
if (!config?.enabled || !indexSnapshot) return;
|
|
48
|
+
return { systemPrompt: buildInjection(event.systemPrompt, indexSnapshot) };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
pi.registerCommand("memory", {
|
|
52
|
+
description: "Show memory status, toggle enabled, or open files",
|
|
53
|
+
handler: async (args, ctx) => {
|
|
54
|
+
if (!config || !memoryDir) {
|
|
55
|
+
ctx.ui.notify("Memory not initialized.", "info");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (args === "off" || args === "on") {
|
|
59
|
+
config = { ...config, enabled: args === "on" };
|
|
60
|
+
ctx.ui.notify(`Memory ${args}`, "info");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md"));
|
|
64
|
+
const indexRaw = await readFile(join(memoryDir, "MEMORY.md"), "utf8").catch(() => "");
|
|
65
|
+
const lineCount = indexRaw ? indexRaw.split("\n").filter(Boolean).length : 0;
|
|
66
|
+
const meta = await readDreamMeta(memoryDir);
|
|
67
|
+
const summary = [
|
|
68
|
+
`Memory: ${config.enabled ? "enabled" : "disabled"}`,
|
|
69
|
+
`Dir: ${memoryDir}`,
|
|
70
|
+
`Index: ${lineCount}/${config.memIndexMaxLines} lines`,
|
|
71
|
+
`Topic files: ${files.filter((f) => f !== "MEMORY.md").join(", ") || "none"}`,
|
|
72
|
+
`Last dream: ${meta?.lastDreamAt ?? "never"}`,
|
|
73
|
+
].join("\n");
|
|
74
|
+
ctx.ui.notify(summary, "info");
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
pi.registerCommand("dream", {
|
|
79
|
+
description: "Consolidate all memory files via a headless agent",
|
|
80
|
+
handler: async (_args, ctx) => {
|
|
81
|
+
if (!config || !memoryDir) {
|
|
82
|
+
ctx.ui.notify("Memory not initialized.", "info");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const ok = await ctx.ui.confirm("Dream", "Consolidate all memory files? This rewrites them in-place.");
|
|
86
|
+
if (!ok) return;
|
|
87
|
+
const model = resolveDreamModel(config, ctx);
|
|
88
|
+
if (!model) {
|
|
89
|
+
ctx.ui.notify("No model available for dream (check dream.model config / API key).", "error");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
ctx.ui.setStatus("dream", "Consolidating memory...");
|
|
93
|
+
try {
|
|
94
|
+
const summary = await runDream({ model, memoryDir, cwd: memoryDir, signal: ctx.signal });
|
|
95
|
+
const sessions = (await SessionManager.list(ctx.cwd)).length;
|
|
96
|
+
await writeDreamMeta(memoryDir, sessions);
|
|
97
|
+
ctx.ui.notify(summary, "info");
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
ctx.ui.notify(`Dream failed: ${e.message}`, "error");
|
|
100
|
+
} finally {
|
|
101
|
+
ctx.ui.setStatus("dream", undefined);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yandy0725/pi-memory",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"description": "File-system driven persistent memory layer for pi coding agent",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/yandy/pi-packages",
|
|
12
|
+
"directory": "pi-memory"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"index.ts",
|
|
20
|
+
"src/"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"lint": "biome lint .",
|
|
27
|
+
"format": "biome format --write ."
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-ai": ">=0.80.2",
|
|
36
|
+
"@earendil-works/pi-coding-agent": ">=0.80.2",
|
|
37
|
+
"@earendil-works/pi-tui": ">=0.80.2",
|
|
38
|
+
"typebox": "*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typebox": "^1.1.38"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export interface MemoryConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
memoryDir: string;
|
|
9
|
+
memIndexMaxLines: number;
|
|
10
|
+
memIndexMaxBytes: number;
|
|
11
|
+
dream: { nudgeAfterSessions: number; nudgeAfterHours: number; model: string };
|
|
12
|
+
sessionSearch: { maxSessions: number; maxMatches: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_CONFIG: MemoryConfig = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
memoryDir: join(homedir(), CONFIG_DIR_NAME, "memory"),
|
|
18
|
+
memIndexMaxLines: 200,
|
|
19
|
+
memIndexMaxBytes: 25600,
|
|
20
|
+
dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24, model: "auto" },
|
|
21
|
+
sessionSearch: { maxSessions: 10, maxMatches: 5 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function expandTilde(p: string): string {
|
|
25
|
+
if (p === "~") return homedir();
|
|
26
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deepMerge<T>(base: T, over: Partial<T>): T {
|
|
31
|
+
const out: any = { ...base };
|
|
32
|
+
for (const k of Object.keys(over) as (keyof T)[]) {
|
|
33
|
+
const ov = over[k] as any;
|
|
34
|
+
if (ov && typeof ov === "object" && !Array.isArray(ov) && typeof (out as any)[k] === "object") {
|
|
35
|
+
(out as any)[k] = deepMerge((out as any)[k], ov);
|
|
36
|
+
} else if (ov !== undefined) {
|
|
37
|
+
(out as any)[k] = ov;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readJsonSafe(path: string): Partial<MemoryConfig> {
|
|
44
|
+
try {
|
|
45
|
+
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf-8")) as Partial<MemoryConfig>;
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore malformed
|
|
48
|
+
}
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LoadConfigContext {
|
|
53
|
+
cwd: string;
|
|
54
|
+
isProjectTrusted(): boolean;
|
|
55
|
+
_globalDir?: string;
|
|
56
|
+
_configDirName?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadConfig(ctx: LoadConfigContext): Promise<MemoryConfig> {
|
|
60
|
+
const agentDir = ctx._globalDir ?? getAgentDir();
|
|
61
|
+
const configDirName = ctx._configDirName ?? CONFIG_DIR_NAME;
|
|
62
|
+
let cfg: MemoryConfig = { ...DEFAULT_CONFIG };
|
|
63
|
+
|
|
64
|
+
const globalFile = join(agentDir, "pi-memory.json");
|
|
65
|
+
cfg = deepMerge(cfg, readJsonSafe(globalFile));
|
|
66
|
+
|
|
67
|
+
if (ctx.isProjectTrusted()) {
|
|
68
|
+
const projectFile = join(ctx.cwd, configDirName, "pi-memory.json");
|
|
69
|
+
cfg = deepMerge(cfg, readJsonSafe(projectFile));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cfg.memoryDir = expandTilde(cfg.memoryDir);
|
|
73
|
+
return cfg;
|
|
74
|
+
}
|
package/src/dream.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAgentSession,
|
|
3
|
+
SessionManager,
|
|
4
|
+
DefaultResourceLoader,
|
|
5
|
+
SettingsManager,
|
|
6
|
+
AuthStorage,
|
|
7
|
+
ModelRegistry,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { mkdtemp } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import type { MemoryConfig } from "./config";
|
|
13
|
+
|
|
14
|
+
export const DREAM_SYSTEM_PROMPT = `You are a memory consolidation agent. Your job: read all memory files in the given directory, deduplicate entries, merge contradictions, update outdated info, and reorganize the MEMORY.md index to be concise and accurate.
|
|
15
|
+
Rules:
|
|
16
|
+
- Only modify files under the given directory. Never touch anything else.
|
|
17
|
+
- Preserve all valuable knowledge; only remove true duplicates or outdated facts.
|
|
18
|
+
- Keep MEMORY.md within the stated line limit; each line: - [Title](file.md) — description.
|
|
19
|
+
- Write specific descriptions so the index alone tells what each file holds.
|
|
20
|
+
- When done, output a concise summary of changes (merged N, removed N, updated N).`;
|
|
21
|
+
|
|
22
|
+
export function buildDreamTask(memoryDir: string, maxLines: number): string {
|
|
23
|
+
return `Consolidate the memory files under ${memoryDir}. Read every .md file (including MEMORY.md), then:
|
|
24
|
+
1. Deduplicate entries that say the same thing.
|
|
25
|
+
2. Merge contradictory or overlapping entries into one accurate entry.
|
|
26
|
+
3. Update outdated information.
|
|
27
|
+
4. Reorganize MEMORY.md so it stays <= ${maxLines} lines, one pointer per topic file: - [Title](file.md) — description.
|
|
28
|
+
Only edit files under ${memoryDir}. When finished, print a one-line summary of changes.`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveDreamModel(config: MemoryConfig, ctx: { model: any; modelRegistry: any }): any | null {
|
|
32
|
+
if (config.dream.model === "auto") return ctx.model;
|
|
33
|
+
const slash = config.dream.model.indexOf("/");
|
|
34
|
+
if (slash === -1) return null;
|
|
35
|
+
const provider = config.dream.model.slice(0, slash);
|
|
36
|
+
const id = config.dream.model.slice(slash + 1);
|
|
37
|
+
return ctx.modelRegistry?.find(provider, id) ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function extractSummary(messages: any[]): string {
|
|
41
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
42
|
+
const m = messages[i];
|
|
43
|
+
if (m.role === "assistant") {
|
|
44
|
+
if (typeof m.content === "string") return m.content;
|
|
45
|
+
const t = (m.content || []).find((b: any) => b.type === "text");
|
|
46
|
+
if (t) return t.text;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return "Dream completed.";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RunDreamOpts {
|
|
53
|
+
model: any;
|
|
54
|
+
memoryDir: string;
|
|
55
|
+
cwd: string;
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
createSession?: typeof createAgentSession;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runDream(opts: RunDreamOpts): Promise<string> {
|
|
61
|
+
const createSession = opts.createSession ?? createAgentSession;
|
|
62
|
+
// DI: when opts.createSession is provided (test path), skip real loader/auth construction.
|
|
63
|
+
// When not provided (production), build the real isolated DefaultResourceLoader + AuthStorage.
|
|
64
|
+
const useReal = !opts.createSession;
|
|
65
|
+
const loader = useReal
|
|
66
|
+
? await (async () => {
|
|
67
|
+
const isolatedAgentDir = await mkdtemp(join(tmpdir(), "pi-dream-"));
|
|
68
|
+
const l = new DefaultResourceLoader({
|
|
69
|
+
cwd: opts.memoryDir,
|
|
70
|
+
agentDir: isolatedAgentDir,
|
|
71
|
+
settingsManager: SettingsManager.inMemory({}),
|
|
72
|
+
systemPromptOverride: () => DREAM_SYSTEM_PROMPT,
|
|
73
|
+
});
|
|
74
|
+
await l.reload();
|
|
75
|
+
return l;
|
|
76
|
+
})()
|
|
77
|
+
: undefined;
|
|
78
|
+
const authStorage = useReal ? AuthStorage.create() : undefined;
|
|
79
|
+
|
|
80
|
+
const { session } = await createSession({
|
|
81
|
+
model: opts.model,
|
|
82
|
+
...(authStorage ? { authStorage } : {}),
|
|
83
|
+
...(useReal ? { modelRegistry: ModelRegistry.create(authStorage!) } : {}),
|
|
84
|
+
tools: ["read", "edit", "write"],
|
|
85
|
+
cwd: opts.memoryDir,
|
|
86
|
+
sessionManager: useReal ? SessionManager.inMemory(opts.memoryDir) : undefined,
|
|
87
|
+
...(loader ? { resourceLoader: loader } : {}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
let summary = "Dream completed.";
|
|
91
|
+
const unsub = session.subscribe((e: any) => {
|
|
92
|
+
if (e.type === "agent_end") {
|
|
93
|
+
summary = extractSummary(session.messages);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const onAbort = () => session.abort?.();
|
|
98
|
+
opts.signal?.addEventListener("abort", onAbort);
|
|
99
|
+
try {
|
|
100
|
+
await session.prompt(buildDreamTask(opts.memoryDir, 200));
|
|
101
|
+
// Extract from messages as fallback when agent_end didn't fire (test path / edge case).
|
|
102
|
+
if (summary === "Dream completed." && session.messages?.length) {
|
|
103
|
+
summary = extractSummary(session.messages);
|
|
104
|
+
}
|
|
105
|
+
return summary;
|
|
106
|
+
} finally {
|
|
107
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
108
|
+
unsub();
|
|
109
|
+
session.dispose();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface IndexEntry {
|
|
2
|
+
title: string;
|
|
3
|
+
topic: string;
|
|
4
|
+
description: string;
|
|
5
|
+
raw: string;
|
|
6
|
+
}
|
|
7
|
+
export interface IndexFile {
|
|
8
|
+
entries: IndexEntry[];
|
|
9
|
+
raw: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Matches: - [Title](topic.md) — description (em-dash U+2014 or --)
|
|
13
|
+
const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*[—-]{1,2}\s*(.+)$/;
|
|
14
|
+
|
|
15
|
+
export function parseIndex(content: string): IndexFile {
|
|
16
|
+
const entries: IndexEntry[] = [];
|
|
17
|
+
for (const line of content.split("\n")) {
|
|
18
|
+
const m = line.match(LINE_RE);
|
|
19
|
+
if (m) entries.push({ title: m[1].trim(), topic: m[2].trim(), description: m[3].trim(), raw: line });
|
|
20
|
+
}
|
|
21
|
+
return { entries, raw: content };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function serializeIndex(entries: IndexEntry[]): string {
|
|
25
|
+
return entries.map((e) => `- [${e.title}](${e.topic}) — ${e.description}`).join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function upsertEntry(entries: IndexEntry[], entry: IndexEntry): IndexEntry[] {
|
|
29
|
+
const idx = entries.findIndex((e) => e.topic === entry.topic);
|
|
30
|
+
if (idx === -1) return [...entries, entry];
|
|
31
|
+
const next = [...entries];
|
|
32
|
+
next[idx] = { ...entry };
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function truncateForInjection(content: string, maxLines: number, maxBytes: number): { ok: boolean; content: string; truncated: boolean } {
|
|
37
|
+
const lines = content.split("\n");
|
|
38
|
+
let out = content;
|
|
39
|
+
let truncated = false;
|
|
40
|
+
if (lines.length > maxLines) {
|
|
41
|
+
out = lines.slice(0, maxLines).join("\n");
|
|
42
|
+
truncated = true;
|
|
43
|
+
}
|
|
44
|
+
if (Buffer.byteLength(out, "utf8") > maxBytes) {
|
|
45
|
+
// cut by bytes
|
|
46
|
+
let cut = out;
|
|
47
|
+
while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
|
|
48
|
+
out = cut;
|
|
49
|
+
truncated = true;
|
|
50
|
+
}
|
|
51
|
+
if (truncated) out += `\n[truncated: memory index exceeds injection limit]`;
|
|
52
|
+
return { ok: !truncated, content: out, truncated };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkCapacity(entries: IndexEntry[], maxLines: number, maxBytes: number): boolean {
|
|
56
|
+
const serialized = serializeIndex(entries);
|
|
57
|
+
if (entries.length > maxLines) return false;
|
|
58
|
+
if (Buffer.byteLength(serialized, "utf8") > maxBytes) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
package/src/inject.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { truncateForInjection } from "./index-file";
|
|
4
|
+
|
|
5
|
+
export async function loadIndexSnapshot(memoryDir: string, maxLines: number, maxBytes: number): Promise<string> {
|
|
6
|
+
try {
|
|
7
|
+
const raw = await readFile(join(memoryDir, "MEMORY.md"), "utf8");
|
|
8
|
+
const { content } = truncateForInjection(raw, maxLines, maxBytes);
|
|
9
|
+
return content ? `# Memory Index\n${content}` : "";
|
|
10
|
+
} catch {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildInjection(systemPrompt: string, snapshot: string): string {
|
|
16
|
+
if (!snapshot) return systemPrompt;
|
|
17
|
+
return `${systemPrompt}\n\n${snapshot}`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import { parseIndex, serializeIndex, upsertEntry, checkCapacity, type IndexEntry } from "./index-file";
|
|
8
|
+
import { buildFrontmatter, appendContent, isEmptyAfterRemove } from "./topic-file";
|
|
9
|
+
import { safeTopicPath } from "./paths";
|
|
10
|
+
|
|
11
|
+
export interface AddParams { content: string; topic: string; title?: string; description?: string; maxLines: number; maxBytes: number; }
|
|
12
|
+
export interface ReplaceParams { old_text: string; content: string; topic?: string; }
|
|
13
|
+
export interface RemoveParams { old_text: string; topic?: string; }
|
|
14
|
+
export interface ActionResult { ok: boolean; error?: string; entries?: IndexEntry[]; }
|
|
15
|
+
|
|
16
|
+
const MEMORY_MD = "MEMORY.md";
|
|
17
|
+
|
|
18
|
+
async function readIndex(memoryDir: string): Promise<IndexEntry[]> {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(join(memoryDir, MEMORY_MD), "utf8");
|
|
21
|
+
return parseIndex(raw).entries;
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function slug(s: string): string {
|
|
28
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "memory";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function today(): string {
|
|
32
|
+
return new Date().toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResult> {
|
|
36
|
+
let topicPath: string;
|
|
37
|
+
try {
|
|
38
|
+
topicPath = safeTopicPath(memoryDir, p.topic);
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
return { ok: false, error: e.message };
|
|
41
|
+
}
|
|
42
|
+
return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
|
|
43
|
+
await mkdir(dirname(topicPath), { recursive: true });
|
|
44
|
+
const entries = await readIndex(memoryDir);
|
|
45
|
+
const title = p.title ?? p.topic.replace(/\.md$/i, "");
|
|
46
|
+
const description = p.description ?? p.content.split("\n")[0].slice(0, 80);
|
|
47
|
+
const next = upsertEntry(entries, { title, topic: p.topic, description, raw: "" });
|
|
48
|
+
if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
|
|
49
|
+
return { ok: false, error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}` };
|
|
50
|
+
}
|
|
51
|
+
// write topic file
|
|
52
|
+
let existing: string | null = null;
|
|
53
|
+
try { existing = await readFile(topicPath, "utf8"); } catch { existing = null; }
|
|
54
|
+
const isNew = !existing;
|
|
55
|
+
const out = isNew
|
|
56
|
+
? `${buildFrontmatter({ name: slug(title), description, type: "project", updated: today() })}${appendContent(null, title, p.content)}`
|
|
57
|
+
: appendContent(existing, title, p.content);
|
|
58
|
+
await writeFile(topicPath, out, "utf8");
|
|
59
|
+
// write index
|
|
60
|
+
await writeFile(join(memoryDir, MEMORY_MD), serializeIndex(next) + "\n", "utf8");
|
|
61
|
+
return { ok: true, entries: next };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface MatchSite { file: string; type: "index" | "topic"; }
|
|
66
|
+
|
|
67
|
+
async function findMatches(memoryDir: string, old_text: string, topic?: string): Promise<MatchSite[]> {
|
|
68
|
+
const sites: MatchSite[] = [];
|
|
69
|
+
// index
|
|
70
|
+
const idxRaw = await readFile(join(memoryDir, MEMORY_MD), "utf8").catch(() => "");
|
|
71
|
+
if (idxRaw.includes(old_text)) sites.push({ file: MEMORY_MD, type: "index" });
|
|
72
|
+
// topics
|
|
73
|
+
const files = topic ? [topic] : (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== MEMORY_MD);
|
|
74
|
+
for (const f of files) {
|
|
75
|
+
const c = await readFile(join(memoryDir, f), "utf8").catch(() => "");
|
|
76
|
+
if (c.includes(old_text)) sites.push({ file: f, type: "topic" });
|
|
77
|
+
}
|
|
78
|
+
return sites;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function doReplace(memoryDir: string, p: ReplaceParams): Promise<ActionResult> {
|
|
82
|
+
if (p.topic) {
|
|
83
|
+
try { safeTopicPath(memoryDir, p.topic); } catch { return { ok: false, error: "Unsafe topic path" }; }
|
|
84
|
+
}
|
|
85
|
+
const sites = await findMatches(memoryDir, p.old_text, p.topic);
|
|
86
|
+
if (sites.length === 0) return { ok: false, error: `No match for old_text` };
|
|
87
|
+
if (sites.length > 1) return { ok: false, error: `Multiple matches (${sites.length}); specify topic. Sites: ${sites.map((s) => s.file).join(", ")}` };
|
|
88
|
+
const site = sites[0];
|
|
89
|
+
const filePath = join(memoryDir, site.file);
|
|
90
|
+
return withFileMutationQueue(filePath, async () => {
|
|
91
|
+
const raw = await readFile(filePath, "utf8");
|
|
92
|
+
const next = raw.replace(p.old_text, p.content);
|
|
93
|
+
await writeFile(filePath, next, "utf8");
|
|
94
|
+
return { ok: true };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function doRemove(memoryDir: string, p: RemoveParams): Promise<ActionResult> {
|
|
99
|
+
// When no topic is specified, search only the index (doRemove semantics:
|
|
100
|
+
// removing an entry from MEMORY.md). When topic is given, search in
|
|
101
|
+
// that topic file plus the index.
|
|
102
|
+
let sites: MatchSite[];
|
|
103
|
+
if (p.topic) {
|
|
104
|
+
try { safeTopicPath(memoryDir, p.topic); } catch { return { ok: false, error: "Unsafe topic path" }; }
|
|
105
|
+
sites = await findMatches(memoryDir, p.old_text, p.topic);
|
|
106
|
+
} else {
|
|
107
|
+
const idxRaw = await readFile(join(memoryDir, MEMORY_MD), "utf8").catch(() => "");
|
|
108
|
+
sites = idxRaw.includes(p.old_text) ? [{ file: MEMORY_MD, type: "index" }] : [];
|
|
109
|
+
}
|
|
110
|
+
if (sites.length === 0) return { ok: false, error: `No match for old_text` };
|
|
111
|
+
if (sites.length > 1) return { ok: false, error: `Multiple matches (${sites.length}); specify topic. Sites: ${sites.map((s) => s.file).join(", ")}` };
|
|
112
|
+
const site = sites[0];
|
|
113
|
+
const filePath = join(memoryDir, site.file);
|
|
114
|
+
return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
|
|
115
|
+
if (site.type === "index") {
|
|
116
|
+
const raw = await readFile(filePath, "utf8");
|
|
117
|
+
const lines = raw.split("\n").filter((l) => !l.includes(p.old_text));
|
|
118
|
+
await writeFile(filePath, lines.join("\n").replace(/\n{3,}/g, "\n\n"), "utf8");
|
|
119
|
+
} else {
|
|
120
|
+
const raw = await readFile(filePath, "utf8");
|
|
121
|
+
const next = raw.replace(p.old_text, "");
|
|
122
|
+
if (isEmptyAfterRemove(next)) {
|
|
123
|
+
await unlink(filePath).catch(() => {});
|
|
124
|
+
// also drop its index line
|
|
125
|
+
const idxRaw = await readFile(join(memoryDir, MEMORY_MD), "utf8").catch(() => "");
|
|
126
|
+
if (idxRaw) {
|
|
127
|
+
const lines = idxRaw.split("\n").filter((l) => !l.includes(`](${site.file})`));
|
|
128
|
+
await writeFile(join(memoryDir, MEMORY_MD), lines.join("\n"), "utf8");
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
await writeFile(filePath, next, "utf8");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { ok: true };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// search memory scope — implemented fully in Task 9
|
|
139
|
+
export async function searchMemory(memoryDir: string, query: string): Promise<string> {
|
|
140
|
+
const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== MEMORY_MD);
|
|
141
|
+
const q = query.toLowerCase();
|
|
142
|
+
const hits: string[] = [];
|
|
143
|
+
for (const f of files) {
|
|
144
|
+
const lines = (await readFile(join(memoryDir, f), "utf8").catch(() => "")).split("\n");
|
|
145
|
+
for (const [i, line] of lines.entries()) {
|
|
146
|
+
if (line.toLowerCase().includes(q)) {
|
|
147
|
+
const ctx = lines.slice(Math.max(0, i - 2), i + 3).join("\n");
|
|
148
|
+
hits.push(`### ${f}\n\`\`\`\n${ctx}\n\`\`\``);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return hits.length ? hits.join("\n\n") : "No matches in memory.";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface MemoryToolDeps {
|
|
156
|
+
getMemoryDir: () => string | null;
|
|
157
|
+
getConfig: () => { memIndexMaxLines: number; memIndexMaxBytes: number; sessionSearch: { maxSessions: number; maxMatches: number } };
|
|
158
|
+
getEnabled: () => boolean;
|
|
159
|
+
searchSessions: (cwd: string, query: string, cfg: { maxSessions: number; maxMatches: number }) => Promise<string>;
|
|
160
|
+
cwd: () => string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createMemoryTool(deps: MemoryToolDeps) {
|
|
164
|
+
return {
|
|
165
|
+
name: "memory",
|
|
166
|
+
label: "Memory",
|
|
167
|
+
description:
|
|
168
|
+
"Read/write project memory across sessions. action 'add' stores content under a topic file (auto-created) and upserts the MEMORY.md index; 'replace'/'remove' locate by substring (old_text); 'search' queries memory files or history sessions (scope: memory|sessions).",
|
|
169
|
+
promptSnippet: "Read/write project memory across sessions (add/replace/remove/search).",
|
|
170
|
+
promptGuidelines: [
|
|
171
|
+
"Use memory to persist project facts, user preferences, and lessons learned across sessions.",
|
|
172
|
+
"Use memory action 'add' with an explicit topic filename when you discover something worth remembering long-term.",
|
|
173
|
+
"Use memory action 'search' with scope='sessions' to find past work in history sessions.",
|
|
174
|
+
],
|
|
175
|
+
parameters: Type.Object({
|
|
176
|
+
action: StringEnum(["add", "replace", "remove", "search"] as const),
|
|
177
|
+
content: Type.Optional(Type.String({ description: "Knowledge text to store (add) or replacement text (replace)" })),
|
|
178
|
+
topic: Type.Optional(Type.String({ description: "Target topic filename, e.g. 'debugging.md'. Auto-created if new (add)." })),
|
|
179
|
+
title: Type.Optional(Type.String({ description: "Short title for the MEMORY.md index line (add). Defaults to topic stem." })),
|
|
180
|
+
description: Type.Optional(Type.String({ description: "One-line description for the MEMORY.md index line (add). Defaults to first line of content truncated ~80 chars." })),
|
|
181
|
+
old_text: Type.Optional(Type.String({ description: "Substring to locate (replace/remove). Matched against topic files and MEMORY.md index lines." })),
|
|
182
|
+
query: Type.Optional(Type.String()),
|
|
183
|
+
scope: Type.Optional(StringEnum(["memory", "sessions"] as const)),
|
|
184
|
+
}),
|
|
185
|
+
renderCall(args: any, theme: any) {
|
|
186
|
+
let t = theme.fg("toolTitle", theme.bold("memory ")) + theme.fg("muted", args.action);
|
|
187
|
+
if (args.topic) t += ` ${theme.fg("accent", args.topic)}`;
|
|
188
|
+
if (args.query) t += ` ${theme.fg("dim", `"${args.query}"`)}`;
|
|
189
|
+
return new Text(t, 0, 0);
|
|
190
|
+
},
|
|
191
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
192
|
+
const txt = result.content?.[0];
|
|
193
|
+
const text = txt?.type === "text" ? txt.text : "";
|
|
194
|
+
if (result.details?.error) return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
|
|
195
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text.split("\n")[0]), 0, 0);
|
|
196
|
+
},
|
|
197
|
+
async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, _ctx: any) {
|
|
198
|
+
if (!deps.getEnabled()) throw new Error("Memory is disabled (run /memory on)");
|
|
199
|
+
const dir = deps.getMemoryDir();
|
|
200
|
+
const cfg = deps.getConfig();
|
|
201
|
+
if (!dir) throw new Error("Memory not initialized (no session_start yet)");
|
|
202
|
+
let text: string;
|
|
203
|
+
let details: any = {};
|
|
204
|
+
switch (params.action) {
|
|
205
|
+
case "add": {
|
|
206
|
+
if (!params.content) throw new Error("content is required for add");
|
|
207
|
+
if (!params.topic) throw new Error("topic is required for add");
|
|
208
|
+
const r = await doAdd(dir, { content: params.content, topic: params.topic, title: params.title, description: params.description, maxLines: cfg.memIndexMaxLines, maxBytes: cfg.memIndexMaxBytes });
|
|
209
|
+
if (!r.ok) throw new Error(r.error);
|
|
210
|
+
text = `Added to ${params.topic}. Index now has ${r.entries?.length ?? 0} entries.`;
|
|
211
|
+
details = { entries: r.entries?.length };
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "replace": {
|
|
215
|
+
if (!params.old_text || !params.content) throw new Error("old_text and content are required for replace");
|
|
216
|
+
const r = await doReplace(dir, { old_text: params.old_text, content: params.content, topic: params.topic });
|
|
217
|
+
if (!r.ok) throw new Error(r.error);
|
|
218
|
+
text = "Replaced.";
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case "remove": {
|
|
222
|
+
if (!params.old_text) throw new Error("old_text is required for remove");
|
|
223
|
+
const r = await doRemove(dir, { old_text: params.old_text, topic: params.topic });
|
|
224
|
+
if (!r.ok) throw new Error(r.error);
|
|
225
|
+
text = "Removed.";
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case "search": {
|
|
229
|
+
if (!params.query) throw new Error("query is required for search");
|
|
230
|
+
if (params.scope === "sessions") {
|
|
231
|
+
text = await deps.searchSessions(deps.cwd(), params.query, cfg.sessionSearch);
|
|
232
|
+
} else {
|
|
233
|
+
text = await searchMemory(dir, params.query);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
default:
|
|
238
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
239
|
+
}
|
|
240
|
+
return { content: [{ type: "text", text }], details };
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
package/src/nudge.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { MemoryConfig } from "./config";
|
|
5
|
+
|
|
6
|
+
export interface DreamMeta { lastDreamAt: string; sessionCountAtDream: number; }
|
|
7
|
+
|
|
8
|
+
const META_FILE = ".dream-meta.json";
|
|
9
|
+
|
|
10
|
+
export async function readDreamMeta(memoryDir: string): Promise<DreamMeta | null> {
|
|
11
|
+
try {
|
|
12
|
+
const raw = await readFile(join(memoryDir, META_FILE), "utf8");
|
|
13
|
+
return JSON.parse(raw) as DreamMeta;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function writeDreamMeta(memoryDir: string, sessionCount: number): Promise<void> {
|
|
20
|
+
const meta: DreamMeta = { lastDreamAt: new Date().toISOString(), sessionCountAtDream: sessionCount };
|
|
21
|
+
await writeFile(join(memoryDir, META_FILE), JSON.stringify(meta), "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatNudge(sessions: number, newEntries: number): string {
|
|
25
|
+
return `💡 ${sessions} sessions, ${newEntries} new entries since last dream. /dream`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function shouldNudge(
|
|
29
|
+
memoryDir: string,
|
|
30
|
+
config: MemoryConfig,
|
|
31
|
+
cwd: string,
|
|
32
|
+
): Promise<{ nudge: boolean; message: string }> {
|
|
33
|
+
const sessions = (await SessionManager.list(cwd)).length;
|
|
34
|
+
const meta = await readDreamMeta(memoryDir);
|
|
35
|
+
const newEntries = meta ? Math.max(0, sessions - meta.sessionCountAtDream) : sessions;
|
|
36
|
+
if (meta) {
|
|
37
|
+
const hoursSince = (Date.now() - new Date(meta.lastDreamAt).getTime()) / 3600_000;
|
|
38
|
+
if (hoursSince >= config.dream.nudgeAfterHours && newEntries >= config.dream.nudgeAfterSessions) {
|
|
39
|
+
return { nudge: true, message: formatNudge(sessions, newEntries) };
|
|
40
|
+
}
|
|
41
|
+
} else if (sessions >= config.dream.nudgeAfterSessions) {
|
|
42
|
+
return { nudge: true, message: formatNudge(sessions, newEntries) };
|
|
43
|
+
}
|
|
44
|
+
return { nudge: false, message: "" };
|
|
45
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { join, resolve, normalize, sep } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileP = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
async function gitToplevel(cwd: string): Promise<string | null> {
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execFileP("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 3000 });
|
|
11
|
+
return stdout.trim() || null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function projectHash(cwd: string): Promise<string> {
|
|
18
|
+
const key = (await gitToplevel(cwd)) ?? resolve(cwd);
|
|
19
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 12);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolveMemoryDir(
|
|
23
|
+
config: { memoryDir: string },
|
|
24
|
+
cwd: string,
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const hash = await projectHash(cwd);
|
|
27
|
+
return join(config.memoryDir, hash);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function safeTopicPath(memoryDir: string, topic: string): string {
|
|
31
|
+
const normalized = normalize(topic);
|
|
32
|
+
if (normalized.includes("..") || normalized.startsWith(sep)) {
|
|
33
|
+
throw new Error(`Unsafe topic path: ${topic}`);
|
|
34
|
+
}
|
|
35
|
+
const resolved = resolve(memoryDir, normalized);
|
|
36
|
+
const resolvedMemoryDir = resolve(memoryDir);
|
|
37
|
+
if (!resolved.startsWith(resolvedMemoryDir + sep) && resolved !== resolvedMemoryDir) {
|
|
38
|
+
throw new Error(`Topic escapes memory dir: ${topic}`);
|
|
39
|
+
}
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SessionManager, truncateHead } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
function extractText(content: any): string {
|
|
6
|
+
if (typeof content === "string") return content;
|
|
7
|
+
if (Array.isArray(content)) {
|
|
8
|
+
return content.filter((b: any) => b.type === "text").map((b: any) => b.text).join(" ");
|
|
9
|
+
}
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function searchSessions(
|
|
14
|
+
cwd: string,
|
|
15
|
+
query: string,
|
|
16
|
+
config: { maxSessions: number; maxMatches: number },
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const sessions = (await SessionManager.list(cwd)).slice(0, config.maxSessions);
|
|
19
|
+
const q = query.toLowerCase();
|
|
20
|
+
const hits: string[] = [];
|
|
21
|
+
for (const s of sessions) {
|
|
22
|
+
const raw = await readFile(s.path, "utf8").catch(() => "");
|
|
23
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
24
|
+
// gather message texts in order for context windows
|
|
25
|
+
const msgs: { text: string; idx: number }[] = [];
|
|
26
|
+
for (const [i, line] of lines.entries()) {
|
|
27
|
+
let entry: any;
|
|
28
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
29
|
+
if (entry.type === "message" && entry.message) {
|
|
30
|
+
const t = extractText(entry.message.content);
|
|
31
|
+
if (t) msgs.push({ text: t, idx: i });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const m of msgs) {
|
|
35
|
+
if (m.text.toLowerCase().includes(q)) {
|
|
36
|
+
const ctx = msgs
|
|
37
|
+
.filter((x) => Math.abs(x.idx - m.idx) <= 50)
|
|
38
|
+
.map((x) => ` ${x.text.slice(0, 200)}`)
|
|
39
|
+
.join("\n");
|
|
40
|
+
hits.push(`## Session ${basename(s.path)} (${s.modified.toISOString().slice(0, 10)})\n…matched: "${m.text.slice(0, 150)}"…\ncontext:\n${ctx}`);
|
|
41
|
+
if (hits.length >= config.maxMatches) break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (hits.length >= config.maxMatches) break;
|
|
45
|
+
}
|
|
46
|
+
if (!hits.length) return "No matches in sessions.";
|
|
47
|
+
const out = `Found ${hits.length} match(es):\n\n${hits.join("\n\n")}`;
|
|
48
|
+
const trunc = truncateHead(out, { maxLines: 2000, maxBytes: 50000 });
|
|
49
|
+
return trunc.content;
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface TopicMeta {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
type: string;
|
|
5
|
+
updated: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function buildFrontmatter(meta: TopicMeta): string {
|
|
9
|
+
return `---
|
|
10
|
+
name: ${meta.name}
|
|
11
|
+
description: ${meta.description}
|
|
12
|
+
type: ${meta.type}
|
|
13
|
+
updated: ${meta.updated}
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function appendContent(existing: string | null, heading: string, content: string): string {
|
|
20
|
+
const section = `## ${heading}\n\n${content}`;
|
|
21
|
+
if (!existing || existing.trim() === "") return `# ${heading}\n\n${content}`;
|
|
22
|
+
return `${existing.trimEnd()}\n\n${section}\n`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isEmptyAfterRemove(content: string): boolean {
|
|
26
|
+
return content.trim() === "";
|
|
27
|
+
}
|