@tikoci/rosetta 0.6.3 → 0.6.4

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.
@@ -51,17 +51,41 @@ after
51
51
  });
52
52
 
53
53
  describe("extractPlainText", () => {
54
- it("keeps heading and paragraph separated", () => {
54
+ it("emits heading markers", () => {
55
55
  const { document } = parseHTML("<div><h1>Basic Setup</h1><p>This is the basic VRRP configuration example.</p></div>");
56
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");
57
+ expect(out).toContain("# Basic Setup\nThis is the basic VRRP configuration example.");
59
58
  });
60
59
 
61
- it("keeps subsection heading boundaries", () => {
60
+ it("emits h2 markers for subsections", () => {
62
61
  const { document } = parseHTML("<div><h2>Overview</h2><p>The Point-to-Point Protocol (PPP) provides...</p></div>");
63
62
  const out = extractPlainText(document.querySelector("div"));
64
- expect(out).toContain("Overview\nThe Point-to-Point Protocol (PPP) provides...");
65
- expect(out).not.toContain("OverviewThe");
63
+ expect(out).toContain("## Overview\nThe Point-to-Point Protocol (PPP) provides...");
64
+ });
65
+
66
+ it("emits list markers for ul", () => {
67
+ const { document } = parseHTML("<div><ul><li>First item</li><li>Second item</li></ul></div>");
68
+ const out = extractPlainText(document.querySelector("div"));
69
+ expect(out).toContain("- First item");
70
+ expect(out).toContain("- Second item");
71
+ });
72
+
73
+ it("emits numbered markers for ol", () => {
74
+ const { document } = parseHTML("<div><ol><li>Step one</li><li>Step two</li></ol></div>");
75
+ const out = extractPlainText(document.querySelector("div"));
76
+ expect(out).toContain("1. Step one");
77
+ expect(out).toContain("2. Step two");
78
+ });
79
+
80
+ it("wraps strong/b in **bold**", () => {
81
+ const { document } = parseHTML("<div><p>The <strong>disabled</strong> property controls this.</p></div>");
82
+ const out = extractPlainText(document.querySelector("div"));
83
+ expect(out).toContain("**disabled**");
84
+ });
85
+
86
+ it("wraps code in backticks", () => {
87
+ const { document } = parseHTML("<div><p>Use <code>/ip/address</code> to configure.</p></div>");
88
+ const out = extractPlainText(document.querySelector("div"));
89
+ expect(out).toContain("`/ip/address`");
66
90
  });
67
91
  });
@@ -123,13 +123,17 @@ export function sanitizeExtractedText(input: string): string {
123
123
  }
124
124
 
125
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.
126
+ * Convert a DOM subtree to readable text with lightweight markdown signals.
127
+ * Preserves block boundaries (avoids "OverviewThe" token merge) and emits:
128
+ * - Heading markers: ## Heading (matching h1–h6 level)
129
+ * - List markers: - item (ul) or 1. item (ol)
130
+ * - Emphasis: **bold** for <strong>/<b>, `code` for <code>
128
131
  */
129
132
  export function extractPlainText(root: Element | null): string {
130
133
  if (!root) return "";
131
134
 
132
135
  let out = "";
136
+ let olCounter = 0;
133
137
 
134
138
  const addBreak = () => {
135
139
  if (!out) return;
@@ -160,6 +164,78 @@ export function extractPlainText(root: Element | null): string {
160
164
  return;
161
165
  }
162
166
 
167
+ // Heading markers: ## Heading
168
+ const headingMatch = tag.match(/^h([1-6])$/);
169
+ if (headingMatch) {
170
+ addBreak();
171
+ const level = Number.parseInt(headingMatch[1], 10);
172
+ const prefix = "#".repeat(level);
173
+ const headingText = (el.textContent || "").replace(/\s+/g, " ").trim();
174
+ if (headingText) {
175
+ addText(`${prefix} ${headingText}`);
176
+ }
177
+ addBreak();
178
+ return; // don't recurse into heading children — already captured via textContent
179
+ }
180
+
181
+ // Ordered list: reset counter
182
+ if (tag === "ol") {
183
+ const savedCounter = olCounter;
184
+ olCounter = 0;
185
+ addBreak();
186
+ for (const child of el.childNodes) {
187
+ walk(child as unknown as Node);
188
+ }
189
+ addBreak();
190
+ olCounter = savedCounter;
191
+ return;
192
+ }
193
+
194
+ // Unordered list
195
+ if (tag === "ul") {
196
+ addBreak();
197
+ for (const child of el.childNodes) {
198
+ walk(child as unknown as Node);
199
+ }
200
+ addBreak();
201
+ return;
202
+ }
203
+
204
+ // List item: prefix with - or N.
205
+ if (tag === "li") {
206
+ addBreak();
207
+ const parentTag = (el.parentElement?.tagName || "").toLowerCase();
208
+ if (parentTag === "ol") {
209
+ olCounter++;
210
+ addText(`${olCounter}.`);
211
+ } else {
212
+ addText("-");
213
+ }
214
+ for (const child of el.childNodes) {
215
+ walk(child as unknown as Node);
216
+ }
217
+ addBreak();
218
+ return;
219
+ }
220
+
221
+ // Inline emphasis: **bold**
222
+ if (tag === "strong" || tag === "b") {
223
+ const inner = (el.textContent || "").replace(/\s+/g, " ").trim();
224
+ if (inner) {
225
+ addText(`**${inner}**`);
226
+ }
227
+ return; // don't recurse — already captured
228
+ }
229
+
230
+ // Inline code: `code`
231
+ if (tag === "code") {
232
+ const inner = (el.textContent || "").replace(/\s+/g, " ").trim();
233
+ if (inner) {
234
+ addText(`\`${inner}\``);
235
+ }
236
+ return; // don't recurse — already captured
237
+ }
238
+
163
239
  const isBlock = BLOCK_TAGS.has(tag);
164
240
  if (isBlock) addBreak();
165
241
 
package/src/mcp.ts CHANGED
@@ -205,6 +205,8 @@ function createServer() {
205
205
  const server = new McpServer({
206
206
  name: "rosetta",
207
207
  version: RESOLVED_VERSION,
208
+ }, {
209
+ instructions: "RouterOS documentation search. Start with routeros_search for any RouterOS question — it searches across pages, callouts, and properties with BM25 ranking. Drill into specific pages with routeros_get_page (by ID or title). For device hardware specs use routeros_device_lookup; for version-specific command changes use routeros_command_diff. Only v7 data exists (7.9+) — v6 is out of scope and answers will be unreliable.",
208
210
  });
209
211
 
210
212
  server.registerResource(
package/src/query.test.ts CHANGED
@@ -505,6 +505,24 @@ describe("searchPages", () => {
505
505
  expect(res.ftsQuery).toBeTruthy();
506
506
  expect(res.query).toBe("dhcp server");
507
507
  });
508
+
509
+ test("returns best_section for pages with matching sections", () => {
510
+ // Page 3 (Bridging and Switching) has sections including "VLAN Setup" with VLAN content
511
+ const res = searchPages("bridge vlan");
512
+ const bridging = res.results.find((r) => r.id === 3);
513
+ expect(bridging).toBeDefined();
514
+ expect(bridging?.best_section).toBeDefined();
515
+ expect(bridging?.best_section?.heading).toBe("VLAN Setup");
516
+ expect(bridging?.best_section?.anchor_id).toBe("BridgingandSwitching-VLANSetup");
517
+ expect(bridging?.best_section?.url).toContain("#BridgingandSwitching-VLANSetup");
518
+ });
519
+
520
+ test("omits best_section for pages without sections", () => {
521
+ // Page 1 (DHCP Server) has no sections
522
+ const res = searchPages("dhcp lease");
523
+ expect(res.results.length).toBeGreaterThan(0);
524
+ expect(res.results[0].best_section).toBeUndefined();
525
+ });
508
526
  });
509
527
 
510
528
  // ---------------------------------------------------------------------------
package/src/query.ts CHANGED
@@ -15,6 +15,7 @@ export type SearchResult = {
15
15
  word_count: number;
16
16
  code_lines: number;
17
17
  excerpt: string;
18
+ best_section?: { heading: string; anchor_id: string; url: string };
18
19
  };
19
20
 
20
21
  export type SearchResponse = {
@@ -157,6 +158,8 @@ export function searchPages(question: string, limit = DEFAULT_LIMIT): SearchResp
157
158
  fallbackMode = "or";
158
159
  }
159
160
 
161
+ attachBestSections(results, terms);
162
+
160
163
  return { query: question, ftsQuery, fallbackMode, results, total: results.length };
161
164
  }
162
165
 
@@ -179,6 +182,43 @@ function runFtsQuery(ftsQuery: string, limit: number): SearchResult[] {
179
182
  }
180
183
  }
181
184
 
185
+ /** For each search result with sections, find the section whose text best matches the search terms. */
186
+ function attachBestSections(results: SearchResult[], terms: string[]): void {
187
+ if (results.length === 0 || terms.length === 0) return;
188
+
189
+ const stmt = db.prepare(
190
+ `SELECT heading, anchor_id, text FROM sections WHERE page_id = ? ORDER BY sort_order`,
191
+ );
192
+
193
+ for (const result of results) {
194
+ const sections = stmt.all(result.id) as Array<{ heading: string; anchor_id: string; text: string }>;
195
+ if (sections.length === 0) continue;
196
+
197
+ let bestSection: (typeof sections)[0] | null = null;
198
+ let bestScore = 0;
199
+
200
+ for (const sec of sections) {
201
+ const haystack = `${sec.heading} ${sec.text}`.toLowerCase();
202
+ let score = 0;
203
+ for (const term of terms) {
204
+ if (haystack.includes(term)) score++;
205
+ }
206
+ if (score > bestScore) {
207
+ bestScore = score;
208
+ bestSection = sec;
209
+ }
210
+ }
211
+
212
+ if (bestSection && bestScore > 0) {
213
+ result.best_section = {
214
+ heading: bestSection.heading,
215
+ anchor_id: bestSection.anchor_id,
216
+ url: `${result.url}#${bestSection.anchor_id}`,
217
+ };
218
+ }
219
+ }
220
+ }
221
+
182
222
  /** Section TOC entry returned when a large page would be truncated. */
183
223
  export type SectionTocEntry = {
184
224
  heading: string;