@tikoci/rosetta 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
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
@@ -265,6 +265,9 @@ function renderSearchResults(resp: SearchResponse): string {
265
265
  const out: string[] = [];
266
266
  const modeNote = resp.fallbackMode === "or" ? dim(" (OR fallback)") : "";
267
267
  out.push(` ${bold(String(resp.results.length))} of ${resp.total} results for ${cyan(`"${resp.query}"`)}${modeNote}`);
268
+ if (resp.note) {
269
+ out.push(` ${yellow("Hint:")} ${resp.note}`);
270
+ }
268
271
  out.push("");
269
272
 
270
273
  const w = termWidth();
@@ -1092,7 +1095,8 @@ async function handleNumberSelect(idx: number): Promise<void> {
1092
1095
  async function doSearch(query: string): Promise<void> {
1093
1096
  const resp = searchPages(query);
1094
1097
  if (resp.results.length === 0) {
1095
- console.log(` ${dim("No results.")} Try: ${cyan("props")} ${query}, ${cyan("cal")} ${query}, ${cyan("vid")} ${query}`);
1098
+ const extra = resp.note ? ` ${yellow("Hint:")} ${resp.note}` : "";
1099
+ console.log(` ${dim("No results.")} Try: ${cyan("props")} ${query}, ${cyan("cal")} ${query}, ${cyan("vid")} ${query}${extra}`);
1096
1100
  return;
1097
1101
  }
1098
1102
  await paged(renderSearchResults(resp));
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseDudePage } from "./extract-dude.ts";
5
+
6
+ const probesHtml = readFileSync(join(import.meta.dirname, "..", "dude", "pages", "Probes.html"), "utf-8");
7
+
8
+ describe("parseDudePage", () => {
9
+ test("uses the inner article body instead of wiki chrome", () => {
10
+ const parsed = parseDudePage(probesHtml, "https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes");
11
+ expect(parsed.text).not.toContain("Jump to navigation");
12
+ expect(parsed.text).not.toContain("Jump to search");
13
+ expect(parsed.text).not.toContain("Retrieved from");
14
+ expect(parsed.text).not.toContain("FILE ARCHIVED ON");
15
+ expect(parsed.text).not.toContain("Contents");
16
+ expect(parsed.text).toContain("The Probes pane shows the available methods");
17
+ });
18
+
19
+ test("extracts useful inline and block code snippets", () => {
20
+ const parsed = parseDudePage(probesHtml, "https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes");
21
+ expect(parsed.code).toContain("Custom_Voltage_Function()");
22
+ expect(parsed.code).toContain('if((oid("1.3.6.1.4.1.14988.1.1.3.8.0")>19),"1", "0")');
23
+ });
24
+
25
+ test("keeps content images but skips wiki UI icons", () => {
26
+ const parsed = parseDudePage(probesHtml, "https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes");
27
+ expect(parsed.images.some((img) => img.filename === "Version.png")).toBeFalse();
28
+ expect(parsed.images.some((img) => img.filename === "Dude-probes-all.JPG")).toBeTrue();
29
+ });
30
+ });
@@ -44,7 +44,7 @@ interface PageDef {
44
44
  // ── Page list (from CDX API enumeration) ──
45
45
 
46
46
  const V6_PAGES: PageDef[] = [
47
- { wikiPath: "Manual:The_Dude", slug: "The_Dude", path: "The Dude", version: "v6" },
47
+ // Note: Manual:The_Dude and Manual:The_Dude_v6 are wiki stubs (no content)
48
48
  { wikiPath: "Manual:The_Dude_v6/Installation", slug: "Installation", path: "The Dude > v6 > Installation", version: "v6" },
49
49
  { wikiPath: "Manual:The_Dude_v6/First_use", slug: "First_use", path: "The Dude > v6 > First Use", version: "v6" },
50
50
  { wikiPath: "Manual:The_Dude_v6/Interface", slug: "Interface", path: "The Dude > v6 > Interface", version: "v6" },
@@ -90,8 +90,7 @@ const V3_PAGES: PageDef[] = [
90
90
  { wikiPath: "Manual:The_Dude/Interface", slug: "v3_Interface", path: "The Dude > v3/v4 > Interface", version: "v3" },
91
91
  { wikiPath: "Manual:The_Dude/Device_settings", slug: "v3_Device_settings", path: "The Dude > v3/v4 > Device Settings", version: "v3" },
92
92
  { wikiPath: "Manual:The_Dude/Device_discovery", slug: "v3_Device_discovery", path: "The Dude > v3/v4 > Device Discovery", version: "v3" },
93
- { wikiPath: "Manual:The_Dude/Device_map", slug: "v3_Device_map", path: "The Dude > v3/v4 > Device Map", version: "v3" },
94
- { wikiPath: "Manual:The_Dude/Device_list", slug: "v3_Device_list", path: "The Dude > v3/v4 > Device List", version: "v3" },
93
+ // Note: Manual:The_Dude/Device_map and Manual:The_Dude/Device_list are wiki stubs (no content)
95
94
  { wikiPath: "Manual:The_Dude/Networks", slug: "v3_Networks", path: "The Dude > v3/v4 > Networks", version: "v3" },
96
95
  { wikiPath: "Manual:The_Dude/Links", slug: "v3_Links", path: "The Dude > v3/v4 > Links", version: "v3" },
97
96
  { wikiPath: "Manual:The_Dude/Agents", slug: "v3_Agents", path: "The Dude > v3/v4 > Agents", version: "v3" },
@@ -161,7 +160,44 @@ interface ParsedPage {
161
160
  images: Array<{ filename: string; altText: string | null; originalUrl: string; waybackImageUrl: string }>;
162
161
  }
163
162
 
164
- function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
163
+ const DUDE_NOISE_SELECTORS = [
164
+ "#toc",
165
+ ".toc",
166
+ ".mw-editsection",
167
+ ".navbox",
168
+ "#catlinks",
169
+ ".printfooter",
170
+ "#wm-ipp-base",
171
+ "#wm-ipp",
172
+ "#donato",
173
+ ".wb-autocomplete-suggestions",
174
+ "#mw-navigation",
175
+ "#footer",
176
+ ".noprint",
177
+ "#jump-to-nav",
178
+ ".mw-jump-link",
179
+ "#siteSub",
180
+ "#contentSub",
181
+ "#contentSub2",
182
+ "script",
183
+ "style",
184
+ "noscript",
185
+ ] as const;
186
+
187
+ function removeNoise(root: ParentNode): void {
188
+ for (const sel of DUDE_NOISE_SELECTORS) {
189
+ for (const el of root.querySelectorAll(sel)) el.remove();
190
+ }
191
+ }
192
+
193
+ function getPrimaryContent(document: Document): Element | null {
194
+ return document.querySelector("#mw-content-text .mw-parser-output")
195
+ ?? document.querySelector("#mw-content-text")
196
+ ?? document.querySelector("#bodyContent .mw-parser-output")
197
+ ?? document.querySelector("#bodyContent");
198
+ }
199
+
200
+ export function parseDudePage(html: string, _waybackBaseUrl: string): ParsedPage {
165
201
  const { document } = parseHTML(html);
166
202
 
167
203
  // Title from first h1 or page heading
@@ -172,21 +208,17 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
172
208
  .replace(/_/g, " ") ?? "Unknown";
173
209
 
174
210
  // Main content area
175
- const content = document.querySelector("#mw-content-text, .mw-parser-output, #bodyContent");
211
+ const content = getPrimaryContent(document);
176
212
  if (!content) return { title, text: "", code: "", lastEdited: null, images: [] };
177
213
 
178
- // Remove navigation, TOC, edit links, Wayback Machine toolbar
179
- for (const sel of ["#toc", ".toc", ".mw-editsection", ".navbox", "#catlinks",
180
- ".printfooter", "#wm-ipp-base", "#wm-ipp", "#donato", ".wb-autocomplete-suggestions",
181
- "#mw-navigation", "#footer", ".noprint"]) {
182
- for (const el of content.querySelectorAll(sel)) el.remove();
183
- }
184
-
185
214
  // Extract code blocks
186
215
  const codeBlocks: string[] = [];
187
- for (const pre of content.querySelectorAll("pre, code.ros")) {
216
+ const seenCodeBlocks = new Set<string>();
217
+ for (const pre of content.querySelectorAll("pre, code")) {
188
218
  const text = pre.textContent?.trim();
189
- if (text && text.length > 10) codeBlocks.push(text);
219
+ if (!text || text.length < 6 || seenCodeBlocks.has(text)) continue;
220
+ seenCodeBlocks.add(text);
221
+ codeBlocks.push(text);
190
222
  }
191
223
 
192
224
  // Extract images
@@ -195,6 +227,8 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
195
227
  for (const img of content.querySelectorAll("img")) {
196
228
  const src = img.getAttribute("src") ?? "";
197
229
  const alt = img.getAttribute("alt") ?? null;
230
+ const parentA = img.closest("a");
231
+ const href = parentA?.getAttribute("href") ?? "";
198
232
 
199
233
  // Skip tiny icons, navigation icons, and Wayback Machine UI images
200
234
  const width = Number.parseInt(img.getAttribute("width") ?? "0", 10);
@@ -204,21 +238,17 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
204
238
  // Extract filename from MediaWiki image URLs
205
239
  // Patterns: /images/X/XX/Filename.JPG or /wiki/File:Filename.JPG
206
240
  let filename: string | null = null;
241
+ const fileMatch = href.match(/File:([^&"]+)/);
242
+ if (fileMatch) filename = decodeURIComponent(fileMatch[1]);
243
+
207
244
  const srcMatch = src.match(/\/images\/[0-9a-f]\/[0-9a-f]{2}\/([^/?]+)/i)
208
245
  ?? src.match(/\/([^/]+\.(?:jpg|jpeg|png|gif|svg))(?:\?|$)/i);
209
246
  if (srcMatch) {
210
- filename = decodeURIComponent(srcMatch[1]);
211
- }
212
-
213
- // Also check parent <a> links for File: references
214
- if (!filename) {
215
- const parentA = img.closest("a");
216
- const href = parentA?.getAttribute("href") ?? "";
217
- const fileMatch = href.match(/File:([^&"]+)/);
218
- if (fileMatch) filename = decodeURIComponent(fileMatch[1]);
247
+ filename ??= decodeURIComponent(srcMatch[1]);
219
248
  }
220
249
 
221
250
  if (!filename) continue;
251
+ filename = filename.replace(/^\d+px-/, "");
222
252
  if (seenFilenames.has(filename)) continue;
223
253
  // Skip common wiki icons
224
254
  if (/^Icon-\w+\.png$/i.test(filename)) continue;
@@ -242,9 +272,17 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
242
272
  }
243
273
 
244
274
  // Extract body text
245
- const textContent = content.textContent ?? "";
275
+ const textRoot = content.cloneNode(true) as Element;
276
+ removeNoise(textRoot);
277
+ for (const img of textRoot.querySelectorAll("img")) img.remove();
278
+
279
+ const textContent = textRoot.textContent ?? "";
246
280
  // Clean up whitespace: collapse multiple blank lines, trim
247
- const text = textContent.replace(/\n{3,}/g, "\n\n").trim();
281
+ const text = textContent
282
+ .replace(/Retrieved from\s+"https?:\/\/[^\n"]+"/gi, "")
283
+ .replace(/FILE ARCHIVED ON[\s\S]*$/i, "")
284
+ .replace(/\n[ \t]*\n[ \t]*\n+/g, "\n\n")
285
+ .trim();
248
286
 
249
287
  // Last edited date from footer
250
288
  const lastEditedEl = document.querySelector("#footer-info-lastmod, .lastmod");
@@ -326,7 +364,7 @@ async function main() {
326
364
  }
327
365
 
328
366
  // Parse HTML
329
- const parsed = parsePage(html, wbUrl);
367
+ const parsed = parseDudePage(html, wbUrl);
330
368
  if (!parsed.text && !parsed.code) {
331
369
  console.log(` WARN: empty content for ${pageDef.slug}`);
332
370
  }
@@ -403,7 +441,9 @@ async function main() {
403
441
  console.log(`DB: ${stats.c} dude_pages, ${imgStats.c} dude_images`);
404
442
  }
405
443
 
406
- main().catch((e) => {
407
- console.error("Fatal:", e);
408
- process.exit(1);
409
- });
444
+ if (import.meta.main) {
445
+ main().catch((e) => {
446
+ console.error("Fatal:", e);
447
+ process.exit(1);
448
+ });
449
+ }
package/src/mcp.ts CHANGED
@@ -462,6 +462,7 @@ Workflow — what to do next:
462
462
 
463
463
  Tips:
464
464
  - Use specific technical terms: "DHCP relay agent" not "how to set up DHCP"
465
+ - For retired Dude GUI or wiki topics, prefer routeros_dude_search; routeros_search covers current RouterOS v7 docs
465
466
  - Documentation: 317 pages from March 2026 Confluence export (~515K words)
466
467
  - Docs reflect the then-current long-term release (~7.22), not version-pinned
467
468
  - Command data: RouterOS 7.9–7.23beta2. No v6 data available.
@@ -990,14 +991,26 @@ server.registerTool(
990
991
  Returns the complete page text, code blocks, and a list of GUI screenshots with local file paths.
991
992
  Screenshots are downloaded images from the archived wiki — use a file viewer for multimodal analysis.
992
993
 
994
+ max_length defaults to 16000. If the page text+code exceeds it, content is truncated and a
995
+ truncated field shows the original lengths. Dude pages are generally small (< 12K chars)
996
+ so truncation is uncommon.
997
+
993
998
  → routeros_dude_search: find pages by topic
994
999
  → routeros_command_tree: browse /dude commands in current RouterOS`,
995
1000
  inputSchema: {
996
1001
  id: z.union([z.number().int(), z.string()]).describe("Page ID (number) or title/slug (string)"),
1002
+ max_length: z
1003
+ .number()
1004
+ .int()
1005
+ .min(1000)
1006
+ .max(200000)
1007
+ .optional()
1008
+ .default(16000)
1009
+ .describe("Max combined text+code characters to return (default: 16000)."),
997
1010
  },
998
1011
  },
999
- async ({ id }) => {
1000
- const page = getDudePage(typeof id === "string" && /^\d+$/.test(id) ? Number.parseInt(id, 10) : id);
1012
+ async ({ id, max_length }) => {
1013
+ const page = getDudePage(typeof id === "string" && /^\d+$/.test(id) ? Number.parseInt(id, 10) : id, max_length);
1001
1014
  if (!page) {
1002
1015
  return {
1003
1016
  content: [
package/src/query.test.ts CHANGED
@@ -1770,6 +1770,11 @@ describe("searchDude", () => {
1770
1770
  const results = searchDude("dude", 1);
1771
1771
  expect(results.length).toBeLessThanOrEqual(1);
1772
1772
  });
1773
+
1774
+ test("searchPages adds a Dude-specific hint for explicit Dude queries", () => {
1775
+ const results = searchPages("the dude probes");
1776
+ expect(results.note).toContain("routeros_dude_search");
1777
+ });
1773
1778
  });
1774
1779
 
1775
1780
  // ---------------------------------------------------------------------------
@@ -1780,22 +1785,22 @@ describe("getDudePage", () => {
1780
1785
  test("returns page by ID with images", () => {
1781
1786
  const page = getDudePage(1);
1782
1787
  expect(page).not.toBeNull();
1783
- expect(page!.title).toBe("Probes");
1784
- expect(page!.images.length).toBe(2);
1785
- expect(page!.images[0].filename).toBe("Dude-probes-all.JPG");
1788
+ expect(page?.title).toBe("Probes");
1789
+ expect(page?.images.length).toBe(2);
1790
+ expect(page?.images[0]?.filename).toBe("Dude-probes-all.JPG");
1786
1791
  });
1787
1792
 
1788
1793
  test("returns page by title", () => {
1789
1794
  const page = getDudePage("Probes");
1790
1795
  expect(page).not.toBeNull();
1791
- expect(page!.id).toBe(1);
1796
+ expect(page?.id).toBe(1);
1792
1797
  });
1793
1798
 
1794
1799
  test("returns page by slug", () => {
1795
1800
  const page = getDudePage("Device_discovery");
1796
1801
  expect(page).not.toBeNull();
1797
- expect(page!.title).toBe("Device Discovery");
1798
- expect(page!.images.length).toBe(1);
1802
+ expect(page?.title).toBe("Device Discovery");
1803
+ expect(page?.images.length).toBe(1);
1799
1804
  });
1800
1805
 
1801
1806
  test("returns null for non-existent page", () => {
package/src/query.ts CHANGED
@@ -24,6 +24,7 @@ export type SearchResponse = {
24
24
  fallbackMode: "or" | null;
25
25
  results: SearchResult[];
26
26
  total: number;
27
+ note?: string;
27
28
  };
28
29
 
29
30
  type CsvScalar = string | number | null;
@@ -88,6 +89,14 @@ const COMPOUND_TERMS: [string, string][] = [
88
89
  ["address", "list"],
89
90
  ];
90
91
 
92
+ function getSpecialSearchHint(question: string): string | undefined {
93
+ const normalized = question.toLowerCase();
94
+ if (/(?:^|\b)(?:the\s+)?dude(?:\b|$)/.test(normalized)) {
95
+ return 'This looks like a Dude query. For retired Dude GUI or wiki topics, use routeros_dude_search. For current RouterOS CLI and server paths, use routeros_command_tree with path "/dude".';
96
+ }
97
+ return undefined;
98
+ }
99
+
91
100
  function escapeCsv(value: CsvScalar): string {
92
101
  if (value === null) return "";
93
102
  const text = String(value);
@@ -141,8 +150,9 @@ export function buildFtsQuery(terms: string[], mode: "AND" | "OR"): string {
141
150
 
142
151
  export function searchPages(question: string, limit = DEFAULT_LIMIT): SearchResponse {
143
152
  const terms = extractTerms(question);
153
+ const note = getSpecialSearchHint(question);
144
154
  if (terms.length === 0) {
145
- return { query: question, ftsQuery: "", fallbackMode: null, results: [], total: 0 };
155
+ return { query: question, ftsQuery: "", fallbackMode: null, results: [], total: 0, ...(note ? { note } : {}) };
146
156
  }
147
157
 
148
158
  // Try AND first
@@ -160,7 +170,7 @@ export function searchPages(question: string, limit = DEFAULT_LIMIT): SearchResp
160
170
 
161
171
  attachBestSections(results, terms);
162
172
 
163
- return { query: question, ftsQuery, fallbackMode, results, total: results.length };
173
+ return { query: question, ftsQuery, fallbackMode, results, total: results.length, ...(note ? { note } : {}) };
164
174
  }
165
175
 
166
176
  function runFtsQuery(ftsQuery: string, limit: number): SearchResult[] {
@@ -1566,6 +1576,7 @@ export type DudePageResult = {
1566
1576
  last_edited: string | null;
1567
1577
  word_count: number | null;
1568
1578
  images: DudeImageResult[];
1579
+ truncated?: { text_total: number; code_total: number };
1569
1580
  };
1570
1581
 
1571
1582
  /** Search archived Dude wiki pages via FTS, with AND→OR fallback. */
@@ -1596,7 +1607,9 @@ function runDudeFtsQuery(ftsQuery: string, limit: number): DudeSearchResult[] {
1596
1607
  FROM dude_pages_fts fts
1597
1608
  JOIN dude_pages dp ON dp.id = fts.rowid
1598
1609
  WHERE dude_pages_fts MATCH ?
1599
- ORDER BY rank
1610
+ ORDER BY bm25(dude_pages_fts, 3.0, 2.0, 1.0, 0.5),
1611
+ CASE dp.version WHEN 'v6' THEN 0 ELSE 1 END,
1612
+ dp.title
1600
1613
  LIMIT ?`,
1601
1614
  )
1602
1615
  .all(ftsQuery, limit) as DudeSearchResult[];
@@ -1605,8 +1618,9 @@ function runDudeFtsQuery(ftsQuery: string, limit: number): DudeSearchResult[] {
1605
1618
  }
1606
1619
  }
1607
1620
 
1608
- /** Get a Dude wiki page by ID or title, with associated images. */
1609
- export function getDudePage(idOrTitle: number | string): DudePageResult | null {
1621
+ /** Get a Dude wiki page by ID or title, with associated images.
1622
+ * maxLength: if set, truncates text+code combined and adds a truncated field. */
1623
+ export function getDudePage(idOrTitle: number | string, maxLength?: number): DudePageResult | null {
1610
1624
  const row =
1611
1625
  typeof idOrTitle === "number"
1612
1626
  ? db.prepare("SELECT * FROM dude_pages WHERE id = ?").get(idOrTitle)
@@ -1616,6 +1630,21 @@ export function getDudePage(idOrTitle: number | string): DudePageResult | null {
1616
1630
  page.images = db
1617
1631
  .prepare("SELECT id, filename, alt_text, caption, local_path, original_url, wayback_url FROM dude_images WHERE page_id = ? ORDER BY sort_order")
1618
1632
  .all(page.id) as DudeImageResult[];
1633
+
1634
+ if (maxLength) {
1635
+ const textLen = page.text.length;
1636
+ const codeLen = page.code?.length ?? 0;
1637
+ if (textLen + codeLen > maxLength) {
1638
+ const codeBudget = Math.min(codeLen, Math.floor(maxLength * 0.2));
1639
+ const textBudget = maxLength - codeBudget;
1640
+ page.truncated = { text_total: textLen, code_total: codeLen };
1641
+ page.text = textLen > textBudget ? `${page.text.slice(0, textBudget)}\n\n[... truncated — ${textLen} chars total, showing first ${textBudget}]` : page.text;
1642
+ if (page.code && codeLen > codeBudget) {
1643
+ page.code = `${page.code.slice(0, codeBudget)}\n# [... truncated — ${codeLen} chars total]`;
1644
+ }
1645
+ }
1646
+ }
1647
+
1619
1648
  return page;
1620
1649
  }
1621
1650