@tikoci/rosetta 0.5.1 → 0.5.2

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.5.1",
3
+ "version": "0.5.2",
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
@@ -281,7 +281,7 @@ function renderSearchResults(resp: SearchResponse): string {
281
281
 
282
282
  // Navigation hints
283
283
  const hints = [
284
- `${cyan("#N")} view page`,
284
+ `${cyan("[N]")} view page`,
285
285
  `${cyan("[s <query>]")} search`,
286
286
  `${cyan("[p <query>]")} properties`,
287
287
  `${cyan("[cmd <path>]")} commands`,
@@ -374,7 +374,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
374
374
  // Navigation hints
375
375
  out.push("");
376
376
  const hints: string[] = [];
377
- if (page.sections && page.sections.length > 0) hints.push(`${cyan("#N")} section`);
377
+ if (page.sections && page.sections.length > 0) hints.push(`${cyan("[N]")} section`);
378
378
  hints.push(`${cyan("[p]")} properties`);
379
379
  hints.push(`${cyan("[cmd]")} command tree`);
380
380
  hints.push(`${cyan("[cal]")} callouts`);
@@ -480,7 +480,7 @@ function renderDeviceResults(results: DeviceResult[], mode: string, total: numbe
480
480
  out.push("");
481
481
  }
482
482
 
483
- out.push(` ${cyan("#N")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
483
+ out.push(` ${cyan("[N]")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
484
484
  return out.join("\n");
485
485
  }
486
486
 
@@ -529,24 +529,17 @@ function renderDeviceCard(d: DeviceResult): string {
529
529
  out.push(` ${dim("Block diagram:")} ${cyan(link(d.block_diagram_url, "view"))}`);
530
530
  }
531
531
 
532
- // Test results (attached for exact matches) — default to 512B for a useful baseline
532
+ // Test results (attached for exact matches)
533
533
  if (d.test_results && d.test_results.length > 0) {
534
- const pkt512 = d.test_results.filter((t) => t.packet_size === 512);
535
- const displayTests = pkt512.length > 0 ? pkt512 : d.test_results.slice(0, 20);
536
- const pktNote = pkt512.length > 0 ? " (512B baseline)" : "";
537
534
  out.push("");
538
- out.push(` ${bold("Benchmarks:")} ${dim(`(${displayTests.length} of ${d.test_results.length} tests${pktNote})`)}`);
539
- for (const t of displayTests) {
535
+ out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
536
+ for (const t of d.test_results.slice(0, 12)) {
540
537
  const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
541
538
  const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
542
539
  out.push(` ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(t.configuration, 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(mbps)} ${dim(kpps)}`);
543
540
  }
544
- if (d.test_results.length > displayTests.length) {
545
- const otherSizes = [...new Set(
546
- d.test_results.filter((t) => t.packet_size !== 512).map((t) => t.packet_size),
547
- )].sort((a, b) => a - b);
548
- const sizesStr = otherSizes.map((s) => `${s}B`).join(", ");
549
- out.push(` ${dim(`Also at ${sizesStr} — use`)} ${cyan("tests")} ${dim("<type> <size> for other packet sizes")}`);
541
+ if (d.test_results.length > 12) {
542
+ out.push(` ${dim(`... and ${d.test_results.length - 12} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
550
543
  }
551
544
  }
552
545
 
@@ -637,7 +630,7 @@ function renderChangelogs(results: ChangelogResult[]): string {
637
630
  }
638
631
 
639
632
  out.push("");
640
- out.push(` ${cyan("[cl 7.22 bgp]")} version + topic ${cyan("[cl 7.21..7.22]")} range ${cyan("[cl breaking]")} breaking only ${cyan("[b]")} back`);
633
+ out.push(` ${cyan("[cl breaking]")} breaking only ${cyan("[cl <ver>]")} specific version ${cyan("[b]")} back`);
641
634
  return out.join("\n");
642
635
  }
643
636
 
@@ -728,6 +721,13 @@ function renderStats(): string {
728
721
  const out: string[] = [];
729
722
  out.push(` ${bold("Database Statistics")}`);
730
723
  out.push(` ${dim("Path:")} ${stats.db_path}`);
724
+ if (stats.db_size_bytes != null) {
725
+ const mb = stats.db_size_bytes / (1024 * 1024);
726
+ out.push(` ${dim("Size:")} ${mb.toFixed(1)} MB`);
727
+ }
728
+ if (stats.schema_version != null) {
729
+ out.push(` ${dim("Schema:")} v${stats.schema_version}`);
730
+ }
731
731
  out.push(` ${dim("Export:")} ${stats.doc_export}`);
732
732
  out.push("");
733
733
 
@@ -771,7 +771,7 @@ function renderHelp(): string {
771
771
  cmd("props <query>", "sp", "Search properties by FTS", "routeros_search_properties");
772
772
  cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
773
773
  cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
774
- cmd("tests [device] <type> [packet_size] [mode]", "", "Cross-device benchmarks (512B default)", "routeros_search_tests");
774
+ cmd("tests [device] [type]", "", "Cross-device benchmarks", "routeros_search_tests");
775
775
  cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)", "routeros_search_callouts");
776
776
  cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
777
777
  cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
@@ -791,69 +791,8 @@ function renderHelp(): string {
791
791
  return out.join("\n");
792
792
  }
793
793
 
794
- /** Fetch the full transcript text for a specific video segment. */
795
- function getVideoSegmentTranscript(youtubeVideoId: string, startS: number): string | null {
796
- try {
797
- const row = db
798
- .prepare(
799
- `SELECT vs.transcript FROM video_segments vs
800
- JOIN videos v ON v.id = vs.video_id
801
- WHERE v.video_id = ? AND vs.start_s = ? LIMIT 1`,
802
- )
803
- .get(youtubeVideoId, startS) as { transcript: string } | null;
804
- return row?.transcript ?? null;
805
- } catch {
806
- return null;
807
- }
808
- }
809
-
810
794
  // ── Command dispatcher ──
811
795
 
812
- /** Re-render the current context after going back — uses cached results where possible. */
813
- async function renderCurrentCtx(): Promise<void> {
814
- switch (ctx.type) {
815
- case "home":
816
- console.log(renderWelcome());
817
- return;
818
- case "search":
819
- await paged(renderSearchResults(ctx.response));
820
- return;
821
- case "commands": {
822
- const children = browseCommands(ctx.path);
823
- if (children.length > 0) await paged(renderCommandTree(ctx.path, children));
824
- else console.log(dim(` No children at "${ctx.path}".`));
825
- return;
826
- }
827
- case "devices":
828
- await paged(renderDeviceResults(ctx.results, "cached", ctx.results.length));
829
- return;
830
- case "device":
831
- await paged(renderDeviceCard(ctx.device));
832
- return;
833
- case "callouts":
834
- if (ctx.results.length > 0) await paged(renderCallouts(ctx.results));
835
- return;
836
- case "changelogs":
837
- if (ctx.results.length > 0) await paged(renderChangelogs(ctx.results));
838
- return;
839
- case "videos":
840
- if (ctx.results.length > 0) await paged(renderVideos(ctx.results));
841
- return;
842
- case "page": {
843
- const page = getPage(ctx.pageId);
844
- if (page) await paged(renderPage(page));
845
- return;
846
- }
847
- case "sections": {
848
- const page = getPage(ctx.pageId);
849
- if (page) await paged(renderPage(page));
850
- return;
851
- }
852
- default:
853
- console.log(dim(` In ${ctx.type} context. Type a command or help.`));
854
- }
855
- }
856
-
857
796
  async function dispatch(input: string): Promise<void> {
858
797
  const trimmed = input.trim();
859
798
  if (!trimmed) return;
@@ -888,8 +827,7 @@ async function dispatch(input: string): Promise<void> {
888
827
  if (!popCtx()) {
889
828
  console.log(dim(" Already at top."));
890
829
  } else {
891
- console.log(dim(` ← ${ctx.type}`));
892
- await renderCurrentCtx();
830
+ console.log(dim(` ← back to ${ctx.type}`));
893
831
  }
894
832
  return;
895
833
 
@@ -904,20 +842,7 @@ async function dispatch(input: string): Promise<void> {
904
842
  return;
905
843
 
906
844
  case "page": {
907
- if (!rest) {
908
- // From commands context: navigate to the linked page
909
- if (ctx.type === "commands") {
910
- const row = db.prepare("SELECT page_id FROM commands WHERE path = ? LIMIT 1").get(ctx.path) as { page_id: number | null } | null;
911
- if (row?.page_id) {
912
- await doPage(String(row.page_id));
913
- return;
914
- }
915
- console.log(dim(` No linked page for ${ctx.path}.`));
916
- return;
917
- }
918
- console.log(dim(" Usage: page <id|title>"));
919
- return;
920
- }
845
+ if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
921
846
  await doPage(rest);
922
847
  return;
923
848
  }
@@ -925,12 +850,13 @@ async function dispatch(input: string): Promise<void> {
925
850
  case "p":
926
851
  case "prop": {
927
852
  if (!rest) {
928
- // Context-scoped: show properties for current page or sections context
929
- const pageId = ctx.type === "page" ? ctx.pageId : ctx.type === "sections" ? ctx.pageId : null;
930
- const title = ctx.type === "page" ? ctx.title : ctx.type === "sections" ? ctx.title : null;
931
- if (pageId !== null && title !== null) {
932
- await doPropsForPage(pageId, title);
933
- return;
853
+ // Context-scoped: show properties for current page
854
+ if (ctx.type === "page") {
855
+ const page = getPage(ctx.pageId, 0); // just get metadata
856
+ if (page) {
857
+ await doPropsForPage(ctx.pageId, ctx.title);
858
+ return;
859
+ }
934
860
  }
935
861
  console.log(dim(" Usage: prop <name> — or navigate to a page first"));
936
862
  return;
@@ -948,15 +874,8 @@ async function dispatch(input: string): Promise<void> {
948
874
 
949
875
  case "cmd":
950
876
  case "tree": {
951
- let cmdPath = rest;
952
- if (!cmdPath && ctx.type === "commands") {
953
- // Re-display current commands context
954
- cmdPath = ctx.path;
955
- } else if (cmdPath && !cmdPath.startsWith("/") && ctx.type === "commands") {
956
- // Relative path: resolve against current commands path
957
- cmdPath = `${ctx.path}/${cmdPath}`;
958
- }
959
- await doCommandTree(cmdPath);
877
+ const path = rest || (ctx.type === "commands" ? ctx.path : "");
878
+ await doCommandTree(path);
960
879
  return;
961
880
  }
962
881
 
@@ -974,24 +893,13 @@ async function dispatch(input: string): Promise<void> {
974
893
 
975
894
  case "cal":
976
895
  case "callouts": {
977
- if (!rest) {
978
- // Context-aware: show callouts for the current page directly
979
- const pageId = ctx.type === "page" ? ctx.pageId
980
- : ctx.type === "sections" ? ctx.pageId
981
- : null;
982
- if (pageId !== null) {
983
- const pageCallouts = db.prepare(
984
- `SELECT c.type, c.content, p.title as page_title, p.url as page_url,
985
- c.page_id, c.content as excerpt
986
- FROM callouts c JOIN pages p ON p.id = c.page_id
987
- WHERE c.page_id = ? ORDER BY c.sort_order`,
988
- ).all(pageId) as CalloutResult[];
989
- if (pageCallouts.length > 0) {
990
- await paged(renderCallouts(pageCallouts));
991
- pushCtx({ type: "callouts", query: "", results: pageCallouts });
992
- } else {
993
- console.log(dim(" No callouts for this page."));
994
- }
896
+ if (!rest && ctx.type === "page") {
897
+ // Show callouts for current page
898
+ const results = searchCallouts("", undefined, 50);
899
+ const pageCallouts = results.filter((c) => c.page_id === (ctx as { pageId: number }).pageId);
900
+ if (pageCallouts.length > 0) {
901
+ await paged(renderCallouts(pageCallouts));
902
+ pushCtx({ type: "callouts", query: "", results: pageCallouts });
995
903
  return;
996
904
  }
997
905
  }
@@ -1006,7 +914,6 @@ async function dispatch(input: string): Promise<void> {
1006
914
  }
1007
915
 
1008
916
  case "vid":
1009
- case "video":
1010
917
  case "videos": {
1011
918
  if (!rest) { console.log(dim(" Usage: videos <query>")); return; }
1012
919
  await doSearchVideos(rest);
@@ -1077,18 +984,9 @@ async function handleNumberSelect(idx: number): Promise<void> {
1077
984
  if (ctx.type === "videos" && ctx.results[idx]) {
1078
985
  const v = ctx.results[idx];
1079
986
  const timeUrl = v.start_s > 0 ? `${v.url}&t=${v.start_s}` : v.url;
1080
- const out: string[] = [];
1081
- out.push(`\n ${bold(v.title)}`);
1082
- if (v.chapter_title) out.push(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${formatTime(v.start_s)}`)}`);
1083
- out.push(` ${cyan(link(timeUrl))}`);
1084
- // Fetch and show full transcript for this segment
1085
- const transcript = getVideoSegmentTranscript(v.video_id, v.start_s);
1086
- if (transcript) {
1087
- out.push("");
1088
- out.push(` ${dim("── transcript ──")}`);
1089
- out.push(transcript.split("\n").map((l) => ` ${l}`).join("\n"));
1090
- }
1091
- await paged(out.join("\n"));
987
+ console.log(`\n ${bold(v.title)}`);
988
+ if (v.chapter_title) console.log(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${formatTime(v.start_s)}`)}`);
989
+ console.log(` ${cyan(link(timeUrl))}\n`);
1092
990
  return;
1093
991
  }
1094
992
  if (ctx.type === "properties" && ctx.results[idx]) {
@@ -1215,64 +1113,33 @@ async function doDeviceLookup(query: string): Promise<void> {
1215
1113
  }
1216
1114
 
1217
1115
  async function doTests(argsStr: string): Promise<void> {
1218
- const parts = argsStr.split(/\s+/).filter(Boolean);
1219
- const filters: Record<string, string | number> = {};
1220
-
1221
- // Parse args if provided
1222
- if (argsStr) {
1223
- const knownTypes = ["ethernet", "ipsec"];
1224
- let offset = 0;
1225
- if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
1226
- filters.device = parts[0];
1227
- offset = 1;
1228
- }
1229
- if (parts[offset]) filters.test_type = parts[offset];
1230
- // packet_size is always numeric; mode can be multi-word — identify by type
1231
- const remaining = parts.slice(offset + 1);
1232
- const pktIdx = remaining.findIndex((p) => /^\d+$/.test(p));
1233
- if (pktIdx !== -1) {
1234
- filters.packet_size = Number.parseInt(remaining[pktIdx], 10);
1235
- const modeParts = [...remaining.slice(0, pktIdx), ...remaining.slice(pktIdx + 1)];
1236
- if (modeParts.length > 0) filters.mode = modeParts.join(" ");
1237
- } else if (remaining.length > 0) {
1238
- filters.mode = remaining.join(" ");
1239
- }
1240
- }
1241
-
1242
- // Auto-inject device name from context when in a device view and none specified
1243
- if (!filters.device && ctx.type === "device") {
1244
- filters.device = ctx.device.product_name;
1245
- }
1246
-
1247
- // Show help if no filters could be determined
1248
- if (!argsStr && !filters.device) {
1116
+ if (!argsStr) {
1117
+ // Show available filter values
1249
1118
  const meta = getTestResultMeta();
1250
1119
  console.log(` ${bold("Test filters:")}`);
1251
1120
  console.log(` ${dim("Types:")} ${meta.test_types.join(", ")}`);
1252
1121
  console.log(` ${dim("Modes:")} ${meta.modes.join(", ")}`);
1253
1122
  console.log(` ${dim("Packet sizes:")} ${meta.packet_sizes.join(", ")}`);
1254
1123
  console.log("");
1255
- console.log(` ${dim("Usage: tests [device] <type> [packet_size] [mode]")}`);
1124
+ console.log(` ${dim("Usage: tests [device] <type> [mode] [packet_size]")}`);
1256
1125
  console.log(` ${dim("Example: tests ethernet Routing 1518")}`);
1257
1126
  console.log(` ${dim("Example: tests rb5009 ethernet 1518")}`);
1258
1127
  return;
1259
1128
  }
1260
1129
 
1261
- // Round packet_size to the nearest valid test packet size
1262
- if (filters.packet_size) {
1263
- const validSizes = [64, 512, 1400, 1518];
1264
- const size = Number(filters.packet_size);
1265
- const rounded = validSizes.reduce((prev, curr) =>
1266
- Math.abs(curr - size) < Math.abs(prev - size) ? curr : prev,
1267
- );
1268
- if (rounded !== size) {
1269
- console.log(dim(` Rounding ${size}B → ${rounded}B (nearest test packet size)`));
1270
- filters.packet_size = rounded;
1271
- }
1272
- }
1130
+ const parts = argsStr.split(/\s+/);
1131
+ const filters: Record<string, string | number> = {};
1273
1132
 
1274
- // Default packet_size to 512 for a focused baseline comparison
1275
- if (!filters.packet_size && !filters.mode) filters.packet_size = 512;
1133
+ // Known test types if parts[0] is not a known type, treat it as a device filter
1134
+ const knownTypes = ["ethernet", "ipsec"];
1135
+ let offset = 0;
1136
+ if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
1137
+ filters.device = parts[0];
1138
+ offset = 1;
1139
+ }
1140
+ if (parts[offset]) filters.test_type = parts[offset];
1141
+ if (parts[offset + 1]) filters.mode = parts[offset + 1];
1142
+ if (parts[offset + 2] && /^\d+$/.test(parts[offset + 2])) filters.packet_size = Number.parseInt(parts[offset + 2], 10);
1276
1143
 
1277
1144
  const result = searchDeviceTests(filters);
1278
1145
  if (result.results.length === 0) {
@@ -1379,6 +1246,7 @@ async function doCurrentVersions(): Promise<void> {
1379
1246
  const ch = pad(channel, 14);
1380
1247
  out.push(` ${dim(ch)} ${bold(String(v))}`);
1381
1248
  }
1249
+ out.push(` ${dim(pad("winbox 4", 14))} ${bold(String(result.winbox ?? dim("unavailable")))}`);
1382
1250
  console.log(out.join("\n"));
1383
1251
  }
1384
1252
 
@@ -1429,9 +1297,6 @@ async function main() {
1429
1297
  } catch (err) {
1430
1298
  console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
1431
1299
  }
1432
- // Clear any keys that leaked from the pager into readline's internal line buffer
1433
- // (the key pressed to exit paging — q/SPACE/ENTER — can appear in the next prompt)
1434
- rl.write(null as unknown as string, { ctrl: true, name: "u" });
1435
1300
  rl.setPrompt(buildPrompt());
1436
1301
  rl.prompt();
1437
1302
  });
package/src/db.ts CHANGED
@@ -441,8 +441,24 @@ export function checkSchemaVersion(): { ok: boolean; actual: number; expected: n
441
441
  export function getDbStats() {
442
442
  const count = (sql: string) =>
443
443
  Number((db.prepare(sql).get() as { c: number }).c ?? 0);
444
+ const dbSizeBytes = (() => {
445
+ try {
446
+ return Bun.file(DB_PATH).size;
447
+ } catch {
448
+ return null;
449
+ }
450
+ })();
451
+ const schemaVersion = (() => {
452
+ try {
453
+ return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
454
+ } catch {
455
+ return null;
456
+ }
457
+ })();
444
458
  return {
445
459
  db_path: DB_PATH,
460
+ db_size_bytes: dbSizeBytes,
461
+ schema_version: schemaVersion,
446
462
  pages: count("SELECT COUNT(*) AS c FROM pages"),
447
463
  sections: count("SELECT COUNT(*) AS c FROM sections"),
448
464
  properties: count("SELECT COUNT(*) AS c FROM properties"),
@@ -120,7 +120,11 @@ if (lines.length < 2) {
120
120
  // Skip header row
121
121
  const dataLines = lines.slice(1);
122
122
 
123
- // Idempotent: clear existing data (FTS triggers handle cleanup)
123
+ // Idempotent: clear existing data (FTS triggers handle cleanup).
124
+ // device_test_results FKs into devices — wipe it first to avoid a FOREIGN KEY
125
+ // constraint failure on re-run over a populated DB. extract-test-results runs
126
+ // later in the pipeline and repopulates it.
127
+ db.run("DELETE FROM device_test_results");
124
128
  db.run("DELETE FROM devices");
125
129
 
126
130
  const insert = db.prepare(`INSERT INTO devices (
@@ -0,0 +1,67 @@
1
+ // Set BEFORE any import that transitively loads db.ts
2
+ process.env.DB_PATH = ":memory:";
3
+
4
+ import { describe, expect, it } from "bun:test";
5
+ import { parseHTML } from "linkedom";
6
+
7
+ // Dynamic import so the DB_PATH assignment above wins over module caching
8
+ const { extractPlainText, sanitizeExtractedText } = await import("./extract-html.ts");
9
+
10
+ describe("sanitizeExtractedText", () => {
11
+ it("removes Confluence TOC CDATA style blocks", () => {
12
+ const input = `
13
+ Intro
14
+ /*<![CDATA[*/
15
+ div.rbtoc1774430868497 {padding: 0px;}
16
+ div.rbtoc1774430868497 ul {margin-left: 0px;}
17
+ div.rbtoc1774430868497 li {margin-left: 0px;padding-left: 0px;}
18
+ /*]]>*/
19
+ Basic Setup
20
+ `;
21
+
22
+ const out = sanitizeExtractedText(input);
23
+ expect(out).toContain("Intro");
24
+ expect(out).toContain("Basic Setup");
25
+ expect(out).not.toContain("rbtoc1774430868497");
26
+ expect(out).not.toContain("/*<![CDATA[*/");
27
+ expect(out).not.toContain("/*]]>*/");
28
+ });
29
+
30
+ it("removes bare rbtoc css lines without wrappers", () => {
31
+ const input = `
32
+ Top
33
+
34
+ div.rbtoc1234 {padding: 0px;}
35
+ div.rbtoc1234 ul {margin-left: 0px;}
36
+
37
+ after
38
+ `;
39
+
40
+ const out = sanitizeExtractedText(input);
41
+ expect(out).toContain("Top");
42
+ expect(out).toContain("after");
43
+ expect(out).not.toContain("div.rbtoc1234");
44
+ });
45
+
46
+ it("keeps normal RouterOS content", () => {
47
+ const input = "/interface vrrp add interface=ether1 vrid=49 priority=254";
48
+ const out = sanitizeExtractedText(input);
49
+ expect(out).toBe(input);
50
+ });
51
+ });
52
+
53
+ describe("extractPlainText", () => {
54
+ it("keeps heading and paragraph separated", () => {
55
+ const { document } = parseHTML("<div><h1>Basic Setup</h1><p>This is the basic VRRP configuration example.</p></div>");
56
+ const out = extractPlainText(document.querySelector("div"));
57
+ expect(out).toContain("Basic Setup\nThis is the basic VRRP configuration example.");
58
+ expect(out).not.toContain("Basic SetupThis");
59
+ });
60
+
61
+ it("keeps subsection heading boundaries", () => {
62
+ const { document } = parseHTML("<div><h2>Overview</h2><p>The Point-to-Point Protocol (PPP) provides...</p></div>");
63
+ const out = extractPlainText(document.querySelector("div"));
64
+ expect(out).toContain("Overview\nThe Point-to-Point Protocol (PPP) provides...");
65
+ expect(out).not.toContain("OverviewThe");
66
+ });
67
+ });
@@ -64,6 +64,120 @@ interface SectionRow {
64
64
  sort_order: number;
65
65
  }
66
66
 
67
+ const BLOCK_TAGS = new Set([
68
+ "address",
69
+ "article",
70
+ "aside",
71
+ "blockquote",
72
+ "dd",
73
+ "div",
74
+ "dl",
75
+ "dt",
76
+ "fieldset",
77
+ "figcaption",
78
+ "figure",
79
+ "footer",
80
+ "form",
81
+ "h1",
82
+ "h2",
83
+ "h3",
84
+ "h4",
85
+ "h5",
86
+ "h6",
87
+ "header",
88
+ "hr",
89
+ "li",
90
+ "main",
91
+ "nav",
92
+ "ol",
93
+ "p",
94
+ "pre",
95
+ "section",
96
+ "table",
97
+ "tbody",
98
+ "td",
99
+ "th",
100
+ "thead",
101
+ "tr",
102
+ "ul",
103
+ ]);
104
+
105
+ /**
106
+ * Remove Confluence TOC style leakage that may survive DOM cleanup when HTML is malformed.
107
+ * Keeps cleanup narrow to rbtoc CSS so normal content is not affected.
108
+ */
109
+ export function sanitizeExtractedText(input: string): string {
110
+ if (!input) return "";
111
+
112
+ return input
113
+ // Full CDATA-wrapped TOC style block.
114
+ .replace(/\/\*<!\[CDATA\[\*\/[\s\S]*?\/\*\]\]>\*\//g, "")
115
+ // Bare TOC CSS lines that can leak without wrappers.
116
+ .replace(/^\s*div\.rbtoc\d+[^\n]*(?:\n\s*div\.rbtoc\d+[^\n]*)*/gm, "")
117
+ // Any orphaned CDATA markers.
118
+ .replace(/\/\*<!\[CDATA\[\*\//g, "")
119
+ .replace(/\/\*\]\]>\*\//g, "")
120
+ // Prevent giant gaps in rendered output.
121
+ .replace(/\n{3,}/g, "\n\n")
122
+ .trim();
123
+ }
124
+
125
+ /**
126
+ * Convert a DOM subtree to readable plain text while preserving block boundaries.
127
+ * This avoids merged tokens like "OverviewThe" caused by raw textContent collapse.
128
+ */
129
+ export function extractPlainText(root: Element | null): string {
130
+ if (!root) return "";
131
+
132
+ let out = "";
133
+
134
+ const addBreak = () => {
135
+ if (!out) return;
136
+ if (!out.endsWith("\n")) out += "\n";
137
+ };
138
+
139
+ const addText = (text: string) => {
140
+ const normalized = text.replace(/\s+/g, " ").trim();
141
+ if (!normalized) return;
142
+ if (out && !out.endsWith("\n") && !out.endsWith(" ")) out += " ";
143
+ out += normalized;
144
+ };
145
+
146
+ const walk = (node: Node) => {
147
+ if (node.nodeType === 3) {
148
+ addText(node.textContent || "");
149
+ return;
150
+ }
151
+
152
+ if (node.nodeType !== 1) return;
153
+
154
+ const el = node as Element;
155
+ const tag = el.tagName.toLowerCase();
156
+
157
+ if (tag === "style" || tag === "script" || tag === "noscript") return;
158
+ if (tag === "br") {
159
+ addBreak();
160
+ return;
161
+ }
162
+
163
+ const isBlock = BLOCK_TAGS.has(tag);
164
+ if (isBlock) addBreak();
165
+
166
+ for (const child of el.childNodes) {
167
+ walk(child as unknown as Node);
168
+ }
169
+
170
+ if (isBlock) addBreak();
171
+ };
172
+
173
+ walk(root as unknown as Node);
174
+
175
+ return out
176
+ .replace(/[ \t]*\n[ \t]*/g, "\n")
177
+ .replace(/\n{3,}/g, "\n\n")
178
+ .trim();
179
+ }
180
+
67
181
  /**
68
182
  * Split main content into sections by h1–h3 headings with id attributes.
69
183
  * Uses innerHTML + regex to locate heading boundaries, then parses each
@@ -105,7 +219,7 @@ function extractSections(mainContent: Element, pageId: number): SectionRow[] {
105
219
  codeChunks.push(ce.textContent?.trim() || "");
106
220
  }
107
221
 
108
- const text = root?.textContent?.trim() || "";
222
+ const text = sanitizeExtractedText(extractPlainText(root));
109
223
  const code = codeChunks.join("\n\n");
110
224
 
111
225
  return {
@@ -187,7 +301,7 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
187
301
  }
188
302
 
189
303
  // Plain text from main content (includes code block text too, which is fine for FTS)
190
- const text = mainContent?.textContent?.trim() || "";
304
+ const text = sanitizeExtractedText(extractPlainText(mainContent));
191
305
  const wordCount = text.split(/\s+/).filter(Boolean).length;
192
306
 
193
307
  // Callouts: extract note/warning/info blocks
@@ -238,149 +352,155 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
238
352
 
239
353
  // ---- Main ----
240
354
 
241
- console.log("Initializing database...");
242
- initDb();
355
+ function main() {
356
+ console.log("Initializing database...");
357
+ initDb();
243
358
 
244
359
  // Drop existing data for clean re-extraction (respect FK order)
245
- db.run("DELETE FROM sections;");
246
- db.run("DELETE FROM callouts;");
247
- db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
248
- db.run("DELETE FROM properties;");
249
- db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
250
- db.run("PRAGMA foreign_keys = OFF;");
251
- db.run("DELETE FROM pages;");
252
- db.run("PRAGMA foreign_keys = ON;");
253
- db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
254
-
255
- const htmlFiles = readdirSync(HTML_DIR)
256
- .filter((f) => f.endsWith(".html") && f !== "index.html")
257
- .sort();
258
-
259
- console.log(`Extracting ${htmlFiles.length} HTML files from ${HTML_DIR}`);
360
+ db.run("DELETE FROM sections;");
361
+ db.run("DELETE FROM callouts;");
362
+ db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
363
+ db.run("DELETE FROM properties;");
364
+ db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
365
+ db.run("PRAGMA foreign_keys = OFF;");
366
+ db.run("DELETE FROM pages;");
367
+ db.run("PRAGMA foreign_keys = ON;");
368
+ db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
369
+
370
+ const htmlFiles = readdirSync(HTML_DIR)
371
+ .filter((f) => f.endsWith(".html") && f !== "index.html")
372
+ .sort();
373
+
374
+ console.log(`Extracting ${htmlFiles.length} HTML files from ${HTML_DIR}`);
260
375
 
261
376
  // Two-pass insert: first without parent_id (avoids FK ordering issues),
262
377
  // then update parent relationships.
263
- const insertPage = db.prepare(`
378
+ const insertPage = db.prepare(`
264
379
  INSERT OR REPLACE INTO pages
265
380
  (id, slug, title, path, depth, parent_id, url, text, code, code_lang,
266
381
  author, last_updated, word_count, code_lines, html_file)
267
382
  VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
268
383
  `);
269
- const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
384
+ const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
270
385
 
271
- let extracted = 0;
272
- let skipped = 0;
273
- let totalWords = 0;
274
- let totalCodeLines = 0;
275
- let totalCallouts = 0;
386
+ let extracted = 0;
387
+ let skipped = 0;
388
+ let totalWords = 0;
389
+ let totalCodeLines = 0;
390
+ let totalCallouts = 0;
276
391
 
277
- const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
392
+ const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
278
393
 
279
394
  // Pass 1: extract and insert all pages (parent_id = NULL)
280
- const insertAll = db.transaction(() => {
281
- for (const file of htmlFiles) {
282
- const html = readFileSync(resolve(HTML_DIR, file), "utf-8");
283
- const page = extractPage(file, html);
284
- if (!page) {
285
- skipped++;
286
- console.warn(` skipped: ${file}`);
287
- continue;
395
+ const insertAll = db.transaction(() => {
396
+ for (const file of htmlFiles) {
397
+ const html = readFileSync(resolve(HTML_DIR, file), "utf-8");
398
+ const page = extractPage(file, html);
399
+ if (!page) {
400
+ skipped++;
401
+ console.warn(` skipped: ${file}`);
402
+ continue;
403
+ }
404
+ insertPage.run(
405
+ page.id,
406
+ page.slug,
407
+ page.title,
408
+ page.path,
409
+ page.depth,
410
+ page.url,
411
+ page.text,
412
+ page.code,
413
+ page.code_lang,
414
+ page.author,
415
+ page.last_updated,
416
+ page.word_count,
417
+ page.code_lines,
418
+ page.html_file,
419
+ );
420
+ allPages.push(page);
421
+ extracted++;
422
+ totalWords += page.word_count;
423
+ totalCodeLines += page.code_lines;
288
424
  }
289
- insertPage.run(
290
- page.id,
291
- page.slug,
292
- page.title,
293
- page.path,
294
- page.depth,
295
- page.url,
296
- page.text,
297
- page.code,
298
- page.code_lang,
299
- page.author,
300
- page.last_updated,
301
- page.word_count,
302
- page.code_lines,
303
- page.html_file,
304
- );
305
- allPages.push(page);
306
- extracted++;
307
- totalWords += page.word_count;
308
- totalCodeLines += page.code_lines;
309
- }
310
- });
311
- insertAll();
425
+ });
426
+ insertAll();
312
427
 
313
428
  // Pass 2: set parent_id where the parent actually exists in the DB
314
- const pageIds = new Set(allPages.map((p) => p.id));
315
- const setParents = db.transaction(() => {
316
- for (const page of allPages) {
317
- if (page.parent_id && pageIds.has(page.parent_id)) {
318
- updateParent.run(page.parent_id, page.id);
429
+ const pageIds = new Set(allPages.map((p) => p.id));
430
+ const setParents = db.transaction(() => {
431
+ for (const page of allPages) {
432
+ if (page.parent_id && pageIds.has(page.parent_id)) {
433
+ updateParent.run(page.parent_id, page.id);
434
+ }
319
435
  }
320
- }
321
- });
322
- setParents();
436
+ });
437
+ setParents();
323
438
 
324
439
  // Pass 3: insert callouts
325
- const insertCallout = db.prepare(`
440
+ const insertCallout = db.prepare(`
326
441
  INSERT INTO callouts (page_id, type, content, sort_order)
327
442
  VALUES (?, ?, ?, ?)
328
443
  `);
329
- const insertCallouts = db.transaction(() => {
330
- for (const page of allPages) {
331
- for (const c of page.callouts) {
332
- insertCallout.run(c.page_id, c.type, c.content, c.sort_order);
333
- totalCallouts++;
444
+ const insertCallouts = db.transaction(() => {
445
+ for (const page of allPages) {
446
+ for (const c of page.callouts) {
447
+ insertCallout.run(c.page_id, c.type, c.content, c.sort_order);
448
+ totalCallouts++;
449
+ }
334
450
  }
335
- }
336
- });
337
- insertCallouts();
451
+ });
452
+ insertCallouts();
338
453
 
339
454
  // Pass 4: insert sections
340
- let totalSections = 0;
341
- let pagesWithSections = 0;
342
- const insertSection = db.prepare(`
455
+ let totalSections = 0;
456
+ let pagesWithSections = 0;
457
+ const insertSection = db.prepare(`
343
458
  INSERT INTO sections (page_id, heading, level, anchor_id, text, code, word_count, sort_order)
344
459
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
345
460
  `);
346
- const insertSections = db.transaction(() => {
347
- for (const page of allPages) {
348
- if (page.sections.length > 0) {
349
- pagesWithSections++;
350
- for (const s of page.sections) {
351
- insertSection.run(s.page_id, s.heading, s.level, s.anchor_id, s.text, s.code, s.word_count, s.sort_order);
352
- totalSections++;
461
+ const insertSections = db.transaction(() => {
462
+ for (const page of allPages) {
463
+ if (page.sections.length > 0) {
464
+ pagesWithSections++;
465
+ for (const s of page.sections) {
466
+ insertSection.run(s.page_id, s.heading, s.level, s.anchor_id, s.text, s.code, s.word_count, s.sort_order);
467
+ totalSections++;
468
+ }
353
469
  }
354
470
  }
355
- }
356
- });
357
- insertSections();
471
+ });
472
+ insertSections();
358
473
 
359
- const ftsCount = (db.prepare("SELECT COUNT(*) as c FROM pages_fts").get() as { c: number }).c;
474
+ const ftsCount = (db.prepare("SELECT COUNT(*) as c FROM pages_fts").get() as { c: number }).c;
360
475
 
361
- console.log(`\nExtraction complete:`);
362
- console.log(` Pages extracted: ${extracted}`);
363
- console.log(` Pages skipped: ${skipped}`);
364
- console.log(` Total words: ${totalWords.toLocaleString()}`);
365
- console.log(` Total code lines: ${totalCodeLines.toLocaleString()}`);
366
- console.log(` Total callouts: ${totalCallouts}`);
367
- console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
368
- console.log(` FTS index rows: ${ftsCount}`);
476
+ console.log(`\nExtraction complete:`);
477
+ console.log(` Pages extracted: ${extracted}`);
478
+ console.log(` Pages skipped: ${skipped}`);
479
+ console.log(` Total words: ${totalWords.toLocaleString()}`);
480
+ console.log(` Total code lines: ${totalCodeLines.toLocaleString()}`);
481
+ console.log(` Total callouts: ${totalCallouts}`);
482
+ console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
483
+ console.log(` FTS index rows: ${ftsCount}`);
369
484
 
370
485
  // Quick search test
371
- const testResults = db
372
- .prepare(
373
- `SELECT s.id, s.title, s.path,
374
- snippet(pages_fts, 2, '>>>', '<<<', '...', 20) as excerpt
375
- FROM pages_fts fts
376
- JOIN pages s ON s.id = fts.rowid
377
- WHERE pages_fts MATCH 'firewall filter'
378
- ORDER BY rank LIMIT 5`,
379
- )
380
- .all();
381
-
382
- console.log(`\nTest search for "firewall filter":`);
383
- for (const r of testResults as Array<{ id: number; title: string; path: string; excerpt: string }>) {
384
- console.log(` [${r.id}] ${r.path}`);
385
- console.log(` ${r.excerpt}`);
486
+ const testResults = db
487
+ .prepare(
488
+ `SELECT s.id, s.title, s.path,
489
+ snippet(pages_fts, 2, '>>>', '<<<', '...', 20) as excerpt
490
+ FROM pages_fts fts
491
+ JOIN pages s ON s.id = fts.rowid
492
+ WHERE pages_fts MATCH 'firewall filter'
493
+ ORDER BY rank LIMIT 5`,
494
+ )
495
+ .all();
496
+
497
+ console.log(`\nTest search for "firewall filter":`);
498
+ for (const r of testResults as Array<{ id: number; title: string; path: string; excerpt: string }>) {
499
+ console.log(` [${r.id}] ${r.path}`);
500
+ console.log(` ${r.excerpt}`);
501
+ }
502
+ }
503
+
504
+ if (import.meta.main) {
505
+ main();
386
506
  }
package/src/mcp.ts CHANGED
@@ -1032,17 +1032,8 @@ Workflow:
1032
1032
  };
1033
1033
  }
1034
1034
 
1035
- // Round packet_size to the nearest valid test size (64, 512, 1400, 1518)
1036
- let effectivePacketSize = packet_size;
1037
- if (packet_size !== undefined) {
1038
- const validSizes = [64, 512, 1400, 1518];
1039
- effectivePacketSize = validSizes.reduce((prev, curr) =>
1040
- Math.abs(curr - packet_size) < Math.abs(prev - packet_size) ? curr : prev,
1041
- );
1042
- }
1043
-
1044
1035
  const result = searchDeviceTests(
1045
- { device, test_type, mode, configuration, packet_size: effectivePacketSize, sort_by },
1036
+ { device, test_type, mode, configuration, packet_size, sort_by },
1046
1037
  limit,
1047
1038
  );
1048
1039
 
@@ -1065,9 +1056,6 @@ Workflow:
1065
1056
  text: JSON.stringify({
1066
1057
  ...result,
1067
1058
  has_more: result.total > result.results.length,
1068
- ...(effectivePacketSize !== packet_size && packet_size !== undefined
1069
- ? { packet_size_rounded: `${packet_size} → ${effectivePacketSize} (nearest valid test size)` }
1070
- : {}),
1071
1059
  }, null, 2),
1072
1060
  }],
1073
1061
  };
@@ -1081,7 +1069,7 @@ server.registerTool(
1081
1069
  {
1082
1070
  description: `Fetch current RouterOS version numbers from MikroTik's upgrade server.
1083
1071
 
1084
- Returns the latest version for each release channel: stable, long-term, testing, development.
1072
+ Returns the latest version for each release channel (stable, long-term, testing, development) plus the current WinBox 4 version.
1085
1073
  Useful for determining if a user's version is current, outdated, or unpatched.
1086
1074
 
1087
1075
  Key context for version reasoning:
package/src/query.ts CHANGED
@@ -1493,27 +1493,31 @@ function runVideosFtsQuery(ftsQuery: string, limit: number): VideoSearchResult[]
1493
1493
  const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
1494
1494
 
1495
1495
  /** Fetch current RouterOS versions from MikroTik's upgrade server. */
1496
+ const WINBOX_URL = "https://upgrade.mikrotik.com/routeros/winbox/LATEST.4";
1497
+
1496
1498
  export async function fetchCurrentVersions(): Promise<{
1497
1499
  channels: Record<string, string | null>;
1500
+ winbox: string | null;
1498
1501
  fetched_at: string;
1499
1502
  }> {
1500
1503
  const channels: Record<string, string | null> = {};
1501
- await Promise.all(
1502
- VERSION_CHANNELS.map(async (channel) => {
1503
- try {
1504
- const resp = await fetch(`${VERSION_BASE_URL}.${channel}`, {
1505
- signal: AbortSignal.timeout(10_000),
1506
- });
1507
- if (resp.ok) {
1508
- const text = await resp.text();
1509
- channels[channel] = text.trim().split(/\s+/)[0] || null;
1510
- } else {
1511
- channels[channel] = null;
1512
- }
1513
- } catch {
1514
- channels[channel] = null;
1515
- }
1516
- }),
1517
- );
1518
- return { channels, fetched_at: new Date().toISOString() };
1504
+ const fetchOne = async (url: string): Promise<string | null> => {
1505
+ try {
1506
+ const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) });
1507
+ if (!resp.ok) return null;
1508
+ const text = await resp.text();
1509
+ return text.trim().split(/\s+/)[0] || null;
1510
+ } catch {
1511
+ return null;
1512
+ }
1513
+ };
1514
+ const [, winbox] = await Promise.all([
1515
+ Promise.all(
1516
+ VERSION_CHANNELS.map(async (channel) => {
1517
+ channels[channel] = await fetchOne(`${VERSION_BASE_URL}.${channel}`);
1518
+ }),
1519
+ ),
1520
+ fetchOne(WINBOX_URL),
1521
+ ]);
1522
+ return { channels, winbox, fetched_at: new Date().toISOString() };
1519
1523
  }