@yandy0725/pi-memory 1.4.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -140
- package/README.zh.md +264 -157
- package/index.ts +460 -117
- package/package.json +3 -3
- package/src/agent-runner.ts +25 -9
- package/src/config.ts +81 -6
- package/src/dream.ts +99 -59
- package/src/entry-file.ts +81 -0
- package/src/entry-index.ts +112 -0
- package/src/extract.ts +378 -60
- package/src/filename.ts +48 -0
- package/src/fs-lock.ts +251 -0
- package/src/index-source.ts +186 -0
- package/src/inject.ts +129 -77
- package/src/memory-store.ts +496 -0
- package/src/memory-tool.ts +263 -328
- package/src/model-resolver.ts +1 -1
- package/src/paths.ts +1 -14
- package/src/process-lock.ts +122 -0
- package/src/sanitize.ts +36 -0
- package/src/snapshot.ts +68 -0
- package/src/index-file.ts +0 -91
- package/src/topic-file.ts +0 -119
package/src/inject.ts
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
4
1
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
import { runHeadlessAgent } from "./agent-runner";
|
|
6
3
|
import type { SessionPersistenceConfig, ThinkLevel } from "./config";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
4
|
+
import type { EntryType } from "./entry-file";
|
|
5
|
+
import { MEMORY_INDEX_SECTION } from "./index-source";
|
|
6
|
+
import type { MemoryStore } from "./memory-store";
|
|
7
|
+
import { sanitizeForInjection } from "./sanitize";
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
/**
|
|
10
|
+
* 注入用的「按行 + 按字节」截断。原本住在 `index-file.ts`(topic 模型的索引层),
|
|
11
|
+
* 但它的两个消费者都在本文件里(索引快照 + entry 正文),因此随 v2 迁到此处,行为逐字不变。
|
|
12
|
+
*/
|
|
13
|
+
export function truncateForInjection(
|
|
14
|
+
content: string,
|
|
15
|
+
maxLines: number,
|
|
16
|
+
maxBytes: number,
|
|
17
|
+
): { ok: boolean; content: string; truncated: boolean } {
|
|
18
|
+
const lines = content.split("\n");
|
|
19
|
+
let out = content;
|
|
20
|
+
let truncated = false;
|
|
21
|
+
if (lines.length > maxLines) {
|
|
22
|
+
out = lines.slice(0, maxLines).join("\n");
|
|
23
|
+
truncated = true;
|
|
17
24
|
}
|
|
25
|
+
if (Buffer.byteLength(out, "utf8") > maxBytes) {
|
|
26
|
+
let cut = out;
|
|
27
|
+
while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
|
|
28
|
+
out = cut;
|
|
29
|
+
truncated = true;
|
|
30
|
+
}
|
|
31
|
+
if (truncated) out += `\n[truncated: memory index exceeds injection limit]`;
|
|
32
|
+
return { ok: !truncated, content: out, truncated };
|
|
18
33
|
}
|
|
19
34
|
|
|
20
35
|
export function buildInjection(systemPrompt: string, snapshot: string): string {
|
|
@@ -22,67 +37,105 @@ export function buildInjection(systemPrompt: string, snapshot: string): string {
|
|
|
22
37
|
return `${systemPrompt}\n\n${snapshot}`;
|
|
23
38
|
}
|
|
24
39
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `memory_index` section 的值(spec §9.1 / D13):读索引 → 截断 → 净化。
|
|
42
|
+
*
|
|
43
|
+
* **不再自己加 `# Memory Index\n` 前缀**:v1 的索引快照函数会补一份头部,而 v2 的
|
|
44
|
+
* `MEMORY.md` 由 `rebuildIndex`(默认头部就是 `# Memory Index`)写入 —— 再补一次
|
|
45
|
+
* 注入文本里就有两份标题。
|
|
46
|
+
*
|
|
47
|
+
* 空索引返回 `""`(调用方仍要把 `""` 无条件写进 sections,见 `applyIndexSection`)。
|
|
48
|
+
*
|
|
49
|
+
* **顺序是「先截断、后净化」,刻意如此**(Plan C 终审 #6 的决策:保留现状)。注入预算是
|
|
50
|
+
* **软**约束,而反过来(先净化后截断)会在字节上限处切出半个 HTML entity(`…&l`)——
|
|
51
|
+
* 那才是真正会让模型读错的破损。代价:`<` → `<` 会让净化后的字节数略超 `maxBytes`
|
|
52
|
+
* (索引正文里尖括号极少,实际放大可忽略)。`injectSurfacedContent` 同理。
|
|
53
|
+
*/
|
|
54
|
+
export async function buildIndexSection(store: MemoryStore, maxLines: number, maxBytes: number): Promise<string> {
|
|
55
|
+
const raw = await store.readIndex();
|
|
56
|
+
const { content } = truncateForInjection(raw, maxLines, maxBytes);
|
|
57
|
+
return sanitizeForInjection(content);
|
|
31
58
|
}
|
|
32
59
|
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
/**
|
|
61
|
+
* 把冻结的索引值写进 `event.systemPromptOptions.sections`(就地修改这个可变对象)。
|
|
62
|
+
*
|
|
63
|
+
* 返回 `false` 表示宿主 SDK 太旧、没有 `sections`(本地类型是 0.80.2),调用方必须回退
|
|
64
|
+
* `{ systemPrompt: buildInjection(…) }`(spec §19 的退路:功能不受损,只是缓存变差)。
|
|
65
|
+
*
|
|
66
|
+
* **无条件设置**,哪怕值是 `""`:省略这个键等于告诉 pi「该 section 不应存在」,
|
|
67
|
+
* `diffSystemPromptSections` 会生成 `{ memory_index: null }`,索引被从 system prompt 里
|
|
68
|
+
* **静默删除**(spec §9.1 的 null 陷阱)。「不想改」只能靠喂回逐字节相同的值实现。
|
|
69
|
+
*/
|
|
70
|
+
export function applyIndexSection(systemPromptOptions: unknown, value: string): boolean {
|
|
71
|
+
const options = systemPromptOptions as { sections?: unknown } | null | undefined;
|
|
72
|
+
const sections = options?.sections;
|
|
73
|
+
if (typeof sections !== "object" || sections === null) return false;
|
|
74
|
+
(sections as Record<string, string | null>)[MEMORY_INDEX_SECTION] = value;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
35
77
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const s = await stat(join(memoryDir, f));
|
|
45
|
-
mtimeMs = s.mtimeMs;
|
|
46
|
-
} catch {
|
|
47
|
-
/* ignore */
|
|
48
|
-
}
|
|
49
|
-
manifests.push({
|
|
50
|
-
filename: f,
|
|
51
|
-
name: meta.name,
|
|
52
|
-
description: meta.description,
|
|
53
|
-
type: meta.type,
|
|
54
|
-
mtimeMs,
|
|
55
|
-
});
|
|
56
|
-
} catch {
|
|
57
|
-
/* skip unreadable files */
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return manifests.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
78
|
+
/** 侧查询清单的一行 = 一条 entry(v1 是一个 topic 文件)。 */
|
|
79
|
+
export interface EntryManifest {
|
|
80
|
+
file: string;
|
|
81
|
+
name: string;
|
|
82
|
+
description: string;
|
|
83
|
+
type: EntryType;
|
|
84
|
+
modified: string;
|
|
61
85
|
}
|
|
62
86
|
|
|
87
|
+
/** 清单总预算(字符)。v1 把每条 description 硬切成 80 字符,v2 改为按清单长度均分。 */
|
|
88
|
+
export const SIDE_QUERY_MANIFEST_CHARS = 4000;
|
|
89
|
+
/** 均分的下限:清单再长,每条也至少留 80 字符,否则侧查询没有判别依据。 */
|
|
90
|
+
export const SIDE_QUERY_MIN_DESC_CHARS = 80;
|
|
91
|
+
/**
|
|
92
|
+
* 清单条数上限。恢复了 v1 的界:v1 的候选集合就是 MEMORY.md 索引本身,而索引有
|
|
93
|
+
* `memIndexMaxLines`(默认 200)行上限,所以侧查询看到的条目天然有界。v2 改成扫描目录后
|
|
94
|
+
* 清单会随目录无限增长,而 `buildSideQueryTask` 每行都要带 description —— 不封顶会把 prompt
|
|
95
|
+
* 和每轮迭代开销一起拉爆。200 与索引上限同量级。
|
|
96
|
+
*/
|
|
97
|
+
export const SIDE_QUERY_MAX_ENTRIES = 200;
|
|
63
98
|
|
|
99
|
+
/**
|
|
100
|
+
* 从 store 取清单。**不逐文件 readFile** —— mtime 缓存由 store 持有(spec §9.2),
|
|
101
|
+
* 于是每轮只付一次 `readdir` + 每文件一次 `stat`,而不是最多 200 次 `readFile`。
|
|
102
|
+
*
|
|
103
|
+
* 排序为 `modified` **降序**:新记忆优先给侧查询看(`listEntries` 是升序,这里翻过来),
|
|
104
|
+
* 然后截到 `SIDE_QUERY_MAX_ENTRIES`(Finding I2)。
|
|
105
|
+
*/
|
|
106
|
+
export async function scanEntries(store: MemoryStore): Promise<EntryManifest[]> {
|
|
107
|
+
const summaries = await store.listEntries();
|
|
108
|
+
return summaries
|
|
109
|
+
.map((s) => ({ file: s.file, name: s.name, description: s.description, type: s.type, modified: s.modified }))
|
|
110
|
+
.sort((a, b) =>
|
|
111
|
+
a.modified === b.modified ? a.file.localeCompare(b.file) : b.modified.localeCompare(a.modified),
|
|
112
|
+
)
|
|
113
|
+
.slice(0, SIDE_QUERY_MAX_ENTRIES);
|
|
114
|
+
}
|
|
64
115
|
|
|
65
116
|
export async function injectSurfacedContent(
|
|
66
|
-
|
|
117
|
+
store: MemoryStore,
|
|
67
118
|
selectedFiles: string[],
|
|
68
|
-
|
|
119
|
+
maxEntryBytes: number,
|
|
69
120
|
maxInjectionBytes: number,
|
|
70
121
|
): Promise<string> {
|
|
71
122
|
const blocks: string[] = [];
|
|
72
123
|
let totalBytes = 0;
|
|
73
124
|
|
|
74
|
-
for (const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
for (const file of selectedFiles) {
|
|
126
|
+
// 经 store 读:v2 的注入单位是 entry 的**正文**,不是整个文件(frontmatter 不进上下文)。
|
|
127
|
+
const entry = await store.readEntry(file);
|
|
128
|
+
if (!entry) continue;
|
|
129
|
+
const { content } = truncateForInjection(entry.body, 999999, maxEntryBytes);
|
|
130
|
+
// spec §13:正文与 name 都要净化(用户/模型写进磁盘的内容可能含 `</relevant_memories>`
|
|
131
|
+
// 之类的仿冒标签)。包裹标签是我们自己生成的,不净化。
|
|
132
|
+
// 先截断后净化的取舍见 `buildIndexSection` 的注释(Plan C 终审 #6:预算是软约束,
|
|
133
|
+
// 而「净化后截断」会切出半个 entity)。
|
|
134
|
+
const block = `## ${sanitizeForInjection(entry.name)}\n${sanitizeForInjection(content)}`;
|
|
135
|
+
const blockBytes = Buffer.byteLength(block, "utf8");
|
|
136
|
+
if (totalBytes + blockBytes > maxInjectionBytes) break;
|
|
137
|
+
blocks.push(block);
|
|
138
|
+
totalBytes += blockBytes;
|
|
86
139
|
}
|
|
87
140
|
|
|
88
141
|
if (blocks.length === 0) return "";
|
|
@@ -90,20 +143,19 @@ export async function injectSurfacedContent(
|
|
|
90
143
|
}
|
|
91
144
|
|
|
92
145
|
/** Build the side-query task prompt. */
|
|
93
|
-
export function buildSideQueryTask(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const lines = manifest.map((t) =>
|
|
99
|
-
`[${t.type}] ${t.filename} — ${t.description.slice(0, 80)}`,
|
|
146
|
+
export function buildSideQueryTask(manifest: EntryManifest[], userPrompt: string, maxFiles: number): string {
|
|
147
|
+
// `Math.max(1, …)`:空清单不会走到这里(runSideQuery 先返回 []),但除零会得到 Infinity。
|
|
148
|
+
const perEntry = Math.max(
|
|
149
|
+
SIDE_QUERY_MIN_DESC_CHARS,
|
|
150
|
+
Math.floor(SIDE_QUERY_MANIFEST_CHARS / Math.max(1, manifest.length)),
|
|
100
151
|
);
|
|
152
|
+
const lines = manifest.map((e) => `[${e.type}] ${e.file} — ${e.description.slice(0, perEntry)}`);
|
|
101
153
|
|
|
102
154
|
return [
|
|
103
|
-
`You are a memory relevance selector. Select up to ${maxFiles}
|
|
155
|
+
`You are a memory relevance selector. Select up to ${maxFiles} memory files most relevant to the user query.`,
|
|
104
156
|
"If nothing matches, select none.",
|
|
105
157
|
"",
|
|
106
|
-
"===
|
|
158
|
+
"=== Memory Files ===",
|
|
107
159
|
...lines,
|
|
108
160
|
"",
|
|
109
161
|
"=== User Query ===",
|
|
@@ -114,33 +166,32 @@ export function buildSideQueryTask(
|
|
|
114
166
|
}
|
|
115
167
|
|
|
116
168
|
/** Parse selected_files JSON from headless agent response. */
|
|
117
|
-
function parseSelectedFiles(result: string, candidates:
|
|
169
|
+
function parseSelectedFiles(result: string, candidates: EntryManifest[], maxFiles: number): string[] {
|
|
118
170
|
try {
|
|
119
171
|
const jsonMatch = result.match(/\{[^}]*"selected_files"[^}]*\}/s);
|
|
120
172
|
if (!jsonMatch) return [];
|
|
121
173
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
122
174
|
const files: string[] = parsed.selected_files ?? [];
|
|
123
|
-
return files.filter((f: string) => candidates.some((c) => c.
|
|
175
|
+
return files.filter((f: string) => candidates.some((c) => c.file === f)).slice(0, maxFiles);
|
|
124
176
|
} catch {
|
|
125
177
|
return [];
|
|
126
178
|
}
|
|
127
179
|
}
|
|
128
180
|
|
|
129
|
-
/** Run a lightweight headless side-query to select relevant
|
|
181
|
+
/** Run a lightweight headless side-query to select relevant entry files.
|
|
130
182
|
* Returns [] on timeout/failure — no fallback. */
|
|
131
183
|
export async function runSideQuery(
|
|
132
|
-
manifest:
|
|
184
|
+
manifest: EntryManifest[],
|
|
133
185
|
userPrompt: string,
|
|
134
|
-
|
|
186
|
+
injectedFiles: Set<string>,
|
|
135
187
|
maxFiles: number,
|
|
136
188
|
thinkLevel: ThinkLevel,
|
|
137
|
-
model: string
|
|
189
|
+
model: string,
|
|
138
190
|
modelRegistry: ModelRegistry,
|
|
139
|
-
parentModel: Model<any> | undefined,
|
|
140
191
|
memoryDir: string,
|
|
141
192
|
sessionPersistence?: SessionPersistenceConfig,
|
|
142
193
|
): Promise<string[]> {
|
|
143
|
-
const candidates = manifest.filter((
|
|
194
|
+
const candidates = manifest.filter((entry) => !injectedFiles.has(entry.file));
|
|
144
195
|
if (candidates.length === 0) return [];
|
|
145
196
|
const task = buildSideQueryTask(candidates, userPrompt, maxFiles);
|
|
146
197
|
try {
|
|
@@ -149,10 +200,11 @@ export async function runSideQuery(
|
|
|
149
200
|
cwd: memoryDir,
|
|
150
201
|
modelRegistry,
|
|
151
202
|
model,
|
|
152
|
-
parentModel,
|
|
153
203
|
thinkLevel,
|
|
154
204
|
maxTurns: 1,
|
|
155
205
|
timeoutMs: 30_000,
|
|
206
|
+
// 侧查询没有任何 customTools,零工具就是意图:tools: [](白名单)把 builtin 也关掉。
|
|
207
|
+
// 若以后要给它加 customTools,必须改成 noTools: "builtin"(见 agent-runner.ts 的警告)。
|
|
156
208
|
tools: [],
|
|
157
209
|
sessionPersistence,
|
|
158
210
|
});
|