@tikoci/rosetta 0.7.5 → 0.7.7
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/package.json +1 -1
- package/src/browse.ts +294 -16
- package/src/classify.ts +43 -1
- package/src/extract-dude.test.ts +10 -1
- package/src/extract-html.ts +10 -0
- package/src/mcp.ts +5 -3
- package/src/query.test.ts +44 -1
- package/src/query.ts +46 -42
- package/src/release.test.ts +20 -0
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -70,6 +70,53 @@ function link(url: string, display?: string): string {
|
|
|
70
70
|
return `${ESC}]8;;${url}\x07${display ?? url}${ESC}]8;;\x07`;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Lightweight Markdown → ANSI renderer for text we render in the TUI.
|
|
75
|
+
* Handles **bold**, *italic*, `code`, headings (#/##/###), [text](url), and
|
|
76
|
+
* bullet lists. Skips fenced code blocks (``` ... ```) — they're left as
|
|
77
|
+
* indented dim text so RouterOS CLI snippets stay readable.
|
|
78
|
+
*
|
|
79
|
+
* Not a full Markdown parser. Designed to clean up content stored as Markdown
|
|
80
|
+
* (skill files, callout text) so the source markup tokens don't leak into
|
|
81
|
+
* the rendered output. See BACKLOG "TUI usability improvements".
|
|
82
|
+
*/
|
|
83
|
+
function mdToAnsi(s: string): string {
|
|
84
|
+
if (!s) return s;
|
|
85
|
+
const lines = s.split("\n");
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
let inFence = false;
|
|
88
|
+
for (let raw of lines) {
|
|
89
|
+
if (/^\s*```/.test(raw)) {
|
|
90
|
+
inFence = !inFence;
|
|
91
|
+
out.push(dim(raw.replace(/```\w*/, "```")));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (inFence) {
|
|
95
|
+
out.push(dim(` ${raw}`));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Headings
|
|
99
|
+
const h = raw.match(/^(#{1,3})\s+(.*)$/);
|
|
100
|
+
if (h) {
|
|
101
|
+
const level = h[1].length;
|
|
102
|
+
const text = h[2];
|
|
103
|
+
if (level === 1) out.push(bold(`══ ${text}`));
|
|
104
|
+
else if (level === 2) out.push(bold(`── ${text}`));
|
|
105
|
+
else out.push(bold(text));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Bullets
|
|
109
|
+
raw = raw.replace(/^(\s*)[-*]\s+/, (_m, sp) => `${sp}${cyan("•")} `);
|
|
110
|
+
// Inline: links, code, bold, italic — order matters.
|
|
111
|
+
raw = raw.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, txt, url) => link(url, cyan(txt)));
|
|
112
|
+
raw = raw.replace(/`([^`]+)`/g, (_m, code) => `${ESC}[48;5;236m ${code} ${ESC}[0m`);
|
|
113
|
+
raw = raw.replace(/\*\*([^*]+)\*\*/g, (_m, t) => bold(t));
|
|
114
|
+
raw = raw.replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, pre, t) => `${pre}${ESC}[3m${t}${ESC}[0m`);
|
|
115
|
+
out.push(raw);
|
|
116
|
+
}
|
|
117
|
+
return out.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
73
120
|
/** Terminal width, with fallback */
|
|
74
121
|
function termWidth(): number {
|
|
75
122
|
return process.stdout.columns || 80;
|
|
@@ -147,9 +194,23 @@ function formatTime(seconds: number): string {
|
|
|
147
194
|
// ── Pager ──
|
|
148
195
|
|
|
149
196
|
/** Output lines with paging. Returns true if user quit early. */
|
|
197
|
+
/**
|
|
198
|
+
* Interactive pager.
|
|
199
|
+
*
|
|
200
|
+
* Keys:
|
|
201
|
+
* SPACE / f advance one page
|
|
202
|
+
* ENTER / j advance one line
|
|
203
|
+
* b / < previous page
|
|
204
|
+
* g jump to top
|
|
205
|
+
* G jump to bottom
|
|
206
|
+
* q quit pager (returns true)
|
|
207
|
+
*
|
|
208
|
+
* Shows a status line with page X/Y and line N/M. Returns true if user quit
|
|
209
|
+
* before EOF, false otherwise.
|
|
210
|
+
*/
|
|
150
211
|
async function paged(output: string): Promise<boolean> {
|
|
151
212
|
const lines = output.split("\n");
|
|
152
|
-
const pageSize = termHeight() - 2;
|
|
213
|
+
const pageSize = Math.max(5, termHeight() - 2);
|
|
153
214
|
if (lines.length <= pageSize || !process.stdout.isTTY) {
|
|
154
215
|
process.stdout.write(`${output}\n`);
|
|
155
216
|
return false;
|
|
@@ -158,19 +219,29 @@ async function paged(output: string): Promise<boolean> {
|
|
|
158
219
|
while (offset < lines.length) {
|
|
159
220
|
const chunk = lines.slice(offset, offset + pageSize);
|
|
160
221
|
process.stdout.write(`${chunk.join("\n")}\n`);
|
|
161
|
-
offset
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
222
|
+
const newOffset = offset + pageSize;
|
|
223
|
+
if (newOffset >= lines.length) return false;
|
|
224
|
+
const totalPages = Math.ceil(lines.length / pageSize);
|
|
225
|
+
const curPage = Math.floor(newOffset / pageSize);
|
|
226
|
+
const prompt = dim(
|
|
227
|
+
`── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back | g/G top/end | q quit]`,
|
|
228
|
+
);
|
|
229
|
+
process.stdout.write(prompt);
|
|
230
|
+
const key = await waitForKey();
|
|
231
|
+
process.stdout.write(`\r${" ".repeat(Math.min(termWidth(), stripAnsi(prompt).length))}\r`);
|
|
232
|
+
if (key === "q" || key === "Q") return true;
|
|
233
|
+
if (key === "g") { offset = 0; continue; }
|
|
234
|
+
if (key === "G") { offset = Math.max(0, lines.length - pageSize); continue; }
|
|
235
|
+
if (key === "b" || key === "<" || key === "\x1b[D") {
|
|
236
|
+
offset = Math.max(0, offset - pageSize);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (key === "\r" || key === "\n" || key === "j" || key === "\x1b[B") {
|
|
240
|
+
offset += 1;
|
|
241
|
+
continue;
|
|
173
242
|
}
|
|
243
|
+
// SPACE / f / anything else → next page
|
|
244
|
+
offset = newOffset;
|
|
174
245
|
}
|
|
175
246
|
return false;
|
|
176
247
|
}
|
|
@@ -447,7 +518,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
|
|
|
447
518
|
// Text content (if present and not a TOC-only view)
|
|
448
519
|
if (page.text && !page.sections) {
|
|
449
520
|
out.push(hr());
|
|
450
|
-
out.push(page.text);
|
|
521
|
+
out.push(mdToAnsi(page.text));
|
|
451
522
|
if (page.code) {
|
|
452
523
|
out.push("");
|
|
453
524
|
out.push(dim("── code ──"));
|
|
@@ -465,7 +536,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
|
|
|
465
536
|
out.push(hr());
|
|
466
537
|
out.push(` ${bold(`§ ${page.section.heading}`)} ${dim(`(level ${page.section.level})`)}`);
|
|
467
538
|
out.push("");
|
|
468
|
-
if (page.text) out.push(page.text);
|
|
539
|
+
if (page.text) out.push(mdToAnsi(page.text));
|
|
469
540
|
if (page.code) {
|
|
470
541
|
out.push("");
|
|
471
542
|
out.push(dim("── code ──"));
|
|
@@ -988,6 +1059,12 @@ function renderHelp(): string {
|
|
|
988
1059
|
cmd("help", "?", "This help");
|
|
989
1060
|
cmd("quit", "q", "Exit");
|
|
990
1061
|
|
|
1062
|
+
out.push("");
|
|
1063
|
+
out.push(` ${bold("MCP probe (dot-commands)")} ${dim("— see what an agent sees")}`);
|
|
1064
|
+
out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Same query path as MCP, raw JSON output")}`);
|
|
1065
|
+
out.push(` ${cyan(pad(".routeros_get_page <id> [section=]", 38))} ${dim("Probe page response with JSON")}`);
|
|
1066
|
+
out.push(` ${cyan(pad(".routeros_lookup_property name= ...", 38))} ${dim("Direct property lookup")}`);
|
|
1067
|
+
out.push(` ${cyan(pad(".help", 38))} ${dim("Full list of dot-commands")}`);
|
|
991
1068
|
out.push("");
|
|
992
1069
|
out.push(` ${dim("Navigation: type a number to select from results.")}`);
|
|
993
1070
|
out.push(` ${dim("After viewing a page, [p] = properties for that page.")}`);
|
|
@@ -1001,12 +1078,213 @@ function renderHelp(): string {
|
|
|
1001
1078
|
return out.join("\n");
|
|
1002
1079
|
}
|
|
1003
1080
|
|
|
1081
|
+
// ── MCP probe (dot-commands) ──────────────────────────────────────────────
|
|
1082
|
+
//
|
|
1083
|
+
// `.<tool_name> [positional...] [key=value ...]` invokes the same query
|
|
1084
|
+
// function the MCP server uses and dumps the raw JSON response. Lets a human
|
|
1085
|
+
// in the TUI see exactly what an agent would see — useful for debugging tool
|
|
1086
|
+
// descriptions, classifier output, and `related` block contents at any limit.
|
|
1087
|
+
//
|
|
1088
|
+
// Format mirrors the MCP tool name (e.g. `.routeros_search dhcp limit=12`).
|
|
1089
|
+
// Positional tokens are joined into the tool's "primary" argument (query,
|
|
1090
|
+
// path, name, etc.). `key=value` pairs override or add fields.
|
|
1091
|
+
|
|
1092
|
+
type DotArgs = Record<string, string | number | boolean>;
|
|
1093
|
+
|
|
1094
|
+
function parseDotArgs(rest: string, primary?: string): DotArgs {
|
|
1095
|
+
const args: DotArgs = {};
|
|
1096
|
+
const positional: string[] = [];
|
|
1097
|
+
// Split on whitespace but allow key="quoted value"
|
|
1098
|
+
const tokens = rest.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
|
|
1099
|
+
for (const t of tokens) {
|
|
1100
|
+
const m = t.match(/^([a-z_]\w*)=(.*)$/);
|
|
1101
|
+
if (m) {
|
|
1102
|
+
let v: string | number | boolean = m[2].replace(/^"|"$/g, "");
|
|
1103
|
+
if (v === "true") v = true;
|
|
1104
|
+
else if (v === "false") v = false;
|
|
1105
|
+
else if (/^-?\d+$/.test(v)) v = Number.parseInt(v, 10);
|
|
1106
|
+
args[m[1]] = v;
|
|
1107
|
+
} else {
|
|
1108
|
+
positional.push(t.replace(/^"|"$/g, ""));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (primary && positional.length > 0 && args[primary] === undefined) {
|
|
1112
|
+
args[primary] = positional.join(" ");
|
|
1113
|
+
}
|
|
1114
|
+
return args;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
type DotTool = {
|
|
1118
|
+
/** Field name that bare positional tokens are joined into. */
|
|
1119
|
+
primary?: string;
|
|
1120
|
+
/** Short one-line description shown by `.help`. */
|
|
1121
|
+
desc: string;
|
|
1122
|
+
/** Run the tool — return any JSON-serializable object. */
|
|
1123
|
+
run: (args: DotArgs) => unknown;
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const dotTools: Record<string, DotTool> = {
|
|
1127
|
+
routeros_search: {
|
|
1128
|
+
primary: "query",
|
|
1129
|
+
desc: "Unified search — same as `s <query>` but with raw JSON response",
|
|
1130
|
+
run: (a) => searchAll(String(a.query ?? ""), a.limit ? Number(a.limit) : undefined),
|
|
1131
|
+
},
|
|
1132
|
+
routeros_get_page: {
|
|
1133
|
+
primary: "page",
|
|
1134
|
+
desc: "Full page by id or title (args: page=, max_length=, section=)",
|
|
1135
|
+
run: (a) => {
|
|
1136
|
+
const p = a.page;
|
|
1137
|
+
const id = typeof p === "number" ? p : /^\d+$/.test(String(p)) ? Number.parseInt(String(p), 10) : String(p);
|
|
1138
|
+
return getPage(
|
|
1139
|
+
id,
|
|
1140
|
+
a.max_length !== undefined ? Number(a.max_length) : undefined,
|
|
1141
|
+
a.section !== undefined ? String(a.section) : undefined,
|
|
1142
|
+
);
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
routeros_lookup_property: {
|
|
1146
|
+
primary: "name",
|
|
1147
|
+
desc: "Property by exact name (args: name=, command_path=)",
|
|
1148
|
+
run: (a) => lookupProperty(String(a.name ?? ""), a.command_path ? String(a.command_path) : undefined),
|
|
1149
|
+
},
|
|
1150
|
+
routeros_command_tree: {
|
|
1151
|
+
primary: "path",
|
|
1152
|
+
desc: "Browse command tree (args: path=, version=, arch=)",
|
|
1153
|
+
run: (a) => {
|
|
1154
|
+
const path = String(a.path ?? "");
|
|
1155
|
+
if (a.version) return browseCommandsAtVersion(path, String(a.version), a.arch ? String(a.arch) : undefined);
|
|
1156
|
+
return browseCommands(path, a.arch ? String(a.arch) : undefined);
|
|
1157
|
+
},
|
|
1158
|
+
},
|
|
1159
|
+
routeros_search_changelogs: {
|
|
1160
|
+
primary: "query",
|
|
1161
|
+
desc: "Search changelogs (args: query=, version=, from_version=, to_version=, category=, breaking_only=, limit=)",
|
|
1162
|
+
run: (a) => searchChangelogs(String(a.query ?? ""), {
|
|
1163
|
+
version: a.version ? String(a.version) : undefined,
|
|
1164
|
+
fromVersion: a.from_version ? String(a.from_version) : undefined,
|
|
1165
|
+
toVersion: a.to_version ? String(a.to_version) : undefined,
|
|
1166
|
+
category: a.category ? String(a.category) : undefined,
|
|
1167
|
+
breakingOnly: a.breaking_only === true || a.breaking_only === "true",
|
|
1168
|
+
limit: a.limit ? Number(a.limit) : undefined,
|
|
1169
|
+
}),
|
|
1170
|
+
},
|
|
1171
|
+
routeros_command_version_check: {
|
|
1172
|
+
primary: "command_path",
|
|
1173
|
+
desc: "Version range for a command path (args: command_path=)",
|
|
1174
|
+
run: (a) => {
|
|
1175
|
+
const p = String(a.command_path ?? "");
|
|
1176
|
+
return checkCommandVersions(p.startsWith("/") ? p : `/${p}`);
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
routeros_command_diff: {
|
|
1180
|
+
desc: "Diff command tree between versions (args: from_version=, to_version=, path_prefix=, arch=)",
|
|
1181
|
+
run: (a) => diffCommandVersions(
|
|
1182
|
+
String(a.from_version ?? ""),
|
|
1183
|
+
String(a.to_version ?? ""),
|
|
1184
|
+
a.path_prefix ? String(a.path_prefix) : undefined,
|
|
1185
|
+
a.arch ? String(a.arch) : undefined,
|
|
1186
|
+
),
|
|
1187
|
+
},
|
|
1188
|
+
routeros_device_lookup: {
|
|
1189
|
+
primary: "query",
|
|
1190
|
+
desc: "Device lookup with FTS+filters (args: query=, architecture=, license_level=, has_wireless=, ...)",
|
|
1191
|
+
run: (a) => searchDevices(String(a.query ?? ""), {
|
|
1192
|
+
architecture: a.architecture ? String(a.architecture) : undefined,
|
|
1193
|
+
license_level: a.license_level ? Number(a.license_level) : undefined,
|
|
1194
|
+
has_wireless: a.has_wireless === true || a.has_wireless === "true" ? true : undefined,
|
|
1195
|
+
has_poe: a.has_poe === true || a.has_poe === "true" ? true : undefined,
|
|
1196
|
+
has_lte: a.has_lte === true || a.has_lte === "true" ? true : undefined,
|
|
1197
|
+
min_ram_mb: a.min_ram_mb ? Number(a.min_ram_mb) : undefined,
|
|
1198
|
+
min_storage_mb: a.min_storage_mb ? Number(a.min_storage_mb) : undefined,
|
|
1199
|
+
}, a.limit ? Number(a.limit) : undefined),
|
|
1200
|
+
},
|
|
1201
|
+
routeros_search_tests: {
|
|
1202
|
+
desc: "Cross-device benchmarks (args: device=, test_type=, mode=, configuration=, packet_size=, sort_by=, limit=)",
|
|
1203
|
+
run: (a) => searchDeviceTests({
|
|
1204
|
+
device: a.device ? String(a.device) : undefined,
|
|
1205
|
+
test_type: a.test_type ? String(a.test_type) : undefined,
|
|
1206
|
+
mode: a.mode ? String(a.mode) : undefined,
|
|
1207
|
+
configuration: a.configuration ? String(a.configuration) : undefined,
|
|
1208
|
+
packet_size: a.packet_size ? Number(a.packet_size) : undefined,
|
|
1209
|
+
sort_by: (a.sort_by as "mbps" | "kpps") ?? undefined,
|
|
1210
|
+
}, a.limit ? Number(a.limit) : undefined),
|
|
1211
|
+
},
|
|
1212
|
+
routeros_dude_search: {
|
|
1213
|
+
primary: "query",
|
|
1214
|
+
desc: "Search archived Dude wiki (args: query=, limit=)",
|
|
1215
|
+
run: (a) => searchDude(String(a.query ?? ""), a.limit ? Number(a.limit) : undefined),
|
|
1216
|
+
},
|
|
1217
|
+
routeros_dude_get_page: {
|
|
1218
|
+
primary: "id",
|
|
1219
|
+
desc: "Full Dude wiki page (args: id=, max_length=)",
|
|
1220
|
+
run: (a) => {
|
|
1221
|
+
const id = typeof a.id === "number" ? a.id : /^\d+$/.test(String(a.id)) ? Number.parseInt(String(a.id), 10) : String(a.id);
|
|
1222
|
+
return getDudePage(id, a.max_length ? Number(a.max_length) : undefined);
|
|
1223
|
+
},
|
|
1224
|
+
},
|
|
1225
|
+
routeros_stats: {
|
|
1226
|
+
desc: "DB health / counts",
|
|
1227
|
+
run: () => getDbStats(),
|
|
1228
|
+
},
|
|
1229
|
+
routeros_current_versions: {
|
|
1230
|
+
desc: "Live-fetch RouterOS versions per channel",
|
|
1231
|
+
run: async () => await fetchCurrentVersions(),
|
|
1232
|
+
},
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
async function dispatchDotCommand(input: string): Promise<void> {
|
|
1236
|
+
// .help / .tools — list available probes
|
|
1237
|
+
if (input === ".help" || input === ".?" || input === ".tools") {
|
|
1238
|
+
const out: string[] = [];
|
|
1239
|
+
out.push(` ${bold("MCP probe — direct tool invocation")}`);
|
|
1240
|
+
out.push(` ${dim("Format: .<tool_name> [positional...] [key=value ...]")}`);
|
|
1241
|
+
out.push(` ${dim("Output: raw JSON, exactly what an MCP client would receive.")}`);
|
|
1242
|
+
out.push("");
|
|
1243
|
+
for (const [name, t] of Object.entries(dotTools)) {
|
|
1244
|
+
out.push(` ${cyan(`.${name}`)}`);
|
|
1245
|
+
out.push(` ${dim(t.desc)}`);
|
|
1246
|
+
}
|
|
1247
|
+
out.push("");
|
|
1248
|
+
out.push(` ${dim("Example: .routeros_search firewall filter limit=20")}`);
|
|
1249
|
+
out.push(` ${dim("Example: .routeros_get_page 28282 max_length=4000")}`);
|
|
1250
|
+
out.push(` ${dim("Example: .routeros_lookup_property name=disabled command_path=/ip/firewall/filter")}`);
|
|
1251
|
+
await paged(out.join("\n"));
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const m = input.match(/^\.([a-z_]\w*)\s*(.*)$/i);
|
|
1255
|
+
if (!m) {
|
|
1256
|
+
console.log(dim(` Bad dot-command syntax. Try '.help' for the list.`));
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const name = m[1];
|
|
1260
|
+
const tool = dotTools[name];
|
|
1261
|
+
if (!tool) {
|
|
1262
|
+
console.log(dim(` Unknown MCP tool: .${name}. Try '.help' for the list.`));
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
try {
|
|
1266
|
+
const args = parseDotArgs(m[2] ?? "", tool.primary);
|
|
1267
|
+
const result = await tool.run(args);
|
|
1268
|
+
const json = JSON.stringify(result, null, 2);
|
|
1269
|
+
const banner = dim(`── .${name} args=${JSON.stringify(args)}`);
|
|
1270
|
+
await paged(`${banner}\n${json}`);
|
|
1271
|
+
} catch (e) {
|
|
1272
|
+
console.log(red(` Error invoking .${name}: ${(e as Error).message}`));
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1004
1276
|
// ── Command dispatcher ──
|
|
1005
1277
|
|
|
1006
1278
|
async function dispatch(input: string): Promise<void> {
|
|
1007
1279
|
const trimmed = input.trim();
|
|
1008
1280
|
if (!trimmed) return;
|
|
1009
1281
|
|
|
1282
|
+
// ── MCP probe (dot-command) ──
|
|
1283
|
+
if (trimmed.startsWith(".")) {
|
|
1284
|
+
await dispatchDotCommand(trimmed);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1010
1288
|
// Parse command + args
|
|
1011
1289
|
const parts = trimmed.split(/\s+/);
|
|
1012
1290
|
const command = parts[0].toLowerCase();
|
|
@@ -1609,7 +1887,7 @@ async function doViewSkill(name: string): Promise<void> {
|
|
|
1609
1887
|
out.push(` ${bold(skill.name)} ${dim(`${skill.word_count} words`)}`);
|
|
1610
1888
|
out.push(` ${skill.description}`);
|
|
1611
1889
|
out.push("");
|
|
1612
|
-
out.push(skill.content);
|
|
1890
|
+
out.push(mdToAnsi(skill.content));
|
|
1613
1891
|
if (skill.references.length > 0) {
|
|
1614
1892
|
out.push("");
|
|
1615
1893
|
out.push(` ${bold("Reference Files")} ${dim(`(${skill.references.length} files)`)}`);
|
package/src/classify.ts
CHANGED
|
@@ -14,7 +14,49 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { canonicalize } from "./canonicalize.ts";
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Known RouterOS topics — extracted from changelog categories, top-level
|
|
20
|
+
* command tree names, and common subsystem tokens. Used by the classifier
|
|
21
|
+
* to recognize domain-specific terms before FTS search.
|
|
22
|
+
*
|
|
23
|
+
* Source: `SELECT DISTINCT category FROM changelogs` (153 values) plus
|
|
24
|
+
* top-level commands (app, interface, ip, system) and common synonyms.
|
|
25
|
+
*
|
|
26
|
+
* Defined here (not in query.ts) so classify.ts stays pure (no DB imports)
|
|
27
|
+
* and classify.test.ts cannot transitively load db.ts before test setup.
|
|
28
|
+
*/
|
|
29
|
+
export const KNOWN_TOPICS = new Set([
|
|
30
|
+
// --- From changelog categories (high-frequency subsystems) ---
|
|
31
|
+
"api", "backup", "bgp", "bluetooth", "bonding", "bridge", "capsman",
|
|
32
|
+
"certificate", "chr", "cloud", "conntrack", "console", "container",
|
|
33
|
+
"defconf", "discovery", "disk", "dns", "dot1x", "dude",
|
|
34
|
+
"ethernet", "export", "fetch", "filesystem", "firewall",
|
|
35
|
+
"gps", "graphing", "hardware", "health", "hotspot",
|
|
36
|
+
"ike1", "ike2", "interface", "ipsec", "ipv6",
|
|
37
|
+
"l2tp", "l3hw", "ldp", "led", "log", "lora", "lte",
|
|
38
|
+
"macsec", "mlag", "modem", "mpls", "mqtt",
|
|
39
|
+
"netinstall", "netwatch", "ntp",
|
|
40
|
+
"ospf", "ovpn",
|
|
41
|
+
"poe", "ppp", "pppoe", "pptp", "ptp",
|
|
42
|
+
"queue", "quickset",
|
|
43
|
+
"radius", "resource", "rip", "romon", "route", "routing",
|
|
44
|
+
"serial", "sfp", "smb", "sms", "sniffer", "snmp", "socks", "ssh", "ssl", "sstp",
|
|
45
|
+
"switch", "system",
|
|
46
|
+
"traceroute", "tunnels", "upgrade", "upnp", "ups", "usb", "user",
|
|
47
|
+
"vlan", "vpls", "vrf", "vrrp", "vxlan",
|
|
48
|
+
"webfig", "wifi", "wifiwave2", "winbox", "wireguard", "wireless",
|
|
49
|
+
"zerotier",
|
|
50
|
+
// --- From changelog (DHCP variants, routing sub-protocols) ---
|
|
51
|
+
"dhcp", "dhcpv4", "dhcpv6", "rpki", "pimsm",
|
|
52
|
+
// --- Top-level command paths ---
|
|
53
|
+
"app", "ip",
|
|
54
|
+
// --- Common subsystem shorthand / synonyms ---
|
|
55
|
+
"nat", "mangle", "raw", "filter",
|
|
56
|
+
"bgp-vpn", "user-manager", "traffic-flow", "traffic-generator",
|
|
57
|
+
"route-filter", "routing-filter", "mac-telnet",
|
|
58
|
+
"w60g", "tr069",
|
|
59
|
+
]);
|
|
18
60
|
|
|
19
61
|
export type CommandFragment = {
|
|
20
62
|
/** `key=value` pairs extracted from the input (e.g. `chain=forward`). */
|
package/src/extract-dude.test.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
// Force in-memory DB BEFORE importing extract-dude.ts (which transitively imports
|
|
2
|
+
// db.ts). Without this, db.ts evaluates against the project's real ros-help.db
|
|
3
|
+
// and any later test file (e.g. query.test.ts) that calls DELETE in beforeAll
|
|
4
|
+
// will wipe the CI-built database — exactly the bug that shipped 3-page DBs in
|
|
5
|
+
// release v0.7.6. See BACKLOG.md "Test DB-leak guards".
|
|
6
|
+
process.env.DB_PATH = ":memory:";
|
|
7
|
+
|
|
1
8
|
import { describe, expect, test } from "bun:test";
|
|
2
9
|
import { readFileSync } from "node:fs";
|
|
3
10
|
import { join } from "node:path";
|
|
4
|
-
|
|
11
|
+
|
|
12
|
+
// Dynamic import so the DB_PATH assignment above wins over module hoisting.
|
|
13
|
+
const { parseDudePage } = await import("./extract-dude.ts");
|
|
5
14
|
|
|
6
15
|
const probesHtml = readFileSync(join(import.meta.dirname, "..", "dude", "pages", "Probes.html"), "utf-8");
|
|
7
16
|
|
package/src/extract-html.ts
CHANGED
|
@@ -558,6 +558,16 @@ function main() {
|
|
|
558
558
|
console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
|
|
559
559
|
console.log(` FTS index rows: ${ftsCount}`);
|
|
560
560
|
|
|
561
|
+
// Hard fail if no pages were extracted — silent zero-page runs were the
|
|
562
|
+
// root cause of release v0.7.6 shipping a near-empty DB. CI must fail loud.
|
|
563
|
+
if (extracted === 0) {
|
|
564
|
+
console.error(
|
|
565
|
+
`\n::error::extract-html: 0 pages extracted from ${process.argv[2] ?? "(default dir)"}. ` +
|
|
566
|
+
`Check the HTML export download/unzip step.`,
|
|
567
|
+
);
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
|
|
561
571
|
// Quick search test
|
|
562
572
|
const testResults = db
|
|
563
573
|
.prepare(
|
package/src/mcp.ts
CHANGED
|
@@ -569,8 +569,10 @@ Response shape:
|
|
|
569
569
|
- classified: { version, topics, command_path, device, property } — what the
|
|
570
570
|
classifier detected from your input
|
|
571
571
|
- pages: top FTS matches (title, path, URL, excerpt, best_section)
|
|
572
|
-
- related: callouts, properties, changelogs, videos, commands, devices, skills
|
|
573
|
-
|
|
572
|
+
- related: callouts, properties, changelogs, videos, commands, devices, skills,
|
|
573
|
+
glossary — empty sections are omitted. Cap scales with \`limit\`: small limit
|
|
574
|
+
(default 8) keeps related tight (~3 callouts, ~2 videos); larger limit widens
|
|
575
|
+
the net proportionally so a single "hungry" call can pull deeper context.
|
|
574
576
|
- next_steps: concrete follow-up tool calls informed by the classification
|
|
575
577
|
|
|
576
578
|
Capabilities:
|
|
@@ -603,7 +605,7 @@ Tips:
|
|
|
603
605
|
.max(50)
|
|
604
606
|
.optional()
|
|
605
607
|
.default(8)
|
|
606
|
-
.describe("Max page results (default 8). Related
|
|
608
|
+
.describe("Max page results (default 8). Related-section caps scale with this — set higher (e.g. 20) to pull more callouts/videos/properties in a single call."),
|
|
607
609
|
},
|
|
608
610
|
},
|
|
609
611
|
async ({ query, limit }) => {
|
package/src/query.test.ts
CHANGED
|
@@ -13,7 +13,19 @@ import { beforeAll, describe, expect, test } from "bun:test";
|
|
|
13
13
|
process.env.DB_PATH = ":memory:";
|
|
14
14
|
|
|
15
15
|
// Dynamic imports so the env-var assignment above is visible to db.ts
|
|
16
|
-
const { db, initDb, getDbStats, checkSchemaVersion, SCHEMA_VERSION } = await import("./db.ts");
|
|
16
|
+
const { db, initDb, getDbStats, checkSchemaVersion, SCHEMA_VERSION, DB_PATH } = await import("./db.ts");
|
|
17
|
+
|
|
18
|
+
// Hard guard: if some other test file imported db.ts before us with a real
|
|
19
|
+
// path, the singleton will be pointing at the project's ros-help.db and the
|
|
20
|
+
// DELETEs in beforeAll() below would wipe it. Fail fast with a clear message
|
|
21
|
+
// so this can never silently ship an empty DB again (release v0.7.6 regression).
|
|
22
|
+
if (DB_PATH !== ":memory:") {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`query.test.ts: DB singleton is at "${DB_PATH}" — expected ":memory:". ` +
|
|
25
|
+
`Another test file imported db.ts before this one without setting DB_PATH=:memory:. ` +
|
|
26
|
+
`Refusing to run because beforeAll() would DELETE FROM real tables.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
17
29
|
const {
|
|
18
30
|
extractTerms,
|
|
19
31
|
buildFtsQuery,
|
|
@@ -40,6 +52,7 @@ const {
|
|
|
40
52
|
lookupGlossary,
|
|
41
53
|
listGlossary,
|
|
42
54
|
KNOWN_TOPICS,
|
|
55
|
+
searchAll,
|
|
43
56
|
} = await import("./query.ts");
|
|
44
57
|
const { parseChangelog } = await import("./extract-changelogs.ts");
|
|
45
58
|
const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
|
|
@@ -2083,3 +2096,33 @@ describe("listGlossary", () => {
|
|
|
2083
2096
|
expect(listGlossary("nonexistent")).toEqual([]);
|
|
2084
2097
|
});
|
|
2085
2098
|
});
|
|
2099
|
+
|
|
2100
|
+
// ---------------------------------------------------------------------------
|
|
2101
|
+
// searchAll — unified entrypoint behavior
|
|
2102
|
+
// ---------------------------------------------------------------------------
|
|
2103
|
+
|
|
2104
|
+
describe("searchAll related block", () => {
|
|
2105
|
+
test("includes glossary when input matches a glossary term", () => {
|
|
2106
|
+
const res = searchAll("chr");
|
|
2107
|
+
expect(res.related).toBeDefined();
|
|
2108
|
+
expect(res.related?.glossary).toBeDefined();
|
|
2109
|
+
expect(res.related?.glossary?.term).toBe("chr");
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
test("does not include glossary for unknown terms", () => {
|
|
2113
|
+
const res = searchAll("nonexistentwidgetxyz");
|
|
2114
|
+
expect(res.related?.glossary).toBeUndefined();
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
test("scales callout cap with limit (hunger knob)", () => {
|
|
2118
|
+
// Compare cap behaviour at limit=8 (default) vs limit=30.
|
|
2119
|
+
// We don't assert exact counts — fixture data may not have many callouts —
|
|
2120
|
+
// but we assert that the higher-limit response doesn't artificially cap
|
|
2121
|
+
// BELOW what the lower-limit one returns.
|
|
2122
|
+
const small = searchAll("dhcp", 8);
|
|
2123
|
+
const big = searchAll("dhcp", 30);
|
|
2124
|
+
const smallCallouts = small.related?.callouts?.length ?? 0;
|
|
2125
|
+
const bigCallouts = big.related?.callouts?.length ?? 0;
|
|
2126
|
+
expect(bigCallouts).toBeGreaterThanOrEqual(smallCallouts);
|
|
2127
|
+
});
|
|
2128
|
+
});
|
package/src/query.ts
CHANGED
|
@@ -90,46 +90,9 @@ const COMPOUND_TERMS: [string, string][] = [
|
|
|
90
90
|
["address", "list"],
|
|
91
91
|
];
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* (future) and glossary lookup to recognize domain-specific terms before
|
|
97
|
-
* FTS search. Maintained as a static Set for O(1) lookup.
|
|
98
|
-
*
|
|
99
|
-
* Source: `SELECT DISTINCT category FROM changelogs` (153 values) plus
|
|
100
|
-
* top-level commands (app, interface, ip, system) and common synonyms.
|
|
101
|
-
*/
|
|
102
|
-
export const KNOWN_TOPICS = new Set([
|
|
103
|
-
// --- From changelog categories (high-frequency subsystems) ---
|
|
104
|
-
"api", "backup", "bgp", "bluetooth", "bonding", "bridge", "capsman",
|
|
105
|
-
"certificate", "chr", "cloud", "conntrack", "console", "container",
|
|
106
|
-
"defconf", "discovery", "disk", "dns", "dot1x", "dude",
|
|
107
|
-
"ethernet", "export", "fetch", "filesystem", "firewall",
|
|
108
|
-
"gps", "graphing", "hardware", "health", "hotspot",
|
|
109
|
-
"ike1", "ike2", "interface", "ipsec", "ipv6",
|
|
110
|
-
"l2tp", "l3hw", "ldp", "led", "log", "lora", "lte",
|
|
111
|
-
"macsec", "mlag", "modem", "mpls", "mqtt",
|
|
112
|
-
"netinstall", "netwatch", "ntp",
|
|
113
|
-
"ospf", "ovpn",
|
|
114
|
-
"poe", "ppp", "pppoe", "pptp", "ptp",
|
|
115
|
-
"queue", "quickset",
|
|
116
|
-
"radius", "resource", "rip", "romon", "route", "routing",
|
|
117
|
-
"serial", "sfp", "smb", "sms", "sniffer", "snmp", "socks", "ssh", "ssl", "sstp",
|
|
118
|
-
"switch", "system",
|
|
119
|
-
"traceroute", "tunnels", "upgrade", "upnp", "ups", "usb", "user",
|
|
120
|
-
"vlan", "vpls", "vrf", "vrrp", "vxlan",
|
|
121
|
-
"webfig", "wifi", "wifiwave2", "winbox", "wireguard", "wireless",
|
|
122
|
-
"zerotier",
|
|
123
|
-
// --- From changelog (DHCP variants, routing sub-protocols) ---
|
|
124
|
-
"dhcp", "dhcpv4", "dhcpv6", "rpki", "pimsm",
|
|
125
|
-
// --- Top-level command paths ---
|
|
126
|
-
"app", "ip",
|
|
127
|
-
// --- Common subsystem shorthand / synonyms ---
|
|
128
|
-
"nat", "mangle", "raw", "filter",
|
|
129
|
-
"bgp-vpn", "user-manager", "traffic-flow", "traffic-generator",
|
|
130
|
-
"route-filter", "routing-filter", "mac-telnet",
|
|
131
|
-
"w60g", "tr069",
|
|
132
|
-
]);
|
|
93
|
+
// KNOWN_TOPICS is defined in classify.ts (pure module, no DB) and re-exported
|
|
94
|
+
// from here for backward compatibility. See classify.ts for the canonical definition.
|
|
95
|
+
export { KNOWN_TOPICS } from "./classify.ts";
|
|
133
96
|
|
|
134
97
|
function getSpecialSearchHint(question: string): string | undefined {
|
|
135
98
|
const normalized = question.toLowerCase();
|
|
@@ -266,6 +229,13 @@ type CommandNodeMatch = {
|
|
|
266
229
|
linked_page?: { id: number; title: string; url: string };
|
|
267
230
|
};
|
|
268
231
|
|
|
232
|
+
type RelatedGlossary = {
|
|
233
|
+
term: string;
|
|
234
|
+
definition: string;
|
|
235
|
+
category: string;
|
|
236
|
+
search_hint: string;
|
|
237
|
+
};
|
|
238
|
+
|
|
269
239
|
export type SearchAllRelated = {
|
|
270
240
|
callouts?: RelatedCallout[];
|
|
271
241
|
properties?: RelatedProperty[];
|
|
@@ -275,6 +245,7 @@ export type SearchAllRelated = {
|
|
|
275
245
|
devices?: RelatedDevice[];
|
|
276
246
|
skills?: SkillSummary[];
|
|
277
247
|
command_node?: CommandNodeMatch;
|
|
248
|
+
glossary?: RelatedGlossary;
|
|
278
249
|
};
|
|
279
250
|
|
|
280
251
|
export type SearchAllResponse = {
|
|
@@ -288,12 +259,23 @@ export type SearchAllResponse = {
|
|
|
288
259
|
note?: string;
|
|
289
260
|
};
|
|
290
261
|
|
|
291
|
-
|
|
292
|
-
|
|
262
|
+
// Related-block caps scale with `limit` so an agent (or human) can express
|
|
263
|
+
// "hunger" through one knob — small limit = focused, large limit = wider net.
|
|
264
|
+
// See DESIGN.md "limit as hunger signal" (Parra-aligned: one knob, not many tools).
|
|
265
|
+
const BASE_RELATED_CAP = 3;
|
|
266
|
+
const BASE_RELATED_VIDEO_CAP = 2;
|
|
267
|
+
|
|
268
|
+
function relatedCaps(limit: number): { cap: number; videoCap: number } {
|
|
269
|
+
return {
|
|
270
|
+
cap: Math.max(BASE_RELATED_CAP, Math.floor(limit / 3)),
|
|
271
|
+
videoCap: Math.max(BASE_RELATED_VIDEO_CAP, Math.floor(limit / 4)),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
293
274
|
|
|
294
275
|
export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllResponse {
|
|
295
276
|
const classified = classifyQuery(query);
|
|
296
277
|
const pagesResp = searchPages(query, limit);
|
|
278
|
+
const { cap: RELATED_CAP, videoCap: RELATED_VIDEO_CAP } = relatedCaps(limit);
|
|
297
279
|
|
|
298
280
|
const related: SearchAllRelated = {};
|
|
299
281
|
|
|
@@ -412,6 +394,28 @@ export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllRespon
|
|
|
412
394
|
if (matched.length > 0) related.skills = matched.slice(0, 1);
|
|
413
395
|
}
|
|
414
396
|
|
|
397
|
+
// ── Glossary side query ─────────────────────────────────────────────────
|
|
398
|
+
// For short single-term queries, attach a glossary entry if one matches the
|
|
399
|
+
// raw input (or a topic) by exact term or alias. Surfaces domain jargon to
|
|
400
|
+
// agents without adding a separate tool.
|
|
401
|
+
{
|
|
402
|
+
const candidates: string[] = [];
|
|
403
|
+
if (classified.input.split(/\s+/).length <= 2) candidates.push(classified.input);
|
|
404
|
+
candidates.push(...classified.topics);
|
|
405
|
+
for (const c of candidates) {
|
|
406
|
+
const g = lookupGlossary(c);
|
|
407
|
+
if (g) {
|
|
408
|
+
related.glossary = {
|
|
409
|
+
term: g.term,
|
|
410
|
+
definition: g.definition,
|
|
411
|
+
category: g.category,
|
|
412
|
+
search_hint: g.search_hint,
|
|
413
|
+
};
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
415
419
|
const next_steps = buildNextSteps(classified, pagesResp.results, related);
|
|
416
420
|
|
|
417
421
|
const note = buildSearchAllNote(classified, pagesResp, related);
|
package/src/release.test.ts
CHANGED
|
@@ -387,6 +387,26 @@ describe("release.yml", () => {
|
|
|
387
387
|
expect(src).toContain("npm publish");
|
|
388
388
|
expect(src).toContain("NPM_TOKEN");
|
|
389
389
|
});
|
|
390
|
+
|
|
391
|
+
test("validates DB content before publishing (regression: v0.7.6 shipped 3 pages)", () => {
|
|
392
|
+
const src = readText(".github/workflows/release.yml");
|
|
393
|
+
// Hard guard step that hard-fails the workflow if the built DB is degenerate.
|
|
394
|
+
expect(src).toContain("Validate DB has expected content");
|
|
395
|
+
// Must run BEFORE the artifact build, container push, GH Release, and npm publish.
|
|
396
|
+
const validateIdx = src.indexOf("Validate DB has expected content");
|
|
397
|
+
const buildIdx = src.indexOf("Build release artifacts");
|
|
398
|
+
const releaseIdx = src.indexOf("gh release create");
|
|
399
|
+
const npmIdx = src.indexOf("npm publish");
|
|
400
|
+
expect(validateIdx).toBeGreaterThan(0);
|
|
401
|
+
expect(validateIdx).toBeLessThan(buildIdx);
|
|
402
|
+
expect(validateIdx).toBeLessThan(releaseIdx);
|
|
403
|
+
expect(validateIdx).toBeLessThan(npmIdx);
|
|
404
|
+
// Must check minimum thresholds for the four critical tables.
|
|
405
|
+
expect(src).toMatch(/PAGES.*-lt 200/);
|
|
406
|
+
expect(src).toMatch(/COMMANDS.*-lt 1000/);
|
|
407
|
+
expect(src).toMatch(/DEVICES.*-lt 100/);
|
|
408
|
+
expect(src).toMatch(/PROPERTIES.*-lt 1000/);
|
|
409
|
+
});
|
|
390
410
|
});
|
|
391
411
|
|
|
392
412
|
// ---------------------------------------------------------------------------
|