dsh-subagent-profile 0.5.0 → 0.5.1

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,139 @@
1
+ // lib/core/data-maintenance.mjs — 设置页数据维护(四区清理 + 快照管理路由)。
2
+ // 一个入口四区(避免多按钮):① 候选/建议清理(进化资产域全清)② 快照清理
3
+ // (snapshots/ 一键清理)③ 提醒中心已处理归档(只移出活动列表,存储与
4
+ // governance-audit 保留——提醒=审计记录,绝不物理删除)④ 重置统计(dispatch.jsonl
5
+ // 封存为 dispatch-archive-<ts>.jsonl,历史台账归档保留、统计从零开始;summaries.json
6
+ // 置空重建)。每次清理记一条治理审计(kind: data-cleared / reminder-archived /
7
+ // stats-reset,字段 target/count/ts)。快照列表/开关/单条查看路由同驻本模块
8
+ // (快照域装配与清除口径单一来源)。CSRF 与 loopback 守卫由 createHttpRoutes
9
+ // 入口统一完成,本模块只做解析与执行。
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { basename, dirname, join } from 'node:path';
13
+ import { json, readBody, persistOk } from './http-helpers.mjs';
14
+ import { writeSummaries } from './evolution-summary.mjs';
15
+ import { SNAPSHOT_CAP } from './snapshots.mjs';
16
+
17
+ // ④ 重置统计执行:活动台账封存(rename,内容字节不变)+ 空台账重建 + summaries
18
+ // 置空。归档名有意不匹配轮转封存件模式(dispatch-<seq>-<ts>.jsonl):金丝雀基线
19
+ // 不再读入(统计从零开始),后续自动轮转也不会淘汰(决策留痕保留)。
20
+ function resetStats(deps) {
21
+ const dir = dirname(deps.dispatchFile);
22
+ let count = 0;
23
+ let archivedFile = null;
24
+ if (existsSync(deps.dispatchFile)) {
25
+ try {
26
+ count = readFileSync(deps.dispatchFile, 'utf8').split('\n').filter((line) => line.trim() !== '').length;
27
+ } catch { count = 0; }
28
+ if (count > 0) {
29
+ const stamp = Date.now();
30
+ let archive = join(dir, `dispatch-archive-${stamp}.jsonl`);
31
+ let attempt = 0;
32
+ while (existsSync(archive)) {
33
+ attempt += 1;
34
+ archive = join(dir, `dispatch-archive-${stamp}-${attempt}.jsonl`);
35
+ }
36
+ try {
37
+ mkdirSync(dir, { recursive: true });
38
+ renameSync(deps.dispatchFile, archive);
39
+ archivedFile = basename(archive);
40
+ } catch (error) {
41
+ deps.logger.warn(`[dsh-subagent-profile] 台账封存失败(${error instanceof Error ? error.message : String(error)}),统计未重置`);
42
+ return { count: 0, error: '台账封存失败,统计未重置' };
43
+ }
44
+ }
45
+ }
46
+ try {
47
+ writeFileSync(deps.dispatchFile, '', 'utf8');
48
+ writeSummaries(deps.summariesFile, { l1: {}, l2: {} });
49
+ } catch (error) {
50
+ deps.logger.warn(`[dsh-subagent-profile] 统计重建失败(${error instanceof Error ? error.message : String(error)})`);
51
+ return { count, archivedFile, error: '统计重建失败' };
52
+ }
53
+ deps.recordAudit({ kind: 'stats-reset', target: 'stats', count, ts: Date.now(), ...(archivedFile !== null ? { archivedFile } : {}) });
54
+ return { count, archivedFile };
55
+ }
56
+
57
+ export function createDataMaintenance({ dispatchFile, summariesFile, snapshots, reminderStore, evolution, logger, store, recordAudit = () => {} } = {}) {
58
+ const deps = { dispatchFile, summariesFile, snapshots, reminderStore, evolution, logger, store, recordAudit };
59
+ return {
60
+ // 四区清理:非法 zone 返回 null(调用方 400);成功返回 { count, ... }。
61
+ // 审计一次清理一条(target/count/ts),快照清理沿用 B8 口径 kind=data-cleared。
62
+ clean(zone) {
63
+ if (zone === 'candidates') {
64
+ const count = deps.evolution !== undefined && typeof deps.evolution.purgeCandidates === 'function' ? deps.evolution.purgeCandidates() : 0;
65
+ deps.recordAudit({ kind: 'data-cleared', target: 'candidates', count, ts: Date.now() });
66
+ return { count };
67
+ }
68
+ if (zone === 'snapshots') {
69
+ const count = deps.snapshots !== undefined && typeof deps.snapshots.clear === 'function' ? deps.snapshots.clear() : 0;
70
+ deps.recordAudit({ kind: 'data-cleared', target: 'snapshots', count, ts: Date.now() });
71
+ return { count };
72
+ }
73
+ if (zone === 'reminders') {
74
+ const count = deps.reminderStore !== undefined && typeof deps.reminderStore.archiveActed === 'function' ? deps.reminderStore.archiveActed() : 0;
75
+ deps.recordAudit({ kind: 'reminder-archived', target: 'reminders', count, ts: Date.now() });
76
+ return { count };
77
+ }
78
+ if (zone === 'stats') return resetStats(deps);
79
+ return null;
80
+ },
81
+ };
82
+ }
83
+
84
+ // ── 路由处理(快照管理 + 数据维护 + 提醒归档视图)─────────────────────────────
85
+
86
+ function handleSnapshotsList(deps, res) {
87
+ if (deps.snapshots === undefined) return json(res, 404, { ok: false, error: '配置快照域未装配' });
88
+ return json(res, 200, {
89
+ ok: true,
90
+ snapshotEnabled: typeof deps.store.loadSnapshotEnabled === 'function' ? deps.store.loadSnapshotEnabled() : false,
91
+ count: deps.snapshots.count(),
92
+ cap: SNAPSHOT_CAP,
93
+ entries: deps.snapshots.list(),
94
+ });
95
+ }
96
+
97
+ async function handleSnapshotsSetEnabled(deps, req, res) {
98
+ const body = await readBody(req);
99
+ const next = !!(body && body.snapshotEnabled === true);
100
+ const persist = deps.store.persistSnapshotEnabled(next);
101
+ return persistOk(res, { snapshotEnabled: deps.store.loadSnapshotEnabled() }, persist);
102
+ }
103
+
104
+ function handleSnapshotView(deps, url, res) {
105
+ const id = url.searchParams.get('id') ?? '';
106
+ const snapshot = deps.snapshots !== undefined ? deps.snapshots.view(id) : null;
107
+ if (snapshot === null) return json(res, 404, { ok: false, error: '快照不存在或已损坏' });
108
+ return json(res, 200, { ok: true, snapshot });
109
+ }
110
+
111
+ async function handleMaintenanceClean(deps, req, res) {
112
+ const body = await readBody(req);
113
+ const zone = typeof body.zone === 'string' ? body.zone : '';
114
+ if (zone === '') return json(res, 400, { ok: false, error: 'zone 不能为空' });
115
+ if (deps.maintenance === undefined) return json(res, 404, { ok: false, error: '数据维护入口未装配' });
116
+ const result = deps.maintenance.clean(zone);
117
+ if (result === null) return json(res, 400, { ok: false, error: 'zone 无效(candidates/snapshots/reminders/stats)' });
118
+ if (result.error !== undefined) return json(res, 500, { ok: false, error: result.error });
119
+ return json(res, 200, { ok: true, zone, count: result.count, ...(result.archivedFile !== undefined ? { archivedFile: result.archivedFile } : {}) });
120
+ }
121
+
122
+ function handleRemindersArchived(deps, url, res) {
123
+ const offset = Number.parseInt(url.searchParams.get('offset') ?? '0', 10);
124
+ const limit = Number.parseInt(url.searchParams.get('limit') ?? '20', 10);
125
+ const page = deps.reminderStore !== undefined && typeof deps.reminderStore.listArchived === 'function'
126
+ ? deps.reminderStore.listArchived({ offset, limit })
127
+ : { reminders: [], total: 0, offset: 0, limit: 20 };
128
+ return json(res, 200, { ok: true, ...page });
129
+ }
130
+
131
+ // 数据维护域路由分发:只处理本域路径,未知交给统一 404。
132
+ export async function routeDataMaintenance(deps, req, res, url, sub) {
133
+ if (req.method === 'GET' && sub === '/snapshots') return handleSnapshotsList(deps, res);
134
+ if (req.method === 'POST' && sub === '/snapshots/set-enabled') return handleSnapshotsSetEnabled(deps, req, res);
135
+ if (req.method === 'GET' && sub === '/snapshot') return handleSnapshotView(deps, url, res);
136
+ if (req.method === 'POST' && sub === '/maintenance/clean') return handleMaintenanceClean(deps, req, res);
137
+ if (req.method === 'GET' && sub === '/reminders/archived') return handleRemindersArchived(deps, url, res);
138
+ return json(res, 404, { ok: false, error: `未知路由 ${sub}` });
139
+ }
@@ -91,6 +91,22 @@ export function collectChildCalls(session, maxCalls = 6) {
91
91
  return { calls, truncated: totalCalls > maxCalls, totalCalls };
92
92
  }
