@yandy0725/pi-memory 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ File-system driven persistent memory layer for pi coding agent. Stores project k
4
4
 
5
5
  ## Features
6
6
 
7
- - **One `memory` tool**, four actions: `add` (store knowledge), `replace` (edit existing), `remove` (delete), `search` (query memory or session history)
7
+ - **One `memory` tool**, four actions: `add` (append entry), `remove` (delete entry by title), `read` (load topic or entry), `search` (query memory or session history)
8
8
  - **Topic-based file organization**: each `memory add` writes to a named `.md` file under the project's memory directory
9
9
  - **`MEMORY.md` index** — auto-generated table of contents with line/byte capacity limits
10
10
  - **Snapshot injection**: on every new session, the memory index is appended to the system prompt, keeping the agent aware of past work
@@ -68,38 +68,35 @@ Project-level config (`.pi/pi-memory.json`) is only loaded when the project is t
68
68
  ## Tool reference
69
69
 
70
70
  ```
71
- memory(action: "add" | "replace" | "remove" | "search",
72
- content?, topic?, title?, description?,
73
- old_text?, query?, scope?)
71
+ memory(action: "add" | "remove" | "search" | "read",
72
+ content?, topic?, title?,
73
+ entry?, query?, scope?)
74
74
  ```
75
75
 
76
76
  ### `add`
77
77
 
78
- Stores content under a topic file and upserts the MEMORY.md index.
78
+ Appends an entry to a topic file and adds a new line to the MEMORY.md index (no upsert — multiple entries per topic).
79
79
 
80
80
  - **`content`** (required) — knowledge text to persist
81
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)
82
+ - **`title`** (required) — short title for the index line and entry heading
84
83
 
85
- ### `replace`
84
+ ### `remove`
86
85
 
87
- Locates `old_text` as a substring and replaces it with `content`.
86
+ Deletes an entry by exact title match on the MEMORY.md index. Removes both the index line and the corresponding `##` block from the topic file. When the last entry in a topic is removed, the topic file is deleted.
88
87
 
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
88
+ - **`entry`** (required) — exact entry title to remove
92
89
 
93
- ### `remove`
90
+ ### `read`
94
91
 
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.
92
+ Loads memory content. Either an entire topic file or a single entry block.
96
93
 
97
- - **`old_text`** (required) — substring to delete
98
- - **`topic`** (optional) — narrow search to a specific file
94
+ - **`topic`** (optional) — topic name, e.g. `"debugging"` or `"debugging.md"`. Loads the entire topic file
95
+ - **`entry`** (optional) — entry title. Returns the specific `## Entry Title` block
99
96
 
100
97
  ### `search`
101
98
 
102
- Queries either memory files or session history.
99
+ Queries either memory files or session history. Memory search returns the full entry block (entire `##` section) for each match.
103
100
 
104
101
  - **`query`** (required) — search keyword
105
102
  - **`scope`** (optional) — `"memory"` (default, scans topic files) or `"sessions"` (scans session history)
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 功能
6
6
 
7
- - **一个 `memory` 工具**,四种操作:`add`(存储知识)、`replace`(编辑现有内容)、`remove`(删除)、`search`(查询记忆或会话历史)
7
+ - **一个 `memory` 工具**,四种操作:`add`(追加条目)、`remove`(按标题删除条目)、`read`(加载主题或条目)、`search`(查询记忆或会话历史)
8
8
  - **基于主题的文件组织**:每次 `memory add` 都会向项目记忆目录下指定名称的 `.md` 文件写入内容
9
9
  - **`MEMORY.md` 索引**:自动生成的目录,带有行数/字节容量限制
10
10
  - **快照注入**:每个新会话启动时,记忆索引会追加到系统提示中,让代理始终感知过往工作
@@ -68,38 +68,35 @@ pi install npm:@yandy0725/pi-memory
68
68
  ## 工具参考
69
69
 
