@yandy0725/pi-memory 1.4.0 → 2.0.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.
@@ -1,328 +1,187 @@
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 { withFileMutationQueue, type ToolDefinition } from "@earendil-works/pi-coding-agent";
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
- checkCapacity,
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
- export interface AddParams {
29
- content: string;
30
- topic: string;
31
- title: string;
32
- type?: string;
33
- maxLines: number;
34
- maxBytes: number;
35
- }
36
- export interface RemoveParams {
37
- entry: string;
38
- }
39
- export interface ActionResult {
40
- ok: boolean;
41
- error?: string;
42
- entries?: IndexEntry[];
43
- }
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";
44
17
 
45
- const MEMORY_MD = "MEMORY.md";
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
+ ]);
46
31
 
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
- }
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
+ ]);
55
42
 
56
- function today(): string {
57
- return new Date().toISOString().slice(0, 10);
58
- }
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
+ ]);
59
54
 
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);
109
-
110
- next = updateHook(entries, topic, hook);
111
- if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
112
- return {
113
- ok: false,
114
- error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}`,
115
- };
116
- }
117
- await writeFile(topicPath, withDesc, "utf8");
118
- }
119
-
120
- // write index
121
- await writeFile(join(memoryDir, MEMORY_MD), `${serializeIndex(next)}\n`, "utf8");
122
- return { ok: true, entries: next };
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
- }
151
-
152
- // biome-ignore lint/style/noNonNullAssertion: foundTopic assigned in guard above
153
- const topicFile = foundTopic!;
154
- const topicPath = safeTopicPath(memoryDir, topicFile);
155
-
156
- // Remove ## block from topic file
157
- try {
158
- const raw = await readFile(topicPath, "utf8");
159
- const afterRemoval = removeEntrySection(raw, p.entry);
160
-
161
- if (hasEntries(afterRemoval)) {
162
- // Still has entries: update hook + description from remaining entries, refresh date
163
- const remaining = parseEntries(afterRemoval);
164
- const newHook = remaining.map((e) => e.title).join("; ").slice(0, 150);
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
- getConfig: () => {
205
- memIndexMaxLines: number;
206
- memIndexMaxBytes: number;
207
- sessionSearch: { maxSessions: number; maxMatches: number };
208
- };
67
+ /** `session_start` 之后才有值;为 null 时工具报「未初始化」而不是崩。 */
68
+ getStore: () => MemoryStore | null;
69
+ getConfig: () => MemoryToolConfig;
209
70
  getEnabled: () => boolean;
210
71
  searchSessions: (cwd: string, query: string, cfg: { maxSessions: number; maxMatches: number }) => Promise<string>;
211
72
  cwd: () => string;
212
73
  }
213
74
 
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
- ),
75
+ export interface MemoryToolOptions {
76
+ /** 本 session 可见的 action 子集。缺省 = 主 agent 的 5 个。 */
77
+ actions?: readonly MemoryAction[];
78
+ /** 整轮逻辑锁持有者(dream / extract)传 true:它们的原语调用不得再去抢自己已持有的锁。 */
79
+ skipLogicalLock?: boolean;
80
+ /** dream 传 true:进入时已对整目录拍过一次快照,内部每个原语不再各拍一次。 */
81
+ skipSnapshot?: boolean;
82
+ /**
83
+ * 每次**成功**的写 action 之后回调(读 action 不回调,失败抛错也不回调)。
84
+ * index.ts 用它给 extract 统计本轮写入条数(spec §14)—— 工具跑在 headless 子会话里,
85
+ * 自己看不到 UI,只能把「写了几条」回报给宿主。
86
+ */
87
+ onWrite?: (info: { action: MemoryAction; name?: string }) => void;
88
+ }
89
+
90
+ interface MemoryParams {
91
+ action: MemoryAction;
92
+ name?: string;
93
+ description?: string;
94
+ type?: "user" | "feedback" | "project" | "reference";
95
+ content?: string;
96
+ query?: string;
97
+ scope?: "memory" | "sessions";
98
+ new_name?: string;
99
+ }
100
+
101
+ const TYPE_VALUES = ["user", "feedback", "project", "reference"] as const;
102
+
103
+ /**
104
+ * 参数 schema 由 `actions` **动态构建**:不在集合里的 action 不出现在枚举里,
105
+ * `new_name` 也只在含 `rename` 的 schema(= dream)里存在。这是 D12 的执行点。
106
+ */
107
+ function buildParameters(actions: readonly MemoryAction[]) {
108
+ const props: Record<string, TSchema> = {
109
+ action: StringEnum(actions, { description: "Which memory operation to perform." }),
110
+ name: Type.Optional(
111
+ Type.String({
112
+ description:
113
+ "Unique, human-readable title of the memory. Required for add; the lookup key for replace/remove/rename.",
234
114
  }),
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." }),
115
+ ),
116
+ description: Type.Optional(
117
+ Type.String({
118
+ description:
119
+ "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
120
  }),
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
- ];
121
+ ),
122
+ type: Type.Optional(StringEnum(TYPE_VALUES, { description: "Memory type. Defaults to feedback." })),
123
+ content: Type.Optional(
124
+ Type.String({
125
+ description: "The memory body. Required for add and replace; it becomes the whole entry file.",
126
+ }),
127
+ ),
128
+ query: Type.Optional(Type.String({ description: "Search text (search)." })),
129
+ scope: Type.Optional(
130
+ StringEnum(["memory", "sessions"] as const, {
131
+ description: "search target: memory entries (default) or past sessions.",
132
+ }),
133
+ ),
134
+ };
135
+ if (actions.includes("rename")) {
136
+ props.new_name = Type.Optional(
137
+ Type.String({
138
+ description: "New unique title for the memory (rename). Its file name and index line follow.",
139
+ }),
140
+ );
141
+ }
142
+ return Type.Object(props);
143
+ }
144
+
145
+ /**
146
+ * 与 `MemoryStore.#capacityWarning` 同文案。`rebuildIndex` **不返回** capacityWarning
147
+ * (已知 API 不一致,spec §19),所以 dream 的 `rebuild_index` 必须自己算一遍并把可操作的
148
+ * 警告回给模型 —— 恢复原语恰恰最可能在膨胀目录上运行。
149
+ */
150
+ function capacityWarning(cap: IndexCapacity, cfg: MemoryToolConfig): string {
151
+ return (
152
+ `MEMORY.md is over its limit: ${cap.lineCount}/${cfg.memIndexMaxLines} lines, ` +
153
+ `${cap.byteLength}/${cfg.memIndexMaxBytes} bytes. The write succeeded, but everything past ` +
154
+ "the limit is dropped on the next load. Rewrite it now: keep one line per entry, merge or drop " +
155
+ "stale entries, and move detail into entry bodies rather than the index."
156
+ );
284
157
  }
