@tikoci/rosetta 0.6.2 → 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.
- package/package.json +1 -1
- package/src/browse.ts +4 -0
- package/src/canonicalize.test.ts +520 -0
- package/src/canonicalize.ts +629 -0
- package/src/extract-changelogs.test.ts +24 -0
- package/src/extract-changelogs.ts +17 -4
- package/src/extract-html.test.ts +30 -6
- package/src/extract-html.ts +78 -2
- package/src/mcp.ts +60 -56
- package/src/query.test.ts +18 -0
- package/src/query.ts +40 -0
- package/src/release.test.ts +5 -0
- package/src/setup.ts +3 -1
package/src/extract-html.test.ts
CHANGED
|
@@ -51,17 +51,41 @@ after
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
describe("extractPlainText", () => {
|
|
54
|
-
it("
|
|
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("
|
|
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
|
-
|
|
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
|
});
|
package/src/extract-html.ts
CHANGED
|
@@ -123,13 +123,17 @@ export function sanitizeExtractedText(input: string): string {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
* Convert a DOM subtree to readable
|
|
127
|
-
*
|
|
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
|
@@ -46,6 +46,62 @@ function link(url: string, display?: string): string {
|
|
|
46
46
|
return `\x1b]8;;${url}\x07${display ?? url}\x1b]8;;\x07`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Ensure the DB exists, has page data, and matches the current schema version.
|
|
51
|
+
* This must run before importing db.ts/query.ts to avoid creating an empty DB file
|
|
52
|
+
* on fresh installs.
|
|
53
|
+
*/
|
|
54
|
+
async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
55
|
+
const { resolveDbPath, SCHEMA_VERSION } = await import("./paths.ts");
|
|
56
|
+
const { downloadDb } = await import("./setup.ts");
|
|
57
|
+
|
|
58
|
+
const dbPath = resolveDbPath(import.meta.dirname);
|
|
59
|
+
|
|
60
|
+
const pageCount = (() => {
|
|
61
|
+
try {
|
|
62
|
+
const check = new (require("bun:sqlite").default)(dbPath, { readonly: true });
|
|
63
|
+
const row = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
|
|
64
|
+
check.close();
|
|
65
|
+
return row.c;
|
|
66
|
+
} catch {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
|
|
71
|
+
if (pageCount === 0) {
|
|
72
|
+
try {
|
|
73
|
+
await downloadDb(dbPath, log);
|
|
74
|
+
log("Database downloaded successfully.");
|
|
75
|
+
} catch (e) {
|
|
76
|
+
log(`Auto-download failed: ${e}`);
|
|
77
|
+
log(`Run: ${process.argv[0]} --setup`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const dbSchemaVersion = (() => {
|
|
83
|
+
try {
|
|
84
|
+
const check = new (require("bun:sqlite").default)(dbPath, { readonly: true });
|
|
85
|
+
const row = check.prepare("PRAGMA user_version").get() as { user_version: number };
|
|
86
|
+
check.close();
|
|
87
|
+
return row.user_version;
|
|
88
|
+
} catch {
|
|
89
|
+
return SCHEMA_VERSION;
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
if (dbSchemaVersion !== SCHEMA_VERSION) {
|
|
94
|
+
log(`DB schema version mismatch (DB=${dbSchemaVersion}, expected=${SCHEMA_VERSION}) - re-downloading updated database...`);
|
|
95
|
+
try {
|
|
96
|
+
await downloadDb(dbPath, log);
|
|
97
|
+
log("Database updated successfully.");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
log(`Auto-download failed: ${e}`);
|
|
100
|
+
log(`Run: ${process.argv[0]} --refresh`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
49
105
|
if (args.includes("--version") || args.includes("-v")) {
|
|
50
106
|
console.log(`rosetta ${RESOLVED_VERSION}`);
|
|
51
107
|
process.exit(0);
|
|
@@ -85,6 +141,7 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
85
141
|
(async () => {
|
|
86
142
|
|
|
87
143
|
if (args[0] === "browse") {
|
|
144
|
+
await ensureDbReady((msg) => process.stderr.write(`${msg}\n`));
|
|
88
145
|
// Strip "browse" from argv so browse.ts only sees flags/queries
|
|
89
146
|
process.argv.splice(2, 1);
|
|
90
147
|
await import("./browse.ts");
|
|
@@ -113,62 +170,7 @@ const { z } = await import("zod/v3");
|
|
|
113
170
|
// Dynamic imports — db.ts eagerly opens the DB file on import,
|
|
114
171
|
// so we must import after the --setup guard to avoid creating
|
|
115
172
|
// an empty ros-help.db on fresh installs.
|
|
116
|
-
|
|
117
|
-
// Check if DB has data BEFORE importing db.ts. If empty/missing,
|
|
118
|
-
// auto-download so db.ts opens the real database.
|
|
119
|
-
const { resolveDbPath, SCHEMA_VERSION } = await import("./paths.ts");
|
|
120
|
-
const _dbPath = resolveDbPath(import.meta.dirname);
|
|
121
|
-
|
|
122
|
-
const _pageCount = (() => {
|
|
123
|
-
try {
|
|
124
|
-
const check = new (require("bun:sqlite").default)(_dbPath, { readonly: true });
|
|
125
|
-
const row = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
|
|
126
|
-
check.close();
|
|
127
|
-
return row.c;
|
|
128
|
-
} catch {
|
|
129
|
-
return 0;
|
|
130
|
-
}
|
|
131
|
-
})();
|
|
132
|
-
|
|
133
|
-
if (_pageCount === 0) {
|
|
134
|
-
const { downloadDb } = await import("./setup.ts");
|
|
135
|
-
// Use stderr — stdout is the MCP stdio transport
|
|
136
|
-
const log = (msg: string) => process.stderr.write(`${msg}\n`);
|
|
137
|
-
try {
|
|
138
|
-
await downloadDb(_dbPath, log);
|
|
139
|
-
log("Database downloaded successfully.");
|
|
140
|
-
} catch (e) {
|
|
141
|
-
log(`Auto-download failed: ${e}`);
|
|
142
|
-
log(`Run: ${process.argv[0]} --setup`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Check schema version — a bunx auto-update may bring a new code version whose
|
|
147
|
-
// schema is incompatible with the existing ~/.rosetta/ros-help.db.
|
|
148
|
-
// MUST be checked before importing db.ts, because initDb() stamps user_version.
|
|
149
|
-
const _dbSchemaVersion = (() => {
|
|
150
|
-
try {
|
|
151
|
-
const check = new (require("bun:sqlite").default)(_dbPath, { readonly: true });
|
|
152
|
-
const row = check.prepare("PRAGMA user_version").get() as { user_version: number };
|
|
153
|
-
check.close();
|
|
154
|
-
return row.user_version;
|
|
155
|
-
} catch {
|
|
156
|
-
return SCHEMA_VERSION; // unreadable — assume ok, initDb() will stamp it
|
|
157
|
-
}
|
|
158
|
-
})();
|
|
159
|
-
|
|
160
|
-
if (_dbSchemaVersion !== SCHEMA_VERSION) {
|
|
161
|
-
const { downloadDb } = await import("./setup.ts");
|
|
162
|
-
const log = (msg: string) => process.stderr.write(`${msg}\n`);
|
|
163
|
-
log(`DB schema version mismatch (DB=${_dbSchemaVersion}, expected=${SCHEMA_VERSION}) — re-downloading updated database...`);
|
|
164
|
-
try {
|
|
165
|
-
await downloadDb(_dbPath, log);
|
|
166
|
-
log("Database updated successfully.");
|
|
167
|
-
} catch (e) {
|
|
168
|
-
log(`Auto-download failed: ${e}`);
|
|
169
|
-
log(`Run: ${process.argv[0]} --refresh`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
173
|
+
await ensureDbReady((msg) => process.stderr.write(`${msg}\n`));
|
|
172
174
|
|
|
173
175
|
// Now import db.ts (opens the DB) and query.ts
|
|
174
176
|
const { db, getDbStats, initDb } = await import("./db.ts");
|
|
@@ -203,6 +205,8 @@ function createServer() {
|
|
|
203
205
|
const server = new McpServer({
|
|
204
206
|
name: "rosetta",
|
|
205
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.",
|
|
206
210
|
});
|
|
207
211
|
|
|
208
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;
|
package/src/release.test.ts
CHANGED
|
@@ -324,6 +324,11 @@ describe("CLI flags", () => {
|
|
|
324
324
|
test("supports --refresh flag", () => {
|
|
325
325
|
expect(src).toContain("--refresh");
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
test("browse mode bootstraps database before importing browse.ts", () => {
|
|
329
|
+
expect(src).toContain('if (args[0] === "browse")');
|
|
330
|
+
expect(src).toContain("await ensureDbReady");
|
|
331
|
+
});
|
|
327
332
|
});
|
|
328
333
|
|
|
329
334
|
// ---------------------------------------------------------------------------
|
package/src/setup.ts
CHANGED
|
@@ -81,7 +81,9 @@ export async function runSetup(force = false) {
|
|
|
81
81
|
console.log();
|
|
82
82
|
try {
|
|
83
83
|
const { default: sqlite } = await import("bun:sqlite");
|
|
84
|
-
|
|
84
|
+
// Note: open in read-write mode (not readonly) to allow WAL checkpoint.
|
|
85
|
+
// WAL-mode databases can fail to initialize properly in readonly mode.
|
|
86
|
+
const db = new sqlite(dbPath);
|
|
85
87
|
const row = db.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
|
|
86
88
|
const cmdRow = db.prepare("SELECT COUNT(*) AS c FROM commands WHERE type='cmd'").get() as { c: number };
|
|
87
89
|
const versionRow = db.prepare("PRAGMA user_version").get() as { user_version: number };
|