dsh-deeptutor 0.3.0 → 0.3.2

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.
@@ -120,16 +120,32 @@ acceptance criteria; references.
120
120
 
121
121
  Pass `html` to `deeptutor_run` and the answer is rendered to a self-contained
122
122
  page (dark/light toggle, TOC, collapsible quiz answers, print-friendly) with
123
- the bundled **html-doc** converter, plus the `.md` source next to it:
123
+ the **html-doc** converter, plus the `.md` source next to it:
124
124
 
125
125
  ```
126
126
  deeptutor_run capability=deep_solve prompt=<topic> kbs=[...] tools=[rag, web_search]
127
- html="data/study/<topic>/knowledge.html" html_title="<主题> · 知识点" language=zh
127
+ html="<absolute path, e.g. /home/me/study/<topic>/knowledge.html>" html_title="<主题> · 知识点" language=zh
128
128
  ```
129
129
 
130
- Relative paths resolve against your working directory. To render other
131
- Markdown, use the html-doc skill's converter
132
- (`node <html-doc skill dir>/scripts/md-to-html.js <in.md> --out <out.html> --title "<t>" --toc`).
130
+ `html` must be an **absolute** path. A tool has no workspace root (dsh hands it
131
+ only the agent identity, and the MCP server has no session), so a relative path
132
+ cannot be resolved against your working directory — it is rejected instead of
133
+ silently landing under the host process cwd. Prefix it with your working
134
+ directory explicitly.
135
+
136
+ The converter and its template are resolved in this order, and the tool result
137
+ prints which layer won (`converter:` / `template:` lines):
138
+
139
+ 1. `HTML_DOC_SCRIPT` / `HTML_DOC_TEMPLATE` env vars (explicit override; a
140
+ set-but-missing path is an error),
141
+ 2. the installed skill at `<DSH_HOME>/skills/html-doc/` (`DSH_HOME` defaults to
142
+ `~/.dsh`) — edit the template there to customise the page, no plugin edit
143
+ needed,
144
+ 3. the copy bundled with the plugin.
145
+
146
+ To render other Markdown, use the html-doc skill's converter
147
+ (`node <html-doc skill dir>/scripts/md-to-html.js <in.md> --out <out.html> --title "<t>" --toc`,
148
+ plus `--template <file>` to pick a specific template).
133
149
  Tell the user the file paths.
134
150
 
135
151
  ### 6. Archive (only on request)
@@ -39,6 +39,8 @@ cat notes.md | node scripts/md-to-html.js --title "Quiz" --theme dark --out quiz
39
39
  # --toc generate a table of contents from headings (h2/h3)
40
40
  # --lang <code> html lang attribute (default: zh-CN)
41
41
  # --no-collapse disable <details> wrapping for quiz answers (default: collapse enabled)
42
+ # --template <file> HTML template to use (default: the assets/ copy next to the
43
+ # script, or $HTML_DOC_TEMPLATE when that is set)
42
44
  ```
43
45
 
44
46
  ## Supported Markdown
@@ -84,6 +86,29 @@ Edit `assets/template.html` — it uses `{{TITLE}}`, `{{BODY}}`, `{{THEME}}`, `{
84
86
  - Table styling, blockquote styling, task-list checkboxes
85
87
  - Responsive layout (comfortable reading column)
86
88
 
89
+ ### Which template wins
90
+
91
+ The converter resolves the template in this order (first hit wins); the `--out`
92
+ log line names the file it used, and `deeptutor_run`'s `html` note prints it too:
93
+
94
+ 1. `--template <file>` on the command line,
95
+ 2. `$HTML_DOC_TEMPLATE`,
96
+ 3. `assets/template.html` next to the script (the copy shipped in this skill),
97
+ 4. `../.agents/skills/html-doc/assets/template.html` from a package-root duplicate.
98
+
99
+ An explicitly requested template (1 or 2) that does not exist is an error
100
+ instead of a silent fallback, so a typo cannot produce pages from the default
101
+ template.
102
+
103
+ `deeptutor_run`'s `html` path resolves this in the **host**, not inside this
104
+ script: it prefers the **installed** skill at `<DSH_HOME>/skills/html-doc/` over
105
+ the copy bundled in the plugin package (with `$HTML_DOC_SCRIPT`/`$HTML_DOC_TEMPLATE`
106
+ as explicit overrides), then hands the winning template to the converter as
107
+ `--template`. Editing the installed template therefore changes the next render —
108
+ no plugin edit needed. (Re-running the installer copies the bundled skill files
109
+ back over the installed ones, so keep a copy of a heavily customised template
110
+ outside `<DSH_HOME>/skills/` and point `$HTML_DOC_TEMPLATE` at it.)
111
+
87
112
  ## Notes
88
113
 
89
114
  - Zero dependencies — only `node` (≥ 20.11, uses `import.meta.dirname`) is required; no npm install needed
@@ -4,9 +4,12 @@
4
4
  *
5
5
  * Usage:
6
6
  * node md-to-html.js <input.md> [--out out.html] [--title "T"] [--theme dark|light]
7
- * [--toc] [--lang zh-CN] [--no-collapse]
7
+ * [--toc] [--lang zh-CN] [--no-collapse] [--template template.html]
8
8
  * cat file.md | node md-to-html.js --title "T" --out out.html
9
9
  *
10
+ * Template resolution order: --template, $HTML_DOC_TEMPLATE, the assets/ copy
11
+ * next to this script, then the package-root duplicate's skill copy.
12
+ *
10
13
  * Features: headings, fenced code blocks, inline code/bold/italic/links, lists,
11
14
  * task lists, blockquotes, pipe tables, hr, quiz answer collapsing (<details>),
12
15
  * raw HTML passthrough, HTML escaping. Zero npm dependencies (Node >= 20.11 — uses import.meta.dirname).
@@ -27,9 +30,10 @@ function parseArgs(argv) {
27
30
  toc: false,
28
31
  lang: "zh-CN",
29
32
  collapse: true,
33
+ template: null,
30
34
  };
31
35
  const positional = [];
32
- const valueFlags = new Set(["--out", "--title", "--theme", "--lang"]);
36
+ const valueFlags = new Set(["--out", "--title", "--theme", "--lang", "--template"]);
33
37
  for (let i = 0; i < argv.length; i++) {
34
38
  const a = argv[i];
35
39
  if (a === "--toc") opts.toc = true;
@@ -50,7 +54,11 @@ function parseArgs(argv) {
50
54
  fail(`Invalid --theme value "${value}": expected dark or light`);
51
55
  }
52
56
  opts.theme = value;
53
- } else if (a === "--lang") opts.lang = value;
57
+ } else if (a === "--lang") {
58
+ opts.lang = value;
59
+ } else if (a === "--template") {
60
+ opts.template = value;
61
+ }
54
62
  } else if (a.startsWith("--")) {
55
63
  fail(`Unknown option: ${a} (run with --help for usage)`);
56
64
  } else positional.push(a);
@@ -77,6 +85,8 @@ Options:
77
85
  --theme <mode> dark | light (default: light; toggle also available in page)
78
86
  --toc generate a table of contents from h2/h3 headings
79
87
  --lang <code> html lang attribute (default: zh-CN)
88
+ --template <file> HTML template to use (default: the assets/ copy next to this
89
+ script, or $HTML_DOC_TEMPLATE when set)
80
90
  --no-collapse disable quiz-answer collapsing
