@yandy0725/pi-memory 2.0.0 → 2.1.1

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/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { readFile, unlink } from "node:fs/promises";
1
+ import { unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
4
  import { SessionManager } from "@earendil-works/pi-coding-agent";
5
- import { loadConfig, type MemoryConfig, type SessionPersistenceConfig } from "./src/config";
5
+ import { loadConfig, modelConfigErrors, requiredModel, type MemoryConfig, type SessionPersistenceConfig } from "./src/config";
6
6
  import { runDream } from "./src/dream";
7
7
  import { indexCapacity, parseEntryIndex } from "./src/entry-index";
8
8
  import { runExtract } from "./src/extract";
9
- import { readLockStatus } from "./src/fs-lock";
9
+ import { readLockStatus, type LockInfo } from "./src/fs-lock";
10
10
  import { readRecordedMemoryIndex } from "./src/index-source";
11
11
  import { applyIndexSection, buildIndexSection, buildInjection, injectSurfacedContent, runSideQuery, scanEntries } from "./src/inject";
12
12
  import {
@@ -16,7 +16,7 @@ import {
16
16
  type MemoryToolDeps,
17
17
  } from "./src/memory-tool";
18
18
  import { LOCK_FILE, MemoryStore } from "./src/memory-store";
19
- import { MIGRATED_FILE, migrateIfNeeded } from "./src/migrate";
19
+ import { resolveModel } from "./src/model-resolver";
20
20
  import { readDreamMeta, shouldNudge, writeDreamMeta } from "./src/nudge";
21
21
  import { resolveMemoryDir } from "./src/paths";
22
22
  import { searchSessions } from "./src/session-search";
@@ -68,58 +68,41 @@ function countInjectedBlocks(content: string): number {
68
68
  return Math.max(0, content.split("\n## ").length - 1);
69
69
  }
70
70
 
71
- /**
72
- * `/memory` 的迁移状态行(spec §14)。`.migrated` 由迁移在**最后一步**写(§15.3 步骤 6),
73
- * 所以「标记不在」= 还没迁完 = `pending`(下次 session_start 会重试);
74
- * 标记读不懂也按 `pending` 报 —— 宁可让用户多看到一次重试,不可假装已经迁完。
75
- * `files`/`entries` 都是 0 则是「扫过了、根本没东西要迁」= `not needed`。
76
- */
77
- async function readMigrationStatus(memoryDir: string): Promise<string> {
78
- try {
79
- const marker = JSON.parse(await readFile(join(memoryDir, MIGRATED_FILE), "utf8")) as {
80
- migratedAt?: unknown;
81
- entries?: unknown;
82
- files?: unknown;
83
- };
84
- if (
85
- typeof marker.migratedAt !== "string" ||
86
- typeof marker.entries !== "number" ||
87
- typeof marker.files !== "number"
88
- ) {
89
- return "pending";
90
- }
91
- if (marker.files === 0 && marker.entries === 0) return "not needed";
92
- return `migrated at ${marker.migratedAt} (${marker.entries} entries from ${marker.files} files)`;
93
- } catch {
94
- return "pending";
95
- }
71
+ /** `<op> (pid N on <hostname>, started <ISO>)` —— `/memory` 的 Lock 行与 unlock 确认框共用同一份描述。 */
72
+ function describeHolder(holder: LockInfo): string {
73
+ return `${holder.op} (pid ${holder.pid} on ${holder.hostname}, started ${holder.startedAt})`;
96
74
  }
97
75
 
98
76
  /**
99
77
  * `/memory` 的锁状态行。只读不碰 —— `.lock` **永不自动回收**(spec §5.1),
100
78
  * 人工清除只有 `/memory unlock` 一个入口。
79
+ * 带 hostname:`.lock` 是目录里的普通文件,同一份目录可能被挂载到多台机器(spec §19),
80
+ * 只看 pid 无法判断持有者是不是“本机的另一个进程”。
101
81
  */
102
82
  async function lockStatusLine(memoryDir: string): Promise<string> {
103
83
  const status = await readLockStatus(join(memoryDir, LOCK_FILE));
104
84
  if (status.kind === "absent") return "free";
105
85
  if (status.kind === "unreadable") return "unreadable — run /memory unlock";
106
- return `held by ${status.holder.op} (pid ${status.holder.pid}, started ${status.holder.startedAt})`;
86
+ return `held by ${describeHolder(status.holder)}`;
107
87
  }
108
88
 
109
89
  /**
110
90
  * `/memory unlock`:崩溃遗留的 `.lock` 的**唯一**人工清除入口(spec §19 风险表)。
111
91
  * 必须显式 confirm,而且只删 `.lock` 本身 —— 删错了就是两个写入者同时持有锁。
92
+ * confirm 正文在 `held` 时点名持有者:只有人能把「本机已死的进程」与「别的机器正在跑」区分开。
112
93
  */
113
94
  async function unlockMemory(memoryDir: string, ui: ExtensionUIContext): Promise<void> {
114
95
  const lockPath = join(memoryDir, LOCK_FILE);
115
- if ((await readLockStatus(lockPath)).kind === "absent") {
96
+ const status = await readLockStatus(lockPath);
97
+ if (status.kind === "absent") {
116
98
  ui.notify("No lock present.", "info");
117
99
  return;
118
100
  }
119
- const ok = await ui.confirm(
120
- "Memory lock",
121
- "Remove the memory lock file? Only do this if no memory operation is running.",
122
- );
101
+ const question =
102
+ status.kind === "held"
103
+ ? `Remove the memory lock file? It is held by ${describeHolder(status.holder)}. Only do this if no memory operation is running.`
104
+ : "Remove the memory lock file? Only do this if no memory operation is running.";
105
+ const ok = await ui.confirm("Memory lock", question);
123
106
  if (!ok) return;
124
107
  try {
125
108
  await unlink(lockPath);
@@ -134,9 +117,7 @@ async function unlockMemory(memoryDir: string, ui: ExtensionUIContext): Promise<
134
117
  }
135
118
  }
136
119
 
137
- function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "model"): string | undefined;
138
- function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "sessionPersistence"): SessionPersistenceConfig | undefined;
139
- function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "model" | "sessionPersistence"): string | SessionPersistenceConfig | undefined {
120
+ function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "sessionPersistence"): SessionPersistenceConfig | undefined {
140
121
  const perTask = cfg[task][key];
141
122
  if (perTask !== undefined) return perTask;
142
123
  return cfg.defaults?.[key];
@@ -147,6 +128,8 @@ export default function (pi: ExtensionAPI) {
147
128
  let config: MemoryConfig | null = null;
148
129
  /** 唯一写入通道(D4)。session_start 里建,之后工具 / extract / dream 都只经它读写。 */
149
130
  let store: MemoryStore | null = null;
131
+ /** 配置错误态(模型校验失败或初始化失败)。/memory 重复显示它,直到用户改好配置并重启。 */
132
+ let configError: string | null = null;
150
133
  let indexSnapshot = "";
151
134
  let toolRegistered = false;
152
135
  let currentCwd = "";
@@ -161,6 +144,28 @@ export default function (pi: ExtensionAPI) {
161
144
  */
162
145
  const inFlight = new Set<Promise<unknown>>();
163
146
 
147
+ /** `enabled: false` 时给用户的唯一动作。工具文案与 `/memory` 状态共用同一份,避免两处漂移。 */
148
+ const ENABLE_HINT = 'set "enabled": true in memory.json and restart';
149
+
150
+ /**
151
+ * 清空本 session 的运行时状态。三条早退路径(disabled / 配置错误 / 初始化失败)共用:
152
+ * 残留上一 session 的 store 会让后续写入落到别的项目目录(Plan C ledger R51)。
153
+ */
154
+ function resetSessionState(): void {
155
+ memoryDir = null;
156
+ store = null;
157
+ indexSnapshot = "";
158
+ injectedFiles.clear();
159
+ }
160
+
161
+ /** 统一的配置错误态:记录 → 清空运行时 → 通知。无 UI 时只记录(`/memory` 仍可读到)。 */
162
+ function failConfig(errors: string[], ctx: ExtensionContext): void {
163
+ configError = errors.join("\n");
164
+ resetSessionState();
165
+ if (!ctx.hasUI) return;
166
+ ctx.ui.notify(`pi-memory config error:\n${errors.map((e) => `- ${e}`).join("\n")}`, "error");
167
+ }
168
+
164
169
  /** 登记一个后台 promise,settle 后自动摘掉;返回同一个 promise 以便调用方继续链式处理。 */
165
170
  function track<T>(promise: Promise<T>): Promise<T> {
166
171
  inFlight.add(promise);
@@ -177,44 +182,36 @@ export default function (pi: ExtensionAPI) {
177
182
  getStore: () => store,
178
183
  // biome-ignore lint/style/noNonNullAssertion: config 在 session_start 里赋值,工具执行必然晚于它
179
184
  getConfig: () => config!,
180
- getEnabled: () => config?.enabled ?? false,
185
+ getUnavailableMessage: () =>
186
+ configError
187
+ ? `Memory not initialized — ${configError.split("\n")[0]}; run /memory for details`
188
+ : config?.enabled === false
189
+ ? `Memory is disabled — ${ENABLE_HINT}`
190
+ : null,
181
191
  searchSessions,
182
192
  cwd: () => currentCwd,
183
193
  };
184
194
 
185
- pi.on("session_start", async (event, ctx) => {
186
- // 先重置限流配额:本 session 若以 disabled 启动(下面提前 return),用户中途
187
- // `/memory on` 之后仍应拿到一次失败通知 —— 否则上一 session 残留的 true 会一直吞掉它。
188
- extractErrorNotified = false;
189
- config = await loadConfig(ctx);
190
- if (!config.enabled) return;
195
+ /**
196
+ * 建立本 session 的记忆运行时:目录 → store → 索引来源 → 注册工具。
197
+ *
198
+ * 只在 `session_start` 调用,且调用方已确认 `config.enabled`(中途启用路径已随 `/memory on` 删除)。
199
+ * 抛错 = 初始化失败,由调用方转成配置错误态(`configError`)。
200
+ * `reason` 只有 `session_start` 会传:resume / fork / reload 用 transcript 的录制值(D14)。
201
+ */
202
+ async function initMemory(ctx: ExtensionContext, reason?: string): Promise<void> {
203
+ // biome-ignore lint/style/noNonNullAssertion: 调用方已确认 enabled
204
+ const cfg = config!;
191
205
  currentCwd = ctx.cwd;
192
- memoryDir = await resolveMemoryDir(config, ctx.cwd);
193
- store = new MemoryStore({
194
- memoryDir,
195
- indexMaxLines: config.memIndexMaxLines,
196
- indexMaxBytes: config.memIndexMaxBytes,
197
- lock: config.lock,
206
+ const dir = await resolveMemoryDir(cfg, ctx.cwd);
207
+ const activeStore = new MemoryStore({
208
+ memoryDir: dir,
209
+ indexMaxLines: cfg.memIndexMaxLines,
210
+ indexMaxBytes: cfg.memIndexMaxBytes,
211
+ lock: cfg.lock,
198
212
  });
199
-
200
- // v1 → v2 的自动迁移(spec §15 / D10)。必须在读 indexSnapshot **之前**:
201
- // 否则本会话注入的是迁移前的旧索引。
202
- // 失败不能拖垮 session_start:记忆迁移不了也比整个会话起不来好,而且 `.migrated`
203
- // 未写 → 下次 session_start 会重试(spec §15.4)。
204
- try {
205
- const migration = await migrateIfNeeded(store);
206
- if (migration && ctx.hasUI) {
207
- ctx.ui.notify(
208
- `Migrated ${migration.entries} memories from ${migration.files} topic files. Backup at ${migration.backupDir}`,
209
- "info",
210
- );
211
- }
212
- } catch (e) {
213
- if (ctx.hasUI) {
214
- // biome-ignore lint/suspicious/noExplicitAny: error catch
215
- ctx.ui.notify(`Memory migration failed: ${(e as any).message}`, "error");
216
- }
217
- }
213
+ memoryDir = dir;
214
+ store = activeStore;
218
215
 
219
216
  // D13 / D14:索引值在整个 session 内**冻结**。resume / fork / reload 必须用 transcript 里的
220
217
  // 录制值 —— 否则被恢复会话的 system prompt 头部会被改写,而 memory_index 是头部的最后一段,
@@ -222,11 +219,10 @@ export default function (pi: ExtensionAPI) {
222
219
  // sessionEntryToContextMessages)才回退磁盘读。
223
220
  // 录制值**不再 re-sanitize**:写入当年已经净化过,而 sanitizeForInjection 是幂等的 ——
224
221
  // 保持字节恒等更利于缓存。
225
- const reason = (event as { reason?: string }).reason ?? "startup";
226
222
  const useRecorded = reason === "resume" || reason === "fork" || reason === "reload";
227
223
  const recorded = useRecorded ? readRecordedMemoryIndex(ctx.sessionManager) : null;
228
224
  indexSnapshot =
229
- recorded ?? (await buildIndexSection(store, config.memIndexInjectMaxLines, config.memIndexInjectMaxBytes));
225
+ recorded ?? (await buildIndexSection(activeStore, cfg.memIndexInjectMaxLines, cfg.memIndexInjectMaxBytes));
230
226
 
231
227
  // register memory tool once
232
228
  if (!toolRegistered) {
@@ -236,12 +232,52 @@ export default function (pi: ExtensionAPI) {
236
232
  );
237
233
  toolRegistered = true;
238
234
  }
235
+ }
236
+ pi.on("session_start", async (event, ctx) => {
237
+ // 复位必须在**任何可能抛错的调用之前**跑完:冷启动与 disabled 会话都要有干净的一次失败通知
238
+ // 配额、干净的错误态,以及**清空的运行时**。loadConfig(里面的 getAgentDir)与下面的
239
+ // modelConfigErrors(宿主给的 registry 可能既没有 getAvailable 也没有 getAll)都属于宿主契约
240
+ // 之外的部分:它们一旦抛出而复位还没跑,上一 session 的 store / memoryDir 就会留在**已经注册**
241
+ // 的 `memory` 工具背后 —— 项目 B 的 agent 能写进项目 A 的目录(Plan C ledger R51)。
242
+ extractErrorNotified = false;
243
+ configError = null;
244
+ resetSessionState();
245
+ // `loadConfig` 抛错(`getAgentDir` / `isProjectTrusted` 属宿主契约)与初始化失败同一处理:
246
+ // 转成配置错误态,不把裸 reject 冒给宿主(spec §2.4)。用局部变量承接是为了让后面的收窄
247
+ // 不受 `config` 这个工厂作用域 let 影响。(nudge 块仍可能抛错,属既有暴露面,不在本次范围。)
248
+ let loaded: MemoryConfig;
249
+ try {
250
+ loaded = await loadConfig(ctx);
251
+ } catch (e) {
252
+ failConfig([`Failed to load memory config: ${e instanceof Error ? e.message : String(e)}`], ctx);
253
+ return;
254
+ }
255
+ config = loaded;
256
+ // 状态已经干净,disabled 直接早退即可。
257
+ if (!loaded.enabled) return;
258
+ try {
259
+ // 启动校验(spec §2.3):模型键缺失 / 不可解析 → 本会话**完全不初始化**
260
+ //(不解析目录、不建 store、不注册工具),错误态由 `/memory` 重复显示。
261
+ // 校验本身抛错(registry 不合契约)也走同一个 catch:failConfig 自己会复位运行时。
262
+ const errors = modelConfigErrors(loaded, (value) => resolveModel(value, ctx.modelRegistry) !== undefined);
263
+ if (errors.length > 0) {
264
+ failConfig(errors, ctx);
265
+ return;
266
+ }
267
+ await initMemory(ctx, (event as { reason?: string }).reason);
268
+ } catch (e) {
269
+ // spec §2.4:初始化失败走同一个错误态,不冒泡给宿主(那只会变成一条裸报错)。
270
+ failConfig([`Failed to initialize memory: ${e instanceof Error ? e.message : String(e)}`], ctx);
271
+ return;
272
+ }
239
273
 
240
274
  // nudge
241
- if (ctx.hasUI) {
275
+ // 先拷到 const:initMemory 里赋的值,TS 在本函数的控制流里看不到收窄。
276
+ const nudgeDir = memoryDir;
277
+ if (ctx.hasUI && nudgeDir) {
242
278
  // 同步段快照:shouldNudge / confirm 之后的 fire-and-forget 链不再碰 ctx(见 notifyDream)。
243
279
  const ui = ctx.ui;
244
- const { nudge, message, sessions } = await shouldNudge(memoryDir, config, ctx.cwd);
280
+ const { nudge, message, sessions } = await shouldNudge(nudgeDir, config, ctx.cwd);
245
281
  if (nudge) {
246
282
  const ok = await ui.confirm("Memory Consolidation", `${message}\n\nConsolidate memory files now?`);
247
283
  // dream 需要 store(整轮逻辑锁 + 快照都挂在它上面);这里必然非空,但 const 拷贝让 TS 也能看到。
@@ -249,9 +285,9 @@ export default function (pi: ExtensionAPI) {
249
285
  if (ok && activeStore) {
250
286
  // Fire-and-forget: does not block session_start. The headless
251
287
  // dream agent runs independently; completion notifies the user.
252
- const dreamModel = resolveDefault(config, "dream", "model");
288
+ const dreamModel = requiredModel(config, "dream");
253
289
  const dreamThinkLevel = config.dream.thinkLevel;
254
- const dir = memoryDir;
290
+ const dir = nudgeDir;
255
291
  ui.setStatus("dream", "Consolidating memory...");
256
292
  const dreamRun = runDream({
257
293
  model: dreamModel,
@@ -260,7 +296,6 @@ export default function (pi: ExtensionAPI) {
260
296
  store: activeStore,
261
297
  maxLines: config.memIndexMaxLines,
262
298
  modelRegistry: ctx.modelRegistry,
263
- parentModel: ctx.model,
264
299
  sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
265
300
  // dream 的 7 个 action 只注入它自己的 headless session(D12);整轮持锁与
266
301
  // 进入时的全目录快照都在 runDream 里,所以内部原语两个选项都跳过。
@@ -327,9 +362,8 @@ export default function (pi: ExtensionAPI) {
327
362
  injectedFiles,
328
363
  autoSurfacing.maxFiles,
329
364
  autoSurfacing.thinkLevel,
330
- resolveDefault(config, "autoSurfacing", "model"),
365
+ requiredModel(config, "autoSurfacing"),
331
366
  ctx.modelRegistry,
332
- ctx.model,
333
367
  memoryDir,
334
368
  resolveDefault(config, "autoSurfacing", "sessionPersistence"),
335
369
  );
@@ -404,6 +438,9 @@ export default function (pi: ExtensionAPI) {
404
438
  const extractConfig = config.extractMemories;
405
439
  if (!extractConfig?.enabled) return;
406
440
  if (!event.messages || event.messages.length === 0) return;
441
+ // 守卫之后立刻取值:requiredModel 抛错必须发生在这里(agent_end 直接失败),而不是在
442
+ // runExtract({...}) 的字面量求值中途 —— 那时 track / .then 的收尾链已经无从挂上。
443
+ const extractModel = requiredModel(config, "extractMemories");
407
444
  // 本轮经 extract 的工具真实写入了几条(工具的 onWrite 回调计数,spec §14)。
408
445
  let written = 0;
409
446
  // pi 的 `ExtensionContext` 是代理:`hasUI` / `ui` 的 getter 会先 `runner.assertActive()`,
@@ -413,7 +450,7 @@ export default function (pi: ExtensionAPI) {
413
450
  const ui = ctx.hasUI ? ctx.ui : undefined;
414
451
  const extractRun = runExtract({
415
452
  agentsMdBlocks: extractAgentsMdBlocks(lastSystemPrompt),
416
- model: resolveDefault(config, "extractMemories", "model"),
453
+ model: extractModel,
417
454
  thinkLevel: extractConfig.thinkLevel,
418
455
  memoryDir: dir,
419
456
  store: activeStore,
@@ -424,7 +461,6 @@ export default function (pi: ExtensionAPI) {
424
461
  maxToolResultChars: extractConfig.maxToolResultChars,
425
462
  maxAssistantChars: extractConfig.maxAssistantChars,
426
463
  modelRegistry: ctx.modelRegistry,
427
- parentModel: ctx.model,
428
464
  // extract 的工具集与主 agent 相同(5 个 action,D12),且**只**注入它自己的 headless session。
429
465
  // 不开 skipSnapshot:extract 没有整轮快照,它的每次写入都该留下自己的回滚点。
430
466
  customTools: [
@@ -467,20 +503,51 @@ export default function (pi: ExtensionAPI) {
467
503
  });
468
504
 
469
505
  pi.registerCommand("memory", {
470
- description: "Show memory status, toggle enabled, or remove a stale lock",
506
+ description: "Show memory status or remove a stale lock",
471
507
  handler: async (args, ctx) => {
472
- const activeStore = store;
473
- if (!config || !memoryDir || !activeStore) {
508
+ // 配置都读不出来(`loadConfig` 抛错)时也要报真实原因,而不是笼统的「未初始化」——
509
+ // `/memory` 是重读错误态的唯一入口。首次会话就失败时 `config` 仍是 null,所以这里先看错误态。
510
+ if (!config) {
511
+ if (configError) {
512
+ ctx.ui.notify(
513
+ ["Memory: misconfigured", "Dir: not initialized", ...configError.split("\n").map((e) => `- ${e}`)].join("\n"),
514
+ "info",
515
+ );
516
+ return;
517
+ }
474
518
  ctx.ui.notify("Memory not initialized.", "info");
475
519
  return;
476
520
  }
477
- if (args === "off" || args === "on") {
478
- config = { ...config, enabled: args === "on" };
479
- ctx.ui.notify(`Memory ${args}`, "info");
521
+ if (args === "unlock") {
522
+ // `unlock` 只需要**目录**、不需要 store:以 disabled 启动的会话也要能清锁
523
+ //(它是崩溃遗留 `.lock` 的唯一人工入口,spec §19)。
524
+ let dir = memoryDir;
525
+ if (!dir) {
526
+ // 目录解析本身可能失败(HOME 不可写 / git 探测炸了):失败要变成一条可读的 error
527
+ // 通知,不能冒泡成命令 handler 抛错(那只会变成一条裸的宿主报错)。
528
+ try {
529
+ dir = await resolveMemoryDir(config, ctx.cwd);
530
+ } catch (e) {
531
+ ctx.ui.notify(
532
+ `Failed to resolve memory dir: ${e instanceof Error ? e.message : String(e)}`,
533
+ "error",
534
+ );
535
+ return;
536
+ }
537
+ }
538
+ await unlockMemory(dir, ctx.ui);
480
539
  return;
481
540
  }
482
- if (args === "unlock") {
483
- await unlockMemory(memoryDir, ctx.ui);
541
+ // 先拷到 const:`store` / `memoryDir` 是工厂作用域的 let,异步回调里 TS 不保留外层收窄。
542
+ const activeStore = store;
543
+ const dir = memoryDir;
544
+ if (!dir || !activeStore) {
545
+ // configError 非空 = 校验或初始化失败(`/memory` 是用户重读错误的唯一入口);
546
+ // 否则只可能是配置里 enabled 为假。
547
+ const lines = configError
548
+ ? ["Memory: misconfigured", "Dir: not initialized", ...configError.split("\n").map((e) => `- ${e}`)]
549
+ : ["Memory: disabled", `Dir: not initialized — ${ENABLE_HINT}`];
550
+ ctx.ui.notify(lines.join("\n"), "info");
484
551
  return;
485
552
  }
486
553
  // 容量用写入那一侧的口径(memIndexMax*):用户要知道的是「还能不能写」,
@@ -490,12 +557,11 @@ export default function (pi: ExtensionAPI) {
490
557
  const cap = indexCapacity(indexRaw, config.memIndexMaxLines, config.memIndexMaxBytes);
491
558
  const summary = [
492
559
  `Memory: ${config.enabled ? "enabled" : "disabled"}`,
493
- `Dir: ${memoryDir}`,
560
+ `Dir: ${dir}`,
494
561
  `Index: ${cap.lineCount}/${config.memIndexMaxLines} lines, ${cap.byteLength}/${config.memIndexMaxBytes} bytes, ${parseEntryIndex(indexRaw).unrecognized} unrecognized lines`,
495
562
  `Entries: ${(await activeStore.listEntries()).length}`,
496
- `Last dream: ${(await readDreamMeta(memoryDir))?.lastDreamAt ?? "never"}`,
497
- `Migration: ${await readMigrationStatus(memoryDir)}`,
498
- `Lock: ${await lockStatusLine(memoryDir)}`,
563
+ `Last dream: ${(await readDreamMeta(dir))?.lastDreamAt ?? "never"}`,
564
+ `Lock: ${await lockStatusLine(dir)}`,
499
565
  ].join("\n");
500
566
  ctx.ui.notify(summary, "info");
501
567
  },
@@ -516,15 +582,16 @@ export default function (pi: ExtensionAPI) {
516
582
  const ok = await ctx.ui.confirm("Dream", "Consolidate all memory files? This rewrites them in-place.");
517
583
  if (!ok) return;
518
584
  const dir = memoryDir;
585
+ // 先取模型再改状态:requiredModel 抛错时不能把 "dream" 状态留在那儿(这条链没有 .finally)。
586
+ const dreamModel = requiredModel(config, "dream");
519
587
  ui?.setStatus("dream", "Consolidating memory...");
520
588
  const dreamRun = runDream({
521
- model: resolveDefault(config, "dream", "model"),
589
+ model: dreamModel,
522
590
  thinkLevel: config.dream.thinkLevel,
523
591
  memoryDir,
524
592
  store: activeStore,
525
593
  maxLines: config.memIndexMaxLines,
526
594
  modelRegistry: ctx.modelRegistry,
527
- parentModel: ctx.model,
528
595
  sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
529
596
  customTools: [
530
597
  createMemoryTool(toolDeps, {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.0.0",
6
+ "version": "2.1.1",
7
7
  "description": "File-system driven persistent memory layer for pi coding agent",
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -39,9 +39,9 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@biomejs/biome": "^2.5.14",
42
- "@types/node": "^26.6.2",
42
+ "@types/node": "^26.6.3",
43
43
  "typebox": "^1.3.34",
44
44
  "typescript": "~7.0.2",
45
- "vitest": "^5.0.1"
45
+ "vitest": "^5.0.2"
46
46
  }
47
47
  }
@@ -1,4 +1,3 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
1
  import { join } from "node:path";
3
2
  import {
4
3
  type AgentSession,
@@ -18,8 +17,8 @@ export interface HeadlessAgentOpts {
18
17
  task: string;
19
18
  cwd: string;
20
19
  modelRegistry: import("@earendil-works/pi-coding-agent").ModelRegistry;
21
- model?: string;
22
- parentModel?: Model<any>;
20
+ /** 必填:模型必须来自显式配置(启动校验已经保证可解析),没有父模型回退。 */
21
+ model: string;
23
22
  thinkLevel?: ThinkLevel;
24
23
  maxTurns?: number;
25
24
  signal?: AbortSignal;
@@ -56,10 +55,12 @@ const GRACE_TURNS = 1;
56
55
  * so pi-memory's own before_agent_start cannot recurse.
57
56
  */
58
57
  export async function runHeadlessAgent(opts: HeadlessAgentOpts): Promise<string> {
59
- // 1. Resolve model: undefined → parentModel; otherwise fuzzy resolve (fallback parent)
60
- const resolvedModel = !opts.model
61
- ? opts.parentModel
62
- : (resolveModel(opts.model, opts.modelRegistry) ?? opts.parentModel);
58
+ // 模型必须显式配置且可解析:启动校验之外再挡一次(会话中途凭据被移除等),
59
+ // 结果是显式失败,而不是静默用父会话模型跑一个用户没选的模型。
60
+ const resolvedModel = resolveModel(opts.model, opts.modelRegistry);
61
+ if (!resolvedModel) {
62
+ throw new Error(`model "${opts.model}" is not resolvable (unknown id or missing credentials)`);
63
+ }
63
64
 
64
65
  // 2. Build a pure resource loader (no extensions/skills/context files/etc.)
65
66
  const settingsManager = SettingsManager.inMemory();
package/src/config.ts CHANGED
@@ -15,6 +15,7 @@ export interface SessionPersistenceConfig {
15
15
 
16
16
  /** Shared defaults that per-task configs inherit. Per-task fields override these. */
17
17
  export interface DefaultsConfig {
18
+ /** 共享模型:per-task 未指定时生效。**没有默认值** —— 必须由用户显式配置。 */
18
19
  model?: string;
19
20
  sessionPersistence?: SessionPersistenceConfig;
20
21
  }
@@ -86,6 +87,7 @@ export interface MemoryConfig {
86
87
  export const DEFAULT_CONFIG: MemoryConfig = {
87
88
  enabled: true,
88
89
  // headless 子会话默认只在内存里跑:extract / dream / 侧查询都不该往用户的 sessions 目录里落盘。
90
+ // **没有模型默认值**:model 必须由用户显式配置(defaults.model 或 per-task),否则 session_start 报错。
89
91
  defaults: { sessionPersistence: { enabled: false } },
90
92
  memoryDir: join(homedir(), CONFIG_DIR_NAME, "memory"),
91
93
  memIndexMaxLines: 200,
@@ -112,6 +114,54 @@ export const DEFAULT_CONFIG: MemoryConfig = {
112
114
  },
113
115
  };
114
116
 
117
+ /** 需要显式模型的子任务。顺序固定:dream → extractMemories → autoSurfacing(校验信息按此顺序输出)。 */
118
+ export type ModelTask = "dream" | "extractMemories" | "autoSurfacing";
119
+
120
+ /** 某任务的模型值:per-task 优先,其次共享的 defaults.model。 */
121
+ function taskModel(cfg: MemoryConfig, task: ModelTask): string | undefined {
122
+ return cfg[task].model ?? cfg.defaults?.model;
123
+ }
124
+
125
+ /**
126
+ * 会执行的任务及其模型值。`enabled: false` 的会话不执行任何一个任务 —— 包括 dream,
127
+ * 因为 `/dream` 命令与 nudge 都被 `config.enabled` 挡住。
128
+ */
129
+ export function requiredModels(cfg: MemoryConfig): Array<{ task: ModelTask; value: string | undefined }> {
130
+ if (!cfg.enabled) return [];
131
+ const out: Array<{ task: ModelTask; value: string | undefined }> = [
132
+ { task: "dream", value: taskModel(cfg, "dream") },
133
+ ];
134
+ if (cfg.extractMemories.enabled) out.push({ task: "extractMemories", value: taskModel(cfg, "extractMemories") });
135
+ if (cfg.autoSurfacing.enabled) out.push({ task: "autoSurfacing", value: taskModel(cfg, "autoSurfacing") });
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * 启动校验:空数组 = 通过。
141
+ * `resolve` 由调用方注入(生产时是 `(v) => resolveModel(v, ctx.modelRegistry) !== undefined`),
142
+ * 因此本函数不依赖 SDK 的 registry 类型,可用假 resolve 单测。
143
+ */
144
+ export function modelConfigErrors(cfg: MemoryConfig, resolve: (value: string) => boolean): string[] {
145
+ const errors: string[] = [];
146
+ for (const { task, value } of requiredModels(cfg)) {
147
+ if (value === undefined) {
148
+ errors.push(`no model for ${task} — set "${task}.model" or "defaults.model" in memory.json`);
149
+ } else if (!resolve(value)) {
150
+ errors.push(`model "${value}" for ${task} is not resolvable (unknown id or missing credentials)`);
151
+ }
152
+ }
153
+ return errors;
154
+ }
155
+
156
+ /** 已通过启动校验的模型值;缺失时抛错(真正的守卫在 session_start,这里是防御)。 */
157
+ export function requiredModel(cfg: MemoryConfig, task: ModelTask): string {
158
+ const value = taskModel(cfg, task);
159
+ if (value === undefined) {
160
+ throw new Error(`no model for ${task} — set "${task}.model" or "defaults.model" in memory.json`);
161
+ }
162
+ return value;
163
+ }
164
+
115
165
  function expandTilde(p: string): string {
116
166
  if (p === "~") return homedir();
117
167
  if (p.startsWith("~/")) return join(homedir(), p.slice(2));
package/src/dream.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import type { Model } from "@earendil-works/pi-ai";
4
3
  import type { ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
4
  import { runHeadlessAgent } from "./agent-runner";
6
5
  import type { SessionPersistenceConfig, ThinkLevel } from "./config";
@@ -52,7 +51,8 @@ Work only through the \`memory\` tool. When done, output a concise summary of wh
52
51
  }
53
52
 
54
53
  export interface RunDreamOpts {
55
- model?: string;
54
+ /** 必填:模型来自显式配置(启动校验已保证可解析),没有父模型回退。 */
55
+ model: string;
56
56
  thinkLevel: ThinkLevel;
57
57
  memoryDir: string;
58
58
  /** 唯一写入通道。dream 的整轮互斥与快照都挂在它上面。 */
@@ -60,7 +60,6 @@ export interface RunDreamOpts {
60
60
  /** 索引行数硬上限(= `config.memIndexMaxLines`),写进 prompt 让 dream 承担容量管理。 */
61
61
  maxLines: number;
62
62
  modelRegistry: ModelRegistry;
63
- parentModel?: Model<any>;
64
63
  sessionPersistence?: SessionPersistenceConfig;
65
64
  /** dream 专属的 7-action `memory` 工具(D12):只注入这个 headless session,不进主 agent 的 schema。 */
66
65
  customTools: ToolDefinition[];
@@ -72,7 +71,7 @@ export interface RunDreamOpts {
72
71
  * - `.backups`(目录)跳过 —— 快照不套快照;不带 `withFileTypes` 时它会被 `cp` 递归复制。
73
72
  * - `sessions/`(目录,`sessionPersistence.enabled` 时存在)跳过 —— 同理,而且它可能很大。
74
73
  * `createSnapshot` 的 `cp` 遇到目录会抛 `ERR_FS_EISDIR`(非 ENOENT → fail-closed 上抛 → dream 直接失败)。
75
- * - `.lock` / `.migrated` / `.dream-meta.json` 跳过(都是 dotfile):锁记录与标记不属于记忆内容。
74
+ * - `.lock` / `.dream-meta.json` 跳过(都是 dotfile):锁记录与元数据不属于记忆内容。
76
75
  * - 其余全部 `*.md`(entry 文件 + `MEMORY.md`)都会被快照。
77
76
  */
78
77
  async function snapshotFiles(memoryDir: string): Promise<string[]> {
@@ -112,7 +111,6 @@ export async function runDream(opts: RunDreamOpts): Promise<string> {
112
111
  cwd: opts.memoryDir,
113
112
  modelRegistry: opts.modelRegistry,
114
113
  model: opts.model,
115
- parentModel: opts.parentModel,
116
114
  thinkLevel: opts.thinkLevel,
117
115
  maxTurns: undefined,
118
116
  timeoutMs: 600_000,