@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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/browse.ts +143 -188
- package/src/db.ts +146 -7
- package/src/extract-commands.ts +55 -10
- package/src/extract-devices.ts +5 -1
- package/src/extract-dude.ts +409 -0
- package/src/extract-html.test.ts +67 -0
- package/src/extract-html.ts +232 -112
- package/src/mcp-http.test.ts +7 -5
- package/src/mcp.ts +280 -15
- package/src/paths.ts +1 -1
- package/src/query.test.ts +124 -6
- package/src/query.ts +115 -20
- package/src/release.test.ts +20 -0
package/src/extract-html.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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-http.test.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
551
|
-
expect(tools2.length).toBe(
|
|
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(
|
|
575
|
+
expect(tools.length).toBe(16);
|
|
574
576
|
|
|
575
577
|
// Client1 is gone
|
|
576
578
|
const resp = await fetch(server.url, {
|