93
93
 
94
+ // childTotalTokens:宿主 tokenMeter 启发式估算(surface 口径,非计费 usage);仅
95
+ // completed 结算时测量,服务缺失/非函数/抛错均返回 undefined(fail-soft)。
96
+ export function measureChildTokens(getService, session) {
97
+ if (session === undefined || session === null) return undefined;
98
+ const meter = getService('tokenMeter');
99
+ if (meter === undefined || typeof meter.measure !== 'function') return undefined;
100
+ try {
101
+ const measured = meter.measure(session);
102
+ return measured !== null && typeof measured === 'object' && typeof measured.totalTokens === 'number'
103
+ ? measured.totalTokens
104
+ : undefined;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
94
110
  // 把一个后台 one-shot run 结算为带前台同等可观测元数据的任务结果。非 completed
95
111
  // 停因转 failed(aborted => killed,官方词汇表)并附部分输出;硬失败绝不 reject
96
112
  // 任务。
@@ -10,7 +10,7 @@ import { defineTool } from './shims.mjs';
10
10
  import { textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency, trustLabel, trustAudit, dispatchLabel } from './pure.mjs';
11
11
  import { resolveWhitelist } from './whitelist.mjs';
12
12
  import { recordEscapeAllow } from './escape.mjs';
13
- import { settleStart, collectChildUsage, collectChildCalls } from './delegation.mjs';
13
+ import { settleStart, collectChildUsage, collectChildCalls, measureChildTokens } from './delegation.mjs';
14
14
  import { DISPATCH_OUTPUT_SCHEMA, DISPATCH_PARAMETERS, DISPATCH_RENDER, imageTextOf, normalizeReasoningEffort } from './dispatch-schema.mjs';
15
15
  import { createDecisionTrace, finalizeTrace, assertTraceSize, parentContextOf, requestedOf, effectiveMeta, recordApprovalGate, recordFailure, profileSnapshotOf } from './decision-trace.mjs';
16
16
  import { profileDirectoryRows } from './profile-directory.mjs';
@@ -207,22 +207,6 @@ async function runBackground(args, merged, meta, request, parent, deps, pruneRes
207
207
  return out;
208
208
  }
209
209
 
210
- // childTotalTokens:宿主 tokenMeter 启发式估算(surface 口径,非计费 usage);仅
211
- // completed 结算时测量,服务缺失/非函数/抛错均返回 undefined(fail-soft)。
212
- function measureChildTokens(getService, session) {
213
- if (session === undefined || session === null) return undefined;
214
- const meter = getService('tokenMeter');
215
- if (meter === undefined || typeof meter.measure !== 'function') return undefined;
216
- try {
217
- const measured = meter.measure(session);
218
- return measured !== null && typeof measured === 'object' && typeof measured.totalTokens === 'number'
219
- ? measured.totalTokens
220
- : undefined;
221
- } catch {
222
- return undefined;
223
- }
224
- }
225
-
226
210
  // 仅 completed 结算时测量一次(tokenMeter 估算 + usage 分解 + 调用明细),dispose
227
211
  // 前读子 session 保证事件流完整;全 fail-soft(缺失/抛错 → undefined)绝不阻断结算。
228
212
  function measureForegroundRun(deps, run) {
@@ -345,8 +329,7 @@ async function runDispatch(args, exec, deps) {
345
329
  const meta = buildMeta(args, merged, parent);
346
330
  const request = buildRequest(args, merged, parent, exec.signal);
347
331
  const adoptionBase = { promptExcerpt: promptExcerptOf(args?.prompt), requestedModel: merged.model, parentModel: parent.options?.model };
348
- // 结果回收默认剪枝:在 textFrom 前复用宿主 toolResultPruner.pruneContent 预剪;
349
- // pruner 缺失时 pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。
332
+ // 结果回收默认剪枝:textFrom 前复用宿主 toolResultPruner.pruneContent 预剪;pruner 缺失时 pruneBlocks 回退为不剪(增强、非硬依赖)。
350
333
  const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
351
334
  logDispatchDecision(deps.logger, args, merged, parent);
352
335
  // 总预算守卫:前/后台在 execute 入口占并发额度(continuable 不占),结算处 release
@@ -354,6 +337,8 @@ async function runDispatch(args, exec, deps) {
354
337
  guardHandle = applyBudgetGate(deps, parent, args, merged, trace);
355
338
  // 派发台账静态元数据(双栏 cfg/task 指纹),分支结算处补 child_id+outcome 写盘。
356
339
  const base = deps.evoLedger.baseEntry({ parent, args, merged, mode, effectiveModelSource: trace.requested.source });
340
+ // 配置快照(开关关恒跳过;失败绝不阻断派发):记录完整 config 供「保存为方案」复现。
341
+ if (deps.snapshots !== undefined && typeof deps.snapshots.capture === 'function') { try { deps.snapshots.capture({ profileKey: profileKeyOf(base.effective), config: merged, mode }); } catch { /* fail-soft */ } }
357
342
  recordEscapeAllow(deps, parent, merged, trace);
358
343
  if (args.continuable === true) return await runContinuable(args, merged, meta, parent, exec, deps, trace, effectiveAllow, base);
359
344
  if (args.run_in_background === true) return await runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, exec);
@@ -365,8 +350,8 @@ async function runDispatch(args, exec, deps) {
365
350
  }
366
351
  }
367
352
 
368
- export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice }) {
369
- const deps = { store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice };
353
+ export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice, snapshots }) {
354
+ const deps = { store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice, snapshots };
370
355
  const dispatchTool = defineTool({
371
356
  name: 'dispatch',
372
357
  description: '派发子任务给派生子 Agent,可逐个覆盖其 preset、model、provider、推理档位、persona、工具白名单、token 预算或递归深度。优先使用已保存的 profile,仅少数字段需要临时覆盖时才传 per-call 参数;未指定 model 时子代理继承父模型,查资料/汇总/读文件等轻任务请优先考虑 cheap 方案或显式 model=flash。细粒度路由/授权发现请用官方 subagent 工具 + list_subagent_models(provider/model 成对、受本会话官方模型选择白名单);dispatch 只管方案(profile)及方案内覆盖。前台等待结果;run_in_background: true 启动后台任务(单轮即弃);continuable: true 启动持久子 Agent,后续轮次经 send_message 工具延续对话;失败经结算通知回报;超时可 send_message 追问。失败动作映射:stopReason=max-tokens→用 profiles_query 降档或 profile 加 maxTokens;error→换 profile/preset;refusal→重述任务;连续失败→告知用户。前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
@@ -81,6 +81,18 @@ function sweepExpiredAssets() {
81
81
  return 0;
82
82
  }
83
83
 
84
+ // 数据维护清理:删除全部资产文件 + 索引置空(审计由调用方记)。返回删除条数;
85
+ // 单个文件删除失败不阻断其余(best effort)。
86
+ function purgeAllAssets(state) {
87
+ const count = state.index.assets.length;
88
+ for (const entry of state.index.assets) {
89
+ try { rmSync(join(state.dir, `${entry.id}.json`), { force: true }); } catch { /* best effort */ }
90
+ }
91
+ state.index.assets = [];
92
+ state.persistIndex();
93
+ return count;
94
+ }
95
+
84
96
  // 状态流转:资产文件先行(合并 patch + 状态 + updatedAt),成功后同步索引。
85
97
  function transitionAsset(state, id, nextState, patch) {
86
98
  const safe = safeAssetId(id);
@@ -161,6 +173,7 @@ export function createEvolutionAssets({ dshHome, logger, onGovernanceFailure = (
161
173
  .filter((e) => e.profileKey === profileKey && UNRESOLVED_STATES.has(e.state))
162
174
  .map((e) => ({ ...e })),
163
175
  countBySource: (source) => state.index.assets.filter((e) => e.source === source).length,
176
+ purgeAll: () => purgeAllAssets(state),
164
177
  ids: () => state.index.assets.map((e) => e.id),
165
178
  sweepExpired: (now) => sweepExpiredAssets(state, now),
166
179
  };
@@ -409,5 +409,6 @@ export function createEvolutionEngine({
409
409
  evaluateCanaries: (now = Date.now()) => evaluateCanaries(deps, now),
410
410
  evolutionStatus: () => evolutionStatusOf({ getApplyEnabled: deps.getApplyEnabled, summaries: deps.getSummaries() }),
411
411
  backtest: (records, candidateConfig, opts) => deps.getHoldoutBacktest()(records, candidateConfig, opts),
412
+ purgeCandidates: () => assets.purgeAll(),
412
413
  };
413
414
  }
@@ -198,17 +198,27 @@ async function handleEvolutionRegenerate(deps, req, res) {
198
198
  return json(res, 200, { ok: true, ...result });
199
199
  }
200
200
 
201
- // 保存为方案:把建议来源的当前派发配置(身份键可还原的预设/提供方/模型段)存成
202
- // 新方案,便于复用与对比。自定义人格与工具过滤原文未留存(台账只存存在性),
203
- // 不在新方案内。身份键无可还原字段时 400。
201
+ // 保存为方案:优先从配置快照取(同 profileKey 最近一条快照 → 完整 config,含
202
+ // persona 原文与 toolFilter 全量,可完整复现该笔派发并记 snapshot-used 审计);
203
+ // 未命中回退「可还原段」逻辑(仅预设/提供方/模型,副注说明隐私取舍)。
204
+ // 身份键无可保存字段时 400。
204
205
  async function handleAdviceSaveProfile(deps, req, res) {
205
206
  const body = await readBody(req);
206
207
  const profileKey = body && typeof body.profileKey === 'string' ? body.profileKey : '';
207
208
  if (profileKey === '') return json(res, 400, { ok: false, error: 'profileKey 不能为空' });
208
- const from = sourceConfigFromKey(profileKey, {});
209
+ const snapshot = deps.snapshots !== undefined && typeof deps.snapshots.latestByProfileKey === 'function'
210
+ ? deps.snapshots.latestByProfileKey(profileKey)
211
+ : null;
209
212
  const config = {};
210
- for (const field of ['preset', 'provider', 'model']) {
211
- if (typeof from[field] === 'string' && from[field] !== '') config[field] = from[field];
213
+ if (snapshot !== null && snapshot.config !== null && typeof snapshot.config === 'object') {
214
+ for (const field of ['preset', 'provider', 'model', 'reasoningEffort', 'maxTokens', 'maxDepth', 'persona', 'toolFilter']) {
215
+ if (snapshot.config[field] !== undefined) config[field] = snapshot.config[field];
216
+ }
217
+ } else {
218
+ const from = sourceConfigFromKey(profileKey, {});
219
+ for (const field of ['preset', 'provider', 'model']) {
220
+ if (typeof from[field] === 'string' && from[field] !== '') config[field] = from[field];
221
+ }
212
222
  }
213
223
  if (Object.keys(config).length === 0) {
214
224
  return json(res, 400, { ok: false, error: '该配置无可保存字段(未指定预设/提供方/模型)' });
@@ -222,13 +232,19 @@ async function handleAdviceSaveProfile(deps, req, res) {
222
232
  }
223
233
  if (taken.has(id)) return json(res, 400, { ok: false, error: '方案编号冲突,无法保存' });
224
234
  const name = slug.length > 0 ? `建议配置-${slug.slice(0, 16)}` : '建议配置';
225
- const { clean, warnings } = sanitizeProfile({ ...config, id, name, description: '来自建议面板的配置快照(仅预设/模型/提供方;人格与工具过滤不落盘,按隐私设计)。便于复用与对比分析。' }, { strict: true });
235
+ const description = snapshot !== null
236
+ ? '来自建议面板的配置快照(含自定义人格与工具过滤,取自本机快照)。便于复用与对比分析。'
237
+ : '来自建议面板的配置快照(仅预设/模型/提供方;人格与工具过滤不落盘,按隐私设计)。便于复用与对比分析。';
238
+ const { clean, warnings } = sanitizeProfile({ ...config, id, name, description }, { strict: true });
226
239
  if (warnings.length > 0) {
227
240
  const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
228
241
  return json(res, 400, { ok: false, error: `保存被拒绝:${detail}` });
229
242
  }
230
243
  deps.store.profiles.set(clean.id, { ...clean, persisted: true });
231
- return persistOk(res, { id: clean.id, name: clean.name }, deps.store.persistProfiles());
244
+ if (snapshot !== null && typeof deps.recordAudit === 'function') {
245
+ deps.recordAudit({ kind: 'snapshot-used', profile_key: profileKey, file: typeof snapshot.file === 'string' ? snapshot.file : '' });
246
+ }
247
+ return persistOk(res, { id: clean.id, name: clean.name, fromSnapshot: snapshot !== null }, deps.store.persistProfiles());
232
248
  }
233
249
 
234
250
  export { handleEvolutionAssets, handleEvolutionApply, handleEvolutionRollback, handleEvolutionRegenerate, handleAdviceSaveProfile, handleSettingsCandidate };
@@ -10,6 +10,7 @@ import { profileStatsFromSummaries } from './profile-directory.mjs';
10
10
  import { costSummaryFromSummaries } from './cost-evidence.mjs';
11
11
  import { json, readBody, persistOk } from './http-helpers.mjs';
12
12
  import { handleEvolutionAssets, handleEvolutionApply, handleEvolutionRollback, handleEvolutionRegenerate, handleAdviceSaveProfile, handleSettingsCandidate } from './evolution-routes.mjs';
13
+ import { createDataMaintenance, routeDataMaintenance } from './data-maintenance.mjs';
13
14
 
14
15
  // 只有 loopback 接口可以驱动设置 HTTP 路由。
15
16
  const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@@ -381,6 +382,7 @@ async function routeRequest(deps, req, res, url, sub) {
381
382
  if (req.method === 'POST' && sub === '/advice/save-profile') return handleAdviceSaveProfile(deps, req, res);
382
383
  if (req.method === 'POST' && sub === '/settings/candidate') return handleSettingsCandidate(deps, req, res);
383
384
  if (req.method === 'POST' && sub === '/options/refresh') return handleRefresh(deps, res);
385
+ if ((req.method === 'GET' && (sub === '/snapshots' || sub === '/snapshot' || sub === '/reminders/archived')) || (req.method === 'POST' && (sub === '/snapshots/set-enabled' || sub === '/maintenance/clean'))) return routeDataMaintenance(deps, req, res, url, sub);
384
386
  if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
385
387
  if (req.method === 'POST' && sub === '/set-evolution-advice') return handleSetEvolutionAdvice(deps, req, res);
386
388
  if (req.method === 'POST' && sub === '/set-escape') return handleSetEscape(deps, req, res);
@@ -394,8 +396,10 @@ async function routeRequest(deps, req, res, url, sub) {
394
396
  json(res, 404, { ok: false, error: `未知路由 ${sub}` });
395
397
  }
396
398
 
397
- export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, adviceSource, evolution, logger }) {
398
- const deps = { store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, adviceSource, evolution, logger };
399
+ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, adviceSource, evolution, logger, snapshots, recordAudit }) {
400
+ const deps = { store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, adviceSource, evolution, logger, snapshots, recordAudit };
401
+ // 数据维护域装配(快照清理/候选清理/提醒归档/统计重置 + 快照管理路由依赖)。
402
+ deps.maintenance = createDataMaintenance({ dispatchFile, summariesFile, snapshots, reminderStore, evolution, logger, store, recordAudit });
399
403
  const handler = async (req, res) => {
400
404
  const remote = req.socket?.remoteAddress;
401
405
  if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
@@ -75,3 +75,30 @@ export function assertOfficialModelRoute(policy, provider, model) {
75
75
  const allowed = policy.allowedModels.some((route) => route.provider === provider && route.model === model);
76
76
  if (!allowed) throw new Error(officialModelRejectMessage(provider, model, policy.allowedModels));
77
77
  }
78
+
79
+ // 官方模型选择可见性行(dispatch:profiles 静态段 note 追加):投影存在(已启用)
80
+ // 时列出官方允许清单;投影缺失(未启用)给「无官方发现工具」与 profiles_query
81
+ // 授权路由引导。读取异常(形状校验 throw)fail-soft:debug 日志后按未启用展示,
82
+ // 绝不影响 profiles 目录段本身。
83
+ export function officialModelSelectionLine(ctx, context, logger) {
84
+ try {
85
+ const session = context?.agent?.session;
86
+ const policy = readModelSelectionPolicy(ctx, session);
87
+ if (policy.configured === true) {
88
+ const allowed = policy.allowedModels.map((route) => `${route.provider}/${route.model}`).join('、');
89
+ return `官方子代理模型选择:已启用(允许:${allowed})`;
90
+ }
91
+ } catch (error) {
92
+ if (logger !== undefined && typeof logger.debug === 'function') {
93
+ logger.debug('[dsh-subagent-profile] 官方模型选择投影读取失败,按未启用提示:' + (error instanceof Error ? error.message : String(error)));
94
+ }
95
+ }
96
+ return '官方子代理模型选择:未启用(无官方发现工具;授权路由请用 profiles_query 查看在册状态)';
97
+ }
98
+
99
+ // dispatch:profiles 段包装:目录文本非空时追加官方模型选择可见性行。静态段辅助
100
+ // 驻留 lib/core,index.mjs 装配不承载新增文案(行门)。
101
+ export function appendOfficialVisibilityLine(baseText, ctx, context, logger) {
102
+ if (typeof baseText !== 'string' || baseText === '') return '';
103
+ return baseText + '\n' + officialModelSelectionLine(ctx, context, logger);
104
+ }
@@ -184,11 +184,12 @@ function persistProfiles(state, logger) {
184
184
  // (派发优化建议注入,默认关,需显式开)+ escapeEnabled(逃生舱放行,默认关——信任
185
185
  // 底板,仅设置页显式 opt-in 可放开)+ applyEnabled(进化候选生成与应用写路径,默认
186
186
  // 关——应用能力只由人显式开启)+ candidateMode/candidateTtlH(候选方案更新策略与
187
- // 有效期,用户可配置)。开关共用同一 state.json,读写整份对象以互不覆盖;
187
+ // 有效期,用户可配置)+ snapshotEnabled(派发配置快照,默认关——本机私有域,仅
188
+ // 设置页显式 opt-in 开启)。开关共用同一 state.json,读写整份对象以互不覆盖;
188
189
  // 缺失文件回退默认值(全新部署),损坏文件 fail-closed(全关,绝不静默回退
189
190
  // enabled:true —— 安全禁用会被撤销),内存态仍驱动本进程。
190
- const STATE_DEFAULTS = { enabled: true, evolutionAdvice: false, escapeEnabled: false, applyEnabled: false, candidateMode: 'auto', candidateTtlH: 24 };
191
- const STATE_FAIL_CLOSED = { enabled: false, evolutionAdvice: false, escapeEnabled: false, applyEnabled: false, candidateMode: 'auto', candidateTtlH: 24 };
191
+ const STATE_DEFAULTS = { enabled: true, evolutionAdvice: false, escapeEnabled: false, applyEnabled: false, candidateMode: 'auto', candidateTtlH: 24, snapshotEnabled: false };
192
+ const STATE_FAIL_CLOSED = { enabled: false, evolutionAdvice: false, escapeEnabled: false, applyEnabled: false, candidateMode: 'auto', candidateTtlH: 24, snapshotEnabled: false };
192
193
 
193
194
  // 候选有效期小时数归一:0(不自动过期)或 1~8760(一年内)的整数原样保留,
194
195
  // 其余值回退默认 24 小时(读取与写入共用同一口径,防坏值落盘)。
@@ -208,6 +209,7 @@ function loadState(state) {
208
209
  applyEnabled: parsed.applyEnabled === true,
209
210
  candidateMode: parsed.candidateMode === 'manual' ? 'manual' : 'auto',
210
211
  candidateTtlH: validCandidateTtlH(parsed.candidateTtlH),
212
+ snapshotEnabled: parsed.snapshotEnabled === true,
211
213
  };
212
214
  } catch (error) {
213
215
  // fail-closed(S2):损坏的 state.json 若回退 enabled:true,会把「安全禁用」静默撤销。
@@ -294,6 +296,8 @@ export function createProfileStore({ dshHome, logger, onGovernanceFailure = () =
294
296
  persistCandidateMode: (value) => persistState(state, { candidateMode: value === 'manual' ? 'manual' : 'auto' }, onGovernanceFailure),
295
297
  loadCandidateTtlH: () => loadState(state).candidateTtlH,
296
298
  persistCandidateTtlH: (value) => persistState(state, { candidateTtlH: validCandidateTtlH(value) }, onGovernanceFailure),
299
+ loadSnapshotEnabled: () => loadState(state).snapshotEnabled,
300
+ persistSnapshotEnabled: (value) => persistState(state, { snapshotEnabled: value === true }, onGovernanceFailure),
297
301
  // getAllowFailOpen:cost guard 必须在派发时读取迁移标志;
298
302
  // loadProfiles/persistProfiles 持有该写入。
299
303
  getAllowFailOpen: () => state.allowFailOpen,
@@ -65,6 +65,10 @@ function loadState(stateFile, state, warn) {
65
65
  createdAt: Number.isFinite(raw.createdAt) ? raw.createdAt : 0,
66
66
  action: typeof raw.action === 'string' ? raw.action : null,
67
67
  actedAt: Number.isFinite(raw.actedAt) ? raw.actedAt : null,
68
+ // 归档标记:数据维护「归档已处理提醒」置位;归档只移出活动列表、绝不物理删除
69
+ // (提醒=审计记录,存储与 governance-audit 保留)。
70
+ archived: raw.archived === true,
71
+ archivedAt: Number.isFinite(raw.archivedAt) ? raw.archivedAt : null,
68
72
  });
69
73
  }
70
74
  } catch (error) {
@@ -117,6 +121,8 @@ function recordReminder(state, persist, audit, { severity, kind, title, detail,
117
121
  createdAt: Date.now(),
118
122
  action: null,
119
123
  actedAt: null,
124
+ archived: false,
125
+ archivedAt: null,
120
126
  };
121
127
  state.reminders.push(reminder);
122
128
  if (state.reminders.length > state.maxReminders) state.reminders.shift();
@@ -153,8 +159,34 @@ function actOnReminder(state, persist, audit, id, action) {
153
159
  }
154
160
 
155
161
  function listReminders(state, unreadOnly) {
156
- const all = [...state.reminders].reverse();
157
- return unreadOnly ? all.filter((r) => r.unread === true) : all;
162
+ const active = state.reminders.filter((r) => r.archived !== true).reverse();
163
+ return unreadOnly ? active.filter((r) => r.unread === true) : active;
164
+ }
165
+
166
+ // 归档视图:只读、按处理时间倒序、分页(防大量已处理条目一次性渲染卡 UI)。
167
+ // 返回 { reminders, total, offset, limit };坏参数归一安全值。
168
+ function listArchivedReminders(state, offset, limit) {
169
+ const off = Number.isInteger(offset) && offset >= 0 ? offset : 0;
170
+ const lim = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 100) : 20;
171
+ const archived = state.reminders
172
+ .filter((r) => r.archived === true)
173
+ .sort((a, b) => (b.archivedAt ?? b.actedAt ?? 0) - (a.archivedAt ?? a.actedAt ?? 0));
174
+ return { reminders: archived.slice(off, off + lim), total: archived.length, offset: off, limit: lim };
175
+ }
176
+
177
+ // 归档已处理条目:action 非空且未归档的一并置 archived(只移出活动列表、保留存储)。
178
+ // 返回本次归档条数;审计由调用方按一次清理记一条(数据维护口径)。
179
+ function archiveActedReminders(state, persist) {
180
+ const now = Date.now();
181
+ let count = 0;
182
+ for (const reminder of state.reminders) {
183
+ if (typeof reminder.action !== 'string' || reminder.action === '' || reminder.archived === true) continue;
184
+ reminder.archived = true;
185
+ reminder.archivedAt = now;
186
+ count += 1;
187
+ }
188
+ if (count > 0) persist();
189
+ return count;
158
190
  }
159
191
 
160
192
  export function createReminderStore({ dshHome, audit = () => {}, warn = () => {}, maxReminders = MAX_REMINDERS } = {}) {
@@ -167,6 +199,8 @@ export function createReminderStore({ dshHome, audit = () => {}, warn = () => {}
167
199
  ack: (ids) => ackReminders(state, persist, audit, ids),
168
200
  actOn: (id, action) => actOnReminder(state, persist, audit, id, action),
169
201
  list: ({ unreadOnly = false } = {}) => listReminders(state, unreadOnly),
170
- unreadCount: () => state.reminders.filter((r) => r.unread === true).length,
202
+ listArchived: ({ offset = 0, limit = 20 } = {}) => listArchivedReminders(state, offset, limit),
203
+ archiveActed: () => archiveActedReminders(state, persist),
204
+ unreadCount: () => state.reminders.filter((r) => r.unread === true && r.archived !== true).length,
171
205
  };
172
206
  }
@@ -0,0 +1,148 @@
1
+ // lib/core/snapshots.mjs — 派发配置快照域(subagent-evolution/snapshots/)。
2
+ // 开关开启后每次 dispatch effective 形成后写一条完整 config 快照(含 persona 原文
3
+ // 与 toolFilter 全量),供「保存为方案」事后完整复现该笔派发;快照是用户显式
4
+ // opt-in 的本机私有域(不是台账,dispatch.jsonl 不落原文——隐私红线保持)。
5
+ // 原子写(tmp+rename);索引 snapshots.index.json 记录元数据(无原文),按 ts
6
+ // 倒序读取;损坏文件/索引 fail-soft 跳过;总量护栏:快照 ≤200 条,到上限停止
7
+ // 新写并提示清理(不自动删旧)。import-free(仅 node 内置),可被裸 CI 单测。
8
+ // 审计:snapshot-written(写)/ snapshot-used(保存为方案命中,由调用方记)。
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ // 快照总量护栏(B7 定稿值):到上限停止新写(不自动删旧,提示清理)。
15
+ export const SNAPSHOT_CAP = 200;
16
+
17
+ // 完整 config 字段集:与 dispatch merged 同源(persona 原文、toolFilter 完整对象)。
18
+ const CONFIG_FIELDS = ['preset', 'provider', 'model', 'reasoningEffort', 'maxTokens', 'maxDepth', 'persona', 'toolFilter'];
19
+
20
+ // profileKey 摘要(文件名安全前缀,非可逆)。
21
+ function digestOf(profileKey) {
22
+ return createHash('sha256').update(String(profileKey)).digest('hex').slice(0, 12);
23
+ }
24
+
25
+ // 原子写(tmp+rename):崩溃不产生截断文件;失败清理 tmp + warn + 治理审计钩子。
26
+ function atomicWrite(file, payload, logger, onGovernanceFailure) {
27
+ const tmp = `${file}.tmp`;
28
+ try {
29
+ mkdirSync(dirname(file), { recursive: true });
30
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
31
+ renameSync(tmp, file);
32
+ return { persisted: true };
33
+ } catch (error) {
34
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
35
+ if (logger !== undefined && typeof logger.warn === 'function') {
36
+ logger.warn(`[dsh-subagent-profile] 配置快照写入失败:${error instanceof Error ? error.message : String(error)}`);
37
+ }
38
+ if (typeof onGovernanceFailure === 'function') onGovernanceFailure();
39
+ return { persisted: false, error: error instanceof Error ? error.message : String(error) };
40
+ }
41
+ }
42
+
43
+ // 读索引;缺失回退空表,损坏/形状不符 fail-soft 从空起步(快照文件仍在,索引可重建)。
44
+ function readIndex(indexFile, logger) {
45
+ if (!existsSync(indexFile)) return [];
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(indexFile, 'utf8'));
48
+ if (parsed !== null && typeof parsed === 'object' && parsed.v === 1 && Array.isArray(parsed.entries)) {
49
+ return parsed.entries.filter((e) => e !== null && typeof e === 'object' && typeof e.id === 'string');
50
+ }
51
+ } catch { /* fail-soft */ }
52
+ if (logger !== undefined && typeof logger.warn === 'function') {
53
+ logger.warn('[dsh-subagent-profile] 配置快照索引损坏,按空索引处理(快照文件保留)');
54
+ }
55
+ return [];
56
+ }
57
+
58
+ // 读单个快照文件;缺失/损坏/形状不符返回 null(fail-soft,调用方跳过)。
59
+ function readSnapshotFile(file, logger) {
60
+ if (!existsSync(file)) return null;
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
63
+ if (parsed !== null && typeof parsed === 'object' && parsed.v === 1 && typeof parsed.id === 'string' && parsed.config !== null && typeof parsed.config === 'object') {
64
+ return parsed;
65
+ }
66
+ } catch { /* fail-soft */ }
67
+ if (logger !== undefined && typeof logger.warn === 'function') {
68
+ logger.warn(`[dsh-subagent-profile] 配置快照文件损坏,跳过:${file}`);
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // dispatch effective 形成后调用:开关关恒跳过;到护栏上限停止新写(不自动删旧)。
74
+ // 幂等键 = 摘要+ts;同键碰撞跳过。审计只记元数据(无 persona/prompt 原文)。
75
+ function captureSnapshot(deps, { profileKey, config, mode }) {
76
+ if (!deps.getEnabled()) return null;
77
+ if (deps.state.index.length >= SNAPSHOT_CAP) {
78
+ if (deps.logger !== undefined && typeof deps.logger.warn === 'function') {
79
+ deps.logger.warn(`[dsh-subagent-profile] 配置快照已达 ${SNAPSHOT_CAP} 条护栏上限,停止新快照(可在设置页数据维护区清理)`);
80
+ }
81
+ return null;
82
+ }
83
+ const key = typeof profileKey === 'string' ? profileKey : '(inline)';
84
+ const ts = Date.now();
85
+ const id = `${digestOf(key)}-${ts}`;
86
+ if (deps.state.index.some((e) => e.id === id)) return null;
87
+ const payload = { v: 1, id, ts, profileKey: key, from: typeof mode === 'string' && mode !== '' ? mode : 'dispatch', config: {} };
88
+ for (const field of CONFIG_FIELDS) {
89
+ if (config !== null && typeof config === 'object' && config[field] !== undefined) payload.config[field] = config[field];
90
+ }
91
+ const size = Buffer.byteLength(JSON.stringify(payload));
92
+ const file = join(deps.dir, `${id}.json`);
93
+ const written = atomicWrite(file, payload, deps.logger, deps.onGovernanceFailure);
94
+ if (!written.persisted) return null;
95
+ deps.state.index.push({ key, ts, size, id, from: payload.from });
96
+ deps.persistIndex();
97
+ deps.recordAudit({ kind: 'snapshot-written', profile_key: key, file: `${id}.json`, size });
98
+ return { id, ts, profileKey: key };
99
+ }
100
+
101
+ // 索引元数据列表(ts 倒序;无 config 原文)。
102
+ function listSnapshots(state) {
103
+ return [...state.index].sort((a, b) => b.ts - a.ts);
104
+ }
105
+
106
+ // 单条完整快照(只读查看用;含 persona 原文,UI 默认折叠)。
107
+ function viewSnapshot(deps, id) {
108
+ if (typeof id !== 'string' || id === '' || !deps.state.index.some((e) => e.id === id)) return null;
109
+ return readSnapshotFile(join(deps.dir, `${id}.json`), deps.logger);
110
+ }
111
+
112
+ // 「保存为方案」数据源:按 profileKey 匹配最近一条快照(ts 倒序首条命中)。
113
+ function latestSnapshot(deps, profileKey) {
114
+ for (const entry of listSnapshots(deps.state)) {
115
+ if (entry.key !== profileKey) continue;
116
+ const snapshot = readSnapshotFile(join(deps.dir, `${entry.id}.json`), deps.logger);
117
+ if (snapshot !== null) return { ...snapshot, file: `${entry.id}.json` };
118
+ }
119
+ return null;
120
+ }
121
+
122
+ // 一键清理:删除全部快照文件 + 索引置空。返回删除条数(审计由调用方记)。
123
+ function clearSnapshots(deps) {
124
+ const count = deps.state.index.length;
125
+ for (const entry of deps.state.index) {
126
+ try { rmSync(join(deps.dir, `${entry.id}.json`), { force: true }); } catch { /* 单个删除失败不阻断其余 */ }
127
+ }
128
+ deps.state.index = [];
129
+ try { rmSync(deps.indexFile, { force: true }); } catch { /* best effort */ }
130
+ return count;
131
+ }
132
+
133
+ export function createSnapshotStore({ dshHome, logger, getEnabled = () => false, recordAudit = () => {}, onGovernanceFailure = () => {} } = {}) {
134
+ const dir = join(dshHome, 'subagent-evolution', 'snapshots');
135
+ const indexFile = join(dir, 'snapshots.index.json');
136
+ const state = { index: readIndex(indexFile, logger) };
137
+ const deps = { dir, indexFile, state, logger, getEnabled, recordAudit, onGovernanceFailure, persistIndex: () => atomicWrite(indexFile, { v: 1, entries: state.index }, logger, onGovernanceFailure) };
138
+ return {
139
+ dir,
140
+ indexFile,
141
+ capture: (input) => captureSnapshot(deps, input),
142
+ list: () => listSnapshots(state),
143
+ view: (id) => viewSnapshot(deps, id),
144
+ latestByProfileKey: (profileKey) => latestSnapshot(deps, profileKey),
145
+ count: () => state.index.length,
146
+ clear: () => clearSnapshots(deps),
147
+ };
148
+ }