81
91
  --help show this help`);
82
92
  }
@@ -351,11 +361,25 @@ function main() {
351
361
  const generated = new Date().toISOString().slice(0, 10);
352
362
  // The same converter ships in two locations (.agents/skills/html-doc/scripts/
353
363
  // and a package-root scripts/ copy); resolve the template wherever we are.
364
+ // Order: --template, $HTML_DOC_TEMPLATE, then the copies next to this script.
365
+ // An explicit override that does not exist is an error: a typo must not
366
+ // silently fall through to a different template.
367
+ const explicitTemplate = opts.template || process.env.HTML_DOC_TEMPLATE || null;
354
368
  const templateCandidates = [
355
369
  path.join(import.meta.dirname, "..", "assets", "template.html"),
356
370
  path.join(import.meta.dirname, "..", ".agents", "skills", "html-doc", "assets", "template.html"),
357
371
  ];
358
- const templatePath = templateCandidates.find((p) => fs.existsSync(p));
372
+ let templatePath;
373
+ if (explicitTemplate) {
374
+ if (!fs.existsSync(explicitTemplate)) {
375
+ fail(
376
+ `template not found: ${explicitTemplate} (from ${opts.template ? "--template" : "HTML_DOC_TEMPLATE"})`,
377
+ );
378
+ }
379
+ templatePath = explicitTemplate;
380
+ } else {
381
+ templatePath = templateCandidates.find((p) => fs.existsSync(p));
382
+ }
359
383
  if (!templatePath) {
360
384
  fail(`template.html not found (looked in: ${templateCandidates.join(", ")})`);
361
385
  }
@@ -380,7 +404,7 @@ function main() {
380
404
  if (opts.out) {
381
405
  fs.writeFileSync(opts.out, template, "utf8");
382
406
  console.log(
383
- `[html-doc] wrote ${path.resolve(opts.out)} (${Buffer.byteLength(template)} bytes)`,
407
+ `[html-doc] wrote ${path.resolve(opts.out)} (${Buffer.byteLength(template)} bytes, template: ${templatePath})`,
384
408
  );
385
409
  } else {
386
410
  process.stdout.write(template);
package/README.md CHANGED
@@ -58,7 +58,7 @@ A typical flow when you ask your agent for learning help:
58
58
 
59
59
  1. **Ask** — *"Explain C# generics covariance using my dotnet knowledge base, and generate a study page."*
60
60
  2. The agent grounds the answer in your own material: `deeptutor_kb` (`action=search`, `kb=dotnet`).
61
- 3. It runs a deep-solve: `deeptutor_run` (`capability=deep_solve`, `kbs=[dotnet]`, `html=data/study/csharp-covariance.html`) — the answer comes back and a self-contained HTML page (plus the `.md` source) is written to disk.
61
+ 3. It runs a deep-solve: `deeptutor_run` (`capability=deep_solve`, `kbs=[dotnet]`, `html=/home/me/study/csharp-covariance.html`) — the answer comes back and a self-contained HTML page (plus the `.md` source) is written to disk. The `html` path must be absolute; relative paths are rejected.
62
62
  4. It files a summary: `deeptutor_note` (`notebook=dotnet-learning`, `type=solve`).
63
63
 
64
64
  Nothing here is a fixed script — the agent picks the tools and parameters based
@@ -182,7 +182,7 @@ install`:
182
182
 
183
183
  ```json
184
184
  {
185
- "dependencies": { "dsh-deeptutor": "^0.3.0" },
185
+ "dependencies": { "dsh-deeptutor": "^0.3.1" },
186
186
  "dsh": {
187
187
  "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-deeptutor"] }
188
188
  }
@@ -215,8 +215,12 @@ Two skills ship inside this package (`.agents/skills/deeptutor` and
215
215
 
216
216
  - `deeptutor` — the agent-facing learning workflow (`~/.dsh/skills/deeptutor/SKILL.md`)
217
217
  - `html-doc` — renders study answers to self-contained HTML pages
218
- (`~/.dsh/skills/html-doc/`); the bundle uses the same converter
219
- (`scripts/md-to-html.js`) when `deeptutor_run` gets an `html` path
218
+ (`~/.dsh/skills/html-doc/`); when `deeptutor_run` gets an `html` path, the
219
+ converter and template are resolved as `$HTML_DOC_SCRIPT` /
220
+ `$HTML_DOC_TEMPLATE` → the installed skill above → the copy bundled in this
221
+ package. Editing the installed template (say, wiring KaTeX in) changes the
222
+ next render without patching the plugin, and the tool result prints which
223
+ layer supplied each file. The `html` path itself must be absolute.
220
224
 
221
225
  Installing a newer package version overwrites skill files in place; files not
222
226
  shipped by the package are never deleted.
@@ -290,8 +294,7 @@ scripts/install-profile.mjs # one-shot profile installer (exposed as the `dsh-d
290
294
  scripts/mcp-launch.mjs # starts the MCP server for the Claude Code plugin
291
295
  scripts/check-cli.ts # drift check against a live DeepTutor (`npm run check:cli`)
292
296
  .agents/skills/ # bundled skills: deeptutor/ + html-doc/ (installed to <DSH_HOME>/skills/)
293
- .claude-plugin/ # Claude Code plugin.json + marketplace.json
294
- .mcp.json # the plugin's MCP server (runs scripts/mcp-launch.mjs)
297
+ .claude-plugin/ # Claude Code plugin.json (skills + MCP server) + marketplace.json
295
298
  cordis.yml # dev overlay: insert src/index.ts by absolute path
296
299
  cordis.patch.yml # bundle patch: insert the package by name
297
300
  ```
package/README.zh.md CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  1. **提问** —— *"用我的 dotnet 知识库讲讲 C# 泛型协变,并生成学习页。"*
40
40
  2. agent 先检索你的自有资料:`deeptutor_kb`(`action=search`,`kb=dotnet`)。
41
- 3. 再执行深度讲解:`deeptutor_run`(`capability=deep_solve`,`kbs=[dotnet]`,`html=data/study/csharp-covariance.html`)—— 答案返回的同时,自包含 HTML 学习页(及配套 `.md` 源文件)写入磁盘。
41
+ 3. 再执行深度讲解:`deeptutor_run`(`capability=deep_solve`,`kbs=[dotnet]`,`html=/home/me/study/csharp-covariance.html`)—— 答案返回的同时,自包含 HTML 学习页(及配套 `.md` 源文件)写入磁盘。`html` 必须是绝对路径,相对路径会被拒绝。
42
42
  4. 最后归档摘要:`deeptutor_note`(`notebook=dotnet-learning`,`type=solve`)。
43
43
 
44
44
  以上不是固定脚本 —— agent 会根据你的提问自由选择工具与参数。
@@ -119,7 +119,7 @@ dsh --profile web --dump-config | grep dsh-deeptutor
119
119
 
120
120
  ```json
121
121
  {
122
- "dependencies": { "dsh-deeptutor": "^0.3.0" },
122
+ "dependencies": { "dsh-deeptutor": "^0.3.1" },
123
123
  "dsh": {
124
124
  "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-deeptutor"] }
125
125
  }
@@ -146,7 +146,7 @@ bundle 清单(`dsh.bundle.patch → cordis.patch.yml`)负责插入插件行;上
146
146
  本包携带两个 skill(`.agents/skills/deeptutor` 与 `.agents/skills/html-doc`),随包安装器每次运行时都会把它们复制到 `<DSH_HOME>/skills/<name>/`(dsh 自动发现)—— 所以 `pnpm dlx dsh-deeptutor` 一次装齐插件 **和** skill:
147
147
 
148
148
  - `deeptutor` — 面向 agent 的学习工作流(`~/.dsh/skills/deeptutor/SKILL.md`)
149
- - `html-doc` — 将学习内容渲染为自包含 HTML 页面(`~/.dsh/skills/html-doc/`);`deeptutor_run` 传入 `html` 路径时,插件内部使用同一转换器(`scripts/md-to-html.js`)
149
+ - `html-doc` — 将学习内容渲染为自包含 HTML 页面(`~/.dsh/skills/html-doc/`);`deeptutor_run` 传入 `html` 时,转换器与模板按 `$HTML_DOC_SCRIPT` / `$HTML_DOC_TEMPLATE` → 上面这个已安装 skill → 包内副本的顺序解析。改已安装模板(例如真正接上 KaTeX)下次渲染即生效,不用改插件包;工具结果会打印每层实际用的文件。`html` 路径必须是绝对路径。
150
150
 
151
151
  升级包版本时会就地覆盖 skill 文件;包未携带的文件不会被删除。
152
152
 
@@ -205,8 +205,7 @@ scripts/install-profile.mjs # 一键 profile 安装器(以 `dsh-deeptutor` 二
205
205
  scripts/mcp-launch.mjs # 为 Claude Code 插件启动 MCP server
206
206
  scripts/check-cli.ts # 对真实 DeepTutor 的漂移检查(`npm run check:cli`)
207
207
  .agents/skills/ # 随包 skill:deeptutor/ + html-doc/(安装到 <DSH_HOME>/skills/)
