dsh-deeptutor 0.1.11 → 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 +29 -22
- package/README.zh.md +13 -11
- 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 +2 -2
- 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/src/index.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DeepTutor bridge plugin for DeepSeek Harness (dsh).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - deeptutor_run — execute a DeepTutor learning capability (HTTP/WS first, CLI fallback)
|
|
4
|
+
* Registers three model-facing tools:
|
|
5
|
+
* - deeptutor_run — execute a DeepTutor learning capability
|
|
7
6
|
* - deeptutor_kb — list / search / info knowledge bases
|
|
8
7
|
* - deeptutor_note — archive a Markdown learning note to a server notebook
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
* - Local:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Transport: every tool drives the deeptutor CLI.
|
|
10
|
+
* - Local: DEEPTUTOR_SSH_HOST unset → runs the local CLI (DEEPTUTOR_LOCAL_BIN).
|
|
11
|
+
* - Remote: DEEPTUTOR_SSH_HOST set → runs the server's CLI over SSH.
|
|
12
|
+
* The HTTP/WS API was dropped: DeepTutor 1.7 moved it off /api/v1 and put it behind
|
|
13
|
+
* a login, so the old probe always 404'd and cost ~30s per call before the CLI ran anyway.
|
|
15
14
|
*
|
|
16
|
-
* Env config (agent-agnostic): DEEPTUTOR_SSH_HOST /
|
|
15
|
+
* Env config (agent-agnostic): DEEPTUTOR_SSH_HOST / DEEPTUTOR_LOCAL_BIN /
|
|
17
16
|
* DEEPTUTOR_REMOTE_BIN / DEEPTUTOR_REMOTE_HOME — see src/config.ts and
|
|
18
17
|
* .agents/skills/deeptutor/SKILL.md.
|
|
19
18
|
*/
|
|
@@ -21,9 +20,8 @@ import type { Context } from "@deepseek-ai/cordis";
|
|
|
21
20
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
22
21
|
import { CAPABILITIES, configGuide, configHint, SSH_HOST, truncate } from "./config.ts";
|
|
23
22
|
import { cliNoteLocal, cliNoteRemote, runCli } from "./cli-exec.ts";
|
|
24
|
-
import { apiJson, ensureApi, killTunnel } from "./http-api.ts";
|
|
25
23
|
import { renderHtml } from "./html-render.ts";
|
|
26
|
-
import { cliRunTurn, fmtTurnResult
|
|
24
|
+
import { cliRunTurn, fmtTurnResult } from "./turn.ts";
|
|
27
25
|
import { HTML_DOC_SCRIPT } from "./html-render.ts";
|
|
28
26
|
|
|
29
27
|
export const name = "dsh-deeptutor";
|
|
@@ -41,10 +39,6 @@ function json<T extends Record<string, unknown>>(o: T): Jsonify<T> {
|
|
|
41
39
|
|
|
42
40
|
export function apply(ctx: Context) {
|
|
43
41
|
console.log(`[dsh-deeptutor] plugin loaded (html script: ${HTML_DOC_SCRIPT})`);
|
|
44
|
-
// Kill the SSH tunnel process when the plugin unloads.
|
|
45
|
-
ctx.effect(() => {
|
|
46
|
-
return () => killTunnel();
|
|
47
|
-
});
|
|
48
42
|
|
|
49
43
|
// ---------- Tool 1: run a DeepTutor learning capability ----------
|
|
50
44
|
ctx.tools.register(
|
|
@@ -130,27 +124,6 @@ export function apply(ctx: Context) {
|
|
|
130
124
|
async execute(args, exec) {
|
|
131
125
|
const timeoutMs = (args.timeout ?? 600) * 1000;
|
|
132
126
|
const maxChars = args.max_chars ?? 30000;
|
|
133
|
-
try {
|
|
134
|
-
if (await ensureApi()) {
|
|
135
|
-
const r = await wsRunTurn(args, timeoutMs, exec.signal);
|
|
136
|
-
const htmlNote = await renderHtml(args, r.answer);
|
|
137
|
-
return json({
|
|
138
|
-
mode: "http",
|
|
139
|
-
sessionId: r.sessionId,
|
|
140
|
-
toolCalls: r.toolCalls,
|
|
141
|
-
errors: r.errors,
|
|
142
|
-
text:
|
|
143
|
-
configHint() + fmtTurnResult(args.capability, r, maxChars) + htmlNote,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
} catch (err: any) {
|
|
147
|
-
if (exec.signal.aborted) {
|
|
148
|
-
// user cancelled mid-flight: don't start a fresh CLI run behind their back
|
|
149
|
-
throw new Error("[deeptutor_run cancelled]");
|
|
150
|
-
}
|
|
151
|
-
console.log(`[deeptutor] HTTP/WS failed, falling back to CLI: ${err?.message}`);
|
|
152
|
-
}
|
|
153
|
-
// fallback: local/SSH CLI
|
|
154
127
|
try {
|
|
155
128
|
const r = await cliRunTurn(args, timeoutMs, exec.signal);
|
|
156
129
|
const htmlNote = await renderHtml(args, r.answer);
|
|
@@ -169,7 +142,7 @@ export function apply(ctx: Context) {
|
|
|
169
142
|
}
|
|
170
143
|
throw new Error(
|
|
171
144
|
configGuide() +
|
|
172
|
-
`[deeptutor_run failed
|
|
145
|
+
`[deeptutor_run failed] ${err?.message ?? String(err)}`,
|
|
173
146
|
);
|
|
174
147
|
}
|
|
175
148
|
},
|
|
@@ -230,50 +203,7 @@ export function apply(ctx: Context) {
|
|
|
230
203
|
},
|
|
231
204
|
async execute(args, exec) {
|
|
232
205
|
const maxChars = args.max_chars ?? 30000;
|
|
233
|
-
//
|
|
234
|
-
// list/info prefer HTTP, fall back to CLI
|
|
235
|
-
if (args.action !== "search") {
|
|
236
|
-
try {
|
|
237
|
-
if (await ensureApi()) {
|
|
238
|
-
if (args.action === "list") {
|
|
239
|
-
const arr = await apiJson("/api/v1/knowledge/list", undefined, {
|
|
240
|
-
signal: exec.signal,
|
|
241
|
-
});
|
|
242
|
-
const body = Array.isArray(arr)
|
|
243
|
-
? arr
|
|
244
|
-
.map(
|
|
245
|
-
(kb: any) =>
|
|
246
|
-
`- ${kb.name} (status=${kb.status ?? "?"}, ${kb.statistics ? JSON.stringify(kb.statistics) : "no stats"}${kb.is_default ? ", default" : ""})`,
|
|
247
|
-
)
|
|
248
|
-
.join("\n")
|
|
249
|
-
: JSON.stringify(arr);
|
|
250
|
-
return json({
|
|
251
|
-
action: "list",
|
|
252
|
-
mode: "http",
|
|
253
|
-
text:
|
|
254
|
-
configHint() +
|
|
255
|
-
`Knowledge bases (${Array.isArray(arr) ? arr.length : "?"}):\n${body}`,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
const kb = await apiJson(
|
|
259
|
-
`/api/v1/knowledge/${encodeURIComponent(args.kb ?? "")}`,
|
|
260
|
-
undefined,
|
|
261
|
-
{ signal: exec.signal },
|
|
262
|
-
);
|
|
263
|
-
return json({
|
|
264
|
-
action: "info",
|
|
265
|
-
mode: "http",
|
|
266
|
-
kb: args.kb,
|
|
267
|
-
text: truncate(JSON.stringify(kb, null, 2), maxChars),
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
} catch (err: any) {
|
|
271
|
-
console.log(
|
|
272
|
-
`[deeptutor] kb ${args.action} HTTP failed, falling back to CLI: ${err?.message}`,
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
// CLI fallback (local or SSH). NOTE: `kb search` has no --top-k flag
|
|
206
|
+
// Local or SSH CLI. NOTE: `kb search` has no --top-k flag
|
|
277
207
|
// (verified on deeptutor CLI 1.5.x); top_k is applied locally below.
|
|
278
208
|
let argv: string[];
|
|
279
209
|
if (args.action === "list") {
|
|
@@ -392,60 +322,7 @@ export function apply(ctx: Context) {
|
|
|
392
322
|
async execute(args, exec) {
|
|
393
323
|
const recordType = (args.type ?? "chat") as string;
|
|
394
324
|
const nbName = args.notebook.trim();
|
|
395
|
-
//
|
|
396
|
-
try {
|
|
397
|
-
if (await ensureApi()) {
|
|
398
|
-
const list = await apiJson("/api/v1/notebook/list", undefined, {
|
|
399
|
-
signal: exec.signal,
|
|
400
|
-
});
|
|
401
|
-
const notebooks: any[] = list?.notebooks ?? [];
|
|
402
|
-
let nb = notebooks.find((n) => n.name?.trim() === nbName);
|
|
403
|
-
if (!nb) {
|
|
404
|
-
const created = await apiJson(
|
|
405
|
-
"/api/v1/notebook/create",
|
|
406
|
-
{
|
|
407
|
-
method: "POST",
|
|
408
|
-
headers: { "Content-Type": "application/json" },
|
|
409
|
-
body: JSON.stringify({
|
|
410
|
-
name: nbName,
|
|
411
|
-
description: `Archived by dsh agent (${new Date().toISOString().slice(0, 10)})`,
|
|
412
|
-
}),
|
|
413
|
-
},
|
|
414
|
-
{ signal: exec.signal },
|
|
415
|
-
);
|
|
416
|
-
nb = created?.notebook ?? created;
|
|
417
|
-
}
|
|
418
|
-
const nbId = nb?.id ?? (typeof nb === "string" ? nb : undefined);
|
|
419
|
-
if (!nbId) throw new Error("cannot resolve notebook id");
|
|
420
|
-
const added = await apiJson(
|
|
421
|
-
"/api/v1/notebook/add_record",
|
|
422
|
-
{
|
|
423
|
-
method: "POST",
|
|
424
|
-
headers: { "Content-Type": "application/json" },
|
|
425
|
-
body: JSON.stringify({
|
|
426
|
-
notebook_ids: [nbId],
|
|
427
|
-
record_type: recordType,
|
|
428
|
-
title: args.title,
|
|
429
|
-
user_query: args.title,
|
|
430
|
-
output: args.content,
|
|
431
|
-
}),
|
|
432
|
-
},
|
|
433
|
-
{ signal: exec.signal },
|
|
434
|
-
);
|
|
435
|
-
return json({
|
|
436
|
-
notebook: nbName,
|
|
437
|
-
nbId: String(nbId),
|
|
438
|
-
title: args.title,
|
|
439
|
-
mode: "http",
|
|
440
|
-
text:
|
|
441
|
-
configHint() +
|
|
442
|
-
`[note archived (HTTP)] notebook=${nbName} (${nbId}), title=${args.title}, type=${recordType}\n${truncate(JSON.stringify(added ?? {}), 800)}`,
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
} catch (err: any) {
|
|
446
|
-
console.log(`[deeptutor] note HTTP failed, falling back to CLI: ${err?.message}`);
|
|
447
|
-
}
|
|
448
|
-
// CLI fallback: remote deployment uses the remote PY_NOTE script; local deployment runs locally
|
|
325
|
+
// Remote deployment runs the PY_NOTE script on the server; local deployment runs locally
|
|
449
326
|
const { stdout, stderr, code } = SSH_HOST
|
|
450
327
|
? await cliNoteRemote(nbName, args.title, recordType, args.content, exec.signal)
|
|
451
328
|
: await cliNoteLocal(nbName, args.title, recordType, args.content, exec.signal);
|
|
@@ -461,7 +338,7 @@ export function apply(ctx: Context) {
|
|
|
461
338
|
mode: "cli",
|
|
462
339
|
text:
|
|
463
340
|
configHint() +
|
|
464
|
-
`[note archived
|
|
341
|
+
`[note archived] notebook=${nbName}, title=${args.title}, type=${recordType}\n${truncate((stdout ?? "").trim(), 2000)}`,
|
|
465
342
|
});
|
|
466
343
|
},
|
|
467
344
|
}),
|
package/src/turn.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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.ts";
|
|
6
6
|
import { runCli } from "./cli-exec.ts";
|
|
7
7
|
|
|
8
8
|
export interface TurnResult {
|
|
@@ -13,7 +13,7 @@ export interface TurnResult {
|
|
|
13
13
|
title?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/** Fields
|
|
16
|
+
/** Fields cliRunTurn reads off the deeptutor_run tool params. */
|
|
17
17
|
export interface RunParams {
|
|
18
18
|
prompt: string;
|
|
19
19
|
capability: string;
|
|
@@ -24,7 +24,7 @@ export interface RunParams {
|
|
|
24
24
|
config?: Record<string, unknown>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/** Fold one
|
|
27
|
+
/** Fold one CLI JSONL event into the running turn state. Exported for unit tests. */
|
|
28
28
|
export function collectEvent(state: TurnResult, e: any) {
|
|
29
29
|
if (e.type === "content" && typeof e.content === "string") state.answer += e.content;
|
|
30
30
|
else if (e.type === "result" && typeof e.metadata?.response === "string")
|
|
@@ -39,95 +39,6 @@ export function collectEvent(state: TurnResult, e: any) {
|
|
|
39
39
|
if (!state.sessionId && typeof e.session_id === "string") state.sessionId = e.session_id;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Execute one turn over WebSocket (streaming). */
|
|
43
|
-
export function wsRunTurn(
|
|
44
|
-
params: RunParams,
|
|
45
|
-
timeoutMs: number,
|
|
46
|
-
signal?: AbortSignal,
|
|
47
|
-
): Promise<TurnResult> {
|
|
48
|
-
return new Promise((resolve, reject) => {
|
|
49
|
-
if (signal?.aborted) return reject(new Error("Aborted"));
|
|
50
|
-
const WSImpl: any = (globalThis as any).WebSocket;
|
|
51
|
-
if (!WSImpl) return reject(new Error("WebSocket is not available in this environment"));
|
|
52
|
-
const ws = new WSImpl(`${API_BASE.replace(/^http/, "ws")}/api/v1/ws`);
|
|
53
|
-
let settled = false;
|
|
54
|
-
const state: TurnResult = { answer: "", errors: [], toolCalls: [] };
|
|
55
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
-
const finish = (fn: () => void) => {
|
|
57
|
-
if (settled) return;
|
|
58
|
-
settled = true;
|
|
59
|
-
if (timer) clearTimeout(timer);
|
|
60
|
-
signal?.removeEventListener("abort", onAbort);
|
|
61
|
-
fn();
|
|
62
|
-
};
|
|
63
|
-
timer = setTimeout(() => {
|
|
64
|
-
finish(() => {
|
|
65
|
-
try {
|
|
66
|
-
ws.close();
|
|
67
|
-
} catch {}
|
|
68
|
-
reject(new Error(`WebSocket timeout (${Math.round(timeoutMs / 1000)}s)`));
|
|
69
|
-
});
|
|
70
|
-
}, timeoutMs);
|
|
71
|
-
const onAbort = () => {
|
|
72
|
-
finish(() => {
|
|
73
|
-
try {
|
|
74
|
-
ws.close();
|
|
75
|
-
} catch {}
|
|
76
|
-
reject(new Error("Aborted"));
|
|
77
|
-
});
|
|
78
|
-
};
|
|
79
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
80
|
-
ws.onopen = () => {
|
|
81
|
-
ws.send(
|
|
82
|
-
JSON.stringify({
|
|
83
|
-
type: "start_turn",
|
|
84
|
-
content: params.prompt,
|
|
85
|
-
capability: params.capability,
|
|
86
|
-
session_id: params.session_id ?? null,
|
|
87
|
-
tools: params.tools ?? [],
|
|
88
|
-
knowledge_bases: params.kbs ?? [],
|
|
89
|
-
language: params.language ?? "en",
|
|
90
|
-
config: params.config ?? {},
|
|
91
|
-
notebook_references: [],
|
|
92
|
-
history_references: [],
|
|
93
|
-
attachments: [],
|
|
94
|
-
skills: [],
|
|
95
|
-
}),
|
|
96
|
-
);
|
|
97
|
-
};
|
|
98
|
-
ws.onmessage = (ev: any) => {
|
|
99
|
-
let e: any;
|
|
100
|
-
try {
|
|
101
|
-
e = JSON.parse(ev.data);
|
|
102
|
-
} catch {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
collectEvent(state, e);
|
|
106
|
-
if (e.type === "done") {
|
|
107
|
-
finish(() => {
|
|
108
|
-
try {
|
|
109
|
-
ws.close();
|
|
110
|
-
} catch {}
|
|
111
|
-
resolve(state);
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
ws.onerror = () => {
|
|
116
|
-
finish(() => {
|
|
117
|
-
try {
|
|
118
|
-
ws.close();
|
|
119
|
-
} catch {}
|
|
120
|
-
reject(new Error("WebSocket connection error (server not running or tunnel down)"));
|
|
121
|
-
});
|
|
122
|
-
};
|
|
123
|
-
ws.onclose = () => {
|
|
124
|
-
finish(() => {
|
|
125
|
-
reject(new Error("WebSocket closed unexpectedly"));
|
|
126
|
-
});
|
|
127
|
-
};
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
42
|
/**
|
|
132
43
|
* Build the `deeptutor run` argv for a capability turn (pure, unit-tested).
|
|
133
44
|
* Scalar config values go as repeatable --config k=v; object/array values are
|
package/src/win-cmd.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
|
|
18
|
+
export interface LocalCommand {
|
|
19
|
+
file: string;
|
|
20
|
+
args: string[];
|
|
21
|
+
/** Pass the args to CreateProcess unchanged (needed for the cmd.exe command line). */
|
|
22
|
+
windowsVerbatimArguments?: boolean;
|
|
23
|
+
/** Set when the command cannot be run as requested; nothing should be spawned. */
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
|
|
28
|
+
|
|
29
|
+
/** Quote one argument for a cmd.exe command line (cross-spawn's escapeArgument). */
|
|
30
|
+
export function escapeCmdArgument(arg: string, doubleEscapeMetaChars: boolean): string {
|
|
31
|
+
let out = arg.replace(/(\\*)"/g, '$1$1\\"');
|
|
32
|
+
out = out.replace(/(\\*)$/, "$1$1");
|
|
33
|
+
out = `"${out}"`;
|
|
34
|
+
out = out.replace(META_CHARS, "^$1");
|
|
35
|
+
if (doubleEscapeMetaChars) out = out.replace(META_CHARS, "^$1");
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Escape the command path itself for a cmd.exe command line. */
|
|
40
|
+
export function escapeCmdCommand(command: string): string {
|
|
41
|
+
return command.replace(META_CHARS, "^$1");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the `cmd.exe /d /s /c "..."` invocation for a batch file. Arguments are escaped
|
|
46
|
+
* twice because a batch file that forwards `%*` makes cmd.exe parse them a second time.
|
|
47
|
+
*/
|
|
48
|
+
export function buildCmdInvocation(batchFile: string, args: string[], comSpec = "cmd.exe"): LocalCommand {
|
|
49
|
+
const line = [escapeCmdCommand(batchFile), ...args.map((a) => escapeCmdArgument(a, true))].join(" ");
|
|
50
|
+
return { file: comSpec, args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* If a .cmd/.bat wrapper does nothing but forward its arguments to an executable, return
|
|
55
|
+
* that executable's path. Accepts `@echo off`, `rem`/`::` comments and `setlocal`/`endlocal`
|
|
56
|
+
* around a single `["]<path>.exe["] %*` line; `%~dp0` is expanded to the wrapper's folder.
|
|
57
|
+
*/
|
|
58
|
+
export function parseCmdWrapper(text: string, wrapperPath: string): string | undefined {
|
|
59
|
+
const lines = text
|
|
60
|
+
.split(/\r?\n/)
|
|
61
|
+
.map((l) => l.trim())
|
|
62
|
+
.filter(
|
|
63
|
+
(l) =>
|
|
64
|
+
l !== "" &&
|
|
65
|
+
!/^@?echo\s+off$/i.test(l) &&
|
|
66
|
+
!/^@?(rem(\s|$)|::)/i.test(l) &&
|
|
67
|
+
!/^@?(setlocal|endlocal)(\s|$)/i.test(l),
|
|
68
|
+
);
|
|
69
|
+
if (lines.length !== 1) return undefined;
|
|
70
|
+
const m = /^@?(?:"([^"]+\.(?:exe|com))"|([^\s"]+\.(?:exe|com)))\s+%\*$/i.exec(lines[0]);
|
|
71
|
+
if (!m) return undefined;
|
|
72
|
+
// Windows path rules regardless of the host, so this also behaves the same in Linux CI.
|
|
73
|
+
const dir = win32.dirname(wrapperPath);
|
|
74
|
+
const target = (m[1] ?? m[2]).replace(/%~dp0/gi, dir + "\\");
|
|
75
|
+
if (/%/.test(target)) return undefined; // other variables: let cmd.exe expand them
|
|
76
|
+
return win32.resolve(dir, target);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Find a bare command name on PATH, trying each PATHEXT extension (Windows search order). */
|
|
80
|
+
export function findOnPath(
|
|
81
|
+
name: string,
|
|
82
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
83
|
+
exists: (p: string) => boolean = existsSync,
|
|
84
|
+
): string | undefined {
|
|
85
|
+
const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
86
|
+
// Windows path rules regardless of the host, so this also behaves the same in Linux CI.
|
|
87
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean);
|
|
88
|
+
for (const dir of dirs) {
|
|
89
|
+
for (const ext of exts) {
|
|
90
|
+
const candidate = win32.join(dir.replace(/^"|"$/g, ""), name + ext.toLowerCase());
|
|
91
|
+
if (exists(candidate)) return candidate;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Turn a local CLI path plus argv into something execFile can start on this platform. */
|
|
98
|
+
export function resolveLocalCommand(
|
|
99
|
+
file: string,
|
|
100
|
+
args: string[],
|
|
101
|
+
platform: NodeJS.Platform = process.platform,
|
|
102
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
103
|
+
): LocalCommand {
|
|
104
|
+
if (platform !== "win32") return { file, args };
|
|
105
|
+
let target = file;
|
|
106
|
+
const isBareName = !/[\\/]/.test(target) && extname(target) === "";
|
|
107
|
+
if (isBareName) target = findOnPath(target, env) ?? target;
|
|
108
|
+
if (!/\.(cmd|bat)$/i.test(target)) return { file: target, args };
|
|
109
|
+
|
|
110
|
+
let wrapped: string | undefined;
|
|
111
|
+
try {
|
|
112
|
+
wrapped = parseCmdWrapper(readFileSync(target, "utf8"), target);
|
|
113
|
+
} catch {
|
|
114
|
+
wrapped = undefined;
|
|
115
|
+
}
|
|
116
|
+
if (wrapped && existsSync(wrapped)) return { file: wrapped, args };
|
|
117
|
+
|
|
118
|
+
if (args.some((a) => /[\r\n]/.test(a))) {
|
|
119
|
+
return {
|
|
120
|
+
file: target,
|
|
121
|
+
args,
|
|
122
|
+
error:
|
|
123
|
+
`${target} is a batch file, and cmd.exe cannot pass arguments that contain line breaks. ` +
|
|
124
|
+
"Point DEEPTUTOR_LOCAL_BIN at deeptutor.exe instead (in a pip/venv install it is " +
|
|
125
|
+
"<venv>\\Scripts\\deeptutor.exe).",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return buildCmdInvocation(target, args, env.ComSpec ?? env.COMSPEC ?? "cmd.exe");
|
|
129
|
+
}
|
package/lib/http-api.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/** Kill the SSH tunnel process, if one is running (call on plugin disposal). */
|
|
2
|
-
export declare function killTunnel(): void;
|
|
3
|
-
export declare function apiProbe(timeoutMs?: number): Promise<boolean>;
|
|
4
|
-
/** Ensure the local API is reachable: probe → start SSH tunnel if needed (remote only) → wait. */
|
|
5
|
-
export declare function ensureApi(): Promise<boolean>;
|
|
6
|
-
/** JSON fetch against the DeepTutor API. Combines a fixed timeout with an optional caller-supplied abort signal. */
|
|
7
|
-
export declare function apiJson(path: string, init?: RequestInit, opts?: {
|
|
8
|
-
timeoutMs?: number;
|
|
9
|
-
signal?: AbortSignal;
|
|
10
|
-
}): Promise<any>;
|
package/lib/http-api.js
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DeepTutor bridge: HTTP/WS transport (API probing, SSH tunnel management, JSON calls).
|
|
3
|
-
*/
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { API_BASE, SSH_HOST, TUNNEL_PORT } from "./config.js";
|
|
6
|
-
let tunnelProc = null;
|
|
7
|
-
/** Kill the SSH tunnel process, if one is running (call on plugin disposal). */
|
|
8
|
-
export function killTunnel() {
|
|
9
|
-
if (tunnelProc && tunnelProc.exitCode === null) {
|
|
10
|
-
try {
|
|
11
|
-
tunnelProc.kill();
|
|
12
|
-
}
|
|
13
|
-
catch { }
|
|
14
|
-
tunnelProc = null;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
export async function apiProbe(timeoutMs = 1500) {
|
|
18
|
-
try {
|
|
19
|
-
const res = await fetch(`${API_BASE}/api/v1/system/status`, {
|
|
20
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
21
|
-
});
|
|
22
|
-
return res.ok;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
/** Ensure the local API is reachable: probe → start SSH tunnel if needed (remote only) → wait. */
|
|
29
|
-
export async function ensureApi() {
|
|
30
|
-
if (await apiProbe(1200))
|
|
31
|
-
return true;
|
|
32
|
-
if (!SSH_HOST)
|
|
33
|
-
return false; // Local deployment: fall back to local CLI when serve is down, no tunnel needed
|
|
34
|
-
let tunnelDead = false;
|
|
35
|
-
if (!tunnelProc || tunnelProc.exitCode !== null) {
|
|
36
|
-
tunnelProc = spawn("ssh", [
|
|
37
|
-
"-N",
|
|
38
|
-
"-L",
|
|
39
|
-
`${TUNNEL_PORT}:127.0.0.1:${TUNNEL_PORT}`,
|
|
40
|
-
"-o",
|
|
41
|
-
"BatchMode=yes",
|
|
42
|
-
"-o",
|
|
43
|
-
"ExitOnForwardFailure=yes",
|
|
44
|
-
"-o",
|
|
45
|
-
"ServerAliveInterval=30",
|
|
46
|
-
"-o",
|
|
47
|
-
"ServerAliveCountMax=3",
|
|
48
|
-
"-o",
|
|
49
|
-
"ConnectTimeout=10",
|
|
50
|
-
SSH_HOST,
|
|
51
|
-
], { stdio: "ignore", windowsHide: true });
|
|
52
|
-
// Fast-fail: a tunnel that exits immediately (bad alias, port in use,
|
|
53
|
-
// refused connection) is not going to recover — stop waiting for it.
|
|
54
|
-
tunnelProc.once("exit", () => {
|
|
55
|
-
tunnelDead = true;
|
|
56
|
-
tunnelProc = null;
|
|
57
|
-
});
|
|
58
|
-
tunnelProc.unref();
|
|
59
|
-
}
|
|
60
|
-
for (let i = 0; i < 20; i++) {
|
|
61
|
-
if (tunnelDead)
|
|
62
|
-
return false;
|
|
63
|
-
if (await apiProbe(1000))
|
|
64
|
-
return true;
|
|
65
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
66
|
-
}
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
/** JSON fetch against the DeepTutor API. Combines a fixed timeout with an optional caller-supplied abort signal. */
|
|
70
|
-
export async function apiJson(path, init, opts = {}) {
|
|
71
|
-
const controller = new AbortController();
|
|
72
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
|
|
73
|
-
const onAbort = () => controller.abort();
|
|
74
|
-
// An already-aborted signal never fires its "abort" event again — check the
|
|
75
|
-
// flag first so a pre-aborted call fails immediately instead of running to
|
|
76
|
-
// completion against a still-open controller.
|
|
77
|
-
if (opts.signal?.aborted) {
|
|
78
|
-
controller.abort();
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
const res = await fetch(`${API_BASE}${path}`, { ...init, signal: controller.signal });
|
|
85
|
-
if (!res.ok)
|
|
86
|
-
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
87
|
-
const text = await res.text();
|
|
88
|
-
return text ? JSON.parse(text) : null;
|
|
89
|
-
}
|
|
90
|
-
finally {
|
|
91
|
-
clearTimeout(timer);
|
|
92
|
-
opts.signal?.removeEventListener("abort", onAbort);
|
|
93
|
-
}
|
|
94
|
-
}
|
package/scripts/sync-skills.mjs
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Copy the shared skill files from a pi-extensions checkout into this repo.
|
|
4
|
-
* The pi repo is the single source of truth; the files must be agent-neutral.
|
|
5
|
-
*
|
|
6
|
-
* Usage:
|
|
7
|
-
* node scripts/sync-skills.mjs [path-to-pi-extensions]
|
|
8
|
-
* Default source: ../pi-extensions
|
|
9
|
-
*/
|
|
10
|
-
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
11
|
-
import { dirname, join, resolve } from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
|
-
|
|
14
|
-
const here = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
-
const piRoot = resolve(process.argv[2] ?? join(here, "..", "pi-extensions"));
|
|
16
|
-
|
|
17
|
-
// [pi source, this-repo destination]
|
|
18
|
-
// The bundle uses the root scripts/ copy at runtime (src/html-render.ts);
|
|
19
|
-
// the skill copy ships inside the package under .agents/skills/. Both must
|
|
20
|
-
// stay identical.
|
|
21
|
-
const FILES = [
|
|
22
|
-
["skills/deeptutor/SKILL.md", ".agents/skills/deeptutor/SKILL.md"],
|
|
23
|
-
["skills/deeptutor/README.md", ".agents/skills/deeptutor/README.md"],
|
|
24
|
-
["skills/html-doc/SKILL.md", ".agents/skills/html-doc/SKILL.md"],
|
|
25
|
-
["skills/html-doc/assets/template.html", ".agents/skills/html-doc/assets/template.html"],
|
|
26
|
-
["skills/html-doc/scripts/md-to-html.js", "scripts/md-to-html.js"],
|
|
27
|
-
["skills/html-doc/scripts/md-to-html.js", ".agents/skills/html-doc/scripts/md-to-html.js"],
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
let failures = 0;
|
|
31
|
-
for (const [src, dst] of FILES) {
|
|
32
|
-
const from = join(piRoot, src);
|
|
33
|
-
const to = join(here, dst);
|
|
34
|
-
if (!existsSync(from)) {
|
|
35
|
-
console.error(`[sync-skills] missing source: ${from}`);
|
|
36
|
-
failures++;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
mkdirSync(dirname(to), { recursive: true });
|
|
40
|
-
copyFileSync(from, to);
|
|
41
|
-
console.log(`[sync-skills] ${src} -> ${dst}`);
|
|
42
|
-
}
|
|
43
|
-
if (failures > 0) {
|
|
44
|
-
console.error("[sync-skills] some sources missing — is the pi-extensions path correct?");
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
|
-
console.log("[sync-skills] done — review the diff, then commit.");
|