dsh-subagent-profile 0.3.3 → 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 +49 -20
- package/README.zh.md +102 -73
- package/docs/screenshots/dispatch-card.png +0 -0
- package/docs/screenshots/settings-page1.png +0 -0
- package/docs/screenshots/settings-page2.png +0 -0
- package/index.mjs +153 -75
- package/lib/client.js +3081 -444
- package/lib/core/adoption-reminder.mjs +48 -0
- package/lib/core/adoption-tracker.mjs +430 -0
- package/lib/core/background-ledger.mjs +71 -0
- package/lib/core/cost-evidence.mjs +145 -0
- package/lib/core/cost-guard.mjs +1 -1
- package/lib/core/decision-trace.mjs +11 -31
- package/lib/core/delegation.mjs +11 -7
- package/lib/core/dispatch-gates.mjs +12 -5
- package/lib/core/dispatch-guard.mjs +6 -0
- package/lib/core/dispatch-schema.mjs +10 -5
- package/lib/core/dispatch-tool.mjs +52 -45
- package/lib/core/draft-gates.mjs +45 -0
- package/lib/core/drafts-store.mjs +45 -0
- package/lib/core/evolution-advice.mjs +323 -0
- 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 +149 -33
- 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/evolution-summary.mjs +46 -223
- package/lib/core/http-helpers.mjs +35 -0
- package/lib/core/http-routes.mjs +169 -115
- package/lib/core/presets-sync.mjs +254 -256
- package/lib/core/prices.mjs +46 -0
- package/lib/core/profile-directory.mjs +139 -0
- package/lib/core/profile-provider.mjs +5 -3
- package/lib/core/profiles-store.mjs +24 -6
- package/lib/core/reminder-store.mjs +172 -0
- package/lib/core/session-read.mjs +17 -0
- package/lib/core/shims.mjs +13 -2
- package/package.json +82 -82
- package/presets/orchestrator/agent.cordis.yml +243 -243
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// lib/core/evolution-advice.mjs — evolution:advice 派发优化建议链(从 evolution-summary.mjs
|
|
2
|
+
// 拆出以守文件行门):suggestAdvice(纯函数四条件 + 升/降轴硬规则)、readSummaries /
|
|
3
|
+
// refreshSummaries / computeAdvice / buildAdviceText(生产聚合触发与注入文本)。
|
|
4
|
+
// 聚合本体(computeSummaries/writeSummaries)仍驻 evolution-summary.mjs;本模块只读
|
|
5
|
+
// 台账并调度聚合,绝无写回台账路径。fail-soft 纪律与 evolution-summary 同源。
|
|
6
|
+
//
|
|
7
|
+
// 依赖方向:本模块 → evolution-summary.mjs(单向,无环)。
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
+
import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel, profileKeyOf } from './evolution-summary.mjs';
|
|
11
|
+
import { loadWithMigration } from './evolution-persistence.mjs';
|
|
12
|
+
|
|
13
|
+
// --- 派发优化建议(suggestAdvice)-----------------------------------------------
|
|
14
|
+
// 从单条 L1 聚合(l1Entry)产出「一项确定性建议」或 null。四条件齐备才产出:
|
|
15
|
+
// N>=minN、weighted_success 低于阈值(表现不佳才建议)、置信度达标、cooldown
|
|
16
|
+
// 未过期。方向按升/降轴硬规则:能力轴(preset/model/provider/persona/
|
|
17
|
+
// toolFilter)只建议降;预算轴(effort)可建议升;无法定位安全轴的键保守维持
|
|
18
|
+
// (平)。阈值 successThreshold / confidenceMinLevel / cooldownMs 为建议初值,
|
|
19
|
+
// 待实测后校准。
|
|
20
|
+
const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
21
|
+
// 置信度级别中文映射(注入文本人话化,与客户端 ZH.confidenceZh 同口径)。
|
|
22
|
+
const CONFIDENCE_ZH = { low: '低', medium: '中等', high: '高' };
|
|
23
|
+
|
|
24
|
+
function hasCapabilityAxis(profileKey) {
|
|
25
|
+
return /(^|\|)(preset|provider|model|persona|toolFilter):/.test(String(profileKey ?? ''));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasBudgetAxis(profileKey) {
|
|
29
|
+
return /(^|\|)effort:/.test(String(profileKey ?? ''));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 从 profileKey 提取 preset 候选值(无 preset 段返回 null,如 inline / 纯预算键)。
|
|
33
|
+
export function presetFromKey(profileKey) {
|
|
34
|
+
if (typeof profileKey !== 'string' || profileKey === '' || profileKey === '(inline)') return null;
|
|
35
|
+
for (const part of profileKey.split('|')) {
|
|
36
|
+
if (part.startsWith('preset:')) return part.slice('preset:'.length);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 建议候选校验:preset 候选非 system-trust 白名单即 fail-loud 抛错(与派发守卫
|
|
42
|
+
// whitelist 闸一致,不静默滤掉)。whitelist 接受 Set 或数组。
|
|
43
|
+
export function assertSystemCandidate(whitelist, preset) {
|
|
44
|
+
if (preset === null || preset === undefined || preset === '' || preset === 'inherit') return;
|
|
45
|
+
const inList = whitelist instanceof Set ? whitelist.has(preset) : Array.isArray(whitelist) && whitelist.includes(preset);
|
|
46
|
+
if (!inList) {
|
|
47
|
+
throw new Error(`evolution:advice: 候选预设 "${preset}" 不在 system-trust 白名单(拒绝非 system 候选)`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function performanceText(entry) {
|
|
52
|
+
const outcome = entry?.outcome ?? {};
|
|
53
|
+
const perf = entry?.perf ?? {};
|
|
54
|
+
const parts = [
|
|
55
|
+
`过去 ${entry?.deployments_total ?? 0} 次:完成 ${outcome.completed ?? 0}、失败 ${outcome.failed ?? 0}、终止 ${outcome.killed ?? 0}`,
|
|
56
|
+
`加权成功分 ${entry?.score?.weighted_success ?? 0}`,
|
|
57
|
+
];
|
|
58
|
+
if (perf.avg_elapsed_ms !== null && perf.avg_elapsed_ms !== undefined) parts.push(`平均耗时 ${perf.avg_elapsed_ms}ms`);
|
|
59
|
+
if (perf.avg_output_len !== null && perf.avg_output_len !== undefined) parts.push(`平均输出 ${perf.avg_output_len} 字符`);
|
|
60
|
+
return parts.join(',');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function directionFor(profileKey, capability, budget) {
|
|
64
|
+
if (budget && !capability) return '升';
|
|
65
|
+
if (capability) return '降';
|
|
66
|
+
return '平';
|
|
67
|
+
}
|
|
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
|
+
|
|
130
|
+
export function suggestAdvice({
|
|
131
|
+
l1Entry,
|
|
132
|
+
profileKey = '',
|
|
133
|
+
l2 = null,
|
|
134
|
+
minN = 3,
|
|
135
|
+
cooldownMs = 600000,
|
|
136
|
+
now = Date.now(),
|
|
137
|
+
successThreshold = 0.6,
|
|
138
|
+
confidenceMinLevel = 'medium',
|
|
139
|
+
} = {}) {
|
|
140
|
+
const entry = l1Entry !== null && typeof l1Entry === 'object' ? l1Entry : {};
|
|
141
|
+
const key = typeof profileKey === 'string' ? profileKey : (typeof entry.profileKey === 'string' ? entry.profileKey : '');
|
|
142
|
+
const n = Number.isFinite(entry.confidence?.n) ? entry.confidence.n : 0;
|
|
143
|
+
const level = typeof entry.confidence?.level === 'string' ? entry.confidence.level : confidenceLevel(n, minN);
|
|
144
|
+
const score = Number.isFinite(entry.score?.weighted_success) ? entry.score.weighted_success : 0;
|
|
145
|
+
const until = entry.cooldown?.until_ts;
|
|
146
|
+
|
|
147
|
+
// 四条件齐备才产出(任一不满足返回 null)。
|
|
148
|
+
if (n < minN) return null;
|
|
149
|
+
if ((CONFIDENCE_RANK[level] ?? -1) < (CONFIDENCE_RANK[confidenceMinLevel] ?? 0)) return null;
|
|
150
|
+
if (until !== null && until !== undefined && now < until) return null;
|
|
151
|
+
if (!(score < successThreshold)) return null;
|
|
152
|
+
|
|
153
|
+
// 方向判定:优先读组内轴存在性(L1 键去 effort 后由聚合写入);legacy summaries
|
|
154
|
+
// 无 axes 字段时回退键文本正则判定(保持旧 summaries.json 可读)。
|
|
155
|
+
const axes = entry.axes !== null && typeof entry.axes === 'object' ? entry.axes : null;
|
|
156
|
+
const capability = axes !== null && typeof axes.capability === 'boolean' ? axes.capability : hasCapabilityAxis(key);
|
|
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);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
profileKey: key,
|
|
163
|
+
performanceText: performanceText(entry),
|
|
164
|
+
suggestion,
|
|
165
|
+
// 建议对象与理由:确定性人话,同输入恒同输出;面板渲染「建议:…」与「理由:…」。
|
|
166
|
+
objectText: suggestion === '降' ? downObjectText(segments) : suggestion === '升' ? budgetObjectText(key, l2) : '',
|
|
167
|
+
reasonText: suggestion === '降' ? downReasonText(segments, entry) : suggestion === '升' ? upReasonText(key, l2) : '',
|
|
168
|
+
confidence: level,
|
|
169
|
+
cooldownUntil: now + cooldownMs,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- 派发优化建议(evolution:advice 注入段,默认关)----------------------------
|
|
174
|
+
|
|
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;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 生产聚合触发点(T1 修复):读 dispatch.jsonl → parseDispatchRecords → computeSummaries
|
|
186
|
+
// → writeSummaries。opts 可注入 parentAdoptedConfirmedFalse join(adoption-tracker 的
|
|
187
|
+
// 「已确认未采纳」计数,惰性 join 进 weighted_success)。缺失台账返回 skipped:'no-ledger'。
|
|
188
|
+
export function refreshSummaries({ dispatchFile, summariesFile, logger, opts = {} } = {}) {
|
|
189
|
+
if (!existsSync(dispatchFile)) return { persisted: false, skipped: 'no-ledger' };
|
|
190
|
+
let lines;
|
|
191
|
+
try {
|
|
192
|
+
lines = readFileSync(dispatchFile, 'utf8').split('\n');
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
if (logger !== undefined && typeof logger.warn === 'function') {
|
|
196
|
+
logger.warn(`[dsh-subagent-profile] evolution:advice 读派发台账失败:${message}`);
|
|
197
|
+
}
|
|
198
|
+
return { persisted: false, error: message };
|
|
199
|
+
}
|
|
200
|
+
const { records, skipped } = parseDispatchRecords(lines);
|
|
201
|
+
const summaries = computeSummaries(records, opts);
|
|
202
|
+
const res = writeSummaries(summariesFile, summaries);
|
|
203
|
+
return { ...res, records: records.length, skippedLines: skipped };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// summaries.json 是否过期(缺失但台账存在,或台账 mtime 更新)——惰性重算判据。
|
|
207
|
+
function summariesStale(dispatchFile, summariesFile) {
|
|
208
|
+
if (!existsSync(summariesFile)) return existsSync(dispatchFile);
|
|
209
|
+
try {
|
|
210
|
+
return statSync(dispatchFile).mtimeMs > statSync(summariesFile).mtimeMs;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 注入段文本:全局聚合摘要 + 各 profile 一条确定性建议(人类读、非指令)。
|
|
217
|
+
export function renderAdviceText(advice, global) {
|
|
218
|
+
const direction = (value) => (value === '升' ? '可上调预算' : value === '降' ? '建议降级' : '维持现状');
|
|
219
|
+
const lines = advice.map((a) => {
|
|
220
|
+
const untilText = Number.isFinite(a.cooldownUntil)
|
|
221
|
+
? `(冷却至 ${new Date(a.cooldownUntil).toLocaleTimeString('zh-CN', { hour12: false })})`
|
|
222
|
+
: '';
|
|
223
|
+
return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${CONFIDENCE_ZH[a.confidence] ?? a.confidence})${untilText}`;
|
|
224
|
+
});
|
|
225
|
+
const total = global.completed + global.failed + global.killed;
|
|
226
|
+
return `以下为过去派发的只读统计与确定性建议(仅供人类参考,不改变派发行为):\n` +
|
|
227
|
+
`全局:累计 ${total} 次(完成 ${global.completed}、失败 ${global.failed}、终止 ${global.killed})。\n${lines.join('\n')}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 建议产出后把各 profile 的新 cooldown.until_ts 写回 summaries.json(原子写、
|
|
231
|
+
// fail-soft)。写前重读磁盘再合并:并发注入各自基于最新磁盘态写回,把互相覆盖
|
|
232
|
+
// 窗口压到最小(残余竞态:读-写间隙,彻底解决需进程内锁,成本不值)。
|
|
233
|
+
function writeBackCooldowns(file, data, produced, logger) {
|
|
234
|
+
const fresh = readSummaries(file, logger);
|
|
235
|
+
const base = fresh !== null && typeof fresh === 'object' ? fresh : data;
|
|
236
|
+
const l1 = { ...(base.l1 ?? {}) };
|
|
237
|
+
for (const [profileKey, until] of produced) {
|
|
238
|
+
const group = l1[profileKey];
|
|
239
|
+
l1[profileKey] = { ...group, cooldown: { ...(group?.cooldown ?? {}), until_ts: until } };
|
|
240
|
+
}
|
|
241
|
+
const res = writeSummaries(file, { l1, l2: base.l2 ?? {} });
|
|
242
|
+
if (res.persisted !== true) {
|
|
243
|
+
logger.warn(`[dsh-subagent-profile] evolution:advice cooldown 写回失败:${res.error ?? '未知错误'}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 结构化建议(读+算,不写回 cooldown、不渲染文本):供注入段与只读面板共用。
|
|
248
|
+
export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger, onLoss }) {
|
|
249
|
+
if (dispatchFile !== undefined && summariesStale(dispatchFile, summariesFile)) refreshSummaries({ dispatchFile, summariesFile, logger });
|
|
250
|
+
let data = readSummaries(summariesFile, logger, { onLoss });
|
|
251
|
+
// summaries.json 存在但不可读(损坏/版本不符)→ fail-soft 重建(幂等可复算),
|
|
252
|
+
// 损坏资产不阻断建议产出;台账缺失时维持空建议(refreshSummaries 的 no-ledger 语义)。
|
|
253
|
+
if (data === null && dispatchFile !== undefined && existsSync(summariesFile)) {
|
|
254
|
+
refreshSummaries({ dispatchFile, summariesFile, logger });
|
|
255
|
+
data = readSummaries(summariesFile, logger, { onLoss });
|
|
256
|
+
}
|
|
257
|
+
if (data === null) return { advice: [], global: { completed: 0, failed: 0, killed: 0, total: 0 }, produced: [], summaries: null };
|
|
258
|
+
const l1 = data.l1 ?? {};
|
|
259
|
+
const keys = Object.keys(l1).sort();
|
|
260
|
+
const advice = [];
|
|
261
|
+
const produced = [];
|
|
262
|
+
const global = { completed: 0, failed: 0, killed: 0 };
|
|
263
|
+
for (const profileKey of keys) {
|
|
264
|
+
const group = l1[profileKey];
|
|
265
|
+
if (group === null || typeof group !== 'object') continue;
|
|
266
|
+
global.completed += group.outcome?.completed ?? 0;
|
|
267
|
+
global.failed += group.outcome?.failed ?? 0;
|
|
268
|
+
global.killed += group.outcome?.killed ?? 0;
|
|
269
|
+
const suggestion = suggestAdvice({ l1Entry: group, profileKey, l2: data.l2 });
|
|
270
|
+
if (suggestion === null) continue;
|
|
271
|
+
// 只对实际产出的建议键校验候选(N 不足不产出建议的组不要求 system-trust)。
|
|
272
|
+
assertSystemCandidate(whitelist, presetFromKey(profileKey));
|
|
273
|
+
advice.push(suggestion);
|
|
274
|
+
produced.push([profileKey, suggestion.cooldownUntil]);
|
|
275
|
+
}
|
|
276
|
+
return { advice, global: { ...global, total: global.completed + global.failed + global.killed }, produced, summaries: data };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 派发优化建议段文本生成:computeAdvice → 写回 cooldown(防每请求重复注入)→ 渲染;空建议返回空串。
|
|
280
|
+
export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger, onLoss }) {
|
|
281
|
+
const { advice, global, produced, summaries } = computeAdvice({ summariesFile, dispatchFile, whitelist, logger, onLoss });
|
|
282
|
+
if (advice.length === 0) return '';
|
|
283
|
+
writeBackCooldowns(summariesFile, summaries, produced, logger);
|
|
284
|
+
return renderAdviceText(advice, global);
|
|
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
|
+
}
|