208
- .claude-plugin/ # Claude Code 的 plugin.json 和 marketplace.json
209
- .mcp.json # 插件的 MCP server(运行 scripts/mcp-launch.mjs)
208
+ .claude-plugin/ # Claude Code 的 plugin.json(skill 和 MCP server)和 marketplace.json
210
209
  cordis.yml # 开发 overlay:按绝对路径插入 src/index.ts
211
210
  cordis.patch.yml # bundle patch:按包名插入插件
212
211
  ```
package/lib/cli-exec.d.ts CHANGED
@@ -1,9 +1,33 @@
1
+ /**
2
+ * Upper bound for one CLI run's captured stdout/stderr.
3
+ *
4
+ * `deeptutor run --format json` writes one JSON line per streamed chunk, and a
5
+ * chunk is roughly one character of the answer once event metadata is included
6
+ * (~1.5 KB of JSON per output character measured on 1.7.1). A 1500-character
7
+ * answer is therefore ~2 MB, already past Node's 1 MB `execFile` default: the
8
+ * child is killed mid-turn and the only trace is a string `err.code`
9
+ * (`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`) with empty stderr. 64 MB covers a
10
+ * ~40k-character answer, well beyond the CLI's own per-turn token ceiling.
11
+ */
12
+ export declare const MAX_CLI_OUTPUT_BYTES: number;
1
13
  export interface CliResult {
2
14
  stdout: string;
3
15
  stderr: string;
4
16
  code: number;
5
17
  killed: boolean;
18
+ /** Node-level failure code, kept when stderr is empty so callers can report it. */
19
+ error?: string;
6
20
  }
21
+ /** Model-facing detail for a failed CLI run: stderr first, else the Node error code. */
22
+ export declare function describeCliFailure(res: Pick<CliResult, "stderr" | "error">): string;
23
+ /** Exec one command with optional abort + timeout, resolving instead of throwing.
24
+ * Exported for tests: the captured-output ceiling is part of the contract. */
25
+ export declare function execFileResolved(file: string, args: string[], opts?: {
26
+ signal?: AbortSignal;
27
+ timeoutMs?: number;
28
+ env?: Record<string, string>;
29
+ windowsVerbatimArguments?: boolean;
30
+ }): Promise<CliResult>;
7
31
  /** Options shared by every CLI invocation. */
8
32
  export interface CliOptions {
9
33
  signal?: AbortSignal;
package/lib/cli-exec.js CHANGED
@@ -8,6 +8,27 @@ import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { DEEPTUTOR_BIN, DEEPTUTOR_HOME, LOCAL_BIN, SSH_HOST } from "./config.js";
10
10
  import { resolveLocalCommand } from "./win-cmd.js";
11
+ /**
12
+ * Upper bound for one CLI run's captured stdout/stderr.
13
+ *
14
+ * `deeptutor run --format json` writes one JSON line per streamed chunk, and a
15
+ * chunk is roughly one character of the answer once event metadata is included
16
+ * (~1.5 KB of JSON per output character measured on 1.7.1). A 1500-character
17
+ * answer is therefore ~2 MB, already past Node's 1 MB `execFile` default: the
18
+ * child is killed mid-turn and the only trace is a string `err.code`
19
+ * (`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`) with empty stderr. 64 MB covers a
20
+ * ~40k-character answer, well beyond the CLI's own per-turn token ceiling.
21
+ */
22
+ export const MAX_CLI_OUTPUT_BYTES = 64 * 1024 * 1024;
23
+ /** Model-facing detail for a failed CLI run: stderr first, else the Node error code. */
24
+ export function describeCliFailure(res) {
25
+ const stderr = (res.stderr ?? "").trim();
26
+ if (stderr)
27
+ return stderr;
28
+ if (res.error === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
29
+ return `output exceeded ${MAX_CLI_OUTPUT_BYTES} bytes (see MAX_CLI_OUTPUT_BYTES)`;
30
+ return res.error || "(no stderr)";
31
+ }
11
32
  // Remote runner: reads argv from a JSON file and executes without a shell (avoids all quoting/escaping).
12
33
  const PY_RUNNER = [
13
34
  "import json, subprocess, sys",
@@ -48,14 +69,16 @@ const PY_NOTE = [
48
69
  const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
49
70
  /** Timeout for each notebook-archiver CLI step (list / create / add-md). */
50
71
  const NOTE_STEP_TIMEOUT_MS = 30_000;
51
- /** Exec one command with optional abort + timeout, resolving instead of throwing. */
52
- function execFileResolved(file, args, opts = {}) {
72
+ /** Exec one command with optional abort + timeout, resolving instead of throwing.
73
+ * Exported for tests: the captured-output ceiling is part of the contract. */
74
+ export function execFileResolved(file, args, opts = {}) {
53
75
  return new Promise((resolve) => {
54
76
  const child = execFile(file, args, {
55
77
  env: opts.env ? { ...process.env, ...opts.env } : process.env,
56
78
  windowsHide: true,
57
79
  windowsVerbatimArguments: opts.windowsVerbatimArguments,
58
80
  timeout: opts.timeoutMs,
81
+ maxBuffer: MAX_CLI_OUTPUT_BYTES,
59
82
  }, (err, stdout, stderr) => {
60
83
  const e = err;
61
84
  resolve({
@@ -63,6 +86,7 @@ function execFileResolved(file, args, opts = {}) {
63
86
  stderr: stderr ?? "",
64
87
  code: e ? (typeof e.code === "number" ? e.code : e.code === "ENOENT" ? 127 : 1) : 0,
65
88
  killed: e?.killed ?? false,
89
+ error: e && typeof e.code === "string" ? String(e.code) : undefined,
66
90
  });
67
91
  });
68
92
  opts.signal?.addEventListener("abort", () => {
@@ -107,6 +131,7 @@ async function sshExec(argv, opts = {}) {
107
131
  stderr: (stderr ?? "").trim(),
108
132
  code: res.code ?? -1,
109
133
  killed: res.killed,
134
+ error: res.error,
110
135
  };
111
136
  }
112
137
  /**
@@ -131,12 +156,19 @@ export async function runCli(args, opts = {}) {
131
156
  windowsVerbatimArguments: cmd.windowsVerbatimArguments,
132
157
  });
133
158
  const out = res.stdout ?? "";
134
- const [stdout, stderr] = out.split("__DT_STDERR__");
159
+ // Local mode is a direct execFile: the child's real stderr lands in res.stderr, and
160
+ // the __DT_STDERR__ marker only exists on the ssh path (the remote Python runner
161
+ // folds stderr into stdout). Splitting stdout still strips the marker if some wrapper
162
+ // emits one, but res.stderr must win — reading only the split tail threw away every
163
+ // Python traceback and reported "(no stderr)" for local runs (issue #20).
164
+ const [stdout, wrapped] = out.split("__DT_STDERR__");
165
+ const captured = (res.stderr ?? "").trim();
135
166
  return {
136
167
  stdout: stdout ?? "",
137
- stderr: (stderr ?? "").trim(),
168
+ stderr: captured || (wrapped ?? "").trim(),
138
169
  code: res.code,
139
170
  killed: res.killed,
171
+ error: res.error,
140
172
  };
141
173
  }
142
174
  /** Local-deployment notebook archiver: list → parse ID → create → add-md */
@@ -1,5 +1,36 @@
1
- /** Absolute path to the bundled md-to-html converter script. */
1
+ /** Absolute path to the bundled md-to-html converter (the last-resort fallback). */
2
2
  export declare const HTML_DOC_SCRIPT: string;
3
+ /** dsh home: `$DSH_HOME`, else `~/.dsh` (mirrors scripts/install-profile.mjs). */
4
+ export declare function dshHome(): string;
5
+ export type Resolution = {
6
+ ok: true;
7
+ path: string;
8
+ source: string;
9
+ } | {
10
+ ok: false;
11
+ error: string;
12
+ };
13
+ /**
14
+ * Resolve the converter: explicit `$HTML_DOC_SCRIPT`, the installed html-doc
15
+ * skill, then the package copy. A set-but-missing override is an error — a typo
16
+ * must not silently fall through to a different converter.
17
+ */
18
+ export declare function resolveHtmlDocScript(): Resolution;
19
+ /**
20
+ * Resolve the HTML template: explicit `$HTML_DOC_TEMPLATE`, the installed
21
+ * html-doc skill, then the package copy. A set-but-missing override is an error.
22
+ */
23
+ export declare function resolveHtmlDocTemplate(): Resolution;
24
+ /**
25
+ * Why `html` cannot be a relative path (null when it is acceptable).
26
+ *
27
+ * A tool has no workspace root: dsh hands `execute` only the agent identity
28
+ * (`exec.agent.id`) plus the call's signal, and the MCP server has no session at
29
+ * all, so a relative path could only resolve against the host process cwd — the
30
+ * dsh config directory, not the caller's project. Requiring an absolute path is
31
+ * the one contract that can never write somewhere unexpected.
32
+ */
33
+ export declare function htmlTargetProblem(out: string): string | null;
3
34
  /**
4
35
  * Render a learning answer to a self-contained local HTML file (dark/light toggle,
5
36
  * TOC, collapsible quiz answers, print-friendly). Also writes the companion .md
@@ -1,14 +1,113 @@
1
1
  /**
2
2
  * DeepTutor bridge: render a learning answer to a self-contained local HTML file
3
- * via the bundled zero-dependency Node converter (scripts/md-to-html.js, from
4
- * the html-doc skill). Kept as a standalone module (node built-ins only).
3
+ * via the zero-dependency Node converter (scripts/md-to-html.js, from the
4
+ * html-doc skill). Kept as a standalone module (node built-ins only).
5
+ *
6
+ * Two things are resolved at call time, first hit wins, and both are reported in
7
+ * the tool note so the order is visible without reading this file:
8
+ *
9
+ * converter $HTML_DOC_SCRIPT
10
+ * → <DSH_HOME>/skills/html-doc/scripts/md-to-html.js (installed skill)
11
+ * → the bundled package copy
12
+ * template $HTML_DOC_TEMPLATE
13
+ * → <DSH_HOME>/skills/html-doc/assets/template.html (installed skill)
14
+ * → the bundled package template
15
+ *
16
+ * <DSH_HOME> is `$DSH_HOME` or `~/.dsh` (same rule as scripts/install-profile.mjs),
17
+ * and `<DSH_HOME>/skills/<name>/` is where that installer copies the bundled
18
+ * skills for dsh auto-discovery. Preferring the installed copy means editing the
19
+ * installed html-doc template (say, wiring KaTeX in) changes `deeptutor_run`'s
20
+ * HTML immediately, instead of requiring edits to two files inside the plugin
21
+ * package that the next upgrade overwrites.
5
22
  */
6
23
  import { execFile } from "node:child_process";
7
24
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
- import { basename, dirname, extname, join, resolve } from "node:path";
25
+ import { homedir } from "node:os";
26
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
9
27
  import { fileURLToPath } from "node:url";
10
- /** Absolute path to the bundled md-to-html converter script. */
11
- export const HTML_DOC_SCRIPT = join(fileURLToPath(new URL("..", import.meta.url)), "scripts", "md-to-html.js");
28
+ const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
29
+ /** Absolute path to the bundled md-to-html converter (the last-resort fallback). */
30
+ export const HTML_DOC_SCRIPT = join(PKG_ROOT, "scripts", "md-to-html.js");
31
+ /** Bundled html-doc template, shipped inside the package's copy of the skill. */
32
+ const BUNDLED_TEMPLATE = join(PKG_ROOT, ".agents", "skills", "html-doc", "assets", "template.html");
33
+ /** dsh home: `$DSH_HOME`, else `~/.dsh` (mirrors scripts/install-profile.mjs). */
34
+ export function dshHome() {
35
+ return process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
36
+ }
37
+ /** `<DSH_HOME>/skills/html-doc/<segments…>` — the installer's auto-discovered skill. */
38
+ function installedSkill(...segments) {
39
+ return join(dshHome(), "skills", "html-doc", ...segments);
40
+ }
41
+ /**
42
+ * Resolve the converter: explicit `$HTML_DOC_SCRIPT`, the installed html-doc
43
+ * skill, then the package copy. A set-but-missing override is an error — a typo
44
+ * must not silently fall through to a different converter.
45
+ */
46
+ export function resolveHtmlDocScript() {
47
+ const explicit = process.env.HTML_DOC_SCRIPT?.trim();
48
+ if (explicit) {
49
+ return existsSync(explicit)
50
+ ? { ok: true, path: explicit, source: "HTML_DOC_SCRIPT" }
51
+ : { ok: false, error: `HTML_DOC_SCRIPT points at a missing file: ${explicit}` };
52
+ }
53
+ const installed = installedSkill("scripts", "md-to-html.js");
54
+ if (existsSync(installed))
55
+ return { ok: true, path: installed, source: "installed skill" };
56
+ if (existsSync(HTML_DOC_SCRIPT))
57
+ return { ok: true, path: HTML_DOC_SCRIPT, source: "bundled package" };
58
+ return {
59
+ ok: false,
60
+ error: `html-doc converter not found (looked in: HTML_DOC_SCRIPT, ${installed}, ${HTML_DOC_SCRIPT})`,
61
+ };
62
+ }
63
+ /**
64
+ * Resolve the HTML template: explicit `$HTML_DOC_TEMPLATE`, the installed
65
+ * html-doc skill, then the package copy. A set-but-missing override is an error.
66
+ */
67
+ export function resolveHtmlDocTemplate() {
68
+ const explicit = process.env.HTML_DOC_TEMPLATE?.trim();
69
+ if (explicit) {
70
+ return existsSync(explicit)
71
+ ? { ok: true, path: explicit, source: "HTML_DOC_TEMPLATE" }
72
+ : { ok: false, error: `HTML_DOC_TEMPLATE points at a missing file: ${explicit}` };
73
+ }
74
+ const installed = installedSkill("assets", "template.html");
75
+ if (existsSync(installed))
76
+ return { ok: true, path: installed, source: "installed skill" };
77
+ if (existsSync(BUNDLED_TEMPLATE))
78
+ return { ok: true, path: BUNDLED_TEMPLATE, source: "bundled package" };
79
+ return {
80
+ ok: false,
81
+ error: `html-doc template not found (looked in: HTML_DOC_TEMPLATE, ${installed}, ${BUNDLED_TEMPLATE})`,
82
+ };
83
+ }
84
+ /**
85
+ * Why `html` cannot be a relative path (null when it is acceptable).
86
+ *
87
+ * A tool has no workspace root: dsh hands `execute` only the agent identity
88
+ * (`exec.agent.id`) plus the call's signal, and the MCP server has no session at
89
+ * all, so a relative path could only resolve against the host process cwd — the
90
+ * dsh config directory, not the caller's project. Requiring an absolute path is
91
+ * the one contract that can never write somewhere unexpected.
92
+ */
93
+ export function htmlTargetProblem(out) {
94
+ if (isAbsolute(out.trim()))
95
+ return null;
96
+ return (`html must be an absolute path, got "${out}". Nothing was rendered: this tool cannot ` +
97
+ `resolve a relative path against your working directory (it has no workspace root, so a ` +
98
+ `relative path would land under the host process cwd instead). Prefix it with your working ` +
99
+ `directory, e.g. "<workspace>/data/study/topic/knowledge.html".`);
100
+ }
101
+ /**
102
+ * Extra CLI flag for the converter. The converter already picks up the template
103
+ * sitting next to it, so only pass `--template` when the resolved template is
104
+ * not that sibling — a plain bundled or installed run stays flag-free, and older
105
+ * converter copies that predate `--template` keep working on their own layout.
106
+ */
107
+ function templateArgs(scriptPath, templatePath) {
108
+ const native = resolve(join(dirname(scriptPath), "..", "assets", "template.html"));
109
+ return native === resolve(templatePath) ? [] : ["--template", templatePath];
110
+ }
12
111
  /**
13
112
  * Render a learning answer to a self-contained local HTML file (dark/light toggle,
14
113
  * TOC, collapsible quiz answers, print-friendly). Also writes the companion .md
@@ -21,19 +120,31 @@ export async function renderHtml(params, answer) {
21
120
  const out = params.html;
22
121
  if (!out)
23
122
  return "";
24
- const htmlPath = extname(out).toLowerCase() === ".html" ? resolve(out) : resolve(out + ".html");
123
+ const problem = htmlTargetProblem(out);
124
+ if (problem)
125
+ return `\n[html] ✗ ${problem}`;
126
+ const target = out.trim();
127
+ const htmlPath = extname(target).toLowerCase() === ".html" ? resolve(target) : resolve(target + ".html");
25
128
  const mdPath = htmlPath.slice(0, -extname(htmlPath).length) + ".md";
26
129
  const title = params.html_title || basename(htmlPath, extname(htmlPath));
130
+ const script = resolveHtmlDocScript();
131
+ if (!script.ok)
132
+ return `\n[html] ✗ ${script.error}`;
133
+ const template = resolveHtmlDocTemplate();
134
+ if (!template.ok)
135
+ return `\n[html] ✗ ${template.error}`;
27
136
  try {
28
- if (!existsSync(HTML_DOC_SCRIPT)) {
29
- return `\n[html] ✗ html-doc converter not found: ${HTML_DOC_SCRIPT}`;
30
- }
31
137
  mkdirSync(dirname(htmlPath), { recursive: true });
32
138
  writeFileSync(mdPath, answer || "(no answer)", "utf8");
139
+ const argv = [script.path, mdPath, "--out", htmlPath, "--title", title, "--toc"];
33
140
  await new Promise((res, rej) => {
34
- execFile(process.execPath, [HTML_DOC_SCRIPT, mdPath, "--out", htmlPath, "--title", title, "--toc"], { windowsHide: true, timeout: 60_000 }, (err, _stdout, stderr) => (err ? rej(new Error(stderr || err.message)) : res()));
141
+ execFile(process.execPath, [...argv, ...templateArgs(script.path, template.path)], { windowsHide: true, timeout: 60_000 }, (err, _stdout, stderr) => (err ? rej(new Error(stderr || err.message)) : res()));
35
142
  });
36
- return `\n[html] ✓ rendered (html-doc skill)\n[html] page: ${htmlPath}\n[html] source: ${mdPath}`;
143
+ return (`\n[html] ✓ rendered (html-doc skill)` +
144
+ `\n[html] page: ${htmlPath}` +
145
+ `\n[html] source: ${mdPath}` +
146
+ `\n[html] converter: ${script.path} (${script.source})` +
147
+ `\n[html] template: ${template.path} (${template.source})`);
37
148
  }
38
149
  catch (err) {
39
150
  return `\n[html] ✗ rendering failed: ${err?.message ?? String(err)}`;
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
- import { HTML_DOC_SCRIPT } from "./html-render.js";
2
+ import { resolveHtmlDocScript } from "./html-render.js";
3
3
  import { TOOLS } from "./tools.js";
4
4
  export const name = "dsh-deeptutor";
5
5
  export const inject = ["tools"];
@@ -20,7 +20,8 @@ const OUTPUT = {
20
20
  render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
21
21
  };
22
22
  export function apply(ctx) {
23
- console.log(`[dsh-deeptutor] plugin loaded (html script: ${HTML_DOC_SCRIPT})`);
23
+ const converter = resolveHtmlDocScript();
24
+ console.log(`[dsh-deeptutor] plugin loaded (html-doc converter: ${converter.ok ? `${converter.path} (${converter.source})` : converter.error})`);
24
25
  for (const tool of TOOLS) {
25
26
  ctx.tools.register(defineTool({
26
27
  name: tool.name,
package/lib/tools.js CHANGED
@@ -20,7 +20,7 @@ import { cliNoteLocal, cliNoteRemote, runCli } from "./cli-exec.js";
20
20
  import { classifyCliArgs } from "./cli-policy.js";
21
21
  import { configGuide, configHint, SSH_HOST, truncate } from "./config.js";
22
22
  import { describeCapabilities, discoverCapabilities } from "./discovery.js";
23
- import { renderHtml } from "./html-render.js";
23
+ import { renderHtml, htmlTargetProblem } from "./html-render.js";
24
24
  import { cliRunTurn, fmtTurnResult } from "./turn.js";
25
25
  /** Plain JSON Schema (object) for one tool's parameters. Pure. */
26
26
  export function toJsonSchema(params) {
@@ -95,11 +95,16 @@ const runTool = {
95
95
  max_chars: { type: "number", description: "Max characters of the answer returned, default 30000" },
96
96
  html: {
97
97
  type: "string",
98
- description: "Optional output path for a self-contained local HTML render of the answer (also writes the companion .md next to it), e.g. data/study/abstract-class/knowledge.html. Relative paths resolve against the agent's working directory. Set it when the user wants browsable/printable study material.",
98
+ description: "Optional absolute output path for a self-contained local HTML render of the answer (also writes the companion .md next to it), e.g. /home/me/study/abstract-class/knowledge.html. Must be absolute: this tool has no workspace root, so relative paths are rejected instead of silently landing under the host process cwd. The template comes from the installed html-doc skill when present ($HTML_DOC_TEMPLATE overrides it); see SKILL.md. Set it when the user wants browsable/printable study material.",
99
99
  },
100
100
  html_title: { type: "string", description: "HTML page title (default: derived from the html file name)" },
101
101
  },
102
102
  async execute(args, signal) {
103
+ // Fail before the (possibly minutes-long) DeepTutor turn: a relative html
104
+ // path cannot be resolved against the caller's workspace, so reject it here.
105
+ const htmlProblem = args.html ? htmlTargetProblem(String(args.html)) : null;
106
+ if (htmlProblem)
107
+ throw new Error(`[deeptutor_run] ${htmlProblem}`);
103
108
  const timeoutMs = (args.timeout ?? 600) * 1000;
104
109
  const maxChars = args.max_chars ?? 30000;
105
110
  const capability = String(args.capability).trim();
package/lib/turn.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * (local or over SSH), JSONL event parsing and result formatting.
4
4
  */
5
5
  import { truncate } from "./config.js";
6
- import { runCli } from "./cli-exec.js";
6
+ import { describeCliFailure, runCli } from "./cli-exec.js";
7
7
  /** Fold one CLI JSONL event into the running turn state. Exported for unit tests. */
8
8
  export function collectEvent(state, e) {
9
9
  if (e.type === "content" && typeof e.content === "string")
@@ -49,14 +49,17 @@ export function buildRunArgs(params) {
49
49
  }
50
50
  /** Execute one turn via the CLI (local or SSH). `run` is injectable for tests. */
51
51
  export async function cliRunTurn(params, timeoutMs, signal, run = runCli) {
52
- const { stdout, stderr, code, killed } = await run(buildRunArgs(params), { signal, timeoutMs });
52
+ const { stdout, stderr, code, killed, error } = await run(buildRunArgs(params), {
53
+ signal,
54
+ timeoutMs,
55
+ });
53
56
  if (killed) {
54
57
  // A kill can come from the user cancelling (signal) or from the timeout;
55
58
  // report the real reason instead of always blaming the timeout.
56
59
  throw new Error(signal?.aborted ? "Aborted" : `CLI execution timed out (${Math.round(timeoutMs / 1000)}s)`);
57
60
  }
58
61
  if (code !== 0)
59
- throw new Error(`CLI exit=${code}: ${stderr || "(no stderr)"}`);
62
+ throw new Error(`CLI exit=${code}: ${describeCliFailure({ stderr, error })}`);
60
63
  const state = { answer: "", errors: [], toolCalls: [] };
61
64
  for (const line of stdout.split("\n")) {
62
65
  if (!line.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-deeptutor",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Learning assistant extension for DeepSeek Harness (dsh) and Claude Code (MCP): DeepTutor tutoring for your agent: deep explanations, self-test questions, learning paths, personal knowledge-base search (RAG), note archiving, and any other DeepTutor CLI command (drives the DeepTutor CLI locally or over SSH).",
5
5
  "keywords": [
6
6
  "dsh",
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Launcher for the DeepTutor MCP server, used by the Claude Code plugin
4
- * (.mcp.json → `node ${CLAUDE_PLUGIN_ROOT}/scripts/mcp-launch.mjs`).
4
+ * (`mcpServers` in .claude-plugin/plugin.json → `node ${CLAUDE_PLUGIN_ROOT}/scripts/mcp-launch.mjs`).
5
+ * The config lives in plugin.json, not a root .mcp.json: Claude Code also reads
6
+ * a root .mcp.json as project-scope config whenever this repo is the working
7
+ * directory, where ${CLAUDE_PLUGIN_ROOT} is undefined.
5
8
  *
6
9
  * A plugin installed from the marketplace is a plain git checkout: it has the
7
10
  * TypeScript sources but no compiled lib/ and no node_modules. The server has no
@@ -4,9 +4,12 @@
4
4
  *
5
5
  * Usage:
6
6
  * node md-to-html.js <input.md> [--out out.html] [--title "T"] [--theme dark|light]
7
- * [--toc] [--lang zh-CN] [--no-collapse]
7
+ * [--toc] [--lang zh-CN] [--no-collapse] [--template template.html]
8
8
  * cat file.md | node md-to-html.js --title "T" --out out.html
9
9
  *
10
+ * Template resolution order: --template, $HTML_DOC_TEMPLATE, the assets/ copy
11
+ * next to this script, then the package-root duplicate's skill copy.
12
+ *
10
13
  * Features: headings, fenced code blocks, inline code/bold/italic/links, lists,
11
14
  * task lists, blockquotes, pipe tables, hr, quiz answer collapsing (<details>),
12
15
  * raw HTML passthrough, HTML escaping. Zero npm dependencies (Node >= 20.11 — uses import.meta.dirname).
@@ -27,9 +30,10 @@ function parseArgs(argv) {
27
30
  toc: false,
28
31
  lang: "zh-CN",
29
32
  collapse: true,
33
+ template: null,
30
34
  };
31
35
  const positional = [];
32
- const valueFlags = new Set(["--out", "--title", "--theme", "--lang"]);
36
+ const valueFlags = new Set(["--out", "--title", "--theme", "--lang", "--template"]);
33
37
  for (let i = 0; i < argv.length; i++) {
34
38
  const a = argv[i];
35
39
  if (a === "--toc") opts.toc = true;
@@ -50,7 +54,11 @@ function parseArgs(argv) {
50
54
  fail(`Invalid --theme value "${value}": expected dark or light`);
51
55
  }
52
56
  opts.theme = value;
53
- } else if (a === "--lang") opts.lang = value;
57
+ } else if (a === "--lang") {
58
+ opts.lang = value;
59
+ } else if (a === "--template") {
60
+ opts.template = value;
61
+ }
54
62
  } else if (a.startsWith("--")) {
55
63
  fail(`Unknown option: ${a} (run with --help for usage)`);
56
64
  } else positional.push(a);
@@ -77,6 +85,8 @@ Options:
77
85
  --theme <mode> dark | light (default: light; toggle also available in page)
78
86
  --toc generate a table of contents from h2/h3 headings
79
87
  --lang <code> html lang attribute (default: zh-CN)
88
+ --template <file> HTML template to use (default: the assets/ copy next to this
89
+ script, or $HTML_DOC_TEMPLATE when set)
80
90
  --no-collapse disable quiz-answer collapsing
81
91
  --help show this help`);