70
70
  ```
71
- memory(action: "add" | "replace" | "remove" | "search",
72
- content?, topic?, title?, description?,
73
- old_text?, query?, scope?)
71
+ memory(action: "add" | "remove" | "search" | "read",
72
+ content?, topic?, title?,
73
+ entry?, query?, scope?)
74
74
  ```
75
75
 
76
76
  ### `add`
77
77
 
78
- 将内容存储到主题文件并更新 MEMORY.md 索引。
78
+ 向主题文件追加条目,并向 MEMORY.md 索引添加新行(不更新已有行——一个主题可以有多个条目)。
79
79
 
80
80
  - **`content`**(必填)— 要持久化的知识文本
81
81
  - **`topic`**(必填)— 目标文件名,如 `"debugging.md"`,不存在则自动创建
82
- - **`title`**(可选)— 索引行的短标题(默认取 topic 的文件名部分)
83
- - **`description`**(可选)— 一行描述(默认取内容前 ~80 字符)
82
+ - **`title`**(必填)— 索引行和条目标题
84
83
 
85
- ### `replace`
84
+ ### `remove`
86
85
 
87
- 定位 `old_text` 子串并替换为 `content`。
86
+ 按标题精确匹配删除条目。同时删除索引行和主题文件中对应的 `##` 区块。当主题文件中的最后一条被删除后,主题文件会被清理。
88
87
 
89
- - **`old_text`**(必填)— 要查找的子串
90
- - **`content`**(必填)— 替换文本
91
- - **`topic`**(可选)— 限定搜索范围到指定文件;当文本在多个位置出现时必须提供
88
+ - **`entry`**(必填)— 要删除的条目标题
92
89
 
93
- ### `remove`
90
+ ### `read`
94
91
 
95
- 定位 `old_text` 并删除。当主题文件的全部内容被删除后,文件及其索引条目会被自动清理。
92
+ 加载记忆内容。可以是整个主题文件或单个条目区块。
96
93
 
97
- - **`old_text`**(必填)— 要删除的子串
98
- - **`topic`**(可选)— 限定搜索范围到指定文件
94
+ - **`topic`**(可选)— 主题名称,如 `"debugging"` 或 `"debugging.md"`。加载整个主题文件
95
+ - **`entry`**(可选)— 条目标题。返回对应的 `## 条目标题` 区块
99
96
 
100
97
  ### `search`
101
98
 
102
- 查询记忆文件或会话历史。
99
+ 查询记忆文件或会话历史。记忆搜索返回匹配条目的完整区块(整个 `##` 区域)。
103
100
 
104
101
  - **`query`**(必填)— 搜索关键词
105
102
  - **`scope`**(可选)— `"memory"`(默认,扫描主题文件)或 `"sessions"`(扫描会话历史)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0",
6
+ "version": "0.3.1",
7
7
  "description": "File-system driven persistent memory layer for pi coding agent",
8
8
  "license": "MIT",
