@yandy0725/pi-memory 1.4.0 → 2.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 +245 -140
- package/README.zh.md +264 -157
- package/index.ts +460 -117
- package/package.json +3 -3
- package/src/agent-runner.ts +25 -9
- package/src/config.ts +81 -6
- package/src/dream.ts +99 -59
- package/src/entry-file.ts +81 -0
- package/src/entry-index.ts +112 -0
- package/src/extract.ts +378 -60
- package/src/filename.ts +48 -0
- package/src/fs-lock.ts +251 -0
- package/src/index-source.ts +186 -0
- package/src/inject.ts +129 -77
- package/src/memory-store.ts +496 -0
- package/src/memory-tool.ts +263 -328
- package/src/model-resolver.ts +1 -1
- package/src/paths.ts +1 -14
- package/src/process-lock.ts +122 -0
- package/src/sanitize.ts +36 -0
- package/src/snapshot.ts +68 -0
- package/src/index-file.ts +0 -91
- package/src/topic-file.ts +0 -119
package/src/memory-tool.ts
CHANGED
|
@@ -1,328 +1,191 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
-
import {
|
|
2
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
|
-
import { Type } from "typebox";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
findEntryByTopic,
|
|
10
|
-
type IndexEntry,
|
|
11
|
-
parseIndex,
|
|
12
|
-
removeEntryByTopic,
|
|
13
|
-
serializeIndex,
|
|
14
|
-
updateHook,
|
|
15
|
-
upsertEntryByTopic,
|
|
16
|
-
} from "./index-file";
|
|
17
|
-
import { safeTopicPath } from "./paths";
|
|
18
|
-
import {
|
|
19
|
-
appendContent,
|
|
20
|
-
buildFrontmatter,
|
|
21
|
-
hasEntries,
|
|
22
|
-
parseEntries,
|
|
23
|
-
removeEntrySection,
|
|
24
|
-
replaceFrontmatterField,
|
|
25
|
-
updateFrontmatterDate,
|
|
26
|
-
} from "./topic-file";
|
|
4
|
+
import { Type, type TSchema } from "typebox";
|
|
5
|
+
import { indexCapacity, type IndexCapacity } from "./entry-index";
|
|
6
|
+
import type { MemoryStore } from "./memory-store";
|
|
27
7
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
export
|
|
37
|
-
entry: string;
|
|
38
|
-
}
|
|
39
|
-
export interface ActionResult {
|
|
40
|
-
ok: boolean;
|
|
41
|
-
error?: string;
|
|
42
|
-
entries?: IndexEntry[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const MEMORY_MD = "MEMORY.md";
|
|
46
|
-
|
|
47
|
-
async function readIndex(memoryDir: string): Promise<IndexEntry[]> {
|
|
48
|
-
try {
|
|
49
|
-
const raw = await readFile(join(memoryDir, MEMORY_MD), "utf8");
|
|
50
|
-
return parseIndex(raw).entries;
|
|
51
|
-
} catch {
|
|
52
|
-
return [];
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function today(): string {
|
|
57
|
-
return new Date().toISOString().slice(0, 10);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResult> {
|
|
61
|
-
if (!p.title) return { ok: false, error: "title is required" };
|
|
62
|
-
const memType = p.type ?? "feedback";
|
|
63
|
-
if (!["user", "feedback", "project", "reference"].includes(memType)) {
|
|
64
|
-
return { ok: false, error: `Invalid type "${memType}". Must be one of: user, feedback, project, reference` };
|
|
65
|
-
}
|
|
66
|
-
// Normalize topic: always .md extension (LLM may pass "debugging" or "debugging.md")
|
|
67
|
-
const topic = p.topic.endsWith(".md") ? p.topic : `${p.topic}.md`;
|
|
68
|
-
let topicPath: string;
|
|
69
|
-
try {
|
|
70
|
-
topicPath = safeTopicPath(memoryDir, topic);
|
|
71
|
-
// biome-ignore lint/suspicious/noExplicitAny: error catch
|
|
72
|
-
} catch (e: any) {
|
|
73
|
-
return { ok: false, error: e.message };
|
|
74
|
-
}
|
|
75
|
-
return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
|
|
76
|
-
await mkdir(dirname(topicPath), { recursive: true });
|
|
77
|
-
const entries = await readIndex(memoryDir);
|
|
78
|
-
const existing = findEntryByTopic(entries, topic);
|
|
79
|
-
|
|
80
|
-
let next: IndexEntry[];
|
|
81
|
-
if (!existing) {
|
|
82
|
-
// New topic: create index entry with topic name as display name, entry title as hook
|
|
83
|
-
const name = topic.replace(/\.md$/, "");
|
|
84
|
-
const entry: IndexEntry = { name, topic, hook: p.title, raw: "" };
|
|
85
|
-
next = upsertEntryByTopic(entries, entry);
|
|
86
|
-
if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
|
|
87
|
-
return {
|
|
88
|
-
ok: false,
|
|
89
|
-
error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}`,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
// Create topic file with full frontmatter
|
|
93
|
-
const fm = buildFrontmatter({ name, description: p.title, type: memType, updated: today() });
|
|
94
|
-
const topicContent = appendContent(fm, p.title, p.content);
|
|
95
|
-
await writeFile(topicPath, topicContent, "utf8");
|
|
96
|
-
} else {
|
|
97
|
-
// Existing topic: append entry, then regenerate hook + description from ALL entry titles
|
|
98
|
-
const raw = await readFile(topicPath, "utf8");
|
|
99
|
-
const refreshed = updateFrontmatterDate(raw, today());
|
|
100
|
-
const topicContent = appendContent(refreshed, p.title, p.content);
|
|
101
|
-
|
|
102
|
-
// Build hook + description from all entries
|
|
103
|
-
const allEntries = parseEntries(topicContent);
|
|
104
|
-
const hook = allEntries
|
|
105
|
-
.map((e) => e.title)
|
|
106
|
-
.join("; ")
|
|
107
|
-
.slice(0, 150);
|
|
108
|
-
const withDesc = replaceFrontmatterField(topicContent, "description", hook);
|
|
8
|
+
/**
|
|
9
|
+
* `memory` 工具的 action 全集。注册范围是**硬约束**(spec §4.3 / D12):
|
|
10
|
+
*
|
|
11
|
+
* - 主 agent 与 extract 只拿到 5 个(`MAIN_AGENT_ACTIONS`);
|
|
12
|
+
* - `rename` / `rebuild_index` 是 dream 专属(`DREAM_ACTIONS`),且只经
|
|
13
|
+
* `runHeadlessAgent({ customTools })` 注入 dream 自己的 headless session,**不经**
|
|
14
|
+
* `pi.registerTool()` —— 既省每轮上下文,也避免主 agent 误用破坏结构的能力。
|
|
15
|
+
*/
|
|
16
|
+
export type MemoryAction = "add" | "replace" | "remove" | "list" | "search" | "rename" | "rebuild_index";
|
|
109
17
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export async function doRemove(memoryDir: string, p: RemoveParams): Promise<ActionResult> {
|
|
127
|
-
return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
|
|
128
|
-
const entries = await readIndex(memoryDir);
|
|
129
|
-
|
|
130
|
-
// Search across all topic files to find which one contains this entry
|
|
131
|
-
const files = await readdir(memoryDir).catch(() => []);
|
|
132
|
-
let foundTopic: string | null = null;
|
|
133
|
-
const foundTopics: string[] = [];
|
|
134
|
-
|
|
135
|
-
for (const f of files) {
|
|
136
|
-
if (!f.endsWith(".md") || f === MEMORY_MD) continue;
|
|
137
|
-
const raw = await readFile(join(memoryDir, f), "utf8").catch(() => "");
|
|
138
|
-
const parsed = parseEntries(raw);
|
|
139
|
-
if (parsed.some((e) => e.title === p.entry)) {
|
|
140
|
-
foundTopics.push(f);
|
|
141
|
-
foundTopic = f;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (foundTopics.length === 0) {
|
|
146
|
-
return { ok: false, error: `Entry "${p.entry}" not found in any topic` };
|
|
147
|
-
}
|
|
148
|
-
if (foundTopics.length > 1) {
|
|
149
|
-
return { ok: false, error: `Multiple matches for entry "${p.entry}" in topics: ${foundTopics.join(", ")}` };
|
|
150
|
-
}
|
|
18
|
+
/**
|
|
19
|
+
* 主 agent(`pi.registerTool`)与 extract 子会话共用的 5 个 action。
|
|
20
|
+
*
|
|
21
|
+
* **冻结**:`StringEnum` 按引用持有这个数组,谁 `push` 一下就会静默拓宽**已经注册**的
|
|
22
|
+
* enum(D12 的注册范围是硬约束,不能靠约定守)。
|
|
23
|
+
*/
|
|
24
|
+
export const MAIN_AGENT_ACTIONS: readonly MemoryAction[] = Object.freeze([
|
|
25
|
+
"add",
|
|
26
|
+
"replace",
|
|
27
|
+
"remove",
|
|
28
|
+
"list",
|
|
29
|
+
"search",
|
|
30
|
+
]);
|
|
151
31
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
32
|
+
/** dream 子会话专属的 7 个 action(额外 `rename` / `rebuild_index`)。同样冻结。 */
|
|
33
|
+
export const DREAM_ACTIONS: readonly MemoryAction[] = Object.freeze([
|
|
34
|
+
"add",
|
|
35
|
+
"replace",
|
|
36
|
+
"remove",
|
|
37
|
+
"list",
|
|
38
|
+
"search",
|
|
39
|
+
"rename",
|
|
40
|
+
"rebuild_index",
|
|
41
|
+
]);
|
|
155
42
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
43
|
+
/**
|
|
44
|
+
* 会改动磁盘的 action。只有它们成功完成后才回调 `onWrite`(spec §14:「extract 完成且有
|
|
45
|
+
* 写入 → 通知写入条数」)—— `list` / `search` 是读,不算。
|
|
46
|
+
*/
|
|
47
|
+
const WRITE_ACTIONS: ReadonlySet<MemoryAction> = new Set<MemoryAction>([
|
|
48
|
+
"add",
|
|
49
|
+
"replace",
|
|
50
|
+
"remove",
|
|
51
|
+
"rename",
|
|
52
|
+
"rebuild_index",
|
|
53
|
+
]);
|
|
160
54
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const nextEntries = updateHook(entries, topicFile, newHook);
|
|
166
|
-
const withDate = updateFrontmatterDate(afterRemoval, today());
|
|
167
|
-
const withDesc = replaceFrontmatterField(withDate, "description", newHook);
|
|
168
|
-
await writeFile(topicPath, withDesc, "utf8");
|
|
169
|
-
await writeFile(join(memoryDir, MEMORY_MD), `${serializeIndex(nextEntries)}\n`, "utf8");
|
|
170
|
-
} else {
|
|
171
|
-
// Last entry removed: delete topic file and remove from index
|
|
172
|
-
const nextEntries = removeEntryByTopic(entries, topicFile);
|
|
173
|
-
await unlink(topicPath).catch(() => {});
|
|
174
|
-
await writeFile(join(memoryDir, MEMORY_MD), `${serializeIndex(nextEntries)}\n`, "utf8");
|
|
175
|
-
}
|
|
176
|
-
// biome-ignore lint/suspicious/noExplicitAny: error catch
|
|
177
|
-
} catch (e: any) {
|
|
178
|
-
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
179
|
-
return { ok: false, error: `Topic file "${topicFile}" not found` };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return { ok: true };
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export async function searchMemory(memoryDir: string, query: string): Promise<string> {
|
|
187
|
-
const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== MEMORY_MD);
|
|
188
|
-
const q = query.toLowerCase();
|
|
189
|
-
const hits: string[] = [];
|
|
190
|
-
for (const f of files) {
|
|
191
|
-
const raw = await readFile(join(memoryDir, f), "utf8").catch(() => "");
|
|
192
|
-
const entryBlocks = parseEntries(raw);
|
|
193
|
-
for (const entry of entryBlocks) {
|
|
194
|
-
if (entry.content.toLowerCase().includes(q) || entry.title.toLowerCase().includes(q)) {
|
|
195
|
-
hits.push(`### ${f}\n\`\`\`\n## ${entry.title}\n${entry.content}\n\`\`\``);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return hits.length ? hits.join("\n\n") : "No matches in memory.";
|
|
55
|
+
export interface MemoryToolConfig {
|
|
56
|
+
memIndexMaxLines: number;
|
|
57
|
+
memIndexMaxBytes: number;
|
|
58
|
+
sessionSearch: { maxSessions: number; maxMatches: number };
|
|
200
59
|
}
|
|
201
60
|
|
|
202
61
|
export interface MemoryToolDeps {
|
|
62
|
+
/**
|
|
63
|
+
* 保留给诊断与 Plan C 的 `/memory` 状态输出。工具本身**不直接读目录** ——
|
|
64
|
+
* `MemoryStore` 是唯一写入通道,也是唯一的读取入口(D4)。
|
|
65
|
+
*/
|
|
203
66
|
getMemoryDir: () => string | null;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
67
|
+
/** `session_start` 之后才有值;为 null 时工具报「未初始化」而不是崩。 */
|
|
68
|
+
getStore: () => MemoryStore | null;
|
|
69
|
+
getConfig: () => MemoryToolConfig;
|
|
70
|
+
/**
|
|
71
|
+
* `getStore()` 为 null 时的完整可读原因(由 index.ts 依会话状态拼好),null = 还没 `session_start`。
|
|
72
|
+
* 三种状态:未启动 / 配置错误(模型校验或初始化失败)/ `enabled: false` 的禁用会话。
|
|
73
|
+
*/
|
|
74
|
+
getUnavailableMessage: () => string | null;
|
|
210
75
|
searchSessions: (cwd: string, query: string, cfg: { maxSessions: number; maxMatches: number }) => Promise<string>;
|
|
211
76
|
cwd: () => string;
|
|
212
77
|
}
|
|
213
78
|
|
|
214
|
-
export
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
79
|
+
export interface MemoryToolOptions {
|
|
80
|
+
/** 本 session 可见的 action 子集。缺省 = 主 agent 的 5 个。 */
|
|
81
|
+
actions?: readonly MemoryAction[];
|
|
82
|
+
/** 整轮逻辑锁持有者(dream / extract)传 true:它们的原语调用不得再去抢自己已持有的锁。 */
|
|
83
|
+
skipLogicalLock?: boolean;
|
|
84
|
+
/** dream 传 true:进入时已对整目录拍过一次快照,内部每个原语不再各拍一次。 */
|
|
85
|
+
skipSnapshot?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* 每次**成功**的写 action 之后回调(读 action 不回调,失败抛错也不回调)。
|
|
88
|
+
* index.ts 用它给 extract 统计本轮写入条数(spec §14)—— 工具跑在 headless 子会话里,
|
|
89
|
+
* 自己看不到 UI,只能把「写了几条」回报给宿主。
|
|
90
|
+
*/
|
|
91
|
+
onWrite?: (info: { action: MemoryAction; name?: string }) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface MemoryParams {
|
|
95
|
+
action: MemoryAction;
|
|
96
|
+
name?: string;
|
|
97
|
+
description?: string;
|
|
98
|
+
type?: "user" | "feedback" | "project" | "reference";
|
|
99
|
+
content?: string;
|
|
100
|
+
query?: string;
|
|
101
|
+
scope?: "memory" | "sessions";
|
|
102
|
+
new_name?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const TYPE_VALUES = ["user", "feedback", "project", "reference"] as const;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 参数 schema 由 `actions` **动态构建**:不在集合里的 action 不出现在枚举里,
|
|
109
|
+
* `new_name` 也只在含 `rename` 的 schema(= dream)里存在。这是 D12 的执行点。
|
|
110
|
+
*/
|
|
111
|
+
function buildParameters(actions: readonly MemoryAction[]) {
|
|
112
|
+
const props: Record<string, TSchema> = {
|
|
113
|
+
action: StringEnum(actions, { description: "Which memory operation to perform." }),
|
|
114
|
+
name: Type.Optional(
|
|
115
|
+
Type.String({
|
|
116
|
+
description:
|
|
117
|
+
"Unique, human-readable title of the memory. Required for add; the lookup key for replace/remove/rename.",
|
|
234
118
|
}),
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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." }),
|
|
119
|
+
),
|
|
120
|
+
description: Type.Optional(
|
|
121
|
+
Type.String({
|
|
122
|
+
description:
|
|
123
|
+
"One self-contained line describing this memory. Future sessions pick memories from descriptions alone, so it must make sense without the body. Defaults to the first sentence of content.",
|
|
270
124
|
}),
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
125
|
+
),
|
|
126
|
+
type: Type.Optional(StringEnum(TYPE_VALUES, { description: "Memory type. Defaults to feedback." })),
|
|
127
|
+
content: Type.Optional(
|
|
128
|
+
Type.String({
|
|
129
|
+
description: "The memory body. Required for add and replace; it becomes the whole entry file.",
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
query: Type.Optional(Type.String({ description: "Search text (search)." })),
|
|
133
|
+
scope: Type.Optional(
|
|
134
|
+
StringEnum(["memory", "sessions"] as const, {
|
|
135
|
+
description: "search target: memory entries (default) or past sessions.",
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
138
|
+
};
|
|
139
|
+
if (actions.includes("rename")) {
|
|
140
|
+
props.new_name = Type.Optional(
|
|
141
|
+
Type.String({
|
|
142
|
+
description: "New unique title for the memory (rename). Its file name and index line follow.",
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return Type.Object(props);
|
|
284
147
|
}
|
|
285
148
|
|
|
286
|
-
|
|
149
|
+
/**
|
|
150
|
+
* 与 `MemoryStore.#capacityWarning` 同文案。`rebuildIndex` **不返回** capacityWarning
|
|
151
|
+
* (已知 API 不一致,spec §19),所以 dream 的 `rebuild_index` 必须自己算一遍并把可操作的
|
|
152
|
+
* 警告回给模型 —— 恢复原语恰恰最可能在膨胀目录上运行。
|
|
153
|
+
*/
|
|
154
|
+
function capacityWarning(cap: IndexCapacity, cfg: MemoryToolConfig): string {
|
|
155
|
+
return (
|
|
156
|
+
`MEMORY.md is over its limit: ${cap.lineCount}/${cfg.memIndexMaxLines} lines, ` +
|
|
157
|
+
`${cap.byteLength}/${cfg.memIndexMaxBytes} bytes. The write succeeded, but everything past ` +
|
|
158
|
+
"the limit is dropped on the next load. Rewrite it now: keep one line per entry, merge or drop " +
|
|
159
|
+
"stale entries, and move detail into entry bodies rather than the index."
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createMemoryTool(deps: MemoryToolDeps, options: MemoryToolOptions = {}): ToolDefinition {
|
|
164
|
+
const actions = options.actions ?? MAIN_AGENT_ACTIONS;
|
|
165
|
+
const writeOpts = { skipLogicalLock: options.skipLogicalLock, skipSnapshot: options.skipSnapshot };
|
|
166
|
+
|
|
287
167
|
return {
|
|
288
168
|
name: "memory",
|
|
289
169
|
label: "Memory",
|
|
290
170
|
description:
|
|
291
|
-
"Read/write project memory
|
|
171
|
+
"Read/write persistent project memory. One memory = one file, and MEMORY.md holds exactly one index line per memory. action 'add' creates a memory, or overwrites the one whose name matches exactly; 'replace' rewrites an existing memory's content/description/type; 'remove' deletes it; 'list' shows every memory; 'search' queries memory entries (scope='memory', default) or past sessions (scope='sessions'). A memory's description is the only text a future session sees when deciding relevance, so it must be self-contained.",
|
|
292
172
|
promptSnippet:
|
|
293
|
-
"Read/write project memory
|
|
173
|
+
"Read/write persistent project memory (one memory per file). Make every description self-contained — it is the only relevance signal future sessions get.",
|
|
294
174
|
promptGuidelines: [
|
|
295
|
-
"Use memory to persist project facts, user preferences, and lessons learned across sessions.",
|
|
296
|
-
"
|
|
297
|
-
"
|
|
298
|
-
"
|
|
175
|
+
"Use memory to persist project facts, user preferences, and lessons learned across sessions. Each memory lives in its own file and occupies exactly one line of the MEMORY.md index.",
|
|
176
|
+
"Always pass a description with action 'add' and 'replace'. It must be self-contained and specific (what, where, which value), because future sessions select memories from descriptions alone. Bad: \"Debugging tips\". Good: \"staging SSH listens on 2222, not 22\".",
|
|
177
|
+
"Give each memory a unique, human-readable name. Adding a name that already exists overwrites that memory instead of creating a second one — use 'search' or 'list' first when you are unsure.",
|
|
178
|
+
"Keep one fact per memory and put the detail in content, not in name or description; the index line stays short.",
|
|
179
|
+
"The index has a hard capacity limit. When a write reports that MEMORY.md is over its limit, act on it: merge related memories, remove stale ones, and move detail into content.",
|
|
180
|
+
"Use action 'search' with scope='sessions' to find past work in history sessions.",
|
|
181
|
+
"Relevant memories are surfaced automatically at the start of a turn inside <relevant_memories>; use 'search' or 'list' to look for anything else.",
|
|
299
182
|
],
|
|
300
|
-
parameters:
|
|
301
|
-
action: StringEnum(["add", "remove", "search"] as const),
|
|
302
|
-
// add
|
|
303
|
-
content: Type.Optional(Type.String({ description: "Knowledge text to store (add)." })),
|
|
304
|
-
topic: Type.Optional(
|
|
305
|
-
Type.String({ description: "Target topic filename, e.g. 'debugging.md'. Auto-created if new (add/read)." }),
|
|
306
|
-
),
|
|
307
|
-
title: Type.Optional(
|
|
308
|
-
Type.String({
|
|
309
|
-
description:
|
|
310
|
-
"Descriptive, self-contained title for the MEMORY.md index line. Only index lines are injected into future prompts (NOT topic file content), so the title must convey enough context on its own. Required for add.",
|
|
311
|
-
}),
|
|
312
|
-
),
|
|
313
|
-
type: Type.Optional(StringEnum(["user", "feedback", "project", "reference"] as const)),
|
|
314
|
-
// remove
|
|
315
|
-
entry: Type.Optional(
|
|
316
|
-
Type.String({ description: "Entry title to remove. Exact match on MEMORY.md index line (remove/read)." }),
|
|
317
|
-
),
|
|
318
|
-
// search
|
|
319
|
-
query: Type.Optional(Type.String()),
|
|
320
|
-
scope: Type.Optional(StringEnum(["memory", "sessions"] as const)),
|
|
321
|
-
}),
|
|
183
|
+
parameters: buildParameters(actions),
|
|
322
184
|
// biome-ignore lint/suspicious/noExplicitAny: renderCall args
|
|
323
185
|
renderCall(args: any, theme: any) {
|
|
324
|
-
let t = theme.fg("toolTitle", theme.bold("memory ")) + theme.fg("muted", args.action);
|
|
325
|
-
if (args.
|
|
186
|
+
let t = theme.fg("toolTitle", theme.bold("memory ")) + theme.fg("muted", String(args.action));
|
|
187
|
+
if (args.name) t += ` ${theme.fg("accent", args.name)}`;
|
|
188
|
+
if (args.new_name) t += ` ${theme.fg("accent", `→ ${args.new_name}`)}`;
|
|
326
189
|
if (args.query) t += ` ${theme.fg("dim", `"${args.query}"`)}`;
|
|
327
190
|
return new Text(t, 0, 0);
|
|
328
191
|
},
|
|
@@ -333,52 +196,124 @@ export function createMemoryTool(deps: MemoryToolDeps) {
|
|
|
333
196
|
if (result.details?.error) return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
|
|
334
197
|
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text.split("\n")[0]), 0, 0);
|
|
335
198
|
},
|
|
336
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
337
|
-
async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any,
|
|
338
|
-
|
|
339
|
-
|
|
199
|
+
// biome-ignore lint/suspicious/noExplicitAny: execute params
|
|
200
|
+
async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
|
|
201
|
+
const store = deps.getStore();
|
|
202
|
+
if (!store) {
|
|
203
|
+
// 健康会话之后,同一进程内可能又起了一个配置错误或禁用的会话(pi 没有 unregister API),
|
|
204
|
+
// 工具仍在注册表里:此时「no session_start yet」是假话。原因文案由 index.ts 依状态拼好 ——
|
|
205
|
+
// 工具只负责原样抛出,不猜状态。
|
|
206
|
+
throw new Error(deps.getUnavailableMessage() ?? "Memory not initialized (no session_start yet)");
|
|
207
|
+
}
|
|
340
208
|
const cfg = deps.getConfig();
|
|
341
|
-
|
|
209
|
+
const p = params as MemoryParams;
|
|
210
|
+
// schema 的 action 枚举已经按 session 收窄过(D12);这里是第二道门,防止模型硬编一个
|
|
211
|
+
// 不在集合里的 action 而落到 switch 的 default 之外。
|
|
212
|
+
if (!actions.includes(p.action)) throw new Error(`Unknown action: ${p.action}`);
|
|
213
|
+
|
|
342
214
|
let text: string;
|
|
343
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
215
|
+
// biome-ignore lint/suspicious/noExplicitAny: tool result details
|
|
344
216
|
let details: any = {};
|
|
345
|
-
|
|
217
|
+
|
|
218
|
+
switch (p.action) {
|
|
346
219
|
case "add": {
|
|
347
|
-
if (!
|
|
348
|
-
if (!
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
220
|
+
if (!p.name?.trim()) throw new Error("name is required for add");
|
|
221
|
+
if (!p.content) throw new Error("content is required for add");
|
|
222
|
+
const r = await store.addEntry(
|
|
223
|
+
{ name: p.name, description: p.description, type: p.type, body: p.content },
|
|
224
|
+
writeOpts,
|
|
225
|
+
);
|
|
226
|
+
text = `Saved "${p.name.trim()}" (${r.file}).`;
|
|
227
|
+
// 容量超限**不抛错**(D9 / §8.2):写入已经成功,但必须把可操作的警告回给模型,
|
|
228
|
+
// 让它去重写索引。抛错会让模型以为记忆没存下来而重复写。
|
|
229
|
+
if (r.capacityWarning) text += `\n\n${r.capacityWarning}`;
|
|
230
|
+
details = { file: r.file, capacityWarning: r.capacityWarning };
|
|
231
|
+
// spec §14:写成功了要让用户看见。headless 会话(extract / dream)hasUI=false,
|
|
232
|
+
// 天然不通知;旧调用形状完全不传 ctx,所以用 `?.`。
|
|
233
|
+
// 通知本身必须包起来(Plan C 终审 #11):`ctx.ui` 是宿主代理,session dispose
|
|
234
|
+
// 之后 notify 会抛 —— 一次**已经落盘**的写入不能因此变成工具错误,
|
|
235
|
+
// 否则模型会以为没存下来而重复写。
|
|
236
|
+
if (ctx?.hasUI) {
|
|
237
|
+
try {
|
|
238
|
+
ctx.ui.notify(`Saved: ${p.name.trim()}`, "info");
|
|
239
|
+
} catch {
|
|
240
|
+
/* UI 已失效:宿主不再收这条通知,写入结果不受影响 */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "replace": {
|
|
246
|
+
if (!p.name?.trim()) throw new Error("name is required for replace");
|
|
247
|
+
if (!p.content) throw new Error("content is required for replace");
|
|
248
|
+
// 不改名:改名是 dream 专属的 `rename`(且 `replaceEntry` 的改名路径会在撞名时报错)。
|
|
249
|
+
const r = await store.replaceEntry(
|
|
250
|
+
p.name,
|
|
251
|
+
{ description: p.description, type: p.type, body: p.content },
|
|
252
|
+
writeOpts,
|
|
253
|
+
);
|
|
254
|
+
text = `Replaced "${p.name.trim()}" (${r.file}).`;
|
|
255
|
+
if (r.capacityWarning) text += `\n\n${r.capacityWarning}`;
|
|
256
|
+
details = { file: r.file, capacityWarning: r.capacityWarning };
|
|
361
257
|
break;
|
|
362
258
|
}
|
|
363
259
|
case "remove": {
|
|
364
|
-
if (!
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
260
|
+
if (!p.name?.trim()) throw new Error("name is required for remove");
|
|
261
|
+
await store.removeEntry(p.name, writeOpts);
|
|
262
|
+
text = `Removed "${p.name.trim()}".`;
|
|
263
|
+
details = { removed: p.name.trim() };
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case "list": {
|
|
267
|
+
const entries = await store.listEntries();
|
|
268
|
+
text =
|
|
269
|
+
entries.length === 0
|
|
270
|
+
? "No memories yet."
|
|
271
|
+
: entries
|
|
272
|
+
.map((e) => `- ${e.name} (${e.type}, modified ${e.modified}) — ${e.description} [${e.file}]`)
|
|
273
|
+
.join("\n");
|
|
274
|
+
details = { count: entries.length };
|
|
368
275
|
break;
|
|
369
276
|
}
|
|
370
277
|
case "search": {
|
|
371
|
-
if (!
|
|
372
|
-
if (
|
|
373
|
-
text = await deps.searchSessions(deps.cwd(),
|
|
374
|
-
|
|
375
|
-
|
|
278
|
+
if (!p.query?.trim()) throw new Error("query is required for search");
|
|
279
|
+
if (p.scope === "sessions") {
|
|
280
|
+
text = await deps.searchSessions(deps.cwd(), p.query, cfg.sessionSearch);
|
|
281
|
+
// 与其余分支一致:details 不再留空(Plan B ledger 的 Minor)
|
|
282
|
+
details = { scope: "sessions", query: p.query.trim() };
|
|
283
|
+
break;
|
|
376
284
|
}
|
|
285
|
+
const hits = await store.searchEntries(p.query);
|
|
286
|
+
text =
|
|
287
|
+
hits.length === 0
|
|
288
|
+
? "No matches in memory."
|
|
289
|
+
: hits.map((e) => `## ${e.name}\nfile: ${e.file}\ntype: ${e.type}\n\n${e.body}`).join("\n\n");
|
|
290
|
+
details = { count: hits.length };
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case "rename": {
|
|
294
|
+
if (!p.name?.trim()) throw new Error("name is required for rename");
|
|
295
|
+
if (!p.new_name?.trim()) throw new Error("new_name is required for rename");
|
|
296
|
+
const r = await store.renameEntry(p.name, p.new_name, writeOpts);
|
|
297
|
+
text = `Renamed "${p.name.trim()}" → "${p.new_name.trim()}" (${r.file}).`;
|
|
298
|
+
details = { file: r.file };
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "rebuild_index": {
|
|
302
|
+
const r = await store.rebuildIndex(writeOpts);
|
|
303
|
+
// rebuildIndex 不返回 capacityWarning(spec §19):调用方自己检查。
|
|
304
|
+
const cap = indexCapacity(await store.readIndex(), cfg.memIndexMaxLines, cfg.memIndexMaxBytes);
|
|
305
|
+
text = `Rebuilt index: ${r.entries} entries (${r.headerLines} header lines).`;
|
|
306
|
+
if (!cap.ok) text += `\n\n${capacityWarning(cap, cfg)}`;
|
|
307
|
+
details = { entries: r.entries, headerLines: r.headerLines, overCapacity: !cap.ok };
|
|
377
308
|
break;
|
|
378
309
|
}
|
|
379
310
|
default:
|
|
380
|
-
throw new Error(`Unknown action: ${
|
|
311
|
+
throw new Error(`Unknown action: ${String(p.action)}`);
|
|
381
312
|
}
|
|
313
|
+
|
|
314
|
+
// 写在 switch 之后:任何抛错都会跳过它,于是「成功完成写 action」是唯一触发条件。
|
|
315
|
+
if (WRITE_ACTIONS.has(p.action)) options.onWrite?.({ action: p.action, name: p.name?.trim() });
|
|
316
|
+
|
|
382
317
|
return { content: [{ type: "text", text }], details };
|
|
383
318
|
},
|
|
384
319
|
};
|