82
92
  }
@@ -351,11 +361,25 @@ function main() {
351
361
  const generated = new Date().toISOString().slice(0, 10);
352
362
  // The same converter ships in two locations (.agents/skills/html-doc/scripts/
353
363
  // and a package-root scripts/ copy); resolve the template wherever we are.
364
+ // Order: --template, $HTML_DOC_TEMPLATE, then the copies next to this script.
365
+ // An explicit override that does not exist is an error: a typo must not
366
+ // silently fall through to a different template.
367
+ const explicitTemplate = opts.template || process.env.HTML_DOC_TEMPLATE || null;
354
368
  const templateCandidates = [
355
369
  path.join(import.meta.dirname, "..", "assets", "template.html"),
356
370
  path.join(import.meta.dirname, "..", ".agents", "skills", "html-doc", "assets", "template.html"),
357
371
  ];
358
- const templatePath = templateCandidates.find((p) => fs.existsSync(p));
372
+ let templatePath;
373
+ if (explicitTemplate) {
374
+ if (!fs.existsSync(explicitTemplate)) {
375
+ fail(
376
+ `template not found: ${explicitTemplate} (from ${opts.template ? "--template" : "HTML_DOC_TEMPLATE"})`,
377
+ );
378
+ }
379
+ templatePath = explicitTemplate;
380
+ } else {
381
+ templatePath = templateCandidates.find((p) => fs.existsSync(p));
382
+ }
359
383
  if (!templatePath) {
360
384
  fail(`template.html not found (looked in: ${templateCandidates.join(", ")})`);
361
385
  }
@@ -380,7 +404,7 @@ function main() {
380
404
  if (opts.out) {
381
405
  fs.writeFileSync(opts.out, template, "utf8");
382
406
  console.log(
383
- `[html-doc] wrote ${path.resolve(opts.out)} (${Buffer.byteLength(template)} bytes)`,
407
+ `[html-doc] wrote ${path.resolve(opts.out)} (${Buffer.byteLength(template)} bytes, template: ${templatePath})`,
384
408
  );
385
409
  } else {
386
410
  process.stdout.write(template);
package/src/cli-exec.ts CHANGED
@@ -9,11 +9,35 @@ import { join } from "node:path";
9
9
  import { DEEPTUTOR_BIN, DEEPTUTOR_HOME, LOCAL_BIN, SSH_HOST } from "./config.ts";
10
10
  import { resolveLocalCommand } from "./win-cmd.ts";
11
11
 
12
+ /**
13
+ * Upper bound for one CLI run's captured stdout/stderr.
14
+ *
15
+ * `deeptutor run --format json` writes one JSON line per streamed chunk, and a
16
+ * chunk is roughly one character of the answer once event metadata is included
17
+ * (~1.5 KB of JSON per output character measured on 1.7.1). A 1500-character
18
+ * answer is therefore ~2 MB, already past Node's 1 MB `execFile` default: the
19
+ * child is killed mid-turn and the only trace is a string `err.code`
20
+ * (`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`) with empty stderr. 64 MB covers a
21
+ * ~40k-character answer, well beyond the CLI's own per-turn token ceiling.
22
+ */
23
+ export const MAX_CLI_OUTPUT_BYTES = 64 * 1024 * 1024;
24
+
12
25
  export interface CliResult {
13
26
  stdout: string;
14
27
  stderr: string;
15
28
  code: number;
16
29
  killed: boolean;
30
+ /** Node-level failure code, kept when stderr is empty so callers can report it. */
31
+ error?: string;
32
+ }
33
+
34
+ /** Model-facing detail for a failed CLI run: stderr first, else the Node error code. */
35
+ export function describeCliFailure(res: Pick<CliResult, "stderr" | "error">): string {
36
+ const stderr = (res.stderr ?? "").trim();
37
+ if (stderr) return stderr;
38
+ if (res.error === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
39
+ return `output exceeded ${MAX_CLI_OUTPUT_BYTES} bytes (see MAX_CLI_OUTPUT_BYTES)`;
40
+ return res.error || "(no stderr)";
17
41
  }
18
42
 
19
43
  // Remote runner: reads argv from a JSON file and executes without a shell (avoids all quoting/escaping).
@@ -60,8 +84,9 @@ const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64");
60
84
  /** Timeout for each notebook-archiver CLI step (list / create / add-md). */
61
85
  const NOTE_STEP_TIMEOUT_MS = 30_000;
62
86
 
63
- /** Exec one command with optional abort + timeout, resolving instead of throwing. */
64
- function execFileResolved(
87
+ /** Exec one command with optional abort + timeout, resolving instead of throwing.
88
+ * Exported for tests: the captured-output ceiling is part of the contract. */
89
+ export function execFileResolved(
65
90
  file: string,
66
91
  args: string[],
67
92
  opts: {
@@ -80,6 +105,7 @@ function execFileResolved(
80
105
  windowsHide: true,
81
106
  windowsVerbatimArguments: opts.windowsVerbatimArguments,
82
107
  timeout: opts.timeoutMs,
108
+ maxBuffer: MAX_CLI_OUTPUT_BYTES,
83
109
  },
84
110
  (err, stdout, stderr) => {
85
111
  const e: any = err;
@@ -88,6 +114,7 @@ function execFileResolved(
88
114
  stderr: stderr ?? "",
89
115
  code: e ? (typeof e.code === "number" ? e.code : e.code === "ENOENT" ? 127 : 1) : 0,
90
116
  killed: e?.killed ?? false,
117
+ error: e && typeof e.code === "string" ? String(e.code) : undefined,
91
118
  });
92
119
  },
93
120
  );
@@ -151,6 +178,7 @@ async function sshExec(argv: string[], opts: CliOptions = {}): Promise<CliResult
151
178
  stderr: (stderr ?? "").trim(),
152
179
  code: res.code ?? -1,
153
180
  killed: res.killed,
181
+ error: res.error,
154
182
  };
155
183
  }
156
184
 
@@ -175,12 +203,19 @@ export async function runCli(args: string[], opts: CliOptions = {}): Promise<Cli
175
203
  windowsVerbatimArguments: cmd.windowsVerbatimArguments,
176
204
  });
177
205
  const out = res.stdout ?? "";
178
- const [stdout, stderr] = out.split("__DT_STDERR__");
206
+ // Local mode is a direct execFile: the child's real stderr lands in res.stderr, and
207
+ // the __DT_STDERR__ marker only exists on the ssh path (the remote Python runner
208
+ // folds stderr into stdout). Splitting stdout still strips the marker if some wrapper
209
+ // emits one, but res.stderr must win — reading only the split tail threw away every
210
+ // Python traceback and reported "(no stderr)" for local runs (issue #20).
211
+ const [stdout, wrapped] = out.split("__DT_STDERR__");
212
+ const captured = (res.stderr ?? "").trim();
179
213
  return {
180
214
  stdout: stdout ?? "",
181
- stderr: (stderr ?? "").trim(),
215
+ stderr: captured || (wrapped ?? "").trim(),
182
216
  code: res.code,
183
217
  killed: res.killed,
218
+ error: res.error,
184
219
  };
185
220
  }
