@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/README.md +52 -55
- package/README.zh.md +52 -55
- package/index.ts +163 -96
- package/package.json +3 -3
- package/src/agent-runner.ts +8 -7
- package/src/config.ts +50 -0
- package/src/dream.ts +3 -5
- package/src/extract.ts +52 -15
- package/src/index-source.ts +57 -11
- package/src/inject.ts +9 -5
- package/src/memory-store.ts +12 -14
- package/src/memory-tool.ts +21 -4
- package/src/model-resolver.ts +1 -1
- package/src/process-lock.ts +1 -1
- package/src/migrate.ts +0 -303
package/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
73
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
memoryDir,
|
|
195
|
-
indexMaxLines:
|
|
196
|
-
indexMaxBytes:
|
|
197
|
-
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
288
|
+
const dreamModel = requiredModel(config, "dream");
|
|
253
289
|
const dreamThinkLevel = config.dream.thinkLevel;
|
|
254
|
-
const dir =
|
|
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
|
-
|
|
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:
|
|
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
|
|
506
|
+
description: "Show memory status or remove a stale lock",
|
|
471
507
|
handler: async (args, ctx) => {
|
|
472
|
-
|
|
473
|
-
|
|
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 === "
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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: ${
|
|
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(
|
|
497
|
-
`
|
|
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:
|
|
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.
|
|
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.
|
|
42
|
+
"@types/node": "^26.6.3",
|
|
43
43
|
"typebox": "^1.3.34",
|
|
44
44
|
"typescript": "~7.0.2",
|
|
45
|
-
"vitest": "^5.0.
|
|
45
|
+
"vitest": "^5.0.2"
|
|
46
46
|
}
|
|
47
47
|
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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` / `.
|
|
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,
|