dsh-dsml-rescue 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -50,7 +50,7 @@ This plugin types it.
50
50
 
51
51
  - Continues a stalled turn: DSML markup at the end of the reasoning, and nothing else produced.
52
52
  Neither half alone is enough — an empty turn without the markup is a different failure.
53
- - Sends one short, neutral line — carry on, or wrap up if the work is done.
53
+ - Sends one short, neutral line: take the next step — never an offer to declare the work finished.
54
54
  - Backs off while you are typing.
55
55
  - Gives up after a few attempts instead of burning requests.
56
56
  - Covers subagents too, so a delegated task is not falsely reported as completed while it is stuck.
package/README_zh.md CHANGED
@@ -46,7 +46,7 @@ DSML(DeepSeek Markup Language)是 DeepSeek 表示工具调用的协议标记
46
46
  ## 它做什么
47
47
 
48
48
  - 只救空转的轮次:推理过程末尾带着 DSML 标记,而且没有产出任何别的内容。两个条件缺一不可——空转但末尾没有标记,是另一种失败。
49
- - 发一句很短、很中性的话:接着做,或者已经做完就收尾。
49
+ - 发一句很短、很中性的话:接着做下一步,不给「已经做完了」这种台阶。
50
50
  - 你正在打字时它让开。
51
51
  - 连着几次没救回来就收手,不会一直烧请求。
52
52
  - 子代理也管:被派出去的任务不会在卡住时被误报为已完成。
