@tikoci/rosetta 0.5.1 → 0.6.0

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.
@@ -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
  }
@@ -108,6 +108,8 @@ function createFixtureDb(dbPath: string): void {
108
108
  1, '/system', 'system', 'dir', NULL, 1, 'fixture command', '7.22'
109
109
  );`);
110
110
 
111
+ // Stamp schema version so mcp.ts doesn't trigger a 50MB auto-download
112
+ fixture.run("PRAGMA user_version = 1;");
111
113
  fixture.close();
112
114
  }
113
115
 
@@ -315,7 +317,7 @@ describe("HTTP transport: session lifecycle", () => {
315
317
  expect(serverInfo.name).toBe("rosetta");
316
318
  });
317
319
 
318
- test("tools/list returns all 14 tools after initialization", async () => {
320
+ test("tools/list returns all 16 tools after initialization", async () => {
319
321
  const { sessionId } = await mcpInitialize(server.url);
320
322
 
321
323
  // Send initialized notification first (required by protocol)
@@ -326,7 +328,7 @@ describe("HTTP transport: session lifecycle", () => {
326
328
 
327
329
  const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
328
330
  const tools = result.tools as Array<{ name: string }>;
329
- expect(tools.length).toBe(14);
331
+ expect(tools.length).toBe(16);
330
332
 
331
333
  const toolNames = tools.map((t) => t.name).sort();
332
334
  expect(toolNames).toContain("routeros_search");
@@ -547,8 +549,8 @@ describe("HTTP transport: multi-session", () => {
547
549
  const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
548
550
  const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
549
551
 
550
- expect(tools1.length).toBe(14);
551
- expect(tools2.length).toBe(14);
552
+ expect(tools1.length).toBe(16);
553
+ expect(tools2.length).toBe(16);
552
554
  });
553
555
 
554
556
  test("deleting one session does not affect another", async () => {
@@ -570,7 +572,7 @@ describe("HTTP transport: multi-session", () => {
570
572
  // Client2 still works
571
573
  const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
572
574
  const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
573
- expect(tools.length).toBe(14);
575
+ expect(tools.length).toBe(16);
574
576
 
575
577
  // Client1 is gone
576
578
  const resp = await fetch(server.url, {