dsh-deeptutor 0.1.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/README.md +101 -0
- package/README.zh.md +85 -0
- package/cordis.patch.yml +17 -0
- package/lib/cli-exec.d.ts +28 -0
- package/lib/cli-exec.js +188 -0
- package/lib/config.d.ts +20 -0
- package/lib/config.js +58 -0
- package/lib/html-render.d.ts +14 -0
- package/lib/html-render.js +41 -0
- package/lib/http-api.d.ts +10 -0
- package/lib/http-api.js +77 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +384 -0
- package/lib/turn.d.ts +24 -0
- package/lib/turn.js +159 -0
- package/package.json +53 -0
- package/scripts/md-to-html.js +353 -0
- package/src/cli-exec.ts +251 -0
- package/src/config.ts +70 -0
- package/src/html-render.ts +53 -0
- package/src/http-api.ts +83 -0
- package/src/index.ts +449 -0
- package/src/turn.ts +174 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
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).
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
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
|
+
);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render a learning answer to a self-contained local HTML file (dark/light toggle,
|
|
20
|
+
* TOC, collapsible quiz answers, print-friendly). Also writes the companion .md
|
|
21
|
+
* source next to the html file.
|
|
22
|
+
*
|
|
23
|
+
* Returns a short status note to append to the tool result ("" when no html path
|
|
24
|
+
* given; "[html] ✗ …" note when rendering fails — never throws).
|
|
25
|
+
*/
|
|
26
|
+
export async function renderHtml(
|
|
27
|
+
params: { html?: string; html_title?: string },
|
|
28
|
+
answer: string,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const out = params.html;
|
|
31
|
+
if (!out) return "";
|
|
32
|
+
const htmlPath = extname(out).toLowerCase() === ".html" ? resolve(out) : resolve(out + ".html");
|
|
33
|
+
const mdPath = htmlPath.slice(0, -extname(htmlPath).length) + ".md";
|
|
34
|
+
const title = params.html_title || basename(htmlPath, extname(htmlPath));
|
|
35
|
+
try {
|
|
36
|
+
if (!existsSync(HTML_DOC_SCRIPT)) {
|
|
37
|
+
return `\n[html] ✗ html-doc converter not found: ${HTML_DOC_SCRIPT}`;
|
|
38
|
+
}
|
|
39
|
+
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
40
|
+
writeFileSync(mdPath, answer || "(no answer)", "utf8");
|
|
41
|
+
await new Promise<void>((res, rej) => {
|
|
42
|
+
execFile(
|
|
43
|
+
process.execPath,
|
|
44
|
+
[HTML_DOC_SCRIPT, mdPath, "--out", htmlPath, "--title", title, "--toc"],
|
|
45
|
+
{ windowsHide: true, timeout: 60_000 },
|
|
46
|
+
(err, _stdout, stderr) => (err ? rej(new Error(stderr || err.message)) : res()),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
return `\n[html] ✓ rendered (html-doc skill)\n[html] page: ${htmlPath}\n[html] source: ${mdPath}`;
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
return `\n[html] ✗ rendering failed: ${err?.message ?? String(err)}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/http-api.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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.ts";
|
|
6
|
+
|
|
7
|
+
let tunnelProc: ReturnType<typeof spawn> | null = null;
|
|
8
|
+
|
|
9
|
+
/** Kill the SSH tunnel process, if one is running (call on plugin disposal). */
|
|
10
|
+
export function killTunnel(): void {
|
|
11
|
+
if (tunnelProc && tunnelProc.exitCode === null) {
|
|
12
|
+
try {
|
|
13
|
+
tunnelProc.kill();
|
|
14
|
+
} catch {}
|
|
15
|
+
tunnelProc = null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function apiProbe(timeoutMs = 1500): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(`${API_BASE}/api/v1/system/status`, {
|
|
22
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
23
|
+
});
|
|
24
|
+
return res.ok;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ensure the local API is reachable: probe → start SSH tunnel if needed (remote only) → wait. */
|
|
31
|
+
export async function ensureApi(): Promise<boolean> {
|
|
32
|
+
if (await apiProbe(1200)) return true;
|
|
33
|
+
if (!SSH_HOST) return false; // Local deployment: fall back to local CLI when serve is down, no tunnel needed
|
|
34
|
+
if (!tunnelProc || tunnelProc.exitCode !== null) {
|
|
35
|
+
tunnelProc = spawn(
|
|
36
|
+
"ssh",
|
|
37
|
+
[
|
|
38
|
+
"-N",
|
|
39
|
+
"-L",
|
|
40
|
+
`${TUNNEL_PORT}:127.0.0.1:${TUNNEL_PORT}`,
|
|
41
|
+
"-o",
|
|
42
|
+
"BatchMode=yes",
|
|
43
|
+
"-o",
|
|
44
|
+
"ExitOnForwardFailure=yes",
|
|
45
|
+
"-o",
|
|
46
|
+
"ServerAliveInterval=30",
|
|
47
|
+
"-o",
|
|
48
|
+
"ServerAliveCountMax=3",
|
|
49
|
+
"-o",
|
|
50
|
+
"ConnectTimeout=10",
|
|
51
|
+
SSH_HOST,
|
|
52
|
+
],
|
|
53
|
+
{ stdio: "ignore", windowsHide: true },
|
|
54
|
+
);
|
|
55
|
+
tunnelProc.unref();
|
|
56
|
+
}
|
|
57
|
+
for (let i = 0; i < 20; i++) {
|
|
58
|
+
if (await apiProbe(1000)) return true;
|
|
59
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** JSON fetch against the DeepTutor API. Combines a fixed timeout with an optional caller-supplied abort signal. */
|
|
65
|
+
export async function apiJson(
|
|
66
|
+
path: string,
|
|
67
|
+
init?: RequestInit,
|
|
68
|
+
opts: { timeoutMs?: number; signal?: AbortSignal } = {},
|
|
69
|
+
): Promise<any> {
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
|
|
72
|
+
const onAbort = () => controller.abort();
|
|
73
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`${API_BASE}${path}`, { ...init, signal: controller.signal });
|
|
76
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
return text ? JSON.parse(text) : null;
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepTutor bridge plugin for DeepSeek Harness (dsh).
|
|
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)
|
|
7
|
+
* - deeptutor_kb — list / search / info knowledge bases
|
|
8
|
+
* - deeptutor_note — archive a Markdown learning note to a server notebook
|
|
9
|
+
*
|
|
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.
|
|
15
|
+
*
|
|
16
|
+
* Env config (agent-agnostic): DEEPTUTOR_SSH_HOST / DEEPTUTOR_API_BASE / DEEPTUTOR_LOCAL_BIN /
|
|
17
|
+
* DEEPTUTOR_REMOTE_BIN / DEEPTUTOR_REMOTE_HOME — see src/config.ts and skills/deeptutor/SKILL.md.
|
|
18
|
+
*/
|
|
19
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
20
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
21
|
+
import { CAPABILITIES, configGuide, configHint, SSH_HOST, truncate } from "./config.ts";
|
|
22
|
+
import { cliNoteLocal, cliNoteRemote, runCli } from "./cli-exec.ts";
|
|
23
|
+
import { apiJson, ensureApi, killTunnel } from "./http-api.ts";
|
|
24
|
+
import { renderHtml } from "./html-render.ts";
|
|
25
|
+
import { cliRunTurn, fmtTurnResult, wsRunTurn } from "./turn.ts";
|
|
26
|
+
import { HTML_DOC_SCRIPT } from "./html-render.ts";
|
|
27
|
+
|
|
28
|
+
export const name = "dsh-deeptutor";
|
|
29
|
+
export const inject = ["tools"] as const;
|
|
30
|
+
|
|
31
|
+
/** Drop keys whose value is `undefined` so the canonical JSON value stays lossless-JSON. */
|
|
32
|
+
type Jsonify<T extends Record<string, unknown>> = {
|
|
33
|
+
[K in keyof T as T[K] extends undefined ? never : K]: Exclude<T[K], undefined>;
|
|
34
|
+
};
|
|
35
|
+
function json<T extends Record<string, unknown>>(o: T): Jsonify<T> {
|
|
36
|
+
const out: Record<string, unknown> = {};
|
|
37
|
+
for (const [k, v] of Object.entries(o)) if (v !== undefined) out[k] = v;
|
|
38
|
+
return out as Jsonify<T>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function apply(ctx: Context) {
|
|
42
|
+
console.log(`[dsh-deeptutor] plugin loaded (html script: ${HTML_DOC_SCRIPT})`);
|
|
43
|
+
// Kill the SSH tunnel process when the plugin unloads.
|
|
44
|
+
ctx.effect(() => {
|
|
45
|
+
return () => killTunnel();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---------- Tool 1: run a DeepTutor learning capability ----------
|
|
49
|
+
ctx.tools.register(
|
|
50
|
+
defineTool({
|
|
51
|
+
name: "deeptutor_run",
|
|
52
|
+
description:
|
|
53
|
+
"Execute one learning call on the DeepTutor service: deep_solve (in-depth explanation), deep_question (generate self-test questions), deep_research (deep research), chat (conversation), mastery_path (learning path planning), visualize / math_animator (visualization). Can mount knowledge bases (kbs) and tools (tools like rag / web_search). Returns the final answer plus session_id; pass session_id in later turns to continue the context (inherits chat history, knowledge bases and tools).",
|
|
54
|
+
parameters: {
|
|
55
|
+
capability: {
|
|
56
|
+
type: "string",
|
|
57
|
+
enum: [...CAPABILITIES],
|
|
58
|
+
required: true,
|
|
59
|
+
description: "The learning capability to run",
|
|
60
|
+
},
|
|
61
|
+
prompt: {
|
|
62
|
+
type: "string",
|
|
63
|
+
required: true,
|
|
64
|
+
description:
|
|
65
|
+
"The learning question or instruction for this turn (e.g. explain the async/await state machine)",
|
|
66
|
+
},
|
|
67
|
+
kbs: {
|
|
68
|
+
type: "array",
|
|
69
|
+
items: { type: "string" },
|
|
70
|
+
description:
|
|
71
|
+
"Knowledge base names (check deeptutor_kb list for available ones, e.g. the user's dotnet/sqlserver bases)",
|
|
72
|
+
},
|
|
73
|
+
tools: {
|
|
74
|
+
type: "array",
|
|
75
|
+
items: { type: "string" },
|
|
76
|
+
description:
|
|
77
|
+
"Tools to enable: rag (KB retrieval), web_search, reason, brainstorm, paper_search, code_execution, read_source, etc.",
|
|
78
|
+
},
|
|
79
|
+
session_id: {
|
|
80
|
+
type: "string",
|
|
81
|
+
description:
|
|
82
|
+
"Session ID from a previous deeptutor_run call to continue that context",
|
|
83
|
+
},
|
|
84
|
+
config: {
|
|
85
|
+
type: "object",
|
|
86
|
+
additionalProperties: true,
|
|
87
|
+
description:
|
|
88
|
+
"Capability config key-values, e.g. num_questions=5 for deep_question, mode=report / depth=standard for deep_research",
|
|
89
|
+
},
|
|
90
|
+
language: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Response language code, default en; use zh for Chinese",
|
|
93
|
+
},
|
|
94
|
+
timeout: {
|
|
95
|
+
type: "number",
|
|
96
|
+
description: "Timeout in seconds, default 600; use 900+ for deep_research",
|
|
97
|
+
},
|
|
98
|
+
max_chars: {
|
|
99
|
+
type: "number",
|
|
100
|
+
description:
|
|
101
|
+
"Max characters of the answer returned to the model, default 30000",
|
|
102
|
+
},
|
|
103
|
+
html: {
|
|
104
|
+
type: "string",
|
|
105
|
+
description:
|
|
106
|
+
"Optional output path for a self-contained local HTML render of the answer (bundled md-to-html converter; also writes the companion .md source next to it). e.g. data/study/abstract-class/knowledge.html — open the HTML in a browser to read comfortably. Set it when the user wants browsable/printable study material.",
|
|
107
|
+
},
|
|
108
|
+
html_title: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description:
|
|
111
|
+
"HTML page title (default: derived from the html output file name)",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
schema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
mode: { type: "string" },
|
|
119
|
+
sessionId: { type: "string" },
|
|
120
|
+
answer: { type: "string" },
|
|
121
|
+
toolCalls: { type: "array", items: { type: "string" } },
|
|
122
|
+
errors: { type: "array", items: { type: "string" } },
|
|
123
|
+
text: { type: "string" },
|
|
124
|
+
},
|
|
125
|
+
additionalProperties: true,
|
|
126
|
+
},
|
|
127
|
+
render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
|
|
128
|
+
},
|
|
129
|
+
async execute(args, exec) {
|
|
130
|
+
const timeoutMs = (args.timeout ?? 600) * 1000;
|
|
131
|
+
const maxChars = args.max_chars ?? 30000;
|
|
132
|
+
try {
|
|
133
|
+
if (await ensureApi()) {
|
|
134
|
+
const r = await wsRunTurn(args, timeoutMs, exec.signal);
|
|
135
|
+
const htmlNote = await renderHtml(args, r.answer);
|
|
136
|
+
return json({
|
|
137
|
+
mode: "http",
|
|
138
|
+
sessionId: r.sessionId,
|
|
139
|
+
toolCalls: r.toolCalls,
|
|
140
|
+
errors: r.errors,
|
|
141
|
+
text:
|
|
142
|
+
configHint() + fmtTurnResult(args.capability, r, maxChars) + htmlNote,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
if (exec.signal.aborted) {
|
|
147
|
+
// user cancelled mid-flight: don't start a fresh CLI run behind their back
|
|
148
|
+
throw new Error("[deeptutor_run cancelled]");
|
|
149
|
+
}
|
|
150
|
+
console.log(`[deeptutor] HTTP/WS failed, falling back to CLI: ${err?.message}`);
|
|
151
|
+
}
|
|
152
|
+
// fallback: local/SSH CLI
|
|
153
|
+
try {
|
|
154
|
+
const r = await cliRunTurn(args, timeoutMs, exec.signal);
|
|
155
|
+
const htmlNote = await renderHtml(args, r.answer);
|
|
156
|
+
return json({
|
|
157
|
+
mode: "cli",
|
|
158
|
+
sessionId: r.sessionId,
|
|
159
|
+
toolCalls: r.toolCalls,
|
|
160
|
+
errors: r.errors,
|
|
161
|
+
text:
|
|
162
|
+
configHint() + fmtTurnResult(args.capability, r, maxChars) + htmlNote,
|
|
163
|
+
});
|
|
164
|
+
} catch (err: any) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
configGuide() +
|
|
167
|
+
`[deeptutor_run failed (HTTP and CLI both unavailable)] ${err?.message ?? String(err)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// ---------- Tool 2: knowledge base operations ----------
|
|
175
|
+
ctx.tools.register(
|
|
176
|
+
defineTool({
|
|
177
|
+
name: "deeptutor_kb",
|
|
178
|
+
description:
|
|
179
|
+
"Manage/search DeepTutor knowledge bases: list all bases, search inside a base (RAG hit snippets), info for details. Works in both local and remote deployments. Search the user's personal knowledge bases before generating learning content and combine the hits with authoritative sources.",
|
|
180
|
+
parameters: {
|
|
181
|
+
action: {
|
|
182
|
+
type: "string",
|
|
183
|
+
enum: ["list", "search", "info"],
|
|
184
|
+
required: true,
|
|
185
|
+
description: "What to do with the knowledge bases",
|
|
186
|
+
},
|
|
187
|
+
kb: {
|
|
188
|
+
type: "string",
|
|
189
|
+
description: "Knowledge base name (required for search/info)",
|
|
190
|
+
},
|
|
191
|
+
query: {
|
|
192
|
+
type: "string",
|
|
193
|
+
description:
|
|
194
|
+
"Search question (required for search, e.g. C# generics covariance/contravariance)",
|
|
195
|
+
},
|
|
196
|
+
mode: {
|
|
197
|
+
type: "string",
|
|
198
|
+
enum: ["hybrid", "dense", "sparse"],
|
|
199
|
+
description: "Retrieval mode, default hybrid",
|
|
200
|
+
},
|
|
201
|
+
top_k: {
|
|
202
|
+
type: "number",
|
|
203
|
+
description: "Number of hits to return, default 5",
|
|
204
|
+
},
|
|
205
|
+
max_chars: {
|
|
206
|
+
type: "number",
|
|
207
|
+
description:
|
|
208
|
+
"Max characters of the returned content, default 30000",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
output: {
|
|
212
|
+
schema: {
|
|
213
|
+
type: "object",
|
|
214
|
+
properties: {
|
|
215
|
+
action: { type: "string" },
|
|
216
|
+
mode: { type: "string" },
|
|
217
|
+
kb: { type: "string" },
|
|
218
|
+
query: { type: "string" },
|
|
219
|
+
text: { type: "string" },
|
|
220
|
+
},
|
|
221
|
+
additionalProperties: true,
|
|
222
|
+
},
|
|
223
|
+
render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
|
|
224
|
+
},
|
|
225
|
+
async execute(args, exec) {
|
|
226
|
+
const maxChars = args.max_chars ?? 30000;
|
|
227
|
+
// search goes through the CLI (REST has no dedicated search route);
|
|
228
|
+
// list/info prefer HTTP, fall back to CLI
|
|
229
|
+
if (args.action !== "search") {
|
|
230
|
+
try {
|
|
231
|
+
if (await ensureApi()) {
|
|
232
|
+
if (args.action === "list") {
|
|
233
|
+
const arr = await apiJson("/api/v1/knowledge/list", undefined, {
|
|
234
|
+
signal: exec.signal,
|
|
235
|
+
});
|
|
236
|
+
const body = Array.isArray(arr)
|
|
237
|
+
? arr
|
|
238
|
+
.map(
|
|
239
|
+
(kb: any) =>
|
|
240
|
+
`- ${kb.name} (status=${kb.status ?? "?"}, ${kb.statistics ? JSON.stringify(kb.statistics) : "no stats"}${kb.is_default ? ", default" : ""})`,
|
|
241
|
+
)
|
|
242
|
+
.join("\n")
|
|
243
|
+
: JSON.stringify(arr);
|
|
244
|
+
return json({
|
|
245
|
+
action: "list",
|
|
246
|
+
mode: "http",
|
|
247
|
+
text:
|
|
248
|
+
configHint() +
|
|
249
|
+
`Knowledge bases (${Array.isArray(arr) ? arr.length : "?"}):\n${body}`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const kb = await apiJson(
|
|
253
|
+
`/api/v1/knowledge/${encodeURIComponent(args.kb ?? "")}`,
|
|
254
|
+
undefined,
|
|
255
|
+
{ signal: exec.signal },
|
|
256
|
+
);
|
|
257
|
+
return json({
|
|
258
|
+
action: "info",
|
|
259
|
+
mode: "http",
|
|
260
|
+
kb: args.kb,
|
|
261
|
+
text: truncate(JSON.stringify(kb, null, 2), maxChars),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
} catch (err: any) {
|
|
265
|
+
console.log(
|
|
266
|
+
`[deeptutor] kb ${args.action} HTTP failed, falling back to CLI: ${err?.message}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// CLI fallback (local or SSH)
|
|
271
|
+
let argv: string[];
|
|
272
|
+
if (args.action === "list") {
|
|
273
|
+
argv = ["kb", "list", "--format", "json"];
|
|
274
|
+
} else if (args.action === "info") {
|
|
275
|
+
argv = ["kb", "info", args.kb ?? ""];
|
|
276
|
+
} else {
|
|
277
|
+
argv = [
|
|
278
|
+
"kb",
|
|
279
|
+
"search",
|
|
280
|
+
args.kb ?? "",
|
|
281
|
+
args.query ?? "",
|
|
282
|
+
"--format",
|
|
283
|
+
"json",
|
|
284
|
+
"--mode",
|
|
285
|
+
args.mode ?? "hybrid",
|
|
286
|
+
];
|
|
287
|
+
if (args.top_k) argv.push("--top-k", String(args.top_k));
|
|
288
|
+
}
|
|
289
|
+
const { stdout, stderr, code, killed } = await runCli(argv, {
|
|
290
|
+
signal: exec.signal,
|
|
291
|
+
timeoutMs: 90_000,
|
|
292
|
+
});
|
|
293
|
+
if (killed) throw new Error(configGuide() + "[deeptutor_kb timed out]");
|
|
294
|
+
if (code !== 0) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`[deeptutor_kb failed] exit=${code}\nstderr: ${truncate(stderr || "(none)", 2000)}` +
|
|
297
|
+
configGuide(),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
let body = stdout.trim() || "(empty result)";
|
|
301
|
+
if (args.action === "search") {
|
|
302
|
+
try {
|
|
303
|
+
const arr = JSON.parse(stdout);
|
|
304
|
+
if (Array.isArray(arr)) {
|
|
305
|
+
body = arr
|
|
306
|
+
.map((s: any, i: number) => {
|
|
307
|
+
const text = s.text ?? s.content ?? s.snippet ?? JSON.stringify(s);
|
|
308
|
+
const src = s.source ?? s.doc_name ?? s.file ?? "";
|
|
309
|
+
return `[${i + 1}]${src ? ` source: ${src}` : ""}\n${typeof text === "string" ? text : JSON.stringify(text)}`;
|
|
310
|
+
})
|
|
311
|
+
.join("\n\n");
|
|
312
|
+
body = `${arr.length} hits:\n\n` + body;
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
/* non-JSON output returned as-is */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return json({
|
|
319
|
+
action: args.action,
|
|
320
|
+
mode: "cli",
|
|
321
|
+
kb: args.kb,
|
|
322
|
+
query: args.query,
|
|
323
|
+
text: truncate(body, maxChars),
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// ---------- Tool 3: archive learning notes to a server notebook ----------
|
|
330
|
+
ctx.tools.register(
|
|
331
|
+
defineTool({
|
|
332
|
+
name: "deeptutor_note",
|
|
333
|
+
description:
|
|
334
|
+
"Archive a Markdown learning note (study plan, deep-solve summary, wrong answers, etc.) to a DeepTutor notebook (auto-creates the notebook if missing). Record type: chat / question / research / solve.",
|
|
335
|
+
parameters: {
|
|
336
|
+
notebook: {
|
|
337
|
+
type: "string",
|
|
338
|
+
required: true,
|
|
339
|
+
description:
|
|
340
|
+
"Notebook name, e.g. dotnet-learning (auto-created if missing)",
|
|
341
|
+
},
|
|
342
|
+
title: {
|
|
343
|
+
type: "string",
|
|
344
|
+
required: true,
|
|
345
|
+
description: "Record title, e.g. async/await state machine deep-dive notes",
|
|
346
|
+
},
|
|
347
|
+
type: {
|
|
348
|
+
type: "string",
|
|
349
|
+
enum: ["chat", "question", "research", "solve"],
|
|
350
|
+
description: "Record type, default chat",
|
|
351
|
+
},
|
|
352
|
+
content: {
|
|
353
|
+
type: "string",
|
|
354
|
+
required: true,
|
|
355
|
+
description: "Learning note content in Markdown",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
output: {
|
|
359
|
+
schema: {
|
|
360
|
+
type: "object",
|
|
361
|
+
properties: {
|
|
362
|
+
notebook: { type: "string" },
|
|
363
|
+
nbId: { type: "string" },
|
|
364
|
+
title: { type: "string" },
|
|
365
|
+
mode: { type: "string" },
|
|
366
|
+
text: { type: "string" },
|
|
367
|
+
},
|
|
368
|
+
additionalProperties: true,
|
|
369
|
+
},
|
|
370
|
+
render: (_args, value) => [{ type: "text", text: value.text ?? "" }],
|
|
371
|
+
},
|
|
372
|
+
async execute(args, exec) {
|
|
373
|
+
const recordType = (args.type ?? "chat") as string;
|
|
374
|
+
const nbName = args.notebook.trim();
|
|
375
|
+
// HTTP first
|
|
376
|
+
try {
|
|
377
|
+
if (await ensureApi()) {
|
|
378
|
+
const list = await apiJson("/api/v1/notebook/list", undefined, {
|
|
379
|
+
signal: exec.signal,
|
|
380
|
+
});
|
|
381
|
+
const notebooks: any[] = list?.notebooks ?? [];
|
|
382
|
+
let nb = notebooks.find((n) => n.name?.trim() === nbName);
|
|
383
|
+
if (!nb) {
|
|
384
|
+
const created = await apiJson(
|
|
385
|
+
"/api/v1/notebook/create",
|
|
386
|
+
{
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers: { "Content-Type": "application/json" },
|
|
389
|
+
body: JSON.stringify({
|
|
390
|
+
name: nbName,
|
|
391
|
+
description: `Archived by dsh agent (${new Date().toISOString().slice(0, 10)})`,
|
|
392
|
+
}),
|
|
393
|
+
},
|
|
394
|
+
{ signal: exec.signal },
|
|
395
|
+
);
|
|
396
|
+
nb = created?.notebook ?? created;
|
|
397
|
+
}
|
|
398
|
+
const nbId = nb?.id ?? (typeof nb === "string" ? nb : undefined);
|
|
399
|
+
if (!nbId) throw new Error("cannot resolve notebook id");
|
|
400
|
+
const added = await apiJson(
|
|
401
|
+
"/api/v1/notebook/add_record",
|
|
402
|
+
{
|
|
403
|
+
method: "POST",
|
|
404
|
+
headers: { "Content-Type": "application/json" },
|
|
405
|
+
body: JSON.stringify({
|
|
406
|
+
notebook_ids: [nbId],
|
|
407
|
+
record_type: recordType,
|
|
408
|
+
title: args.title,
|
|
409
|
+
user_query: args.title,
|
|
410
|
+
output: args.content,
|
|
411
|
+
}),
|
|
412
|
+
},
|
|
413
|
+
{ signal: exec.signal },
|
|
414
|
+
);
|
|
415
|
+
return json({
|
|
416
|
+
notebook: nbName,
|
|
417
|
+
nbId: String(nbId),
|
|
418
|
+
title: args.title,
|
|
419
|
+
mode: "http",
|
|
420
|
+
text:
|
|
421
|
+
configHint() +
|
|
422
|
+
`[note archived (HTTP)] notebook=${nbName} (${nbId}), title=${args.title}, type=${recordType}\n${truncate(JSON.stringify(added ?? {}), 800)}`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
} catch (err: any) {
|
|
426
|
+
console.log(`[deeptutor] note HTTP failed, falling back to CLI: ${err?.message}`);
|
|
427
|
+
}
|
|
428
|
+
// CLI fallback: remote deployment uses the remote PY_NOTE script; local deployment runs locally
|
|
429
|
+
const { stdout, stderr, code } = SSH_HOST
|
|
430
|
+
? await cliNoteRemote(nbName, args.title, recordType, args.content, exec.signal)
|
|
431
|
+
: await cliNoteLocal(nbName, args.title, recordType, args.content, exec.signal);
|
|
432
|
+
if (code !== 0) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`[deeptutor_note failed] exit=${code}\n${truncate(stderr || stdout, 2000)}` +
|
|
435
|
+
configGuide(),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return json({
|
|
439
|
+
notebook: nbName,
|
|
440
|
+
title: args.title,
|
|
441
|
+
mode: "cli",
|
|
442
|
+
text:
|
|
443
|
+
configHint() +
|
|
444
|
+
`[note archived (CLI)] notebook=${nbName}, title=${args.title}, type=${recordType}\n${truncate((stdout ?? "").trim(), 2000)}`,
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
}
|