@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/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,25 @@ export default function (pi: ExtensionAPI) {
161
144
  */
162
145
  const inFlight = new Set<Promise<unknown>>();
163
146
 
147
+ /**
148
+ * 清空本 session 的运行时状态。三条早退路径(disabled / 配置错误 / 初始化失败)共用:
149
+ * 残留上一 session 的 store 会让后续写入落到别的项目目录(Plan C ledger R51)。
150
+ */
151
+ function resetSessionState(): void {
152
+ memoryDir = null;
153
+ store = null;
154
+ indexSnapshot = "";
155
+ injectedFiles.clear();
156
+ }
157
+
158
+ /** 统一的配置错误态:记录 → 清空运行时 → 通知。无 UI 时只记录(`/memory` 仍可读到)。 */
159
+ function failConfig(errors: string[], ctx: ExtensionContext): void {
160
+ configError = errors.join("\n");
161
+ resetSessionState();
162
+ if (!ctx.hasUI) return;
163
+ ctx.ui.notify(`pi-memory config error:\n${errors.map((e) => `- ${e}`).join("\n")}`, "error");
164
+ }
165
+
164
166
  /** 登记一个后台 promise,settle 后自动摘掉;返回同一个 promise 以便调用方继续链式处理。 */
165
167
  function track<T>(promise: Promise<T>): Promise<T> {
166
168
  inFlight.add(promise);
@@ -177,44 +179,36 @@ export default function (pi: ExtensionAPI) {
177
179
  getStore: () => store,
178
180
  // biome-ignore lint/style/noNonNullAssertion: config 在 session_start 里赋值,工具执行必然晚于它
179
181
  getConfig: () => config!,
180
- getEnabled: () => config?.enabled ?? false,
182
+ getUnavailableMessage: () =>
183
+ configError
184
+ ? `Memory not initialized — ${configError.split("\n")[0]}; run /memory for details`
185
+ : config?.enabled === false
186
+ ? 'Memory is disabled — set "enabled": true in memory.json and restart'
187
+ : null,
181
188
  searchSessions,
182
189
  cwd: () => currentCwd,
183
190
  };
184
191
 
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;
192
+ /**
193
+ * 建立本 session 的记忆运行时:目录 → store → 索引来源 → 注册工具。
194
+ *
195
+ * 只在 `session_start` 调用,且调用方已确认 `config.enabled`(中途启用路径已随 `/memory on` 删除)。
196
+ * 抛错 = 初始化失败,由调用方转成配置错误态(`configError`)。
197
+ * `reason` 只有 `session_start` 会传:resume / fork / reload 用 transcript 的录制值(D14)。
198
+ */
199
+ async function initMemory(ctx: ExtensionContext, reason?: string): Promise<void> {
200
+ // biome-ignore lint/style/noNonNullAssertion: 调用方已确认 enabled
201
+ const cfg = config!;
191
202
  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,
203
+ const dir = await resolveMemoryDir(cfg, ctx.cwd);
204
+ const activeStore = new MemoryStore({
205
+ memoryDir: dir,
206
+ indexMaxLines: cfg.memIndexMaxLines,
207
+ indexMaxBytes: cfg.memIndexMaxBytes,
208
+ lock: cfg.lock,
198
209
  });
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
- }
210
+ memoryDir = dir;
211
+ store = activeStore;
218
212
 
219
213
  // D13 / D14:索引值在整个 session 内**冻结**。resume / fork / reload 必须用 transcript 里的
220
214
  // 录制值 —— 否则被恢复会话的 system prompt 头部会被改写,而 memory_index 是头部的最后一段,
@@ -222,11 +216,10 @@ export default function (pi: ExtensionAPI) {
222
216
  // sessionEntryToContextMessages)才回退磁盘读。
223
217
  // 录制值**不再 re-sanitize**:写入当年已经净化过,而 sanitizeForInjection 是幂等的 ——
224
218
  // 保持字节恒等更利于缓存。
225
- const reason = (event as { reason?: string }).reason ?? "startup";
226
219
  const useRecorded = reason === "resume" || reason === "fork" || reason === "reload";
227
220
  const recorded = useRecorded ? readRecordedMemoryIndex(ctx.sessionManager) : null;
228
221
  indexSnapshot =
229
- recorded ?? (await buildIndexSection(store, config.memIndexInjectMaxLines, config.memIndexInjectMaxBytes));
222
+ recorded ?? (await buildIndexSection(activeStore, cfg.memIndexInjectMaxLines, cfg.memIndexInjectMaxBytes));
230
223
 
231
224
  // register memory tool once
232
225
  if (!toolRegistered) {
@@ -236,12 +229,42 @@ export default function (pi: ExtensionAPI) {
236
229
  );
237
230
  toolRegistered = true;
238
231
  }
232
+ }
233
+ pi.on("session_start", async (event, ctx) => {
234
+ // 复位必须在**任何可能抛错的调用之前**跑完:冷启动与 disabled 会话都要有干净的一次失败通知
235
+ // 配额、干净的错误态,以及**清空的运行时**。loadConfig(里面的 getAgentDir)与下面的
236
+ // modelConfigErrors(宿主给的 registry 可能既没有 getAvailable 也没有 getAll)都属于宿主契约
237
+ // 之外的部分:它们一旦抛出而复位还没跑,上一 session 的 store / memoryDir 就会留在**已经注册**
238
+ // 的 `memory` 工具背后 —— 项目 B 的 agent 能写进项目 A 的目录(Plan C ledger R51)。
239
+ extractErrorNotified = false;
240
+ configError = null;
241
+ resetSessionState();
242
+ config = await loadConfig(ctx);
243
+ // 状态已经干净,disabled 直接早退即可。
244
+ if (!config.enabled) return;
245
+ try {
246
+ // 启动校验(spec §2.3):模型键缺失 / 不可解析 → 本会话**完全不初始化**
247
+ //(不解析目录、不建 store、不注册工具),错误态由 `/memory` 重复显示。
248
+ // 校验本身抛错(registry 不合契约)也走同一个 catch:failConfig 自己会复位运行时。
249
+ const errors = modelConfigErrors(config, (value) => resolveModel(value, ctx.modelRegistry) !== undefined);
250
+ if (errors.length > 0) {
251
+ failConfig(errors, ctx);
252
+ return;
253
+ }
254
+ await initMemory(ctx, (event as { reason?: string }).reason);
255
+ } catch (e) {
256
+ // spec §2.4:初始化失败走同一个错误态,不冒泡给宿主(那只会变成一条裸报错)。
257
+ failConfig([`Failed to initialize memory: ${e instanceof Error ? e.message : String(e)}`], ctx);
258
+ return;
259
+ }
239
260
 
240
261
  // nudge
241
- if (ctx.hasUI) {
262
+ // 先拷到 const:initMemory 里赋的值,TS 在本函数的控制流里看不到收窄。
263
+ const nudgeDir = memoryDir;
264
+ if (ctx.hasUI && nudgeDir) {
242
265
  // 同步段快照:shouldNudge / confirm 之后的 fire-and-forget 链不再碰 ctx(见 notifyDream)。
243
266
  const ui = ctx.ui;
244
- const { nudge, message, sessions } = await shouldNudge(memoryDir, config, ctx.cwd);
267
+ const { nudge, message, sessions } = await shouldNudge(nudgeDir, config, ctx.cwd);
245
268
  if (nudge) {
246
269
  const ok = await ui.confirm("Memory Consolidation", `${message}\n\nConsolidate memory files now?`);
247
270
  // dream 需要 store(整轮逻辑锁 + 快照都挂在它上面);这里必然非空,但 const 拷贝让 TS 也能看到。
@@ -249,9 +272,9 @@ export default function (pi: ExtensionAPI) {
249
272
  if (ok && activeStore) {
250
273
  // Fire-and-forget: does not block session_start. The headless
251
274
  // dream agent runs independently; completion notifies the user.
252
- const dreamModel = resolveDefault(config, "dream", "model");
275
+ const dreamModel = requiredModel(config, "dream");
253
276
  const dreamThinkLevel = config.dream.thinkLevel;
254
- const dir = memoryDir;
277
+ const dir = nudgeDir;
255
278
  ui.setStatus("dream", "Consolidating memory...");
256
279
  const dreamRun = runDream({
257
280
  model: dreamModel,
@@ -260,7 +283,6 @@ export default function (pi: ExtensionAPI) {
260
283
  store: activeStore,
261
284
  maxLines: config.memIndexMaxLines,
262
285
  modelRegistry: ctx.modelRegistry,
263
- parentModel: ctx.model,
264
286
  sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
265
287
  // dream 的 7 个 action 只注入它自己的 headless session(D12);整轮持锁与
266
288
  // 进入时的全目录快照都在 runDream 里,所以内部原语两个选项都跳过。
@@ -327,9 +349,8 @@ export default function (pi: ExtensionAPI) {
327
349
  injectedFiles,
328
350
  autoSurfacing.maxFiles,
329
351
  autoSurfacing.thinkLevel,
330
- resolveDefault(config, "autoSurfacing", "model"),
352
+ requiredModel(config, "autoSurfacing"),
331
353
  ctx.modelRegistry,
332
- ctx.model,
333
354
  memoryDir,
334
355
  resolveDefault(config, "autoSurfacing", "sessionPersistence"),
335
356
  );
@@ -404,6 +425,9 @@ export default function (pi: ExtensionAPI) {
404
425
  const extractConfig = config.extractMemories;
405
426
  if (!extractConfig?.enabled) return;
406
427
  if (!event.messages || event.messages.length === 0) return;
428
+ // 守卫之后立刻取值:requiredModel 抛错必须发生在这里(agent_end 直接失败),而不是在
429
+ // runExtract({...}) 的字面量求值中途 —— 那时 track / .then 的收尾链已经无从挂上。
430
+ const extractModel = requiredModel(config, "extractMemories");
407
431
  // 本轮经 extract 的工具真实写入了几条(工具的 onWrite 回调计数,spec §14)。
408
432
  let written = 0;
409
433
  // pi 的 `ExtensionContext` 是代理:`hasUI` / `ui` 的 getter 会先 `runner.assertActive()`,
@@ -413,7 +437,7 @@ export default function (pi: ExtensionAPI) {
413
437
  const ui = ctx.hasUI ? ctx.ui : undefined;
414
438
  const extractRun = runExtract({
415
439
  agentsMdBlocks: extractAgentsMdBlocks(lastSystemPrompt),
416
- model: resolveDefault(config, "extractMemories", "model"),
440
+ model: extractModel,
417
441
  thinkLevel: extractConfig.thinkLevel,
418
442
  memoryDir: dir,
419
443
  store: activeStore,
@@ -424,7 +448,6 @@ export default function (pi: ExtensionAPI) {
424
448
  maxToolResultChars: extractConfig.maxToolResultChars,
425
449
  maxAssistantChars: extractConfig.maxAssistantChars,
426
450
  modelRegistry: ctx.modelRegistry,
427
- parentModel: ctx.model,
428
451
  // extract 的工具集与主 agent 相同(5 个 action,D12),且**只**注入它自己的 headless session。
429
452
  // 不开 skipSnapshot:extract 没有整轮快照,它的每次写入都该留下自己的回滚点。
430
453
  customTools: [
@@ -467,20 +490,42 @@ export default function (pi: ExtensionAPI) {
467
490
  });
468
491
 
469
492
  pi.registerCommand("memory", {
470
- description: "Show memory status, toggle enabled, or remove a stale lock",
493
+ description: "Show memory status or remove a stale lock",
471
494
  handler: async (args, ctx) => {
472
- const activeStore = store;
473
- if (!config || !memoryDir || !activeStore) {
495
+ if (!config) {
474
496
  ctx.ui.notify("Memory not initialized.", "info");
475
497
  return;
476
498
  }
477
- if (args === "off" || args === "on") {
478
- config = { ...config, enabled: args === "on" };
479
- ctx.ui.notify(`Memory ${args}`, "info");
499
+ if (args === "unlock") {
500
+ // `unlock` 只需要**目录**、不需要 store:以 disabled 启动的会话也要能清锁
501
+ //(它是崩溃遗留 `.lock` 的唯一人工入口,spec §19)。
502
+ let dir = memoryDir;
503
+ if (!dir) {
504
+ // 目录解析本身可能失败(HOME 不可写 / git 探测炸了):失败要变成一条可读的 error
505
+ // 通知,不能冒泡成命令 handler 抛错(那只会变成一条裸的宿主报错)。
506
+ try {
507
+ dir = await resolveMemoryDir(config, ctx.cwd);
508
+ } catch (e) {
509
+ ctx.ui.notify(
510
+ `Failed to resolve memory dir: ${e instanceof Error ? e.message : String(e)}`,
511
+ "error",
512
+ );
513
+ return;
514
+ }
515
+ }
516
+ await unlockMemory(dir, ctx.ui);
480
517
  return;
481
518
  }
482
- if (args === "unlock") {
483
- await unlockMemory(memoryDir, ctx.ui);
519
+ // 先拷到 const:`store` / `memoryDir` 是工厂作用域的 let,异步回调里 TS 不保留外层收窄。
520
+ const activeStore = store;
521
+ const dir = memoryDir;
522
+ if (!dir || !activeStore) {
523
+ // configError 非空 = 校验或初始化失败(`/memory` 是用户重读错误的唯一入口);
524
+ // 否则只可能是配置里 enabled 为假。
525
+ const lines = configError
526
+ ? ["Memory: misconfigured", "Dir: not initialized", ...configError.split("\n").map((e) => `- ${e}`)]
527
+ : ["Memory: disabled", 'Dir: not initialized — set "enabled": true in memory.json and restart'];
528
+ ctx.ui.notify(lines.join("\n"), "info");
484
529
  return;
485
530
  }
486
531
  // 容量用写入那一侧的口径(memIndexMax*):用户要知道的是「还能不能写」,
@@ -490,12 +535,11 @@ export default function (pi: ExtensionAPI) {
490
535
  const cap = indexCapacity(indexRaw, config.memIndexMaxLines, config.memIndexMaxBytes);
491
536
  const summary = [
492
537
  `Memory: ${config.enabled ? "enabled" : "disabled"}`,
493
- `Dir: ${memoryDir}`,
538
+ `Dir: ${dir}`,
494
539
  `Index: ${cap.lineCount}/${config.memIndexMaxLines} lines, ${cap.byteLength}/${config.memIndexMaxBytes} bytes, ${parseEntryIndex(indexRaw).unrecognized} unrecognized lines`,
495
540
  `Entries: ${(await activeStore.listEntries()).length}`,
496
- `Last dream: ${(await readDreamMeta(memoryDir))?.lastDreamAt ?? "never"}`,
497
- `Migration: ${await readMigrationStatus(memoryDir)}`,
498
- `Lock: ${await lockStatusLine(memoryDir)}`,
541
+ `Last dream: ${(await readDreamMeta(dir))?.lastDreamAt ?? "never"}`,
542
+ `Lock: ${await lockStatusLine(dir)}`,
499
543
  ].join("\n");
500
544
  ctx.ui.notify(summary, "info");
501
545
  },
@@ -516,15 +560,16 @@ export default function (pi: ExtensionAPI) {
516
560
  const ok = await ctx.ui.confirm("Dream", "Consolidate all memory files? This rewrites them in-place.");
517
561
  if (!ok) return;
518
562
  const dir = memoryDir;
563
+ // 先取模型再改状态:requiredModel 抛错时不能把 "dream" 状态留在那儿(这条链没有 .finally)。
564
+ const dreamModel = requiredModel(config, "dream");
519
565
  ui?.setStatus("dream", "Consolidating memory...");
520
566
  const dreamRun = runDream({
521
- model: resolveDefault(config, "dream", "model"),
567
+ model: dreamModel,
522
568
  thinkLevel: config.dream.thinkLevel,
523
569
  memoryDir,
524
570
  store: activeStore,
525
571
  maxLines: config.memIndexMaxLines,
526
572
  modelRegistry: ctx.modelRegistry,
527
- parentModel: ctx.model,
528
573
  sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
529
574
  customTools: [
530
575
  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.0",
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,