9
9
  "repository": {
package/src/dream.ts CHANGED
@@ -11,20 +11,24 @@ import { mkdtemp } from "node:fs/promises";
11
11
  import { tmpdir } from "node:os";
12
12
  import type { MemoryConfig } from "./config";
13
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.
14
+ export const DREAM_SYSTEM_PROMPT = `You are a memory consolidation agent. Your job: read all memory files in the given directory, consolidate entries within each topic (merge duplicates, resolve contradictions, update outdated info), and rebuild the MEMORY.md index to be concise and accurate.
15
15
  Rules:
16
+ - Each topic file contains entries as \`## Entry Title\` blocks.
16
17
  - 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).`;
18
+ - Deduplicate entries: if two entries in the same topic contain the same info, merge them.
19
+ - If entries across different topics overlap, move the content to the more appropriate topic.
20
+ - Rebuild MEMORY.md index: list entries you deem valuable (not necessarily every entry). Each line: - [Entry Title](topic.md). Accuracy matters more than completeness.
21
+ - CRITICAL for entry titles: Only the MEMORY.md index is injected into future coding sessions (topic file content is NOT seen). Every entry title must be self-descriptive and convey enough context to be useful at a glance. Prefer specific, actionable titles like "use uv instead of pip for Python package management" over vague ones like "python tools" or "workflow rules". If an existing title is too vague, rewrite it — keep the original ## heading in the topic file for full context.
22
+ - When done, output a concise summary of changes (merged N, removed N, moved N, updated N).`;
21
23
 
22
24
  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
+ return `Consolidate the memory files under ${memoryDir}. Read every .md file (including MEMORY.md), then:
26
+ 1. Deduplicate entries within each topic that say the same thing.
25
27
  2. Merge contradictory or overlapping entries into one accurate entry.
26
28
  3. Update outdated information.
27
- 4. Reorganize MEMORY.md so it stays <= ${maxLines} lines, one pointer per topic file: - [Title](file.md) — description.
29
+ 4. Move entries to more appropriate topic files when needed.
30
+ 5. Rebuild MEMORY.md (max ${maxLines} lines): - [Entry Title](topic.md) per entry you deem valuable (not necessarily every entry). Entries use ## Entry Title format.
31
+ IMPORTANT: Only MEMORY.md index lines are injected into future coding sessions (topic file content is NOT seen by the coding agent). Rewrite every entry title to be self-contained and descriptive — like "always use uv instead of pip for Python" instead of just "python tools". The title alone must tell the model what the entry is about.
28
32
  Only edit files under ${memoryDir}. When finished, print a one-line summary of changes.`;
29
33
  }
30
34
 
package/src/index-file.ts CHANGED
@@ -1,60 +1,82 @@
1
1
  export interface IndexEntry {
2
- title: string;
3
- topic: string;
4
- description: string;
5
- raw: string;
2
+ title: string;
3
+ topic: string;
4
+ raw: string;
6
5
  }
7
6
  export interface IndexFile {
8
- entries: IndexEntry[];
9
- raw: string;
7
+ entries: IndexEntry[];
8
+ raw: string;
10
9
  }
11
10
 
12
- // Matches: - [Title](topic.md) — description (em-dash U+2014 or --)
13
- const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*[—-]{1,2}\s*(.+)$/;
11
+ // Matches: - [Title](topic.md)
12
+ const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*$/;
14
13
 
15
14
  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 };
15
+ const entries: IndexEntry[] = [];
16
+ for (const line of content.split("\n")) {
17
+ const m = line.match(LINE_RE);
18
+ if (m) entries.push({ title: m[1].trim(), topic: m[2].trim(), raw: line });
19
+ }
20
+ return { entries, raw: content };
22
21
  }
23
22
 
24
23
  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;
24
+ return entries.map((e) => `- [${e.title}](${e.topic})`).join("\n");
25
+ }
26
+
27
+ export function addEntry(entries: IndexEntry[], entry: IndexEntry): IndexEntry[] {
28
+ return [...entries, entry];
29
+ }
30
+
31
+ export function removeEntryByTitle(entries: IndexEntry[], title: string): IndexEntry[] {
32
+ const idx = entries.findIndex((e) => e.title === title);
33
+ if (idx === -1) throw new Error(`Entry "${title}" not found in index`);
34
+ const next = [...entries];
35
+ next.splice(idx, 1);
36
+ return next;
37
+ }
38
+
39
+ export interface MatchResult {
40
+ entry: IndexEntry | null;
41
+ unique: boolean;
42
+ }
43
+
44
+ export function matchEntryByTitle(entries: IndexEntry[], title: string): MatchResult {
45
+ const matches = entries.filter((e) => e.title === title);
46
+ if (matches.length === 0) return { entry: null, unique: false };
47
+ if (matches.length === 1) return { entry: matches[0], unique: true };
48
+ return { entry: matches[0], unique: false };
49
+ }
50
+
51
+ export function truncateForInjection(
52
+ content: string,
53
+ maxLines: number,
54
+ maxBytes: number,
55
+ ): { ok: boolean; content: string; truncated: boolean } {
56
+ const lines = content.split("\n");
57
+ let out = content;
58
+ let truncated = false;
59
+ if (lines.length > maxLines) {
60
+ out = lines.slice(0, maxLines).join("\n");
61
+ truncated = true;
62
+ }
63
+ if (Buffer.byteLength(out, "utf8") > maxBytes) {
64
+ let cut = out;
65
+ while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
66
+ out = cut;
67
+ truncated = true;
68
+ }
69
+ if (truncated) out += `\n[truncated: memory index exceeds injection limit]`;
70
+ return { ok: !truncated, content: out, truncated };
71
+ }
72
+
73
+ export function checkCapacity(
74
+ entries: IndexEntry[],
75
+ maxLines: number,
76
+ maxBytes: number,
77
+ ): boolean {
78
+ const serialized = serializeIndex(entries);
79
+ if (entries.length > maxLines) return false;
80
+ if (Buffer.byteLength(serialized, "utf8") > maxBytes) return false;
81
+ return true;
60
82
  }
@@ -4,13 +4,14 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
5
  import { StringEnum } from "@earendil-works/pi-ai";
6
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";
7
+ import { parseIndex, serializeIndex, addEntry, removeEntryByTitle, matchEntryByTitle, checkCapacity, type IndexEntry } from "./index-file";
8
+ import { buildFrontmatter, appendContent, updateFrontmatterDate, removeEntrySection, hasEntries, parseEntries } from "./topic-file";
9
9
  import { safeTopicPath } from "./paths";
10
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; }
11
+ export interface AddParams { content: string; topic: string; title: string; maxLines: number; maxBytes: number; }
12
+ export interface RemoveParams { entry: string; }
13
+ export interface ReadParams { topic?: string; entry?: string; }
14
+ export interface ReadResult { ok: boolean; error?: string; content?: string; }
14
15
  export interface ActionResult { ok: boolean; error?: string; entries?: IndexEntry[]; }
15
16
 
16
17
  const MEMORY_MD = "MEMORY.md";
@@ -24,15 +25,12 @@ async function readIndex(memoryDir: string): Promise<IndexEntry[]> {
24
25
  }
25
26
  }
26
27
 
27
- function slug(s: string): string {
28
- return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "memory";
29
- }
30
-
31
28
  function today(): string {
32
29
  return new Date().toISOString().slice(0, 10);
33
30
  }
34
31
 
35
32
  export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResult> {
33
+ if (!p.title) return { ok: false, error: "title is required" };
36
34
  let topicPath: string;
37
35
  try {
38
36
  topicPath = safeTopicPath(memoryDir, p.topic);
@@ -42,110 +40,114 @@ export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResu
42
40
  return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
43
41
  await mkdir(dirname(topicPath), { recursive: true });
44
42
  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: "" });
43
+ const next = addEntry(entries, { title: p.title, topic: p.topic, raw: "" });
48
44
  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)}` };