186
221
 
@@ -1,19 +1,123 @@
1
1
  /**
2
2
  * DeepTutor bridge: render a learning answer to a self-contained local HTML file
3
- * via the bundled zero-dependency Node converter (scripts/md-to-html.js, from
4
- * the html-doc skill). Kept as a standalone module (node built-ins only).
3
+ * via the zero-dependency Node converter (scripts/md-to-html.js, from the
4
+ * html-doc skill). Kept as a standalone module (node built-ins only).
5
+ *
6
+ * Two things are resolved at call time, first hit wins, and both are reported in
7
+ * the tool note so the order is visible without reading this file:
8
+ *
9
+ * converter $HTML_DOC_SCRIPT
10
+ * → <DSH_HOME>/skills/html-doc/scripts/md-to-html.js (installed skill)
11
+ * → the bundled package copy
12
+ * template $HTML_DOC_TEMPLATE
13
+ * → <DSH_HOME>/skills/html-doc/assets/template.html (installed skill)
14
+ * → the bundled package template
15
+ *
16
+ * <DSH_HOME> is `$DSH_HOME` or `~/.dsh` (same rule as scripts/install-profile.mjs),
17
+ * and `<DSH_HOME>/skills/<name>/` is where that installer copies the bundled
18
+ * skills for dsh auto-discovery. Preferring the installed copy means editing the
19
+ * installed html-doc template (say, wiring KaTeX in) changes `deeptutor_run`'s
20
+ * HTML immediately, instead of requiring edits to two files inside the plugin
21
+ * package that the next upgrade overwrites.
5
22
  */
