@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 += pageSize;
162
- if (offset < lines.length) {
163
- const remaining = lines.length - offset;
164
- process.stdout.write(dim(`-- ${remaining} more lines [Q quit | SPACE next page | ENTER next line]`));
165
- const key = await waitForKey();
166
- // Clear the "more" line
167
- process.stdout.write(`\r${" ".repeat(termWidth())}\r`);
168
- if (key === "q" || key === "Q") return true;
169
- // ENTER or arrow-down → advance one line; SPACE → advance one page
170
- if (key === "\r" || key === "\n" || key === "\x1b[B") {
171
- offset -= pageSize - 1; // back up so only 1 line advances
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
- import { KNOWN_TOPICS } from "./query.ts";
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`). */
@@ -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
- import { parseDudePage } from "./extract-dude.ts";
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
 
@@ -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
- each capped at 2–3 entries, empty sections omitted
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 sections are always capped at 2–3."),
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
- * Known RouterOS topics extracted from changelog categories, top-level
95
- * command tree names, and common subsystem tokens. Used by the classifier
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
- const RELATED_CAP = 3;
292
- const RELATED_VIDEO_CAP = 2;
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);
@@ -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
  // ---------------------------------------------------------------------------