45
+ return {
46
+ ok: false,
47
+ error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}`,
48
+ };
50
49
  }
51
50
  // write topic file
52
51
  let existing: string | null = null;
53
52
  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");
53
+ if (!existing) {
54
+ const out = `${buildFrontmatter({ updated: today() })}${appendContent(null, p.title, p.content)}`;
55
+ await writeFile(topicPath, out, "utf8");
56
+ } else {
57
+ const updated = updateFrontmatterDate(existing, today());
58
+ const out = appendContent(updated, p.title, p.content);
59
+ await writeFile(topicPath, out, "utf8");
60
+ }
59
61
  // write index
60
62
  await writeFile(join(memoryDir, MEMORY_MD), serializeIndex(next) + "\n", "utf8");
61
63
  return { ok: true, entries: next };
62
64
  });
63
65
  }
64
66
 
65
- interface MatchSite { file: string; type: "index" | "topic"; }
67
+ export async function doRemove(memoryDir: string, p: RemoveParams): Promise<ActionResult> {
68
+ return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
69
+ const entries = await readIndex(memoryDir);
70
+ const match = matchEntryByTitle(entries, p.entry);
71
+ if (!match.entry) {
72
+ return { ok: false, error: `Entry "${p.entry}" not found in index` };
73
+ }
74
+ if (!match.unique) {
75
+ const matchingTopics = entries
76
+ .filter((e) => e.title === p.entry)
77
+ .map((e) => e.topic)
78
+ .join(", ");
79
+ return { ok: false, error: `Multiple matches for entry "${p.entry}" in topics: ${matchingTopics}` };
80
+ }
81
+
82
+ const topicFile = match.entry.topic;
83
+ const topicPath = safeTopicPath(memoryDir, topicFile);
66
84
 
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
- }
85
+ // remove index line
86
+ const updated = removeEntryByTitle(entries, p.entry);
87
+ await writeFile(join(memoryDir, MEMORY_MD), serializeIndex(updated) + "\n", "utf8");
88
+
89
+ // remove ## block from topic file
90
+ try {
91
+ const raw = await readFile(topicPath, "utf8");
92
+ const afterRemoval = removeEntrySection(raw, p.entry);
93
+ if (hasEntries(afterRemoval)) {
94
+ const refreshed = updateFrontmatterDate(afterRemoval, today());
95
+ await writeFile(topicPath, refreshed, "utf8");
96
+ } else {
97
+ await unlink(topicPath).catch(() => {});
98
+ }
99
+ } catch (e: any) {
100
+ // topic file missing — still ok, index already removed
101
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
102
+ }
80
103
 
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
104
  return { ok: true };
95
105
  });
96
106
  }
97
107
 
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[];
108
+ export async function doRead(memoryDir: string, p: ReadParams): Promise<ReadResult> {
103
109
  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" }] : [];
110
+ const topicName = p.topic.endsWith(".md") ? p.topic : `${p.topic}.md`;
111
+ let topicPath: string;
112
+ try {
113
+ topicPath = safeTopicPath(memoryDir, topicName);
114
+ } catch (e: any) {
115
+ return { ok: false, error: e.message };
116
+ }
117
+ try {
118
+ const content = await readFile(topicPath, "utf8");
119
+ return { ok: true, content };
120
+ } catch {
121
+ return { ok: false, error: `Topic "${p.topic}" not found` };
122
+ }
109
123
  }
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");
124
+ if (p.entry) {
125
+ const files = (await readdir(memoryDir).catch(() => [])).filter(
126
+ (f) => f.endsWith(".md") && f !== MEMORY_MD,
127
+ );
128
+ for (const f of files) {
129
+ const raw = await readFile(join(memoryDir, f), "utf8").catch(() => "");
130
+ const entries = parseEntries(raw);
131
+ const found = entries.find((e) => e.title === p.entry);
132
+ if (found) {
133
+ return { ok: true, content: `## ${found.title}\n\n${found.content}` };
132
134
  }
