dsh-deeptutor 0.1.10 → 0.2.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/.agents/skills/deeptutor/README.md +17 -36
- package/.agents/skills/deeptutor/SKILL.md +14 -14
- package/README.md +35 -26
- package/README.zh.md +14 -12
- package/lib/cli-exec.js +13 -2
- package/lib/config.d.ts +6 -6
- package/lib/config.js +16 -17
- package/lib/index.d.ts +8 -9
- package/lib/index.js +5 -111
- package/lib/turn.d.ts +2 -4
- package/lib/turn.js +4 -95
- package/lib/win-cmd.d.ts +27 -0
- package/lib/win-cmd.js +108 -0
- package/package.json +4 -4
- package/scripts/check-dsh-compat.mjs +191 -0
- package/src/cli-exec.ts +18 -7
- package/src/config.ts +16 -17
- package/src/index.ts +13 -136
- package/src/turn.ts +5 -94
- package/src/win-cmd.ts +129 -0
- package/lib/http-api.d.ts +0 -10
- package/lib/http-api.js +0 -94
- package/scripts/sync-skills.mjs +0 -47
- package/src/http-api.ts +0 -98
package/lib/index.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
2
2
|
import { CAPABILITIES, configGuide, configHint, SSH_HOST, truncate } from "./config.js";
|
|
3
3
|
import { cliNoteLocal, cliNoteRemote, runCli } from "./cli-exec.js";
|
|
4
|
-
import { apiJson, ensureApi, killTunnel } from "./http-api.js";
|
|
5
4
|
import { renderHtml } from "./html-render.js";
|
|
6
|
-
import { cliRunTurn, fmtTurnResult
|
|
5
|
+
import { cliRunTurn, fmtTurnResult } from "./turn.js";
|
|
7
6
|
import { HTML_DOC_SCRIPT } from "./html-render.js";
|
|
8
7
|
export const name = "dsh-deeptutor";
|
|
9
8
|
export const inject = ["tools"];
|
|
@@ -16,10 +15,6 @@ function json(o) {
|
|
|
16
15
|
}
|
|
17
16
|
export function apply(ctx) {
|
|
18
17
|
console.log(`[dsh-deeptutor] plugin loaded (html script: ${HTML_DOC_SCRIPT})`);
|
|
19
|
-
// Kill the SSH tunnel process when the plugin unloads.
|
|
20
|
-
ctx.effect(() => {
|
|
21
|
-
return () => killTunnel();
|
|
22
|
-
});
|
|
23
18
|
// ---------- Tool 1: run a DeepTutor learning capability ----------
|
|
24
19
|
ctx.tools.register(defineTool({
|
|
25
20
|
name: "deeptutor_run",
|
|
@@ -94,27 +89,6 @@ export function apply(ctx) {
|
|
|
94
89
|
async execute(args, exec) {
|
|
95
90
|
const timeoutMs = (args.timeout ?? 600) * 1000;
|
|
96
91
|
const maxChars = args.max_chars ?? 30000;
|
|
97
|
-
try {
|
|
98
|
-
if (await ensureApi()) {
|
|
99
|
-
const r = await wsRunTurn(args, timeoutMs, exec.signal);
|
|
100
|
-
const htmlNote = await renderHtml(args, r.answer);
|
|
101
|
-
return json({
|
|
102
|
-
mode: "http",
|
|
103
|
-
sessionId: r.sessionId,
|
|
104
|
-
toolCalls: r.toolCalls,
|
|
105
|
-
errors: r.errors,
|
|
106
|
-
text: configHint() + fmtTurnResult(args.capability, r, maxChars) + htmlNote,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
catch (err) {
|
|
111
|
-
if (exec.signal.aborted) {
|
|
112
|
-
// user cancelled mid-flight: don't start a fresh CLI run behind their back
|
|
113
|
-
throw new Error("[deeptutor_run cancelled]");
|
|
114
|
-
}
|
|
115
|
-
console.log(`[deeptutor] HTTP/WS failed, falling back to CLI: ${err?.message}`);
|
|
116
|
-
}
|
|
117
|
-
// fallback: local/SSH CLI
|
|
118
92
|
try {
|
|
119
93
|
const r = await cliRunTurn(args, timeoutMs, exec.signal);
|
|
120
94
|
const htmlNote = await renderHtml(args, r.answer);
|
|
@@ -132,7 +106,7 @@ export function apply(ctx) {
|
|
|
132
106
|
throw new Error("[deeptutor_run cancelled]");
|
|
133
107
|
}
|
|
134
108
|
throw new Error(configGuide() +
|
|
135
|
-
`[deeptutor_run failed
|
|
109
|
+
`[deeptutor_run failed] ${err?.message ?? String(err)}`);
|
|
136
110
|
}
|
|
137
111
|
},
|
|
138
112
|
}));
|
|
@@ -185,41 +159,7 @@ export function apply(ctx) {
|
|
|
185
159
|
},
|
|
186
160
|
async execute(args, exec) {
|
|
187
161
|
const maxChars = args.max_chars ?? 30000;
|
|
188
|
-
//
|
|
189
|
-
// list/info prefer HTTP, fall back to CLI
|
|
190
|
-
if (args.action !== "search") {
|
|
191
|
-
try {
|
|
192
|
-
if (await ensureApi()) {
|
|
193
|
-
if (args.action === "list") {
|
|
194
|
-
const arr = await apiJson("/api/v1/knowledge/list", undefined, {
|
|
195
|
-
signal: exec.signal,
|
|
196
|
-
});
|
|
197
|
-
const body = Array.isArray(arr)
|
|
198
|
-
? arr
|
|
199
|
-
.map((kb) => `- ${kb.name} (status=${kb.status ?? "?"}, ${kb.statistics ? JSON.stringify(kb.statistics) : "no stats"}${kb.is_default ? ", default" : ""})`)
|
|
200
|
-
.join("\n")
|
|
201
|
-
: JSON.stringify(arr);
|
|
202
|
-
return json({
|
|
203
|
-
action: "list",
|
|
204
|
-
mode: "http",
|
|
205
|
-
text: configHint() +
|
|
206
|
-
`Knowledge bases (${Array.isArray(arr) ? arr.length : "?"}):\n${body}`,
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
const kb = await apiJson(`/api/v1/knowledge/${encodeURIComponent(args.kb ?? "")}`, undefined, { signal: exec.signal });
|
|
210
|
-
return json({
|
|
211
|
-
action: "info",
|
|
212
|
-
mode: "http",
|
|
213
|
-
kb: args.kb,
|
|
214
|
-
text: truncate(JSON.stringify(kb, null, 2), maxChars),
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
console.log(`[deeptutor] kb ${args.action} HTTP failed, falling back to CLI: ${err?.message}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
// CLI fallback (local or SSH). NOTE: `kb search` has no --top-k flag
|
|
162
|
+
// Local or SSH CLI. NOTE: `kb search` has no --top-k flag
|
|
223
163
|
// (verified on deeptutor CLI 1.5.x); top_k is applied locally below.
|
|
224
164
|
let argv;
|
|
225
165
|
if (args.action === "list") {
|
|
@@ -337,53 +277,7 @@ export function apply(ctx) {
|
|
|
337
277
|
async execute(args, exec) {
|
|
338
278
|
const recordType = (args.type ?? "chat");
|
|
339
279
|
const nbName = args.notebook.trim();
|
|
340
|
-
//
|
|
341
|
-
try {
|
|
342
|
-
if (await ensureApi()) {
|
|
343
|
-
const list = await apiJson("/api/v1/notebook/list", undefined, {
|
|
344
|
-
signal: exec.signal,
|
|
345
|
-
});
|
|
346
|
-
const notebooks = list?.notebooks ?? [];
|
|
347
|
-
let nb = notebooks.find((n) => n.name?.trim() === nbName);
|
|
348
|
-
if (!nb) {
|
|
349
|
-
const created = await apiJson("/api/v1/notebook/create", {
|
|
350
|
-
method: "POST",
|
|
351
|
-
headers: { "Content-Type": "application/json" },
|
|
352
|
-
body: JSON.stringify({
|
|
353
|
-
name: nbName,
|
|
354
|
-
description: `Archived by dsh agent (${new Date().toISOString().slice(0, 10)})`,
|
|
355
|
-
}),
|
|
356
|
-
}, { signal: exec.signal });
|
|
357
|
-
nb = created?.notebook ?? created;
|
|
358
|
-
}
|
|
359
|
-
const nbId = nb?.id ?? (typeof nb === "string" ? nb : undefined);
|
|
360
|
-
if (!nbId)
|
|
361
|
-
throw new Error("cannot resolve notebook id");
|
|
362
|
-
const added = await apiJson("/api/v1/notebook/add_record", {
|
|
363
|
-
method: "POST",
|
|
364
|
-
headers: { "Content-Type": "application/json" },
|
|
365
|
-
body: JSON.stringify({
|
|
366
|
-
notebook_ids: [nbId],
|
|
367
|
-
record_type: recordType,
|
|
368
|
-
title: args.title,
|
|
369
|
-
user_query: args.title,
|
|
370
|
-
output: args.content,
|
|
371
|
-
}),
|
|
372
|
-
}, { signal: exec.signal });
|
|
373
|
-
return json({
|
|
374
|
-
notebook: nbName,
|
|
375
|
-
nbId: String(nbId),
|
|
376
|
-
title: args.title,
|
|
377
|
-
mode: "http",
|
|
378
|
-
text: configHint() +
|
|
379
|
-
`[note archived (HTTP)] notebook=${nbName} (${nbId}), title=${args.title}, type=${recordType}\n${truncate(JSON.stringify(added ?? {}), 800)}`,
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
catch (err) {
|
|
384
|
-
console.log(`[deeptutor] note HTTP failed, falling back to CLI: ${err?.message}`);
|
|
385
|
-
}
|
|
386
|
-
// CLI fallback: remote deployment uses the remote PY_NOTE script; local deployment runs locally
|
|
280
|
+
// Remote deployment runs the PY_NOTE script on the server; local deployment runs locally
|
|
387
281
|
const { stdout, stderr, code } = SSH_HOST
|
|
388
282
|
? await cliNoteRemote(nbName, args.title, recordType, args.content, exec.signal)
|
|
389
283
|
: await cliNoteLocal(nbName, args.title, recordType, args.content, exec.signal);
|
|
@@ -396,7 +290,7 @@ export function apply(ctx) {
|
|
|
396
290
|
title: args.title,
|
|
397
291
|
mode: "cli",
|
|
398
292
|
text: configHint() +
|
|
399
|
-
`[note archived
|
|
293
|
+
`[note archived] notebook=${nbName}, title=${args.title}, type=${recordType}\n${truncate((stdout ?? "").trim(), 2000)}`,
|
|
400
294
|
});
|
|
401
295
|
},
|
|
402
296
|
}));
|
package/lib/turn.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface TurnResult {
|
|
|
6
6
|
sessionId?: string;
|
|
7
7
|
title?: string;
|
|
8
8
|
}
|
|
9
|
-
/** Fields
|
|
9
|
+
/** Fields cliRunTurn reads off the deeptutor_run tool params. */
|
|
10
10
|
export interface RunParams {
|
|
11
11
|
prompt: string;
|
|
12
12
|
capability: string;
|
|
@@ -16,10 +16,8 @@ export interface RunParams {
|
|
|
16
16
|
language?: string;
|
|
17
17
|
config?: Record<string, unknown>;
|
|
18
18
|
}
|
|
19
|
-
/** Fold one
|
|
19
|
+
/** Fold one CLI JSONL event into the running turn state. Exported for unit tests. */
|
|
20
20
|
export declare function collectEvent(state: TurnResult, e: any): void;
|
|
21
|
-
/** Execute one turn over WebSocket (streaming). */
|
|
22
|
-
export declare function wsRunTurn(params: RunParams, timeoutMs: number, signal?: AbortSignal): Promise<TurnResult>;
|
|
23
21
|
/**
|
|
24
22
|
* Build the `deeptutor run` argv for a capability turn (pure, unit-tested).
|
|
25
23
|
* Scalar config values go as repeatable --config k=v; object/array values are
|
package/lib/turn.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DeepTutor bridge: one learning "turn" — running a capability
|
|
3
|
-
* or
|
|
2
|
+
* DeepTutor bridge: one learning "turn" — running a capability through the deeptutor CLI
|
|
3
|
+
* (local or over SSH), JSONL event parsing and result formatting.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { truncate } from "./config.js";
|
|
6
6
|
import { runCli } from "./cli-exec.js";
|
|
7
|
-
/** Fold one
|
|
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")
|
|
10
10
|
state.answer += e.content;
|
|
@@ -19,97 +19,6 @@ export function collectEvent(state, e) {
|
|
|
19
19
|
if (!state.sessionId && typeof e.session_id === "string")
|
|
20
20
|
state.sessionId = e.session_id;
|
|
21
21
|
}
|
|
22
|
-
/** Execute one turn over WebSocket (streaming). */
|
|
23
|
-
export function wsRunTurn(params, timeoutMs, signal) {
|
|
24
|
-
return new Promise((resolve, reject) => {
|
|
25
|
-
if (signal?.aborted)
|
|
26
|
-
return reject(new Error("Aborted"));
|
|
27
|
-
const WSImpl = globalThis.WebSocket;
|
|
28
|
-
if (!WSImpl)
|
|
29
|
-
return reject(new Error("WebSocket is not available in this environment"));
|
|
30
|
-
const ws = new WSImpl(`${API_BASE.replace(/^http/, "ws")}/api/v1/ws`);
|
|
31
|
-
let settled = false;
|
|
32
|
-
const state = { answer: "", errors: [], toolCalls: [] };
|
|
33
|
-
let timer;
|
|
34
|
-
const finish = (fn) => {
|
|
35
|
-
if (settled)
|
|
36
|
-
return;
|
|
37
|
-
settled = true;
|
|
38
|
-
if (timer)
|
|
39
|
-
clearTimeout(timer);
|
|
40
|
-
signal?.removeEventListener("abort", onAbort);
|
|
41
|
-
fn();
|
|
42
|
-
};
|
|
43
|
-
timer = setTimeout(() => {
|
|
44
|
-
finish(() => {
|
|
45
|
-
try {
|
|
46
|
-
ws.close();
|
|
47
|
-
}
|
|
48
|
-
catch { }
|
|
49
|
-
reject(new Error(`WebSocket timeout (${Math.round(timeoutMs / 1000)}s)`));
|
|
50
|
-
});
|
|
51
|
-
}, timeoutMs);
|
|
52
|
-
const onAbort = () => {
|
|
53
|
-
finish(() => {
|
|
54
|
-
try {
|
|
55
|
-
ws.close();
|
|
56
|
-
}
|
|
57
|
-
catch { }
|
|
58
|
-
reject(new Error("Aborted"));
|
|
59
|
-
});
|
|
60
|
-
};
|
|
61
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
62
|
-
ws.onopen = () => {
|
|
63
|
-
ws.send(JSON.stringify({
|
|
64
|
-
type: "start_turn",
|
|
65
|
-
content: params.prompt,
|
|
66
|
-
capability: params.capability,
|
|
67
|
-
session_id: params.session_id ?? null,
|
|
68
|
-
tools: params.tools ?? [],
|
|
69
|
-
knowledge_bases: params.kbs ?? [],
|
|
70
|
-
language: params.language ?? "en",
|
|
71
|
-
config: params.config ?? {},
|
|
72
|
-
notebook_references: [],
|
|
73
|
-
history_references: [],
|
|
74
|
-
attachments: [],
|
|
75
|
-
skills: [],
|
|
76
|
-
}));
|
|
77
|
-
};
|
|
78
|
-
ws.onmessage = (ev) => {
|
|
79
|
-
let e;
|
|
80
|
-
try {
|
|
81
|
-
e = JSON.parse(ev.data);
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
collectEvent(state, e);
|
|
87
|
-
if (e.type === "done") {
|
|
88
|
-
finish(() => {
|
|
89
|
-
try {
|
|
90
|
-
ws.close();
|
|
91
|
-
}
|
|
92
|
-
catch { }
|
|
93
|
-
resolve(state);
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
ws.onerror = () => {
|
|
98
|
-
finish(() => {
|
|
99
|
-
try {
|
|
100
|
-
ws.close();
|
|
101
|
-
}
|
|
102
|
-
catch { }
|
|
103
|
-
reject(new Error("WebSocket connection error (server not running or tunnel down)"));
|
|
104
|
-
});
|
|
105
|
-
};
|
|
106
|
-
ws.onclose = () => {
|
|
107
|
-
finish(() => {
|
|
108
|
-
reject(new Error("WebSocket closed unexpectedly"));
|
|
109
|
-
});
|
|
110
|
-
};
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
22
|
/**
|
|
114
23
|
* Build the `deeptutor run` argv for a capability turn (pure, unit-tested).
|
|
115
24
|
* Scalar config values go as repeatable --config k=v; object/array values are
|
package/lib/win-cmd.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface LocalCommand {
|
|
2
|
+
file: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
/** Pass the args to CreateProcess unchanged (needed for the cmd.exe command line). */
|
|
5
|
+
windowsVerbatimArguments?: boolean;
|
|
6
|
+
/** Set when the command cannot be run as requested; nothing should be spawned. */
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Quote one argument for a cmd.exe command line (cross-spawn's escapeArgument). */
|
|
10
|
+
export declare function escapeCmdArgument(arg: string, doubleEscapeMetaChars: boolean): string;
|
|
11
|
+
/** Escape the command path itself for a cmd.exe command line. */
|
|
12
|
+
export declare function escapeCmdCommand(command: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Build the `cmd.exe /d /s /c "..."` invocation for a batch file. Arguments are escaped
|
|
15
|
+
* twice because a batch file that forwards `%*` makes cmd.exe parse them a second time.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildCmdInvocation(batchFile: string, args: string[], comSpec?: string): LocalCommand;
|
|
18
|
+
/**
|
|
19
|
+
* If a .cmd/.bat wrapper does nothing but forward its arguments to an executable, return
|
|
20
|
+
* that executable's path. Accepts `@echo off`, `rem`/`::` comments and `setlocal`/`endlocal`
|
|
21
|
+
* around a single `["]<path>.exe["] %*` line; `%~dp0` is expanded to the wrapper's folder.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseCmdWrapper(text: string, wrapperPath: string): string | undefined;
|
|
24
|
+
/** Find a bare command name on PATH, trying each PATHEXT extension (Windows search order). */
|
|
25
|
+
export declare function findOnPath(name: string, env?: NodeJS.ProcessEnv, exists?: (p: string) => boolean): string | undefined;
|
|
26
|
+
/** Turn a local CLI path plus argv into something execFile can start on this platform. */
|
|
27
|
+
export declare function resolveLocalCommand(file: string, args: string[], platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): LocalCommand;
|
package/lib/win-cmd.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepTutor bridge: launching the local deeptutor CLI on Windows.
|
|
3
|
+
*
|
|
4
|
+
* Node's execFile cannot start a .cmd/.bat file directly on Windows (spawn EINVAL, see
|
|
5
|
+
* issue #8), and it does not search PATHEXT for a bare command name. resolveLocalCommand
|
|
6
|
+
* turns DEEPTUTOR_LOCAL_BIN into something execFile can run:
|
|
7
|
+
* 1. a bare name is looked up on PATH using PATHEXT;
|
|
8
|
+
* 2. a .cmd/.bat that only forwards its arguments to an .exe (`"C:\...\deeptutor.exe" %*`)
|
|
9
|
+
* is replaced by that .exe, so no shell is involved;
|
|
10
|
+
* 3. any other .cmd/.bat runs through `cmd.exe /d /s /c` with every argument quoted and
|
|
11
|
+
* its metacharacters escaped (the same scheme as cross-spawn).
|
|
12
|
+
* cmd.exe ends a command line at a newline, so case 3 cannot pass multi-line arguments;
|
|
13
|
+
* resolveLocalCommand reports that instead of silently truncating the prompt.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { extname, win32 } from "node:path";
|
|
17
|
+
const META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
|
|
18
|
+
/** Quote one argument for a cmd.exe command line (cross-spawn's escapeArgument). */
|
|
19
|
+
export function escapeCmdArgument(arg, doubleEscapeMetaChars) {
|
|
20
|
+
let out = arg.replace(/(\\*)"/g, '$1$1\\"');
|
|
21
|
+
out = out.replace(/(\\*)$/, "$1$1");
|
|
22
|
+
out = `"${out}"`;
|
|
23
|
+
out = out.replace(META_CHARS, "^$1");
|
|
24
|
+
if (doubleEscapeMetaChars)
|
|
25
|
+
out = out.replace(META_CHARS, "^$1");
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Escape the command path itself for a cmd.exe command line. */
|
|
29
|
+
export function escapeCmdCommand(command) {
|
|
30
|
+
return command.replace(META_CHARS, "^$1");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the `cmd.exe /d /s /c "..."` invocation for a batch file. Arguments are escaped
|
|
34
|
+
* twice because a batch file that forwards `%*` makes cmd.exe parse them a second time.
|
|
35
|
+
*/
|
|
36
|
+
export function buildCmdInvocation(batchFile, args, comSpec = "cmd.exe") {
|
|
37
|
+
const line = [escapeCmdCommand(batchFile), ...args.map((a) => escapeCmdArgument(a, true))].join(" ");
|
|
38
|
+
return { file: comSpec, args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* If a .cmd/.bat wrapper does nothing but forward its arguments to an executable, return
|
|
42
|
+
* that executable's path. Accepts `@echo off`, `rem`/`::` comments and `setlocal`/`endlocal`
|
|
43
|
+
* around a single `["]<path>.exe["] %*` line; `%~dp0` is expanded to the wrapper's folder.
|
|
44
|
+
*/
|
|
45
|
+
export function parseCmdWrapper(text, wrapperPath) {
|
|
46
|
+
const lines = text
|
|
47
|
+
.split(/\r?\n/)
|
|
48
|
+
.map((l) => l.trim())
|
|
49
|
+
.filter((l) => l !== "" &&
|
|
50
|
+
!/^@?echo\s+off$/i.test(l) &&
|
|
51
|
+
!/^@?(rem(\s|$)|::)/i.test(l) &&
|
|
52
|
+
!/^@?(setlocal|endlocal)(\s|$)/i.test(l));
|
|
53
|
+
if (lines.length !== 1)
|
|
54
|
+
return undefined;
|
|
55
|
+
const m = /^@?(?:"([^"]+\.(?:exe|com))"|([^\s"]+\.(?:exe|com)))\s+%\*$/i.exec(lines[0]);
|
|
56
|
+
if (!m)
|
|
57
|
+
return undefined;
|
|
58
|
+
// Windows path rules regardless of the host, so this also behaves the same in Linux CI.
|
|
59
|
+
const dir = win32.dirname(wrapperPath);
|
|
60
|
+
const target = (m[1] ?? m[2]).replace(/%~dp0/gi, dir + "\\");
|
|
61
|
+
if (/%/.test(target))
|
|
62
|
+
return undefined; // other variables: let cmd.exe expand them
|
|
63
|
+
return win32.resolve(dir, target);
|
|
64
|
+
}
|
|
65
|
+
/** Find a bare command name on PATH, trying each PATHEXT extension (Windows search order). */
|
|
66
|
+
export function findOnPath(name, env = process.env, exists = existsSync) {
|
|
67
|
+
const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
68
|
+
// Windows path rules regardless of the host, so this also behaves the same in Linux CI.
|
|
69
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean);
|
|
70
|
+
for (const dir of dirs) {
|
|
71
|
+
for (const ext of exts) {
|
|
72
|
+
const candidate = win32.join(dir.replace(/^"|"$/g, ""), name + ext.toLowerCase());
|
|
73
|
+
if (exists(candidate))
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Turn a local CLI path plus argv into something execFile can start on this platform. */
|
|
80
|
+
export function resolveLocalCommand(file, args, platform = process.platform, env = process.env) {
|
|
81
|
+
if (platform !== "win32")
|
|
82
|
+
return { file, args };
|
|
83
|
+
let target = file;
|
|
84
|
+
const isBareName = !/[\\/]/.test(target) && extname(target) === "";
|
|
85
|
+
if (isBareName)
|
|
86
|
+
target = findOnPath(target, env) ?? target;
|
|
87
|
+
if (!/\.(cmd|bat)$/i.test(target))
|
|
88
|
+
return { file: target, args };
|
|
89
|
+
let wrapped;
|
|
90
|
+
try {
|
|
91
|
+
wrapped = parseCmdWrapper(readFileSync(target, "utf8"), target);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
wrapped = undefined;
|
|
95
|
+
}
|
|
96
|
+
if (wrapped && existsSync(wrapped))
|
|
97
|
+
return { file: wrapped, args };
|
|
98
|
+
if (args.some((a) => /[\r\n]/.test(a))) {
|
|
99
|
+
return {
|
|
100
|
+
file: target,
|
|
101
|
+
args,
|
|
102
|
+
error: `${target} is a batch file, and cmd.exe cannot pass arguments that contain line breaks. ` +
|
|
103
|
+
"Point DEEPTUTOR_LOCAL_BIN at deeptutor.exe instead (in a pip/venv install it is " +
|
|
104
|
+
"<venv>\\Scripts\\deeptutor.exe).",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return buildCmdInvocation(target, args, env.ComSpec ?? env.COMSPEC ?? "cmd.exe");
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-deeptutor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Learning assistant extension for DeepSeek Harness (dsh): DeepTutor tutoring for your agent — deep explanations, self-test questions, learning paths, personal knowledge-base search (RAG), and note archiving (
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Learning assistant extension for DeepSeek Harness (dsh): DeepTutor tutoring for your agent — deep explanations, self-test questions, learning paths, personal knowledge-base search (RAG), and note archiving (drives the DeepTutor CLI locally or over SSH).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"dsh-plugin",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"engines": {
|
|
24
24
|
"node": ">=20.11",
|
|
25
|
-
"dsh": "
|
|
25
|
+
"dsh": ">=0.1.0-rc.6"
|
|
26
26
|
},
|
|
27
27
|
"type": "module",
|
|
28
28
|
"main": "./lib/index.js",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
68
|
-
"@deepseek-ai/dsh-tools": "
|
|
68
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@deepseek-ai/cordis": "^4.0.5",
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Boot-verify dsh-deeptutor against the `dsh` on PATH.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the manifest no longer caps the supported dsh line (that is
|
|
6
|
+
* what issue #5 was about), so the compatibility gate is replaced by this
|
|
7
|
+
* probe. The scheduled `dsh-compat` workflow runs it against the npm `latest`
|
|
8
|
+
* and `next` releases; run it by hand after a dsh upgrade as well.
|
|
9
|
+
*
|
|
10
|
+
* What it does, against a throwaway DSH_HOME:
|
|
11
|
+
* 1. packs this repository (prepack builds + typechecks) unless --spec is given;
|
|
12
|
+
* 2. installs the package into a fresh `compat` profile;
|
|
13
|
+
* 3. cold-boots that profile with a dead model endpoint;
|
|
14
|
+
* 4. asserts the plugin activated (no `did not activate` / import failure).
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node scripts/check-dsh-compat.mjs [--spec <dir|tarball|npm-spec>] [--timeout 60]
|
|
18
|
+
* [--dsh-bin dsh] [--json] [--keep-workspace]
|
|
19
|
+
*
|
|
20
|
+
* Exit codes: 0 = the plugin activated, 1 = it did not, 2 = inconclusive.
|
|
21
|
+
* The probe only reads and writes inside its own temporary DSH_HOME.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
|
|
32
|
+
function option(name, fallback) {
|
|
33
|
+
const index = argv.indexOf(name);
|
|
34
|
+
return index >= 0 && index + 1 < argv.length ? argv[index + 1] : fallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dshBin = option("--dsh-bin", process.platform === "win32" ? "dsh.cmd" : "dsh");
|
|
38
|
+
const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
39
|
+
const timeoutSeconds = Number(option("--timeout", "60"));
|
|
40
|
+
const jsonOutput = argv.includes("--json");
|
|
41
|
+
const keepWorkspace = argv.includes("--keep-workspace");
|
|
42
|
+
const specArgument = option("--spec", undefined);
|
|
43
|
+
|
|
44
|
+
const PROFILE = "compat";
|
|
45
|
+
const FAILURE = /did not activate|failed to import|plugin tree failed to load|Cannot find module|ERR_MODULE_NOT_FOUND/i;
|
|
46
|
+
const ACTIVATED = /\[dsh-deeptutor\] plugin loaded/;
|
|
47
|
+
|
|
48
|
+
function run(command, args, options = {}) {
|
|
49
|
+
return spawnSync(command, args, {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
timeout: (options.timeoutSeconds ?? 180) * 1000,
|
|
52
|
+
killSignal: "SIGKILL",
|
|
53
|
+
input: "",
|
|
54
|
+
cwd: options.cwd ?? repoRoot,
|
|
55
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tail(text, lines = 25) {
|
|
60
|
+
return String(text ?? "")
|
|
61
|
+
.trimEnd()
|
|
62
|
+
.split("\n")
|
|
63
|
+
.slice(-lines)
|
|
64
|
+
.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The model/patch layer the boot probe needs: a real model name behind a dead port. */
|
|
68
|
+
function writeProbePatch(profileDir) {
|
|
69
|
+
mkdirSync(profileDir, { recursive: true });
|
|
70
|
+
writeFileSync(
|
|
71
|
+
join(profileDir, "cordis.patch.yml"),
|
|
72
|
+
[
|
|
73
|
+
"# generated by scripts/check-dsh-compat.mjs — real model name behind a dead endpoint",
|
|
74
|
+
"- id: llm-compat",
|
|
75
|
+
" config:",
|
|
76
|
+
" models:",
|
|
77
|
+
" - id: Qwen3.6-35B",
|
|
78
|
+
" contextWindow: 262144",
|
|
79
|
+
" maxTokens: 8192",
|
|
80
|
+
"- id: agent-default-model",
|
|
81
|
+
" config:",
|
|
82
|
+
" provider: deepseek-official",
|
|
83
|
+
" model: Qwen3.6-35B",
|
|
84
|
+
"- id: \"@deepseek-ai/cordis-plugin-hmr\"",
|
|
85
|
+
" disabled: true",
|
|
86
|
+
"",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main() {
|
|
92
|
+
const dshVersionProbe = run(dshBin, ["--version"], { timeoutSeconds: 30 });
|
|
93
|
+
if (dshVersionProbe.error !== undefined || dshVersionProbe.status !== 0) {
|
|
94
|
+
return {
|
|
95
|
+
verdict: "inconclusive",
|
|
96
|
+
stage: "dsh-version",
|
|
97
|
+
detail: `${dshBin} is not runnable: ${String(dshVersionProbe.error ?? dshVersionProbe.stderr)}`.trim(),
|
|
98
|
+
exitCode: 2,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const dshVersion = String(dshVersionProbe.stdout ?? "").trim().split("\n").pop() ?? "unknown";
|
|
102
|
+
|
|
103
|
+
const workspace = mkdtempSync(join(tmpdir(), "dsh-compat-"));
|
|
104
|
+
const dshHome = join(workspace, ".dsh");
|
|
105
|
+
const env = { DSH_HOME: dshHome };
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
if (!keepWorkspace) rmSync(workspace, { recursive: true, force: true });
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
let spec = specArgument;
|
|
112
|
+
if (spec === undefined) {
|
|
113
|
+
// --cache: keep npm state inside the probe workspace, so a read-only or
|
|
114
|
+
// root-owned npm cache on the caller's machine cannot fail the probe.
|
|
115
|
+
const packed = run(npmBin, ["pack", "--pack-destination", workspace, "--cache", join(workspace, "npm-cache")], { timeoutSeconds: 300 });
|
|
116
|
+
if (packed.status !== 0) {
|
|
117
|
+
return { verdict: "inconclusive", stage: "pack", detail: tail(`${packed.stdout}\n${packed.stderr}`), exitCode: 2 };
|
|
118
|
+
}
|
|
119
|
+
const tarball = readdirSync(workspace).find((name) => name.endsWith(".tgz"));
|
|
120
|
+
if (tarball === undefined) {
|
|
121
|
+
return { verdict: "inconclusive", stage: "pack", detail: "npm pack produced no tarball", exitCode: 2 };
|
|
122
|
+
}
|
|
123
|
+
spec = join(workspace, tarball);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
writeProbePatch(join(dshHome, "profiles", PROFILE));
|
|
127
|
+
|
|
128
|
+
// `-w`: dsh profiles are single-package pnpm workspaces and pnpm refuses
|
|
129
|
+
// to add to a workspace root without it (observed on pnpm 9 + dsh 0.2.1).
|
|
130
|
+
// Isolated pnpm store: a machine-wide store (store-dir in ~/.npmrc) can be
|
|
131
|
+
// read-only for this user, which would look like an install failure.
|
|
132
|
+
const install = run(
|
|
133
|
+
dshBin,
|
|
134
|
+
["plugin", "--profile", PROFILE, "add", "-w", `--store-dir=${join(workspace, "pnpm-store")}`, spec],
|
|
135
|
+
{ timeoutSeconds: 300, env },
|
|
136
|
+
);
|
|
137
|
+
if (install.status !== 0) {
|
|
138
|
+
return {
|
|
139
|
+
verdict: "fail",
|
|
140
|
+
stage: "install",
|
|
141
|
+
detail: tail(`${install.stdout}\n${install.stderr}`),
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const boot = run(dshBin, ["--profile", PROFILE, "ok"], {
|
|
147
|
+
timeoutSeconds,
|
|
148
|
+
env: {
|
|
149
|
+
...env,
|
|
150
|
+
DEEPSEEK_BASE_URL: "http://127.0.0.1:9",
|
|
151
|
+
DEEPSEEK_API_KEY: "sk-compat-probe-000000000000000000000000",
|
|
152
|
+
DSH_PERMISSION_MODE: "danger-full-access",
|
|
153
|
+
},
|
|
154
|
+
cwd: dshHome,
|
|
155
|
+
});
|
|
156
|
+
const log = `${boot.stdout ?? ""}\n${boot.stderr ?? ""}`;
|
|
157
|
+
const timedOut = boot.error?.code === "ETIMEDOUT";
|
|
158
|
+
const failure = log.match(FAILURE);
|
|
159
|
+
if (failure !== null) {
|
|
160
|
+
return { verdict: "fail", stage: "boot", detail: tail(log), signature: failure[0], exitCode: 1 };
|
|
161
|
+
}
|
|
162
|
+
if (log.match(ACTIVATED) !== null) {
|
|
163
|
+
return { verdict: "pass", stage: "boot", detail: "plugin activation marker seen", exitCode: 0 };
|
|
164
|
+
}
|
|
165
|
+
if (timedOut) {
|
|
166
|
+
return { verdict: "pass", stage: "boot", detail: "boot stayed alive through the probe window without a failure signature", exitCode: 0 };
|
|
167
|
+
}
|
|
168
|
+
return { verdict: "inconclusive", stage: "boot", detail: tail(log), exitCode: 2 };
|
|
169
|
+
} finally {
|
|
170
|
+
cleanup();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = main();
|
|
175
|
+
const versionProbe = run(dshBin, ["--version"], { timeoutSeconds: 30 });
|
|
176
|
+
const report = {
|
|
177
|
+
dshBin,
|
|
178
|
+
dshVersion: String(versionProbe.stdout ?? "").trim().split("\n").pop() ?? "unavailable",
|
|
179
|
+
spec: specArgument ?? "(packed from the repository)",
|
|
180
|
+
...result,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (jsonOutput) {
|
|
184
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
185
|
+
} else {
|
|
186
|
+
const label = report.verdict === "pass" ? "PASS" : report.verdict === "fail" ? "FAIL" : "INCONCLUSIVE";
|
|
187
|
+
process.stdout.write(`[dsh-compat] ${label} · dsh ${report.dshVersion} · stage ${report.stage}\n`);
|
|
188
|
+
if (report.detail !== undefined && report.detail !== "") process.stdout.write(`${report.detail}\n`);
|
|
189
|
+
if (keepWorkspace) process.stdout.write(`[dsh-compat] workspace kept\n`);
|
|
190
|
+
}
|
|
191
|
+
process.exit(report.exitCode);
|