@tikoci/rosetta 0.6.9 → 0.7.1

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 CHANGED
@@ -275,22 +275,19 @@ Ask your AI assistant questions like:
275
275
 
276
276
  ## MCP Tools
277
277
 
278
- The server exposes 16 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
278
+ The server exposes 13 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
279
279
 
280
280
  | Tool | What it does |
281
281
  |------|-------------|
282
- | `routeros_search` | **Start here.** Full-text search across all documentation pages |
282
+ | `routeros_search` | **Start here.** Unified search with input classifier — returns pages + related callouts, videos, properties, changelogs, devices, skills |
283
283
  | `routeros_get_page` | Full page content by ID or title, section-aware for large pages |
284
284
  | `routeros_lookup_property` | Property by exact name — type, default, description |
285
- | `routeros_search_properties` | FTS across 4,860 property names and descriptions |
286
285
  | `routeros_command_tree` | Browse the command hierarchy (`/ip/firewall/filter` style) |
287
- | `routeros_search_callouts` | Search warnings, notes, and tips |
288
286
  | `routeros_search_changelogs` | Changelogs filtered by version range, category, breaking flag |
289
287
  | `routeros_command_version_check` | Which RouterOS versions include a command path |
290
288
  | `routeros_command_diff` | Added/removed commands between two RouterOS versions |
291
289
  | `routeros_device_lookup` | Hardware specs — filter by architecture, RAM, PoE, wireless, etc. |
292
290
  | `routeros_search_tests` | Cross-device ethernet and IPSec benchmarks |
293
- | `routeros_search_videos` | YouTube transcript search with chapter timestamps |
294
291
  | `routeros_dude_search` | FTS across archived Dude wiki docs (separate from RouterOS search) |
295
292
  | `routeros_dude_get_page` | Full Dude wiki page by ID or title, with screenshot metadata |