133
135
  }
134
- return { ok: true };
135
- });
136
+ return { ok: false, error: `Entry "${p.entry}" not found in any topic` };
137
+ }
138
+ return { ok: false, error: "Either topic or entry must be provided" };
136
139
  }
137
140
 
138
- // search memory scope — implemented fully in Task 9
139
141
  export async function searchMemory(memoryDir: string, query: string): Promise<string> {
140
142
  const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== MEMORY_MD);
141
143
  const q = query.toLowerCase();
142
144
  const hits: string[] = [];
143
145
  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\`\`\``);
146
+ const raw = await readFile(join(memoryDir, f), "utf8").catch(() => "");
147
+ const entryBlocks = parseEntries(raw);
148
+ for (const entry of entryBlocks) {
149
+ if (entry.content.toLowerCase().includes(q) || entry.title.toLowerCase().includes(q)) {
150
+ hits.push(`### ${f}\n\`\`\`\n## ${entry.title}\n${entry.content}\n\`\`\``);
149
151
  }
150
152
  }
151
153
  }
@@ -165,20 +167,23 @@ export function createMemoryTool(deps: MemoryToolDeps) {
165
167
  name: "memory",
166
168
  label: "Memory",
167
169
  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
+ "Read/write project memory across sessions. action 'add' appends content under a topic (auto-created) as an entry; 'remove' deletes an entry by title; 'read' loads a topic or entry; '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).",
171
+ promptSnippet: "Read/write project memory across sessions (add/remove/search/read). Only index titles are injected — make titles self-descriptive.",
170
172
  promptGuidelines: [
171
173
  "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.",
174
+ "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.",
173
175
  "Use memory action 'search' with scope='sessions' to find past work in history sessions.",
176
+ "Use memory action 'read' with topic or entry to load stored knowledge.",
174
177
  ],
175
178
  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." })),
179
+ action: StringEnum(["add", "remove", "search", "read"] as const),
180
+ // add
181
+ content: Type.Optional(Type.String({ description: "Knowledge text to store (add)." })),
182
+ topic: Type.Optional(Type.String({ description: "Target topic filename, e.g. 'debugging.md'. Auto-created if new (add/read)." })),
183
+ title: Type.Optional(Type.String({ description: "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." })),
184
+ // remove
185
+ entry: Type.Optional(Type.String({ description: "Entry title to remove. Exact match on MEMORY.md index line (remove/read)." })),
186
+ // search
182
187
  query: Type.Optional(Type.String()),
183
188
  scope: Type.Optional(StringEnum(["memory", "sessions"] as const)),
184
189
  }),