6
23
  import { execFile } from "node:child_process";
7
24
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
- import { basename, dirname, extname, join, resolve } from "node:path";
25
+ import { homedir } from "node:os";
26
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
9
27
  import { fileURLToPath } from "node:url";
10
28
 
11
- /** Absolute path to the bundled md-to-html converter script. */
12
- export const HTML_DOC_SCRIPT = join(
13
- fileURLToPath(new URL("..", import.meta.url)),
14
- "scripts",
15
- "md-to-html.js",
16
- );
29
+ const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
30
+
31
+ /** Absolute path to the bundled md-to-html converter (the last-resort fallback). */
32
+ export const HTML_DOC_SCRIPT = join(PKG_ROOT, "scripts", "md-to-html.js");
33
+
34
+ /** Bundled html-doc template, shipped inside the package's copy of the skill. */
35
+ const BUNDLED_TEMPLATE = join(PKG_ROOT, ".agents", "skills", "html-doc", "assets", "template.html");
36
+
37
+ /** dsh home: `$DSH_HOME`, else `~/.dsh` (mirrors scripts/install-profile.mjs). */
38
+ export function dshHome(): string {
39
+ return process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
40
+ }
41
+
42
+ /** `<DSH_HOME>/skills/html-doc/<segments…>` — the installer's auto-discovered skill. */
43
+ function installedSkill(...segments: string[]): string {
44
+ return join(dshHome(), "skills", "html-doc", ...segments);
45
+ }
46
+
47
+ export type Resolution =
48
+ | { ok: true; path: string; source: string }
49
+ | { ok: false; error: string };
50
+
51
+ /**
52
+ * Resolve the converter: explicit `$HTML_DOC_SCRIPT`, the installed html-doc
53
+ * skill, then the package copy. A set-but-missing override is an error — a typo
54
+ * must not silently fall through to a different converter.
55
+ */
56
+ export function resolveHtmlDocScript(): Resolution {
57
+ const explicit = process.env.HTML_DOC_SCRIPT?.trim();
58
+ if (explicit) {
59
+ return existsSync(explicit)
60
+ ? { ok: true, path: explicit, source: "HTML_DOC_SCRIPT" }
61
+ : { ok: false, error: `HTML_DOC_SCRIPT points at a missing file: ${explicit}` };
62
+ }
63
+ const installed = installedSkill("scripts", "md-to-html.js");
64
+ if (existsSync(installed)) return { ok: true, path: installed, source: "installed skill" };
65
+ if (existsSync(HTML_DOC_SCRIPT)) return { ok: true, path: HTML_DOC_SCRIPT, source: "bundled package" };
66
+ return {
67
+ ok: false,
68
+ error: `html-doc converter not found (looked in: HTML_DOC_SCRIPT, ${installed}, ${HTML_DOC_SCRIPT})`,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Resolve the HTML template: explicit `$HTML_DOC_TEMPLATE`, the installed
74
+ * html-doc skill, then the package copy. A set-but-missing override is an error.
75
+ */
76
+ export function resolveHtmlDocTemplate(): Resolution {
77
+ const explicit = process.env.HTML_DOC_TEMPLATE?.trim();
78
+ if (explicit) {
79
+ return existsSync(explicit)
80
+ ? { ok: true, path: explicit, source: "HTML_DOC_TEMPLATE" }
81
+ : { ok: false, error: `HTML_DOC_TEMPLATE points at a missing file: ${explicit}` };
82
+ }
83
+ const installed = installedSkill("assets", "template.html");
84
+ if (existsSync(installed)) return { ok: true, path: installed, source: "installed skill" };
85
+ if (existsSync(BUNDLED_TEMPLATE)) return { ok: true, path: BUNDLED_TEMPLATE, source: "bundled package" };
86
+ return {
87
+ ok: false,
88
+ error: `html-doc template not found (looked in: HTML_DOC_TEMPLATE, ${installed}, ${BUNDLED_TEMPLATE})`,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Why `html` cannot be a relative path (null when it is acceptable).
94
+ *
95
+ * A tool has no workspace root: dsh hands `execute` only the agent identity
96
+ * (`exec.agent.id`) plus the call's signal, and the MCP server has no session at
97
+ * all, so a relative path could only resolve against the host process cwd — the
98
+ * dsh config directory, not the caller's project. Requiring an absolute path is
99
+ * the one contract that can never write somewhere unexpected.
100
+ */
101
+ export function htmlTargetProblem(out: string): string | null {
102
+ if (isAbsolute(out.trim())) return null;
103
+ return (
104
+ `html must be an absolute path, got "${out}". Nothing was rendered: this tool cannot ` +
105
+ `resolve a relative path against your working directory (it has no workspace root, so a ` +
106
+ `relative path would land under the host process cwd instead). Prefix it with your working ` +
107
+ `directory, e.g. "<workspace>/data/study/topic/knowledge.html".`
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Extra CLI flag for the converter. The converter already picks up the template
113
+ * sitting next to it, so only pass `--template` when the resolved template is
114
+ * not that sibling — a plain bundled or installed run stays flag-free, and older
115
+ * converter copies that predate `--template` keep working on their own layout.
116
+ */
117
+ function templateArgs(scriptPath: string, templatePath: string): string[] {
118
+ const native = resolve(join(dirname(scriptPath), "..", "assets", "template.html"));
119
+ return native === resolve(templatePath) ? [] : ["--template", templatePath];
120
+ }
17
121
 
18
122
  /**
19
123
  * Render a learning answer to a self-contained local HTML file (dark/light toggle,
@@ -29,24 +133,36 @@ export async function renderHtml(
29
133
  ): Promise<string> {
30
134
  const out = params.html;
31
135
  if (!out) return "";
32
- const htmlPath = extname(out).toLowerCase() === ".html" ? resolve(out) : resolve(out + ".html");
136
+ const problem = htmlTargetProblem(out);
137
+ if (problem) return `\n[html] ✗ ${problem}`;
138
+ const target = out.trim();
139
+ const htmlPath =
140
+ extname(target).toLowerCase() === ".html" ? resolve(target) : resolve(target + ".html");
33
141
  const mdPath = htmlPath.slice(0, -extname(htmlPath).length) + ".md";
34
142
  const title = params.html_title || basename(htmlPath, extname(htmlPath));
143
+ const script = resolveHtmlDocScript();
144
+ if (!script.ok) return `\n[html] ✗ ${script.error}`;
145
+ const template = resolveHtmlDocTemplate();
146
+ if (!template.ok) return `\n[html] ✗ ${template.error}`;
35
147
  try {
36
- if (!existsSync(HTML_DOC_SCRIPT)) {
37
- return `\n[html] ✗ html-doc converter not found: ${HTML_DOC_SCRIPT}`;
38
- }
39
148
  mkdirSync(dirname(htmlPath), { recursive: true });
40
149
  writeFileSync(mdPath, answer || "(no answer)", "utf8");
150
+ const argv = [script.path, mdPath, "--out", htmlPath, "--title", title, "--toc"];
41
151
  await new Promise<void>((res, rej) => {
42
152
  execFile(
43
153
  process.execPath,
44
- [HTML_DOC_SCRIPT, mdPath, "--out", htmlPath, "--title", title, "--toc"],
154
+ [...argv, ...templateArgs(script.path, template.path)],
45
155
  { windowsHide: true, timeout: 60_000 },
46
156
  (err, _stdout, stderr) => (err ? rej(new Error(stderr || err.message)) : res()),
47
157
  );
48
158
  });
49
- return `\n[html] ✓ rendered (html-doc skill)\n[html] page: ${htmlPath}\n[html] source: ${mdPath}`;
159
+ return (
160
+ `\n[html] ✓ rendered (html-doc skill)` +
161
+ `\n[html] page: ${htmlPath}` +
162
+ `\n[html] source: ${mdPath}` +
163
+ `\n[html] converter: ${script.path} (${script.source})` +
164
+ `\n[html] template: ${template.path} (${template.source})`
165
+ );
50
166
  } catch (err: any) {
51
167
  return `\n[html] ✗ rendering failed: ${err?.message ?? String(err)}`;
52
168
  }
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import type { Context } from "@deepseek-ai/cordis";
21
21
  import { defineTool } from "@deepseek-ai/dsh-tools";
22
- import { HTML_DOC_SCRIPT } from "./html-render.ts";
22
+ import { resolveHtmlDocScript } from "./html-render.ts";
23
23
  import { TOOLS } from "./tools.ts";
24
24
 
25
25
  export const name = "dsh-deeptutor";
@@ -42,7 +42,10 @@ const OUTPUT = {
42
42
  } as const;
43
43
 
44
44
  export function apply(ctx: Context) {
45
- console.log(`[dsh-deeptutor] plugin loaded (html script: ${HTML_DOC_SCRIPT})`);
45
+ const converter = resolveHtmlDocScript();
46
+ console.log(
47
+ `[dsh-deeptutor] plugin loaded (html-doc converter: ${converter.ok ? `${converter.path} (${converter.source})` : converter.error})`,
48
+ );
46
49
  for (const tool of TOOLS) {
47
50
  ctx.tools.register(
48
51
  defineTool({
package/src/tools.ts CHANGED
@@ -20,7 +20,7 @@ import { cliNoteLocal, cliNoteRemote, runCli } from "./cli-exec.ts";
20
20
  import { classifyCliArgs } from "./cli-policy.ts";
21
21
  import { configGuide, configHint, SSH_HOST, truncate } from "./config.ts";
22
22
  import { describeCapabilities, discoverCapabilities } from "./discovery.ts";
23
- import { renderHtml } from "./html-render.ts";
23
+ import { renderHtml, htmlTargetProblem } from "./html-render.ts";
24
24
  import { cliRunTurn, fmtTurnResult } from "./turn.ts";
25
25
 
26
26
  interface Base {
@@ -134,11 +134,15 @@ const runTool: ToolSpec = {
134
134
  html: {
135
135
  type: "string",
136
136
  description:
137
- "Optional output path for a self-contained local HTML render of the answer (also writes the companion .md next to it), e.g. data/study/abstract-class/knowledge.html. Relative paths resolve against the agent's working directory. Set it when the user wants browsable/printable study material.",
137
+ "Optional absolute output path for a self-contained local HTML render of the answer (also writes the companion .md next to it), e.g. /home/me/study/abstract-class/knowledge.html. Must be absolute: this tool has no workspace root, so relative paths are rejected instead of silently landing under the host process cwd. The template comes from the installed html-doc skill when present ($HTML_DOC_TEMPLATE overrides it); see SKILL.md. Set it when the user wants browsable/printable study material.",
138
138
  },
139
139
  html_title: { type: "string", description: "HTML page title (default: derived from the html file name)" },
140
140
  },
141
141
  async execute(args, signal) {
142
+ // Fail before the (possibly minutes-long) DeepTutor turn: a relative html
143
+ // path cannot be resolved against the caller's workspace, so reject it here.
144
+ const htmlProblem = args.html ? htmlTargetProblem(String(args.html)) : null;
145
+ if (htmlProblem) throw new Error(`[deeptutor_run] ${htmlProblem}`);
142
146
  const timeoutMs = (args.timeout ?? 600) * 1000;
143
147
  const maxChars = args.max_chars ?? 30000;
144
148
  const capability = String(args.capability).trim();
package/src/turn.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * (local or over SSH), JSONL event parsing and result formatting.
4
4
  */
5
5
  import { truncate } from "./config.ts";
6
- import { runCli } from "./cli-exec.ts";
6
+ import { describeCliFailure, runCli } from "./cli-exec.ts";
7
7
 
8
8
  export interface TurnResult {
9
9
  answer: string;
@@ -69,7 +69,10 @@ export async function cliRunTurn(
69
69
  signal?: AbortSignal,
70
70
  run: typeof runCli = runCli,
71
71
  ): Promise<TurnResult> {
72
- const { stdout, stderr, code, killed } = await run(buildRunArgs(params), { signal, timeoutMs });
72
+ const { stdout, stderr, code, killed, error } = await run(buildRunArgs(params), {
73
+ signal,
74
+ timeoutMs,
75
+ });
73
76
  if (killed) {
74
77
  // A kill can come from the user cancelling (signal) or from the timeout;
75
78
  // report the real reason instead of always blaming the timeout.
@@ -77,7 +80,7 @@ export async function cliRunTurn(
77
80
  signal?.aborted ? "Aborted" : `CLI execution timed out (${Math.round(timeoutMs / 1000)}s)`,
78
81
  );
79
82
  }
80
- if (code !== 0) throw new Error(`CLI exit=${code}: ${stderr || "(no stderr)"}`);
83
+ if (code !== 0) throw new Error(`CLI exit=${code}: ${describeCliFailure({ stderr, error })}`);
81
84
  const state: TurnResult = { answer: "", errors: [], toolCalls: [] };
82
85
  for (const line of stdout.split("\n")) {
83
86
  if (!line.trim()) continue;