@yandy0725/pi-memory 2.0.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/extract.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
1
  import type { ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
2
  import { runHeadlessAgent } from "./agent-runner";
4
3
  import type { SessionPersistenceConfig, ThinkLevel } from "./config";
@@ -27,7 +26,8 @@ export interface ConversationLimits {
27
26
  }
28
27
 
29
28
  export interface RunExtractOpts {
30
- model?: string;
29
+ /** 必填:模型来自显式配置(启动校验已保证可解析),没有父模型回退。 */
30
+ model: string;
31
31
  thinkLevel: ThinkLevel;
32
32
  memoryDir: string;
33
33
  /** 唯一写入通道:extract 的整轮互斥挂在它的 `tryWithLogicalLock` 上。 */
@@ -38,7 +38,6 @@ export interface RunExtractOpts {
38
38
  maxToolResultChars: number;
39
39
  maxAssistantChars: number;
40
40
  modelRegistry: ModelRegistry;
41
- parentModel?: Model<any>;
42
41
  sessionPersistence?: SessionPersistenceConfig;
43
42
  /** extract 子会话专属的工具集(5 个 action,D12)。 */
44
43
  customTools: ToolDefinition[];
@@ -60,20 +59,45 @@ const middleMarkerLabel = (omitted: number): string => `[truncated: ${omitted} c
60
59
  */
61
60
  const middleMarker = (omitted: number): string => `\n${middleMarkerLabel(omitted)}\n`;
62
61
 
62
+ /**
63
+ * 删掉 `needle` 的**最后一次**出现(连带它后面紧跟的一个 `\n`);不存在就原样返回。
64
+ *
65
+ * **兜底路径(理论不可达)**:`clipMiddle` 的主路径按 `assemble` 给出的插入位置删除块级
66
+ * 标记;只有位置信息缺失或该位置上不是标记时才回退到这里做字符串搜索。回退也只删最后一次
67
+ * 出现(Plan C 终审 #9):块级标记总在靠后的位置,而正文里可能有同一串。
68
+ */
69
+ function removeLastOccurrence(text: string, needle: string): string {
70
+ const at = text.lastIndexOf(needle);
71
+ if (at === -1) return text;
72
+ const trailingNewline = text[at + needle.length] === "\n" ? 1 : 0;
73
+ return `${text.slice(0, at)}${text.slice(at + needle.length + trailingNewline)}`;
74
+ }
75
+
63
76
  /**
64
77
  * 字符串级的中段裁减:只剩 user 块仍超预算时的回退。
65
78
  *
66
79
  * `alreadyOmitted` 是块级裁减已经丢掉的字符数,它并入标记里的 N,并且**不再插入第二个标记**
67
- * —— 输出里 `[truncated: …]` 恒为一个。旧实现直接对「已含块级标记的文本」再裁一次,
68
- * 于是极端预算下会出现两个标记,而且回退标记的 N 把旧标记自身的长度也算成「省略的正文」
69
- * (Plan B ledger 的 Minor)。
80
+ * —— 输出里 `[truncated: …]` 恒为一个。这一不变量现在真的成立:`markerAt` 是 `assemble`
81
+ * 拼接时算出的标记插入位置,`clipMiddle` 按它删掉**我们自己插进去的那一个**标记,而不是去
82
+ * 字符串里搜。user 正文里完全可能有一份与标记逐字相同的仿冒串(模型/用户抄了我们的截断
83
+ * 标记),它可能落在真标记之前、也可能之后,任何字符串搜索都可能删错那一份、把真标记留在
84
+ * 输出里,于是输出出现两个标记、N 与保留下来的首尾全部错位(Plan C 终审 #9 / Plan D R-D9)。
85
+ * 旧实现直接对「已含块级标记的文本」再裁一次,还额外把旧标记自身的长度算成「省略的正文」。
70
86
  */
71
- function clipMiddle(text: string, maxChars: number, alreadyOmitted = 0): string {
87
+ function clipMiddle(text: string, maxChars: number, alreadyOmitted = 0, markerAt = -1): string {
72
88
  if (maxChars <= 0 || text.length <= maxChars) return text;
73
89
  // 块级标记自己占的字符既不是被省略的正文,也不该在输出里出现第二次。
74
90
  // 它可能是独立一行(后面跟着 `\n`),也可能被 `assemble` 追加在末尾(后面没有 `\n`)。
75
91
  const label = alreadyOmitted > 0 ? middleMarkerLabel(alreadyOmitted) : "";
76
- const body = label === "" ? text : text.replace(`${label}\n`, "").replace(label, "");
92
+ // 主路径:按 `assemble` 给出的位置删除;只有位置缺失或那一段不是标记时才落回字符串搜索。
93
+ let body = text;
94
+ if (label !== "") {
95
+ body =
96
+ markerAt >= 0 && text.startsWith(label, markerAt)
97
+ ? text.slice(0, markerAt) +
98
+ text.slice(markerAt + label.length + (text[markerAt + label.length] === "\n" ? 1 : 0))
99
+ : removeLastOccurrence(text, label);
100
+ }
77
101
  // 给标记文本预留位置(按一个六位数省略量估算),避免「裁减之后反而更长」。
78
102
  const budget = Math.max(0, maxChars - middleMarker(999999).length);
79
103
  const head = Math.min(Math.ceil(budget / 2), body.length);
@@ -243,21 +267,35 @@ export function renderConversation(messages: ExtractMessage[], limits: Conversat
243
267
  let firstDropped = -1;
244
268
 
245
269
  // 标记插在首个被丢块的位置(块之间仍以 `\n` 连接,序号沿用原始下标,允许跳号)。
246
- const assemble = (): string => {
270
+ // 除了文本,还要交出标记在文本里的**插入位置**:`clipMiddle` 只能按这个位置删除自己
271
+ // 插进去的那一个标记,不能去字符串里搜(正文里可能有一份逐字相同的仿冒串,D4/R-D9)。
272
+ const assemble = (): { text: string; markerAt: number } => {
247
273
  const lines: string[] = [];
274
+ let markerLine = -1;
248
275
  let markerInserted = false;
249
276
  for (const entry of remaining) {
250
277
  if (!markerInserted && firstDropped >= 0 && entry.index > firstDropped) {
278
+ markerLine = lines.length;
251
279
  lines.push(middleMarkerLabel(omitted));
252
280
  markerInserted = true;
253
281
  }
254
282
  lines.push(entry.block);
255
283
  }
256
- if (firstDropped >= 0 && !markerInserted) lines.push(middleMarkerLabel(omitted));
257
- return lines.join("\n");
284
+ if (firstDropped >= 0 && !markerInserted) {
285
+ markerLine = lines.length;
286
+ lines.push(middleMarkerLabel(omitted));
287
+ }
288
+ // `join("\n")` 之后:标记前的每一行各占 `length + 1`(行间分隔符)。
289
+ // 从未丢块时没有标记,`markerAt = -1`。
290
+ let markerAt = -1;
291
+ if (markerLine >= 0) {
292
+ markerAt = 0;
293
+ for (let i = 0; i < markerLine; i++) markerAt += lines[i].length + 1;
294
+ }
295
+ return { text: lines.join("\n"), markerAt };
258
296
  };
259
297
 
260
- let text = assemble();
298
+ let { text, markerAt } = assemble();
261
299
  while (text.length > maxChars) {
262
300
  const center = (remaining.length - 1) / 2;
263
301
  let target = -1;
@@ -273,13 +311,13 @@ export function renderConversation(messages: ExtractMessage[], limits: Conversat
273
311
  }
274
312
  // 只剩 user 块:回退到字符串中段裁减(首尾各约一半并预留标记长度)。
275
313
  // 把块级已经省略的量交下去,输出里只会留一个标记。
276
- if (target === -1) return clipMiddle(text, maxChars, omitted);
314
+ if (target === -1) return clipMiddle(text, maxChars, omitted, markerAt);
277
315
 
278
316
  const [dropped] = remaining.splice(target, 1);
279
317
  // N 含被丢块的换行(spec §11.2 的逐字格式)。
280
318
  omitted += dropped.block.length + 1;
281
319
  if (firstDropped === -1) firstDropped = dropped.index;
282
- text = assemble();
320
+ ({ text, markerAt } = assemble());
283
321
  }
284
322
  return text;
285
323
  }
@@ -368,7 +406,6 @@ export async function runExtract(opts: RunExtractOpts): Promise<{ skipped: boole
368
406
  cwd: opts.memoryDir,
369
407
  modelRegistry: opts.modelRegistry,
370
408
  model: opts.model,
371
- parentModel: opts.parentModel,
372
409
  thinkLevel: opts.thinkLevel,
373
410
  maxTurns: 5,
374
411
  timeoutMs: 120_000,
@@ -17,6 +17,14 @@ export interface ReplayableSessionManager {
17
17
  getEntries(): unknown[];
18
18
  getLeafId(): string | null;
19
19
  buildContextEntries?(entries: unknown[], leafId?: string | null): unknown[];
20
+ /**
21
+ * 0.99.2 的实例方法(**无参**),返回 `{ entries, messages, thinkingLevel, model }`。
22
+ * 宿主自己算 system 消息用的就是它(`session-manager.js:882`:
23
+ * `getCurrentSystemMessage(this.buildSessionProjection().messages)`),所以它才是「模型当前
24
+ * 看到哪些 system 消息」的权威来源。本地类型是 0.80.2(没有这个方法)—— 因此声明为可选 +
25
+ * 运行时特性探测,不得 import SDK 的类型。
26
+ */
27
+ buildSessionProjection?(): { messages?: unknown[] } | undefined;
20
28
  }
21
29
 
22
30
  /** 可注入的转换函数(测试用;生产路径从 SDK 包根动态取)。 */
@@ -83,21 +91,33 @@ function resolveConverter(): ((entry: unknown) => unknown[]) | null {
83
91
  }
84
92
 
85
93
  /**
86
- * 从 transcript 里取出**录制的**索引值(D14:resume / fork / reload 必须用它,
87
- * 否则被恢复会话的 system prompt 头部会被改写,折叠路径下其后的整段对话全部失去缓存)。
94
+ * 宿主投影里的 messages(0.99.2 的 `buildSessionProjection()`)。
88
95
  *
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`)。
96
+ * 不可用(没有该方法 / 抛错 / 返回值形状不对)时返回 `null`,调用方回落到下面的自实现重放。
97
+ * 与宿主同源很重要:双重 compaction 的保留边界、`context_edit` 的应用都由宿主决定,我们自己
98
+ * 复刻一份就会在极端会话上漂移(Plan C 终审 #4)。
95
99
  */
96
- export function readRecordedMemoryIndex(sessionManager: unknown, opts?: ReplayOpts): string | null {
100
+ function projectionMessages(sm: Partial<ReplayableSessionManager>): unknown[] | null {
101
+ try {
102
+ // 特征探测也放进 try:宿主把该方法做成抛错的 getter / proxy 时同样只能回退,
103
+ // 不能让异常逃到 session_start。
104
+ if (typeof sm.buildSessionProjection !== "function") return null;
105
+ const messages = sm.buildSessionProjection()?.messages;
106
+ return Array.isArray(messages) ? messages : null;
107
+ } catch {
108
+ // 投影抛错(更老的 session 形状 / 宿主内部不变量不成立):回落自实现重放
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 自实现的 entry 重放(旧 SDK / 老 session 的退路):`getEntries` → `buildContextEntries`
115
+ * → 逐条转成 context messages。任何一步不可得都返回 `null`:转换函数缺失(SDK 太旧)、
116
+ * sessionManager 形状不认识、`getEntries` 抛错。
117
+ */
118
+ function replayEntryMessages(sm: Partial<ReplayableSessionManager>, opts?: ReplayOpts): unknown[] | null {
97
119
  const toMessages = opts?.sessionEntryToContextMessages ?? resolveConverter();
98
120
  if (!toMessages) return null;
99
- if (!isRecord(sessionManager)) return null;
100
- const sm = sessionManager as unknown as Partial<ReplayableSessionManager>;
101
121
  if (typeof sm.getEntries !== "function" || typeof sm.getLeafId !== "function") return null;
102
122
 
103
123
  let entries: unknown[] = [];
@@ -135,6 +155,32 @@ export function readRecordedMemoryIndex(sessionManager: unknown, opts?: ReplayOp
135
155
  }
136
156
  }
137
157
 
158
+ return messages;
159
+ }
160
+
161
+ /**
162
+ * 从 transcript 里取出**录制的**索引值(D14:resume / fork / reload 必须用它,
163
+ * 否则被恢复会话的 system prompt 头部会被改写,折叠路径下其后的整段对话全部失去缓存)。
164
+ *
165
+ * **优先用宿主的 `buildSessionProjection()`** —— 与宿主算 `getCurrentSystemMessage` 同源;
166
+ * 拿不到(0.80.2 / 更老的 session / 投影抛错)才回落到自实现的 entry 重放(Plan C 终审 #4)。
167
+ *
168
+ * 两条路径都不可得时返回 `null`,由调用方回退磁盘读:SDK 太旧(既无投影也无转换函数)、
169
+ * sessionManager 形状不认识(含 `buildSessionProjection()` 的返回值里没有 `messages` 数组,
170
+ * `projectionMessages` 对非数组返回 `null`)、`buildSessionProjection` / `getEntries` 抛错、重放后没有这个键、
171
+ * 或该键被 `null` patch 删除。**单条 entry 的形状不认识**不会让整次重放失败:那一条被跳过
172
+ *(见 `replayEntryMessages`),其余 entry 照常重放 —— 它不会变成 `null` 返回值。
173
+ * **绝不把 `null` 当录制值返回**(spec §9.1 的 null 陷阱)。
174
+ *
175
+ * 返回值是**裸值**:宿主把 section 渲染成 `<memory_index>…</memory_index>` 后才写进
176
+ * transcript,所以这里要脱掉那一层再回写(R42 / `unwrapSectionValue`)。
177
+ */
178
+ export function readRecordedMemoryIndex(sessionManager: unknown, opts?: ReplayOpts): string | null {
179
+ if (!isRecord(sessionManager)) return null;
180
+ const sm = sessionManager as unknown as Partial<ReplayableSessionManager>;
181
+ const messages = projectionMessages(sm) ?? replayEntryMessages(sm, opts);
182
+ if (messages === null) return null;
183
+
138
184
  const recorded = replaySystemSections(messages).get(MEMORY_INDEX_SECTION);
139
185
  return typeof recorded === "string" ? unwrapSectionValue(recorded) : null;
140
186
  }
package/src/inject.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
1
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
2
  import { runHeadlessAgent } from "./agent-runner";
4
3
  import type { SessionPersistenceConfig, ThinkLevel } from "./config";
@@ -42,10 +41,15 @@ export function buildInjection(systemPrompt: string, snapshot: string): string {
42
41
  * `memory_index` section 的值(spec §9.1 / D13):读索引 → 截断 → 净化。
43
42
  *
44
43
  * **不再自己加 `# Memory Index\n` 前缀**:v1 的索引快照函数会补一份头部,而 v2 的
45
- * `MEMORY.md` 由 `rebuildIndex`(默认头部就是 `# Memory Index`)与迁移写入 —— 再补一次
44
+ * `MEMORY.md` 由 `rebuildIndex`(默认头部就是 `# Memory Index`)写入 —— 再补一次
46
45
  * 注入文本里就有两份标题。
47
46
  *
48
47
  * 空索引返回 `""`(调用方仍要把 `""` 无条件写进 sections,见 `applyIndexSection`)。
48
+ *
49
+ * **顺序是「先截断、后净化」,刻意如此**(Plan C 终审 #6 的决策:保留现状)。注入预算是
50
+ * **软**约束,而反过来(先净化后截断)会在字节上限处切出半个 HTML entity(`…&l`)——
51
+ * 那才是真正会让模型读错的破损。代价:`<` → `&lt;` 会让净化后的字节数略超 `maxBytes`
52
+ * (索引正文里尖括号极少,实际放大可忽略)。`injectSurfacedContent` 同理。
49
53
  */
50
54
  export async function buildIndexSection(store: MemoryStore, maxLines: number, maxBytes: number): Promise<string> {
51
55
  const raw = await store.readIndex();
@@ -125,6 +129,8 @@ export async function injectSurfacedContent(
125
129
  const { content } = truncateForInjection(entry.body, 999999, maxEntryBytes);
126
130
  // spec §13:正文与 name 都要净化(用户/模型写进磁盘的内容可能含 `</relevant_memories>`
127
131
  // 之类的仿冒标签)。包裹标签是我们自己生成的,不净化。
132
+ // 先截断后净化的取舍见 `buildIndexSection` 的注释(Plan C 终审 #6:预算是软约束,
133
+ // 而「净化后截断」会切出半个 entity)。
128
134
  const block = `## ${sanitizeForInjection(entry.name)}\n${sanitizeForInjection(content)}`;
129
135
  const blockBytes = Buffer.byteLength(block, "utf8");
130
136
  if (totalBytes + blockBytes > maxInjectionBytes) break;
@@ -180,9 +186,8 @@ export async function runSideQuery(
180
186
  injectedFiles: Set<string>,
181
187
  maxFiles: number,
182
188
  thinkLevel: ThinkLevel,
183
- model: string | undefined,
189
+ model: string,
184
190
  modelRegistry: ModelRegistry,
185
- parentModel: Model<any> | undefined,
186
191
  memoryDir: string,
187
192
  sessionPersistence?: SessionPersistenceConfig,
188
193
  ): Promise<string[]> {
@@ -195,7 +200,6 @@ export async function runSideQuery(
195
200
  cwd: memoryDir,
196
201
  modelRegistry,
197
202
  model,
198
- parentModel,
199
203
  thinkLevel,
200
204
  maxTurns: 1,
201
205
  timeoutMs: 30_000,
@@ -17,9 +17,8 @@ export const BACKUP_DIR = ".backups";
17
17
  *
18
18
  * 吞错会让调用方拿到「删除成功」的假信号:removeEntry 已删索引行、已失效缓存但文件还在,
19
19
  * 下次 rebuildIndex(dream 会常规调用)会把它加回来 —— 删除被静默回滚。
20
- * 生产调用点:`MemoryStore` 的 removeEntry / replaceEntry,以及 `migrate.ts` 删除已迁移的
21
- * legacy topic 文件。导出也供测试直接覆盖:这条语义无法在 Linux 上经由 MemoryStore 的公开
22
- * API 触发(锁的临时文件与 entry 文件同目录,目录不可写时会在获取锁阶段先失败)。
20
+ * 生产调用点:`MemoryStore` 的 removeEntry / replaceEntry。导出也供测试直接覆盖:这条语义无法在
21
+ * Linux 上经由 MemoryStore 的公开 API 触发(锁的临时文件与 entry 文件同目录,目录不可写时会在获取锁阶段先失败)。
23
22
  */
24
23
  export async function unlinkStrict(path: string): Promise<void> {
25
24
  try {
@@ -43,12 +42,12 @@ export async function sameFile(a: string, b: string): Promise<boolean> {
43
42
  /**
44
43
  * 写原语的调用选项。
45
44
  *
46
- * `skipLogicalLock` 供**整轮持有者**使用:dream / 迁移先用 `withLogicalLock()` 包住整轮,
45
+ * `skipLogicalLock` 供**整轮持有者**使用:dream 先用 `withLogicalLock()` 包住整轮,
47
46
  * 它内部再调用原语时若还去抢同一把进程内锁就会自锁。默认值(不传)= 自己拿锁,这对
48
47
  * 「一次调用一个作用域」的调用方(主 agent 工具、extract)是安全的默认。
49
48
  *
50
49
  * `skipSnapshot` 供「整轮已经自己拍过一次全目录快照」的持有者使用:dream 进入时快照整个目录
51
- * (spec §6)、迁移自建 `.backups/migrate-<ts>/`(spec §15.3)。它们内部每个原语再各拍一次,
50
+ * (spec §6)。它内部每个原语再各拍一次,
52
51
  * 会让同一批变更产生 N 份重复备份,并把 `snapshotKeep`(默认 5)的名额挤光 —— 用户还想用来做
53
52
  * 崩溃恢复的 `write` 快照会被 `pruneSnapshots` 删掉。
54
53
  */
@@ -91,14 +90,14 @@ function compareSummaries(a: EntrySummary, b: EntrySummary): number {
91
90
  /**
92
91
  * 进程级唯一写入通道。所有对 memory 目录的修改必须经它。
93
92
  *
94
- * 已知崩溃窗口(由写前快照兜底,消费方属 Plan B 的恢复/迁移工具):`replaceEntry` 改名时
93
+ * 已知崩溃窗口(由写前快照兜底;快照保存在 `.backups/`,恢复靠手工取用):`replaceEntry` 改名时
95
94
  * 先写新文件、再删旧文件、最后重写索引;若在「删旧文件」与「重写索引」之间进程退出,
96
95
  * `MEMORY.md` 会残留一条指向已删文件的死链。`upsertIndexLine` 以 file 为键,因此这条死链
97
96
  * 不会被后续写入自动覆盖 —— 只能从 `.backups/` 快照恢复。
98
97
  *
99
- * 锁契约(Plan B 的 dream / 迁移必须遵守):
98
+ * 锁契约(dream 等整轮持有者必须遵守):
100
99
  * - **两级锁,作用域不同**:进程内**逻辑锁**(`process-lock.ts`,按 `memoryDir` 分键)承担
101
- * 「逻辑作用域」(单次原语 = 该次调用;dream / 迁移 = 整轮);跨进程 `.lock` 承担「毫秒级的
100
+ * 「逻辑作用域」(单次原语 = 该次调用;dream = 整轮);跨进程 `.lock` 承担「毫秒级的
102
101
  * 物理写入」。因此 `.lock` 永远只被持有一瞬间,不需要 TTL / 续约 / 接管(见 fs-lock.ts)。
103
102
  * - 不做整轮的调用方(主 agent 工具、extract)**不必传任何选项** —— 默认就会自取逻辑锁。
104
103
  * - 需要整轮独占时:用 `withLogicalLock()` 包住整轮,且**其内部调用必须传
@@ -184,14 +183,13 @@ export class MemoryStore {
184
183
  }
185
184
 
186
185
  /**
187
- * 在「进程内逻辑锁」下跑一整轮(dream / 迁移)。持有期间调用原语必须传
186
+ * 在「进程内逻辑锁」下跑一整轮(dream)。持有期间调用原语必须传
188
187
  * `{ skipLogicalLock: true }`,否则会自锁到超时。
189
188
  *
190
189
  * 整轮互斥放进程内、而不是让跨进程 `.lock` 持整轮,是刻意的:`.lock` 只承担毫秒级的物理写入,
191
190
  * 因而不需要 TTL / 续约 / 接管(见 fs-lock.ts);而 dream 自己的原语调用也不会撞上自己的锁。
192
191
  *
193
- * `timeoutMs` 缺省为 `cfg.lock.timeoutMs`(5s)。整轮持有者可以显式放宽 —— 迁移用 30s
194
- * (spec §15.3 步骤 1),因为它要在锁内逐条重写整个目录。
192
+ * `timeoutMs` 缺省为 `cfg.lock.timeoutMs`(5s);整轮持有者可以显式放宽。
195
193
  */
196
194
  async withLogicalLock<T>(fn: () => Promise<T>, timeoutMs?: number): Promise<T> {
197
195
  return withProcessLock(this.#logicalKey, timeoutMs ?? this.cfg.lock.timeoutMs, fn);
@@ -321,8 +319,8 @@ export class MemoryStore {
321
319
  if (value && /[\r\n]/.test(value)) throw new Error("description must be a single line");
322
320
  }
323
321
 
324
- /** `created` 只接受 `YYYY-MM-DD`(spec §3.1)。迁移会把旧 frontmatter 的 `updated` 归一到这个
325
- * 形状再传进来;非法值写出去会让 `parseEntryFile` 返回 null,整条记忆对 store 静默不可见。 */
322
+ /** `created` 只接受 `YYYY-MM-DD`(spec §3.1);非法值写出去会让 `parseEntryFile` 返回 null,
323
+ * 整条记忆对 store 静默不可见。 */
326
324
  #validateCreated(value: string | undefined): void {
327
325
  if (value === undefined) return;
328
326
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("created must be YYYY-MM-DD");
@@ -345,7 +343,7 @@ export class MemoryStore {
345
343
  const summaries = await this.listEntries();
346
344
  const existing = summaries.find((s) => s.name === name);
347
345
  const file = existing?.file ?? (await this.#resolveTargetFile(name));
348
- // 覆盖同名 entry 时**永远**保留磁盘上的 created(迁移重跑幂等的关键);
346
+ // 覆盖同名 entry 时**永远**保留磁盘上的 created(幂等覆盖的关键);
349
347
  // 只有新建时才接受调用方传入的值,缺省是今天。
350
348
  const created = existing
351
349
  ? ((await this.readEntry(existing.file))?.created ?? isoDate(new Date()))
@@ -67,7 +67,11 @@ export interface MemoryToolDeps {
67
67
  /** `session_start` 之后才有值;为 null 时工具报「未初始化」而不是崩。 */
68
68
  getStore: () => MemoryStore | null;
69
69
  getConfig: () => MemoryToolConfig;
70
- getEnabled: () => boolean;
70
+ /**
71
+ * `getStore()` 为 null 时的完整可读原因(由 index.ts 依会话状态拼好),null = 还没 `session_start`。
72
+ * 三种状态:未启动 / 配置错误(模型校验或初始化失败)/ `enabled: false` 的禁用会话。
73
+ */
74
+ getUnavailableMessage: () => string | null;
71
75
  searchSessions: (cwd: string, query: string, cfg: { maxSessions: number; maxMatches: number }) => Promise<string>;
72
76
  cwd: () => string;
73
77
  }
@@ -194,9 +198,13 @@ export function createMemoryTool(deps: MemoryToolDeps, options: MemoryToolOption
194
198
  },
195
199
  // biome-ignore lint/suspicious/noExplicitAny: execute params
196
200
  async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
197
- if (!deps.getEnabled()) throw new Error("Memory is disabled (run /memory on)");
198
201
  const store = deps.getStore();
199
- if (!store) throw new Error("Memory not initialized (no session_start yet)");
202
+ if (!store) {
203
+ // 健康会话之后,同一进程内可能又起了一个配置错误或禁用的会话(pi 没有 unregister API),
204
+ // 工具仍在注册表里:此时「no session_start yet」是假话。原因文案由 index.ts 依状态拼好 ——
205
+ // 工具只负责原样抛出,不猜状态。
206
+ throw new Error(deps.getUnavailableMessage() ?? "Memory not initialized (no session_start yet)");
207
+ }
200
208
  const cfg = deps.getConfig();
201
209
  const p = params as MemoryParams;
202
210
  // schema 的 action 枚举已经按 session 收窄过(D12);这里是第二道门,防止模型硬编一个
@@ -222,7 +230,16 @@ export function createMemoryTool(deps: MemoryToolDeps, options: MemoryToolOption
222
230
  details = { file: r.file, capacityWarning: r.capacityWarning };
223
231
  // spec §14:写成功了要让用户看见。headless 会话(extract / dream)hasUI=false,
224
232
  // 天然不通知;旧调用形状完全不传 ctx,所以用 `?.`。
225
- if (ctx?.hasUI) ctx.ui.notify(`Saved: ${p.name.trim()}`, "info");
233
+ // 通知本身必须包起来(Plan C 终审 #11):`ctx.ui` 是宿主代理,session dispose
234
+ // 之后 notify 会抛 —— 一次**已经落盘**的写入不能因此变成工具错误,
235
+ // 否则模型会以为没存下来而重复写。
236
+ if (ctx?.hasUI) {
237
+ try {
238
+ ctx.ui.notify(`Saved: ${p.name.trim()}`, "info");
239
+ } catch {
240
+ /* UI 已失效:宿主不再收这条通知,写入结果不受影响 */
241
+ }
242
+ }
226
243
  break;
227
244
  }
228
245
  case "replace": {
@@ -10,7 +10,7 @@ interface ModelEntry {
10
10
  /**
11
11
  * Resolve a model string to a Model instance.
12
12
  * Tries exact "provider/modelId" match (only available models), then fuzzy match.
13
- * Returns the Model on success, or undefined on failure (caller falls back to parent model).
13
+ * Returns the Model on success, or undefined(调用方报错,不回退父模型)。
14
14
  */
15
15
  export function resolveModel(input: string, registry: ModelRegistry): Model<any> | undefined {
16
16
  if (!input) return undefined;
@@ -110,7 +110,7 @@ export async function tryWithProcessLock<T>(key: string, fn: () => Promise<T>):
110
110
 
111
111
  /**
112
112
  * 该 key 上是否有人在持有或排队。
113
- * 用途:整轮持有者(dream / 迁移)启动前断言自己确实已经包住了锁 —— 「忘了包」会静默失去整轮互斥,
113
+ * 用途:整轮持有者(dream)启动前断言自己确实已经包住了锁 —— 「忘了包」会静默失去整轮互斥,
114
114
  * 这条断言把它变成一个立刻可见的错误。
115
115
  *
116
116
  * 注意它只是**诊断用**的:互斥由 `tail` 保证。释放与下一个持有者接上之间存在一个微任务窗口,