@yandy0725/pi-memory 0.1.0 → 0.3.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 +14 -17
- package/README.zh.md +14 -17
- package/package.json +1 -1
- package/src/dream.ts +10 -8
- package/src/index-file.ts +71 -49
- package/src/memory-tool.ts +112 -106
- package/src/topic-file.ts +81 -17
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` (
|
|
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" | "
|
|
72
|
-
content?, topic?, title?,
|
|
73
|
-
|
|
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
|
-
|
|
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`** (
|
|
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
|
-
### `
|
|
84
|
+
### `remove`
|
|
86
85
|
|
|
87
|
-
|
|
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
|
-
- **`
|
|
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
|
-
### `
|
|
90
|
+
### `read`
|
|
94
91
|
|
|
95
|
-
|
|
92
|
+
Loads memory content. Either an entire topic file or a single entry block.
|
|
96
93
|
|
|
97
|
-
- **`
|
|
98
|
-
- **`
|
|
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
|
|
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" | "
|
|
72
|
-
content?, topic?, title?,
|
|
73
|
-
|
|
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
|
-
|
|
78
|
+
向主题文件追加条目,并向 MEMORY.md 索引添加新行(不更新已有行——一个主题可以有多个条目)。
|
|
79
79
|
|
|
80
80
|
- **`content`**(必填)— 要持久化的知识文本
|
|
81
81
|
- **`topic`**(必填)— 目标文件名,如 `"debugging.md"`,不存在则自动创建
|
|
82
|
-
- **`title
|
|
83
|
-
- **`description`**(可选)— 一行描述(默认取内容前 ~80 字符)
|
|
82
|
+
- **`title`**(必填)— 索引行和条目标题
|
|
84
83
|
|
|
85
|
-
### `
|
|
84
|
+
### `remove`
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
按标题精确匹配删除条目。同时删除索引行和主题文件中对应的 `##` 区块。当主题文件中的最后一条被删除后,主题文件会被清理。
|
|
88
87
|
|
|
89
|
-
- **`
|
|
90
|
-
- **`content`**(必填)— 替换文本
|
|
91
|
-
- **`topic`**(可选)— 限定搜索范围到指定文件;当文本在多个位置出现时必须提供
|
|
88
|
+
- **`entry`**(必填)— 要删除的条目标题
|
|
92
89
|
|
|
93
|
-
### `
|
|
90
|
+
### `read`
|
|
94
91
|
|
|
95
|
-
|
|
92
|
+
加载记忆内容。可以是整个主题文件或单个条目区块。
|
|
96
93
|
|
|
97
|
-
- **`
|
|
98
|
-
- **`
|
|
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
package/src/dream.ts
CHANGED
|
@@ -11,20 +11,22 @@ 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,
|
|
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
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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
|
+
- When done, output a concise summary of changes (merged N, removed N, moved N, updated N).`;
|
|
21
22
|
|
|
22
23
|
export function buildDreamTask(memoryDir: string, maxLines: number): string {
|
|
23
|
-
|
|
24
|
-
1. Deduplicate entries that say the same thing.
|
|
24
|
+
return `Consolidate the memory files under ${memoryDir}. Read every .md file (including MEMORY.md), then:
|
|
25
|
+
1. Deduplicate entries within each topic that say the same thing.
|
|
25
26
|
2. Merge contradictory or overlapping entries into one accurate entry.
|
|
26
27
|
3. Update outdated information.
|
|
27
|
-
4.
|
|
28
|
+
4. Move entries to more appropriate topic files when needed.
|
|
29
|
+
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.
|
|
28
30
|
Only edit files under ${memoryDir}. When finished, print a one-line summary of changes.`;
|
|
29
31
|
}
|
|
30
32
|
|
package/src/index-file.ts
CHANGED
|
@@ -1,60 +1,82 @@
|
|
|
1
1
|
export interface IndexEntry {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
raw: string;
|
|
2
|
+
title: string;
|
|
3
|
+
topic: string;
|
|
4
|
+
raw: string;
|
|
6
5
|
}
|
|
7
6
|
export interface IndexFile {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
entries: IndexEntry[];
|
|
8
|
+
raw: string;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
// Matches: - [Title](topic.md)
|
|
13
|
-
const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s
|
|
11
|
+
// Matches: - [Title](topic.md)
|
|
12
|
+
const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
|
14
13
|
|
|
15
14
|
export function parseIndex(content: string): IndexFile {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
}
|
package/src/memory-tool.ts
CHANGED
|
@@ -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,
|
|
8
|
-
import { buildFrontmatter, appendContent,
|
|
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
|
|
12
|
-
export interface
|
|
13
|
-
export interface
|
|
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
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
hits.push(`### ${f}\n\`\`\`\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'
|
|
169
|
-
promptSnippet: "Read/write project memory across sessions (add/
|
|
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.",
|
|
171
|
+
promptSnippet: "Read/write project memory across sessions (add/remove/search/read).",
|
|
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 entry title when you discover something worth remembering long-term.",
|
|
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", "
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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: "Short title for the MEMORY.md index line (add). 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
|
-
|
|
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.
|
|
223
|
-
const r = await doRemove(dir, {
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
26
|
-
|
|
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
|
}
|