@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/src/fs-lock.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { link, open, readFile, rm, unlink } from "node:fs/promises";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* **跨进程**锁。只保护「毫秒级的物理写入」这一件事。
|
|
6
|
+
*
|
|
7
|
+
* 作用域分工(见 process-lock.ts 的说明):进程内的**逻辑作用域**(单次调用、dream 整轮)由
|
|
8
|
+
* `process-lock.ts` 的 Promise 队列承担,因此这里永远只被持有一瞬间 —— 不存在「持有太久」,
|
|
9
|
+
* 也就不需要 TTL、续约心跳与存活探测。
|
|
10
|
+
*
|
|
11
|
+
* **永不自动回收**:另一个进程崩溃留下的锁没有任何人能释放它,但**也不会被自动删掉**。
|
|
12
|
+
* 原因是「移走别人的锁」无法用 POSIX 原语做到可证明安全:`link` 这个合法获取原语的条件正是
|
|
13
|
+
* 「锁路径不存在」,所以任何「先移走旧锁、再建立自己的」的接管都会产生一个空窗,其它等待者
|
|
14
|
+
* 可以合法地抢占它;一旦移走的其实是某个**活持有者**刚建立的记录,互斥就无法再恢复(把记录
|
|
15
|
+
* 挪回去又会顶掉抢占者,而路径只能容纳一条记录)。实测:把 `rm` 换成原子 `rename` 仍然会双持有,
|
|
16
|
+
* 加上「比字节 + 放回」则会把空窗拉长,同进程内可稳定复现双持有。
|
|
17
|
+
*
|
|
18
|
+
* 因此这里的策略是「安全优先」:崩溃遗留的锁**立刻**报一条可操作的错误(写明 pid / op /
|
|
19
|
+
* startedAt / 路径,并提示如何清除),由人(或 Plan B 的显式 `/memory unlock`)处理。
|
|
20
|
+
*/
|
|
21
|
+
export interface LockInfo {
|
|
22
|
+
pid: number;
|
|
23
|
+
hostname: string;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
op: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LockOptions {
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
pollMs?: number;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class MemoryLockedError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
readonly lockPath: string,
|
|
37
|
+
readonly holder: LockInfo | null,
|
|
38
|
+
readonly op: string,
|
|
39
|
+
/** 记录指向一个已死的本机进程,或本身无法解释 —— 没有任何人会释放它,只能人工清除。 */
|
|
40
|
+
readonly abandoned: boolean,
|
|
41
|
+
) {
|
|
42
|
+
const described = holder
|
|
43
|
+
? `${holder.op} (pid ${holder.pid}, started ${holder.startedAt})`
|
|
44
|
+
: "an unreadable record";
|
|
45
|
+
super(
|
|
46
|
+
abandoned
|
|
47
|
+
? `Memory lock at ${lockPath} is abandoned by ${described} — delete the file to clear it`
|
|
48
|
+
: `Memory is locked by ${described}`,
|
|
49
|
+
);
|
|
50
|
+
this.name = "MemoryLockedError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sleep(ms: number): Promise<void> {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 仅用于诊断(决定错误文案,不参与任何回收决策)。 */
|
|
59
|
+
function isProcessAlive(pid: number): boolean {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// 只有 ESRCH(无此进程)才算死亡;EPERM 是「存在但无权限发信号」,其余意外 errno 一并按存活处理。
|
|
65
|
+
return (e as NodeJS.ErrnoException).code !== "ESRCH";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isLockInfo(value: unknown): value is LockInfo {
|
|
70
|
+
if (typeof value !== "object" || value === null) return false;
|
|
71
|
+
const record = value as Record<string, unknown>;
|
|
72
|
+
return (
|
|
73
|
+
typeof record.pid === "number" &&
|
|
74
|
+
Number.isFinite(record.pid) &&
|
|
75
|
+
typeof record.hostname === "string" &&
|
|
76
|
+
typeof record.startedAt === "string" &&
|
|
77
|
+
typeof record.op === "string"
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type LockRead =
|
|
82
|
+
/** 锁路径不存在 —— 这是「空闲」,不是「有问题」。 */
|
|
83
|
+
| { kind: "absent" }
|
|
84
|
+
/** 存在但无法解释(非 JSON、空文件、形状不对、读不到):只有人工能清除它。 */
|
|
85
|
+
| { kind: "unreadable" }
|
|
86
|
+
| { kind: "held"; holder: LockInfo };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 读锁的三种状态。**必须区分「不存在」与「存在但读不懂」** —— 把前者当成后者会让
|
|
90
|
+
* 「持有者刚释放、锁刚被删」被误报成「遗弃的锁」,从而在正常竞争下抛出误导性的错误。
|
|
91
|
+
*/
|
|
92
|
+
async function readLockState(lockPath: string): Promise<LockRead> {
|
|
93
|
+
let raw: string;
|
|
94
|
+
try {
|
|
95
|
+
raw = await readFile(lockPath, "utf8");
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
|
|
98
|
+
// 权限之类的错误:无法判断内容,按「读不懂」处理(不接管、报可操作错误)
|
|
99
|
+
return { kind: "unreadable" };
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const parsed: unknown = JSON.parse(raw);
|
|
103
|
+
return isLockInfo(parsed) ? { kind: "held", holder: parsed } : { kind: "unreadable" };
|
|
104
|
+
} catch {
|
|
105
|
+
return { kind: "unreadable" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 锁的诊断视图(spec §14 的 `/memory`)。
|
|
111
|
+
*
|
|
112
|
+
* **复用私有的 `readLockState`**,不另写一份读逻辑:三态必须与获取路径同源,
|
|
113
|
+
* 否则会出现「`/memory` 说 free,下一次写入却报 locked」这种无法诊断的矛盾。
|
|
114
|
+
* 它**不判断存活、也不删任何东西**(永不自动回收);持有者是否已死由调用方自己看 pid。
|
|
115
|
+
*/
|
|
116
|
+
export async function readLockStatus(
|
|
117
|
+
lockPath: string,
|
|
118
|
+
): Promise<{ kind: "absent" } | { kind: "unreadable" } | { kind: "held"; holder: LockInfo }> {
|
|
119
|
+
return readLockState(lockPath);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type AcquireOutcome =
|
|
123
|
+
| { acquired: true }
|
|
124
|
+
| { acquired: false; holder: LockInfo | null; abandoned: boolean };
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 尝试一次获取。路径为空时不当作失败 —— 那是「刚好被释放」,直接再试一次 link(CAS 会自然地
|
|
128
|
+
* 决出唯一赢家);两次都撞上「刚好被释放」才交回给调用方重试。
|
|
129
|
+
*/
|
|
130
|
+
async function acquireOnce(lockPath: string, info: LockInfo): Promise<AcquireOutcome> {
|
|
131
|
+
for (let round = 0; round < 2; round++) {
|
|
132
|
+
if (await writeExclusive(lockPath, info)) return { acquired: true };
|
|
133
|
+
const state = await readLockState(lockPath);
|
|
134
|
+
if (state.kind === "absent") continue;
|
|
135
|
+
if (state.kind === "unreadable") return { acquired: false, holder: null, abandoned: true };
|
|
136
|
+
return { acquired: false, holder: state.holder, abandoned: isAbandoned(state.holder) };
|
|
137
|
+
}
|
|
138
|
+
return { acquired: false, holder: null, abandoned: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let tempCounter = 0;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 原子获取:先把持有者信息写进同目录的唯一临时文件,再 `link` 到锁路径。
|
|
145
|
+
*
|
|
146
|
+
* 不能用 `open(lockPath, "wx")` 后紧接着单独写内容 —— 那会让锁路径出现「存在但 0 字节」的
|
|
147
|
+
* 中间态,等待者读到空文件会把它判为「无法解释」并据为己有。link 是原子的:锁路径要么不存在,
|
|
148
|
+
* 要么内容是完整的 JSON。
|
|
149
|
+
*/
|
|
150
|
+
async function writeExclusive(lockPath: string, info: LockInfo): Promise<boolean> {
|
|
151
|
+
tempCounter += 1;
|
|
152
|
+
const tempPath = `${lockPath}.${process.pid}.${tempCounter}.tmp`;
|
|
153
|
+
try {
|
|
154
|
+
const handle = await open(tempPath, "wx");
|
|
155
|
+
try {
|
|
156
|
+
await handle.writeFile(JSON.stringify(info), "utf8");
|
|
157
|
+
} finally {
|
|
158
|
+
await handle.close();
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await link(tempPath, lockPath);
|
|
162
|
+
return true;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if ((e as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
await unlink(tempPath).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function holderInfo(op: string, now: number): LockInfo {
|
|
173
|
+
return { pid: process.pid, hostname: hostname(), startedAt: new Date(now).toISOString(), op };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 这把锁是否「已被遗弃」(没有任何人会释放它)。
|
|
178
|
+
* - 记录无法解释:没有持有者能续约,也没人能释放 —— 遗弃。
|
|
179
|
+
* - 同 host 且进程已死:持有者永远不会再释放它 —— 遗弃(这是唯一能确定的遗弃情形)。
|
|
180
|
+
* - 跨 host:存活状况不可知 —— 一律按活持有者处理,绝不接管。
|
|
181
|
+
*/
|
|
182
|
+
function isAbandoned(holder: LockInfo | null): boolean {
|
|
183
|
+
if (holder === null) return true;
|
|
184
|
+
return holder.hostname === hostname() && !isProcessAlive(holder.pid);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 只在锁仍是自己持有的情况下删除;否则留给真正的持有者。
|
|
189
|
+
* 记录读不懂时也**不删** —— 那是别人的状态(或需要人工处理的状态),不该由我们清理。
|
|
190
|
+
*/
|
|
191
|
+
async function releaseLock(lockPath: string): Promise<void> {
|
|
192
|
+
const state = await readLockState(lockPath);
|
|
193
|
+
if (state.kind === "unreadable") return;
|
|
194
|
+
if (state.kind === "held" && !isOwnRecord(state.holder)) return;
|
|
195
|
+
await rm(lockPath, { force: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isOwnRecord(holder: LockInfo): boolean {
|
|
199
|
+
return holder.pid === process.pid && holder.hostname === hostname();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 等待获取锁(轮询 pollMs,默认 50ms);超时或被遗弃时抛 MemoryLockedError。 */
|
|
203
|
+
export async function withLock<T>(
|
|
204
|
+
lockPath: string,
|
|
205
|
+
op: string,
|
|
206
|
+
options: LockOptions,
|
|
207
|
+
fn: () => Promise<T>,
|
|
208
|
+
): Promise<T> {
|
|
209
|
+
const clock = options.now ?? Date.now;
|
|
210
|
+
const info = holderInfo(op, clock());
|
|
211
|
+
const deadline = clock() + options.timeoutMs;
|
|
212
|
+
|
|
213
|
+
for (;;) {
|
|
214
|
+
const outcome = await acquireOnce(lockPath, info);
|
|
215
|
+
if (outcome.acquired) break;
|
|
216
|
+
// 没人能释放它 → 等下去毫无意义;立刻给出可操作的错误,而不是耗满 timeout
|
|
217
|
+
if (outcome.abandoned) throw new MemoryLockedError(lockPath, outcome.holder, op, true);
|
|
218
|
+
if (clock() >= deadline) throw new MemoryLockedError(lockPath, outcome.holder, op, false);
|
|
219
|
+
await sleep(options.pollMs ?? 50);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
return await fn();
|
|
224
|
+
} finally {
|
|
225
|
+
await releaseLock(lockPath);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 只尝试一次,不等待。
|
|
231
|
+
* 活持有者占用 → 返回 null(调用方跳过本轮);被遗弃的锁 → **抛错而不是返回 null** ——
|
|
232
|
+
* 它不会自愈,静默跳过只会让 extract 之类的后台任务永远不再运行且毫无提示。
|
|
233
|
+
*/
|
|
234
|
+
export async function tryWithLock<T>(
|
|
235
|
+
lockPath: string,
|
|
236
|
+
op: string,
|
|
237
|
+
options: LockOptions,
|
|
238
|
+
fn: () => Promise<T>,
|
|
239
|
+
): Promise<T | null> {
|
|
240
|
+
const clock = options.now ?? Date.now;
|
|
241
|
+
const outcome = await acquireOnce(lockPath, holderInfo(op, clock()));
|
|
242
|
+
if (!outcome.acquired) {
|
|
243
|
+
if (outcome.abandoned) throw new MemoryLockedError(lockPath, outcome.holder, op, true);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
return await fn();
|
|
248
|
+
} finally {
|
|
249
|
+
await releaseLock(lockPath);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as sdk from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 索引 section 的名字(spec §9.1)。pi 要求 section 名匹配 `/^[a-z][a-z0-9_-]*$/`
|
|
5
|
+
* (0.99.2 `core/system-prompt.js`),渲染为 `<memory_index>…</memory_index>`。
|
|
6
|
+
*/
|
|
7
|
+
export const MEMORY_INDEX_SECTION = "memory_index";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 重放录制值所需的最小 `SessionManager` 面(结构类型)。
|
|
11
|
+
*
|
|
12
|
+
* 本地类型是 pi-coding-agent **0.80.2**,它的 `ReadonlySessionManager` 没有
|
|
13
|
+
* `buildContextEntries`(0.99.2 才加),所以这里不 import SDK 的类型,而是按结构声明:
|
|
14
|
+
* 0.80.2 能过 `tsc`,0.99.2 的实例天然满足,测试也能直接塞假对象。
|
|
15
|
+
*/
|
|
16
|
+
export interface ReplayableSessionManager {
|
|
17
|
+
getEntries(): unknown[];
|
|
18
|
+
getLeafId(): string | null;
|
|
19
|
+
buildContextEntries?(entries: unknown[], leafId?: string | null): unknown[];
|
|
20
|
+
/**
|
|
21
|
+
* 0.99.2 的实例方法(**无参**),返回 `{ entries, messages, thinkingLevel, model }`。
|
|
22
|
+
* 宿主自己算 system 消息用的就是它(`session-manager.js:882`:
|
|
23
|
+
* `getCurrentSystemMessage(this.buildSessionProjection().messages)`),所以它才是「模型当前
|
|
24
|
+
* 看到哪些 system 消息」的权威来源。本地类型是 0.80.2(没有这个方法)—— 因此声明为可选 +
|
|
25
|
+
* 运行时特性探测,不得 import SDK 的类型。
|
|
26
|
+
*/
|
|
27
|
+
buildSessionProjection?(): { messages?: unknown[] } | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 可注入的转换函数(测试用;生产路径从 SDK 包根动态取)。 */
|
|
31
|
+
export interface ReplayOpts {
|
|
32
|
+
sessionEntryToContextMessages?: (entry: unknown) => unknown[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 脱掉宿主为 section 值加的一层包裹(R42)。
|
|
37
|
+
*
|
|
38
|
+
* 0.99.2 `core/system-prompt.js` 把每个非 `preamble` section 渲染成
|
|
39
|
+
* `<${name}>\n${content}\n</${name}>` 之后才写进 transcript;`sessionEntryToContextMessages`
|
|
40
|
+
* 原样返回录制消息,所以重放拿到的是**带标签**的值。直接回写成 `options.sections[name]`
|
|
41
|
+
* 会被宿主二次包裹:`wrap(wrap(x)) !== wrap(x)` —— resume/fork/reload 第一轮就产生 patch
|
|
42
|
+
*(D13/D14 的头部字节恒等失效),内层闭合标签还会提前闭合外层标签。
|
|
43
|
+
*
|
|
44
|
+
* 只脱一层;不匹配(裸值、老 session、内容碰巧含标签)原样返回。
|
|
45
|
+
*/
|
|
46
|
+
export function unwrapSectionValue(value: string): string {
|
|
47
|
+
const prefix = `<${MEMORY_INDEX_SECTION}>\n`;
|
|
48
|
+
const suffix = `\n</${MEMORY_INDEX_SECTION}>`;
|
|
49
|
+
if (value.length >= prefix.length + suffix.length && value.startsWith(prefix) && value.endsWith(suffix)) {
|
|
50
|
+
return value.slice(prefix.length, value.length - suffix.length);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return typeof value === "object" && value !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 按 pi 的 patch 语义重放 transcript 里的 system 消息,得到「模型当前看到的 sections」。
|
|
61
|
+
*
|
|
62
|
+
* - `value === null` → **删除**该 section(pi 用 null 表示「不存在」,
|
|
63
|
+
* `diffSystemPromptSections` 对「上一状态有、当前状态没有」正是生成 `patch[name] = null`);
|
|
64
|
+
* - 字符串 → 覆盖值,并**保留首次插入位置**(`Map.set` 对已存在的键不改位置)——
|
|
65
|
+
* 折叠路径 `getCurrentSystemMessage` 按顺序重放,位置本身就是语义的一部分。
|
|
66
|
+
*/
|
|
67
|
+
export function replaySystemSections(messages: unknown[]): Map<string, string> {
|
|
68
|
+
const sections = new Map<string, string>();
|
|
69
|
+
for (const message of messages) {
|
|
70
|
+
if (!isRecord(message) || message.role !== "system") continue;
|
|
71
|
+
const patch = message.sections;
|
|
72
|
+
if (!isRecord(patch)) continue;
|
|
73
|
+
for (const [name, value] of Object.entries(patch)) {
|
|
74
|
+
if (value === null) sections.delete(name);
|
|
75
|
+
else if (typeof value === "string") sections.set(name, value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return sections;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 动态取 SDK 包根的 `sessionEntryToContextMessages`。
|
|
83
|
+
*
|
|
84
|
+
* 不能写成 `import { sessionEntryToContextMessages } from "…"`:本地类型是 0.80.2,
|
|
85
|
+
* 具名导入会让 `tsc` 直接报 `has no exported member`,而这个包**不得**动 peerDependencies /
|
|
86
|
+
* lockfile。取不到就返回 null,调用方回退磁盘读(spec §19 的退路)。
|
|
87
|
+
*/
|
|
88
|
+
function resolveConverter(): ((entry: unknown) => unknown[]) | null {
|
|
89
|
+
const candidate = (sdk as unknown as Record<string, unknown>).sessionEntryToContextMessages;
|
|
90
|
+
return typeof candidate === "function" ? (candidate as (entry: unknown) => unknown[]) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 宿主投影里的 messages(0.99.2 的 `buildSessionProjection()`)。
|
|
95
|
+
*
|
|
96
|
+
* 不可用(没有该方法 / 抛错 / 返回值形状不对)时返回 `null`,调用方回落到下面的自实现重放。
|
|
97
|
+
* 与宿主同源很重要:双重 compaction 的保留边界、`context_edit` 的应用都由宿主决定,我们自己
|
|
98
|
+
* 复刻一份就会在极端会话上漂移(Plan C 终审 #4)。
|
|
99
|
+
*/
|
|
100
|
+
function projectionMessages(sm: Partial<ReplayableSessionManager>): unknown[] | null {
|
|
101
|
+
try {
|
|
102
|
+
// 特征探测也放进 try:宿主把该方法做成抛错的 getter / proxy 时同样只能回退,
|
|
103
|
+
// 不能让异常逃到 session_start。
|
|
104
|
+
if (typeof sm.buildSessionProjection !== "function") return null;
|
|
105
|
+
const messages = sm.buildSessionProjection()?.messages;
|
|
106
|
+
return Array.isArray(messages) ? messages : null;
|
|
107
|
+
} catch {
|
|
108
|
+
// 投影抛错(更老的 session 形状 / 宿主内部不变量不成立):回落自实现重放
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 自实现的 entry 重放(旧 SDK / 老 session 的退路):`getEntries` → `buildContextEntries`
|
|
115
|
+
* → 逐条转成 context messages。任何一步不可得都返回 `null`:转换函数缺失(SDK 太旧)、
|
|
116
|
+
* sessionManager 形状不认识、`getEntries` 抛错。
|
|
117
|
+
*/
|
|
118
|
+
function replayEntryMessages(sm: Partial<ReplayableSessionManager>, opts?: ReplayOpts): unknown[] | null {
|
|
119
|
+
const toMessages = opts?.sessionEntryToContextMessages ?? resolveConverter();
|
|
120
|
+
if (!toMessages) return null;
|
|
121
|
+
if (typeof sm.getEntries !== "function" || typeof sm.getLeafId !== "function") return null;
|
|
122
|
+
|
|
123
|
+
let entries: unknown[] = [];
|
|
124
|
+
try {
|
|
125
|
+
const raw = sm.getEntries();
|
|
126
|
+
if (Array.isArray(raw)) entries = raw;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let leafId: string | null = null;
|
|
132
|
+
try {
|
|
133
|
+
leafId = sm.getLeafId();
|
|
134
|
+
} catch {
|
|
135
|
+
// Finding 2 / R43:这个函数在 index.ts 的 session_start 路径上被调用,抛错会让整个
|
|
136
|
+
// session_start 失败 → memory 工具整个会话不注册。拿不到 leaf 就退化成「没有 branch」:
|
|
137
|
+
// 用全部 entry 重放,多出来的 system patch 只会被后面的覆盖或删掉。
|
|
138
|
+
}
|
|
139
|
+
if (typeof sm.buildContextEntries === "function") {
|
|
140
|
+
try {
|
|
141
|
+
const built = sm.buildContextEntries(entries, leafId);
|
|
142
|
+
if (Array.isArray(built)) entries = built;
|
|
143
|
+
} catch {
|
|
144
|
+
// 解析不出当前分支就用全部 entry:多出来的 system patch 只会被后面的覆盖或删掉
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const messages: unknown[] = [];
|
|
149
|
+
for (const e of entries) {
|
|
150
|
+
try {
|
|
151
|
+
const converted = toMessages(e);
|
|
152
|
+
if (Array.isArray(converted)) messages.push(...converted);
|
|
153
|
+
} catch {
|
|
154
|
+
// 单条 entry 的形状不认识:跳过它,别让整次重放失败
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return messages;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 从 transcript 里取出**录制的**索引值(D14:resume / fork / reload 必须用它,
|
|
163
|
+
* 否则被恢复会话的 system prompt 头部会被改写,折叠路径下其后的整段对话全部失去缓存)。
|
|
164
|
+
*
|
|
165
|
+
* **优先用宿主的 `buildSessionProjection()`** —— 与宿主算 `getCurrentSystemMessage` 同源;
|
|
166
|
+
* 拿不到(0.80.2 / 更老的 session / 投影抛错)才回落到自实现的 entry 重放(Plan C 终审 #4)。
|
|
167
|
+
*
|
|
168
|
+
* 两条路径都不可得时返回 `null`,由调用方回退磁盘读:SDK 太旧(既无投影也无转换函数)、
|
|
169
|
+
* sessionManager 形状不认识(含 `buildSessionProjection()` 的返回值里没有 `messages` 数组,
|
|
170
|
+
* `projectionMessages` 对非数组返回 `null`)、`buildSessionProjection` / `getEntries` 抛错、重放后没有这个键、
|
|
171
|
+
* 或该键被 `null` patch 删除。**单条 entry 的形状不认识**不会让整次重放失败:那一条被跳过
|
|
172
|
+
*(见 `replayEntryMessages`),其余 entry 照常重放 —— 它不会变成 `null` 返回值。
|
|
173
|
+
* **绝不把 `null` 当录制值返回**(spec §9.1 的 null 陷阱)。
|
|
174
|
+
*
|
|
175
|
+
* 返回值是**裸值**:宿主把 section 渲染成 `<memory_index>…</memory_index>` 后才写进
|
|
176
|
+
* transcript,所以这里要脱掉那一层再回写(R42 / `unwrapSectionValue`)。
|
|
177
|
+
*/
|
|
178
|
+
export function readRecordedMemoryIndex(sessionManager: unknown, opts?: ReplayOpts): string | null {
|
|
179
|
+
if (!isRecord(sessionManager)) return null;
|
|
180
|
+
const sm = sessionManager as unknown as Partial<ReplayableSessionManager>;
|
|
181
|
+
const messages = projectionMessages(sm) ?? replayEntryMessages(sm, opts);
|
|
182
|
+
if (messages === null) return null;
|
|
183
|
+
|
|
184
|
+
const recorded = replaySystemSections(messages).get(MEMORY_INDEX_SECTION);
|
|
185
|
+
return typeof recorded === "string" ? unwrapSectionValue(recorded) : null;
|
|
186
|
+
}
|