285
158
 
286
- export function createMemoryTool(deps: MemoryToolDeps) {
159
+ export function createMemoryTool(deps: MemoryToolDeps, options: MemoryToolOptions = {}): ToolDefinition {
160
+ const actions = options.actions ?? MAIN_AGENT_ACTIONS;
161
+ const writeOpts = { skipLogicalLock: options.skipLogicalLock, skipSnapshot: options.skipSnapshot };
162
+
287
163
  return {
288
164
  name: "memory",
289
165
  label: "Memory",
290
166
  description:
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.",
167
+ "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
168
  promptSnippet:
293
- "Read/write project memory across sessions (add/remove/search). Only index titles are injected — make titles self-descriptive.",
169
+ "Read/write persistent project memory (one memory per file). Make every description self-contained — it is the only relevance signal future sessions get.",
294
170
  promptGuidelines: [
295
- "Use memory to persist project facts, user preferences, and lessons learned across sessions.",
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.",
297
- "Use memory action 'search' with scope='sessions' to find past work in history sessions.",
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.",
171
+ "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.",
172
+ "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\".",
173
+ "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.",
174
+ "Keep one fact per memory and put the detail in content, not in name or description; the index line stays short.",
175
+ "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.",
176
+ "Use action 'search' with scope='sessions' to find past work in history sessions.",
177
+ "Relevant memories are surfaced automatically at the start of a turn inside <relevant_memories>; use 'search' or 'list' to look for anything else.",
299
178
  ],
300
- parameters: Type.Object({
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
- }),
179
+ parameters: buildParameters(actions),
322
180
  // biome-ignore lint/suspicious/noExplicitAny: renderCall args
323
181
  renderCall(args: any, theme: any) {
324
- let t = theme.fg("toolTitle", theme.bold("memory ")) + theme.fg("muted", args.action);
325
- if (args.topic) t += ` ${theme.fg("accent", args.topic)}`;
182
+ let t = theme.fg("toolTitle", theme.bold("memory ")) + theme.fg("muted", String(args.action));
183
+ if (args.name) t += ` ${theme.fg("accent", args.name)}`;
184
+ if (args.new_name) t += ` ${theme.fg("accent", `→ ${args.new_name}`)}`;
326
185
  if (args.query) t += ` ${theme.fg("dim", `"${args.query}"`)}`;
327
186
  return new Text(t, 0, 0);
328
187
  },
@@ -333,52 +192,111 @@ export function createMemoryTool(deps: MemoryToolDeps) {
333
192
  if (result.details?.error) return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
334
193
  return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text.split("\n")[0]), 0, 0);
335
194
  },
336
- // biome-ignore lint/suspicious/noExplicitAny: renderResult result param
337
- async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, _ctx: any) {
195
+ // biome-ignore lint/suspicious/noExplicitAny: execute params
196
+ async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
338
197
  if (!deps.getEnabled()) throw new Error("Memory is disabled (run /memory on)");
339
- const dir = deps.getMemoryDir();
198
+ const store = deps.getStore();
199
+ if (!store) throw new Error("Memory not initialized (no session_start yet)");
340
200
  const cfg = deps.getConfig();
341
- if (!dir) throw new Error("Memory not initialized (no session_start yet)");
201
+ const p = params as MemoryParams;
202
+ // schema 的 action 枚举已经按 session 收窄过(D12);这里是第二道门,防止模型硬编一个
203
+ // 不在集合里的 action 而落到 switch 的 default 之外。
204
+ if (!actions.includes(p.action)) throw new Error(`Unknown action: ${p.action}`);
205
+
342
206
  let text: string;
343
- // biome-ignore lint/suspicious/noExplicitAny: execute params
207
+ // biome-ignore lint/suspicious/noExplicitAny: tool result details
344
208
  let details: any = {};
345
- switch (params.action) {
209
+
210
+ switch (p.action) {
346
211
  case "add": {
347
- if (!params.content) throw new Error("content is required for add");
348
- if (!params.topic) throw new Error("topic is required for add");
349
- if (!params.title) throw new Error("title is required for add");
350
- const r = await doAdd(dir, {
351
- content: params.content,
352
- topic: params.topic,
353
- title: params.title,
354
- type: params.type,
355
- maxLines: cfg.memIndexMaxLines,
356
- maxBytes: cfg.memIndexMaxBytes,
357
- });
358
- if (!r.ok) throw new Error(r.error);
359
- text = `Added "${params.title}" to ${params.topic}. Index now has ${r.entries?.length ?? 0} entries.`;
360
- details = { entries: r.entries?.length };
212
+ if (!p.name?.trim()) throw new Error("name is required for add");
213
+ if (!p.content) throw new Error("content is required for add");
214
+ const r = await store.addEntry(
215
+ { name: p.name, description: p.description, type: p.type, body: p.content },
216
+ writeOpts,
217
+ );
218
+ text = `Saved "${p.name.trim()}" (${r.file}).`;
219
+ // 容量超限**不抛错**(D9 / §8.2):写入已经成功,但必须把可操作的警告回给模型,
220
+ // 让它去重写索引。抛错会让模型以为记忆没存下来而重复写。
221
+ if (r.capacityWarning) text += `\n\n${r.capacityWarning}`;
222
+ details = { file: r.file, capacityWarning: r.capacityWarning };
223
+ // spec §14:写成功了要让用户看见。headless 会话(extract / dream)hasUI=false,
224
+ // 天然不通知;旧调用形状完全不传 ctx,所以用 `?.`。
225
+ if (ctx?.hasUI) ctx.ui.notify(`Saved: ${p.name.trim()}`, "info");
226
+ break;
227
+ }
228
+ case "replace": {
229
+ if (!p.name?.trim()) throw new Error("name is required for replace");
230
+ if (!p.content) throw new Error("content is required for replace");
231
+ // 不改名:改名是 dream 专属的 `rename`(且 `replaceEntry` 的改名路径会在撞名时报错)。
232
+ const r = await store.replaceEntry(
233
+ p.name,
234
+ { description: p.description, type: p.type, body: p.content },
235
+ writeOpts,
236
+ );
237
+ text = `Replaced "${p.name.trim()}" (${r.file}).`;
238
+ if (r.capacityWarning) text += `\n\n${r.capacityWarning}`;
239
+ details = { file: r.file, capacityWarning: r.capacityWarning };
361
240
  break;
362
241
  }
363
242
  case "remove": {
364
- if (!params.entry) throw new Error("entry is required for remove");
365
- const r = await doRemove(dir, { entry: params.entry });
366
- if (!r.ok) throw new Error(r.error);
367
- text = `Removed entry "${params.entry}".`;
243
+ if (!p.name?.trim()) throw new Error("name is required for remove");
244
+ await store.removeEntry(p.name, writeOpts);
245
+ text = `Removed "${p.name.trim()}".`;
246
+ details = { removed: p.name.trim() };
247
+ break;
248
+ }
249
+ case "list": {
250
+ const entries = await store.listEntries();
251
+ text =
252
+ entries.length === 0
253
+ ? "No memories yet."
254
+ : entries
255
+ .map((e) => `- ${e.name} (${e.type}, modified ${e.modified}) — ${e.description} [${e.file}]`)
256
+ .join("\n");
257
+ details = { count: entries.length };
368
258
  break;
369
259
  }
370
260
  case "search": {
371
- if (!params.query) throw new Error("query is required for search");
372
- if (params.scope === "sessions") {
373
- text = await deps.searchSessions(deps.cwd(), params.query, cfg.sessionSearch);
374
- } else {
375
- text = await searchMemory(dir, params.query);
261
+ if (!p.query?.trim()) throw new Error("query is required for search");
262
+ if (p.scope === "sessions") {
263
+ text = await deps.searchSessions(deps.cwd(), p.query, cfg.sessionSearch);
264
+ // 与其余分支一致:details 不再留空(Plan B ledger 的 Minor)
265
+ details = { scope: "sessions", query: p.query.trim() };
266
+ break;
376
267
  }
268
+ const hits = await store.searchEntries(p.query);
269
+ text =
270
+ hits.length === 0
271
+ ? "No matches in memory."
272
+ : hits.map((e) => `## ${e.name}\nfile: ${e.file}\ntype: ${e.type}\n\n${e.body}`).join("\n\n");
273
+ details = { count: hits.length };
274
+ break;
275
+ }
276
+ case "rename": {
277
+ if (!p.name?.trim()) throw new Error("name is required for rename");
278
+ if (!p.new_name?.trim()) throw new Error("new_name is required for rename");
279
+ const r = await store.renameEntry(p.name, p.new_name, writeOpts);
280
+ text = `Renamed "${p.name.trim()}" → "${p.new_name.trim()}" (${r.file}).`;
281
+ details = { file: r.file };
282
+ break;
283
+ }
284
+ case "rebuild_index": {
285
+ const r = await store.rebuildIndex(writeOpts);
286
+ // rebuildIndex 不返回 capacityWarning(spec §19):调用方自己检查。
287
+ const cap = indexCapacity(await store.readIndex(), cfg.memIndexMaxLines, cfg.memIndexMaxBytes);
288
+ text = `Rebuilt index: ${r.entries} entries (${r.headerLines} header lines).`;
289
+ if (!cap.ok) text += `\n\n${capacityWarning(cap, cfg)}`;
290
+ details = { entries: r.entries, headerLines: r.headerLines, overCapacity: !cap.ok };
377
291
  break;
378
292
  }
379
293
  default:
380
- throw new Error(`Unknown action: ${params.action}`);
294
+ throw new Error(`Unknown action: ${String(p.action)}`);
381
295
  }
296
+
297
+ // 写在 switch 之后:任何抛错都会跳过它,于是「成功完成写 action」是唯一触发条件。
298
+ if (WRITE_ACTIONS.has(p.action)) options.onWrite?.({ action: p.action, name: p.name?.trim() });
299
+
382
300
  return { content: [{ type: "text", text }], details };
383
301
  },
384
302
  };