dsh-deeptutor 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,19 +1,18 @@
1
1
  /**
2
2
  * DeepTutor bridge plugin for DeepSeek Harness (dsh).
3
3
  *
4
- * Migrated from the pi coding-agent extension (TecFancy/pi-extensions,
5
- * extensions/deeptutor + skills/deeptutor). Registers three model-facing tools:
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
- * Deployment auto-detection:
11
- * - Local: deeptutor serve runs on this machine → API probe succeeds directly; if serve is
12
- * not running, falls back to the local CLI (DEEPTUTOR_LOCAL_BIN).
13
- * - Remote: serve runs on a server (listening on 127.0.0.1 only) → an SSH tunnel is started
14
- * automatically to reach the API; if the tunnel is unavailable, falls back to the SSH CLI.
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 / DEEPTUTOR_API_BASE / DEEPTUTOR_LOCAL_BIN /
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, wsRunTurn } from "./turn.ts";
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 (HTTP and CLI both unavailable)] ${err?.message ?? String(err)}`,
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
- // search goes through the CLI (REST has no dedicated search route);
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
- // HTTP first
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 (CLI)] notebook=${nbName}, title=${args.title}, type=${recordType}\n${truncate((stdout ?? "").trim(), 2000)}`,
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 over WebSocket (HTTP mode)
3
- * or the CLI (fallback), and shared JSONL/WS event parsing + result formatting.
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 { API_BASE, truncate } from "./config.ts";
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 both wsRunTurn and cliRunTurn read off the deeptutor_run tool params. */
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 event (WS or CLI JSONL) into the running turn state. Exported for unit tests. */
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
- }
@@ -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.");