@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/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 { truncateForInjection } from "./index-file";
8
- import { parseFrontmatter } from "./topic-file";
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
- export async function loadIndexSnapshot(memoryDir: string, maxLines: number, maxBytes: number): Promise<string> {
11
- try {
12
- const raw = await readFile(join(memoryDir, "MEMORY.md"), "utf8");
13
- const { content } = truncateForInjection(raw, maxLines, maxBytes);
14
- return content ? `# Memory Index\n${content}` : "";
15
- } catch {
16
- return "";
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
- export interface TopicManifest {
26
- filename: string;
27
- name: string;
28
- description: string;
29
- type: string;
30
- mtimeMs: number;
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
+ * 那才是真正会让模型读错的破损。代价:`<` → `&lt;` 会让净化后的字节数略超 `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
- export async function scanTopics(memoryDir: string): Promise<TopicManifest[]> {
34
- const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== "MEMORY.md");
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
- const manifests: TopicManifest[] = [];
37
- for (const f of files.slice(0, 200)) {
38
- try {
39
- const raw = await readFile(join(memoryDir, f), "utf8");
40
- const meta = parseFrontmatter(raw);
41
- if (!meta) continue;
42
- let mtimeMs = 0;
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
- memoryDir: string,
117
+ store: MemoryStore,
67
118
  selectedFiles: string[],
68
- maxTopicBytes: number,
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 f of selectedFiles) {
75
- try {
76
- const raw = await readFile(join(memoryDir, f), "utf8");
77
- const { content } = truncateForInjection(raw, 999999, maxTopicBytes);
78
- const block = `## ${f}\n${content}`;
79
- const blockBytes = Buffer.byteLength(block, "utf8");
80
- if (totalBytes + blockBytes > maxInjectionBytes) break;
81
- blocks.push(block);
82
- totalBytes += blockBytes;
83
- } catch {
84
- /* skip unreadable files */
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
- manifest: TopicManifest[],
95
- userPrompt: string,
96
- maxFiles: number,
97
- ): string {
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} topic files most relevant to the user query.`,
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
- "=== Topic Files ===",
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: TopicManifest[], maxFiles: number): string[] {
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.filename === f)).slice(0, maxFiles);
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 topic files.
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: TopicManifest[],
184
+ manifest: EntryManifest[],
133
185
  userPrompt: string,
134
- injectedTopics: Set<string>,
186
+ injectedFiles: Set<string>,
135
187
  maxFiles: number,
136
188
  thinkLevel: ThinkLevel,
137
- model: string | undefined,
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((t) => !injectedTopics.has(t.filename));
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
  });