@ai-agent-forge/plugin-memory 0.88.13 → 0.88.15
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/agent-forge.json +1 -1
- package/dist/capability.d.ts +10 -7
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +24 -14
- package/dist/capability.js.map +1 -1
- package/dist/entry.d.ts.map +1 -1
- package/dist/entry.js +8 -1
- package/dist/entry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/inference-bin.bundle.js +77 -58
- package/dist/memory/command-face.d.ts.map +1 -1
- package/dist/memory/command-face.js +32 -10
- package/dist/memory/command-face.js.map +1 -1
- package/dist/memory/curator.d.ts +184 -0
- package/dist/memory/curator.d.ts.map +1 -0
- package/dist/memory/curator.js +551 -0
- package/dist/memory/curator.js.map +1 -0
- package/dist/memory/enabling.d.ts.map +1 -1
- package/dist/memory/enabling.js +6 -0
- package/dist/memory/enabling.js.map +1 -1
- package/dist/memory/hooks.d.ts +12 -55
- package/dist/memory/hooks.d.ts.map +1 -1
- package/dist/memory/hooks.js +2 -229
- package/dist/memory/hooks.js.map +1 -1
- package/dist/testing/memory-testkit.d.ts +3 -1
- package/dist/testing/memory-testkit.d.ts.map +1 -1
- package/dist/testing/memory-testkit.js +50 -7
- package/dist/testing/memory-testkit.js.map +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 归档会话整理器(记忆归档与整理设计 §5-§7,2026-10-22 裁决落地):
|
|
3
|
+
* 归档即后台整理。memory 插件负责调度、切片预算、批次协议、幂等提交与
|
|
4
|
+
* 生命周期;"从会话里提炼什么"由经 `memory.curation-policy@1` 能力目录
|
|
5
|
+
* require 到的整理策略插件回答(第一方默认策略是 plugins/memory-curation,
|
|
6
|
+
* 与第三方策略走同一公共契约,本模块对两者无差别)。
|
|
7
|
+
*
|
|
8
|
+
* 与来源切割(§6 用户裁决):整理产物是独立原子——不带 sessionRefs、不带指向
|
|
9
|
+
* 会话条目的 sourceRefs、不写 provenance;删除会话不触碰记忆原子。操作凭据
|
|
10
|
+
* 留在本侧 journal(`<agentDir>/archive/sessions/curation-journal.jsonl`,
|
|
11
|
+
* batchId + 内容 digest,不含会话原文)。
|
|
12
|
+
*
|
|
13
|
+
* 批次协议(§5.3.6/§5.3.7):journal 是批次的可恢复事实来源——追加式 JSONL,
|
|
14
|
+
* 行带 status:"open" 行先落盘(批次完整结果),全部原子提交完成后追加
|
|
15
|
+
* "committed" 事实行,同 batchId 以文件末条为准。curateSession 开始时先查
|
|
16
|
+
* 该会话的 open 批次:存在则不调策略、不读会话,直接按持久化的 statements
|
|
17
|
+
* 以相同确定性 observationId 重放提交(已提交条目去重跳过,剩余补齐),收口
|
|
18
|
+
* 后照常 reportCuration——旧批次未处理时不开始新批次,两批各自不完整的原子
|
|
19
|
+
* 不可能并存;重放发现批次实际早已全部提交(全部幂等跳过)也走完收口。
|
|
20
|
+
* 旧版无 status 字段的行等价 open(旧版在提交前一刻落盘,语义相同)。
|
|
21
|
+
*
|
|
22
|
+
* 暂停/续跑(§5.4 用户裁决):策略抛错、超时(60s 上限)或会话中止信号触发时
|
|
23
|
+
* 返回 paused——不写 curated、不落 journal、不留部分提交;curation 维度保持
|
|
24
|
+
* 缺失,归档事件重投或启动 sweep 会自然续跑。已提交条目经确定性 memoryId
|
|
25
|
+
* (owner + `curation:<batchId>:<index>`)在 store 回查去重,同 batchId 重试
|
|
26
|
+
* 不会产生重复原子。
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { buildMemoryAtomV1 } from "./foundation.js";
|
|
32
|
+
import { MEMORY_CONTRACT_VERSION_V2 } from "./transfer.js";
|
|
33
|
+
/** 整理策略能力 id(记忆归档与整理设计 §7.2 冻结契约)。 */
|
|
34
|
+
export const MEMORY_CURATION_POLICY_CAPABILITY_ID = "memory.curation-policy";
|
|
35
|
+
/** 整理策略能力版本(exact version matching)。 */
|
|
36
|
+
export const MEMORY_CURATION_POLICY_VERSION = 1;
|
|
37
|
+
/** 每片最大条目数(有界切片,按条数)。 */
|
|
38
|
+
export const CURATION_SLICE_MAX_ENTRIES = 200;
|
|
39
|
+
/** 最多片数;超出截断并在 existingStatements 摘要里说明。 */
|
|
40
|
+
export const CURATION_MAX_SLICES = 20;
|
|
41
|
+
/** 传给策略的既有 statement 摘要条数上限(去重参考)。 */
|
|
42
|
+
export const CURATION_EXISTING_STATEMENTS_LIMIT = 100;
|
|
43
|
+
/** 既有 statement 摘要的每条截断长度(字符)。 */
|
|
44
|
+
export const CURATION_EXISTING_STATEMENT_PREFIX_CHARS = 200;
|
|
45
|
+
/** 单批 proposal 条数上限;超出部分丢弃并 warn。 */
|
|
46
|
+
export const CURATION_MAX_PROPOSALS = 20;
|
|
47
|
+
/** 单条 statement 上限(与 memory_write 内容契约同口径,UTF-16 单元)。 */
|
|
48
|
+
export const CURATION_STATEMENT_MAX_CHARS = 700;
|
|
49
|
+
/** 策略调用硬截止;超时按暂停处理(模型恢复后续跑)。 */
|
|
50
|
+
export const CURATION_POLICY_TIMEOUT_MS = 60_000;
|
|
51
|
+
/** 整理产物 facets 的 capability 命名空间(与 tag facet 同域 "builtin.memory")。 */
|
|
52
|
+
const CURATION_FACET_NAMESPACE = "builtin.memory";
|
|
53
|
+
/** 整理产物保留档(first-party retention registry "long",随记忆自身周期朝生暮死)。 */
|
|
54
|
+
const CURATION_RETENTION_MODE_ID = "long";
|
|
55
|
+
/** 整理批次 journal 路径(相对 agentDir;只含 batchId/digest/结果,无会话原文)。 */
|
|
56
|
+
const CURATION_JOURNAL_RELATIVE_PATH = join("archive", "sessions", "curation-journal.jsonl");
|
|
57
|
+
/**
|
|
58
|
+
* `session.archived@1`(宿主 lifecycle catalog)数据的结构化收窄——只取整理器
|
|
59
|
+
* 读的字段。订阅走 `{id, version}` 引用形式:定义归宿主 bootstrap lifecycle
|
|
60
|
+
* 插件所有,宿主未定义该事件时注册被结构性拒绝(D-028 插件装载失败,绝不会
|
|
61
|
+
* 变成会话失败)。
|
|
62
|
+
*/
|
|
63
|
+
export const SESSION_ARCHIVED_EVENT_REFERENCE = { id: "session.archived", version: 1 };
|
|
64
|
+
/** 批次凭据:batchId = sha256(sessionId + 各 statement digest 拼接)。 */
|
|
65
|
+
export function curationBatchIdOf(sessionId, statementDigests) {
|
|
66
|
+
const hash = createHash("sha256");
|
|
67
|
+
hash.update(sessionId);
|
|
68
|
+
for (const digest of statementDigests)
|
|
69
|
+
hash.update(digest);
|
|
70
|
+
return hash.digest("hex");
|
|
71
|
+
}
|
|
72
|
+
/** 单条 statement 的 sha256 digest(journal 与 batchId 共用)。 */
|
|
73
|
+
export function curationStatementDigest(statement) {
|
|
74
|
+
return createHash("sha256").update(statement, "utf8").digest("hex");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* open 行形状校验(journal 由本模块写出,重放前仍校验——防崩溃残行与外手改写
|
|
78
|
+
* 产生非法原子)。旧版行无 status 字段:形状同 open,等价 open 处理。
|
|
79
|
+
*/
|
|
80
|
+
function isOpenEntryShape(value) {
|
|
81
|
+
if (value.status !== "open" && value.status !== undefined)
|
|
82
|
+
return false;
|
|
83
|
+
if (!Array.isArray(value.statements) || !Array.isArray(value.digests))
|
|
84
|
+
return false;
|
|
85
|
+
return value.statements.every((item) => isPlainRecord(item) &&
|
|
86
|
+
typeof item.statement === "string" &&
|
|
87
|
+
item.statement !== "" &&
|
|
88
|
+
Array.isArray(item.facets) &&
|
|
89
|
+
item.facets.every((facet) => isPlainRecord(facet) && typeof facet.key === "string" && typeof facet.value === "string"));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 读取该会话的未完成批次(§5.3.6 崩溃重试入口,§5.3.7 新批次守卫的依据):
|
|
93
|
+
* journal 是追加式事实流,同 batchId 以文件末条为准;末条仍为 open 即未完成。
|
|
94
|
+
* 多个 open 批次并存时(仅可能来自协议启用前的旧文件)取文件序最后一条。
|
|
95
|
+
* 文件不存在返回 undefined;JSON 解析失败的残行告警跳过(只可能是崩溃中追加
|
|
96
|
+
* 的末行,不影响更早的完整行);不可重放的 open 行属于事实来源损坏,向上抛错
|
|
97
|
+
* ——按 §5.3.7 拒绝开始新批次,而不是在坏批次之上再开一批。
|
|
98
|
+
*/
|
|
99
|
+
async function readOpenCurationBatch(journalPath, sessionId, logger) {
|
|
100
|
+
let text;
|
|
101
|
+
try {
|
|
102
|
+
text = await readFile(journalPath, "utf8");
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code === "ENOENT")
|
|
106
|
+
return undefined;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
const lastLineByBatch = new Map();
|
|
110
|
+
for (const [line, raw] of text.split("\n").entries()) {
|
|
111
|
+
if (raw.trim() === "")
|
|
112
|
+
continue;
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(raw);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
logger?.warn(`memory curation journal line ${line + 1} is not valid JSON; skipped`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!isPlainRecord(parsed) || parsed.sessionId !== sessionId)
|
|
122
|
+
continue;
|
|
123
|
+
const batchId = parsed.batchId;
|
|
124
|
+
if (typeof batchId !== "string" || batchId === "")
|
|
125
|
+
continue;
|
|
126
|
+
if (parsed.status === "committed") {
|
|
127
|
+
lastLineByBatch.set(batchId, { line, closed: true, open: undefined });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!isOpenEntryShape(parsed)) {
|
|
131
|
+
logger?.warn(`memory curation journal line ${line + 1} has an unrecognized shape; skipped`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
lastLineByBatch.set(batchId, { line, closed: false, open: parsed });
|
|
135
|
+
}
|
|
136
|
+
let latest;
|
|
137
|
+
for (const record of lastLineByBatch.values()) {
|
|
138
|
+
if (record.closed || record.open === undefined)
|
|
139
|
+
continue;
|
|
140
|
+
if (latest === undefined || record.line > latest.line)
|
|
141
|
+
latest = record;
|
|
142
|
+
}
|
|
143
|
+
return latest?.open;
|
|
144
|
+
}
|
|
145
|
+
/** 追加 committed 事实行(open 行落盘时目录已创建,此处无需 mkdir)。 */
|
|
146
|
+
async function appendCommittedJournalLine(journalPath, batchId, sessionId, now) {
|
|
147
|
+
const entry = {
|
|
148
|
+
batchId,
|
|
149
|
+
sessionId,
|
|
150
|
+
at: new Date(now()).toISOString(),
|
|
151
|
+
status: "committed",
|
|
152
|
+
};
|
|
153
|
+
await appendFile(journalPath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 确定性构造整理原子(记忆归档与整理设计 §5.2 冻结字段表)。不复用 buildWriteAtom——
|
|
157
|
+
* 那里 observationId 前缀写死 memory_write 会话序列。identityHash 由装配方
|
|
158
|
+
* 注入(capability.ts 的 FNV-1a,同一写入身份口径)。
|
|
159
|
+
*/
|
|
160
|
+
export function buildCuratedAtom(input) {
|
|
161
|
+
const observationId = `curation:${input.batchId}:${input.index}`;
|
|
162
|
+
const memoryId = `mem-${input.identityHash(`${input.owner}\u0000${observationId}`)}`;
|
|
163
|
+
const occurredAt = new Date(input.now()).toISOString();
|
|
164
|
+
return buildMemoryAtomV1({
|
|
165
|
+
memoryId,
|
|
166
|
+
contractVersion: MEMORY_CONTRACT_VERSION_V2,
|
|
167
|
+
schemaVersion: 1,
|
|
168
|
+
scope: "long-term",
|
|
169
|
+
retentionMode: CURATION_RETENTION_MODE_ID,
|
|
170
|
+
owner: input.owner,
|
|
171
|
+
profileId: input.manifestId,
|
|
172
|
+
...(input.domain.kind === "suite" ? { suiteId: input.domain.suiteId } : {}),
|
|
173
|
+
retentionPolicyVersion: "retention@1",
|
|
174
|
+
memoryKind: "synthesis",
|
|
175
|
+
payload: { statement: input.proposal.statement },
|
|
176
|
+
occurredAt,
|
|
177
|
+
recordedAt: occurredAt,
|
|
178
|
+
// 记忆与来源会话彻底切割(§6):无来源引用、无会话引用、无 provenance。
|
|
179
|
+
sourceRefs: [],
|
|
180
|
+
observationId,
|
|
181
|
+
sessionRefs: [],
|
|
182
|
+
agentInstanceRefs: [],
|
|
183
|
+
projectRefs: [],
|
|
184
|
+
subjectRefs: [],
|
|
185
|
+
facets: input.proposal.facets.map((facet) => ({
|
|
186
|
+
namespace: CURATION_FACET_NAMESPACE,
|
|
187
|
+
schemaVersion: 1,
|
|
188
|
+
key: facet.key,
|
|
189
|
+
value: facet.value,
|
|
190
|
+
})),
|
|
191
|
+
relations: [],
|
|
192
|
+
// 模型归纳内容(§5.2 evidenceClass derived):保守置信与推断写入同档 0.6。
|
|
193
|
+
confidence: 0.6,
|
|
194
|
+
importance: 0.5,
|
|
195
|
+
evidenceClass: "derived",
|
|
196
|
+
contentRevision: `c-${input.identityHash(input.proposal.statement)}`,
|
|
197
|
+
writeReason: `${input.manifestId}/curation`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 逐条提交批次原子(顺序照 write-face 工具路径:先账本后可见,投影埋点收尾)。
|
|
202
|
+
* 幂等判定同时看 store 与账本快照(§5.3 崩溃重试语义的提交侧,新批次与 journal
|
|
203
|
+
* 重放共用):提交循环可能在"账本已 append、store 未 commit"的窗口被打断——
|
|
204
|
+
* 账本是持久事实,重放对该窗口内的原子只补齐 store 可见性与投影,不再 append
|
|
205
|
+
* 重复账本行;两侧都在的条目跳过,都缺的整条提交。调用方负责 journal 状态收口
|
|
206
|
+
* 与 reportCuration。
|
|
207
|
+
*/
|
|
208
|
+
function commitBatchAtoms(deps, accepted, batchId) {
|
|
209
|
+
const { domain, ledger } = deps.resolveDomain();
|
|
210
|
+
const ledgeredMemoryIds = new Set(ledger.atomsSnapshot().map((atom) => atom.memoryId));
|
|
211
|
+
let committed = 0;
|
|
212
|
+
let duplicates = 0;
|
|
213
|
+
for (const [index, proposal] of accepted.entries()) {
|
|
214
|
+
const atom = buildCuratedAtom({
|
|
215
|
+
proposal,
|
|
216
|
+
batchId,
|
|
217
|
+
index,
|
|
218
|
+
owner: deps.owner,
|
|
219
|
+
domain,
|
|
220
|
+
manifestId: deps.manifest.id,
|
|
221
|
+
now: deps.now,
|
|
222
|
+
identityHash: deps.identityHash,
|
|
223
|
+
});
|
|
224
|
+
const ledgered = ledgeredMemoryIds.has(atom.memoryId);
|
|
225
|
+
if (ledgered && deps.store.get(atom.memoryId, { owner: deps.owner }) !== undefined) {
|
|
226
|
+
duplicates += 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!ledgered)
|
|
230
|
+
ledger.append(atom);
|
|
231
|
+
deps.store.commit(atom);
|
|
232
|
+
deps.vectorProjection.upsertAtom(atom);
|
|
233
|
+
committed += 1;
|
|
234
|
+
}
|
|
235
|
+
return { committed, duplicates };
|
|
236
|
+
}
|
|
237
|
+
/** payload `{ statement }` 约定的读取投影(与 capability.statementOf 同构)。 */
|
|
238
|
+
function statementTextOf(payload) {
|
|
239
|
+
if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
|
|
240
|
+
const statement = payload.statement;
|
|
241
|
+
if (typeof statement === "string")
|
|
242
|
+
return statement;
|
|
243
|
+
}
|
|
244
|
+
return JSON.stringify(payload);
|
|
245
|
+
}
|
|
246
|
+
const boundedErrorText = (error) => {
|
|
247
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
248
|
+
const codePoints = Array.from(message);
|
|
249
|
+
return codePoints.length <= 200 ? message : `${codePoints.slice(0, 200).join("")}…`;
|
|
250
|
+
};
|
|
251
|
+
const isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
252
|
+
/**
|
|
253
|
+
* proposals 校验(提交闸门在 memory 侧,记忆归档与整理设计 §7.2):statement 非空且
|
|
254
|
+
* ≤700 字符、条数 ≤20(超出丢弃并 warn)、facets 形状非法整条丢弃。facets 内
|
|
255
|
+
* 单项 key/value 非空字符串校验,非法项丢弃该 facet(不连坐整条)。结果整体
|
|
256
|
+
* 非 { proposals: [...] } 形状 = 策略契约失败,返回 undefined 交由调用方暂停。
|
|
257
|
+
*/
|
|
258
|
+
function validateProposals(raw, logger) {
|
|
259
|
+
if (!isPlainRecord(raw) || !Array.isArray(raw.proposals))
|
|
260
|
+
return undefined;
|
|
261
|
+
const accepted = [];
|
|
262
|
+
let dropped = 0;
|
|
263
|
+
for (const [index, item] of raw.proposals.entries()) {
|
|
264
|
+
if (accepted.length >= CURATION_MAX_PROPOSALS) {
|
|
265
|
+
dropped += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!isPlainRecord(item) || typeof item.statement !== "string") {
|
|
269
|
+
logger?.warn(`memory curation dropped proposal[${index}]: statement must be a string`);
|
|
270
|
+
dropped += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const statement = item.statement;
|
|
274
|
+
if (statement.trim() === "") {
|
|
275
|
+
logger?.warn(`memory curation dropped proposal[${index}]: statement is empty`);
|
|
276
|
+
dropped += 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (statement.length > CURATION_STATEMENT_MAX_CHARS) {
|
|
280
|
+
logger?.warn(`memory curation dropped proposal[${index}]: statement is ${statement.length} characters (limit ${CURATION_STATEMENT_MAX_CHARS})`);
|
|
281
|
+
dropped += 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (item.facets !== undefined && !Array.isArray(item.facets)) {
|
|
285
|
+
logger?.warn(`memory curation dropped proposal[${index}]: facets must be an array when present`);
|
|
286
|
+
dropped += 1;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const facets = [];
|
|
290
|
+
for (const [facetIndex, rawFacet] of (item.facets ?? []).entries()) {
|
|
291
|
+
if (!isPlainRecord(rawFacet) ||
|
|
292
|
+
typeof rawFacet.key !== "string" ||
|
|
293
|
+
rawFacet.key.trim() === "" ||
|
|
294
|
+
typeof rawFacet.value !== "string" ||
|
|
295
|
+
rawFacet.value.trim() === "") {
|
|
296
|
+
logger?.warn(`memory curation dropped proposal[${index}] facet[${facetIndex}]: key/value must be non-empty strings`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
facets.push({ key: rawFacet.key, value: rawFacet.value });
|
|
300
|
+
}
|
|
301
|
+
accepted.push({ statement, facets });
|
|
302
|
+
}
|
|
303
|
+
if (dropped > 0) {
|
|
304
|
+
logger?.warn(`memory curation dropped ${dropped} invalid proposal(s) before committing the batch`);
|
|
305
|
+
}
|
|
306
|
+
return { accepted, dropped };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* 策略调用竞速:`handle.result` vs 60s 硬截止 vs 会话中止信号。三个败因都收敛
|
|
310
|
+
* 为 paused 的 reason(模型不可用/挂起 → 暂停续跑语义,§5.4 用户裁决)。
|
|
311
|
+
*/
|
|
312
|
+
async function awaitPolicyResult(handle, signal) {
|
|
313
|
+
let timer;
|
|
314
|
+
// 中止信号 → 立即拒绝的竞速臂;dispose 摘除监听,竞速已定时不再持有信号。
|
|
315
|
+
let abortArm;
|
|
316
|
+
if (signal !== undefined) {
|
|
317
|
+
abortArm = (() => {
|
|
318
|
+
let rejectFn;
|
|
319
|
+
const onAbort = () => rejectFn?.(new Error("memory curation aborted: session abort signal fired"));
|
|
320
|
+
const promise = new Promise((_, reject) => {
|
|
321
|
+
rejectFn = reject;
|
|
322
|
+
if (signal.aborted)
|
|
323
|
+
reject(new Error("memory curation aborted: session abort signal fired"));
|
|
324
|
+
else
|
|
325
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
326
|
+
});
|
|
327
|
+
return { promise, dispose: () => signal.removeEventListener("abort", onAbort) };
|
|
328
|
+
})();
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const value = await Promise.race([
|
|
332
|
+
handle.result,
|
|
333
|
+
new Promise((_, reject) => {
|
|
334
|
+
timer = setTimeout(() => reject(new Error(`memory curation policy did not settle within ${CURATION_POLICY_TIMEOUT_MS}ms`)), CURATION_POLICY_TIMEOUT_MS);
|
|
335
|
+
timer.unref?.();
|
|
336
|
+
}),
|
|
337
|
+
...(abortArm === undefined ? [] : [abortArm.promise]),
|
|
338
|
+
]);
|
|
339
|
+
return { ok: true, value };
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
343
|
+
if (message.includes("did not settle within"))
|
|
344
|
+
return { ok: false, reason: "policy-timeout", error };
|
|
345
|
+
if (message.includes("abort signal fired"))
|
|
346
|
+
return { ok: false, reason: "aborted", error };
|
|
347
|
+
return { ok: false, reason: "policy-error", error };
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
if (timer !== undefined)
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
abortArm?.dispose();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 整理一个归档会话(记忆归档与整理设计 §5 批次协议):幂等状态回查 → journal
|
|
357
|
+
* open 批次重放(有则不调策略、不读会话,按持久化结果补齐提交并收口,§5.3.6/
|
|
358
|
+
* §5.3.7)→ 策略 require → 有界分页读 → 逐片策略 propose(每片一次调用;任一
|
|
359
|
+
* 片失败/超时/中止/非法结果即整批暂停,§5.4)→ 合并候选 → journal open 行落盘
|
|
360
|
+
* → 逐条幂等提交 → journal committed 收口 → 回写 curated。任何暂停/失败路径都
|
|
361
|
+
* 不写 curated、不留部分提交。
|
|
362
|
+
*/
|
|
363
|
+
export async function curateSession(deps, sessionId) {
|
|
364
|
+
const { api, sessionArchive, store, owner } = deps;
|
|
365
|
+
const logger = api.logger;
|
|
366
|
+
if (sessionArchive === undefined) {
|
|
367
|
+
// 无归档面则 reportCuration 不可能:本地记录后直接返回。
|
|
368
|
+
logger?.warn(`memory curation skipped for session ${sessionId}: host exposes no session-archive face`);
|
|
369
|
+
return { status: "skipped", reason: "no-archive" };
|
|
370
|
+
}
|
|
371
|
+
// 幂等:该会话已有整理结论时不再跑批(重试链路与事件重投的自然短路)。
|
|
372
|
+
const existingStatus = await sessionArchive.curationStatus(sessionId);
|
|
373
|
+
if (existingStatus === "curated")
|
|
374
|
+
return { status: "skipped", reason: "already-curated" };
|
|
375
|
+
if (existingStatus === "skipped")
|
|
376
|
+
return { status: "skipped", reason: "already-skipped" };
|
|
377
|
+
const journalPath = join(deps.agentDir, CURATION_JOURNAL_RELATIVE_PATH);
|
|
378
|
+
// 批次协议(§5.3.6/§5.3.7):open 批次是唯一事实来源——不调策略、不读会话,
|
|
379
|
+
// 直接按持久化的 statements 以相同确定性 observationId 重放提交(已提交条目
|
|
380
|
+
// 去重跳过,剩余补齐),全部落库后收口 committed 并 reportCuration。"旧批次
|
|
381
|
+
// 未处理时拒绝开始新批次"由此自然成立:open 批次永远优先重放,两批各自不
|
|
382
|
+
// 完整的原子不可能并存;全部幂等跳过(实际早已提交完)也走完收口。重放不
|
|
383
|
+
// 依赖策略能力——journal 不依赖策略,策略缺席不阻塞恢复。
|
|
384
|
+
const openBatch = await readOpenCurationBatch(journalPath, sessionId, logger);
|
|
385
|
+
if (openBatch !== undefined) {
|
|
386
|
+
const accepted = openBatch.statements.map((item) => ({
|
|
387
|
+
statement: item.statement,
|
|
388
|
+
facets: item.facets.map((facet) => ({ key: facet.key, value: facet.value })),
|
|
389
|
+
}));
|
|
390
|
+
const { committed, duplicates } = commitBatchAtoms(deps, accepted, openBatch.batchId);
|
|
391
|
+
await appendCommittedJournalLine(journalPath, openBatch.batchId, sessionId, deps.now);
|
|
392
|
+
logger?.info(`memory curation replayed open batch ${openBatch.batchId} for session ${sessionId}: ${committed} committed, ${duplicates} already present`);
|
|
393
|
+
await sessionArchive.reportCuration(sessionId, "curated", { batchId: openBatch.batchId });
|
|
394
|
+
return { status: "curated", batchId: openBatch.batchId, committed, duplicates };
|
|
395
|
+
}
|
|
396
|
+
// 策略 require(optional 消费模式,coding-tools requireCoordination 先例):
|
|
397
|
+
// 未装策略 = 整理不可用,会话按 skipped 回写(宿主确认框须明示未经整理)。
|
|
398
|
+
let policyHandle;
|
|
399
|
+
try {
|
|
400
|
+
policyHandle = api.capabilities.require(MEMORY_CURATION_POLICY_CAPABILITY_ID, {
|
|
401
|
+
version: MEMORY_CURATION_POLICY_VERSION,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
await sessionArchive.reportCuration(sessionId, "skipped");
|
|
406
|
+
return { status: "skipped", reason: "policy-missing" };
|
|
407
|
+
}
|
|
408
|
+
// 有界分页读(整理器读归档会话的唯一公共路径):每片 ≤200 条,最多 20 片,
|
|
409
|
+
// 超出截断并在 existingStatements 摘要里说明。
|
|
410
|
+
const slices = [];
|
|
411
|
+
let offset;
|
|
412
|
+
for (let slice = 0; slice < CURATION_MAX_SLICES; slice += 1) {
|
|
413
|
+
const chunk = await sessionArchive.readEntries(sessionId, {
|
|
414
|
+
offset,
|
|
415
|
+
limit: CURATION_SLICE_MAX_ENTRIES,
|
|
416
|
+
});
|
|
417
|
+
if (chunk.entries.length > 0)
|
|
418
|
+
slices.push(chunk.entries);
|
|
419
|
+
offset = chunk.nextOffset;
|
|
420
|
+
if (offset === undefined)
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
const truncated = offset !== undefined;
|
|
424
|
+
// 既有 statement 摘要(当前 store 实例内,owner 边界;≤100 条,前 200 字符)。
|
|
425
|
+
const existingSet = new Set();
|
|
426
|
+
for (const record of store.list({ owner })) {
|
|
427
|
+
if (existingSet.size >= CURATION_EXISTING_STATEMENTS_LIMIT)
|
|
428
|
+
break;
|
|
429
|
+
existingSet.add(statementTextOf(record.atom.payload).slice(0, CURATION_EXISTING_STATEMENT_PREFIX_CHARS));
|
|
430
|
+
}
|
|
431
|
+
const existingStatements = [...existingSet];
|
|
432
|
+
if (truncated) {
|
|
433
|
+
existingStatements.push(`[session input truncated after ${CURATION_MAX_SLICES} slices (${CURATION_MAX_SLICES * CURATION_SLICE_MAX_ENTRIES} entries); entries beyond this bound were not read]`);
|
|
434
|
+
}
|
|
435
|
+
// 策略调用(逐片,§5.4 单次模型调用输入有界):每片一次 propose、顺序执行;
|
|
436
|
+
// 任一片失败/超时/中止 → 整批暂停(不写 curated、不留部分提交;curation 维度
|
|
437
|
+
// 保持缺失,重投或 sweep 自然续跑)。call 本身同步抛错(宿主分派失败/handler
|
|
438
|
+
// 同步 throw)与 result 的 rejection 同语义。
|
|
439
|
+
const accepted = [];
|
|
440
|
+
for (const [sliceIndex, slice] of slices.entries()) {
|
|
441
|
+
let callOutcome;
|
|
442
|
+
try {
|
|
443
|
+
callOutcome = await awaitPolicyResult(policyHandle.call({
|
|
444
|
+
op: "propose",
|
|
445
|
+
sessionId,
|
|
446
|
+
slices: [slice],
|
|
447
|
+
existingStatements,
|
|
448
|
+
}), api.sessionAbortSignal?.());
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
callOutcome = { ok: false, reason: "policy-error", error };
|
|
452
|
+
}
|
|
453
|
+
if (!callOutcome.ok) {
|
|
454
|
+
logger?.warn(`memory curation paused for session ${sessionId} (${callOutcome.reason}) at slice ${sliceIndex + 1}/${slices.length}: ${boundedErrorText(callOutcome.error)}`);
|
|
455
|
+
return { status: "paused", reason: callOutcome.reason };
|
|
456
|
+
}
|
|
457
|
+
const validated = validateProposals(callOutcome.value, logger);
|
|
458
|
+
if (validated === undefined) {
|
|
459
|
+
logger?.warn(`memory curation paused for session ${sessionId}: policy returned a malformed propose result at slice ${sliceIndex + 1}/${slices.length}`);
|
|
460
|
+
return { status: "paused", reason: "invalid-result" };
|
|
461
|
+
}
|
|
462
|
+
accepted.push(...validated.accepted);
|
|
463
|
+
}
|
|
464
|
+
// 批次条数上限(§7.2 提交闸门)跨片生效:单批上限不变,超出部分丢弃并 warn。
|
|
465
|
+
if (accepted.length > CURATION_MAX_PROPOSALS) {
|
|
466
|
+
const dropped = accepted.length - CURATION_MAX_PROPOSALS;
|
|
467
|
+
accepted.splice(CURATION_MAX_PROPOSALS);
|
|
468
|
+
logger?.warn(`memory curation dropped ${dropped} proposal(s) beyond the batch limit of ${CURATION_MAX_PROPOSALS}`);
|
|
469
|
+
}
|
|
470
|
+
// 批次凭据:batchId = sha256(sessionId + 各 statement digest 拼接);完整结果
|
|
471
|
+
// 先落 journal(可恢复的事实来源,不含会话原文),再逐条提交。
|
|
472
|
+
const digests = accepted.map((proposal) => curationStatementDigest(proposal.statement));
|
|
473
|
+
const batchId = curationBatchIdOf(sessionId, digests);
|
|
474
|
+
const journalEntry = {
|
|
475
|
+
batchId,
|
|
476
|
+
sessionId,
|
|
477
|
+
at: new Date(deps.now()).toISOString(),
|
|
478
|
+
status: "open",
|
|
479
|
+
statements: accepted.map((proposal) => ({ statement: proposal.statement, facets: proposal.facets })),
|
|
480
|
+
digests,
|
|
481
|
+
};
|
|
482
|
+
await mkdir(join(deps.agentDir, "archive", "sessions"), { recursive: true });
|
|
483
|
+
await appendFile(journalPath, `${JSON.stringify(journalEntry)}\n`, "utf8");
|
|
484
|
+
// 逐条提交(§5.3 崩溃重试语义的提交侧),完成后追加 committed 事实行收口,
|
|
485
|
+
// 再回写 curated——重试读到 committed 即不再重放该批次。
|
|
486
|
+
const { committed, duplicates } = commitBatchAtoms(deps, accepted, batchId);
|
|
487
|
+
await appendCommittedJournalLine(journalPath, batchId, sessionId, deps.now);
|
|
488
|
+
await sessionArchive.reportCuration(sessionId, "curated", { batchId });
|
|
489
|
+
return { status: "curated", batchId, committed, duplicates };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* 装配归档整理器(记忆归档与整理设计 §5-§7):
|
|
493
|
+
* - 启动 sweep:`sessionArchive.listArchived()` 找 curation 缺失的会话逐个整理;
|
|
494
|
+
* - `session.archived@1` 事件观察:归档即后台整理(handler 同步返回,整理在
|
|
495
|
+
* 后台跑,不阻塞事件投递)。
|
|
496
|
+
* 同一会话的整理经每会话一条 promise 链串行(sweep 与事件重投并发时天然去重)。
|
|
497
|
+
* 归档面缺席(testkit/老宿主)时两个触发都不注册,返回空注册列表。
|
|
498
|
+
*/
|
|
499
|
+
export function createMemoryCurator(deps) {
|
|
500
|
+
const { api, sessionArchive } = deps;
|
|
501
|
+
const logger = api.logger;
|
|
502
|
+
if (sessionArchive === undefined)
|
|
503
|
+
return { registrations: [] };
|
|
504
|
+
const chains = new Map();
|
|
505
|
+
const enqueue = (sessionId) => {
|
|
506
|
+
// 每会话一条链: 前一链的失败不阻断后续 (catch 抹平), 同会话并发触发
|
|
507
|
+
// (事件重投 + sweep) 在链上串行, 不会并发跑批。
|
|
508
|
+
const last = chains.get(sessionId) ?? Promise.resolve();
|
|
509
|
+
const next = last.catch(() => undefined).then(() => curateSession(deps, sessionId));
|
|
510
|
+
chains.set(sessionId, next);
|
|
511
|
+
void next.catch((error) => {
|
|
512
|
+
logger?.warn(`memory curation failed for session ${sessionId}: ${boundedErrorText(error)}`);
|
|
513
|
+
});
|
|
514
|
+
// 链落定后摘除表项 (守卫: 已被更新的链接手时不删)。catch 抹平后的
|
|
515
|
+
// finally 臂自身不会 reject, 无 unhandled rejection。
|
|
516
|
+
void next
|
|
517
|
+
.catch(() => undefined)
|
|
518
|
+
.finally(() => {
|
|
519
|
+
if (chains.get(sessionId) === next)
|
|
520
|
+
chains.delete(sessionId);
|
|
521
|
+
});
|
|
522
|
+
};
|
|
523
|
+
// 启动 sweep:后台串行补齐历史归档会话的整理(curation 维度已有结论的跳过)。
|
|
524
|
+
const sweep = async () => {
|
|
525
|
+
try {
|
|
526
|
+
const archived = await sessionArchive.listArchived();
|
|
527
|
+
for (const item of archived) {
|
|
528
|
+
if (item.curation !== undefined)
|
|
529
|
+
continue;
|
|
530
|
+
enqueue(item.sessionId);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
logger?.warn(`memory curation startup sweep failed: ${boundedErrorText(error)}`);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
void sweep();
|
|
538
|
+
const registrations = [];
|
|
539
|
+
// lifecycle 在 CapabilityAPI 类型上是必选成员(experimental),最小宿主可以
|
|
540
|
+
// 不带该面;事件触发缺席时启动 sweep 仍可用,与 hooks 同款降级。
|
|
541
|
+
if (api.lifecycle !== undefined) {
|
|
542
|
+
registrations.push(api.lifecycle.registerObserve(SESSION_ARCHIVED_EVENT_REFERENCE, (event) => {
|
|
543
|
+
enqueue(event.data.sessionId);
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
logger?.warn("memory curator: host exposes no lifecycle face; session.archived trigger is unavailable");
|
|
548
|
+
}
|
|
549
|
+
return { registrations };
|
|
550
|
+
}
|
|
551
|
+
//# sourceMappingURL=curator.js.map
|