@yandy0725/pi-memory 1.4.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/README.md +245 -140
- package/README.zh.md +264 -157
- package/index.ts +460 -117
- package/package.json +3 -3
- package/src/agent-runner.ts +25 -9
- package/src/config.ts +81 -6
- package/src/dream.ts +99 -59
- package/src/entry-file.ts +81 -0
- package/src/entry-index.ts +112 -0
- package/src/extract.ts +378 -60
- package/src/filename.ts +48 -0
- package/src/fs-lock.ts +251 -0
- package/src/index-source.ts +186 -0
- package/src/inject.ts +129 -77
- package/src/memory-store.ts +496 -0
- package/src/memory-tool.ts +263 -328
- package/src/model-resolver.ts +1 -1
- package/src/paths.ts +1 -14
- package/src/process-lock.ts +122 -0
- package/src/sanitize.ts +36 -0
- package/src/snapshot.ts +68 -0
- package/src/index-file.ts +0 -91
- package/src/topic-file.ts +0 -119
package/index.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { unlink } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type { ExtensionAPI } 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
|
+
import { indexCapacity, parseEntryIndex } from "./src/entry-index";
|
|
7
8
|
import { runExtract } from "./src/extract";
|
|
9
|
+
import { readLockStatus, type LockInfo } from "./src/fs-lock";
|
|
10
|
+
import { readRecordedMemoryIndex } from "./src/index-source";
|
|
11
|
+
import { applyIndexSection, buildIndexSection, buildInjection, injectSurfacedContent, runSideQuery, scanEntries } from "./src/inject";
|
|
8
12
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "./src/
|
|
15
|
-
import {
|
|
13
|
+
createMemoryTool,
|
|
14
|
+
DREAM_ACTIONS,
|
|
15
|
+
MAIN_AGENT_ACTIONS,
|
|
16
|
+
type MemoryToolDeps,
|
|
17
|
+
} from "./src/memory-tool";
|
|
18
|
+
import { LOCK_FILE, MemoryStore } from "./src/memory-store";
|
|
19
|
+
import { resolveModel } from "./src/model-resolver";
|
|
16
20
|
import { readDreamMeta, shouldNudge, writeDreamMeta } from "./src/nudge";
|
|
17
21
|
import { resolveMemoryDir } from "./src/paths";
|
|
18
22
|
import { searchSessions } from "./src/session-search";
|
|
@@ -26,9 +30,94 @@ function extractAgentsMdBlocks(systemPrompt: string): string[] {
|
|
|
26
30
|
return blocks;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
/**
|
|
34
|
+
* dream 的两条 fire-and-forget 链(session_start 的 nudge 与 `/dream`)共用的收尾工具。
|
|
35
|
+
* handler 在**同步段**把 `ctx.ui` 快照成 `ExtensionUIContext | undefined`,链里一律不再读 `ctx`:
|
|
36
|
+
* pi 的 `ExtensionContext` 是代理,`hasUI` / `ui` 的 getter 会 `assertActive()`,session dispose
|
|
37
|
+
* (/new、切换、fork、reload)之后读取即抛错。dream 走 600s timeout,而 `session_shutdown`
|
|
38
|
+
* 只等 `lock.timeoutMs`(默认 5s),所以「dream 在途 + session 已销毁」是常态路径;通知 / 清状态
|
|
39
|
+
* 自己抛错也不能逃逸成未处理 rejection(没有全局 handler 时 Node 会直接杀掉进程)。
|
|
40
|
+
*/
|
|
41
|
+
function notifyDream(ui: ExtensionUIContext | undefined, message: string, level: "info" | "error"): void {
|
|
42
|
+
try {
|
|
43
|
+
ui?.notify(message, level);
|
|
44
|
+
} catch {
|
|
45
|
+
/* UI 已失效:宿主不再收这条通知 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clearDreamStatus(ui: ExtensionUIContext | undefined): void {
|
|
50
|
+
try {
|
|
51
|
+
ui?.setStatus("dream", undefined);
|
|
52
|
+
} catch {
|
|
53
|
+
/* UI 已失效 */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** rejected 的值不一定是 Error(pi 的模型层可能抛字符串 / 对象)。 */
|
|
58
|
+
function dreamFailureMessage(e: unknown): string {
|
|
59
|
+
return `Dream failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `<relevant_memories>` 里实际注入的块数(每块以 `\n## ` 开头)。
|
|
64
|
+
* 近似值:正文里自己以 `## ` 开头的行会被多算 —— 它只用于一条通知文案(spec §14),
|
|
65
|
+
* 而把 `injectSurfacedContent` 的返回值改成结构体反而会弄脏 Plan B 已钉住的接口。
|
|
66
|
+
*/
|
|
67
|
+
function countInjectedBlocks(content: string): number {
|
|
68
|
+
return Math.max(0, content.split("\n## ").length - 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
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})`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `/memory` 的锁状态行。只读不碰 —— `.lock` **永不自动回收**(spec §5.1),
|
|
78
|
+
* 人工清除只有 `/memory unlock` 一个入口。
|
|
79
|
+
* 带 hostname:`.lock` 是目录里的普通文件,同一份目录可能被挂载到多台机器(spec §19),
|
|
80
|
+
* 只看 pid 无法判断持有者是不是“本机的另一个进程”。
|
|
81
|
+
*/
|
|
82
|
+
async function lockStatusLine(memoryDir: string): Promise<string> {
|
|
83
|
+
const status = await readLockStatus(join(memoryDir, LOCK_FILE));
|
|
84
|
+
if (status.kind === "absent") return "free";
|
|
85
|
+
if (status.kind === "unreadable") return "unreadable — run /memory unlock";
|
|
86
|
+
return `held by ${describeHolder(status.holder)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `/memory unlock`:崩溃遗留的 `.lock` 的**唯一**人工清除入口(spec §19 风险表)。
|
|
91
|
+
* 必须显式 confirm,而且只删 `.lock` 本身 —— 删错了就是两个写入者同时持有锁。
|
|
92
|
+
* confirm 正文在 `held` 时点名持有者:只有人能把「本机已死的进程」与「别的机器正在跑」区分开。
|
|
93
|
+
*/
|
|
94
|
+
async function unlockMemory(memoryDir: string, ui: ExtensionUIContext): Promise<void> {
|
|
95
|
+
const lockPath = join(memoryDir, LOCK_FILE);
|
|
96
|
+
const status = await readLockStatus(lockPath);
|
|
97
|
+
if (status.kind === "absent") {
|
|
98
|
+
ui.notify("No lock present.", "info");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
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);
|
|
106
|
+
if (!ok) return;
|
|
107
|
+
try {
|
|
108
|
+
await unlink(lockPath);
|
|
109
|
+
ui.notify("Memory lock removed.", "info");
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// confirm 到 unlink 之间锁自己消失了:那正是想要的结果
|
|
112
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
|
|
113
|
+
ui.notify("No lock present.", "info");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
ui.notify(`Failed to remove memory lock: ${(e as Error).message}`, "error");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "extractMemories", key: "sessionPersistence"): SessionPersistenceConfig | undefined {
|
|
32
121
|
const perTask = cfg[task][key];
|
|
33
122
|
if (perTask !== undefined) return perTask;
|
|
34
123
|
return cfg.defaults?.[key];
|
|
@@ -37,64 +126,191 @@ function resolveDefault(cfg: MemoryConfig, task: "dream" | "autoSurfacing" | "ex
|
|
|
37
126
|
export default function (pi: ExtensionAPI) {
|
|
38
127
|
let memoryDir: string | null = null;
|
|
39
128
|
let config: MemoryConfig | null = null;
|
|
129
|
+
/** 唯一写入通道(D4)。session_start 里建,之后工具 / extract / dream 都只经它读写。 */
|
|
130
|
+
let store: MemoryStore | null = null;
|
|
131
|
+
/** 配置错误态(模型校验失败或初始化失败)。/memory 重复显示它,直到用户改好配置并重启。 */
|
|
132
|
+
let configError: string | null = null;
|
|
40
133
|
let indexSnapshot = "";
|
|
41
134
|
let toolRegistered = false;
|
|
42
|
-
|
|
135
|
+
let currentCwd = "";
|
|
136
|
+
/** 本 session 已注入过的 entry **文件名**(spec §9.3)。session_compact 会清空它。 */
|
|
137
|
+
const injectedFiles = new Set<string>();
|
|
43
138
|
let lastSystemPrompt = "";
|
|
139
|
+
/** extract 失败通知的限流(spec §14:同一 session 最多 1 次)。session_start 重置。 */
|
|
140
|
+
let extractErrorNotified = false;
|
|
141
|
+
/**
|
|
142
|
+
* 在途的后台写操作(dream / extract)。`session_shutdown` 等它们收尾,上限
|
|
143
|
+
* `lock.timeoutMs`(spec §10)—— 进程在写入中途被杀会留下永远没人释放的 `.lock`。
|
|
144
|
+
*/
|
|
145
|
+
const inFlight = new Set<Promise<unknown>>();
|
|
44
146
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
|
|
166
|
+
/** 登记一个后台 promise,settle 后自动摘掉;返回同一个 promise 以便调用方继续链式处理。 */
|
|
167
|
+
function track<T>(promise: Promise<T>): Promise<T> {
|
|
168
|
+
inFlight.add(promise);
|
|
169
|
+
promise.catch(() => undefined).finally(() => inFlight.delete(promise));
|
|
170
|
+
return promise;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 主 agent、extract、dream 共用同一份依赖,三者只差 `actions` 与锁/快照选项(D12)。
|
|
175
|
+
* 用 getter 而不是快照值:config / memoryDir / store 都在 session_start 里才确定。
|
|
176
|
+
*/
|
|
177
|
+
const toolDeps: MemoryToolDeps = {
|
|
178
|
+
getMemoryDir: () => memoryDir,
|
|
179
|
+
getStore: () => store,
|
|
180
|
+
// biome-ignore lint/style/noNonNullAssertion: config 在 session_start 里赋值,工具执行必然晚于它
|
|
181
|
+
getConfig: () => config!,
|
|
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,
|
|
188
|
+
searchSessions,
|
|
189
|
+
cwd: () => currentCwd,
|
|
190
|
+
};
|
|
191
|
+
|
|
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!;
|
|
202
|
+
currentCwd = ctx.cwd;
|
|
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,
|
|
209
|
+
});
|
|
210
|
+
memoryDir = dir;
|
|
211
|
+
store = activeStore;
|
|
212
|
+
|
|
213
|
+
// D13 / D14:索引值在整个 session 内**冻结**。resume / fork / reload 必须用 transcript 里的
|
|
214
|
+
// 录制值 —— 否则被恢复会话的 system prompt 头部会被改写,而 memory_index 是头部的最后一段,
|
|
215
|
+
// 折叠路径下其后的整段对话全部失去缓存。录制值拿不到(更老的 session、旧 SDK 没有
|
|
216
|
+
// sessionEntryToContextMessages)才回退磁盘读。
|
|
217
|
+
// 录制值**不再 re-sanitize**:写入当年已经净化过,而 sanitizeForInjection 是幂等的 ——
|
|
218
|
+
// 保持字节恒等更利于缓存。
|
|
219
|
+
const useRecorded = reason === "resume" || reason === "fork" || reason === "reload";
|
|
220
|
+
const recorded = useRecorded ? readRecordedMemoryIndex(ctx.sessionManager) : null;
|
|
221
|
+
indexSnapshot =
|
|
222
|
+
recorded ?? (await buildIndexSection(activeStore, cfg.memIndexInjectMaxLines, cfg.memIndexInjectMaxBytes));
|
|
50
223
|
|
|
51
224
|
// register memory tool once
|
|
52
225
|
if (!toolRegistered) {
|
|
53
226
|
pi.registerTool(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// biome-ignore lint/style/noNonNullAssertion: config assigned in guard above
|
|
57
|
-
getConfig: () => config!,
|
|
58
|
-
getEnabled: () => config?.enabled ?? false,
|
|
59
|
-
searchSessions,
|
|
60
|
-
cwd: () => ctx.cwd,
|
|
61
|
-
// biome-ignore lint/suspicious/noExplicitAny: pi registerTool type cast
|
|
62
|
-
}) as any,
|
|
227
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi registerTool type cast
|
|
228
|
+
createMemoryTool(toolDeps) as any,
|
|
63
229
|
);
|
|
64
230
|
toolRegistered = true;
|
|
65
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
|
+
}
|
|
66
260
|
|
|
67
261
|
// nudge
|
|
68
|
-
|
|
69
|
-
|
|
262
|
+
// 先拷到 const:initMemory 里赋的值,TS 在本函数的控制流里看不到收窄。
|
|
263
|
+
const nudgeDir = memoryDir;
|
|
264
|
+
if (ctx.hasUI && nudgeDir) {
|
|
265
|
+
// 同步段快照:shouldNudge / confirm 之后的 fire-and-forget 链不再碰 ctx(见 notifyDream)。
|
|
266
|
+
const ui = ctx.ui;
|
|
267
|
+
const { nudge, message, sessions } = await shouldNudge(nudgeDir, config, ctx.cwd);
|
|
70
268
|
if (nudge) {
|
|
71
|
-
const ok = await
|
|
72
|
-
|
|
269
|
+
const ok = await ui.confirm("Memory Consolidation", `${message}\n\nConsolidate memory files now?`);
|
|
270
|
+
// dream 需要 store(整轮逻辑锁 + 快照都挂在它上面);这里必然非空,但 const 拷贝让 TS 也能看到。
|
|
271
|
+
const activeStore = store;
|
|
272
|
+
if (ok && activeStore) {
|
|
73
273
|
// Fire-and-forget: does not block session_start. The headless
|
|
74
274
|
// dream agent runs independently; completion notifies the user.
|
|
75
|
-
const dreamModel =
|
|
275
|
+
const dreamModel = requiredModel(config, "dream");
|
|
76
276
|
const dreamThinkLevel = config.dream.thinkLevel;
|
|
77
|
-
const dir =
|
|
78
|
-
|
|
79
|
-
runDream({
|
|
277
|
+
const dir = nudgeDir;
|
|
278
|
+
ui.setStatus("dream", "Consolidating memory...");
|
|
279
|
+
const dreamRun = runDream({
|
|
80
280
|
model: dreamModel,
|
|
81
281
|
thinkLevel: dreamThinkLevel,
|
|
82
282
|
memoryDir: dir,
|
|
283
|
+
store: activeStore,
|
|
284
|
+
maxLines: config.memIndexMaxLines,
|
|
83
285
|
modelRegistry: ctx.modelRegistry,
|
|
84
|
-
parentModel: ctx.model,
|
|
85
286
|
sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
287
|
+
// dream 的 7 个 action 只注入它自己的 headless session(D12);整轮持锁与
|
|
288
|
+
// 进入时的全目录快照都在 runDream 里,所以内部原语两个选项都跳过。
|
|
289
|
+
customTools: [
|
|
290
|
+
createMemoryTool(toolDeps, {
|
|
291
|
+
actions: DREAM_ACTIONS,
|
|
292
|
+
skipLogicalLock: true,
|
|
293
|
+
skipSnapshot: true,
|
|
294
|
+
}),
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
track(dreamRun);
|
|
298
|
+
// 两参 `.then`(而不是 `.then().catch()`):成功分支自己写 meta,两个分支都不再读 ctx;
|
|
299
|
+
// writeDreamMeta 的失败也必须在分支内兜住,否则它会变成无人接管的 rejection。
|
|
300
|
+
void dreamRun
|
|
301
|
+
.then(
|
|
302
|
+
async (summary) => {
|
|
303
|
+
try {
|
|
304
|
+
await writeDreamMeta(dir, sessions);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
notifyDream(ui, dreamFailureMessage(e), "error");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
notifyDream(ui, summary, "info");
|
|
310
|
+
},
|
|
311
|
+
(e: unknown) => notifyDream(ui, dreamFailureMessage(e), "error"),
|
|
312
|
+
)
|
|
313
|
+
.finally(() => clearDreamStatus(ui));
|
|
98
314
|
}
|
|
99
315
|
}
|
|
100
316
|
}
|
|
@@ -102,7 +318,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
102
318
|
|
|
103
319
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
104
320
|
lastSystemPrompt = event.systemPrompt;
|
|
105
|
-
|
|
321
|
+
// 先拷到 const:`store` 是工厂作用域的 let,在异步回调里 TS 不会保留它的外层收窄。
|
|
322
|
+
const activeStore = store;
|
|
323
|
+
if (!config?.enabled || !memoryDir || !activeStore) return;
|
|
324
|
+
|
|
325
|
+
// 索引 section:**每一轮无条件**写入冻结值(含 resume / fork / reload)。
|
|
326
|
+
// 省略这个键 = pi 的 diffSystemPromptSections 生成 { memory_index: null } = 把索引从
|
|
327
|
+
// system prompt 里静默删掉(spec §9.1 的 null 陷阱)。「不想改」只能靠喂回同一个值。
|
|
328
|
+
// 必须在 auto-surfacing **之前**:surfacing 的 `finally` 在 await 之后读 `ctx.hasUI`,
|
|
329
|
+
// session 在途中 dispose 会让 handler 从那里抛出(R49)——那时 section 已经被跳过,
|
|
330
|
+
// 这一轮就从「喂回冻结值」变成了「删段」。
|
|
331
|
+
const applied = applyIndexSection(event.systemPromptOptions, indexSnapshot);
|
|
106
332
|
|
|
107
333
|
const autoSurfacing = config.autoSurfacing;
|
|
108
334
|
// Skip auto-surfacing in subagent sessions: pi-subagents injects an
|
|
@@ -115,30 +341,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
341
|
if (autoSurfacing?.enabled && event.prompt && !isSubagent) {
|
|
116
342
|
try {
|
|
117
343
|
if (ctx.hasUI) ctx.ui.setStatus("surfacing", "Searching relevant memories…");
|
|
118
|
-
const manifest = await
|
|
344
|
+
const manifest = await scanEntries(activeStore);
|
|
119
345
|
if (manifest.length > 0) {
|
|
120
346
|
const selected = await runSideQuery(
|
|
121
347
|
manifest,
|
|
122
348
|
event.prompt.slice(0, 4000),
|
|
123
|
-
|
|
349
|
+
injectedFiles,
|
|
124
350
|
autoSurfacing.maxFiles,
|
|
125
351
|
autoSurfacing.thinkLevel,
|
|
126
|
-
|
|
352
|
+
requiredModel(config, "autoSurfacing"),
|
|
127
353
|
ctx.modelRegistry,
|
|
128
|
-
ctx.model,
|
|
129
354
|
memoryDir,
|
|
130
355
|
resolveDefault(config, "autoSurfacing", "sessionPersistence"),
|
|
131
356
|
);
|
|
132
357
|
if (selected.length > 0) {
|
|
133
358
|
const content = await injectSurfacedContent(
|
|
134
|
-
|
|
359
|
+
activeStore,
|
|
135
360
|
selected,
|
|
136
|
-
autoSurfacing.
|
|
361
|
+
autoSurfacing.maxEntryBytes,
|
|
137
362
|
autoSurfacing.maxInjectionBytes,
|
|
138
363
|
);
|
|
139
364
|
if (content) {
|
|
140
|
-
for (const f of selected)
|
|
365
|
+
for (const f of selected) injectedFiles.add(f);
|
|
141
366
|
injectedMessage = { customType: "memory-auto-surfacing", content, display: false };
|
|
367
|
+
// spec §14:让用户看见「这一轮想起了什么」。headless 会话不通知。
|
|
368
|
+
if (ctx.hasUI) {
|
|
369
|
+
ctx.ui.notify(`Recalled: ${countInjectedBlocks(content)} entries`, "info");
|
|
370
|
+
}
|
|
142
371
|
}
|
|
143
372
|
}
|
|
144
373
|
}
|
|
@@ -149,73 +378,168 @@ export default function (pi: ExtensionAPI) {
|
|
|
149
378
|
}
|
|
150
379
|
}
|
|
151
380
|
|
|
152
|
-
// MEMORY.md index injection (always last after auto-surfacing)
|
|
153
381
|
return {
|
|
154
|
-
|
|
382
|
+
// 旧 SDK(没有 sections,本地类型就是 0.80.2)的退路:功能不受损,只是缓存变差。
|
|
383
|
+
...(applied ? {} : { systemPrompt: buildInjection(event.systemPrompt, indexSnapshot) }),
|
|
155
384
|
...(injectedMessage ? { message: injectedMessage } : {}),
|
|
156
385
|
};
|
|
157
386
|
});
|
|
158
387
|
|
|
388
|
+
// D14:compaction 是**唯一**的会话内刷新点。compaction 已经重写了对话中段,头部再变一次
|
|
389
|
+
// 的边际缓存损失最小,而长会话到这时候往往已经攒下了新记忆(spec §9.1(c) / §10)。
|
|
390
|
+
pi.on("session_compact", async () => {
|
|
391
|
+
const activeStore = store;
|
|
392
|
+
if (!config?.enabled || !activeStore) return;
|
|
393
|
+
// compaction 会把已注入的内容挤出上下文:不清空,这些 entry 本会话再也不会浮现。
|
|
394
|
+
injectedFiles.clear();
|
|
395
|
+
indexSnapshot = await buildIndexSection(
|
|
396
|
+
activeStore,
|
|
397
|
+
config.memIndexInjectMaxLines,
|
|
398
|
+
config.memIndexInjectMaxBytes,
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// spec §10:退出前等在途写操作收尾(上限 lock.timeoutMs),避免留下 stale 锁。
|
|
403
|
+
pi.on("session_shutdown", async () => {
|
|
404
|
+
if (inFlight.size === 0) return;
|
|
405
|
+
// `Promise.race` 胜出后兜底定时器仍然挂在事件循环上(最长 lock.timeoutMs):宿主从
|
|
406
|
+
// main() 返回后靠事件循环自然 drain 退出,漏一个定时器就把退出拖满超时。显式 clear +
|
|
407
|
+
// unref —— 这条路径必须不留下任何定时器。
|
|
408
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
409
|
+
const giveUp = new Promise<void>((resolve) => {
|
|
410
|
+
timer = setTimeout(resolve, config?.lock.timeoutMs ?? 5000);
|
|
411
|
+
timer.unref?.();
|
|
412
|
+
});
|
|
413
|
+
try {
|
|
414
|
+
await Promise.race([Promise.allSettled([...inFlight]), giveUp]);
|
|
415
|
+
} finally {
|
|
416
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
159
420
|
pi.on("agent_end", async (event, ctx) => {
|
|
160
|
-
|
|
421
|
+
// 先拷到 const:`store` / `memoryDir` 是工厂作用域的 let,在异步回调里 TS 不保留外层收窄。
|
|
422
|
+
const activeStore = store;
|
|
423
|
+
const dir = memoryDir;
|
|
424
|
+
if (!config?.enabled || !dir || !activeStore) return;
|
|
161
425
|
const extractConfig = config.extractMemories;
|
|
162
426
|
if (!extractConfig?.enabled) return;
|
|
163
427
|
if (!event.messages || event.messages.length === 0) return;
|
|
164
|
-
|
|
428
|
+
// 守卫之后立刻取值:requiredModel 抛错必须发生在这里(agent_end 直接失败),而不是在
|
|
429
|
+
// runExtract({...}) 的字面量求值中途 —— 那时 track / .then 的收尾链已经无从挂上。
|
|
430
|
+
const extractModel = requiredModel(config, "extractMemories");
|
|
431
|
+
// 本轮经 extract 的工具真实写入了几条(工具的 onWrite 回调计数,spec §14)。
|
|
432
|
+
let written = 0;
|
|
433
|
+
// pi 的 `ExtensionContext` 是代理:`hasUI` / `ui` 的 getter 会先 `runner.assertActive()`,
|
|
434
|
+
// session dispose(/new、switch、fork、reload、退出宽限超时)之后调用即抛错。extract 是
|
|
435
|
+
// 长时后台任务(120s timeout),而 `session_shutdown` 只等 `lock.timeoutMs`(默认 5s),
|
|
436
|
+
// 所以「extract 在途 + session 已销毁」是常态路径:UI 必须在**同步段**快照,回调里不再碰 ctx。
|
|
437
|
+
const ui = ctx.hasUI ? ctx.ui : undefined;
|
|
438
|
+
const extractRun = runExtract({
|
|
165
439
|
agentsMdBlocks: extractAgentsMdBlocks(lastSystemPrompt),
|
|
166
|
-
model:
|
|
440
|
+
model: extractModel,
|
|
167
441
|
thinkLevel: extractConfig.thinkLevel,
|
|
168
|
-
memoryDir,
|
|
442
|
+
memoryDir: dir,
|
|
443
|
+
store: activeStore,
|
|
444
|
+
// **不再**把消息压成 `{role, content}` 字符串(那是提取失真的根因,spec §11.1):
|
|
445
|
+
// 原样交给 extract,由 toExtractMessages 保留角色 / tool_call / tool_result。
|
|
446
|
+
messages: event.messages,
|
|
447
|
+
maxContextTokens: extractConfig.maxContextTokens,
|
|
448
|
+
maxToolResultChars: extractConfig.maxToolResultChars,
|
|
449
|
+
maxAssistantChars: extractConfig.maxAssistantChars,
|
|
169
450
|
modelRegistry: ctx.modelRegistry,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
451
|
+
// extract 的工具集与主 agent 相同(5 个 action,D12),且**只**注入它自己的 headless session。
|
|
452
|
+
// 不开 skipSnapshot:extract 没有整轮快照,它的每次写入都该留下自己的回滚点。
|
|
453
|
+
customTools: [
|
|
454
|
+
createMemoryTool(toolDeps, {
|
|
455
|
+
actions: MAIN_AGENT_ACTIONS,
|
|
456
|
+
skipLogicalLock: true,
|
|
457
|
+
onWrite: () => {
|
|
458
|
+
written += 1;
|
|
459
|
+
},
|
|
460
|
+
}),
|
|
461
|
+
],
|
|
175
462
|
sessionPersistence: resolveDefault(config, "extractMemories", "sessionPersistence"),
|
|
176
|
-
messages: event.messages.map((m) => ({
|
|
177
|
-
// biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
178
|
-
role: String((m as any).role ?? ""),
|
|
179
|
-
content:
|
|
180
|
-
// biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
181
|
-
typeof (m as any).content === "string"
|
|
182
|
-
? // biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
183
|
-
(m as any).content
|
|
184
|
-
: // biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
185
|
-
typeof (m as any).output === "string"
|
|
186
|
-
? // biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
187
|
-
(m as any).output
|
|
188
|
-
: // biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
189
|
-
JSON.stringify((m as any).content ?? ""),
|
|
190
|
-
})),
|
|
191
|
-
maxContextTokens: extractConfig.maxContextTokens,
|
|
192
|
-
}).catch(() => {
|
|
193
|
-
/* silently ignore extract errors */
|
|
194
463
|
});
|
|
464
|
+
track(extractRun);
|
|
465
|
+
// 用两参 `.then`(而不是 `.then().catch()`):成功通知自己抛错只会进它自己的分支,
|
|
466
|
+
// 不会被失败回调接住 —— 否则会被误报成 `Extract failed:` 并烧掉本 session 的失败配额。
|
|
467
|
+
void extractRun.then(
|
|
468
|
+
(result) => {
|
|
469
|
+
// 只有「没被锁跳过」且「真的写了东西」才报数:否则每轮都弹一条空通知。
|
|
470
|
+
if (result.skipped || written === 0 || !ui) return;
|
|
471
|
+
try {
|
|
472
|
+
ui.notify(`Extracted ${written} ${written === 1 ? "memory" : "memories"}.`, "info");
|
|
473
|
+
} catch {
|
|
474
|
+
// UI 已失效(session dispose 后宿主会忽略这条通知):不能拖垮进程。
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
(e: unknown) => {
|
|
478
|
+
// spec §14:失败不再被静默吞掉,但同一 session 只报一次(extract 每轮都跑,
|
|
479
|
+
// 模型挂了的时候不能把用户淹没在重复通知里)。
|
|
480
|
+
if (extractErrorNotified || !ui) return;
|
|
481
|
+
extractErrorNotified = true;
|
|
482
|
+
try {
|
|
483
|
+
// rejected 的值不一定是 Error(pi 的模型层可能抛字符串 / 对象)。
|
|
484
|
+
ui.notify(`Extract failed: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
485
|
+
} catch {
|
|
486
|
+
// 配额已经用掉,不再重试。
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
);
|
|
195
490
|
});
|
|
196
491
|
|
|
197
492
|
pi.registerCommand("memory", {
|
|
198
|
-
description: "Show memory status
|
|
493
|
+
description: "Show memory status or remove a stale lock",
|
|
199
494
|
handler: async (args, ctx) => {
|
|
200
|
-
if (!config
|
|
495
|
+
if (!config) {
|
|
201
496
|
ctx.ui.notify("Memory not initialized.", "info");
|
|
202
497
|
return;
|
|
203
498
|
}
|
|
204
|
-
if (args === "
|
|
205
|
-
|
|
206
|
-
|
|
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);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
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");
|
|
207
529
|
return;
|
|
208
530
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
531
|
+
// 容量用写入那一侧的口径(memIndexMax*):用户要知道的是「还能不能写」,
|
|
532
|
+
// 而注入口径(memIndexInject*)默认与它同值(D3)。unrecognized 是索引里非空但
|
|
533
|
+
// 解析不了的行数 —— 手写标题/分组/被 Windows 编辑器改坏的行都在这里露出来。
|
|
534
|
+
const indexRaw = await activeStore.readIndex();
|
|
535
|
+
const cap = indexCapacity(indexRaw, config.memIndexMaxLines, config.memIndexMaxBytes);
|
|
213
536
|
const summary = [
|
|
214
537
|
`Memory: ${config.enabled ? "enabled" : "disabled"}`,
|
|
215
|
-
`Dir: ${
|
|
216
|
-
`Index: ${lineCount}/${config.memIndexMaxLines} lines`,
|
|
217
|
-
`
|
|
218
|
-
`Last dream: ${
|
|
538
|
+
`Dir: ${dir}`,
|
|
539
|
+
`Index: ${cap.lineCount}/${config.memIndexMaxLines} lines, ${cap.byteLength}/${config.memIndexMaxBytes} bytes, ${parseEntryIndex(indexRaw).unrecognized} unrecognized lines`,
|
|
540
|
+
`Entries: ${(await activeStore.listEntries()).length}`,
|
|
541
|
+
`Last dream: ${(await readDreamMeta(dir))?.lastDreamAt ?? "never"}`,
|
|
542
|
+
`Lock: ${await lockStatusLine(dir)}`,
|
|
219
543
|
].join("\n");
|
|
220
544
|
ctx.ui.notify(summary, "info");
|
|
221
545
|
},
|
|
@@ -224,34 +548,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
224
548
|
pi.registerCommand("dream", {
|
|
225
549
|
description: "Consolidate all memory files via a headless agent",
|
|
226
550
|
handler: async (_args, ctx) => {
|
|
227
|
-
|
|
551
|
+
const activeStore = store;
|
|
552
|
+
if (!config || !memoryDir || !activeStore) {
|
|
228
553
|
ctx.ui.notify("Memory not initialized.", "info");
|
|
229
554
|
return;
|
|
230
555
|
}
|
|
556
|
+
// 同步段快照:dream 在途时用户可能 /new、切换、fork、reload。`cwd` 也要快照 ——
|
|
557
|
+
// 完成回调里的 `SessionManager.list` 在 await 之后才读它(见 notifyDream 的说明)。
|
|
558
|
+
const ui = ctx.hasUI ? ctx.ui : undefined;
|
|
559
|
+
const cwd = ctx.cwd;
|
|
231
560
|
const ok = await ctx.ui.confirm("Dream", "Consolidate all memory files? This rewrites them in-place.");
|
|
232
561
|
if (!ok) return;
|
|
233
562
|
const dir = memoryDir;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
563
|
+
// 先取模型再改状态:requiredModel 抛错时不能把 "dream" 状态留在那儿(这条链没有 .finally)。
|
|
564
|
+
const dreamModel = requiredModel(config, "dream");
|
|
565
|
+
ui?.setStatus("dream", "Consolidating memory...");
|
|
566
|
+
const dreamRun = runDream({
|
|
567
|
+
model: dreamModel,
|
|
237
568
|
thinkLevel: config.dream.thinkLevel,
|
|
238
569
|
memoryDir,
|
|
570
|
+
store: activeStore,
|
|
571
|
+
maxLines: config.memIndexMaxLines,
|
|
239
572
|
modelRegistry: ctx.modelRegistry,
|
|
240
|
-
parentModel: ctx.model,
|
|
241
573
|
sessionPersistence: resolveDefault(config, "dream", "sessionPersistence"),
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
574
|
+
customTools: [
|
|
575
|
+
createMemoryTool(toolDeps, {
|
|
576
|
+
actions: DREAM_ACTIONS,
|
|
577
|
+
skipLogicalLock: true,
|
|
578
|
+
skipSnapshot: true,
|
|
579
|
+
}),
|
|
580
|
+
],
|
|
581
|
+
});
|
|
582
|
+
track(dreamRun);
|
|
583
|
+
void dreamRun
|
|
584
|
+
.then(
|
|
585
|
+
async (summary) => {
|
|
586
|
+
try {
|
|
587
|
+
const sessions = (await SessionManager.list(cwd)).length;
|
|
588
|
+
await writeDreamMeta(dir, sessions);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
notifyDream(ui, dreamFailureMessage(e), "error");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
notifyDream(ui, summary, "info");
|
|
594
|
+
},
|
|
595
|
+
(e: unknown) => notifyDream(ui, dreamFailureMessage(e), "error"),
|
|
596
|
+
)
|
|
597
|
+
.finally(() => clearDreamStatus(ui));
|
|
255
598
|
},
|
|
256
599
|
});
|
|
257
600
|
}
|