@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.
package/src/fs-lock.ts ADDED
@@ -0,0 +1,251 @@
1
+ import { link, open, readFile, rm, unlink } from "node:fs/promises";
2
+ import { hostname } from "node:os";
3
+
4
+ /**
5
+ * **跨进程**锁。只保护「毫秒级的物理写入」这一件事。
6
+ *
7
+ * 作用域分工(见 process-lock.ts 的说明):进程内的**逻辑作用域**(单次调用、dream 整轮)由
8
+ * `process-lock.ts` 的 Promise 队列承担,因此这里永远只被持有一瞬间 —— 不存在「持有太久」,
9
+ * 也就不需要 TTL、续约心跳与存活探测。
10
+ *
11
+ * **永不自动回收**:另一个进程崩溃留下的锁没有任何人能释放它,但**也不会被自动删掉**。
12
+ * 原因是「移走别人的锁」无法用 POSIX 原语做到可证明安全:`link` 这个合法获取原语的条件正是
13
+ * 「锁路径不存在」,所以任何「先移走旧锁、再建立自己的」的接管都会产生一个空窗,其它等待者
14
+ * 可以合法地抢占它;一旦移走的其实是某个**活持有者**刚建立的记录,互斥就无法再恢复(把记录
15
+ * 挪回去又会顶掉抢占者,而路径只能容纳一条记录)。实测:把 `rm` 换成原子 `rename` 仍然会双持有,
16
+ * 加上「比字节 + 放回」则会把空窗拉长,同进程内可稳定复现双持有。
17
+ *
18
+ * 因此这里的策略是「安全优先」:崩溃遗留的锁**立刻**报一条可操作的错误(写明 pid / op /
19
+ * startedAt / 路径,并提示如何清除),由人(或 Plan B 的显式 `/memory unlock`)处理。
20
+ */
21
+ export interface LockInfo {
22
+ pid: number;
23
+ hostname: string;
24
+ startedAt: string;
25
+ op: string;
26
+ }
27
+
28
+ export interface LockOptions {
29
+ timeoutMs: number;
30
+ pollMs?: number;
31
+ now?: () => number;
32
+ }
33
+
34
+ export class MemoryLockedError extends Error {
35
+ constructor(
36
+ readonly lockPath: string,
37
+ readonly holder: LockInfo | null,
38
+ readonly op: string,
39
+ /** 记录指向一个已死的本机进程,或本身无法解释 —— 没有任何人会释放它,只能人工清除。 */
40
+ readonly abandoned: boolean,
41
+ ) {
42
+ const described = holder
43
+ ? `${holder.op} (pid ${holder.pid}, started ${holder.startedAt})`
44
+ : "an unreadable record";
45
+ super(
46
+ abandoned
47
+ ? `Memory lock at ${lockPath} is abandoned by ${described} — delete the file to clear it`
48
+ : `Memory is locked by ${described}`,
49
+ );
50
+ this.name = "MemoryLockedError";
51
+ }
52
+ }
53
+
54
+ function sleep(ms: number): Promise<void> {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }
57
+
58
+ /** 仅用于诊断(决定错误文案,不参与任何回收决策)。 */
59
+ function isProcessAlive(pid: number): boolean {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ } catch (e) {
64
+ // 只有 ESRCH(无此进程)才算死亡;EPERM 是「存在但无权限发信号」,其余意外 errno 一并按存活处理。
65
+ return (e as NodeJS.ErrnoException).code !== "ESRCH";
66
+ }
67
+ }
68
+
69
+ function isLockInfo(value: unknown): value is LockInfo {
70
+ if (typeof value !== "object" || value === null) return false;
71
+ const record = value as Record<string, unknown>;
72
+ return (
73
+ typeof record.pid === "number" &&
74
+ Number.isFinite(record.pid) &&
75
+ typeof record.hostname === "string" &&
76
+ typeof record.startedAt === "string" &&
77
+ typeof record.op === "string"
78
+ );
79
+ }
80
+
81
+ type LockRead =
82
+ /** 锁路径不存在 —— 这是「空闲」,不是「有问题」。 */
83
+ | { kind: "absent" }
84
+ /** 存在但无法解释(非 JSON、空文件、形状不对、读不到):只有人工能清除它。 */
85
+ | { kind: "unreadable" }
86
+ | { kind: "held"; holder: LockInfo };
87
+
88
+ /**
89
+ * 读锁的三种状态。**必须区分「不存在」与「存在但读不懂」** —— 把前者当成后者会让
90
+ * 「持有者刚释放、锁刚被删」被误报成「遗弃的锁」,从而在正常竞争下抛出误导性的错误。
91
+ */
92
+ async function readLockState(lockPath: string): Promise<LockRead> {
93
+ let raw: string;
94
+ try {
95
+ raw = await readFile(lockPath, "utf8");
96
+ } catch (e) {
97
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
98
+ // 权限之类的错误:无法判断内容,按「读不懂」处理(不接管、报可操作错误)
99
+ return { kind: "unreadable" };
100
+ }
101
+ try {
102
+ const parsed: unknown = JSON.parse(raw);
103
+ return isLockInfo(parsed) ? { kind: "held", holder: parsed } : { kind: "unreadable" };
104
+ } catch {
105
+ return { kind: "unreadable" };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * 锁的诊断视图(spec §14 的 `/memory`)。
111
+ *
112
+ * **复用私有的 `readLockState`**,不另写一份读逻辑:三态必须与获取路径同源,
113
+ * 否则会出现「`/memory` 说 free,下一次写入却报 locked」这种无法诊断的矛盾。
114
+ * 它**不判断存活、也不删任何东西**(永不自动回收);持有者是否已死由调用方自己看 pid。
115
+ */
116
+ export async function readLockStatus(
117
+ lockPath: string,
118
+ ): Promise<{ kind: "absent" } | { kind: "unreadable" } | { kind: "held"; holder: LockInfo }> {
119
+ return readLockState(lockPath);
120
+ }
121
+
122
+ type AcquireOutcome =
123
+ | { acquired: true }
124
+ | { acquired: false; holder: LockInfo | null; abandoned: boolean };
125
+
126
+ /**
127
+ * 尝试一次获取。路径为空时不当作失败 —— 那是「刚好被释放」,直接再试一次 link(CAS 会自然地
128
+ * 决出唯一赢家);两次都撞上「刚好被释放」才交回给调用方重试。
129
+ */
130
+ async function acquireOnce(lockPath: string, info: LockInfo): Promise<AcquireOutcome> {
131
+ for (let round = 0; round < 2; round++) {
132
+ if (await writeExclusive(lockPath, info)) return { acquired: true };
133
+ const state = await readLockState(lockPath);
134
+ if (state.kind === "absent") continue;
135
+ if (state.kind === "unreadable") return { acquired: false, holder: null, abandoned: true };
136
+ return { acquired: false, holder: state.holder, abandoned: isAbandoned(state.holder) };
137
+ }
138
+ return { acquired: false, holder: null, abandoned: false };
139
+ }
140
+
141
+ let tempCounter = 0;
142
+
143
+ /**
144
+ * 原子获取:先把持有者信息写进同目录的唯一临时文件,再 `link` 到锁路径。
145
+ *
146
+ * 不能用 `open(lockPath, "wx")` 后紧接着单独写内容 —— 那会让锁路径出现「存在但 0 字节」的
147
+ * 中间态,等待者读到空文件会把它判为「无法解释」并据为己有。link 是原子的:锁路径要么不存在,
148
+ * 要么内容是完整的 JSON。
149
+ */
150
+ async function writeExclusive(lockPath: string, info: LockInfo): Promise<boolean> {
151
+ tempCounter += 1;
152
+ const tempPath = `${lockPath}.${process.pid}.${tempCounter}.tmp`;
153
+ try {
154
+ const handle = await open(tempPath, "wx");
155
+ try {
156
+ await handle.writeFile(JSON.stringify(info), "utf8");
157
+ } finally {
158
+ await handle.close();
159
+ }
160
+ try {
161
+ await link(tempPath, lockPath);
162
+ return true;
163
+ } catch (e) {
164
+ if ((e as NodeJS.ErrnoException).code === "EEXIST") return false;
165
+ throw e;
166
+ }
167
+ } finally {
168
+ await unlink(tempPath).catch(() => {});
169
+ }
170
+ }
171
+
172
+ function holderInfo(op: string, now: number): LockInfo {
173
+ return { pid: process.pid, hostname: hostname(), startedAt: new Date(now).toISOString(), op };
174
+ }
175
+
176
+ /**
177
+ * 这把锁是否「已被遗弃」(没有任何人会释放它)。
178
+ * - 记录无法解释:没有持有者能续约,也没人能释放 —— 遗弃。
179
+ * - 同 host 且进程已死:持有者永远不会再释放它 —— 遗弃(这是唯一能确定的遗弃情形)。
180
+ * - 跨 host:存活状况不可知 —— 一律按活持有者处理,绝不接管。
181
+ */
182
+ function isAbandoned(holder: LockInfo | null): boolean {
183
+ if (holder === null) return true;
184
+ return holder.hostname === hostname() && !isProcessAlive(holder.pid);
185
+ }
186
+
187
+ /**
188
+ * 只在锁仍是自己持有的情况下删除;否则留给真正的持有者。
189
+ * 记录读不懂时也**不删** —— 那是别人的状态(或需要人工处理的状态),不该由我们清理。
190
+ */
191
+ async function releaseLock(lockPath: string): Promise<void> {
192
+ const state = await readLockState(lockPath);
193
+ if (state.kind === "unreadable") return;
194
+ if (state.kind === "held" && !isOwnRecord(state.holder)) return;
195
+ await rm(lockPath, { force: true });
196
+ }
197
+
198
+ function isOwnRecord(holder: LockInfo): boolean {
199
+ return holder.pid === process.pid && holder.hostname === hostname();
200
+ }
201
+
202
+ /** 等待获取锁(轮询 pollMs,默认 50ms);超时或被遗弃时抛 MemoryLockedError。 */
203
+ export async function withLock<T>(
204
+ lockPath: string,
205
+ op: string,
206
+ options: LockOptions,
207
+ fn: () => Promise<T>,
208
+ ): Promise<T> {
209
+ const clock = options.now ?? Date.now;
210
+ const info = holderInfo(op, clock());
211
+ const deadline = clock() + options.timeoutMs;
212
+
213
+ for (;;) {
214
+ const outcome = await acquireOnce(lockPath, info);
215
+ if (outcome.acquired) break;
216
+ // 没人能释放它 → 等下去毫无意义;立刻给出可操作的错误,而不是耗满 timeout
217
+ if (outcome.abandoned) throw new MemoryLockedError(lockPath, outcome.holder, op, true);
218
+ if (clock() >= deadline) throw new MemoryLockedError(lockPath, outcome.holder, op, false);
219
+ await sleep(options.pollMs ?? 50);
220
+ }
221
+
222
+ try {
223
+ return await fn();
224
+ } finally {
225
+ await releaseLock(lockPath);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * 只尝试一次,不等待。
231
+ * 活持有者占用 → 返回 null(调用方跳过本轮);被遗弃的锁 → **抛错而不是返回 null** ——
232
+ * 它不会自愈,静默跳过只会让 extract 之类的后台任务永远不再运行且毫无提示。
233
+ */
234
+ export async function tryWithLock<T>(
235
+ lockPath: string,
236
+ op: string,
237
+ options: LockOptions,
238
+ fn: () => Promise<T>,
239
+ ): Promise<T | null> {
240
+ const clock = options.now ?? Date.now;
241
+ const outcome = await acquireOnce(lockPath, holderInfo(op, clock()));
242
+ if (!outcome.acquired) {
243
+ if (outcome.abandoned) throw new MemoryLockedError(lockPath, outcome.holder, op, true);
244
+ return null;
245
+ }
246
+ try {
247
+ return await fn();
248
+ } finally {
249
+ await releaseLock(lockPath);
250
+ }
251
+ }
@@ -0,0 +1,140 @@
1
+ import * as sdk from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * 索引 section 的名字(spec §9.1)。pi 要求 section 名匹配 `/^[a-z][a-z0-9_-]*$/`
5
+ * (0.99.2 `core/system-prompt.js`),渲染为 `<memory_index>…</memory_index>`。
6
+ */
7
+ export const MEMORY_INDEX_SECTION = "memory_index";
8
+
9
+ /**
10
+ * 重放录制值所需的最小 `SessionManager` 面(结构类型)。
11
+ *
12
+ * 本地类型是 pi-coding-agent **0.80.2**,它的 `ReadonlySessionManager` 没有
13
+ * `buildContextEntries`(0.99.2 才加),所以这里不 import SDK 的类型,而是按结构声明:
14
+ * 0.80.2 能过 `tsc`,0.99.2 的实例天然满足,测试也能直接塞假对象。
15
+ */
16
+ export interface ReplayableSessionManager {
17
+ getEntries(): unknown[];
18
+ getLeafId(): string | null;
19
+ buildContextEntries?(entries: unknown[], leafId?: string | null): unknown[];
20
+ }
21
+
22
+ /** 可注入的转换函数(测试用;生产路径从 SDK 包根动态取)。 */
23
+ export interface ReplayOpts {
24
+ sessionEntryToContextMessages?: (entry: unknown) => unknown[];
25
+ }
26
+
27
+ /**
28
+ * 脱掉宿主为 section 值加的一层包裹(R42)。
29
+ *
30
+ * 0.99.2 `core/system-prompt.js` 把每个非 `preamble` section 渲染成
31
+ * `<${name}>\n${content}\n</${name}>` 之后才写进 transcript;`sessionEntryToContextMessages`
32
+ * 原样返回录制消息,所以重放拿到的是**带标签**的值。直接回写成 `options.sections[name]`
33
+ * 会被宿主二次包裹:`wrap(wrap(x)) !== wrap(x)` —— resume/fork/reload 第一轮就产生 patch
34
+ *(D13/D14 的头部字节恒等失效),内层闭合标签还会提前闭合外层标签。
35
+ *
36
+ * 只脱一层;不匹配(裸值、老 session、内容碰巧含标签)原样返回。
37
+ */
38
+ export function unwrapSectionValue(value: string): string {
39
+ const prefix = `<${MEMORY_INDEX_SECTION}>\n`;
40
+ const suffix = `\n</${MEMORY_INDEX_SECTION}>`;
41
+ if (value.length >= prefix.length + suffix.length && value.startsWith(prefix) && value.endsWith(suffix)) {
42
+ return value.slice(prefix.length, value.length - suffix.length);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === "object" && value !== null;
49
+ }
50
+
51
+ /**
52
+ * 按 pi 的 patch 语义重放 transcript 里的 system 消息,得到「模型当前看到的 sections」。
53
+ *
54
+ * - `value === null` → **删除**该 section(pi 用 null 表示「不存在」,
55
+ * `diffSystemPromptSections` 对「上一状态有、当前状态没有」正是生成 `patch[name] = null`);
56
+ * - 字符串 → 覆盖值,并**保留首次插入位置**(`Map.set` 对已存在的键不改位置)——
57
+ * 折叠路径 `getCurrentSystemMessage` 按顺序重放,位置本身就是语义的一部分。
58
+ */
59
+ export function replaySystemSections(messages: unknown[]): Map<string, string> {
60
+ const sections = new Map<string, string>();
61
+ for (const message of messages) {
62
+ if (!isRecord(message) || message.role !== "system") continue;
63
+ const patch = message.sections;
64
+ if (!isRecord(patch)) continue;
65
+ for (const [name, value] of Object.entries(patch)) {
66
+ if (value === null) sections.delete(name);
67
+ else if (typeof value === "string") sections.set(name, value);
68
+ }
69
+ }
70
+ return sections;
71
+ }
72
+
73
+ /**
74
+ * 动态取 SDK 包根的 `sessionEntryToContextMessages`。
75
+ *
76
+ * 不能写成 `import { sessionEntryToContextMessages } from "…"`:本地类型是 0.80.2,
77
+ * 具名导入会让 `tsc` 直接报 `has no exported member`,而这个包**不得**动 peerDependencies /
78
+ * lockfile。取不到就返回 null,调用方回退磁盘读(spec §19 的退路)。
79
+ */
80
+ function resolveConverter(): ((entry: unknown) => unknown[]) | null {
81
+ const candidate = (sdk as unknown as Record<string, unknown>).sessionEntryToContextMessages;
82
+ return typeof candidate === "function" ? (candidate as (entry: unknown) => unknown[]) : null;
83
+ }
84
+
85
+ /**
86
+ * 从 transcript 里取出**录制的**索引值(D14:resume / fork / reload 必须用它,
87
+ * 否则被恢复会话的 system prompt 头部会被改写,折叠路径下其后的整段对话全部失去缓存)。
88
+ *
89
+ * 任何一步不可得都返回 `null`,由调用方回退磁盘读:SDK 太旧(没有转换函数)、
90
+ * sessionManager 形状不认识、entry 转换抛错、重放后没有这个键、或该键被 `null` patch 删除。
91
+ * **绝不把 `null` 当录制值返回**(spec §9.1 的 null 陷阱)。
92
+ *
93
+ * 返回值是**裸值**:宿主把 section 渲染成 `<memory_index>…</memory_index>` 后才写进
94
+ * transcript,所以这里要脱掉那一层再回写(R42 / `unwrapSectionValue`)。
95
+ */
96
+ export function readRecordedMemoryIndex(sessionManager: unknown, opts?: ReplayOpts): string | null {
97
+ const toMessages = opts?.sessionEntryToContextMessages ?? resolveConverter();
98
+ if (!toMessages) return null;
99
+ if (!isRecord(sessionManager)) return null;
100
+ const sm = sessionManager as unknown as Partial<ReplayableSessionManager>;
101
+ if (typeof sm.getEntries !== "function" || typeof sm.getLeafId !== "function") return null;
102
+
103
+ let entries: unknown[] = [];
104
+ try {
105
+ const raw = sm.getEntries();
106
+ if (Array.isArray(raw)) entries = raw;
107
+ } catch {
108
+ return null;
109
+ }
110
+
111
+ let leafId: string | null = null;
112
+ try {
113
+ leafId = sm.getLeafId();
114
+ } catch {
115
+ // Finding 2 / R43:这个函数在 index.ts 的 session_start 路径上被调用,抛错会让整个
116
+ // session_start 失败 → memory 工具整个会话不注册。拿不到 leaf 就退化成「没有 branch」:
117
+ // 用全部 entry 重放,多出来的 system patch 只会被后面的覆盖或删掉。
118
+ }
119
+ if (typeof sm.buildContextEntries === "function") {
120
+ try {
121
+ const built = sm.buildContextEntries(entries, leafId);
122
+ if (Array.isArray(built)) entries = built;
123
+ } catch {
124
+ // 解析不出当前分支就用全部 entry:多出来的 system patch 只会被后面的覆盖或删掉
125
+ }
126
+ }
127
+
128
+ const messages: unknown[] = [];
129
+ for (const e of entries) {
130
+ try {
131
+ const converted = toMessages(e);
132
+ if (Array.isArray(converted)) messages.push(...converted);
133
+ } catch {
134
+ // 单条 entry 的形状不认识:跳过它,别让整次重放失败
135
+ }
136
+ }
137
+
138
+ const recorded = replaySystemSections(messages).get(MEMORY_INDEX_SECTION);
139
+ return typeof recorded === "string" ? unwrapSectionValue(recorded) : null;
140
+ }
package/src/inject.ts CHANGED
@@ -1,20 +1,36 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
2
- import { join } from "node:path";
3
1
  import type { Model } from "@earendil-works/pi-ai";
4
2
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
5
3
  import { runHeadlessAgent } from "./agent-runner";
6
4
  import type { SessionPersistenceConfig, ThinkLevel } from "./config";
7
- import { truncateForInjection } from "./index-file";
8
- import { parseFrontmatter } from "./topic-file";
5
+ import type { EntryType } from "./entry-file";
6
+ import { MEMORY_INDEX_SECTION } from "./index-source";
7
+ import type { MemoryStore } from "./memory-store";
8
+ import { sanitizeForInjection } from "./sanitize";
9
9
 
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 "";
10
+ /**
11
+ * 注入用的「按行 + 按字节」截断。原本住在 `index-file.ts`(topic 模型的索引层),
12
+ * 但它的两个消费者都在本文件里(索引快照 + entry 正文),因此随 v2 迁到此处,行为逐字不变。
13
+ */
14
+ export function truncateForInjection(
15
+ content: string,
16
+ maxLines: number,
17
+ maxBytes: number,
18
+ ): { ok: boolean; content: string; truncated: boolean } {
19
+ const lines = content.split("\n");
20
+ let out = content;
21
+ let truncated = false;
22
+ if (lines.length > maxLines) {
23
+ out = lines.slice(0, maxLines).join("\n");
24
+ truncated = true;
17
25
  }
26
+ if (Buffer.byteLength(out, "utf8") > maxBytes) {
27
+ let cut = out;
28
+ while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
29
+ out = cut;
30
+ truncated = true;
31
+ }
32
+ if (truncated) out += `\n[truncated: memory index exceeds injection limit]`;
33
+ return { ok: !truncated, content: out, truncated };
18
34
  }
19
35
 
20
36
  export function buildInjection(systemPrompt: string, snapshot: string): string {
@@ -22,67 +38,98 @@ export function buildInjection(systemPrompt: string, snapshot: string): string {
22
38
  return `${systemPrompt}\n\n${snapshot}`;
23
39
  }
24
40
 
25
- export interface TopicManifest {
26
- filename: string;
27
- name: string;
28
- description: string;
29
- type: string;
30
- mtimeMs: number;
41
+ /**
42
+ * `memory_index` section 的值(spec §9.1 / D13):读索引 → 截断 → 净化。
43
+ *
44
+ * **不再自己加 `# Memory Index\n` 前缀**:v1 的索引快照函数会补一份头部,而 v2 的
45
+ * `MEMORY.md` 由 `rebuildIndex`(默认头部就是 `# Memory Index`)与迁移写入 —— 再补一次
46
+ * 注入文本里就有两份标题。
47
+ *
48
+ * 空索引返回 `""`(调用方仍要把 `""` 无条件写进 sections,见 `applyIndexSection`)。
49
+ */
50
+ export async function buildIndexSection(store: MemoryStore, maxLines: number, maxBytes: number): Promise<string> {
51
+ const raw = await store.readIndex();
52
+ const { content } = truncateForInjection(raw, maxLines, maxBytes);
53
+ return sanitizeForInjection(content);
31
54
  }
32
55
 
33
- export async function scanTopics(memoryDir: string): Promise<TopicManifest[]> {
34
- const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== "MEMORY.md");
56
+ /**
57
+ * 把冻结的索引值写进 `event.systemPromptOptions.sections`(就地修改这个可变对象)。
58
+ *
59
+ * 返回 `false` 表示宿主 SDK 太旧、没有 `sections`(本地类型是 0.80.2),调用方必须回退
60
+ * `{ systemPrompt: buildInjection(…) }`(spec §19 的退路:功能不受损,只是缓存变差)。
61
+ *
62
+ * **无条件设置**,哪怕值是 `""`:省略这个键等于告诉 pi「该 section 不应存在」,
63
+ * `diffSystemPromptSections` 会生成 `{ memory_index: null }`,索引被从 system prompt 里
64
+ * **静默删除**(spec §9.1 的 null 陷阱)。「不想改」只能靠喂回逐字节相同的值实现。
65
+ */
66
+ export function applyIndexSection(systemPromptOptions: unknown, value: string): boolean {
67
+ const options = systemPromptOptions as { sections?: unknown } | null | undefined;
68
+ const sections = options?.sections;
69
+ if (typeof sections !== "object" || sections === null) return false;
70
+ (sections as Record<string, string | null>)[MEMORY_INDEX_SECTION] = value;
71
+ return true;
72
+ }
35
73
 
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);
74
+ /** 侧查询清单的一行 = 一条 entry(v1 是一个 topic 文件)。 */
75
+ export interface EntryManifest {
76
+ file: string;
77
+ name: string;
78
+ description: string;
79
+ type: EntryType;
80
+ modified: string;
61
81
  }
62
82
 
83
+ /** 清单总预算(字符)。v1 把每条 description 硬切成 80 字符,v2 改为按清单长度均分。 */
84
+ export const SIDE_QUERY_MANIFEST_CHARS = 4000;
85
+ /** 均分的下限:清单再长,每条也至少留 80 字符,否则侧查询没有判别依据。 */
86
+ export const SIDE_QUERY_MIN_DESC_CHARS = 80;
87
+ /**
88
+ * 清单条数上限。恢复了 v1 的界:v1 的候选集合就是 MEMORY.md 索引本身,而索引有
89
+ * `memIndexMaxLines`(默认 200)行上限,所以侧查询看到的条目天然有界。v2 改成扫描目录后
90
+ * 清单会随目录无限增长,而 `buildSideQueryTask` 每行都要带 description —— 不封顶会把 prompt
91
+ * 和每轮迭代开销一起拉爆。200 与索引上限同量级。
92
+ */
93
+ export const SIDE_QUERY_MAX_ENTRIES = 200;
63
94
 
95
+ /**
96
+ * 从 store 取清单。**不逐文件 readFile** —— mtime 缓存由 store 持有(spec §9.2),
97
+ * 于是每轮只付一次 `readdir` + 每文件一次 `stat`,而不是最多 200 次 `readFile`。
98
+ *
99
+ * 排序为 `modified` **降序**:新记忆优先给侧查询看(`listEntries` 是升序,这里翻过来),
100
+ * 然后截到 `SIDE_QUERY_MAX_ENTRIES`(Finding I2)。
101
+ */
102
+ export async function scanEntries(store: MemoryStore): Promise<EntryManifest[]> {
103
+ const summaries = await store.listEntries();
104
+ return summaries
105
+ .map((s) => ({ file: s.file, name: s.name, description: s.description, type: s.type, modified: s.modified }))
106
+ .sort((a, b) =>
107
+ a.modified === b.modified ? a.file.localeCompare(b.file) : b.modified.localeCompare(a.modified),
108
+ )
109
+ .slice(0, SIDE_QUERY_MAX_ENTRIES);
110
+ }
64
111
 
65
112
  export async function injectSurfacedContent(
66
- memoryDir: string,
113
+ store: MemoryStore,
67
114
  selectedFiles: string[],
68
- maxTopicBytes: number,
115
+ maxEntryBytes: number,
69
116
  maxInjectionBytes: number,
70
117
  ): Promise<string> {
71
118
  const blocks: string[] = [];
72
119
  let totalBytes = 0;
73
120
 
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
- }
121
+ for (const file of selectedFiles) {
122
+ // 经 store 读:v2 的注入单位是 entry 的**正文**,不是整个文件(frontmatter 不进上下文)。
123
+ const entry = await store.readEntry(file);
124
+ if (!entry) continue;
125
+ const { content } = truncateForInjection(entry.body, 999999, maxEntryBytes);
126
+ // spec §13:正文与 name 都要净化(用户/模型写进磁盘的内容可能含 `</relevant_memories>`
127
+ // 之类的仿冒标签)。包裹标签是我们自己生成的,不净化。
128
+ const block = `## ${sanitizeForInjection(entry.name)}\n${sanitizeForInjection(content)}`;
129
+ const blockBytes = Buffer.byteLength(block, "utf8");
130
+ if (totalBytes + blockBytes > maxInjectionBytes) break;
131
+ blocks.push(block);
132
+ totalBytes += blockBytes;
86
133
  }
87
134
 
88
135
  if (blocks.length === 0) return "";
@@ -90,20 +137,19 @@ export async function injectSurfacedContent(
90
137
  }
91
138
 
92
139
  /** 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)}`,
140
+ export function buildSideQueryTask(manifest: EntryManifest[], userPrompt: string, maxFiles: number): string {
141
+ // `Math.max(1, …)`:空清单不会走到这里(runSideQuery 先返回 []),但除零会得到 Infinity。
142
+ const perEntry = Math.max(
143
+ SIDE_QUERY_MIN_DESC_CHARS,
144
+ Math.floor(SIDE_QUERY_MANIFEST_CHARS / Math.max(1, manifest.length)),
100
145
  );
146
+ const lines = manifest.map((e) => `[${e.type}] ${e.file} — ${e.description.slice(0, perEntry)}`);
101
147
 
102
148
  return [
103
- `You are a memory relevance selector. Select up to ${maxFiles} topic files most relevant to the user query.`,
149
+ `You are a memory relevance selector. Select up to ${maxFiles} memory files most relevant to the user query.`,
104
150
  "If nothing matches, select none.",
105
151
  "",
106
- "=== Topic Files ===",
152
+ "=== Memory Files ===",
107
153
  ...lines,
108
154
  "",
109
155
  "=== User Query ===",
@@ -114,24 +160,24 @@ export function buildSideQueryTask(
114
160
  }
115
161
 
116
162
  /** Parse selected_files JSON from headless agent response. */
117
- function parseSelectedFiles(result: string, candidates: TopicManifest[], maxFiles: number): string[] {
163
+ function parseSelectedFiles(result: string, candidates: EntryManifest[], maxFiles: number): string[] {
118
164
  try {
119
165
  const jsonMatch = result.match(/\{[^}]*"selected_files"[^}]*\}/s);
120
166
  if (!jsonMatch) return [];
121
167
  const parsed = JSON.parse(jsonMatch[0]);
122
168
  const files: string[] = parsed.selected_files ?? [];
123
- return files.filter((f: string) => candidates.some((c) => c.filename === f)).slice(0, maxFiles);
169
+ return files.filter((f: string) => candidates.some((c) => c.file === f)).slice(0, maxFiles);
124
170
  } catch {
125
171
  return [];
126
172
  }
127
173
  }
128
174
 
129
- /** Run a lightweight headless side-query to select relevant topic files.
175
+ /** Run a lightweight headless side-query to select relevant entry files.
130
176
  * Returns [] on timeout/failure — no fallback. */
131
177
  export async function runSideQuery(
132
- manifest: TopicManifest[],
178
+ manifest: EntryManifest[],
133
179
  userPrompt: string,
134
- injectedTopics: Set<string>,
180
+ injectedFiles: Set<string>,
135
181
  maxFiles: number,
136
182
  thinkLevel: ThinkLevel,
137
183
  model: string | undefined,
@@ -140,7 +186,7 @@ export async function runSideQuery(
140
186
  memoryDir: string,
141
187
  sessionPersistence?: SessionPersistenceConfig,
142
188
  ): Promise<string[]> {
143
- const candidates = manifest.filter((t) => !injectedTopics.has(t.filename));
189
+ const candidates = manifest.filter((entry) => !injectedFiles.has(entry.file));
144
190
  if (candidates.length === 0) return [];
145
191
  const task = buildSideQueryTask(candidates, userPrompt, maxFiles);
146
192
  try {
@@ -153,6 +199,8 @@ export async function runSideQuery(
153
199
  thinkLevel,
154
200
  maxTurns: 1,
155
201
  timeoutMs: 30_000,
202
+ // 侧查询没有任何 customTools,零工具就是意图:tools: [](白名单)把 builtin 也关掉。
203
+ // 若以后要给它加 customTools,必须改成 noTools: "builtin"(见 agent-runner.ts 的警告)。
156
204
  tools: [],
157
205
  sessionPersistence,
158
206
  });