dsh-subagent-profile 0.3.4 → 0.4.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 +1 -0
- package/README.zh.md +1 -0
- package/index.mjs +14 -5
- package/lib/client.js +948 -170
- package/lib/core/delegation.mjs +3 -2
- package/lib/core/evolution-advice.mjs +125 -26
- package/lib/core/evolution-assets.mjs +167 -0
- package/lib/core/evolution-draft.mjs +312 -0
- package/lib/core/evolution-engine.mjs +269 -0
- package/lib/core/evolution-generate.mjs +196 -0
- package/lib/core/evolution-ledger.mjs +131 -26
- package/lib/core/evolution-persistence.mjs +213 -0
- package/lib/core/evolution-renewal.mjs +64 -0
- package/lib/core/evolution-routes.mjs +109 -0
- package/lib/core/http-helpers.mjs +35 -0
- package/lib/core/http-routes.mjs +21 -36
- package/lib/core/profile-provider.mjs +2 -1
- package/lib/core/profiles-store.mjs +23 -5
- package/lib/core/session-read.mjs +17 -0
- package/lib/core/shims.mjs +2 -1
- package/package.json +1 -1
package/lib/core/delegation.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// lib/core/profile-provider.mjs 的前台结果闭包使用);无 @deepseek-ai 依赖。
|
|
5
5
|
|
|
6
6
|
import { stopReasonError, withPartialText, textFrom, trustLabel, trustAudit } from './pure.mjs';
|
|
7
|
+
import { readSessionEvents } from './session-read.mjs';
|
|
7
8
|
|
|
8
9
|
// 子 Agent 会话的真实计费 token 五段分解:累加 session.events 里每条
|
|
9
10
|
// assistant/message 事件携带的 provider usage(inputTokens / outputTokens /
|
|
@@ -20,7 +21,7 @@ import { stopReasonError, withPartialText, textFrom, trustLabel, trustAudit } fr
|
|
|
20
21
|
// 前缀匹配或累加 chunk,此守卫必须同步改并补测试)。
|
|
21
22
|
export function collectChildUsage(session) {
|
|
22
23
|
if (session === undefined || session === null) return undefined;
|
|
23
|
-
const events = session
|
|
24
|
+
const events = readSessionEvents(session);
|
|
24
25
|
if (!Array.isArray(events)) return undefined;
|
|
25
26
|
const totals = { inputTokens: 0, outputTokens: 0 };
|
|
26
27
|
const optionalTotals = { cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 };
|
|
@@ -65,7 +66,7 @@ export function collectChildUsage(session) {
|
|
|
65
66
|
// 若需更严可继续下调 maxCalls。
|
|
66
67
|
export function collectChildCalls(session, maxCalls = 6) {
|
|
67
68
|
if (session === undefined || session === null) return undefined;
|
|
68
|
-
const events = session
|
|
69
|
+
const events = readSessionEvents(session);
|
|
69
70
|
if (!Array.isArray(events)) return undefined;
|
|
70
71
|
const calls = [];
|
|
71
72
|
let totalCalls = 0;
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// 依赖方向:本模块 → evolution-summary.mjs(单向,无环)。
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
-
import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel } from './evolution-summary.mjs';
|
|
10
|
+
import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel, profileKeyOf } from './evolution-summary.mjs';
|
|
11
|
+
import { loadWithMigration } from './evolution-persistence.mjs';
|
|
11
12
|
|
|
12
13
|
// --- 派发优化建议(suggestAdvice)-----------------------------------------------
|
|
13
14
|
// 从单条 L1 聚合(l1Entry)产出「一项确定性建议」或 null。四条件齐备才产出:
|
|
@@ -17,6 +18,8 @@ import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel
|
|
|
17
18
|
// (平)。阈值 successThreshold / confidenceMinLevel / cooldownMs 为建议初值,
|
|
18
19
|
// 待实测后校准。
|
|
19
20
|
const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
21
|
+
// 置信度级别中文映射(注入文本人话化,与客户端 ZH.confidenceZh 同口径)。
|
|
22
|
+
const CONFIDENCE_ZH = { low: '低', medium: '中等', high: '高' };
|
|
20
23
|
|
|
21
24
|
function hasCapabilityAxis(profileKey) {
|
|
22
25
|
return /(^|\|)(preset|provider|model|persona|toolFilter):/.test(String(profileKey ?? ''));
|
|
@@ -63,9 +66,71 @@ function directionFor(profileKey, capability, budget) {
|
|
|
63
66
|
return '平';
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
// --- 建议对象与理由(确定性人话)-----------------------------------------------
|
|
70
|
+
// objectText:建议直接带对象(模型改用更低档 / 去掉自定义人格……),按最保守改变
|
|
71
|
+
// 取一段(自定义人格/工具过滤先、模型次之、预设最后——与候选生成降档的保守序一致);
|
|
72
|
+
// 升预算方向读 L2 预算轴存在性给「推理强度上调一档 / maxTokens 上调」。
|
|
73
|
+
// reasonText:理由一句话,从身份键段 + 聚合计数推导(同输入恒同输出,不含任何
|
|
74
|
+
// 配置原文)。
|
|
75
|
+
|
|
76
|
+
function adviceKeySegments(profileKey) {
|
|
77
|
+
const parsed = { preset: false, model: false, persona: false, toolFilter: false };
|
|
78
|
+
for (const part of String(profileKey ?? '').split('|')) {
|
|
79
|
+
if (part.startsWith('preset:')) parsed.preset = true;
|
|
80
|
+
else if (part.startsWith('model:')) parsed.model = true;
|
|
81
|
+
else if (part === 'persona:1') parsed.persona = true;
|
|
82
|
+
else if (part === 'toolFilter:1') parsed.toolFilter = true;
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function downObjectText(segments) {
|
|
88
|
+
if (segments.persona) return '去掉自定义人格';
|
|
89
|
+
if (segments.toolFilter) return '移除工具过滤';
|
|
90
|
+
if (segments.model) return '模型改用更低档';
|
|
91
|
+
if (segments.preset) return '预设改用更低档';
|
|
92
|
+
return '能力配置降级';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function budgetObjectText(profileKey, l2) {
|
|
96
|
+
const source = l2 !== null && typeof l2 === 'object' ? l2 : {};
|
|
97
|
+
let hasEffort = false;
|
|
98
|
+
let hasTokens = false;
|
|
99
|
+
for (const axisKey of Object.keys(source)) {
|
|
100
|
+
if (typeof axisKey !== 'string') continue;
|
|
101
|
+
if (axisKey.startsWith(`${profileKey}:reasoningEffort:`)) hasEffort = true;
|
|
102
|
+
else if (axisKey.startsWith(`${profileKey}:maxTokens:`)) hasTokens = true;
|
|
103
|
+
}
|
|
104
|
+
if (hasEffort) return '推理强度上调一档';
|
|
105
|
+
if (hasTokens) return 'maxTokens 上调';
|
|
106
|
+
return '预算上调';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function downReasonText(segments, entry) {
|
|
110
|
+
const n = Number.isFinite(entry?.confidence?.n) ? entry.confidence.n : (entry?.deployments_total ?? 0);
|
|
111
|
+
const killed = Number.isFinite(entry?.outcome?.killed) ? entry.outcome.killed : 0;
|
|
112
|
+
if (segments.persona) {
|
|
113
|
+
return killed > 0
|
|
114
|
+
? `带自定义人格的派发 ${n} 次里 ${killed} 次被终止,去掉后可复用父会话统一提示`
|
|
115
|
+
: '去掉自定义人格后,子 Agent 复用父会话统一提示,配置更简单';
|
|
116
|
+
}
|
|
117
|
+
if (segments.toolFilter) return '移除工具过滤后,子 Agent 的工具面回到父会话范围,配置更简单';
|
|
118
|
+
if (segments.model) return '同类任务改用更低档模型,成本通常更低';
|
|
119
|
+
if (segments.preset) return '相邻低档预设能力相近,成本通常更低';
|
|
120
|
+
return `近期表现低于预期(加权成功分 ${entry?.score?.weighted_success ?? 0}),降低能力配置是更稳妥的方向`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function upReasonText(profileKey, l2) {
|
|
124
|
+
const text = budgetObjectText(profileKey, l2);
|
|
125
|
+
if (text === '推理强度上调一档') return '推理强度上调一档可提升复杂任务完成质量';
|
|
126
|
+
if (text === 'maxTokens 上调') return 'token 上限上调后可减少中途截断';
|
|
127
|
+
return '预算上调后可提升复杂任务完成质量';
|
|
128
|
+
}
|
|
129
|
+
|
|
66
130
|
export function suggestAdvice({
|
|
67
131
|
l1Entry,
|
|
68
132
|
profileKey = '',
|
|
133
|
+
l2 = null,
|
|
69
134
|
minN = 3,
|
|
70
135
|
cooldownMs = 600000,
|
|
71
136
|
now = Date.now(),
|
|
@@ -90,11 +155,16 @@ export function suggestAdvice({
|
|
|
90
155
|
const axes = entry.axes !== null && typeof entry.axes === 'object' ? entry.axes : null;
|
|
91
156
|
const capability = axes !== null && typeof axes.capability === 'boolean' ? axes.capability : hasCapabilityAxis(key);
|
|
92
157
|
const budget = axes !== null && typeof axes.budget === 'boolean' ? axes.budget : hasBudgetAxis(key);
|
|
158
|
+
const suggestion = directionFor(key, capability, budget);
|
|
159
|
+
const segments = adviceKeySegments(key);
|
|
93
160
|
|
|
94
161
|
return {
|
|
95
162
|
profileKey: key,
|
|
96
163
|
performanceText: performanceText(entry),
|
|
97
|
-
suggestion
|
|
164
|
+
suggestion,
|
|
165
|
+
// 建议对象与理由:确定性人话,同输入恒同输出;面板渲染「建议:…」与「理由:…」。
|
|
166
|
+
objectText: suggestion === '降' ? downObjectText(segments) : suggestion === '升' ? budgetObjectText(key, l2) : '',
|
|
167
|
+
reasonText: suggestion === '降' ? downReasonText(segments, entry) : suggestion === '升' ? upReasonText(key, l2) : '',
|
|
98
168
|
confidence: level,
|
|
99
169
|
cooldownUntil: now + cooldownMs,
|
|
100
170
|
};
|
|
@@ -102,23 +172,14 @@ export function suggestAdvice({
|
|
|
102
172
|
|
|
103
173
|
// --- 派发优化建议(evolution:advice 注入段,默认关)----------------------------
|
|
104
174
|
|
|
105
|
-
// 读 summaries.json
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (logger !== undefined && typeof logger.warn === 'function') {
|
|
114
|
-
logger.warn(`[dsh-subagent-profile] summaries.json 版本 ${String(parsed.v)} 未知,跳过该资产(fail-soft)`);
|
|
115
|
-
}
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
return parsed;
|
|
119
|
-
} catch {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
175
|
+
// 读 summaries.json(存在才读)。版本化经通用持久化机制:v 低于当前版本时内存
|
|
176
|
+
// 前向迁移后原子写回;损坏/形状不符/未知或超前版本 fail-soft 返回 null,并告警
|
|
177
|
+
// + onLoss(迁移丢失计数)。l1 形状校验保留(读端数据契约)。
|
|
178
|
+
export function readSummaries(file, logger, opts = {}) {
|
|
179
|
+
const data = loadWithMigration(file, 'summaries', { logger, onLoss: opts.onLoss });
|
|
180
|
+
if (data === null) return null;
|
|
181
|
+
if (data.l1 === null || typeof data.l1 !== 'object') return null;
|
|
182
|
+
return data;
|
|
122
183
|
}
|
|
123
184
|
|
|
124
185
|
// 生产聚合触发点(T1 修复):读 dispatch.jsonl → parseDispatchRecords → computeSummaries
|
|
@@ -159,7 +220,7 @@ export function renderAdviceText(advice, global) {
|
|
|
159
220
|
const untilText = Number.isFinite(a.cooldownUntil)
|
|
160
221
|
? `(冷却至 ${new Date(a.cooldownUntil).toLocaleTimeString('zh-CN', { hour12: false })})`
|
|
161
222
|
: '';
|
|
162
|
-
return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${a.confidence})${untilText}`;
|
|
223
|
+
return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${CONFIDENCE_ZH[a.confidence] ?? a.confidence})${untilText}`;
|
|
163
224
|
});
|
|
164
225
|
const total = global.completed + global.failed + global.killed;
|
|
165
226
|
return `以下为过去派发的只读统计与确定性建议(仅供人类参考,不改变派发行为):\n` +
|
|
@@ -184,14 +245,14 @@ function writeBackCooldowns(file, data, produced, logger) {
|
|
|
184
245
|
}
|
|
185
246
|
|
|
186
247
|
// 结构化建议(读+算,不写回 cooldown、不渲染文本):供注入段与只读面板共用。
|
|
187
|
-
export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }) {
|
|
248
|
+
export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger, onLoss }) {
|
|
188
249
|
if (dispatchFile !== undefined && summariesStale(dispatchFile, summariesFile)) refreshSummaries({ dispatchFile, summariesFile, logger });
|
|
189
|
-
let data = readSummaries(summariesFile, logger);
|
|
250
|
+
let data = readSummaries(summariesFile, logger, { onLoss });
|
|
190
251
|
// summaries.json 存在但不可读(损坏/版本不符)→ fail-soft 重建(幂等可复算),
|
|
191
252
|
// 损坏资产不阻断建议产出;台账缺失时维持空建议(refreshSummaries 的 no-ledger 语义)。
|
|
192
253
|
if (data === null && dispatchFile !== undefined && existsSync(summariesFile)) {
|
|
193
254
|
refreshSummaries({ dispatchFile, summariesFile, logger });
|
|
194
|
-
data = readSummaries(summariesFile, logger);
|
|
255
|
+
data = readSummaries(summariesFile, logger, { onLoss });
|
|
195
256
|
}
|
|
196
257
|
if (data === null) return { advice: [], global: { completed: 0, failed: 0, killed: 0, total: 0 }, produced: [], summaries: null };
|
|
197
258
|
const l1 = data.l1 ?? {};
|
|
@@ -205,7 +266,7 @@ export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }
|
|
|
205
266
|
global.completed += group.outcome?.completed ?? 0;
|
|
206
267
|
global.failed += group.outcome?.failed ?? 0;
|
|
207
268
|
global.killed += group.outcome?.killed ?? 0;
|
|
208
|
-
const suggestion = suggestAdvice({ l1Entry: group, profileKey });
|
|
269
|
+
const suggestion = suggestAdvice({ l1Entry: group, profileKey, l2: data.l2 });
|
|
209
270
|
if (suggestion === null) continue;
|
|
210
271
|
// 只对实际产出的建议键校验候选(N 不足不产出建议的组不要求 system-trust)。
|
|
211
272
|
assertSystemCandidate(whitelist, presetFromKey(profileKey));
|
|
@@ -216,9 +277,47 @@ export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }
|
|
|
216
277
|
}
|
|
217
278
|
|
|
218
279
|
// 派发优化建议段文本生成:computeAdvice → 写回 cooldown(防每请求重复注入)→ 渲染;空建议返回空串。
|
|
219
|
-
export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger }) {
|
|
220
|
-
const { advice, global, produced, summaries } = computeAdvice({ summariesFile, dispatchFile, whitelist, logger });
|
|
280
|
+
export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger, onLoss }) {
|
|
281
|
+
const { advice, global, produced, summaries } = computeAdvice({ summariesFile, dispatchFile, whitelist, logger, onLoss });
|
|
221
282
|
if (advice.length === 0) return '';
|
|
222
283
|
writeBackCooldowns(summariesFile, summaries, produced, logger);
|
|
223
284
|
return renderAdviceText(advice, global);
|
|
224
285
|
}
|
|
286
|
+
|
|
287
|
+
// --- 近期派发明细(面板折叠表数据源)------------------------------------------
|
|
288
|
+
// 从派发台账读行,用「行生效配置 → profileKeyOf(与聚合层同口径)」匹配各建议键,
|
|
289
|
+
// 取最近 limit 条(按 ts 倒序)。只取渲染所需字段(时间/会话/子 Agent/结果/耗时/模式),
|
|
290
|
+
// 不含任何原文。台账缺失/读失败/无匹配 → 空表(fail-soft,面板显示 0 次)。
|
|
291
|
+
export function recentDispatchDetails(dispatchFile, profileKeys, { limit = 5 } = {}) {
|
|
292
|
+
const out = {};
|
|
293
|
+
const wanted = new Set(Array.isArray(profileKeys) ? profileKeys.filter((key) => typeof key === 'string') : []);
|
|
294
|
+
if (typeof dispatchFile !== 'string' || !existsSync(dispatchFile) || wanted.size === 0) return out;
|
|
295
|
+
let lines;
|
|
296
|
+
try {
|
|
297
|
+
lines = readFileSync(dispatchFile, 'utf8').split('\n');
|
|
298
|
+
} catch {
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
const { records } = parseDispatchRecords(lines);
|
|
302
|
+
const byKey = new Map();
|
|
303
|
+
for (const record of records) {
|
|
304
|
+
if (record === null || typeof record !== 'object') continue;
|
|
305
|
+
const key = profileKeyOf(record.effective);
|
|
306
|
+
if (!wanted.has(key)) continue;
|
|
307
|
+
const rows = byKey.get(key) ?? [];
|
|
308
|
+
rows.push({
|
|
309
|
+
ts: typeof record.ts === 'number' ? record.ts : null,
|
|
310
|
+
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
|
|
311
|
+
childId: typeof record.child_id === 'string' ? record.child_id : null,
|
|
312
|
+
mode: typeof record.mode === 'string' ? record.mode : '',
|
|
313
|
+
outcome: record.outcome !== null && typeof record.outcome === 'object' && typeof record.outcome.status === 'string' ? record.outcome.status : '',
|
|
314
|
+
elapsedMs: record.outcome !== null && typeof record.outcome === 'object' && Number.isFinite(record.outcome.elapsed_ms) ? record.outcome.elapsed_ms : null,
|
|
315
|
+
});
|
|
316
|
+
byKey.set(key, rows);
|
|
317
|
+
}
|
|
318
|
+
for (const [key, rows] of byKey) {
|
|
319
|
+
rows.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0));
|
|
320
|
+
out[key] = rows.slice(0, limit);
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// lib/core/evolution-assets.mjs — 进化候选资产域(subagent-evolution/assets/)。
|
|
2
|
+
// 与 drafts-store 隔离:drafts 是「存为方案」通道(source='human'/'dispatch',无
|
|
3
|
+
// 生命周期);evolution 资产带五态生命周期(draft/proposed/applied/expired/
|
|
4
|
+
// rolled_back)与治理审计,本域只落进化候选。资产文件与索引各自原子写
|
|
5
|
+
// (tmp+rename),损坏/缺失 fail-soft;写失败 warn + 通知治理审计钩子(不抛)。
|
|
6
|
+
// 状态流转(本域职责内):draft →(apply 确认后由装配层驱动)→ proposed;
|
|
7
|
+
// 过期判定与换代由装配层驱动(先比对数据变化再转 expired 并留审计);
|
|
8
|
+
// applied/rolled_back 供金丝雀判定与回滚流转。
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const ASSETS_VERSION = 1;
|
|
14
|
+
const ASSET_STATES = new Set(['draft', 'proposed', 'applied', 'expired', 'rolled_back']);
|
|
15
|
+
const UNRESOLVED_STATES = new Set(['draft', 'proposed']);
|
|
16
|
+
const ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
|
|
17
|
+
|
|
18
|
+
// 原子写(tmp+rename):崩溃不产生截断文件;失败 fail-soft 清理 tmp + warn +
|
|
19
|
+
// 治理审计钩子。返回 {persisted, error?} 由调用方按 fail-soft 处理。
|
|
20
|
+
function atomicWrite(file, payload, logger, onGovernanceFailure) {
|
|
21
|
+
const tmp = `${file}.tmp`;
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
24
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
25
|
+
renameSync(tmp, file);
|
|
26
|
+
return { persisted: true };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
29
|
+
if (logger !== undefined && typeof logger.warn === 'function') {
|
|
30
|
+
logger.warn(`[dsh-subagent-profile] evolution assets: 资产写入失败:${error instanceof Error ? error.message : String(error)}`);
|
|
31
|
+
}
|
|
32
|
+
onGovernanceFailure();
|
|
33
|
+
return { persisted: false, error: error instanceof Error ? error.message : String(error) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 读索引(assets.index.json);缺失/损坏/形状不符回退空索引并 warn。
|
|
38
|
+
function loadIndex(file, logger) {
|
|
39
|
+
if (!existsSync(file)) return { v: ASSETS_VERSION, assets: [] };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
42
|
+
if (parsed !== null && typeof parsed === 'object' && parsed.v === ASSETS_VERSION && Array.isArray(parsed.assets)) {
|
|
43
|
+
return { v: ASSETS_VERSION, assets: parsed.assets.filter((e) => e !== null && typeof e === 'object' && typeof e.id === 'string') };
|
|
44
|
+
}
|
|
45
|
+
} catch { /* fail-soft 从零 */ }
|
|
46
|
+
if (logger !== undefined && typeof logger.warn === 'function') {
|
|
47
|
+
logger.warn('[dsh-subagent-profile] evolution assets: 索引文件形状不符或损坏,按空索引处理');
|
|
48
|
+
}
|
|
49
|
+
return { v: ASSETS_VERSION, assets: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 读单个资产文件;缺失/损坏/状态非法返回 null(fail-soft,索引条目调用方跳过)。
|
|
53
|
+
function loadAssetFile(file) {
|
|
54
|
+
if (!existsSync(file)) return null;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
57
|
+
if (parsed !== null && typeof parsed === 'object' && parsed.v === ASSETS_VERSION
|
|
58
|
+
&& typeof parsed.id === 'string' && ASSET_STATES.has(parsed.state)) {
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
} catch { /* fail-soft */ }
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 资产 id 白名单校验(文件名直接由 id 拼接,防路径穿越)。
|
|
66
|
+
function safeAssetId(id) {
|
|
67
|
+
return typeof id === 'string' && ID_PATTERN.test(id) ? id : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 索引条目 → 完整资产(读文件合并;文件缺失/损坏返回 null 由调用方跳过)。
|
|
71
|
+
function readAsset(state, entry) {
|
|
72
|
+
const asset = loadAssetFile(join(state.dir, `${entry.id}.json`));
|
|
73
|
+
if (asset === null) return null;
|
|
74
|
+
return { ...asset, index: entry };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 过期判定与换代由装配层(evolution-engine)驱动:候选过期后需先比对数据变化
|
|
78
|
+
// 再决定是否转 expired 并留审计,本域不再按时间盲扫 draft。此函数保留为稳定
|
|
79
|
+
// 接口(list/get 的读前钩子),恒返回 0、不改任何资产。
|
|
80
|
+
function sweepExpiredAssets() {
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 状态流转:资产文件先行(合并 patch + 状态 + updatedAt),成功后同步索引。
|
|
85
|
+
function transitionAsset(state, id, nextState, patch) {
|
|
86
|
+
const safe = safeAssetId(id);
|
|
87
|
+
if (safe === null) return { persisted: false, error: '资产 id 非法' };
|
|
88
|
+
const entry = state.index.assets.find((e) => e.id === safe);
|
|
89
|
+
if (entry === undefined) return { persisted: false, error: '资产不存在' };
|
|
90
|
+
if (!ASSET_STATES.has(nextState)) return { persisted: false, error: '资产状态非法' };
|
|
91
|
+
const asset = loadAssetFile(join(state.dir, safe + '.json'));
|
|
92
|
+
if (asset === null) return { persisted: false, error: '资产文件缺失或损坏' };
|
|
93
|
+
const next = { ...asset, ...patch, state: nextState, updatedAt: Date.now() };
|
|
94
|
+
const persisted = state.persistAsset(next);
|
|
95
|
+
if (persisted.persisted) {
|
|
96
|
+
entry.state = nextState;
|
|
97
|
+
entry.updatedAt = next.updatedAt;
|
|
98
|
+
// 索引镜像有效期(换代判定按索引条目读 expiry,patch 后必须同步)。
|
|
99
|
+
entry.expiry = typeof next.expiry === 'number' ? next.expiry : null;
|
|
100
|
+
state.persistIndex();
|
|
101
|
+
}
|
|
102
|
+
return persisted;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 新资产落盘:校验 → 写资产文件 → 追加索引条目 → 写索引(文件先行,索引为清单
|
|
106
|
+
// 事实源;索引写失败仅告警,内存态继续驱动本进程)。
|
|
107
|
+
function addAsset(state, asset) {
|
|
108
|
+
if (asset === null || typeof asset !== 'object') return { persisted: false, error: '资产必须是对象' };
|
|
109
|
+
const safe = safeAssetId(asset.id);
|
|
110
|
+
if (safe === null || asset.state === undefined || !ASSET_STATES.has(asset.state)) {
|
|
111
|
+
return { persisted: false, error: '资产 id 或状态非法' };
|
|
112
|
+
}
|
|
113
|
+
if (state.index.assets.some((e) => e.id === safe)) return { persisted: false, error: '资产 id 已存在' };
|
|
114
|
+
const persisted = state.persistAsset(asset);
|
|
115
|
+
if (!persisted.persisted) return persisted;
|
|
116
|
+
state.index.assets.push({
|
|
117
|
+
id: asset.id,
|
|
118
|
+
source: typeof asset.source === 'string' ? asset.source : 'evolution',
|
|
119
|
+
profileKey: typeof asset.profile_key === 'string' ? asset.profile_key : '',
|
|
120
|
+
state: asset.state,
|
|
121
|
+
axis: asset.axis,
|
|
122
|
+
direction: asset.direction,
|
|
123
|
+
expiry: typeof asset.expiry === 'number' ? asset.expiry : null,
|
|
124
|
+
createdAt: asset.createdAt,
|
|
125
|
+
updatedAt: asset.updatedAt,
|
|
126
|
+
});
|
|
127
|
+
const indexPersisted = state.persistIndex();
|
|
128
|
+
return indexPersisted.persisted ? { persisted: true, id: asset.id } : { persisted: false, error: indexPersisted.error };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 按 id 读资产(清扫过期草稿后);id 非法或不存在返回 undefined。
|
|
132
|
+
function getAsset(state, id) {
|
|
133
|
+
sweepExpiredAssets(state);
|
|
134
|
+
const safe = safeAssetId(id);
|
|
135
|
+
if (safe === null) return undefined;
|
|
136
|
+
const entry = state.index.assets.find((e) => e.id === safe);
|
|
137
|
+
if (entry === undefined) return undefined;
|
|
138
|
+
return readAsset(state, entry) ?? undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createEvolutionAssets({ dshHome, logger, onGovernanceFailure = () => {} } = {}) {
|
|
142
|
+
const dir = join(dshHome, 'subagent-evolution', 'assets');
|
|
143
|
+
const indexFile = join(dir, 'assets.index.json');
|
|
144
|
+
const state = {
|
|
145
|
+
dir,
|
|
146
|
+
index: loadIndex(indexFile, logger),
|
|
147
|
+
persistIndex: () => atomicWrite(indexFile, state.index, logger, onGovernanceFailure),
|
|
148
|
+
persistAsset: (asset) => atomicWrite(join(dir, `${asset.id}.json`), asset, logger, onGovernanceFailure),
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
list() {
|
|
152
|
+
sweepExpiredAssets(state);
|
|
153
|
+
return state.index.assets.map((entry) => readAsset(state, entry)).filter((a) => a !== null);
|
|
154
|
+
},
|
|
155
|
+
get: (id) => getAsset(state, id),
|
|
156
|
+
add: (asset) => addAsset(state, asset),
|
|
157
|
+
transition: (id, nextState, patch) => transitionAsset(state, id, nextState, patch),
|
|
158
|
+
// 同身份键是否仍有未处置(draft/proposed)资产——生成去重与观察期隔离判据。
|
|
159
|
+
hasUnresolved: (profileKey) => state.index.assets.some((e) => e.profileKey === profileKey && UNRESOLVED_STATES.has(e.state)),
|
|
160
|
+
findUnresolved: (profileKey) => state.index.assets
|
|
161
|
+
.filter((e) => e.profileKey === profileKey && UNRESOLVED_STATES.has(e.state))
|
|
162
|
+
.map((e) => ({ ...e })),
|
|
163
|
+
countBySource: (source) => state.index.assets.filter((e) => e.source === source).length,
|
|
164
|
+
ids: () => state.index.assets.map((e) => e.id),
|
|
165
|
+
sweepExpired: (now) => sweepExpiredAssets(state, now),
|
|
166
|
+
};
|
|
167
|
+
}
|