dsh-deeptutor 0.1.4 → 0.1.6
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 +13 -5
- package/README.zh.md +7 -1
- package/lib/index.js +29 -14
- package/package.json +4 -2
- package/scripts/install-profile.mjs +38 -3
- package/skills/deeptutor/README.md +106 -0
- package/skills/deeptutor/SKILL.md +182 -0
- package/skills/html-doc/SKILL.md +91 -0
- package/skills/html-doc/assets/template.html +420 -0
- package/skills/html-doc/scripts/md-to-html.js +353 -0
- package/src/index.ts +28 -13
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* md-to-html.js — zero-dependency Markdown → self-contained HTML converter.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node md-to-html.js <input.md> [--out out.html] [--title "T"] [--theme dark|light]
|
|
7
|
+
* [--toc] [--lang zh-CN] [--no-collapse]
|
|
8
|
+
* cat file.md | node md-to-html.js --title "T" --out out.html
|
|
9
|
+
*
|
|
10
|
+
* Features: headings, fenced code blocks, inline code/bold/italic/links, lists,
|
|
11
|
+
* task lists, blockquotes, pipe tables, hr, quiz answer collapsing (<details>),
|
|
12
|
+
* raw HTML passthrough, HTML escaping. Zero npm dependencies (Node >= 22).
|
|
13
|
+
*/
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// CLI arg parsing
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const opts = {
|
|
24
|
+
out: null,
|
|
25
|
+
title: null,
|
|
26
|
+
theme: "light",
|
|
27
|
+
toc: false,
|
|
28
|
+
lang: "zh-CN",
|
|
29
|
+
collapse: true,
|
|
30
|
+
};
|
|
31
|
+
const positional = [];
|
|
32
|
+
for (let i = 0; i < argv.length; i++) {
|
|
33
|
+
const a = argv[i];
|
|
34
|
+
if (a === "--out") opts.out = argv[++i];
|
|
35
|
+
else if (a === "--title") opts.title = argv[++i];
|
|
36
|
+
else if (a === "--theme") opts.theme = argv[++i] === "dark" ? "dark" : "light";
|
|
37
|
+
else if (a === "--toc") opts.toc = true;
|
|
38
|
+
else if (a === "--lang") opts.lang = argv[++i];
|
|
39
|
+
else if (a === "--no-collapse") opts.collapse = false;
|
|
40
|
+
else if (a === "--help" || a === "-h") {
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
} else positional.push(a);
|
|
44
|
+
}
|
|
45
|
+
return { opts, positional };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function printHelp() {
|
|
49
|
+
console.log(`md-to-html.js — Markdown → self-contained HTML
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
node md-to-html.js <input.md> [options]
|
|
53
|
+
cat file.md | node md-to-html.js [options]
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
--out <file> output path (default: <input>.html, or stdout when stdin)
|
|
57
|
+
--title <text> page title (default: input filename or "Document")
|
|
58
|
+
--theme <mode> dark | light (default: light; toggle also available in page)
|
|
59
|
+
--toc generate a table of contents from h2/h3 headings
|
|
60
|
+
--lang <code> html lang attribute (default: zh-CN)
|
|
61
|
+
--no-collapse disable quiz-answer collapsing
|
|
62
|
+
--help show this help`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
const esc = (s) =>
|
|
69
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
70
|
+
|
|
71
|
+
/** Inline formatting: `code`, **bold**, *italic*, [text](url), ~~strike~~ */
|
|
72
|
+
function inline(text) {
|
|
73
|
+
// Protect already-raw-HTML spans? We escape everything except explicit HTML
|
|
74
|
+
// blocks, so inline HTML is escaped (safe default). Code spans first:
|
|
75
|
+
let out = "";
|
|
76
|
+
const parts = text.split(/(`[^`\n]+`)/g);
|
|
77
|
+
for (const p of parts) {
|
|
78
|
+
if (p.startsWith("`") && p.endsWith("`") && p.length > 1) {
|
|
79
|
+
out += `<code>${esc(p.slice(1, -1))}</code>`;
|
|
80
|
+
} else {
|
|
81
|
+
out += esc(p)
|
|
82
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
83
|
+
.replace(/~~([^~]+)~~/g, "<del>$1</del>")
|
|
84
|
+
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
|
|
85
|
+
.replace(
|
|
86
|
+
/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
|
|
87
|
+
'<a href="$2" target="_blank" rel="noopener">$1</a>',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Split a pipe-table row into cells, honoring escaped pipes. */
|
|
95
|
+
function splitRow(line) {
|
|
96
|
+
const l = line.trim().replace(/^\||\|$/g, "");
|
|
97
|
+
return l.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const isTableSep = (cells) =>
|
|
101
|
+
cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c.replace(/\s/g, "")));
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Block parser
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
function parse(md, opts) {
|
|
107
|
+
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
|
108
|
+
const body = [];
|
|
109
|
+
const toc = [];
|
|
110
|
+
let i = 0;
|
|
111
|
+
|
|
112
|
+
// Heading regex incl. optional trailing closing #s
|
|
113
|
+
const hRe = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
114
|
+
// Quiz-answer heading (collapsed <details>)
|
|
115
|
+
const ansRe = /^(#{1,6})\s+(.*(?:答案|解答|解析|Answer|Explanation|Solution).*)$/i;
|
|
116
|
+
|
|
117
|
+
const flushList = (list) => {
|
|
118
|
+
const tag = list.ordered ? "ol" : "ul";
|
|
119
|
+
let html = `<${tag}>\n`;
|
|
120
|
+
for (const item of list.items) {
|
|
121
|
+
html +=
|
|
122
|
+
item.task !== null
|
|
123
|
+
? `<li class="task${item.task ? " done" : ""}"><input type="checkbox"${item.task ? " checked" : ""} disabled> ${item.html}</li>\n`
|
|
124
|
+
: `<li>${item.html}</li>\n`;
|
|
125
|
+
}
|
|
126
|
+
html += `</${tag}>`;
|
|
127
|
+
body.push(html);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
while (i < lines.length) {
|
|
131
|
+
const line = lines[i];
|
|
132
|
+
|
|
133
|
+
// Fenced code block
|
|
134
|
+
if (/^```/.test(line)) {
|
|
135
|
+
const lang = (line.match(/^```\s*([^\s]+)?/) || [])[1] || "";
|
|
136
|
+
const buf = [];
|
|
137
|
+
i++;
|
|
138
|
+
while (i < lines.length && !/^```/.test(lines[i])) {
|
|
139
|
+
buf.push(lines[i]);
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
i++; // closing fence
|
|
143
|
+
body.push(
|
|
144
|
+
`<pre class="code-block${lang ? " has-lang" : ""}"><span class="lang-badge">${esc(lang)}</span><code>${esc(buf.join("\n"))}</code></pre>`,
|
|
145
|
+
);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Raw HTML block: only the quiz-answer <details>/<summary> convention passes through
|
|
150
|
+
// unescaped. Kept narrow on purpose — deeptutor_run feeds model/web_search-sourced
|
|
151
|
+
// answers into this converter automatically, so a wider tag whitelist (div/table/p/...)
|
|
152
|
+
// would let untrusted <script>/on*= content ride along into the generated local HTML.
|
|
153
|
+
if (/^<(details|summary)\b/i.test(line.trim())) {
|
|
154
|
+
const tag = /^<details\b/i.test(line.trim()) ? "details" : "summary";
|
|
155
|
+
const closeRe = new RegExp(`</${tag}>`, "i");
|
|
156
|
+
const buf = [line];
|
|
157
|
+
i++;
|
|
158
|
+
// Collect until the matching closing tag, not the first blank line/heading — the body
|
|
159
|
+
// of a <details> block is free-form text and commonly contains blank lines.
|
|
160
|
+
if (!closeRe.test(line)) {
|
|
161
|
+
while (i < lines.length && !closeRe.test(lines[i])) {
|
|
162
|
+
buf.push(lines[i]);
|
|
163
|
+
i++;
|
|
164
|
+
}
|
|
165
|
+
if (i < lines.length) {
|
|
166
|
+
buf.push(lines[i]);
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
body.push(buf.join("\n"));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Blank line
|
|
175
|
+
if (line.trim() === "") {
|
|
176
|
+
i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Horizontal rule
|
|
181
|
+
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
|
|
182
|
+
body.push("<hr>");
|
|
183
|
+
i++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Heading
|
|
188
|
+
if (hRe.test(line)) {
|
|
189
|
+
const m = line.match(hRe);
|
|
190
|
+
const level = m[1].length;
|
|
191
|
+
const text = m[2].trim();
|
|
192
|
+
const slug = `h-${body.length}-${
|
|
193
|
+
text
|
|
194
|
+
.toLowerCase()
|
|
195
|
+
.replace(/[^\w\u4e00-\u9fa5]+/g, "-")
|
|
196
|
+
.replace(/^-+|-+$/g, "") || "sec"
|
|
197
|
+
}`;
|
|
198
|
+
if (opts.toc && level <= 3) toc.push({ level, text, slug });
|
|
199
|
+
const inner = inline(text);
|
|
200
|
+
|
|
201
|
+
if (opts.collapse && ansRe.test(line) && level >= 2) {
|
|
202
|
+
// Quiz answer: start a <details> block; collect until next heading
|
|
203
|
+
const titleInner = inner.replace(
|
|
204
|
+
/^(答案与解析|答案解析|答案|解答|解析|Answer|Explanation|Solution)\s*[::]?\s*/i,
|
|
205
|
+
"",
|
|
206
|
+
);
|
|
207
|
+
const buf = [
|
|
208
|
+
`<details class="answer"><summary><span class="answer-tag">答案</span>${titleInner}</summary>`,
|
|
209
|
+
'<div class="answer-body">',
|
|
210
|
+
];
|
|
211
|
+
i++;
|
|
212
|
+
while (i < lines.length && !hRe.test(lines[i]) && !/^```/.test(lines[i])) {
|
|
213
|
+
const l = lines[i];
|
|
214
|
+
if (l.trim() === "") {
|
|
215
|
+
buf.push("");
|
|
216
|
+
i++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
buf.push(`<p>${inline(l)}</p>`);
|
|
220
|
+
i++;
|
|
221
|
+
}
|
|
222
|
+
buf.push("</div></details>");
|
|
223
|
+
body.push(buf.join("\n"));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
body.push(`<h${level} id="${slug}">${inner}</h${level}>`);
|
|
228
|
+
i++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Blockquote
|
|
233
|
+
if (/^>\s?/.test(line)) {
|
|
234
|
+
const buf = [];
|
|
235
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
236
|
+
buf.push(lines[i].replace(/^>\s?/, ""));
|
|
237
|
+
i++;
|
|
238
|
+
}
|
|
239
|
+
body.push(
|
|
240
|
+
`<blockquote>${parse(buf.join("\n"), { ...opts, collapse: false }).html}</blockquote>`,
|
|
241
|
+
);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// List (ul/ol/task)
|
|
246
|
+
if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
|
|
247
|
+
const list = { ordered: /^\s*\d+\.\s+/.test(line), items: [] };
|
|
248
|
+
const indentRe = /^(\s*)/;
|
|
249
|
+
const baseIndent = (line.match(indentRe) || ["", ""])[1].length;
|
|
250
|
+
const isTaskRe = /^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.*)$/;
|
|
251
|
+
|
|
252
|
+
while (i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i])) {
|
|
253
|
+
const cur = lines[i];
|
|
254
|
+
const indent = (cur.match(indentRe) || ["", ""])[1].length;
|
|
255
|
+
if (indent > baseIndent) {
|
|
256
|
+
/* nested list: keep simple, treat as continuation */
|
|
257
|
+
}
|
|
258
|
+
const tm = cur.match(isTaskRe);
|
|
259
|
+
if (tm) {
|
|
260
|
+
list.items.push({ task: tm[3].toLowerCase() === "x", html: inline(tm[4]) });
|
|
261
|
+
} else {
|
|
262
|
+
const content = cur.replace(/^\s*([-*+]|\d+\.)\s+/, "");
|
|
263
|
+
list.items.push({ task: null, html: inline(content) });
|
|
264
|
+
}
|
|
265
|
+
i++;
|
|
266
|
+
}
|
|
267
|
+
flushList(list);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Table
|
|
272
|
+
if (line.includes("|") && i + 1 < lines.length && isTableSep(splitRow(lines[i + 1]))) {
|
|
273
|
+
const header = splitRow(lines[i]);
|
|
274
|
+
i += 2;
|
|
275
|
+
const rows = [];
|
|
276
|
+
while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") {
|
|
277
|
+
rows.push(splitRow(lines[i]));
|
|
278
|
+
i++;
|
|
279
|
+
}
|
|
280
|
+
let html = `<div class="table-wrap"><table><thead><tr>${header.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead><tbody>`;
|
|
281
|
+
for (const r of rows) html += `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`;
|
|
282
|
+
html += "</tbody></table></div>";
|
|
283
|
+
body.push(html);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Paragraph (collect consecutive lines)
|
|
288
|
+
const buf = [line];
|
|
289
|
+
i++;
|
|
290
|
+
while (
|
|
291
|
+
i < lines.length &&
|
|
292
|
+
lines[i].trim() !== "" &&
|
|
293
|
+
!hRe.test(lines[i]) &&
|
|
294
|
+
!/^```/.test(lines[i]) &&
|
|
295
|
+
!/^\s*([-*+]|\d+\.)\s+/.test(lines[i]) &&
|
|
296
|
+
!/^>\s?/.test(lines[i]) &&
|
|
297
|
+
!/^\s*(---|\*\*\*|___)\s*$/.test(lines[i])
|
|
298
|
+
) {
|
|
299
|
+
buf.push(lines[i]);
|
|
300
|
+
i++;
|
|
301
|
+
}
|
|
302
|
+
body.push(`<p>${inline(buf.join(" "))}</p>`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { html: body.join("\n"), toc };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Main
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
function main() {
|
|
312
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
313
|
+
const inputFile = positional[0] || null;
|
|
314
|
+
|
|
315
|
+
let md, baseName;
|
|
316
|
+
if (inputFile) {
|
|
317
|
+
md = fs.readFileSync(inputFile, "utf8");
|
|
318
|
+
baseName = path.basename(inputFile, path.extname(inputFile));
|
|
319
|
+
} else {
|
|
320
|
+
md = fs.readFileSync(0, "utf8"); // stdin
|
|
321
|
+
baseName = "document";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const title = opts.title || baseName;
|
|
325
|
+
const { html, toc } = parse(md, opts);
|
|
326
|
+
|
|
327
|
+
const tocHtml =
|
|
328
|
+
opts.toc && toc.length
|
|
329
|
+
? `<nav class="toc"><h2>目录</h2><ul>${toc.map((t) => `<li class="toc-l${t.level}"><a href="#${t.slug}">${esc(t.text)}</a></li>`).join("")}</ul></nav>`
|
|
330
|
+
: "";
|
|
331
|
+
|
|
332
|
+
const generated = new Date().toISOString().slice(0, 10);
|
|
333
|
+
const templatePath = path.join(import.meta.dirname, "..", "assets", "template.html");
|
|
334
|
+
let template = fs.readFileSync(templatePath, "utf8");
|
|
335
|
+
template = template
|
|
336
|
+
.replace(/\{\{TITLE\}\}/g, esc(title))
|
|
337
|
+
.replace(/\{\{BODY\}\}/g, html)
|
|
338
|
+
.replace(/\{\{TOC\}\}/g, tocHtml)
|
|
339
|
+
.replace(/\{\{THEME\}\}/g, opts.theme)
|
|
340
|
+
.replace(/\{\{LANG\}\}/g, opts.lang)
|
|
341
|
+
.replace(/\{\{GENERATED\}\}/g, generated);
|
|
342
|
+
|
|
343
|
+
if (opts.out) {
|
|
344
|
+
fs.writeFileSync(opts.out, template, "utf8");
|
|
345
|
+
console.log(
|
|
346
|
+
`[html-doc] wrote ${path.resolve(opts.out)} (${Buffer.byteLength(template)} bytes)`,
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
process.stdout.write(template);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
main();
|
package/src/index.ts
CHANGED
|
@@ -200,7 +200,8 @@ export function apply(ctx: Context) {
|
|
|
200
200
|
},
|
|
201
201
|
top_k: {
|
|
202
202
|
type: "number",
|
|
203
|
-
description:
|
|
203
|
+
description:
|
|
204
|
+
"Number of hits to return, default 5 (applied locally to the CLI search result; the deeptutor CLI has no --top-k flag)",
|
|
204
205
|
},
|
|
205
206
|
max_chars: {
|
|
206
207
|
type: "number",
|
|
@@ -267,7 +268,8 @@ export function apply(ctx: Context) {
|
|
|
267
268
|
);
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
|
-
// CLI fallback (local or SSH)
|
|
271
|
+
// CLI fallback (local or SSH). NOTE: `kb search` has no --top-k flag
|
|
272
|
+
// (verified on deeptutor CLI 1.5.x); top_k is applied locally below.
|
|
271
273
|
let argv: string[];
|
|
272
274
|
if (args.action === "list") {
|
|
273
275
|
argv = ["kb", "list", "--format", "json"];
|
|
@@ -284,7 +286,6 @@ export function apply(ctx: Context) {
|
|
|
284
286
|
"--mode",
|
|
285
287
|
args.mode ?? "hybrid",
|
|
286
288
|
];
|
|
287
|
-
if (args.top_k) argv.push("--top-k", String(args.top_k));
|
|
288
289
|
}
|
|
289
290
|
const { stdout, stderr, code, killed } = await runCli(argv, {
|
|
290
291
|
signal: exec.signal,
|
|
@@ -300,16 +301,30 @@ export function apply(ctx: Context) {
|
|
|
300
301
|
let body = stdout.trim() || "(empty result)";
|
|
301
302
|
if (args.action === "search") {
|
|
302
303
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
304
|
+
const parsed = JSON.parse(stdout);
|
|
305
|
+
const fmtHit = (s: any, i: number) => {
|
|
306
|
+
const text = s.text ?? s.content ?? s.snippet ?? JSON.stringify(s);
|
|
307
|
+
const src = s.source ?? s.doc_name ?? s.file ?? "";
|
|
308
|
+
const title = s.title ? ` title: ${s.title}` : "";
|
|
309
|
+
const score = typeof s.score === "number" ? ` score: ${s.score}` : "";
|
|
310
|
+
return `[${i + 1}]${src ? ` source: ${src}` : ""}${title}${score}\n${typeof text === "string" ? text : JSON.stringify(text)}`;
|
|
311
|
+
};
|
|
312
|
+
// Current deeptutor CLI (1.5.x) returns an object
|
|
313
|
+
// { query, answer, content, sources, provider }; older builds
|
|
314
|
+
// returned a bare array of hits. Accept both.
|
|
315
|
+
if (Array.isArray(parsed)) {
|
|
316
|
+
const hits = args.top_k ? parsed.slice(0, args.top_k) : parsed;
|
|
317
|
+
body = `${hits.length} hits:\n\n` + hits.map(fmtHit).join("\n\n");
|
|
318
|
+
} else if (parsed && typeof parsed === "object") {
|
|
319
|
+
const parts: string[] = [];
|
|
320
|
+
if (parsed.query) parts.push(`Query: ${parsed.query}`);
|
|
321
|
+
if (typeof parsed.answer === "string" && parsed.answer.trim())
|
|
322
|
+
parts.push(`Answer: ${parsed.answer.trim()}`);
|
|
323
|
+
const sources = Array.isArray(parsed.sources) ? parsed.sources : [];
|
|
324
|
+
const hits = args.top_k ? sources.slice(0, args.top_k) : sources;
|
|
325
|
+
parts.push(`${hits.length} hits:`);
|
|
326
|
+
parts.push(hits.map(fmtHit).join("\n\n"));
|
|
327
|
+
body = parts.join("\n\n");
|
|
313
328
|
}
|
|
314
329
|
} catch {
|
|
315
330
|
/* non-JSON output returned as-is */
|