@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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * dsh-steward · settings — v0.8.0
3
+ *
4
+ * 用户可改的设置项。设计原则(三条,写在这里是为了以后不要走偏):
5
+ *
6
+ * 1. **只开放「判据的松紧」,不开放「判据的有无」。** 用户可以把「多久没备份算
7
+ * 陈旧」从 7 天改成 3 天,但不能把备份体检关掉——否则管家会退化成只报好消息
8
+ * 的东西,那比没有更危险。
9
+ * 2. **每一项都要能夹紧。** 数字不是自由文本:超出范围一律 clamp 到边界并在返回
10
+ * 里说明被夹了,而不是静默接受一个会让检测失真的值(比如 0 天)。
11
+ * 3. **默认值就是推荐值。** 没配过的用户拿到的行为和 v0.7.8 完全一致;改设置是
12
+ * 可选动作,不是配置负担。
13
+ *
14
+ * 这份 spec 同时是「设置页渲染」的数据源:host 把 SPEC 发给 client,client 照着
15
+ * 画控件。这样标签、单位、范围、解释只有一份,不会出现界面和逻辑两套口径。
16
+ */
17
+
18
+ const SETTINGS_VERSION = 1;
19
+
20
+ /**
21
+ * 一个 spec 条目:
22
+ * key 扁平键(点号分组,落盘时按组存)
23
+ * group 设置页分组
24
+ * label 界面上的名字
25
+ * unit 单位后缀
26
+ * min/max 合法区间(clamp 用)
27
+ * def 默认值
28
+ * why 为什么有这一项 / 调大调小分别意味着什么(界面上直接显示,不藏文档)
29
+ */
30
+ const SPEC = [
31
+ // ── 静音有效期 ────────────────────────────────────────────────
32
+ {
33
+ key: 'ttl.plugin-updates', group: '静音有效期', label: '插件更新提醒',
34
+ unit: '天', min: 1, max: 180, def: 14,
35
+ why: '「有新版本可用」你点了「已知晓」之后,隔多久再提。新版本来得频繁,所以默认只有 14 天;嫌吵可以拉长,但拉太长等于关掉这条提醒。',
36
+ },
37
+ {
38
+ key: 'ttl.workspace-inventory', group: '静音有效期', label: '工作区体积提醒',
39
+ unit: '天', min: 1, max: 365, def: 30,
40
+ why: '会话数据明显长胖时的提醒。长胖是渐进的,一个月提醒一次够用。',
41
+ },
42
+ {
43
+ key: 'ttl.data-at-risk', group: '静音有效期', label: '易失数据未备份提醒',
44
+ unit: '天', min: 1, max: 365, def: 42,
45
+ why: '这是结构性缺口(sessions 永远不在默认备份里),不是一次性事件。默认 6 周提一次,太短会变成唠叨。',
46
+ },
47
+ {
48
+ key: 'ttl.backup-freshness', group: '静音有效期', label: '备份陈旧提醒',
49
+ unit: '天', min: 1, max: 90, def: 7,
50
+ why: '备份这件事的价值全在新鲜度上。默认一周,不建议拉长超过一个月。',
51
+ },
52
+ {
53
+ key: 'ttl.compat-check', group: '静音有效期', label: '升级兼容提醒',
54
+ unit: '天', min: 1, max: 180, def: 30,
55
+ why: '宿主升级后这条会重新变真,所以有效期不宜太长,否则升级完看不到。',
56
+ },
57
+ {
58
+ key: 'ttl.default', group: '静音有效期', label: '其它条目(缺省)',
59
+ unit: '天', min: 1, max: 365, def: 90,
60
+ why: '没单独列出的发现项用这个值。',
61
+ },
62
+
63
+ // ── 判定阈值 ──────────────────────────────────────────────────
64
+ {
65
+ key: 'threshold.growthWarnMb', group: '判定阈值', label: '工作区增长算「长胖」的净增',
66
+ unit: 'MB', min: 1, max: 2048, def: 20,
67
+ why: '比上一次体检净增超过这个数才升 warning。调小 = 更敏感(也更容易吵),调大 = 只有真的暴涨才说话。',
68
+ },
69
+ {
70
+ key: 'threshold.backupStaleDays', group: '判定阈值', label: '备份多久算「陈旧」',
71
+ unit: '天', min: 1, max: 365, def: 7,
72
+ why: '最新一份导出超过这个天数就升 warning。和上面那条「备份陈旧提醒」是两个东西:这条决定**什么时候算问题**,那条决定**静音多久后再提**。',
73
+ },
74
+ {
75
+ key: 'threshold.driftShrinkLines', group: '判定阈值', label: '配置文件少这么多行算「被吞」',
76
+ unit: '行', min: 5, max: 5000, def: 20,
77
+ why: 'cordis.patch.yml / package.json 比基线少超过这么多行,就当作 sanitizeProfile 写默认 stub 的痕迹。正常编辑删几行不该报警,所以别调到 5 以下。',
78
+ },
79
+
80
+ // ── 采集 ──────────────────────────────────────────────────────
81
+ {
82
+ key: 'collect.maxLogs', group: '采集', label: '最多读几份崩溃日志',
83
+ unit: '份', min: 1, max: 200, def: 50,
84
+ why: '崩溃日志通常只有几份。调大只影响极端情况下的读取量,不影响结论。',
85
+ },
86
+ {
87
+ key: 'collect.maxBytesPerLog', group: '采集', label: '单份日志读取上限',
88
+ unit: 'KB', min: 64, max: 16384, def: 2048,
89
+ why: '超过这个大小的日志会被跳过并记进 errors(防止一份巨大日志卡住体检)。默认 2 MB 已远超正常崩溃日志(本机最大 61 KB)。',
90
+ },
91
+
92
+ // ── 界面 ──────────────────────────────────────────────────────
93
+ {
94
+ key: 'ui.autoScanMinutes', group: '界面', label: '面板缓存的自动刷新间隔',
95
+ unit: '分钟', min: 1, max: 240, def: 10,
96
+ why: '切回面板时,缓存超过这个时间就在后台重新体检一次(不会打断你)。设为 1 分钟 = 几乎每次都刷;设大 = 更省,但看到的可能是几分钟前的样子。',
97
+ },
98
+ {
99
+ key: 'ui.entry', group: '界面', label: '面板入口', type: 'choice', def: 'both',
100
+ options: [
101
+ { value: 'both', label: '左侧栏 + 右侧栏' },
102
+ { value: 'left', label: '仅左侧栏' },
103
+ { value: 'right', label: '仅右侧栏' },
104
+ ],
105
+ why: '管家面板挂在哪。只想要一个入口就用「仅左侧栏」或「仅右侧栏」,省得两处重复。故意不提供「完全不显示」——那会让你再也点不到这里改回来,属于把自己锁在门外。保存后立刻生效,不用刷新页面。',
106
+ },
107
+ {
108
+ key: 'ui.toast', group: '界面', label: '严重问题弹窗', type: 'choice', def: 'critical',
109
+ options: [
110
+ { value: 'critical', label: '只弹「严重」' },
111
+ { value: 'warning', label: '「严重」和「留意」都弹' },
112
+ { value: 'off', label: '不弹' },
113
+ ],
114
+ why: '屏幕右下角临时弹窗。这是**通知渠道**的开关,不是检测的开关——关掉它,体检照做、报告照写、静音与归因门一律不变,只是不主动弹出来。默认只弹「严重」:不打扰是设计目标之一,天天弹的东西会被无视,真出事那天也会被无视。',
115
+ },
116
+ {
117
+ key: 'ui.toastSeconds', group: '界面', label: '弹窗停留时长',
118
+ unit: '秒', min: 3, max: 60, def: 12,
119
+ why: '弹窗自动消失的时间。太短看不清,太长会挡视线;鼠标移上去会暂停计时。',
120
+ },
121
+
122
+ // ── 模型巡检 ──────────────────────────────────────────────────
123
+ {
124
+ key: 'model.probeOnReport', group: '模型巡检', label: '体检时顺便探一下默认模型', type: 'choice', def: 'cached',
125
+ options: [
126
+ { value: 'cached', label: '用缓存(最快)' },
127
+ { value: 'always', label: '每次都探' },
128
+ { value: 'off', label: '不探' },
129
+ ],
130
+ why: '默认模型是你此刻真正在用的那一个,它挂了比任何配置问题都直接。默认「用缓存」:一次体检用上次的探测结果(3 小时内有效),不为了体检去花你的配额。「每次都探」会把每次体检变成一次真实模型调用。',
131
+ },
132
+ {
133
+ key: 'model.perProvider', group: '模型巡检', label: '每个服务商探几个模型',
134
+ unit: '个', min: 0, max: 5, def: 2,
135
+ why: '只探每个服务商目录里的前几个(那是它们自己的推荐顺序),不探全量。设 0 = 只探默认模型自己。',
136
+ },
137
+ {
138
+ key: 'model.maxTotal', group: '模型巡检', label: '一轮最多探几个模型',
139
+ unit: '个', min: 1, max: 30, def: 12,
140
+ why: '硬上限。每个探针是一次真实模型调用,免费额度不是无限的。',
141
+ },
142
+ ];
143
+
144
+ /** 扁平键 → 嵌套对象,例如 ttl.plugin-updates → { ttl: { 'plugin-updates': 14 } } */
145
+ function setDeep(target, flatKey, value) {
146
+ const parts = flatKey.split('.');
147
+ let cursor = target;
148
+ for (let i = 0; i < parts.length - 1; i += 1) {
149
+ const k = parts[i];
150
+ if (!cursor[k] || typeof cursor[k] !== 'object') cursor[k] = {};
151
+ cursor = cursor[k];
152
+ }
153
+ cursor[parts[parts.length - 1]] = value;
154
+ return target;
155
+ }
156
+
157
+ function getDeep(source, flatKey, fallback) {
158
+ const parts = flatKey.split('.');
159
+ let cursor = source;
160
+ for (let i = 0; i < parts.length; i += 1) {
161
+ if (!cursor || typeof cursor !== 'object') return fallback;
162
+ cursor = cursor[parts[i]];
163
+ }
164
+ return cursor === undefined || cursor === null ? fallback : cursor;
165
+ }
166
+
167
+ /** 默认设置(扁平键 → 值),每次返回新对象,调用方可随意改。 */
168
+ function defaults() {
169
+ const out = {};
170
+ for (const row of SPEC) setDeep(out, row.key, row.def);
171
+ return out;
172
+ }
173
+
174
+ /** 扁平键 → 值;缺省或未在 spec 里登记的键一律回落默认。 */
175
+ function flatten(settings) {
176
+ const out = {};
177
+ for (const row of SPEC) setDeep(out, row.key, getDeep(settings, row.key, row.def));
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * 校验并夹紧一份用户输入。
183
+ * 返回:
184
+ * ok 是否至少接受了一项(全被丢弃时 false)
185
+ * settings 规范化后的嵌套设置
186
+ * applied 真正被接受的项(扁平键 → 值)
187
+ * clamped 被夹紧的项 [{key, from, to}]
188
+ * rejected 被拒的项 [{key, reason}]
189
+ */
190
+ function normalize(input) {
191
+ const applied = {};
192
+ const clamped = [];
193
+ const rejected = [];
194
+ const source = input && typeof input === 'object' ? input : {};
195
+ for (const row of SPEC) {
196
+ // 两种形态都接受:嵌套 {ttl:{'plugin-updates':7}} 和扁平 {'ttl.plugin-updates':7}。
197
+ // 面板提交的是扁平键(每个输入框知道自己那一个键),外部脚本常提交嵌套。
198
+ // 只认一种形态会让另一种静默变成「没有收到任何可保存的项」。
199
+ let raw = getDeep(source, row.key, undefined);
200
+ if (raw === undefined && Object.prototype.hasOwnProperty.call(source, row.key)) {
201
+ raw = source[row.key];
202
+ }
203
+ if (raw === undefined) continue; // 没提到就不动,保留原值
204
+ if (raw === null || raw === '') { // 显式清空 = 回默认
205
+ applied[row.key] = row.def;
206
+ continue;
207
+ }
208
+ if (row.type === 'choice') {
209
+ const pick = String(raw);
210
+ if (!row.options.some((o) => o.value === pick)) {
211
+ rejected.push({ key: row.key, reason: '不是可选值' });
212
+ continue;
213
+ }
214
+ applied[row.key] = pick;
215
+ continue;
216
+ }
217
+ const num = typeof raw === 'number' ? raw : Number(String(raw).trim());
218
+ if (!Number.isFinite(num)) {
219
+ rejected.push({ key: row.key, reason: '不是数字' });
220
+ continue;
221
+ }
222
+ const rounded = Math.round(num);
223
+ if (rounded < row.min) {
224
+ clamped.push({ key: row.key, from: rounded, to: row.min });
225
+ applied[row.key] = row.min;
226
+ } else if (rounded > row.max) {
227
+ clamped.push({ key: row.key, from: rounded, to: row.max });
228
+ applied[row.key] = row.max;
229
+ } else {
230
+ applied[row.key] = rounded;
231
+ }
232
+ }
233
+ const settings = defaults();
234
+ for (const [key, value] of Object.entries(applied)) setDeep(settings, key, value);
235
+ return { ok: Object.keys(applied).length > 0, settings, applied, clamped, rejected };
236
+ }
237
+
238
+ /** 把磁盘上存的设置与默认值合并(存的内容可能来自旧版本,缺项用默认)。 */
239
+ function merge(saved) {
240
+ const base = defaults();
241
+ if (!saved || typeof saved !== 'object') return base;
242
+ const { settings } = normalize(saved);
243
+ return settings;
244
+ }
245
+
246
+ /** 单值读取,带边界保护 —— 调用方不用自己记范围。 */
247
+ function value(settings, flatKey) {
248
+ const row = SPEC.find((r) => r.key === flatKey);
249
+ if (!row) return undefined;
250
+ const raw = getDeep(settings, flatKey, row.def);
251
+ if (row.type === 'choice') {
252
+ const pick = typeof raw === 'string' ? raw : row.def;
253
+ return row.options.some((o) => o.value === pick) ? pick : row.def;
254
+ }
255
+ const num = Number(raw);
256
+ if (!Number.isFinite(num)) return row.def;
257
+ return Math.min(row.max, Math.max(row.min, Math.round(num)));
258
+ }
259
+
260
+ /** 静音有效期(天);settings 里 ttl.<id> 优先,其次 ttl.default。 */
261
+ function ackTtlDays(settings, findingId) {
262
+ const perItem = value(settings, 'ttl.' + findingId);
263
+ if (perItem !== undefined) return perItem;
264
+ return value(settings, 'ttl.default');
265
+ }
266
+
267
+ /** 给设置页用的可渲染描述:分组 + 每项的当前值与范围。 */
268
+ function describe(settings) {
269
+ const groups = [];
270
+ for (const row of SPEC) {
271
+ let group = groups.find((g) => g.name === row.group);
272
+ if (!group) { group = { name: row.group, items: [] }; groups.push(group); }
273
+ group.items.push({
274
+ key: row.key, label: row.label, unit: row.unit, min: row.min, max: row.max,
275
+ def: row.def, why: row.why, value: value(settings, row.key),
276
+ isDefault: value(settings, row.key) === row.def,
277
+ ...(row.type === 'choice' ? { type: 'choice', options: row.options } : {}),
278
+ });
279
+ }
280
+ return groups;
281
+ }
282
+
283
+ export {
284
+ SETTINGS_VERSION, SPEC, defaults, flatten, normalize, merge, value,
285
+ ackTtlDays, describe,
286
+ };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Steward · checkup + authorized actions",
4
+ "description": "Diagnoses the last boot crash and names the real cause, checks backup freshness, config drift, workspace growth, plugin update direction, host-version compatibility, and model availability. It never deletes files, never runs elevated commands, and never touches the boot path; every action shows you a plan first."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "智能管家 · 体检 + 授权执行",
4
+ "description": "启动后诊断上次崩溃并点名病因、检查备份新鲜度、配置漂移、工作区增长、插件更新方向与宿主版本兼容、模型可用性;不删文件、不代跑提权命令、不改启动流程,动手前一律先给你看计划。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@sidady/dsh-steward",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.12",
4
+ "type": "module",
5
+ "description": "Guardian for DeepSeek Harness: a read-only health check (boot crashes, backup freshness, config drift, workspace growth, plugin updates, host-version compat, model availability) plus an explicitly authorized action layer (plan → single-use fingerprint → confirm → audit) covering cache cleaning, encrypted volatile-data backups, plugin enable/disable, knowledge capture, finding mute, and switching the default model.",
6
+ "keywords": [
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "health-check",
10
+ "steward",
11
+ "read-only",
12
+ "authorized-actions",
13
+ "fail-soft"
14
+ ],
15
+ "exports": {
16
+ ".": "./lib/index.js",
17
+ "./client": "./lib/client.js",
18
+ "./package.json": "./package.json",
19
+ "./locale/*.json": "./locale/*.json",
20
+ "./icon": "./icon.svg"
21
+ },
22
+ "files": [
23
+ "lib/index.js",
24
+ "lib/client.js",
25
+ "lib/actions.js",
26
+ "lib/report.js",
27
+ "lib/intent.js",
28
+ "lib/settings.js",
29
+ "lib/brain.js",
30
+ "lib/models.js",
31
+ "cordis.patch.yml",
32
+ "locale/*.json",
33
+ "icon.svg",
34
+ "README.md",
35
+ "ACCEPTANCE.md",
36
+ "LICENSE",
37
+ "smoke.mjs"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22"
41
+ },
42
+ "icon": "./icon.svg",
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "immediately": true,
50
+ "inject": [
51
+ "@deepseek-ai/dsh-client-ui-slots"
52
+ ]
53
+ }
54
+ },
55
+ "license": "MIT",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/stallenlu/dsh-steward.git"
59
+ },
60
+ "homepage": "https://github.com/stallenlu/dsh-steward#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/stallenlu/dsh-steward/issues"
63
+ },
64
+ "author": "sidady"
65
+ }