@hyzyn/dsh-safe 0.9.0 → 0.10.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.en.md CHANGED
@@ -47,7 +47,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
47
47
  | `dsh-safe list [--profile <name>] [--json]` | show quarantined plugins (`--json` outputs structured JSON; defaults to all profiles) |
48
48
  | `dsh-safe doctor` | environment check: versions, DSH_HOME, profiles, ledger, patch health |
49
49
  | `dsh-safe restore [--profile <name>] (--id <id> \| --all) [--dry-run]` | re-enable auto-disabled plugins (omit `--profile` to cover every profile in the ledger) |
50
- | `dsh-safe explain [--profile <name> \| --file <path>]` | interpret a boot failure with AI: defaults to the last failure record, `--profile` boots the profile live, `--file`/stdin read any log (needs `DSH_SAFE_AI_KEY`) |
50
+ | `dsh-safe explain [id] [--profile <name> \| --file <path>]` | interpret failure info with AI: an `id` interprets that quarantine record (with repair advice); defaults to the last failure log, falling back to the ledger; `--file`/stdin read any log (needs `DSH_SAFE_AI_KEY`) |
51
51
  | `dsh-safe repair [id] [--profile <name>] [--to <version>] [-y] [--dry-run]` | reinstall/upgrade a quarantined plugin and auto-restore it (module-resolution failures only; installs via `dsh plugin`'s pnpm channel) |
52
52
  | `dsh-safe help` (`-h` / `--help`) | show help |
53
53
  | `dsh-safe --version` (`-V`) | show version |
@@ -99,7 +99,7 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
99
99
 
100
100
  Enabled by setting `DSH_SAFE_AI_KEY` (defaults to DeepSeek; OpenAI-compatible — swap providers via `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL`):
101
101
 
102
- - **`dsh-safe explain [--profile <name> | --file <path>]`**: interprets the most recent boot failure by default (stderr is persisted to `$DSH_HOME/dsh-safe/last-failure-<profile>.log` on every failed wrapped boot, for humans too); `--profile` boots that profile live and interprets (60s timeout); `--file`/stdin read any log. Strictly read-only — never touches the patch or ledger.
102
+ - **`dsh-safe explain [id] [--profile <name> | --file <path>]`**: interprets the failure info dsh-safe knows — an `id` interprets that quarantine record and suggests `repair`; by default it interprets the most recent boot failure (stderr persisted to `$DSH_HOME/dsh-safe/last-failure-<profile>.log`), falling back to the ledger; `--file`/stdin read any log. Strictly read-only — never touches the patch or ledger.
103
103
  - **AI fallback identification** (`DSH_SAFE_AI_RECOVER=1`): when the regex signatures can't identify the broken plugin (e.g. after a dsh upgrade changes formats), the AI picks the culprit from the stderr — **its output must pass the exact same validation pipeline** (match against real patch rows, first-party protection, dry-run preview); unmatched picks are passed through as before. Only invoked on startup failure.
104
104
  - Privacy: home paths are redacted to `~` before sending; any AI failure degrades silently.
105
105
 
package/README.md CHANGED
@@ -47,7 +47,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
47
47
  | `dsh-safe list [--profile <名>] [--json]` | 查看隔离名单(`--json` 输出结构化 JSON,缺省全部 profile) |
48
48
  | `dsh-safe doctor` | 环境体检:版本、DSH_HOME、profiles、台账、各 patch 健康度 |
49
49
  | `dsh-safe restore [--profile <名>] (--id <id> \| --all) [--dry-run]` | 恢复被自动禁用的插件(省略 `--profile` 时遍历台账全部 profile) |
50
- | `dsh-safe explain [--profile <名> \| --file <路径>]` | 用 AI 解读启动失败:默认解读最近一次失败记录,`--profile` 现场试启并解读,`--file`/stdin 读任意日志(需 `DSH_SAFE_AI_KEY`) |
50
+ | `dsh-safe explain [id] [--profile <名> \| --file <路径>]` | 用 AI 解读失败信息:指定 `id` 解读该条隔离记录(给 repair 建议);默认解读最近一次失败日志,无日志则解读隔离台账;`--file`/stdin 读任意日志(需 `DSH_SAFE_AI_KEY`) |
51
51
  | `dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限模块解析失败类;经 `dsh plugin` 的 pnpm 通道安装) |
52
52
  | `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
53
53
  | `dsh-safe --version`(`-V`) | 显示版本 |
@@ -99,7 +99,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
99
99
 
100
100
  设置 `DSH_SAFE_AI_KEY` 后启用(默认对接 DeepSeek,OpenAI 兼容接口,可用 `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL` 换任何兼容服务):
101
101
 
102
- - **`dsh-safe explain [--profile <名> | --file <路径>] [-- <dsh 参数…>]`**:默认解读最近一次启动失败(失败时 stderr 自动持久化到 `$DSH_HOME/dsh-safe/last-failure-<profile>.log`,人也可直接翻阅);`--profile` 现场试启该 profile 并解读(60 秒超时,`--` 之后可透传 dsh 启动参数,如 `-- --port 3084`);`--file`/stdin 读任意日志。纯只读,不碰 patch/台账。未知参数严格报错,绝不静默吞。
102
+ - **`dsh-safe explain [id] [--profile <名> | --file <路径>]`**:解读 dsh-safe 所知的失败信息——指定 `id` 时解读该条隔离记录并给出 `repair` 建议;默认解读最近一次启动失败(stderr 自动持久化到 `$DSH_HOME/dsh-safe/last-failure-<profile>.log`);无日志时解读隔离台账;`--file`/stdin 读任意日志。纯只读,不碰 patch/台账。
103
103
  - **AI 兜底识别**(`DSH_SAFE_AI_RECOVER=1`):正则特征识别不出坏插件时(如 dsh 升级换格式),让 AI 从 stderr 里挑元凶——**结果必须仍走同一验证管线**(对照真实 patch 行、第一方保护、dry-run 预览),命中不了照旧透传。仅在启动失败时调用。
104
104
  - 隐私:发送前 home 路径脱敏为 `~`;AI 任何失败都静默降级。
105
105
 
package/lib/ai.js CHANGED
@@ -16,6 +16,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'
16
16
  import { homedir } from 'node:os'
17
17
  import { join } from 'node:path'
18
18
  import { dshHome, resolveDshSpawnTarget } from './dshpaths.js'
19
+ import { loadLedger } from './quarantine.js'
19
20
  import { getLocale, t } from './i18n.js'
20
21
 
21
22
  export const aiEnabled = () => Boolean(process.env.DSH_SAFE_AI_KEY)
@@ -57,14 +58,14 @@ async function chat(messages, { timeoutMs = 30_000 } = {}) {
57
58
  * @returns {Promise<string | null>}
58
59
  */
59
60
  export async function explainFailure(stderr) {
60
- const lang = getLocale() === 'zh' ? '中文' : 'English'
61
+ const zh = getLocale() === 'zh'
61
62
  const prompt = [
62
- getLocale() === 'zh'
63
- ? `以下是一段 dsh(DeepSeek Harness)启动失败时捕获的 stderr。请用中文回答,控制在 200 字内:`
64
- : `The following stderr was captured from a failed dsh (DeepSeek Harness) boot. Answer in English, within 200 words:`,
65
- `1) ${getLocale() === 'zh' ? '一句话说明最可能的失败原因;' : 'One sentence on the most likely cause;'}`,
66
- `2) ${getLocale() === 'zh' ? '指出可疑的插件包名或配置项;' : 'Point out the suspicious plugin package or config;'}`,
67
- `3) ${getLocale() === 'zh' ? '给出 1-3 条具体修复建议(命令或配置修改)。不要编造不存在的插件。' : 'Give 1-3 concrete fixes (commands or config changes). Do not invent plugins that are not present.'}`,
63
+ zh
64
+ ? '以下是一段 dsh(DeepSeek Harness)启动失败时捕获的 stderr。请用中文回答,控制在 200 字内:'
65
+ : 'The following stderr was captured from a failed dsh (DeepSeek Harness) boot. Answer in English, within 200 words:',
66
+ `1) ${zh ? '一句话说明最可能的失败原因;' : 'One sentence on the most likely cause;'}`,
67
+ `2) ${zh ? '指出可疑的插件包名或配置项;' : 'Point out the suspicious plugin package or config;'}`,
68
+ `3) ${zh ? '给出 1-3 条具体修复建议(命令或配置修改)。不要编造不存在的插件。' : 'Give 1-3 concrete fixes (commands or config changes). Do not invent plugins that are not present.'}`,
68
69
  '',
69
70
  '--- stderr ---',
70
71
  redact(stderr),
@@ -109,17 +110,23 @@ export async function detectFailureWithAI(stderr, knownRows) {
109
110
  }
110
111
  }
111
112
 
112
- /** 试启一个 profile 并捕获 stderr(60s 超时;超时留下的部分 stderr 也可解读)。 */
113
- function bootProfileForExplain(profile, extraArgs, { spawn }) {
114
- const target = resolveDshSpawnTarget('dsh')
115
- const { status, stderr } = spawn(target.file, [...target.prefix, '--profile', profile, ...extraArgs], {
116
- stdio: ['ignore', 'ignore', 'pipe'],
117
- env: process.env,
118
- shell: target.shell,
119
- timeout: 60_000,
120
- encoding: 'utf8',
121
- })
122
- return { code: status ?? 'timeout', stderr: stderr ?? '' }
113
+ /** 解读被隔离插件记录(单条或多条):失败原因分析 + 现在该怎么办的建议。 */
114
+ async function interpretLedgerEntries(entries) {
115
+ const zh = getLocale() === 'zh'
116
+ const rows = entries.map(
117
+ (e) => `- id: ${e.id} ${zh ? '包名' : 'package'}: ${e.name ?? '-'} ${zh ? '隔离于' : 'quarantined at'}: ${e.quarantinedAt ?? '-'} ${zh ? '原因' : 'reason'}: ${redact(e.reason ?? '-')}`,
118
+ )
119
+ const prompt = [
120
+ zh
121
+ ? '以下是 dsh-safe(DeepSeek Harness 的启动保险丝)自动隔离的插件记录:启动失败时对应 patch 行被自动禁用。请用中文回答,控制在 200 字内:'
122
+ : 'The following plugins were auto-quarantined by dsh-safe (a startup fuse for DeepSeek Harness): their patch rows were auto-disabled after boot failures. Answer in English, within 200 words:',
123
+ `1) ${zh ? '逐条说明当时最可能的失败原因(模块解析失败 / 配置或代码错误 / 依赖等待);' : 'Per record, the most likely cause of failure (module resolution / config or code error / dependency wait);'}`,
124
+ `2) ${zh ? '给出当前建议:用 dsh-safe repair <id> 重装修复、升级 dsh 后重试、还是保持禁用。不要编造记录里没有的信息。' : 'Advise what to do now: dsh-safe repair <id> reinstall, retry after an upgrade, or keep disabled. Do not invent facts beyond the records.'}`,
125
+ '',
126
+ zh ? '--- 隔离记录 ---' : '--- quarantine records ---',
127
+ ...rows,
128
+ ].join('\n')
129
+ return chat([{ role: 'user', content: prompt }])
123
130
  }
124
131
 
125
132
  /** $DSH_HOME/dsh-safe/ 下最近修改的 last-failure-*.log。 */
@@ -138,13 +145,13 @@ function findLatestFailureFile() {
138
145
  }
139
146
 
140
147
  /**
141
- * `dsh-safe explain`:解读启动失败。输入优先级:
142
- * --file <路径> > --profile <名>(现场试启并解读) >
143
- * 最近一次失败记录(包装启动失败时自动持久化的 last-failure-*.log) >
144
- * stdin 管道。纯只读——不碰 patch/台账。
148
+ * `dsh-safe explain [id]`:AI 解读 dsh-safe 所知的失败信息。
149
+ * - 指定 <id>:解读该条隔离记录(台账 reason + 上下文,给出 repair 建议);
150
+ * - 默认(无 id):优先解读最近一次启动失败(last-failure-*.log),
151
+ * 没有日志时解读隔离台账;--profile/--file/stdin 另见参数。
152
+ * 纯只读——不碰 patch/台账。
145
153
  * @param {string[]} args
146
154
  * @param {{
147
- * spawn?: typeof spawnSync,
148
155
  * isTTY?: boolean,
149
156
  * readStdin?: () => string,
150
157
  * log?: (line: string) => void,
@@ -153,7 +160,6 @@ function findLatestFailureFile() {
153
160
  * @returns {Promise<number>} 退出码
154
161
  */
155
162
  export async function cmdExplain(args, {
156
- spawn = spawnSync,
157
163
  isTTY = process.stdin.isTTY,
158
164
  readStdin = () => readFileSync(0, 'utf8'),
159
165
  log = (line) => process.stderr.write(`${line}\n`),
@@ -165,62 +171,117 @@ export async function cmdExplain(args, {
165
171
  }
166
172
  let file
167
173
  let profile
168
- const bootArgs = []
169
- let passthrough = false
174
+ let id
170
175
  for (let i = 0; i < args.length; i++) {
171
176
  const a = args[i]
172
- if (passthrough) { bootArgs.push(a); continue }
173
- if (a === '--') { passthrough = true; continue }
174
177
  if (a === '--file') file = args[++i]
175
- else if (a.startsWith('--file=')) file = args[i].slice('--file='.length)
178
+ else if (a.startsWith('--file=')) file = a.slice('--file='.length)
176
179
  else if (a === '--profile') profile = args[++i]
177
- else if (a.startsWith('--profile=')) profile = args[i].slice('--profile='.length)
180
+ else if (a.startsWith('--profile=')) profile = a.slice('--profile='.length)
181
+ else if (!a.startsWith('-') && id === undefined) id = a
178
182
  else {
179
- // 严格解析:未知参数绝不静默吞(否则用户传给 dsh 的意图会被丢弃)
183
+ // 严格解析:未知旗标/多余位置参数绝不静默吞
180
184
  log(t('explainUnknownArg', { arg: a }))
181
185
  return 2
182
186
  }
183
187
  }
184
- if (bootArgs.length && profile === undefined) {
185
- log(t('explainArgsNeedProfile'))
186
- return 2
187
- }
188
188
  if (!aiEnabled()) {
189
189
  log(t('aiDisabled'))
190
190
  return 1
191
191
  }
192
- let input = ''
192
+
193
+ // ① 指定隔离记录:解读台账条目(给出 repair 建议)
194
+ if (id !== undefined) {
195
+ const matches = []
196
+ for (const [p, entries] of Object.entries(loadLedger().profiles)) {
197
+ if (profile && p !== profile) continue
198
+ for (const entry of entries ?? []) {
199
+ if (entry.id === id) matches.push({ profile: p, entry })
200
+ }
201
+ }
202
+ if (!matches.length) {
203
+ log(t('repairEntryMissing', { id }))
204
+ return 1
205
+ }
206
+ if (matches.length > 1) {
207
+ log(t('repairAmbiguous', { id, profiles: matches.map((m) => m.profile).join(', ') }))
208
+ return 2
209
+ }
210
+ log(t('explainEntry', { id, profile: matches[0].profile }))
211
+ const answer = await interpretLedgerEntries([matches[0].entry])
212
+ if (!answer) {
213
+ log(t('aiExplainFailed'))
214
+ return 1
215
+ }
216
+ write(answer)
217
+ return 0
218
+ }
219
+
220
+ // ② 指定日志文件
193
221
  if (file !== undefined) {
222
+ let input
194
223
  try {
195
224
  input = readFileSync(file, 'utf8')
196
225
  } catch {
197
226
  log(t('aiFileUnreadable', { file }))
198
227
  return 2
199
228
  }
200
- } else if (profile !== undefined) {
201
- log(t('explainBooting', { profile }))
202
- const { code, stderr } = bootProfileForExplain(profile, bootArgs, { spawn })
203
- if (code === 0 || !stderr?.trim()) {
204
- log(t('explainBootOk', { profile }))
205
- return 0
229
+ if (!input.trim()) {
230
+ log(t('aiNoInput'))
231
+ return 2
232
+ }
233
+ const answer = await explainFailure(input)
234
+ if (!answer) {
235
+ log(t('aiExplainFailed'))
236
+ return 1
206
237
  }
207
- input = stderr
208
- } else if (!isTTY) {
209
- input = readStdin()
210
- } else {
211
- const latest = findLatestFailureFile()
212
- if (!latest) {
213
- log(t('explainNoSource'))
238
+ write(answer)
239
+ return 0
240
+ }
241
+
242
+ // 管道输入
243
+ if (!isTTY) {
244
+ const input = readStdin()
245
+ if (!input.trim()) {
246
+ log(t('aiNoInput'))
214
247
  return 2
215
248
  }
249
+ const answer = await explainFailure(input)
250
+ if (!answer) {
251
+ log(t('aiExplainFailed'))
252
+ return 1
253
+ }
254
+ write(answer)
255
+ return 0
256
+ }
257
+
258
+ // ④ 默认(交互终端):最近一次失败日志 → 隔离台账
259
+ const latest = findLatestFailureFile()
260
+ if (latest) {
216
261
  log(t('explainUsingLast', { file: latest.path }))
217
- input = readFileSync(latest.path, 'utf8')
262
+ const input = readFileSync(latest.path, 'utf8')
263
+ if (!input.trim()) {
264
+ log(t('aiNoInput'))
265
+ return 2
266
+ }
267
+ const answer = await explainFailure(input)
268
+ if (!answer) {
269
+ log(t('aiExplainFailed'))
270
+ return 1
271
+ }
272
+ write(answer)
273
+ return 0
218
274
  }
219
- if (!input.trim()) {
220
- log(t('aiNoInput'))
275
+
276
+ const entries = Object.entries(loadLedger().profiles)
277
+ .filter(([p]) => !profile || p === profile)
278
+ .flatMap(([, list]) => list ?? [])
279
+ if (!entries.length) {
280
+ log(t('explainNoSource'))
221
281
  return 2
222
282
  }
223
- const answer = await explainFailure(input)
283
+ log(t('explainLedger', { count: entries.length }))
284
+ const answer = await interpretLedgerEntries(entries)
224
285
  if (!answer) {
225
286
  log(t('aiExplainFailed'))
226
287
  return 1
package/lib/i18n.js CHANGED
@@ -24,7 +24,7 @@ const ZH = {
24
24
  恢复被自动禁用的插件(升级修复后使用)
25
25
  dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
26
26
  升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
27
- dsh-safe explain [--profile <名> | --file <路径>] 用 AI 解读启动失败(默认解读最近一次失败)
27
+ dsh-safe explain [id] [--profile <名> | --file <路径>] 用 AI 解读:最近失败日志 / 隔离台账 / 任意日志
28
28
  dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]
29
29
  重装/升级被隔离的插件并自动恢复(限模块解析失败类;省略 id 时只有一条记录则直接修)
30
30
  dsh-safe help 显示本帮助
@@ -110,12 +110,11 @@ const ZH = {
110
110
  aiFileUnreadable: '[dsh-safe] 无法读取文件: {file}',
111
111
  aiRecovered: '[dsh-safe] AI 兜底识别出 {count} 个可疑坏插件(结果仍走同一隔离管线)',
112
112
  explainHint: '[dsh-safe] 可运行 dsh-safe explain 解读这次失败',
113
- explainBooting: '[dsh-safe] 正在试启 profile {profile}(60 秒超时;不修改任何文件)…',
114
- explainArgsNeedProfile: '[dsh-safe] 透传的启动参数需要与 --profile 一起使用。',
115
- explainBootOk: '[dsh-safe] profile {profile} 当前能正常启动,没有失败可解读。',
116
113
  explainUsingLast: '[dsh-safe] 解读最近一次失败记录: {file}',
117
114
  explainNoSource: '[dsh-safe] 没有可解读的内容。用法:dsh-safe explain [--profile <名> | --file <路径>],或从 stdin 管道输入。\n 包装启动失败时 stderr 会自动存到 $DSH_HOME/dsh-safe/last-failure-<profile>.log。',
118
- explainUnknownArg: '[dsh-safe] explain 无法识别的参数: {arg}(透传给 dsh 的启动参数请放在 -- 之后,如 -- --port 3084)',
115
+ explainLedger: '[dsh-safe] 解读隔离台账({count} 条)…',
116
+ explainEntry: '[dsh-safe] 解读隔离记录 {id}(profile: {profile})…',
117
+ explainUnknownArg: '[dsh-safe] explain 无法识别的参数: {arg}',
119
118
  excludedByList: '[dsh-safe] 按豁免名单跳过 {label}(不自动禁用)',
120
119
  doctorSelf: 'dsh-safe {version}',
121
120
  doctorDsh: 'dsh {name} {version}',
@@ -159,7 +158,7 @@ Usage:
159
158
  re-enable auto-disabled plugins (after a fixed plugin upgrade)
160
159
  dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
161
160
  upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
162
- dsh-safe explain [--profile <name> | --file <path>] interpret the last boot failure with AI
161
+ dsh-safe explain [id] [--profile <name> | --file <path>] interpret with AI: last failure log / ledger / any log
163
162
  dsh-safe repair [id] [--profile <name>] [--to <ver>] [-y] [--dry-run]
164
163
  reinstall/upgrade a quarantined plugin and auto-restore it (module-resolution failures only; omit id when there is exactly one record)
165
164
  dsh-safe help show this help
@@ -248,12 +247,11 @@ Notes:
248
247
  aiFileUnreadable: '[dsh-safe] cannot read file: {file}',
249
248
  aiRecovered: '[dsh-safe] AI fallback identified {count} suspected broken plugin(s); the same quarantine pipeline applies',
250
249
  explainHint: '[dsh-safe] run dsh-safe explain to interpret this failure',
251
- explainBooting: '[dsh-safe] booting profile {profile} (60s timeout; no files are modified)…',
252
- explainArgsNeedProfile: '[dsh-safe] passthrough boot args require --profile.',
253
- explainBootOk: '[dsh-safe] profile {profile} boots fine right now — nothing failed to interpret.',
254
250
  explainUsingLast: '[dsh-safe] interpreting the last failure record: {file}',
255
251
  explainNoSource: '[dsh-safe] nothing to interpret. Usage: dsh-safe explain [--profile <name> | --file <path>], or pipe via stdin.\n On wrapped-boot failures stderr is saved to $DSH_HOME/dsh-safe/last-failure-<profile>.log automatically.',
256
- explainUnknownArg: '[dsh-safe] unrecognized argument for explain: {arg} (put dsh boot args after --, e.g. -- --port 3084)',
252
+ explainLedger: '[dsh-safe] interpreting the quarantine ledger ({count} record(s))…',
253
+ explainEntry: '[dsh-safe] interpreting quarantine record {id} (profile: {profile})…',
254
+ explainUnknownArg: '[dsh-safe] unrecognized argument for explain: {arg}',
257
255
  excludedByList: '[dsh-safe] skipped {label} per the exclusion list (never auto-disabled)',
258
256
  doctorSelf: 'dsh-safe {version}',
259
257
  doctorDsh: 'dsh {name} {version}',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",