@@ -205,24 +210,18 @@ export function createMemoryTool(deps: MemoryToolDeps) {
205
210
  case "add": {
206
211
  if (!params.content) throw new Error("content is required for add");
207
212
  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 });
213
+ if (!params.title) throw new Error("title is required for add");
214
+ const r = await doAdd(dir, { content: params.content, topic: params.topic, title: params.title, maxLines: cfg.memIndexMaxLines, maxBytes: cfg.memIndexMaxBytes });
209
215
  if (!r.ok) throw new Error(r.error);
210
- text = `Added to ${params.topic}. Index now has ${r.entries?.length ?? 0} entries.`;
216
+ text = `Added "${params.title}" to ${params.topic}. Index now has ${r.entries?.length ?? 0} entries.`;
211
217
  details = { entries: r.entries?.length };
212
218
  break;
213
219
  }
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
220
  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 });
221
+ if (!params.entry) throw new Error("entry is required for remove");
222
+ const r = await doRemove(dir, { entry: params.entry });
224
223
  if (!r.ok) throw new Error(r.error);
225
- text = "Removed.";
224
+ text = `Removed entry "${params.entry}".`;
226
225
  break;
227
226
  }
228
227
  case "search": {
@@ -234,6 +233,13 @@ export function createMemoryTool(deps: MemoryToolDeps) {
234
233
  }
235
234
  break;
236
235
  }
236
+ case "read": {
237
+ if (!params.topic && !params.entry) throw new Error("topic or entry is required for read");
238
+ const r = await doRead(dir, { topic: params.topic, entry: params.entry });
239
+ if (!r.ok) throw new Error(r.error);
240
+ text = r.content!;
241
+ break;
242
+ }
237
243
  default:
238
244
  throw new Error(`Unknown action: ${params.action}`);
239
245
  }