package/lib/index.js CHANGED
@@ -229,13 +229,23 @@ function reduce(state, event, limits) {
229
229
  * sits below the model — a provider route delivering tool-call markup as text —
230
230
  * so nothing the model does can avoid it, and naming it would only invite the
231
231
  * model to talk about the failure instead of continuing the work. The wording
232
- * is fixed rather than configurable: one sentence that asks for the next step,
233
- * plus one legal way out so a rescue cannot become an open-ended loop.
232
+ * is fixed rather than configurable: one instruction, and no clause inviting the
233
+ * model to declare the work finished. The cap is what bounds a rescue, so the
234
+ * text owes no exit — and offering one gives a stalled model a cheaper way to
235
+ * end the turn than doing the work.
234
236
  *
235
237
  * @module dsh-dsml-rescue/messages
236
238
  */
237
- /** What the model reads. Two instructions: continue, or close out if finished. */
238
- const CONTINUE_PROMPT = "Continue with the task. Take the next concrete step now, using a tool if needed. If the work is complete, reply with a short summary.";
239
+ /**
240
+ * What the model reads: one instruction, and nothing about being finished.
241
+ *
242
+ * The rescued turn ended with a tool call that never landed, so the next step
243
+ * has not happened yet; a "say you are done" clause has nothing true to be right
244
+ * about. Taking one is not free either: a prose answer is actionable output, so
245
+ * detection stops calling the turn a stall and the run ends with the work
246
+ * undone.
247
+ */
248
+ const CONTINUE_PROMPT = "Continue with the task. Take the next concrete step now, using a tool if needed.";
239
249
  /**
240
250
  * Build the continuation turn's message.
241
251
  * @param round - this rescue's ordinal in the current streak, from 1.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dsml-rescue",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Auto-continues turns stalled by DSML tool-call markup leaking into reasoning.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -23,6 +23,9 @@
23
23
  "type": "module",
24
24
  "main": "./lib/index.js",
25
25
  "types": "./lib/index.d.ts",
26
+ "bin": {
27
+ "dsh-dsml-rescue-doctor": "./scripts/doctor.mjs"
28
+ },
26
29
  "exports": {
27
30
  ".": {
28
31
  "types": "./lib/index.d.ts",
@@ -37,6 +40,7 @@
37
40
  "icon.svg",
38
41
  "locale/*.json",
39
42
  "cordis.patch.yml",
43
+ "scripts/doctor.mjs",
40
44
  "README.md",
41
45
  "LICENSE",
42
46
  "README_zh.md"
@@ -81,6 +85,8 @@
81
85
  "lint": "eslint .",
82
86
  "format": "prettier --write .",
83
87
  "format:check": "prettier --check .",
88
+ "doctor": "node scripts/doctor.mjs",
89
+ "check:declaration": "node scripts/check-declaration.mjs",
84
90
  "check:release": "node scripts/check-release.mjs",
85
91
  "check": "pnpm run typecheck && pnpm run build && pnpm run test && pnpm run check:release"
86
92
  }
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Install check — will this plugin actually run, and is it the code you just wrote?
4
+ *
5
+ * Why this exists, and why it is **not** a runtime warning: an unmounted plugin has no code
6
+ * running, so it cannot report its own absence. The failure is silent by construction. A profile
7
+ * can list the package under `dependencies` and still never load it — the loader only mounts a
8
+ * package whose name is in `dsh.profile.bundles` (or that a profile patch inserts explicitly).
9
+ * A dependency alone contributes no patch layer and produces no diagnostic anywhere.
10
+ *
11
+ * Worse, the obvious repair does not work: re-running `dsh plugin add <same package>` skips
12
+ * packages that are already dependencies, so a lost bundle row is never restored by re-adding.
13
+ * The repair is remove-then-add, and this check prints exactly that.
14
+ *
15
+ * Two groups of checks:
16
+ * - **Mount state** — reads a profile's `package.json` and `cordis.patch.yml`.
17
+ * - **Artifact freshness** — a `link:` install runs `lib/` from this checkout, so a stale build
18
+ * silently runs code that is not in `src/`. Only applies when run from a repository checkout.
19
+ *
20
+ * Usage:
21
+ * node scripts/doctor.mjs # $DSH_HOME, $DSH_PROFILE, else ~/.dsh + web
22
+ * node scripts/doctor.mjs --profile web --home <DSH_HOME>
23
+ * node scripts/doctor.mjs --json # machine-readable
24
+ *
25
+ * Exit codes: 0 = mounted and current / 1 = not mounted, or the build is stale / 2 = precondition
26
+ * missing (no readable profile manifest).
27
+ *
28
+ * @module dsh-dsml-rescue/doctor
29
+ */
30
+
31
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
32
+ import { homedir } from 'node:os'
33
+ import { dirname, join, resolve } from 'node:path'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
+
36
+ /** The package this doctor is about; also the bundle-list entry the loader needs. */
37
+ export const PACKAGE = 'dsh-dsml-rescue'
38
+
39
+ /** One line of the report. */
40
+ export const OK = 'ok'
41
+ export const FAIL = 'fail'
42
+ export const NOTE = 'note'
43
+
44
+ /**
45
+ * Read one file as UTF-8, or undefined when it is absent or unreadable.
46
+ * @param {string} path - file to read.
47
+ * @returns {string | undefined} the text, or undefined.
48
+ */
49
+ function readText(path) {
50
+ try {
51
+ return readFileSync(path, 'utf8')
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Read and parse a JSON file.
59
+ * @param {string} path - file to read.
60
+ * @returns {{ value?: any, error?: string }} the parsed value, or why it failed.
61
+ */
62
+ function readJson(path) {
63
+ const text = readText(path)
64
+ if (text === undefined) return { error: `cannot read ${path}` }
65
+ try {
66
+ return { value: JSON.parse(text) }
67
+ } catch (error) {
68
+ return { error: `${path} is not valid JSON: ${String(error)}` }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Whether a profile patch inserts this package as a row of its own.
74
+ *
75
+ * A patch row is a second, legitimate way to mount the plugin, so a profile that uses it does not
76
+ * need the bundle-list entry. The check is textual on purpose: reading YAML would mean shipping a
77
+ * parser to answer one question, and a comment mentioning the name must not count as a mount —
78
+ * hence the requirement that the name sits on an `id:`/`name:` line that starts a list item.
79
+ * @param {string | undefined} patchText - the profile's cordis.patch.yml.
80
+ * @param {string} name - package name.
81
+ * @returns {boolean} whether a row mounts it.
82
+ */
83
+ function patchMounts(patchText, name) {
84
+ if (patchText === undefined) return false
85
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
86
+ const row = new RegExp(`^\\s*-?\\s*(?:id|name)\\s*:\\s*['"]?${escaped}['"]?\\s*$`, 'm')
87
+ return row.test(patchText)
88
+ }
89
+
90
+ /**
91
+ * Judge whether the plugin is mounted, from the profile's own two files.
92
+ *
93
+ * Pure apart from nothing: it takes the manifest and the patch text and returns findings, so the
94
+ * rules can be tested without a home directory.
95
+ * @param {{ manifest: any, patchText: string | undefined, name?: string }} input - the profile state.
96
+ * @returns {{ level: string, message: string, hint?: string }[]} findings, most important first.
97
+ */
98
+ export function inspectProfile({ manifest, patchText, name = PACKAGE }) {
99
+ const findings = []
100
+ const dependencies = manifest?.dependencies ?? {}
101
+ const bundles = manifest?.dsh?.profile?.bundles ?? []
102
+ const inDependencies = Object.hasOwn(dependencies, name)
103
+ const inBundles = Array.isArray(bundles) && bundles.includes(name)
104
+ const viaPatch = patchMounts(patchText, name)
105
+
106
+ if (!inDependencies && !viaPatch) {
107
+ findings.push({
108
+ level: FAIL,
109
+ message: `${name} is not in this profile at all`,
110
+ hint: `install it: dsh plugin --profile <profile> add ${name}`,
111
+ })
112
+ return findings
113
+ }
114
+
115
+ if (inBundles) {
116
+ findings.push({
117
+ level: OK,
118
+ message: `${name} is in dsh.profile.bundles — the loader mounts it`,
119
+ })
120
+ } else if (viaPatch) {
121
+ findings.push({
122
+ level: OK,
123
+ message: `${name} is mounted by a row in the profile's cordis.patch.yml`,
124
+ hint: 'a patch row is enough; the bundle-list entry is only needed for a bundle install',
125
+ })
126
+ } else {
127
+ findings.push({
128
+ level: FAIL,
129
+ message: `${name} is a dependency but is NOT in dsh.profile.bundles — it will not load`,
130
+ hint: 'reinstalling does not repair this: `add` skips packages that are already dependencies. Run remove, then add.',
131
+ })
132
+ }
133
+
134
+ if (!inDependencies && viaPatch) {
135
+ findings.push({
136
+ level: NOTE,
137
+ message: 'no dependency entry: the loader resolves the row from somewhere else',
138
+ })
139
+ }
140
+
141
+ return findings
142
+ }
143
+
144
+ /**
145
+ * Whether the built artifact predates the newest source file.
146
+ *
147
+ * A `link:` install reads `lib/` straight out of the checkout, so the build is the code that runs.
148
+ * Editing `src/` without rebuilding therefore changes nothing at runtime, with no error anywhere.
149
+ * Reports nothing when either side is absent (an installed copy has no `src/`).
150
+ * @param {string} repoRoot - the package directory.
151
+ * @returns {{ level: string, message: string, hint?: string }[]} findings.
152
+ */
153
+ export function inspectArtifacts(repoRoot) {
154
+ const libDir = join(repoRoot, 'lib')
155
+ const srcDir = join(repoRoot, 'src')
156
+ if (!existsSync(libDir) || !existsSync(srcDir)) return []
157
+
158
+ /** The newest mtime of every file under `dir` with one of the given extensions. */
159
+ const newest = (dir, extensions) => {
160
+ let time = 0
161
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
162
+ const path = join(dir, entry.name)
163
+ if (entry.isDirectory()) {
164
+ time = Math.max(time, newest(path, extensions))
165
+ continue
166
+ }
167
+ if (!extensions.some((extension) => entry.name.endsWith(extension))) continue
168
+ try {
169
+ time = Math.max(time, statSync(path).mtimeMs)
170
+ } catch {
171
+ /* a file that vanished mid-scan is not a finding */
172
+ }
173
+ }
174
+ return time
175
+ }
176
+
177
+ const built = newest(libDir, ['.js'])
178
+ const source = newest(srcDir, ['.ts'])
179
+ if (built === 0) {
180
+ return [
181
+ {
182
+ level: FAIL,
183
+ message: 'lib/ holds no built JavaScript, so a link install has nothing to load',
184
+ hint: 'run `pnpm run build`',
185
+ },
186
+ ]
187
+ }
188
+ if (source > built) {
189
+ return [
190
+ {
191
+ level: FAIL,
192
+ message: 'src/ is newer than lib/ — a link install runs the stale build',
193
+ hint: 'run `pnpm run build`, then reload the plugin entry or restart the host',
194
+ },
195
+ ]
196
+ }
197
+ return [{ level: OK, message: 'the built artifact is current with src/' }]
198
+ }
199
+
200
+ /**
201
+ * Resolve the profile directory this run should inspect.
202
+ * @param {{ profile?: string, home?: string }} options - command-line options.
203
+ * @returns {{ profile: string, dir: string, home: string }} the resolved location.
204
+ */
205
+ export function resolveProfile(options = {}) {
206
+ const home = resolve(options.home ?? process.env['DSH_HOME'] ?? join(homedir(), '.dsh'))
207
+ const profile = options.profile ?? process.env['DSH_PROFILE'] ?? 'web'
208
+ return { profile, dir: join(home, 'profiles', profile), home }
209
+ }
210
+
211
+ /** Parse `--key value` and `--key=value` flags. */
212
+ function parseArgs(argv) {
213
+ const options = { json: false }
214
+ for (let index = 0; index < argv.length; index += 1) {
215
+ const argument = argv[index] ?? ''
216
+ if (argument === '--json') {
217
+ options.json = true
218
+ continue
219
+ }
220
+ const match = /^--(profile|home)(?:=(.*))?$/.exec(argument)
221
+ if (match === null) continue
222
+ const key = match[1]
223
+ const inline = match[2]
224
+ if (inline !== undefined) options[key] = inline
225
+ else {
226
+ options[key] = argv[index + 1]
227
+ index += 1
228
+ }
229
+ }
230
+ return options
231
+ }
232
+
233
+ /** Run the report. */
234
+ function main() {
235
+ const options = parseArgs(process.argv.slice(2))
236
+ const { profile, dir, home } = resolveProfile(options)
237
+ const manifestPath = join(dir, 'package.json')
238
+ const parsed = readJson(manifestPath)
239
+ if (parsed.error !== undefined) {
240
+ process.stderr.write(`doctor: ${parsed.error}\n`)
241
+ process.stderr.write(`doctor: is the profile name right? (home ${home}, profile ${profile})\n`)
242
+ process.exit(2)
243
+ }
244
+
245
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
246
+ const findings = [
247
+ ...inspectProfile({
248
+ manifest: parsed.value,
249
+ patchText: readText(join(dir, 'cordis.patch.yml')),
250
+ }),
251
+ ...inspectArtifacts(repoRoot),
252
+ ]
253
+ const failed = findings.some((finding) => finding.level === FAIL)
254
+
255
+ if (options.json) {
256
+ process.stdout.write(
257
+ `${JSON.stringify({ profile, home, mounted: !failed, findings }, undefined, 2)}\n`,
258
+ )
259
+ } else {
260
+ process.stdout.write(`doctor: profile ${profile} (${dir})\n`)
261
+ for (const finding of findings) {
262
+ const mark = finding.level === OK ? 'ok ' : finding.level === FAIL ? 'FAIL' : 'note'
263
+ process.stdout.write(` ${mark} ${finding.message}\n`)
264
+ if (finding.hint !== undefined) process.stdout.write(` ${finding.hint}\n`)
265
+ }
266
+ }
267
+ process.exit(failed ? 1 : 0)
268
+ }
269
+
270
+ if (
271
+ process.argv[1] !== undefined &&
272
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
273
+ ) {
274
+ main()
275
+ }