dsh-subagent-profile 0.3.3 → 0.3.4

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.
@@ -0,0 +1,48 @@
1
+ // lib/core/adoption-reminder.mjs — 未采纳判定 → 提醒条目 mint 的统一入口。
2
+ // 职责:把 adoption tracker 的「明确未采纳」记录转成提醒中心条目(标题/说明/主会话
3
+ // id/子会话 id/任务摘要/结算摘要/估算成本),并保留同源治理审计。提醒 store 未就绪
4
+ // 或写失败一律吞掉,绝不阻断判定路径。import-free(仅 node 内置 + cost-evidence),
5
+ // 可被裸 CI 单测 import。
6
+
7
+ import { dispatchCost } from './cost-evidence.mjs';
8
+
9
+ // prompt 摘要:前 200 字符、不存原文。派发登记与未采纳提醒条目共用同一口径。
10
+ export function promptExcerptOf(prompt) {
11
+ return typeof prompt === 'string' ? prompt.slice(0, 200) : '';
12
+ }
13
+
14
+ // 提醒 mint 统一入口:store 未就绪/失败一律吞掉,绝不阻断治理路径。
15
+ export function mintReminder(store, entry) {
16
+ try { if (store !== undefined) store.record(entry); } catch { /* 提醒 mint 失败不影响治理路径(fail-soft) */ }
17
+ }
18
+
19
+ // 未采纳审计:与提醒同源留痕(一条判定 = 一条治理审计),写失败不影响判定。
20
+ function recordUnadoptedAudit(evoLedger, rec) {
21
+ try {
22
+ evoLedger.recordGovernanceAudit({ kind: 'adoption-false', sessionId: rec.parentSessionId, childId: rec.id, ledgerKey: rec.ledgerKey, mode: rec.mode });
23
+ } catch { /* 审计写失败不影响判定 */ }
24
+ }
25
+
26
+ // 未采纳条目组装:severity 用「注意」级;detail 是说明行;任务行复用 prompt 摘要
27
+ // (tracker 侧已截 200 字符);结果行数据来自结算摘要,成本按请求模型(回退父模型)
28
+ // 与 usage 五段计价,算不出就不带成本字段(客户端只显示可得部分)。
29
+ export function mintUnadoptedReminder(store, evoLedger, rec) {
30
+ recordUnadoptedAudit(evoLedger, rec);
31
+ const model = rec.requestedModel ?? rec.parentModel;
32
+ const costEstimated = typeof model === 'string' && model !== ''
33
+ ? dispatchCost({ model, usage: rec.settled?.childUsage })
34
+ : undefined;
35
+ mintReminder(store, {
36
+ severity: 'P2',
37
+ kind: 'adoption-false',
38
+ title: '派发结果未被采纳',
39
+ detail: '父 Agent 未采用该结果。若你认为结果有价值,可前往查看。',
40
+ sessionId: rec.parentSessionId,
41
+ childId: rec.id,
42
+ childSessionId: rec.childSessionId ?? null,
43
+ childMode: rec.mode ?? null,
44
+ promptExcerpt: rec.promptExcerpt ?? '',
45
+ settled: rec.settled ?? null,
46
+ ...(typeof costEstimated === 'number' && Number.isFinite(costEstimated) ? { costEstimated } : {}),
47
+ });
48
+ }
@@ -0,0 +1,430 @@
1
+ // lib/core/adoption-tracker.mjs — parent_adopted 三态判定(插件侧不改宿主)。
2
+ // 用 session/event 事件流 + decisionTrace.execution 锚点判定父 Agent 后续会话
3
+ // 「采纳 / 明确未采纳 / 待定」子结果。判定窗口两个维度:
4
+ // N 轮(session/event 的 turn/end 计数)与 T ms(进程内 setTimeout + unref;重启后
5
+ // pending 记录按 dispatchedAt 时间窗继续判定,窗口连续性按诚实边界保持 live-only)。
6
+ //
7
+ // 三态语义(复用 evolution-summary.adoptStatus):adoptedAt 有值 → 'true'(父后续会话
8
+ // 引用 childId/jobId 或复用子输出内容);decidedFalse → 'false'(用户拒绝/父会话结束/
9
+ // 窗口到期未引用);其余 → 'unknown'(窗口内无信号,不惩罚)。
10
+ //
11
+ // 聚合 join:confirmedFalseCounts() 按 ledgerKey(与 computeSummaries 的 L1 身份键
12
+ // 同源,经 evolution-summary.profileKeyOf 生成)汇总已确认未采纳计数,由调用方传入
13
+ // refreshSummaries({ opts: { parentAdoptedConfirmedFalse } }) 惰性 join 进
14
+ // weighted_success(-0.3 惩罚)。不写回 append-only 的 dispatch.jsonl(既有纪律)。
15
+ //
16
+ // 隐私红线:只持久化结构字段(ledgerKey/sessionId/id/mode/时间戳/锚点/轮数/判定、
17
+ // prompt 摘要 ≤200 字符、结算摘要结构)。绝不落子输出正文——outputSnippet 仅内存存在,
18
+ // 供内容复用判定(第二层信号)后随进程生命周期丢弃。prompt 摘要与结算摘要供「未采纳」
19
+ // 提醒条目展示(任务/结果两行),落盘时不存 prompt 原文与子输出正文。
20
+ // import-free(仅 node 内置 + evolution-summary),可被裸 CI 单测 import。
21
+
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { adoptStatus } from './evolution-summary.mjs';
25
+
26
+ // 判定窗口与内容复用参数(建议初值,待实测校准;windowN/windowMs 可经工厂注入)。
27
+ const DEFAULT_WINDOW_N = 3;
28
+ const DEFAULT_WINDOW_MS = 10 * 60 * 1000;
29
+ const SNIPPET_CHARS = 80;
30
+ const MIN_REUSE_CHARS = 40;
31
+
32
+ function emptyKey(value) {
33
+ return typeof value !== 'string' || value === '';
34
+ }
35
+
36
+ // 记录键:parentSessionId + '\u0000' + childId/jobId(\u0000 不出现在 JSON 字符串)。
37
+ function recordKey(parentSessionId, id) {
38
+ return parentSessionId + '\u0000' + id;
39
+ }
40
+
41
+ // --- sidecar 持久化(原子写 tmp+rename;损坏 fail-soft 从空起步)--------------------
42
+
43
+ function isRecordShape(rec) {
44
+ return rec !== null && typeof rec === 'object' && typeof rec.parentSessionId === 'string'
45
+ && typeof rec.id === 'string' && typeof rec.ledgerKey === 'string';
46
+ }
47
+
48
+ // 结算摘要形状守卫:只接受对象(含 stopReason 字符串或空),其余回退 null。
49
+ function isSettleShape(value) {
50
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
51
+ }
52
+
53
+ // 结算摘要归一:只保留提醒条目结果行需要的字段(stopReason/耗时/token/usage/子会话
54
+ // id),丢弃 calls/output 等展示冗余——控制 sidecar 体积与隐私面。
55
+ function settleSummaryOf(settled) {
56
+ if (settled === null || typeof settled !== 'object') return null;
57
+ const out = {
58
+ stopReason: typeof settled.stopReason === 'string' ? settled.stopReason : '',
59
+ elapsedMs: Number.isFinite(settled.elapsedMs) ? settled.elapsedMs : null,
60
+ };
61
+ if (Number.isFinite(settled.childTotalTokens)) out.childTotalTokens = settled.childTotalTokens;
62
+ if (settled.childUsage !== null && typeof settled.childUsage === 'object' && !Array.isArray(settled.childUsage)) out.childUsage = settled.childUsage;
63
+ if (typeof settled.childSessionId === 'string' && settled.childSessionId !== '') out.childSessionId = settled.childSessionId;
64
+ return out;
65
+ }
66
+
67
+ function loadState(stateFile, records, warn) {
68
+ if (stateFile === undefined) return;
69
+ try {
70
+ if (!existsSync(stateFile)) return;
71
+ const parsed = JSON.parse(readFileSync(stateFile, 'utf8'));
72
+ if (parsed === null || typeof parsed !== 'object' || parsed.v !== 1 || !Array.isArray(parsed.records)) {
73
+ warn('adoption tracker: 判定状态文件版本或形状不符,从空状态起步');
74
+ return;
75
+ }
76
+ for (const raw of parsed.records) {
77
+ if (!isRecordShape(raw)) continue;
78
+ records.set(recordKey(raw.parentSessionId, raw.id), {
79
+ parentSessionId: raw.parentSessionId,
80
+ id: raw.id,
81
+ ledgerKey: raw.ledgerKey,
82
+ mode: typeof raw.mode === 'string' ? raw.mode : 'one-shot',
83
+ dispatchedAt: Number.isFinite(raw.dispatchedAt) ? raw.dispatchedAt : 0,
84
+ anchorSeq: Number.isFinite(raw.anchorSeq) ? raw.anchorSeq : null,
85
+ anchorTurn: Number.isFinite(raw.anchorTurn) ? raw.anchorTurn : null,
86
+ adoptedAt: Number.isFinite(raw.adoptedAt) ? raw.adoptedAt : null,
87
+ decidedFalse: raw.decidedFalse === true,
88
+ snippet: '',
89
+ promptExcerpt: typeof raw.promptExcerpt === 'string' ? raw.promptExcerpt : '',
90
+ requestedModel: typeof raw.requestedModel === 'string' && raw.requestedModel !== '' ? raw.requestedModel : null,
91
+ parentModel: typeof raw.parentModel === 'string' && raw.parentModel !== '' ? raw.parentModel : null,
92
+ childSessionId: typeof raw.childSessionId === 'string' && raw.childSessionId !== '' ? raw.childSessionId : null,
93
+ settled: isSettleShape(raw.settled) ? settleSummaryOf(raw.settled) : null,
94
+ });
95
+ }
96
+ } catch (error) {
97
+ warn('adoption tracker: 判定状态文件读取失败,从空状态起步(' + (error instanceof Error ? error.message : String(error)) + ')');
98
+ }
99
+ }
100
+
101
+ function persistState(stateFile, records, warn) {
102
+ if (stateFile === undefined) return;
103
+ const payload = {
104
+ v: 1,
105
+ records: [...records.values()].map((rec) => ({
106
+ parentSessionId: rec.parentSessionId,
107
+ id: rec.id,
108
+ ledgerKey: rec.ledgerKey,
109
+ mode: rec.mode,
110
+ dispatchedAt: rec.dispatchedAt,
111
+ anchorSeq: rec.anchorSeq,
112
+ anchorTurn: rec.anchorTurn,
113
+ adoptedAt: rec.adoptedAt,
114
+ decidedFalse: rec.decidedFalse,
115
+ promptExcerpt: rec.promptExcerpt ?? '',
116
+ requestedModel: rec.requestedModel ?? null,
117
+ parentModel: rec.parentModel ?? null,
118
+ childSessionId: rec.childSessionId ?? null,
119
+ settled: rec.settled ?? null,
120
+ })),
121
+ };
122
+ const tmp = stateFile + '.tmp';
123
+ try {
124
+ mkdirSync(dirname(stateFile), { recursive: true });
125
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
126
+ renameSync(tmp, stateFile);
127
+ } catch (error) {
128
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
129
+ warn('adoption tracker: 判定状态写入失败(' + (error instanceof Error ? error.message : String(error)) + ')');
130
+ }
131
+ }
132
+
133
+ // --- 事件面纯函数 ----------------------------------------------------------------
134
+
135
+ function parseArguments(raw) {
136
+ if (typeof raw !== 'string' || raw === '') return raw;
137
+ try { return JSON.parse(raw); } catch { return raw; }
138
+ }
139
+
140
+ // 事件文本面:type + data 各字段文本(字符串原样、对象 JSON 化),供精确 id 引用与
141
+ // 内容复用两层判定做子串匹配。
142
+ function eventText(event) {
143
+ if (event === null || typeof event !== 'object') return '';
144
+ const data = event.data;
145
+ const parts = typeof event.type === 'string' ? [event.type] : [];
146
+ if (data === null || typeof data !== 'object') return parts.join('\n');
147
+ for (const key of Object.keys(data)) {
148
+ const value = data[key];
149
+ if (value === null || value === undefined) continue;
150
+ if (typeof value === 'string') parts.push(value);
151
+ else if (typeof value === 'object') {
152
+ try { parts.push(JSON.stringify(value)); } catch { /* best effort */ }
153
+ } else parts.push(String(value));
154
+ }
155
+ return parts.join('\n');
156
+ }
157
+
158
+ // 引用判定(第一层精确 id):job_output 的 job_id 精确等于 id(后台强信号),
159
+ // 或事件文本面包含 id(父 assistant/tool 文本直接引用 childId/jobId)。
160
+ function referencesId(event, id) {
161
+ if (emptyKey(id)) return false;
162
+ const data = event !== null && typeof event === 'object' ? event.data : null;
163
+ if (event !== null && typeof event === 'object' && event.type === 'tool/call'
164
+ && data !== null && typeof data === 'object' && data.name === 'job_output') {
165
+ const args = parseArguments(data.arguments);
166
+ if (args !== null && typeof args === 'object' && args.job_id === id) return true;
167
+ }
168
+ return eventText(event).includes(id);
169
+ }
170
+
171
+ // --- 状态操作(模块级,经 state 注入守函数行门)-------------------------------------
172
+
173
+ function createState(options) {
174
+ const records = new Map();
175
+ const sessionTurns = new Map();
176
+ loadState(options.stateFile, records, options.warn);
177
+ return {
178
+ records,
179
+ sessionTurns,
180
+ stateFile: options.stateFile,
181
+ windowN: options.windowN,
182
+ windowMs: options.windowMs,
183
+ warn: options.warn,
184
+ onDecision: options.onDecision,
185
+ onDecided: options.onDecided,
186
+ now: options.now,
187
+ dirty: false,
188
+ timer: null,
189
+ };
190
+ }
191
+
192
+ function pendingFor(state, parentSessionId) {
193
+ const out = [];
194
+ for (const rec of state.records.values()) {
195
+ if (rec.parentSessionId === parentSessionId && rec.adoptedAt === null && rec.decidedFalse !== true) out.push(rec);
196
+ }
197
+ return out;
198
+ }
199
+
200
+ function hasPending(state) {
201
+ for (const rec of state.records.values()) {
202
+ if (rec.adoptedAt === null && rec.decidedFalse !== true) return true;
203
+ }
204
+ return false;
205
+ }
206
+
207
+ function roundsElapsedOf(state, rec) {
208
+ if (rec.anchorTurn === null) return null;
209
+ const current = state.sessionTurns.get(rec.parentSessionId);
210
+ return current === undefined ? 0 : Math.max(0, current - rec.anchorTurn);
211
+ }
212
+
213
+ // 三态判定:复用 evolution-summary.adoptStatus(生产调用方);decidedFalse 为 tracker
214
+ // 附加的强制未采纳(父会话结束/用户拒绝),优先级在窗口判定之上。
215
+ function triState(state, rec, ts) {
216
+ if (rec.adoptedAt !== null && rec.adoptedAt !== undefined) return 'true';
217
+ if (rec.decidedFalse === true) return 'false';
218
+ return adoptStatus({
219
+ windowN: state.windowN,
220
+ windowMs: state.windowMs,
221
+ dispatchedAt: rec.dispatchedAt,
222
+ adoptedAt: rec.adoptedAt,
223
+ now: ts,
224
+ roundsElapsed: roundsElapsedOf(state, rec),
225
+ });
226
+ }
227
+
228
+ function markDecidedFalse(state, rec) {
229
+ if (rec.adoptedAt !== null || rec.decidedFalse === true) return false;
230
+ rec.decidedFalse = true;
231
+ state.dirty = true;
232
+ // 逐条通知「明确未采纳」判定,钩子失败不影响判定。
233
+ if (typeof state.onDecided === 'function') {
234
+ try { state.onDecided(rec); } catch { /* fail-soft */ }
235
+ }
236
+ return true;
237
+ }
238
+
239
+ // 判定变化后的统一出口:落盘 + 通知调用方重算聚合(fail-soft,绝不抛出)。
240
+ function fireChange(state) {
241
+ if (state.dirty) { persistState(state.stateFile, state.records, state.warn); state.dirty = false; }
242
+ try { state.onDecision(); } catch { /* 调用方钩子失败不影响判定 */ }
243
+ }
244
+
245
+ function checkWindows(state, ts) {
246
+ let changed = false;
247
+ for (const rec of state.records.values()) {
248
+ if (triState(state, rec, ts) === 'false' && markDecidedFalse(state, rec)) changed = true;
249
+ }
250
+ return changed;
251
+ }
252
+
253
+ function ensureTimer(state) {
254
+ if (state.timer !== null || state.windowMs === null) return;
255
+ state.timer = setTimeout(() => {
256
+ state.timer = null;
257
+ const changed = checkWindows(state, state.now());
258
+ if (changed) fireChange(state);
259
+ else if (hasPending(state)) ensureTimer(state);
260
+ }, state.windowMs + 1000);
261
+ // 判定窗口定时器绝不阻塞进程退出(宿主退出/测试结束时丢弃未到期 timer;重启后
262
+ // pending 记录按 dispatchedAt 时间窗继续判定,保持 live-only 语义)。
263
+ if (state.timer !== null && typeof state.timer.unref === 'function') state.timer.unref();
264
+ }
265
+
266
+ // 锚点登记:父 tool/result 携带 decisionTrace.execution 的 dispatch 回灌(唯一可靠的
267
+ // 持久化 child 标识锚点)。返回登记的记录或 null。
268
+ function anchorRecord(state, sessionId, event, data, seq) {
269
+ if (event.type !== 'tool/result') return null;
270
+ const execution = data.meta?.decisionTrace?.execution;
271
+ if (execution === null || typeof execution !== 'object') return null;
272
+ const execParent = typeof execution.parentSessionId === 'string' && execution.parentSessionId !== ''
273
+ ? execution.parentSessionId
274
+ : sessionId;
275
+ if (execParent !== sessionId) return null;
276
+ const id = execution.kind === 'background'
277
+ ? (typeof execution.jobId === 'string' ? execution.jobId : '')
278
+ : (typeof execution.childSessionId === 'string' ? execution.childSessionId : '');
279
+ if (id === '') return null;
280
+ const rec = state.records.get(recordKey(sessionId, id));
281
+ if (rec === undefined || rec.anchorSeq !== null) return null;
282
+ rec.anchorSeq = seq;
283
+ rec.anchorTurn = state.sessionTurns.get(sessionId) ?? 0;
284
+ state.dirty = true;
285
+ return rec;
286
+ }
287
+
288
+ // 引用判定:只扫描锚点之后的父事件(锚点自身必跳过);命中 id 或复用输出片段即采纳。
289
+ function detectReferences(state, sessionId, event, seq, ts) {
290
+ let adopted = false;
291
+ for (const rec of pendingFor(state, sessionId)) {
292
+ if (rec.anchorSeq === null) continue;
293
+ if (seq !== null && seq <= rec.anchorSeq) continue;
294
+ const idHit = referencesId(event, rec.id);
295
+ const reuseHit = rec.snippet.length >= MIN_REUSE_CHARS && eventText(event).includes(rec.snippet);
296
+ if (idHit || reuseHit) {
297
+ rec.adoptedAt = ts;
298
+ state.dirty = true;
299
+ adopted = true;
300
+ }
301
+ }
302
+ return adopted;
303
+ }
304
+
305
+ // session/event firehose 入口(index.mjs 用根 ctx 全局订阅,按 session.id 过滤后调用)。
306
+ function handleEvent(state, session, event) {
307
+ if (session === null || typeof session !== 'object' || event === null || typeof event !== 'object') return;
308
+ const sessionId = typeof session.id === 'string' ? session.id : (typeof session.header?.id === 'string' ? session.header.id : '');
309
+ if (sessionId === '') return;
310
+ const seq = Number.isFinite(event.seq) ? event.seq : null;
311
+ const data = event.data !== null && typeof event.data === 'object' ? event.data : {};
312
+ // 回合推进:turn/end(finally 必发)以 data.turn 为准维护会话回合号。
313
+ if (event.type === 'turn/end') {
314
+ const turn = Number.isFinite(data.turn) ? data.turn : (state.sessionTurns.get(sessionId) ?? 0) + 1;
315
+ state.sessionTurns.set(sessionId, turn);
316
+ }
317
+ anchorRecord(state, sessionId, event, data, seq);
318
+ const adopted = detectReferences(state, sessionId, event, seq, state.now());
319
+ if (adopted || checkWindows(state, state.now())) fireChange(state);
320
+ }
321
+
322
+ function register(state, { parentSessionId, id, ledgerKey, mode, dispatchedAt, promptExcerpt, requestedModel, parentModel }) {
323
+ if (emptyKey(parentSessionId) || emptyKey(id) || emptyKey(ledgerKey)) return false;
324
+ const key = recordKey(parentSessionId, id);
325
+ if (state.records.has(key)) return false;
326
+ state.records.set(key, {
327
+ parentSessionId,
328
+ id,
329
+ ledgerKey,
330
+ mode: typeof mode === 'string' ? mode : 'one-shot',
331
+ dispatchedAt: Number.isFinite(dispatchedAt) ? dispatchedAt : state.now(),
332
+ anchorSeq: null,
333
+ anchorTurn: null,
334
+ adoptedAt: null,
335
+ decidedFalse: false,
336
+ snippet: '',
337
+ promptExcerpt: typeof promptExcerpt === 'string' ? promptExcerpt : '',
338
+ requestedModel: typeof requestedModel === 'string' && requestedModel !== '' ? requestedModel : null,
339
+ parentModel: typeof parentModel === 'string' && parentModel !== '' ? parentModel : null,
340
+ childSessionId: null,
341
+ settled: null,
342
+ });
343
+ state.dirty = true;
344
+ persistState(state.stateFile, state.records, state.warn);
345
+ state.dirty = false;
346
+ ensureTimer(state);
347
+ return true;
348
+ }
349
+
350
+ // 子结算时注入输出片段(仅内存;内容复用判定第二层信号,绝不落盘)。
351
+ function updateOutput(state, parentSessionId, id, output) {
352
+ const rec = state.records.get(recordKey(parentSessionId, id));
353
+ if (rec === undefined) return;
354
+ if (typeof output !== 'string' || output === '') return;
355
+ rec.snippet = output.slice(0, SNIPPET_CHARS);
356
+ }
357
+
358
+ // 子结算时注入结算摘要(落盘结构字段,供未采纳提醒结果行)。后台结算本身带
359
+ // childSessionId;前台/continuable 用登记 id 即子会话 id——缺省时按 mode 兜底。
360
+ function updateSettled(state, parentSessionId, id, settled) {
361
+ const rec = state.records.get(recordKey(parentSessionId, id));
362
+ if (rec === undefined) return;
363
+ const summary = settleSummaryOf(settled);
364
+ rec.settled = summary;
365
+ rec.childSessionId = summary !== null && typeof summary.childSessionId === 'string' && summary.childSessionId !== ''
366
+ ? summary.childSessionId
367
+ : (rec.mode === 'background' ? null : rec.id);
368
+ state.dirty = true;
369
+ persistState(state.stateFile, state.records, state.warn);
370
+ state.dirty = false;
371
+ }
372
+
373
+ // 父会话结束(agent/disposed 挂钩):未决记录一律判明确未采纳并触发重算。
374
+ function parentDisposed(state, parentSessionId) {
375
+ if (emptyKey(parentSessionId)) return;
376
+ let changed = false;
377
+ for (const rec of pendingFor(state, parentSessionId)) {
378
+ if (markDecidedFalse(state, rec)) changed = true;
379
+ }
380
+ if (changed) fireChange(state);
381
+ }
382
+
383
+ function confirmedFalseCounts(state, ts) {
384
+ const counts = new Map();
385
+ for (const rec of state.records.values()) {
386
+ if (triState(state, rec, ts) !== 'false') continue;
387
+ counts.set(rec.ledgerKey, (counts.get(rec.ledgerKey) ?? 0) + 1);
388
+ }
389
+ return Object.fromEntries(counts);
390
+ }
391
+
392
+ function pendingCountOf(state) {
393
+ let count = 0;
394
+ for (const rec of state.records.values()) {
395
+ if (rec.adoptedAt === null && rec.decidedFalse !== true) count += 1;
396
+ }
397
+ return count;
398
+ }
399
+
400
+ export function createAdoptionTracker(options = {}) {
401
+ const state = createState({
402
+ stateFile: options.stateFile,
403
+ windowN: options.windowN ?? DEFAULT_WINDOW_N,
404
+ windowMs: options.windowMs ?? DEFAULT_WINDOW_MS,
405
+ warn: options.warn ?? (() => {}),
406
+ onDecision: options.onDecision ?? (() => {}),
407
+ onDecided: options.onDecided ?? (() => {}),
408
+ now: options.now ?? Date.now,
409
+ });
410
+ return {
411
+ register: (entry) => register(state, entry),
412
+ updateOutput: (parentSessionId, id, output) => updateOutput(state, parentSessionId, id, output),
413
+ updateSettled: (parentSessionId, id, settled) => updateSettled(state, parentSessionId, id, settled),
414
+ handleEvent: (session, event) => handleEvent(state, session, event),
415
+ parentDisposed: (parentSessionId) => parentDisposed(state, parentSessionId),
416
+ checkWindows: (ts) => checkWindows(state, ts),
417
+ triState: (rec, ts) => triState(state, rec, ts),
418
+ // 按记录查询三态(测试与排障用):未知记录返回 null。
419
+ statusOf(parentSessionId, id, ts) {
420
+ const rec = state.records.get(recordKey(parentSessionId, id));
421
+ if (rec === undefined) return null;
422
+ return triState(state, rec, ts);
423
+ },
424
+ confirmedFalseCounts: (ts = state.now()) => confirmedFalseCounts(state, ts),
425
+ pendingCount: () => pendingCountOf(state),
426
+ dispose() {
427
+ if (state.timer !== null) { clearTimeout(state.timer); state.timer = null; }
428
+ },
429
+ };
430
+ }
@@ -0,0 +1,71 @@
1
+ // lib/core/background-ledger.mjs — 后台派发结算内存台账(兜底)。
2
+ // 后台 execute 返回时任务未结算,decisionTrace 无 settled;结算后无法回写已返回的
3
+ // 会话块,故按 sessionId 记录 jobId → settled 摘要,供 /ledger/jobs?session= 路由
4
+ // 让 client 按 jobId join;另带 callId(运行中的 tool-call 块没有
5
+ // jobId,按 callId 精确配对 live 状态)。仅存可观测结算字段,不存子输出正文。进程重启即失。
6
+
7
+ function sanitizeSettled(settled) {
8
+ if (settled === null || typeof settled !== 'object') return { status: 'failed', stopReason: 'error' }
9
+ return {
10
+ status: typeof settled.status === 'string' ? settled.status : 'failed',
11
+ stopReason: typeof settled.stopReason === 'string' ? settled.stopReason : 'error',
12
+ elapsedMs: Number.isFinite(settled.elapsedMs) ? settled.elapsedMs : null,
13
+ ...(Number.isFinite(settled.childTotalTokens) ? { childTotalTokens: settled.childTotalTokens } : {}),
14
+ ...(settled.childUsage !== null && typeof settled.childUsage === 'object' ? { childUsage: settled.childUsage } : {}),
15
+ ...(settled.calls !== null && typeof settled.calls === 'object' ? { calls: settled.calls } : {}),
16
+ ...(typeof settled.childSessionId === 'string' && settled.childSessionId !== '' ? { childSessionId: settled.childSessionId } : {}),
17
+ outputLen: typeof settled.output === 'string' ? settled.output.length : 0,
18
+ }
19
+ }
20
+
21
+ // 条数上限淘汰(与结算记录共用):每会话/总量超限丢最旧,绝不抛。
22
+ function trimSessions(sessions, maxPerSession, maxTotal) {
23
+ let total = 0; for (const l of sessions.values()) total += l.length;
24
+ while (total > maxTotal) {
25
+ const firstKey = sessions.keys().next();
26
+ if (firstKey.done) break;
27
+ const first = sessions.get(firstKey.value);
28
+ first.shift();
29
+ if (first.length === 0) sessions.delete(firstKey.value);
30
+ total -= 1;
31
+ }
32
+ }
33
+
34
+ function createBackgroundLedger({ maxPerSession = 50, maxTotal = 500 } = {}) {
35
+ const sessions = new Map();
36
+ const record = (sessionId, jobId, settled, callId) => {
37
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof jobId !== 'string' || jobId === '') return;
38
+ let list = sessions.get(sessionId);
39
+ if (list === undefined) { list = []; sessions.set(sessionId, list); }
40
+ const existing = list.find((item) => item.jobId === jobId);
41
+ if (existing !== undefined) {
42
+ if (typeof callId === 'string' && callId !== '') existing.callId = callId;
43
+ existing.settled = sanitizeSettled(settled);
44
+ return;
45
+ }
46
+ list.push({ jobId, ...(typeof callId === 'string' && callId !== '' ? { callId } : {}), settled: sanitizeSettled(settled) });
47
+ if (list.length > maxPerSession) list.shift();
48
+ trimSessions(sessions, maxPerSession, maxTotal);
49
+ };
50
+ // 运行中的后台任务补记 childSessionId(jobId → 子会话 id):结算前就可供
51
+ // /ledger/jobs 与子会话页头识别「这是后台派发的子会话」。重复记录只补字段。
52
+ const recordChild = (sessionId, jobId, childSessionId) => {
53
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof jobId !== 'string' || jobId === '' || typeof childSessionId !== 'string' || childSessionId === '') return;
54
+ let list = sessions.get(sessionId);
55
+ if (list === undefined) { list = []; sessions.set(sessionId, list); }
56
+ const existing = list.find((item) => item.jobId === jobId);
57
+ if (existing !== undefined) { existing.childSessionId = childSessionId; return; }
58
+ list.push({ jobId, childSessionId });
59
+ if (list.length > maxPerSession) list.shift();
60
+ trimSessions(sessions, maxPerSession, maxTotal);
61
+ };
62
+ const get = (sessionId) => {
63
+ if (typeof sessionId !== 'string' || sessionId === '') return [];
64
+ const list = sessions.get(sessionId);
65
+ return list === undefined ? [] : [...list];
66
+ };
67
+ const clear = () => sessions.clear();
68
+ return { record, recordChild, get, clear };
69
+ }
70
+
71
+ export { createBackgroundLedger };