296
293
  | `routeros_stats` | Database health and coverage stats |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
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
@@ -20,7 +20,8 @@ import type {
20
20
  DeviceResult,
21
21
  DeviceTestRow,
22
22
  DudeSearchResult,
23
- SearchResponse,
23
+ GlossaryEntry,
24
+ SearchAllResponse,
24
25
  SearchResult,
25
26
  SectionTocEntry,
26
27
  VideoSearchResult,
@@ -35,14 +36,16 @@ import {
35
36
  getPage,
36
37
  getSkill,
37
38
  getTestResultMeta,
39
+ listGlossary,
38
40
  listSkills,
41
+ lookupGlossary,
39
42
  lookupProperty,
43
+ searchAll,
40
44
  searchCallouts,
41
45
  searchChangelogs,
42
46
  searchDevices,
43
47
  searchDeviceTests,
44
48
  searchDude,
45
- searchPages,
46
49
  searchProperties,
47
50
  searchVideos,
48
51
  } from "./query.ts";
@@ -192,7 +195,7 @@ function waitForKey(): Promise<string> {
192
195
 
193
196
  type Context =
194
197
  | { type: "home" }
195
- | { type: "search"; response: SearchResponse; results: SearchResult[] }
198
+ | { type: "search"; response: SearchAllResponse; results: SearchResult[] }
196
199
  | { type: "page"; pageId: number; title: string; commandPath?: string }
197
200
  | { type: "sections"; pageId: number; title: string; sections: SectionTocEntry[] }
198
201
  | { type: "commands"; path: string }
@@ -271,18 +274,22 @@ function renderWelcome(): string {
271
274
  return box(lines, "rosetta");
272
275
  }
273
276
 
274
- function renderSearchResults(resp: SearchResponse): string {
277
+ function renderSearchResults(resp: SearchAllResponse): string {
275
278
  const out: string[] = [];
276
- const modeNote = resp.fallbackMode === "or" ? dim(" (OR fallback)") : "";
277
- out.push(` ${bold(String(resp.results.length))} of ${resp.total} results for ${cyan(`"${resp.query}"`)}${modeNote}`);
279
+ const modeNote = resp.fallback_mode === "or" ? dim(" (OR fallback)") : "";
280
+ out.push(` ${bold(String(resp.pages.length))} of ${resp.total_pages} page results for ${cyan(`"${resp.query}"`)}${modeNote}`);
281
+
282
+ // Classifier signals — compact one-line summary when anything fired
283
+ const classLine = renderClassified(resp.classified);
284
+ if (classLine) out.push(` ${dim("classified:")} ${classLine}`);
278
285
  if (resp.note) {
279
286
  out.push(` ${yellow("Hint:")} ${resp.note}`);
280
287
  }
281
288
  out.push("");
282
289
 
283
290
  const w = termWidth();
284
- for (let i = 0; i < resp.results.length; i++) {
285
- const r = resp.results[i];
291
+ for (let i = 0; i < resp.pages.length; i++) {
292
+ const r = resp.pages[i];
286
293
  const num = dim(`${String(i + 1).padStart(3)} `);
287
294
  const title = bold(truncate(r.title, 30));
288
295
  const path = dim(truncate(r.path, Math.max(20, w - 55)));
@@ -301,6 +308,23 @@ function renderSearchResults(resp: SearchResponse): string {
301
308
  out.push("");
302
309
  }
303
310
 
311
+ // Related block — compact summary of side-query hits
312
+ const related = renderRelated(resp.related, w);
313
+ if (related.length > 0) {
314
+ out.push(` ${bold("related")}`);
315
+ out.push(...related);
316
+ out.push("");
317
+ }
318
+
319
+ // Next-steps block — always render the top 3 suggestions
320
+ if (resp.next_steps.length > 0) {
321
+ out.push(` ${bold("next")}`);
322
+ for (const step of resp.next_steps.slice(0, 3)) {
323
+ out.push(` ${dim("→")} ${step}`);
324
+ }
325
+ out.push("");
326
+ }
327
+
304
328
  // Navigation hints
305
329
  const hints = [
306
330
  `${cyan("[N]")} view page`,
@@ -312,6 +336,62 @@ function renderSearchResults(resp: SearchResponse): string {
312
336
  return out.join("\n");
313
337
  }
314
338
 
339
+ /** One-line summary of classifier output — empty string if nothing fired. */
340
+ function renderClassified(c: SearchAllResponse["classified"]): string {
341
+ const parts: string[] = [];
342
+ if (c.command_path) parts.push(`path=${cyan(c.command_path)}`);
343
+ if (c.version) parts.push(`version=${magenta(c.version)}`);
344
+ if (c.device) parts.push(`device=${yellow(c.device)}`);
345
+ if (c.property) parts.push(`property=${green(c.property)}`);
346
+ if (c.topics.length > 0) parts.push(`topics=[${c.topics.join(",")}]`);
347
+ if (c.command_fragment) {
348
+ if (c.command_fragment.verbs.length > 0) parts.push(`verbs=[${c.command_fragment.verbs.join(",")}]`);
349
+ if (c.command_fragment.pairs.length > 0) {
350
+ const pairs = c.command_fragment.pairs.map((p) => `${p.key}=${p.value}`).join(" ");
351
+ parts.push(`args={${pairs}}`);
352
+ }
353
+ }
354
+ return parts.join(" ");
355
+ }
356
+
357
+ /** Render related sections as one line each. */
358
+ function renderRelated(related: SearchAllResponse["related"], w: number): string[] {
359
+ const out: string[] = [];
360
+ if (related.command_node) {
361
+ const n = related.command_node;
362
+ const link = n.linked_page ? ` → page #${n.linked_page.id} "${n.linked_page.title}"` : "";
363
+ out.push(` ${dim("cmd")} ${cyan(n.path)} (${n.type})${link}`);
364
+ }
365
+ if (related.commands?.length) {
366
+ const names = related.commands.map((c) => c.name).join(", ");
367
+ out.push(` ${dim("children")} ${truncate(names, w - 16)}`);
368
+ }
369
+ if (related.properties?.length) {
370
+ const props = related.properties.map((p) => p.name).join(", ");
371
+ out.push(` ${dim("props")} ${truncate(props, w - 14)}`);
372
+ }
373
+ if (related.devices?.length) {
374
+ const devs = related.devices.map((d) => d.product_name).join(", ");
375
+ out.push(` ${dim("devices")} ${truncate(devs, w - 16)}`);
376
+ }
377
+ if (related.changelogs?.length) {
378
+ const first = related.changelogs[0];
379
+ out.push(` ${dim("changelog")} ${first.version} ${first.category}: ${truncate(first.description, w - 40)}`);
380
+ }
381
+ if (related.videos?.length) {
382
+ const v = related.videos[0];
383
+ out.push(` ${dim("video")} ${truncate(v.title, w - 14)}`);
384
+ }
385
+ if (related.callouts?.length) {
386
+ const c = related.callouts[0];
387
+ out.push(` ${dim("callout")} ${c.type}: ${truncate(c.excerpt, w - 20)}`);
388
+ }
389
+ if (related.skills?.length) {
390
+ out.push(` ${dim("skill")} ${related.skills[0].name}`);
391
+ }
392
+ return out;
393
+ }
394
+
315
395
  function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
316
396
  const out: string[] = [];
317
397
  const w = termWidth();
@@ -890,6 +970,7 @@ function renderHelp(): string {
890
970
  cmd("page <id|title>", "", "View full page", "routeros_get_page");
891
971
  cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
892
972
  cmd("props <query>", "sp", "Search properties by FTS", "routeros_search_properties");
973
+ cmd("glossary [term]", "g", "Look up RouterOS jargon / list glossary");
893
974
  cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
894
975
  cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
895
976
  cmd("tests [device] [type]", "", "Cross-device benchmarks", "routeros_search_tests");
@@ -1001,6 +1082,12 @@ async function dispatch(input: string): Promise<void> {
1001
1082
  return;
1002
1083
  }
1003
1084
 
1085
+ case "g":
1086
+ case "glossary": {
1087
+ await doGlossary(rest);
1088
+ return;
1089
+ }
1090
+
1004
1091
  case "cmd":
1005
1092
  case "tree": {
1006
1093
  const path = rest || (ctx.type === "commands" ? ctx.path : "");
@@ -1193,14 +1280,9 @@ async function handleNumberSelect(idx: number): Promise<void> {
1193
1280
  // ── Action functions ──
1194
1281
 
1195
1282
  async function doSearch(query: string): Promise<void> {
1196
- const resp = searchPages(query);
1197
- if (resp.results.length === 0) {
1198
- const extra = resp.note ? ` ${yellow("Hint:")} ${resp.note}` : "";
1199
- console.log(` ${dim("No results.")} Try: ${cyan("props")} ${query}, ${cyan("cal")} ${query}, ${cyan("vid")} ${query}${extra}`);
1200
- return;
1201
- }
1283
+ const resp = searchAll(query);
1202
1284
  await paged(renderSearchResults(resp));
1203
- pushCtx({ type: "search", response: resp, results: resp.results });
1285
+ pushCtx({ type: "search", response: resp, results: resp.pages });
1204
1286
  }
1205
1287
 
1206
1288
  async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
@@ -1265,6 +1347,44 @@ async function doSearchProperties(query: string): Promise<void> {
1265
1347
  pushCtx({ type: "properties", query, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
1266
1348
  }
1267
1349
 
1350
+ async function doGlossary(rest: string): Promise<void> {
1351
+ if (!rest) {
1352
+ // List all glossary entries grouped by category
1353
+ const entries = listGlossary();
1354
+ if (entries.length === 0) {
1355
+ console.log(dim(" Glossary is empty."));
1356
+ return;
1357
+ }
1358
+ const grouped = new Map<string, GlossaryEntry[]>();
1359
+ for (const e of entries) {
1360
+ const list = grouped.get(e.category) || [];
1361
+ list.push(e);
1362
+ grouped.set(e.category, list);
1363
+ }
1364
+ const lines: string[] = [` ${bold("Glossary")} ${dim(`(${entries.length} terms)`)}\n`];
1365
+ for (const [cat, items] of grouped) {
1366
+ lines.push(` ${yellow(cat.toUpperCase())}`);
1367
+ for (const e of items) {
1368
+ const aliases = e.aliases ? ` ${dim(`(${e.aliases})`)}` : "";
1369
+ lines.push(` ${cyan(e.term)}${aliases} — ${e.definition}`);
1370
+ }
1371
+ lines.push("");
1372
+ }
1373
+ await paged(lines.join("\n"));
1374
+ return;
1375
+ }
1376
+ // Look up a specific term
1377
+ const entry = lookupGlossary(rest);
1378
+ if (!entry) {
1379
+ console.log(dim(` Term "${rest}" not in glossary.`));
1380
+ console.log(` Try: ${cyan("glossary")} ${dim("(no args)")} to see all terms, or ${cyan("s")} ${rest}`);
1381
+ return;
1382
+ }
1383
+ const aliases = entry.aliases ? `\n ${dim("Aliases:")} ${entry.aliases}` : "";
1384
+ const hint = entry.search_hint ? `\n ${dim("Search:")} ${cyan(entry.search_hint)}` : "";
1385
+ console.log(`\n ${bold(entry.term)} ${dim(`[${entry.category}]`)}\n ${entry.definition}${aliases}${hint}\n`);
1386
+ }
1387
+
1268
1388
  async function doCommandTree(path: string): Promise<void> {
1269
1389
  // Extract optional @version suffix: "cmd /ip/address @7.15" or "cmd /ip add @7.20"
1270
1390
  let version: string | undefined;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * classify.test.ts — Table-driven unit tests for the input classifier.
3
+ *
4
+ * Pure-function module — no DB required. Covers each detector in the DESIGN.md
5
+ * detector table plus real overlap cases (topic + version, command-path + topic).
6
+ */
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ import { classifyQuery } from "./classify.ts";
10
+
11
+ type Expectation = {
12
+ version?: string;
13
+ topics?: string[]; // must be a subset of detected topics (order-insensitive)
14
+ topicsExact?: string[]; // exact match on detected topics (order-sensitive)
15
+ command_path?: string;
16
+ fragment_pairs?: Array<{ key: string; value: string }>;
17
+ fragment_verbs?: string[];
18
+ device?: string;
19
+ property?: string;
20
+ none?: boolean; // assert no signals detected
21
+ };
22
+
23
+ const CASES: Array<{ name: string; input: string; expect: Expectation }> = [
24
+ // --- Empty / noise ---
25
+ { name: "empty input", input: "", expect: { none: true } },
26
+ { name: "whitespace only", input: " \t ", expect: { none: true } },
27
+
28
+ // --- Topic detection (KNOWN_TOPICS) ---
29
+ { name: "single topic — bgp", input: "bgp neighbor configuration", expect: { topics: ["bgp"] } },
30
+ { name: "two topics — dhcp + server synonym", input: "dhcp server lease timeout", expect: { topics: ["dhcp"] } },
31
+ { name: "firewall + filter compound", input: "how do I configure firewall filter", expect: { topics: ["firewall", "filter"] } },
32
+ { name: "routing + ospf", input: "routing ospf area configuration", expect: { topics: ["routing", "ospf"] } },
33
+ { name: "wireguard topic", input: "wireguard peer setup", expect: { topics: ["wireguard"] } },
34
+ { name: "ipv6 topic", input: "ipv6 address configuration", expect: { topics: ["ipv6"] } },
35
+
36
+ // --- Version detection ---
37
+ { name: "stable version 7.22", input: "what changed in 7.22", expect: { version: "7.22" } },
38
+ { name: "patch version 7.22.1", input: "7.22.1 release notes", expect: { version: "7.22.1" } },
39
+ { name: "beta version 7.23beta2", input: "whats new in 7.23beta2", expect: { version: "7.23beta2" } },
40
+ { name: "rc version 7.22rc1", input: "7.22rc1 bug", expect: { version: "7.22rc1" } },
41
+ { name: "version with v prefix", input: "v7.22 changelog", expect: { version: "7.22" } },
42
+ { name: "first-match wins on two versions", input: "compare 7.21 and 7.22", expect: { version: "7.21" } },
43
+
44
+ // --- Topic + version overlap (DESIGN.md canonical example) ---
45
+ {
46
+ name: "topic + version + general",
47
+ input: "bgp 7.22 route reflection",
48
+ expect: { topics: ["bgp", "route"], version: "7.22" },
49
+ },
50
+ {
51
+ name: "changelog-like topic+version",
52
+ input: "firewall raw 7.22 breaking",
53
+ expect: { topics: ["firewall", "raw"], version: "7.22" },
54
+ },
55
+
56
+ // --- Command path detection ---
57
+ {
58
+ name: "absolute slash path",
59
+ input: "/ip/firewall/filter",
60
+ expect: { command_path: "/ip/firewall/filter", topics: ["ip", "firewall", "filter"] },
61
+ },
62
+ {
63
+ name: "space-separated absolute path",
64
+ input: "/ip firewall filter",
65
+ expect: { command_path: "/ip/firewall/filter" },
66
+ },
67
+ {
68
+ name: "path without leading slash",
69
+ input: "ip firewall filter",
70
+ expect: { command_path: "/ip/firewall/filter" },
71
+ },
72
+ {
73
+ name: "system scheduler path",
74
+ input: "/system/scheduler",
75
+ expect: { command_path: "/system/scheduler", topics: ["system"] },
76
+ },
77
+ {
78
+ name: "ipv6 firewall raw",
79
+ input: "/ipv6/firewall/raw",
80
+ expect: { command_path: "/ipv6/firewall/raw", topics: ["ipv6", "firewall", "raw"] },
81
+ },
82
+
83
+ // --- Command fragment (key=value, verbs) ---
84
+ {
85
+ name: "add with two pairs",
86
+ input: "add chain=forward action=accept",
87
+ expect: {
88
+ fragment_pairs: [{ key: "chain", value: "forward" }, { key: "action", value: "accept" }],
89
+ fragment_verbs: ["add"],
90
+ },
91
+ },
92
+ {
93
+ name: "set disabled=no",
94
+ input: "set disabled=no",
95
+ expect: {
96
+ fragment_pairs: [{ key: "disabled", value: "no" }],
97
+ fragment_verbs: ["set"],
98
+ },
99
+ },
100
+ {
101
+ name: "print without pairs",
102
+ input: "print detail",
103
+ expect: { fragment_verbs: ["print"] },
104
+ },
105
+
106
+ // --- Device model ---
107
+ { name: "RB model", input: "RB1100AHx4 specs", expect: { device: "RB1100AHx4" } },
108
+ { name: "CCR with dashes", input: "CCR2216-1G-12XS-2XQ throughput", expect: { device: "CCR2216-1G-12XS-2XQ" } },
109
+ { name: "CRS switch", input: "CRS354 port count", expect: { device: "CRS354" } },
110
+ { name: "hEX", input: "hEX S performance", expect: { device: "hEX" } },
111
+ { name: "hAP ax", input: "hAP ax specs", expect: { device: "hAP" } },
112
+ { name: "cAP ac", input: "cAP ac indoor", expect: { device: "cAP" } },
113
+ { name: "SXTsq", input: "SXTsq 5 config", expect: { device: "SXTsq" } },
114
+
115
+ // --- Property name candidate (single short lowercase token) ---
116
+ { name: "property — chain", input: "chain", expect: { property: "chain" } },
117
+ { name: "property — fastpath", input: "fastpath", expect: { property: "fastpath" } },
118
+ {
119
+ name: "single known-topic token is NOT classified as property",
120
+ input: "bgp",
121
+ expect: { topics: ["bgp"] },
122
+ },
123
+
124
+ // --- Non-exclusive / complex inputs ---
125
+ {
126
+ name: "device + topic",
127
+ input: "RB5009 wireguard performance",
128
+ expect: { device: "RB5009", topics: ["wireguard"] },
129
+ },
130
+ {
131
+ name: "container add with verb",
132
+ input: "container add remote-image",
133
+ expect: { topics: ["container"], fragment_verbs: ["add"] },
134
+ },
135
+ ];
136
+
137
+ describe("classifyQuery", () => {
138
+ for (const { name, input, expect: want } of CASES) {
139
+ test(name, () => {
140
+ const result = classifyQuery(input);
141
+
142
+ if (want.none) {
143
+ expect(result.version).toBeUndefined();
144
+ expect(result.command_path).toBeUndefined();
145
+ expect(result.command_fragment).toBeUndefined();
146
+ expect(result.device).toBeUndefined();
147
+ expect(result.property).toBeUndefined();
148
+ expect(result.topics).toEqual([]);
149
+ return;
150
+ }
151
+
152
+ if (want.version !== undefined) expect(result.version).toBe(want.version);
153
+ if (want.command_path !== undefined) expect(result.command_path).toBe(want.command_path);
154
+ if (want.device !== undefined) expect(result.device).toBe(want.device);
155
+ if (want.property !== undefined) expect(result.property).toBe(want.property);
156
+
157
+ if (want.topics !== undefined) {
158
+ for (const t of want.topics) {
159
+ expect(result.topics).toContain(t);
160
+ }
161
+ }
162
+ if (want.topicsExact !== undefined) expect(result.topics).toEqual(want.topicsExact);
163
+
164
+ if (want.fragment_pairs !== undefined) {
165
+ expect(result.command_fragment?.pairs).toEqual(want.fragment_pairs);
166
+ }
167
+ if (want.fragment_verbs !== undefined) {
168
+ expect(result.command_fragment?.verbs).toEqual(want.fragment_verbs);
169
+ }
170
+ });
171
+ }
172
+
173
+ test("multi-word input never fires property detector", () => {
174
+ const result = classifyQuery("chain forward policy");
175
+ expect(result.property).toBeUndefined();
176
+ });
177
+
178
+ test("property detector yields to command_path", () => {
179
+ // "interface" is a top-level command and a known topic — path wins over property
180
+ const result = classifyQuery("interface");
181
+ expect(result.property).toBeUndefined();
182
+ });
183
+
184
+ test("property detector yields to device", () => {
185
+ const result = classifyQuery("RB5009");
186
+ expect(result.property).toBeUndefined();
187
+ expect(result.device).toBe("RB5009");
188
+ });
189
+
190
+ test("version regex doesn't swallow arbitrary decimals", () => {
191
+ const result = classifyQuery("interface stats show 5.0 Gbps");
192
+ expect(result.version).toBeUndefined();
193
+ });
194
+
195
+ test("topics are deduplicated", () => {
196
+ const result = classifyQuery("firewall firewall filter firewall");
197
+ const firewallCount = result.topics.filter((t) => t === "firewall").length;
198
+ expect(firewallCount).toBe(1);
199
+ });
200
+
201
+ test("topics capped at 5", () => {
202
+ const result = classifyQuery("bgp ospf rip dhcp dns routing wireguard ipsec");
203
+ expect(result.topics.length).toBeLessThanOrEqual(5);
204
+ });
205
+ });