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