package/src/topic-file.ts CHANGED
@@ -1,27 +1,91 @@
1
1
  export interface TopicMeta {
2
- name: string;
3
- description: string;
4
- type: string;
5
- updated: string;
2
+ updated: string;
6
3
  }
7
4
 
8
5
  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
- ---
6
+ return `---\nupdated: ${meta.updated}\n---\n\n`;
7
+ }
8
+
9
+ export function appendContent(
10
+ existing: string | null,
11
+ entryTitle: string,
12
+ content: string,
13
+ ): string {
14
+ const section = `## ${entryTitle}\n\n${content}`;
15
+ if (!existing || existing.trim() === "") return section;
16
+ return `${existing.trimEnd()}\n\n${section}\n`;
17
+ }
18
+
19
+ export function updateFrontmatterDate(raw: string, date: string): string {
20
+ return raw.replace(/^(---\n)updated: .+(\n---)/m, `$1updated: ${date}$2`);
21
+ }
22
+
23
+ export function removeEntrySection(raw: string, title: string): string {
24
+ const marker = `## ${title}`;
25
+ // find start of this entry block
26
+ const startIdx = raw.indexOf(`\n${marker}\n`) !== -1
27
+ ? raw.indexOf(`\n${marker}\n`)
28
+ : raw.indexOf(marker);
29
+ if (startIdx === -1) throw new Error(`Entry "${title}" not found`);
15
30
 
16
- `;
31
+ // find start of next ## or EOF
32
+ const afterHeader = raw.indexOf("\n", startIdx + marker.length);
33
+ const nextH2 = raw.indexOf("\n## ", afterHeader + 1);
34
+ const endIdx = nextH2 === -1 ? raw.length : nextH2;
35
+
36
+ // remove including preceding blank lines
37
+ let cutStart = startIdx;
38
+ while (cutStart > 0 && raw[cutStart - 1] === "\n") cutStart--;
39
+ // also strip one more \n if present (the blank line separator)
40
+ if (cutStart > 0 && raw[cutStart - 1] === "\n") cutStart--;
41
+
42
+ let result = raw.slice(0, cutStart) + raw.slice(endIdx);
43
+ // ensure exactly one trailing newline
44
+ result = result.replace(/\n{3,}$/, "\n\n").replace(/\n{2,}$/, "\n");
45
+ if (!result.endsWith("\n")) result += "\n";
46
+ return result;
47
+ }
48
+
49
+ export function hasEntries(raw: string): boolean {
50
+ return /^## /m.test(raw);
17
51
  }
18
52
 
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`;
53
+ export interface ParsedEntry {
54
+ title: string;
55
+ content: string;
23
56
  }
24
57
 
25
- export function isEmptyAfterRemove(content: string): boolean {
26
- return content.trim() === "";
58
+ export function parseEntries(raw: string): ParsedEntry[] {
59
+ const entries: ParsedEntry[] = [];
60
+ const lines = raw.split("\n");
61
+ let currentTitle = "";
62
+ let currentContent: string[] = [];
63
+ let inEntry = false;
64
+ let inFrontmatter = false;
65
+
66
+ for (const line of lines) {
67
+ if (line === "---") {
68
+ inFrontmatter = !inFrontmatter;
69
+ continue;
70
+ }
71
+ if (inFrontmatter) continue;
72
+
73
+ const h2 = line.match(/^## (.+)$/);
74
+ if (h2) {
75
+ if (inEntry) {
76
+ entries.push({ title: currentTitle, content: currentContent.join("\n").trim() });
77
+ }
78
+ currentTitle = h2[1];
79
+ currentContent = [];
80
+ inEntry = true;
81
+ continue;
82
+ }
83
+ if (inEntry) {
84
+ currentContent.push(line);
85
+ }
86
+ }
87
+ if (inEntry) {
88
+ entries.push({ title: currentTitle, content: currentContent.join("\n").trim() });
89
+ }
90
+ return entries;
27
91
  }