@sidady/dsh-steward 0.0.0-stage → 0.9.12

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/lib/index.js ADDED
@@ -0,0 +1,780 @@
1
+ /**
2
+ * dsh-steward · host entry (index.js) — v0.6
3
+ *
4
+ * Deliberately declares NO `export const inject`. Pain points #1/#4 of this
5
+ * project are literally "one plugin that fails to activate crashes the whole
6
+ * web boot". A hard `inject: ['tools','webServer']` makes this bundle a boot
7
+ * dependency; instead every capability is looked up programmatically with
8
+ * ctx.get(name) (confirmed inject-free in dsh-config-manager/lib/core/model-tools.js:268,275
9
+ * and @michengai/dsh-archive-manager/lib/plugin-updater.js:77 optionalService)
10
+ * and each registration is fail-soft: any error is contained so the steward
11
+ * degrades to "event counter only" instead of failing the boot fiber.
12
+ *
13
+ * v0.2 adds the ACTION hub (plan/execute with a single-use fingerprint).
14
+ * Plans are zero-write; executes are audited; passwords never persist.
15
+ * Contract for routes/tools mirrors the running dsh-disk-cleaner@1.6.2 host entry.
16
+ */
17
+ import { collectReport } from './report.js';
18
+ import { createActionHub } from './actions.js';
19
+ import { parseIntent } from './intent.js';
20
+ import { runModel, normalizeBrainOutput } from './brain.js';
21
+ import { defaults as defaultSettings, merge as mergeSettings, normalize as normalizeSettings, describe as describeSettings, value as settingValue } from './settings.js';
22
+ import { MODEL_VERDICT_TTL_MS, MODEL_PROBE_TIMEOUT_MS, probeModel, planSentinel, summarize, defaultVerdict, suggestDefault } from './models.js';
23
+ import { readFileSync } from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+ import path from 'node:path';
26
+ import os from 'node:os';
27
+ import fsp from 'node:fs/promises';
28
+
29
+ export const name = 'dsh-steward';
30
+
31
+ // v0.6.0 L2: the conversational entry. `intent` turns one sentence into ONE
32
+ // target (which action card to open) and never executes anything by itself —
33
+ // the sentence is a shortcut to the same plan→confirm→audit chain a click uses.
34
+ // Kept host-side so the parsing logic is testable with plain node (client
35
+ // bundles cannot be unit-tested here at all).
36
+
37
+ const ROUTE_PREFIX = '/api/dsh-steward/';
38
+ const MAX_BODY = 16384;
39
+
40
+ // Version is read on every ping, NOT cached at module load. The Host process
41
+ // reuses the already-imported host module across remove→install of a link:
42
+ // dependency, so a load-time constant would keep reporting the old version.
43
+ let versionFallback = '0.4.x';
44
+ function pkgVersion() {
45
+ try {
46
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
47
+ const v = String(JSON.parse(readFileSync(pkgPath, 'utf8')).version || '').trim();
48
+ if (v) { versionFallback = v; return v; }
49
+ } catch { /* file briefly absent during reinstall — keep last good value */ }
50
+ return versionFallback;
51
+ }
52
+
53
+ /** Programmatic, inject-free service lookup. Never throws. */
54
+ function getService(ctx, key) {
55
+ try {
56
+ if (typeof ctx.get === 'function') {
57
+ const svc = ctx.get(key);
58
+ if (svc) return svc;
59
+ }
60
+ } catch { /* get() unavailable or service missing — fall through */ }
61
+ try {
62
+ const svc = ctx[key]; // property access requires inject; may throw — contained
63
+ if (svc) return svc;
64
+ } catch { /* "cannot get property X without inject" — expected without inject */ }
65
+ return null;
66
+ }
67
+
68
+ function sendJson(res, status, value) {
69
+ try {
70
+ const body = Buffer.from(JSON.stringify(value));
71
+ res.writeHead(status, {
72
+ 'content-type': 'application/json; charset=utf-8',
73
+ 'cache-control': 'no-store',
74
+ 'content-length': body.length,
75
+ });
76
+ res.end(body);
77
+ } catch { /* response already gone */ }
78
+ }
79
+
80
+ function sameOrigin(req) {
81
+ const origin = req.headers && req.headers.origin;
82
+ const host = req.headers && req.headers.host;
83
+ if (origin === undefined || host === undefined) return true;
84
+ return origin === `http://${host}` || origin === `https://${host}`;
85
+ }
86
+
87
+ // v0.9.8: "the body was unreadable" and "the body was an empty object" are two
88
+ // different facts, and collapsing them was a real bug: readBody returned {} for a
89
+ // broken body, and run() treated action === '' as "run a full checkup". So a
90
+ // malformed request — or one 17 KB payload — silently triggered the most
91
+ // expensive thing this plugin does. Callers can now tell the two apart.
92
+ function readBody(req) {
93
+ return new Promise((resolve) => {
94
+ const chunks = [];
95
+ let size = 0;
96
+ let done = false;
97
+ const finish = (value) => { if (!done) { done = true; resolve(value); } };
98
+ req.on('data', (chunk) => {
99
+ if (done) return;
100
+ size += chunk.length;
101
+ if (size > MAX_BODY) { finish({ parsed: false, reason: 'body exceeds ' + MAX_BODY + ' bytes' }); return; }
102
+ chunks.push(chunk);
103
+ });
104
+ req.on('end', () => {
105
+ if (done) return;
106
+ const text = Buffer.concat(chunks).toString('utf8').trim();
107
+ if (text === '') { finish({ parsed: true, value: {} }); return; }
108
+ try {
109
+ const value = JSON.parse(text);
110
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
111
+ finish({ parsed: false, reason: 'body must be a JSON object' });
112
+ return;
113
+ }
114
+ finish({ parsed: true, value });
115
+ } catch { finish({ parsed: false, reason: 'invalid JSON' }); }
116
+ });
117
+ req.on('error', () => finish({ parsed: false, reason: 'request stream failed' }));
118
+ });
119
+ }
120
+
121
+ /** In-memory, bounded plugin-change counter (resets on restart; no disk IO in callback). */
122
+ function createChangeStore() {
123
+ const changes = [];
124
+ return {
125
+ push(evt) {
126
+ try {
127
+ changes.push({ at: new Date().toISOString(), type: String((evt && (evt.type || evt.action)) || 'change') });
128
+ if (changes.length > 50) changes.shift();
129
+ } catch { /* never throw from an event listener */ }
130
+ },
131
+ snapshot() { return changes.slice(); },
132
+ };
133
+ }
134
+
135
+ function dataDir() {
136
+ const root = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
137
+ return path.join(root, 'dsh-steward');
138
+ }
139
+
140
+ const io = {
141
+ async appendJsonl(file, obj) {
142
+ await fsp.mkdir(path.dirname(file), { recursive: true });
143
+ await fsp.appendFile(file, JSON.stringify(obj) + '\n', 'utf8');
144
+ },
145
+ async readJson(file) {
146
+ try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch { return null; }
147
+ },
148
+ async writeJson(file, value) {
149
+ await fsp.mkdir(path.dirname(file), { recursive: true });
150
+ await fsp.writeFile(file, JSON.stringify(value), 'utf8');
151
+ },
152
+ async readBytes(file) {
153
+ return await fsp.readFile(file);
154
+ },
155
+ // v0.9.8: append-only jsonl files need a way to be read back and rewritten when
156
+ // they cross a cap. Optional by contract — a caller whose io lacks these simply
157
+ // skips trimming rather than failing the write.
158
+ async readText(file) {
159
+ return await fsp.readFile(file, 'utf8');
160
+ },
161
+ async writeText(file, text) {
162
+ await fsp.mkdir(path.dirname(file), { recursive: true });
163
+ await fsp.writeFile(file, text, 'utf8');
164
+ },
165
+ };
166
+
167
+ function renderReportText(report) {
168
+ const lines = [];
169
+ lines.push(`# 智能管家 · 体检报告 (${report.summary.level.toUpperCase()})`);
170
+ lines.push(report.summary.headline);
171
+ lines.push('');
172
+ lines.push(`生成时间: ${report.generatedAt}(UTC) | 状态: ${report.status} | 计数: critical=${report.summary.counts.critical} warning=${report.summary.counts.warning} info=${report.summary.counts.info}`);
173
+ lines.push('');
174
+ for (const f of report.findings) {
175
+ lines.push(`## [${f.severity.toUpperCase()}] ${f.title}`);
176
+ lines.push(f.detail);
177
+ if (f.evidence) lines.push(`证据: ${f.evidence}`);
178
+ if (f.recommendation) lines.push(`建议: ${f.recommendation}`);
179
+ lines.push('');
180
+ }
181
+ lines.push('边界: ' + report.limitations.join(' '));
182
+ if (report.errors.length) lines.push('采集告警: ' + JSON.stringify(report.errors));
183
+ return lines.join('\n');
184
+ }
185
+
186
+ /**
187
+ * Loopback call into another plugin's host route. Uses this process's own
188
+ * advertised origin — 127.0.0.1 loopback is not behind the browser-session
189
+ * trust fence (verified against /api/disk-cleaner/ and
190
+ * /api/dsh-config-manager/export-preview this session).
191
+ */
192
+ function makeRouteFetch(ctx) {
193
+ return async function routeFetch(routePath, body, opts = {}) {
194
+ let base = '';
195
+ try {
196
+ const webApp = getService(ctx, 'webApp');
197
+ if (webApp && typeof webApp.getAdvertisedUrl === 'function') base = String(webApp.getAdvertisedUrl()).replace(/\/+$/, '');
198
+ } catch { /* fall through */ }
199
+ if (!base) {
200
+ const webServer = getService(ctx, 'webServer');
201
+ if (webServer && typeof webServer.getOrigin === 'function') base = String(webServer.getOrigin()).replace(/\/+$/, '');
202
+ }
203
+ if (!base) throw new Error('无法解析本机 GUI origin(webApp/webServer 服务不可用)。');
204
+ const response = await fetch(base + routePath, {
205
+ method: 'POST',
206
+ headers: { 'content-type': 'application/json' },
207
+ body: JSON.stringify(body || {}),
208
+ signal: AbortSignal.timeout(180_000),
209
+ });
210
+ let data;
211
+ try { data = await response.json(); } catch { throw new Error(`${routePath} → HTTP ${response.status}`); }
212
+ if (!response.ok || (opts.requireOk !== false && (!data || data.ok !== true))) {
213
+ throw new Error(`${routePath}: ${(data && (data.error || data.message)) || 'HTTP ' + response.status}`);
214
+ }
215
+ return data;
216
+ };
217
+ }
218
+
219
+ export function apply(ctx) {
220
+ const changes = createChangeStore();
221
+ let lastReport = null;
222
+ const files = {
223
+ audit: path.join(dataDir(), 'audit.jsonl'),
224
+ trend: path.join(dataDir(), 'trend.json'),
225
+ knowledge: path.join(dataDir(), 'knowledge.jsonl'),
226
+ acked: path.join(dataDir(), 'acked.json'),
227
+ lastReport: path.join(dataDir(), 'last-report.json'),
228
+ configBaseline: path.join(dataDir(), 'config-baseline.json'),
229
+ settings: path.join(dataDir(), 'settings.json'),
230
+ modelProbes: path.join(dataDir(), 'model-probes.json'),
231
+ };
232
+ // v0.8.0 设置:内存缓存 + 落盘。读不到/坏了 → 全默认(旧行为),绝不拦住体检。
233
+ let settingsCache = null;
234
+ let settingsSavedAt = null;
235
+ async function loadSettings() {
236
+ if (settingsCache) return settingsCache;
237
+ let saved = null;
238
+ try { saved = await io.readJson(files.settings); } catch { saved = null; }
239
+ if (saved && typeof saved === 'object' && saved.settings) {
240
+ settingsCache = mergeSettings(saved.settings);
241
+ settingsSavedAt = typeof saved.at === 'string' ? saved.at : null;
242
+ } else {
243
+ settingsCache = defaultSettings();
244
+ }
245
+ return settingsCache;
246
+ }
247
+ function currentSettings() {
248
+ return settingsCache || defaultSettings();
249
+ }
250
+ async function saveSettings(input) {
251
+ const before = currentSettings();
252
+ const result = normalizeSettings(input);
253
+ if (!result.ok) {
254
+ // 报错要说人话:choice 项被拒不是「不是数字」,分两种原因分开报,
255
+ // 否则用户填了一个不存在的选项,却被告知「这些值不是数字」。
256
+ const badNumber = result.rejected.filter((r) => r.reason !== '不是可选值');
257
+ const badChoice = result.rejected.filter((r) => r.reason === '不是可选值');
258
+ const parts = [];
259
+ if (badNumber.length) parts.push('这些值不是数字:' + badNumber.map((r) => r.key).join('、'));
260
+ if (badChoice.length) parts.push('这些值不是可选值:' + badChoice.map((r) => r.key).join('、'));
261
+ return {
262
+ ok: false,
263
+ error: parts.length ? parts.join(';') : '没有收到任何可保存的项。',
264
+ rejected: result.rejected,
265
+ groups: describeSettings(before),
266
+ };
267
+ }
268
+ // 合并到现有设置上(只改提交的项,其余保持原值)。
269
+ const next = before;
270
+ for (const [flat, val] of Object.entries(result.applied)) {
271
+ const parts = flat.split('.');
272
+ let cursor = next;
273
+ for (let i = 0; i < parts.length - 1; i += 1) {
274
+ if (!cursor[parts[i]] || typeof cursor[parts[i]] !== 'object') cursor[parts[i]] = {};
275
+ cursor = cursor[parts[i]];
276
+ }
277
+ cursor[parts[parts.length - 1]] = val;
278
+ }
279
+ settingsCache = next;
280
+ settingsSavedAt = new Date().toISOString();
281
+ try { await io.writeJson(files.settings, { at: settingsSavedAt, settings: next }); }
282
+ catch (error) {
283
+ return { ok: false, error: '写入设置失败:' + (error && error.message ? error.message : String(error)), groups: describeSettings(next) };
284
+ }
285
+ const human = ['设置已保存。'];
286
+ if (result.clamped.length) {
287
+ human.push('有 ' + result.clamped.length + ' 项超出范围被夹到边界:' + result.clamped.map((c) => c.key + ' ' + c.from + '→' + c.to).join('、') + '。');
288
+ }
289
+ if (result.rejected.length) {
290
+ human.push('有 ' + result.rejected.length + ' 项因不是数字被忽略:' + result.rejected.map((r) => r.key).join('、') + '。');
291
+ }
292
+ human.push('下一次体检会用新阈值;静音有效期只影响新静音的条目,已有的到期时间不变。');
293
+ return { ok: true, settings: next, groups: describeSettings(next), savedAt: settingsSavedAt, clamped: result.clamped, rejected: result.rejected, human };
294
+ }
295
+ let knowledgeLog = null; // loaded once per process, shared with recall
296
+ async function loadKnowledge() {
297
+ if (Array.isArray(knowledgeLog)) return knowledgeLog;
298
+ knowledgeLog = [];
299
+ try {
300
+ const raw = await fsp.readFile(files.knowledge, 'utf8');
301
+ knowledgeLog = raw.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
302
+ } catch { /* no knowledge yet — empty recall */ }
303
+ return knowledgeLog;
304
+ }
305
+ // ② v0.3.1 cross-restart persistence: on first request after a process
306
+ // restart, restore the last persisted report so the panel is not blank.
307
+ // It is explicitly marked staleAfterRestart — a persisted report may carry
308
+ // CURRENT crash evidence generated in the PREVIOUS process, and the
309
+ // attribution gate in actions.js refuses to act on it until a fresh scan.
310
+ let restoredTried = false;
311
+ async function ensureRestored() {
312
+ if (lastReport || restoredTried) return;
313
+ restoredTried = true;
314
+ try {
315
+ const saved = await io.readJson(files.lastReport);
316
+ if (saved && saved.report && typeof saved.report.generatedAt === 'string') {
317
+ const bootedIso = new Date(Date.now() - Math.floor(process.uptime()) * 1000).toISOString();
318
+ saved.report.staleAfterRestart = saved.report.generatedAt < bootedIso;
319
+ saved.report.restoredFrom = saved.report.generatedAt;
320
+ lastReport = saved.report;
321
+ }
322
+ } catch { /* nothing persisted — start clean */ }
323
+ }
324
+ const hub = createActionHub({
325
+ routeFetch: makeRouteFetch(ctx),
326
+ getPluginManager: () => getService(ctx, 'pluginManager'),
327
+ getDefaultModel: () => getService(ctx, 'agentDefaultModel'),
328
+ getReport: () => lastReport,
329
+ getSettings: () => currentSettings(),
330
+ io,
331
+ now: () => new Date(),
332
+ auditFile: files.audit,
333
+ trendFile: files.trend,
334
+ knowledgeFile: files.knowledge,
335
+ ackedFile: files.acked,
336
+ });
337
+
338
+ // ---------- v0.9.1 模型健康 ----------
339
+ // 目录读取抽成一处:models 与 model-now 走同一份,避免两处口径漂。
340
+ async function readCatalog() {
341
+ const out = { ok: true, available: false, providers: [], default: null, error: '', cachedAt: null };
342
+ try {
343
+ const llm = getService(ctx, 'llm');
344
+ if (llm && typeof llm.listProviders === 'function') {
345
+ out.available = true;
346
+ const list = await llm.listProviders();
347
+ for (const p of Array.isArray(list) ? list : []) {
348
+ const id = p && (p.id || p.provider);
349
+ if (!id) continue;
350
+ const models = [];
351
+ try {
352
+ const ms = typeof llm.listModels === 'function' ? await llm.listModels(id) : [];
353
+ for (const m of Array.isArray(ms) ? ms : []) {
354
+ const mid = m && (m.id || m.model);
355
+ if (mid) models.push({ id: String(mid), name: String(m.name || mid) });
356
+ }
357
+ } catch { /* one provider's catalog failing never hides the rest */ }
358
+ if (models.length) out.providers.push({ id: String(id), name: String(p.name || id), models });
359
+ }
360
+ }
361
+ } catch (e) { out.error = e && e.message ? e.message : String(e); }
362
+ try {
363
+ const sel = getService(ctx, 'agentDefaultModel');
364
+ const cur = sel && typeof sel.currentSelection === 'function' ? sel.currentSelection() : null;
365
+ if (cur && cur.provider && cur.model) out.default = { provider: String(cur.provider), model: String(cur.model) };
366
+ } catch { /* no default selection is fine */ }
367
+ if (!out.default && out.providers.length) out.default = { provider: out.providers[0].id, model: out.providers[0].models[0].id };
368
+ return out;
369
+ }
370
+ function sentinelOptions(settings) {
371
+ return {
372
+ perProvider: settingValue(settings, 'model.perProvider'),
373
+ maxTotal: settingValue(settings, 'model.maxTotal'),
374
+ };
375
+ }
376
+ async function readProbeCache() {
377
+ try {
378
+ const saved = await io.readJson(files.modelProbes);
379
+ if (saved && typeof saved === 'object') return saved;
380
+ } catch { /* no cache yet */ }
381
+ return null;
382
+ }
383
+ function cacheIsFresh(cached) {
384
+ if (!cached || typeof cached.at !== 'string') return false;
385
+ const t = Date.parse(cached.at);
386
+ if (!Number.isFinite(t)) return false;
387
+ return Date.now() - t < MODEL_VERDICT_TTL_MS;
388
+ }
389
+ // 跑一轮哨兵探针。只读:不改任何插件配置、不动默认模型。
390
+ async function runModelScan() {
391
+ const catalog = await readCatalog();
392
+ const settings = currentSettings();
393
+ const plan = planSentinel(catalog, sentinelOptions(settings));
394
+ const llm = getService(ctx, 'llm');
395
+ // v0.9.8: was a strictly sequential loop. With 12 probes at a 45 s timeout the
396
+ // worst case is 9 minutes, and a panel request that hangs that long is
397
+ // indistinguishable from a dead plugin (measured: 115.6 s for one scan, 151.2 s
398
+ // for a report with probeOnReport=always). Three at a time keeps the provider
399
+ // side polite and cuts the wall clock roughly threefold.
400
+ const PROBE_CONCURRENCY = 3;
401
+ const probes = new Array(plan.length);
402
+ let cursor = 0;
403
+ const worker = async () => {
404
+ for (;;) {
405
+ const i = cursor;
406
+ cursor += 1;
407
+ if (i >= plan.length) return;
408
+ probes[i] = await probeModel({ llm }, { ...plan[i], timeoutMs: MODEL_PROBE_TIMEOUT_MS });
409
+ }
410
+ };
411
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(PROBE_CONCURRENCY, plan.length)) }, worker));
412
+ const prev = await readProbeCache();
413
+ const summary = summarize(probes, prev && prev.summary ? prev.summary : null);
414
+ const dv = defaultVerdict(probes, catalog.default);
415
+ const prevBusy = prev && Number.isFinite(prev.defaultBusyRounds) ? prev.defaultBusyRounds : 0;
416
+ // 连续「忙」的轮数:只有连续两轮都拿不到内容,才值得提示——
417
+ // 一次 429 冷却就把默认模型报成故障,是制造噪音。
418
+ const defaultBusyRounds = dv.state === 'busy' ? prevBusy + 1 : 0;
419
+ const suggest = suggestDefault(probes, catalog.default);
420
+ const payloadOut = { at: summary.at, verdicts: probes, summary, default: catalog.default, defaultVerdict: dv, defaultBusyRounds, suggest };
421
+ try { await io.writeJson(files.modelProbes, payloadOut); } catch { /* cache is an optimization, never a requirement */ }
422
+ return { ok: true, ...payloadOut, catalog };
423
+ }
424
+ // 报告里用的那份:按设置决定读缓存 / 真跑 / 跳过。异常一律降级为 null。
425
+ async function modelHealthForReport() {
426
+ try {
427
+ const mode = settingValue(currentSettings(), 'model.probeOnReport');
428
+ if (mode === 'off') return null;
429
+ let cached = await readProbeCache();
430
+ if (mode === 'always' || !cacheIsFresh(cached)) {
431
+ const scanned = await runModelScan();
432
+ cached = { at: scanned.at, verdicts: scanned.verdicts, summary: scanned.summary, default: scanned.default, defaultVerdict: scanned.defaultVerdict, defaultBusyRounds: scanned.defaultBusyRounds, suggest: scanned.suggest };
433
+ }
434
+ if (!cached) return null;
435
+ // 目录一并带上:model-default 的 plan 要用它确认目标模型真的存在。
436
+ // 它只是 listProviders/listModels,不花配额、不动任何状态。
437
+ const catalog = await readCatalog();
438
+ return {
439
+ at: cached.at,
440
+ catalog,
441
+ default: cached.default || null,
442
+ defaultVerdict: cached.defaultVerdict || { state: 'unprobed' },
443
+ defaultBusyRounds: Number.isFinite(cached.defaultBusyRounds) ? cached.defaultBusyRounds : 0,
444
+ suggest: cached.suggest || null,
445
+ summary: cached.summary || null,
446
+ fresh: cacheIsFresh(cached),
447
+ };
448
+ } catch (e) {
449
+ return { error: e && e.message ? e.message : String(e) };
450
+ }
451
+ }
452
+
453
+ async function run(action, payload) {
454
+ if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true };
455
+ // v0.8.0 settings: 只读设置页需要的一切(值 + 范围 + 为什么),以及一次写入。
456
+ if (action === 'settings') {
457
+ await loadSettings();
458
+ return { ok: true, settings: currentSettings(), groups: describeSettings(currentSettings()), defaults: defaultSettings(), savedAt: settingsSavedAt };
459
+ }
460
+ if (action === 'settings-save') {
461
+ await loadSettings();
462
+ return await saveSettings(payload && payload.settings);
463
+ }
464
+ await ensureRestored();
465
+ await loadSettings();
466
+ if (action === 'report') {
467
+ let acked = [];
468
+ try { acked = (await io.readJson(files.acked)) || []; } catch { acked = []; }
469
+ const knowledge = await loadKnowledge();
470
+ // ④ v0.7.6 config drift: compare against the baseline recorded on the
471
+ // previous scan, then renew it. The baseline is ours (data dir), never the
472
+ // DSH config itself — the steward does not write cordis.patch.yml.
473
+ let configBaseline = [];
474
+ try {
475
+ const saved = await io.readJson(files.configBaseline);
476
+ if (saved && Array.isArray(saved.entries)) configBaseline = saved.entries;
477
+ } catch { configBaseline = []; }
478
+ // v0.7.8: a fixed threshold is not a detection. Load the previous report so
479
+ // the workspace check can compare growth instead of an absolute size — the
480
+ // same numbers that always fire on a machine that did not change. Only the
481
+ // workspace block consumes it today; it is passed as a plain object so more
482
+ // collectors can adopt it without another signature change.
483
+ let previous = null;
484
+ try {
485
+ const saved = await io.readJson(files.lastReport);
486
+ if (saved && saved.report && typeof saved.report === 'object') previous = saved.report;
487
+ } catch { previous = null; }
488
+ const modelHealth = await modelHealthForReport();
489
+ lastReport = collectReport({ pluginChanges: changes.snapshot(), acked: Array.isArray(acked) ? acked : [], knowledge, configBaseline, previous, settings: currentSettings(), modelHealth });
490
+ // Persist best-effort; a failed persist never fails the report itself.
491
+ io.writeJson(files.lastReport, { report: lastReport }).catch(() => {});
492
+ if (Array.isArray(lastReport.config) && lastReport.config.length > 0) {
493
+ io.writeJson(files.configBaseline, { at: lastReport.generatedAt, entries: lastReport.config }).catch(() => {});
494
+ }
495
+ return { report: lastReport, text: renderReportText(lastReport) };
496
+ }
497
+ if (action === 'plan') return await hub.plan(payload.kind, payload);
498
+ if (action === 'execute') {
499
+ const out = await hub.execute(payload);
500
+ if (out && out.ok === true && out.recorded) {
501
+ // ③ keep the in-process recall log consistent with what was just written.
502
+ const log = await loadKnowledge();
503
+ log.push(out.recorded);
504
+ }
505
+ return out;
506
+ }
507
+ if (action === 'plans') return { ok: true, plans: hub.listPending() };
508
+ // v0.6.0 L2: one sentence → one intent. Read-only w.r.t. the system: it
509
+ // only decides WHICH card to open. Enriching the haystack (bundle list +
510
+ // current findings) happens here, not in the pure parser.
511
+ if (action === 'intent') {
512
+ const text = typeof payload.text === 'string' ? payload.text : '';
513
+ const bundles = [];
514
+ try {
515
+ const mgr = getService(ctx, 'pluginManager');
516
+ if (mgr && typeof mgr.listBundles === 'function') {
517
+ const list = await mgr.listBundles();
518
+ for (const b of Array.isArray(list) ? list : []) {
519
+ const n = b && (b.name || b.package);
520
+ if (n) bundles.push({ name: n, enabled: b.enabled !== false });
521
+ }
522
+ }
523
+ } catch { /* no bundle list → parser falls back to shape check */ }
524
+ const out = parseIntent(text, { bundles, report: lastReport });
525
+ return { ...out, text, bundlesKnown: bundles.length };
526
+ }
527
+ // v0.7.0: model-backed understanding. Read-only catalog for the picker; the
528
+ // catalog is cached in-process because listModels may hit a provider.
529
+ if (action === 'models') return await readCatalog();
530
+ // v0.9.1: 面板要的模型健康快照(不跑探针,只回目录 + 计划 + 已有缓存)。
531
+ if (action === 'model-now') {
532
+ const catalog = await readCatalog();
533
+ const settings = currentSettings();
534
+ const plan = planSentinel(catalog, sentinelOptions(settings));
535
+ const cached = await readProbeCache();
536
+ const verdicts = cached && Array.isArray(cached.verdicts) ? cached.verdicts : [];
537
+ return {
538
+ ok: true,
539
+ catalog, plan, verdicts,
540
+ fresh: cacheIsFresh(cached),
541
+ at: cached && cached.at ? cached.at : null,
542
+ summary: cached && cached.summary ? cached.summary : null,
543
+ default: catalog.default,
544
+ defaultVerdict: cached && cached.defaultVerdict ? cached.defaultVerdict : defaultVerdict(verdicts, catalog.default),
545
+ defaultBusyRounds: cached && Number.isFinite(cached.defaultBusyRounds) ? cached.defaultBusyRounds : 0,
546
+ suggest: cached && cached.suggest ? cached.suggest : null,
547
+ probeOnReport: settingValue(settings, 'model.probeOnReport'),
548
+ };
549
+ }
550
+ // 真跑一轮哨兵探针并写缓存。这是一次性动作,不改任何插件配置。
551
+ if (action === 'model-scan') return await runModelScan();
552
+ // One sentence through the chosen model. The model only TRANSLATES: its
553
+ // answer is normalized back into the same intent shape the rule parser
554
+ // returns, so the caller walks the identical plan→confirm→audit chain.
555
+ // A model that is unreachable or answers rubbish never becomes an action.
556
+ if (action === 'ask') {
557
+ const text = typeof payload.text === 'string' ? payload.text.trim() : '';
558
+ if (!text) return { ok: false, code: 'empty', error: '先输入一句话。' };
559
+ const bundles = [];
560
+ try {
561
+ const mgr = getService(ctx, 'pluginManager');
562
+ if (mgr && typeof mgr.listBundles === 'function') {
563
+ const list = await mgr.listBundles();
564
+ for (const b of Array.isArray(list) ? list : []) {
565
+ const n = b && (b.name || b.package);
566
+ if (n) bundles.push({ name: n, enabled: b.enabled !== false });
567
+ }
568
+ }
569
+ } catch { /* an unknown bundle list only narrows what the model may name */ }
570
+ try {
571
+ const raw = await runModel({ llm: getService(ctx, 'llm') }, {
572
+ provider: payload.provider,
573
+ model: payload.model,
574
+ prompt: text,
575
+ signal: AbortSignal.timeout(60_000),
576
+ });
577
+ const out = normalizeBrainOutput(raw.text, { bundles, report: lastReport });
578
+ return { ...out, text, bundlesKnown: bundles.length, modelChars: raw.text.length };
579
+ } catch (e) {
580
+ const code = e && e.code ? e.code : 'llm-failed';
581
+ return { ok: false, code, error: e && e.message ? e.message : String(e) };
582
+ }
583
+ }
584
+ // v0.4.4: read-only view of bundle enable state, so the enable card can offer
585
+ // real candidates (currently-disabled bundles) instead of guessing from a
586
+ // crash suspect list. Zero writes; missing service → degraded, never throws.
587
+ if (action === 'bundles') {
588
+ const out = { ok: true, disabled: [], enabled: [], available: false };
589
+ try {
590
+ const mgr = getService(ctx, 'pluginManager');
591
+ if (mgr && typeof mgr.listBundles === 'function') {
592
+ const list = await mgr.listBundles();
593
+ for (const b of Array.isArray(list) ? list : []) {
594
+ const n = b && (b.name || b.package);
595
+ if (!n) continue;
596
+ if (b.enabled === false) out.disabled.push(n); else out.enabled.push(n);
597
+ }
598
+ out.available = true;
599
+ }
600
+ } catch (e) {
601
+ out.ok = false;
602
+ out.error = e && e.message ? e.message : String(e);
603
+ }
604
+ return out;
605
+ }
606
+ if (action === 'audit') {
607
+ let auditLines = [];
608
+ try {
609
+ const raw = await fsp.readFile(files.audit, 'utf8');
610
+ const all = raw.split('\n').filter(Boolean).slice(-20);
611
+ for (const line of all) {
612
+ try { auditLines.push(JSON.parse(line)); } catch { auditLines.push({ raw: line.slice(0, 200) }); }
613
+ }
614
+ } catch { auditLines = []; }
615
+ let trend = [];
616
+ try { const t = await io.readJson(files.trend); if (Array.isArray(t)) trend = t.slice(-20); } catch { trend = []; }
617
+ return { ok: true, audit: auditLines, trend };
618
+ }
619
+ return { ok: false, error: `unknown action: ${String(action)}` };
620
+ }
621
+
622
+ // 1) Event subscription — best-effort, never throws, no disk IO in the callback.
623
+ try {
624
+ ctx.effect(() => {
625
+ try {
626
+ if (typeof ctx.on === 'function') return ctx.on('plugin-manager/changed', (payload) => changes.push(payload));
627
+ } catch { /* event bus unavailable */ }
628
+ return undefined;
629
+ }, 'dsh-steward: events');
630
+ } catch { /* effect registration failed — ignore */ }
631
+
632
+ // 1b) v0.4 proactive exit, part 1: one lightweight checkup shortly after boot,
633
+ // so the FIRST session and the FIRST panel open already have a fresh report —
634
+ // without waiting for a human to ask. Read-only, fail-soft, never throws.
635
+ try {
636
+ const bootTimer = setTimeout(() => {
637
+ run('report', {}).catch(() => {});
638
+ }, 45_000);
639
+ if (typeof bootTimer.unref === 'function') bootTimer.unref();
640
+ } catch { /* timer unavailable */ }
641
+
642
+ // 1c) v0.4 proactive exit, part 2: systemPrompt.section — PROVEN shape from
643
+ // @changfenhuang/dsh-genui/lib/index.js:7772 ({name, order, text}, order via
644
+ // getSectionOrder). Guarded: if the service or getSectionOrder is missing we
645
+ // skip entirely (探针先行 red line: never guess, never endanger boot).
646
+ try {
647
+ const sp = getService(ctx, 'systemPrompt');
648
+ if (sp && typeof sp.section === 'function' && typeof sp.getSectionOrder === 'function') {
649
+ ctx.effect(() => {
650
+ try {
651
+ return sp.section({
652
+ name: 'dsh-steward:guardian',
653
+ order: sp.getSectionOrder('STRUCTURED_OUTPUT'),
654
+ text: 'DSH steward plugin is active. When the user reports the app crashed / failed to boot / a plugin broke, call dsh_steward_report (read-only) FIRST and follow its named suspect instead of guessing. Authorized fixes go through dsh_steward_act: plan first, then execute with planHash + confirm:true. For encrypted backups (kind=backup) NEVER ask for or type the password into chat — the user must enter it in the steward panel themselves.',
655
+ });
656
+ } catch (e) {
657
+ try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] systemPrompt section skipped: ' + String(e && e.message || e)); } catch { /* noop */ }
658
+ return undefined;
659
+ }
660
+ }, 'dsh-steward: prompt section');
661
+ }
662
+ } catch { /* systemPrompt lookup failed — passive mode, panel red dot only */ }
663
+
664
+ // 2) Host prefix route (panel + same-origin callers).
665
+ try {
666
+ const webServer = getService(ctx, 'webServer');
667
+ if (webServer && typeof webServer.register === 'function') {
668
+ ctx.effect(() => {
669
+ try {
670
+ return webServer.register({
671
+ kind: 'prefix',
672
+ path: ROUTE_PREFIX,
673
+ handler: async (req, res) => {
674
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'POST required' }); return; }
675
+ if (!sameOrigin(req)) { sendJson(res, 403, { ok: false, error: 'forbidden origin' }); return; }
676
+ try {
677
+ const body = await readBody(req);
678
+ if (!body.parsed) { sendJson(res, 400, { ok: false, error: 'bad request body: ' + body.reason }); return; }
679
+ const payload = body.value;
680
+ const action = typeof payload.action === 'string' ? payload.action : '';
681
+ // No default action: an omitted action is a caller mistake, not a
682
+ // request for a full scan.
683
+ if (action === '') { sendJson(res, 400, { ok: false, error: 'missing action (send {"action":"report"} to run a checkup)' }); return; }
684
+ const result = await run(action, payload);
685
+ sendJson(res, 200, { ok: result.ok !== false, ...result });
686
+ } catch (error) {
687
+ sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
688
+ }
689
+ },
690
+ });
691
+ } catch (e) {
692
+ // Contained: a route registration failure must NOT fail the boot fiber.
693
+ try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] route registration skipped: ' + String(e && e.message || e)); } catch { /* noop */ }
694
+ return undefined;
695
+ }
696
+ }, 'dsh-steward: routes');
697
+ }
698
+ } catch { /* webServer lookup failed — ignore */ }
699
+
700
+ // 3) Read-only health tool (kept from v0.1).
701
+ try {
702
+ const tools = getService(ctx, 'tools');
703
+ if (tools && typeof tools.register === 'function') {
704
+ ctx.effect(() => {
705
+ try {
706
+ return tools.register({
707
+ name: 'dsh_steward_report',
708
+ description: 'Read-only DeepSeek Harness health check. Diagnoses the last web-boot crash (names the suspect plugin), flags cordis.patch.yml .bak backups and recent plugin changes, and points at existing backup/disk/knowledge capabilities. Never deletes, never runs elevated commands, never touches the boot path.',
709
+ parameters: {
710
+ type: 'object',
711
+ properties: {
712
+ action: { type: 'string', enum: ['report', 'ping'], description: 'report (default) returns the full health report; ping is a liveness probe.' },
713
+ },
714
+ required: [],
715
+ additionalProperties: false,
716
+ },
717
+ output: {
718
+ schema: { type: 'string' },
719
+ render: (args, value) => [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
720
+ },
721
+ timeoutMs: 30_000,
722
+ isConcurrencySafe: () => true,
723
+ presentCall: (args) => ({ card: 'generic', title: '智能管家 · 体检', kind: 'dsh-steward', rawInput: args }),
724
+ async execute(args) {
725
+ const action = args && typeof args.action === 'string' ? args.action : 'report';
726
+ const result = await run(action, {});
727
+ return result.text || JSON.stringify(result, null, 2);
728
+ },
729
+ });
730
+ } catch (e) {
731
+ try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] tool registration skipped: ' + String(e && e.message || e)); } catch { /* noop */ }
732
+ return undefined;
733
+ }
734
+ }, 'dsh-steward: tool');
735
+
736
+ // 4) v0.2 action tool — plan is zero-write; execute needs confirm + fingerprint.
737
+ ctx.effect(() => {
738
+ try {
739
+ return tools.register({
740
+ name: 'dsh_steward_act',
741
+ description: 'Authorized steward actions (plan first, then execute with the returned planHash + confirm:true). Kinds: "clean" (safe-level rebuildable caches via dsh-disk-cleaner; withCaution=true also includes caution-level targets), "backup" (encrypted DCA1 export of volatile data: sessions/pluginData/pluginAssets via config-manager; the password must NOT be typed in chat — ask the user to run this card in the steward panel), "disable" (stop the plugin named by the CURRENT boot-crash evidence; attribution gate refuses stale crashes; reversible, protected by the host #575 enable guard), "enable" (its counterpart: re-enable a bundle by name), "knowledge" (append this checkup to the local trend log of the steward), "ack"/"unack" (mute or unmute a report finding by id; reversible, evidence kept), "model-default" (switch the default model of the host to provider/model — only that one selection changes, no plugin config is touched, fully reversible). Never touches the boot path, never runs elevated commands, never restores whole old configs, never edits the settings of another plugin.',
742
+ parameters: {
743
+ type: 'object',
744
+ properties: {
745
+ action: { type: 'string', enum: ['plan', 'execute', 'plans'], description: 'plan = zero-write preview; execute = needs planHash + confirm:true; plans = list pending.' },
746
+ kind: { type: 'string', enum: ['clean', 'backup', 'disable', 'enable', 'knowledge', 'ack', 'unack', 'model-default'], description: 'which action to plan (required for action=plan).' },
747
+ plugin: { type: 'string', description: 'for kind=disable/enable: exact bundle package name (for disable it must be the suspect named by the current boot-crash finding).' },
748
+ withCaution: { type: 'boolean', description: 'for kind=clean: also include caution-level targets (may be in-use data; defaults false).' },
749
+ id: { type: 'string', description: 'for kind=ack/unack: the finding id from the last report.' },
750
+ provider: { type: 'string', description: 'for kind=model-default: LLM provider id (e.g. "our-free-model").' },
751
+ model: { type: 'string', description: 'for kind=model-default: model id within that provider.' },
752
+ only: { type: 'array', items: { type: 'string' }, description: 'for kind=backup: sections to include; default ["sessions","pluginData","pluginAssets"].' },
753
+ planHash: { type: 'string', description: 'for action=execute: the hash returned by the matching plan.' },
754
+ confirm: { type: 'boolean', description: 'for action=execute: must be true (the authorization gate).' },
755
+ },
756
+ required: ['action'],
757
+ additionalProperties: false,
758
+ },
759
+ output: {
760
+ schema: { type: 'string' },
761
+ render: (args, value) => [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
762
+ },
763
+ timeoutMs: 180_000,
764
+ isConcurrencySafe: () => false,
765
+ presentCall: (args) => ({ card: 'generic', title: `智能管家 · 动作 ${String((args && args.kind) || (args && args.action) || '')}`, kind: 'dsh-steward', rawInput: args }),
766
+ async execute(args) {
767
+ const result = await run(args && args.action, args || {});
768
+ return JSON.stringify(result, null, 2);
769
+ },
770
+ });
771
+ } catch (e) {
772
+ try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] act tool registration skipped: ' + String(e && e.message || e)); } catch { /* noop */ }
773
+ return undefined;
774
+ }
775
+ }, 'dsh-steward: act tool');
776
+ }
777
+ } catch { /* tools lookup failed — ignore */ }
778
+ }
779
+
780
+ export default apply;