@tikoci/rosetta 0.5.0 → 0.5.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP server that gives AI assistants searchable access to the complete [MikroTik RouterOS documentation](https://help.mikrotik.com/docs/spaces/ROS/overview) — 317 pages, 4,860 properties, 40,000-entry command tree, hardware specs for 144 products, 518 YouTube video transcripts, and direct links to help.mikrotik.com.
4
4
 
5
- If you need MikroTik docs, you likely have a MikroTik. Install rosetta once as a container on your router using [RouterOS /app](#install-on-mikrotik-app), and any AI assistant on the network can use it. Or [run it locally](#install-locally-with-bun) on your workstation.
5
+ If you need MikroTik docs, you likely have a MikroTik. Install rosetta once as a container on your router using [RouterOS /app](#install-on-mikrotik-app), and any AI assistant on the network can use it. Or [run it locally](#install-locally-with-bun) on your workstation. **No AI required** — rosetta includes a [terminal browser](#browse-without-ai) for searching the database directly.
6
6
 
7
7
  ### SQL-as-RAG
8
8
 
@@ -10,16 +10,18 @@ Instead of vector embeddings, rosetta uses **SQLite [FTS5](https://www.sqlite.or
10
10
 
11
11
  ### What's Inside
12
12
 
13
- - **317 documentation pages** from MikroTik's official help site (~515K words)
14
- - **4,860 property definitions** with types, defaults, and descriptions
15
- - **5,114 commands** in the RouterOS command hierarchy (551 directories, 34K arguments)
16
- - **1,034 callout blocks** warnings, notes, and tips with important caveats
17
- - **144 hardware products** — CPU, RAM, storage, ports, PoE, wireless, license level, pricing
18
- - **2,874 performance benchmarks** ethernet and IPSec throughput test results for 125 devices (64/512/1518-byte packets, multiple routing/bridging modes), plus block diagrams for 110
19
- - **518 YouTube video transcripts**chapter-level transcript segments from MikroTik's official YouTube channel, searchable by topic with timestamped deep links
20
- - **46 RouterOS versions tracked** (7.9 through 7.23beta2) for command history
21
- - **2 MCP CSV resources** for bulk reporting workflows: full benchmark dataset and full device catalog
22
- - Direct links to help.mikrotik.com for every page and section
13
+ | Data Source | Coverage |
14
+ |-------------|----------|
15
+ | Documentation pages | 317 pages (~515K words) from help.mikrotik.com |
16
+ | Property definitions | 4,860 with types, defaults, descriptions |
17
+ | Command tree | 5,114 commands, 551 dirs, 34K arguments |
18
+ | Version history | 46 RouterOS versions tracked (7.9–7.23beta2) |
19
+ | Hardware products | 144 devices specs, pricing, block diagrams |
20
+ | Performance benchmarks | 2,874 tests across 125 devices (ethernet + IPSec) |
21
+ | YouTube transcripts | 518 videos, ~1,890 chapter-level segments |
22
+ | Callout blocks | 1,034 warnings, notes, and tips |
23
+
24
+ Documentation covers RouterOS **v7 only**, aligned with the long-term release (~7.22) at export time.
23
25
 
24
26
  ---
25
27
 
@@ -91,7 +93,13 @@ Point any HTTP-capable MCP client at the URL from the previous step:
91
93
 
92
94
  > **CHR note:** Cloud Hosted Router in free or trial mode does not include the `/ip/cloud` service needed for HTTPS certificates. Set `use-https=no` on the /app — the URL will use HTTP instead. The UI URL always reflects the correct protocol.
93
95
 
94
- > **HTTP option:** On any platform, you may choose `use-https=no` if you prefer HTTP or are on an isolated network.
96
+ > **HTTP option:** On any platform, you may choose `use-https=no` if you prefer HTTP or are on an isolated network.
97
+
98
+ > **Browse the database from the router:** If rosetta is running as a `/app`, you can use `/container/shell` to access the TUI browser directly:
99
+ > ```routeros
100
+ > /container/shell app-rosetta
101
+ > # /app/rosetta browse
102
+ > ```
95
103
 
96
104
  ---
97
105
 
@@ -107,7 +115,7 @@ bunx @tikoci/rosetta --setup
107
115
 
108
116
  This downloads the database and prints config snippets for all supported MCP clients. Copy-paste the config for your client and you're done.
109
117
 
110
- ### Or configure manually
118
+ ### Configure your MCP client
111
119
 
112
120
  <details>
113
121
  <summary><b>VS Code Copilot</b></summary>
@@ -214,34 +222,36 @@ powershell -c "irm bun.sh/install.ps1 | iex"
214
222
 
215
223
  > **Auto-update:** `bunx` checks the npm registry each session and uses the latest published version automatically. The database in `~/.rosetta/ros-help.db` persists across updates.
216
224
 
217
- ### MCP Resources for Reporting
218
-
219
- If your MCP client supports resources, rosetta also exposes two read-only CSV datasets for bulk analysis and reporting:
225
+ ---
220
226
 
221
- - `rosetta://datasets/device-test-results.csv`
222
- - `rosetta://datasets/devices.csv`
227
+ ## Browse Without AI
223
228
 
224
- In VS Code Copilot, attach them via **Add Context > MCP Resources** or **MCP: Browse Resources**. Use tools for normal search and drill-down; use resources when you explicitly want the whole dataset as CSV.
229
+ Rosetta includes a terminal-based "card catalog" browser **no AI assistant or MCP client required**. It searches the same database the MCP tools use, with a keyboard-driven REPL modeled after a 1980s library terminal.
225
230
 
226
- ---
231
+ ```sh
232
+ bunx @tikoci/rosetta browse
233
+ ```
227
234
 
228
- ## Install from Binary
235
+ Type a search query to find documentation pages, then select a numbered result to drill in. Beyond page search, the browser covers every data source in the database:
229
236
 
230
- Download a compiled binary from [Releases](https://github.com/tikoci/rosetta/releases) — no Bun, Node.js, or other runtime needed.
237
+ | Command | What it searches |
238
+ |---------|-----------------|
239
+ | *(bare text)* | Documentation pages (default) |
240
+ | `dev <query>` | Device hardware specs, block diagrams, benchmarks |
241
+ | `cmd [path]` | Command tree hierarchy |
242
+ | `prop <name>` | Property definitions (scoped to current page when viewing one) |
243
+ | `cal [query]` | Warnings, notes, and tips |
244
+ | `cl [version]` | Changelogs — `cl breaking` for breaking changes only |
245
+ | `vid <query>` | YouTube video transcripts with timestamped chapter links |
246
+ | `diff <from> <to>` | Command tree diff between RouterOS versions |
247
+ | `tests [type]` | Cross-device performance benchmarks |
248
+ | `ver` | Live-fetch current RouterOS versions |
231
249
 
232
- | Platform | File |
233
- |----------|------|
234
- | macOS (Apple Silicon) | `rosetta-macos-arm64.zip` |
235
- | macOS (Intel) | `rosetta-macos-x64.zip` |
236
- | Windows | `rosetta-windows-x64.zip` |
237
- | Linux | `rosetta-linux-x64.zip` |
250
+ Type `help` for the full command list. URLs are clickable in terminals that support OSC 8 hyperlinks (iTerm2, Windows Terminal, GNOME Terminal, etc.).
238
251
 
239
- ```sh
240
- ./rosetta --setup # downloads DB + prints MCP client config
241
- ```
252
+ The browser is also useful as a test harness — it interacts with the data the same way an AI agent would through MCP, so gaps or rough edges visible here often point to MCP tool improvements too.
242
253
 
243
- > **macOS Gatekeeper:** `xattr -d com.apple.quarantine ./rosetta` or System Settings Privacy & Security Allow Anyway.
244
- > **Windows SmartScreen:** Click **More info → Run anyway**.
254
+ > **From a router:** If rosetta is installed as a `/app`, access the browser via `/container/shell app-rosetta` then `/app/rosetta browse`.
245
255
 
246
256
  ---
247
257
 
@@ -252,7 +262,6 @@ Ask your AI assistant questions like:
252
262
  - *"What are the DHCP server properties in RouterOS?"*
253
263
  - *"How do I set up a bridge VLAN?"*
254
264
  - *"Is the /container command available in RouterOS 7.12?"*
255
- - *"What are the firewall filter default chains?"*
256
265
  - *"Show me warnings about hardware offloading"*
257
266
  - *"Which MikroTik routers have L3HW offload, and more than 8 ports of 48V PoE? Include cost."*
258
267
  - *"Compare the RB5009 and CCR2004 IPSec throughput at 1518-byte packets."*
@@ -260,122 +269,30 @@ Ask your AI assistant questions like:
260
269
 
261
270
  ## MCP Tools
262
271
 
263
- The server provides 15 tools, designed to work together:
272
+ The server exposes 14 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
264
273
 
265
274
  | Tool | What it does |
266
275
  |------|-------------|
267
- | `routeros_search` | **Start here.** Full-text search across all pages with BM25 ranking |
268
- | `routeros_get_page` | Retrieve full page content by ID or title. Section-aware for large pages |
269
- | `routeros_lookup_property` | Look up a property by exact name (type, default, description) |
270
- | `routeros_search_properties` | Search across 4,860 property names and descriptions |
271
- | `routeros_command_tree` | Browse the `/ip/firewall/filter` style command hierarchy |
272
- | `routeros_search_callouts` | Search warnings, notes, and tips across all pages |
273
- | `routeros_search_changelogs` | Search parsed changelog entries — filter by version range, category, breaking changes |
274
- | `routeros_command_version_check` | Check which RouterOS versions include a command |
275
- | `routeros_command_diff` | Diff two RouterOS versions — which command paths were added or removed between them |
276
- | `routeros_device_lookup` | Hardware specs for 144 MikroTik products — filter by architecture, RAM, storage, PoE, wireless, LTE. Includes ethernet/IPSec benchmarks and block diagrams for most devices |
277
- | `routeros_search_tests` | Cross-device performance benchmarks filter by test type, mode, packet size; one call replaces 125+ individual lookups |
278
- | `routeros_search_videos` | Search MikroTik YouTube video transcripts chapter-level results with timestamps and excerpts |
279
- | `routeros_stats` | Database health: page/property/command counts, coverage stats |
280
- | `routeros_current_versions` | Fetch current RouterOS versions from MikroTik (live) |
281
-
282
- The AI assistant typically starts with `routeros_search`, then drills into specific pages, properties, or the command tree based on what it finds. Each tool's description includes workflow hints (e.g., "→ use `routeros_get_page` to read full content") and empty-result suggestions so the AI knows how to chain tools together — this is where most of the tuning effort goes.
283
-
284
- ## Troubleshooting
285
-
286
- | Issue | Solution |
287
- |-------|----------|
288
- | **First launch is slow** | One-time database download (~50 MB). Subsequent starts are instant. |
289
- | **`npx @tikoci/rosetta` fails** | This package requires Bun, not Node.js. Use `bunx` instead of `npx`. |
290
- | **`npm install -g` then `rosetta` fails** | Global npm install works if Bun is on PATH — it delegates to `bun` at runtime. But prefer `bunx` — it's simpler and auto-updates. |
291
- | **ChatGPT Apps can't connect** | ChatGPT Apps require a remote HTTPS MCP endpoint. Use the [MikroTik /app install](#install-on-mikrotik-app) for a hosted endpoint, or Codex CLI for local stdio. |
292
- | **Claude Desktop can't find `bunx`** | Claude Desktop on macOS may not inherit shell PATH. Use the full path to bunx (run `which bunx` to find it, typically `~/.bun/bin/bunx`). `bunx @tikoci/rosetta --setup` prints the full-path config. |
293
- | **macOS Gatekeeper blocks binary** | Use `bunx` install (no Gatekeeper issues), or: `xattr -d com.apple.quarantine ./rosetta` |
294
- | **Windows SmartScreen warning** | Use `bunx` install (no SmartScreen issues), or click **More info → Run anyway** |
295
- | **How to update** | `bunx` always uses the latest published version. For binaries, re-download from [Releases](https://github.com/tikoci/rosetta/releases/latest). MikroTik /app with `auto-update: true` pulls the latest image on each boot. |
296
-
297
- ## HTTP Transport
298
-
299
- The [MikroTik /app install](#install-on-mikrotik-app) is the easiest way to get an HTTP endpoint. For other setups, rosetta supports the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) via `--http`:
300
-
301
- ```sh
302
- rosetta --http # http://localhost:8080/mcp
303
- rosetta --http --port 9090 # custom port
304
- rosetta --http --host 0.0.0.0 # accessible from LAN
305
- ```
306
-
307
- Then point your MCP client at the URL:
308
-
309
- ```json
310
- { "url": "http://localhost:8080/mcp" }
311
- ```
312
-
313
- - **Read-only** — queries a local SQLite database, stores nothing.
314
- - **No authentication** — designed for local/trusted-network use. Use a reverse proxy for public exposure.
315
- - **TLS built-in** — `--tls-cert cert.pem --tls-key key.pem` for direct HTTPS without a proxy.
316
- - **Defaults to localhost** — LAN binding (`--host 0.0.0.0`) requires an explicit flag.
317
-
318
- ## Container Images
319
-
320
- Multi-arch OCI images (linux/amd64 + linux/arm64) are published with each release:
321
-
322
- - `ghcr.io/tikoci/rosetta` (GitHub Container Registry)
323
- - `ammo74/rosetta` (Docker Hub)
324
-
325
- ```sh
326
- docker run --rm -p 8080:8080 ghcr.io/tikoci/rosetta:latest
327
- ```
328
-
329
- These are the same images used by the [MikroTik /app install](#install-on-mikrotik-app). Tags: `latest`, version (e.g., `v0.2.1`), and `sha-<commit>`.
330
-
331
- ## Data Sources
332
-
333
- The database combines multiple MikroTik data sources into a single SQLite file with [FTS5](https://www.sqlite.org/fts5.html) full-text search, [porter stemming](https://www.sqlite.org/fts5.html#porter_tokenizer), and [BM25 ranking](https://www.sqlite.org/fts5.html#the_bm25_function):
334
-
335
- - **HTML Documentation** — Confluence space export from help.mikrotik.com (March 2026). 317 pages broken into sections, callouts, and property tables (~515K words) with links back to help.mikrotik.com.
336
-
337
- - **Command Tree** — `inspect.json` from [tikoci/restraml](https://github.com/tikoci/restraml), generated by running `/console/inspect` against RouterOS CHR under QEMU for every version since 7.9 (46 versions tracked: 7.9–7.23beta2).
338
-
339
- - **Product Matrix** — CSV export from mikrotik.com/products/matrix (144 products, 34 columns). Hardware specs, license levels, and pricing.
340
-
341
- - **Device Benchmarks** — Ethernet bridging/routing and IPSec throughput test results scraped from individual product pages on mikrotik.com (2,874 measurements across 125 devices; 64/512/1518-byte packets, multiple configurations). Also captures block diagram image URLs for 110 devices.
342
-
343
- - **YouTube Transcripts** — Auto-generated English transcripts from the official [MikroTik YouTube channel](https://www.youtube.com/@MikroTik/videos) (518 videos, ~1,800 transcript segments). Split by chapter when available, with timestamps for deep linking. MUM conference talks excluded. Extracted via yt-dlp, cached as NDJSON in the repo for reproducible CI builds.
344
-
345
- Documentation covers RouterOS **v7 only** and aligns with the long-term release (~7.22) at export time. v6 had different syntax and major subsystems — answers for v6 are unreliable.
346
-
347
- ## Database (Standalone)
348
-
349
- The SQLite database is downloadable on its own from [GitHub Releases](https://github.com/tikoci/rosetta/releases):
350
-
351
- ```text
352
- https://github.com/tikoci/rosetta/releases/latest/download/ros-help.db.gz
353
- ```
354
-
355
- Use it with any SQLite client:
356
-
357
- ```sh
358
- sqlite3 ros-help.db "SELECT title, url FROM pages_fts WHERE pages_fts MATCH 'DHCP lease' ORDER BY rank LIMIT 5;"
359
- ```
360
-
361
- ### Tables
362
-
363
- | Table | Rows | What's in it |
364
- |-------|------|-------------|
365
- | `pages` | 317 | Documentation pages — title, breadcrumb path, full text, code blocks, help.mikrotik.com URL |
366
- | `sections` | 2,984 | Page chunks split by h1–h3 headings, with anchor IDs for deep linking |
367
- | `callouts` | 1,034 | Warning/Note/Info/Tip boxes extracted from Confluence callout macros |
368
- | `properties` | 4,860 | Command properties — name, type, default value, description (from doc tables) |
369
- | `commands` | 40K+ | RouterOS command hierarchy — dirs, commands, arguments from `/console/inspect` |
370
- | `command_versions` | 1.67M | Junction table: which command paths exist in which RouterOS versions (7.9–7.23beta2) |
371
- | `ros_versions` | 46 | Tracked RouterOS versions with channel (stable/development) |
372
- | `devices` | 144 | MikroTik hardware — CPU, RAM, storage, ports, PoE, wireless, license level, MSRP |
373
- | `device_test_results` | 2,874 | Ethernet and IPSec throughput benchmarks for 125 devices — packet sizes, modes, Mbps/Kpps |
374
- | `changelogs` | varies | Parsed changelog entries per RouterOS version — category, description, breaking flag |
375
- | `videos` | 518 | MikroTik YouTube video metadata — title, description, duration, chapters |
376
- | `video_segments` | ~1,890 | Chapter-level transcript segments with timestamps for deep linking |
377
-
378
- Each content table has a corresponding FTS5 index (e.g., `pages_fts`, `properties_fts`, `devices_fts`, `video_segments_fts`).
276
+ | `routeros_search` | **Start here.** Full-text search across all documentation pages |
277
+ | `routeros_get_page` | Full page content by ID or title, section-aware for large pages |
278
+ | `routeros_lookup_property` | Property by exact name type, default, description |
279
+ | `routeros_search_properties` | FTS across 4,860 property names and descriptions |
280
+ | `routeros_command_tree` | Browse the command hierarchy (`/ip/firewall/filter` style) |
281
+ | `routeros_search_callouts` | Search warnings, notes, and tips |
282
+ | `routeros_search_changelogs` | Changelogs filtered by version range, category, breaking flag |
283
+ | `routeros_command_version_check` | Which RouterOS versions include a command path |
284
+ | `routeros_command_diff` | Added/removed commands between two RouterOS versions |
285
+ | `routeros_device_lookup` | Hardware specs — filter by architecture, RAM, PoE, wireless, etc. |
286
+ | `routeros_search_tests` | Cross-device ethernet and IPSec benchmarks |
287
+ | `routeros_search_videos` | YouTube transcript search with chapter timestamps |
288
+ | `routeros_stats` | Database health and coverage stats |
289
+ | `routeros_current_versions` | Live-fetch current RouterOS versions from MikroTik |
290
+
291
+ Each tool description includes workflow arrows (`→ next_tool`) and empty-result hints so agents chain tools effectively.
292
+
293
+
294
+ ## RTFM for Details
295
+ For additional install options, HTTP transport configuration, data source details, and the database schema, see [MANUAL.md](MANUAL.md).
379
296
 
380
297
  ## Contributing
381
298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.5.0",
3
+ "version": "0.5.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
@@ -15,6 +15,7 @@ import * as readline from "node:readline";
15
15
  import { db, getDbStats, initDb } from "./db.ts";
16
16
  import { resolveVersion } from "./paths.ts";
17
17
  import type {
18
+ CalloutResult,
18
19
  ChangelogResult,
19
20
  DeviceResult,
20
21
  DeviceTestRow,
@@ -151,11 +152,15 @@ async function paged(output: string): Promise<boolean> {
151
152
  offset += pageSize;
152
153
  if (offset < lines.length) {
153
154
  const remaining = lines.length - offset;
154
- process.stdout.write(dim(`── ${remaining} more lines (Enter=next, q=stop) ──`));
155
+ process.stdout.write(dim(`-- ${remaining} more lines [Q quit | SPACE next page | ENTER next line]`));
155
156
  const key = await waitForKey();
156
157
  // Clear the "more" line
157
158
  process.stdout.write(`\r${" ".repeat(termWidth())}\r`);
158
159
  if (key === "q" || key === "Q") return true;
160
+ // ENTER or arrow-down → advance one line; SPACE → advance one page
161
+ if (key === "\r" || key === "\n" || key === "\x1b[B") {
162
+ offset -= pageSize - 1; // back up so only 1 line advances
163
+ }
159
164
  }
160
165
  }
161
166
  return false;
@@ -184,14 +189,14 @@ type Context =
184
189
  | { type: "search"; response: SearchResponse; results: SearchResult[] }
185
190
  | { type: "page"; pageId: number; title: string; commandPath?: string }
186
191
  | { type: "sections"; pageId: number; title: string; sections: SectionTocEntry[] }
187
- | { type: "properties"; query: string; pageId?: number }
188
192
  | { type: "commands"; path: string }
189
193
  | { type: "devices"; query: string; results: DeviceResult[] }
190
194
  | { type: "device"; device: DeviceResult }
191
195
  | { type: "tests" }
192
- | { type: "callouts"; query: string }
193
- | { type: "changelogs" }
194
- | { type: "videos"; query: string }
196
+ | { type: "callouts"; query: string; results: CalloutResult[] }
197
+ | { type: "changelogs"; results: ChangelogResult[] }
198
+ | { type: "videos"; query: string; results: VideoSearchResult[] }
199
+ | { type: "properties"; query: string; pageId?: number; results: Array<{ name: string; page_id: number; page_title: string }> }
195
200
  | { type: "diff" }
196
201
  | { type: "vcheck"; path: string };
197
202
 
@@ -209,6 +214,32 @@ function popCtx(): boolean {
209
214
  return false;
210
215
  }
211
216
 
217
+ /** Build a context-aware prompt string, mirroring RouterOS [admin@router]> style. */
218
+ function buildPrompt(): string {
219
+ const label = contextLabel(ctx);
220
+ if (label) return `${cyan("rosetta")}${dim("[")}${label}${dim("]>")} `;
221
+ return `${cyan("rosetta")}${dim(">")} `;
222
+ }
223
+
224
+ function contextLabel(c: Context): string {
225
+ switch (c.type) {
226
+ case "home": return "";
227
+ case "search": return truncate(`search: "${c.response.query}"`, 30);
228
+ case "page": return truncate(c.title, 30);
229
+ case "sections": return truncate(`${c.title} §`, 30);
230
+ case "properties": return truncate(`prop: "${c.query}"`, 30);
231
+ case "commands": return truncate(`cmd: ${c.path || "/"}`, 30);
232
+ case "devices": return truncate(`dev: "${c.query}"`, 30);
233
+ case "device": return truncate(c.device.product_name, 30);
234
+ case "tests": return "tests";
235
+ case "callouts": return c.query ? truncate(`cal: "${c.query}"`, 30) : "callouts";
236
+ case "changelogs": return "changelogs";
237
+ case "videos": return truncate(`vid: "${c.query}"`, 30);
238
+ case "diff": return "diff";
239
+ case "vcheck": return truncate(`vc: ${c.path}`, 30);
240
+ }
241
+ }
242
+
212
243
  // ── Renderers ──
213
244
 
214
245
  function renderWelcome(): string {
@@ -250,7 +281,7 @@ function renderSearchResults(resp: SearchResponse): string {
250
281
 
251
282
  // Navigation hints
252
283
  const hints = [
253
- `${cyan("[N]")} view page`,
284
+ `${cyan("#N")} view page`,
254
285
  `${cyan("[s <query>]")} search`,
255
286
  `${cyan("[p <query>]")} properties`,
256
287
  `${cyan("[cmd <path>]")} commands`,
@@ -318,7 +349,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
318
349
  if (page.code) {
319
350
  out.push("");
320
351
  out.push(dim("── code ──"));
321
- out.push(page.code);
352
+ out.push(page.code.split("\n").map((l) => ` ${l}`).join("\n"));
322
353
  }
323
354
  }
324
355
 
@@ -336,14 +367,14 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
336
367
  if (page.code) {
337
368
  out.push("");
338
369
  out.push(dim("── code ──"));
339
- out.push(page.code);
370
+ out.push(page.code.split("\n").map((l) => ` ${l}`).join("\n"));
340
371
  }
341
372
  }
342
373
 
343
374
  // Navigation hints
344
375
  out.push("");
345
376
  const hints: string[] = [];
346
- if (page.sections && page.sections.length > 0) hints.push(`${cyan("[N]")} section`);
377
+ if (page.sections && page.sections.length > 0) hints.push(`${cyan("#N")} section`);
347
378
  hints.push(`${cyan("[p]")} properties`);
348
379
  hints.push(`${cyan("[cmd]")} command tree`);
349
380
  hints.push(`${cyan("[cal]")} callouts`);
@@ -449,7 +480,7 @@ function renderDeviceResults(results: DeviceResult[], mode: string, total: numbe
449
480
  out.push("");
450
481
  }
451
482
 
452
- out.push(` ${cyan("[N]")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
483
+ out.push(` ${cyan("#N")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
453
484
  return out.join("\n");
454
485
  }
455
486
 
@@ -498,17 +529,24 @@ function renderDeviceCard(d: DeviceResult): string {
498
529
  out.push(` ${dim("Block diagram:")} ${cyan(link(d.block_diagram_url, "view"))}`);
499
530
  }
500
531
 
501
- // Test results (attached for exact matches)
532
+ // Test results (attached for exact matches) — default to 512B for a useful baseline
502
533
  if (d.test_results && d.test_results.length > 0) {
534
+ const pkt512 = d.test_results.filter((t) => t.packet_size === 512);
535
+ const displayTests = pkt512.length > 0 ? pkt512 : d.test_results.slice(0, 20);
536
+ const pktNote = pkt512.length > 0 ? " (512B baseline)" : "";
503
537
  out.push("");
504
- out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
505
- for (const t of d.test_results.slice(0, 12)) {
538
+ out.push(` ${bold("Benchmarks:")} ${dim(`(${displayTests.length} of ${d.test_results.length} tests${pktNote})`)}`);
539
+ for (const t of displayTests) {
506
540
  const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
507
541
  const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
508
542
  out.push(` ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(t.configuration, 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(mbps)} ${dim(kpps)}`);
509
543
  }
510
- if (d.test_results.length > 12) {
511
- out.push(` ${dim(`... and ${d.test_results.length - 12} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
544
+ if (d.test_results.length > displayTests.length) {
545
+ const otherSizes = [...new Set(
546
+ d.test_results.filter((t) => t.packet_size !== 512).map((t) => t.packet_size),
547
+ )].sort((a, b) => a - b);
548
+ const sizesStr = otherSizes.map((s) => `${s}B`).join(", ");
549
+ out.push(` ${dim(`Also at ${sizesStr} — use`)} ${cyan("tests")} ${dim("<type> <size> for other packet sizes")}`);
512
550
  }
513
551
  }
514
552
 
@@ -582,22 +620,24 @@ function renderChangelogs(results: ChangelogResult[]): string {
582
620
  }
583
621
 
584
622
  let lastVersion = "";
585
- for (const c of results) {
623
+ for (let i = 0; i < results.length; i++) {
624
+ const c = results[i];
586
625
  if (c.version !== lastVersion) {
587
626
  out.push("");
588
627
  out.push(` ${bold(c.version)} ${dim(c.released ?? "")}`);
589
628
  lastVersion = c.version;
590
629
  }
591
- const breaking = c.is_breaking ? red("⚠ ") : " ";
630
+ const num = dim(`${String(i + 1).padStart(3)} `);
631
+ const breaking = c.is_breaking ? red("⚠ ") : "";
592
632
  const cat = dim(pad(c.category, 14));
593
633
  const desc = c.excerpt.includes("**")
594
634
  ? c.excerpt.replace(/\*\*/g, `${ESC}[1m`)
595
- : truncate(c.description, termWidth() - 22);
596
- out.push(` ${breaking}${cat} ${desc}`);
635
+ : truncate(c.description, termWidth() - 26);
636
+ out.push(`${num}${breaking}${cat} ${desc}`);
597
637
  }
598
638
 
599
639
  out.push("");
600
- out.push(` ${cyan("[cl breaking]")} breaking only ${cyan("[cl <ver>]")} specific version ${cyan("[b]")} back`);
640
+ out.push(` ${cyan("[cl 7.22 bgp]")} version + topic ${cyan("[cl 7.21..7.22]")} range ${cyan("[cl breaking]")} breaking only ${cyan("[b]")} back`);
601
641
  return out.join("\n");
602
642
  }
603
643
 
@@ -719,25 +759,26 @@ function renderHelp(): string {
719
759
  out.push(` ${bold("Commands")} ${dim("(bare text = search)")}`);
720
760
  out.push("");
721
761
 
722
- const cmd = (name: string, alias: string, desc: string) => {
723
- out.push(` ${cyan(pad(name, 26))} ${dim(pad(alias, 6))} ${desc}`);
762
+ const cmd = (name: string, alias: string, desc: string, mcp?: string) => {
763
+ const mcpHint = mcp ? ` ${dim(`(${mcp})`)}` : "";
764
+ out.push(` ${cyan(pad(name, 26))} ${dim(pad(alias, 6))} ${desc}${mcpHint}`);
724
765
  };
725
766
 
726
- cmd("<query>", "", "Search pages (default action)");
727
- cmd("search <query>", "s", "Explicit page search");
728
- cmd("page <id|title>", "", "View full page");
729
- cmd("prop <name>", "p", "Look up property (scoped to current page)");
730
- cmd("props <query>", "sp", "Search properties by FTS");
731
- cmd("cmd [path]", "tree", "Browse command tree");
732
- cmd("device <query>", "dev", "Look up device specs");
733
- cmd("tests [type] [mode]", "", "Cross-device benchmarks");
734
- cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)");
735
- cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)");
736
- cmd("videos <query>", "vid", "Search video transcripts");
737
- cmd("diff <from> <to> [path]", "", "Command tree diff between versions");
738
- cmd("vcheck <path>", "vc", "Version range for a command path");
739
- cmd("versions", "ver", "Live-fetch current RouterOS versions");
740
- cmd("stats", "", "Database health / counts");
767
+ cmd("<query>", "", "Search pages (default action)", "routeros_search");
768
+ cmd("search <query>", "s", "Explicit page search", "routeros_search");
769
+ cmd("page <id|title>", "", "View full page", "routeros_get_page");
770
+ cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
771
+ cmd("props <query>", "sp", "Search properties by FTS", "routeros_search_properties");
772
+ cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
773
+ cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
774
+ cmd("tests [device] <type> [packet_size] [mode]", "", "Cross-device benchmarks (512B default)", "routeros_search_tests");
775
+ cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)", "routeros_search_callouts");
776
+ cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
777
+ cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
778
+ cmd("diff <from> <to> [path]", "", "Command tree diff between versions", "routeros_command_diff");
779
+ cmd("vcheck <path>", "vc", "Version range for a command path", "routeros_command_version_check");
780
+ cmd("versions", "ver", "Live-fetch current RouterOS versions", "routeros_current_versions");
781
+ cmd("stats", "", "Database health / counts", "routeros_stats");
741
782
  cmd("back", "b", "Go to previous view");
742
783
  cmd("help", "?", "This help");
743
784
  cmd("quit", "q", "Exit");
@@ -750,8 +791,69 @@ function renderHelp(): string {
750
791
  return out.join("\n");
751
792
  }
752
793
 
794
+ /** Fetch the full transcript text for a specific video segment. */
795
+ function getVideoSegmentTranscript(youtubeVideoId: string, startS: number): string | null {
796
+ try {
797
+ const row = db
798
+ .prepare(
799
+ `SELECT vs.transcript FROM video_segments vs
800
+ JOIN videos v ON v.id = vs.video_id
801
+ WHERE v.video_id = ? AND vs.start_s = ? LIMIT 1`,
802
+ )
803
+ .get(youtubeVideoId, startS) as { transcript: string } | null;
804
+ return row?.transcript ?? null;
805
+ } catch {
806
+ return null;
807
+ }
808
+ }
809
+
753
810
  // ── Command dispatcher ──
754
811
 
812
+ /** Re-render the current context after going back — uses cached results where possible. */
813
+ async function renderCurrentCtx(): Promise<void> {
814
+ switch (ctx.type) {
815
+ case "home":
816
+ console.log(renderWelcome());
817
+ return;
818
+ case "search":
819
+ await paged(renderSearchResults(ctx.response));
820
+ return;
821
+ case "commands": {
822
+ const children = browseCommands(ctx.path);
823
+ if (children.length > 0) await paged(renderCommandTree(ctx.path, children));
824
+ else console.log(dim(` No children at "${ctx.path}".`));
825
+ return;
826
+ }
827
+ case "devices":
828
+ await paged(renderDeviceResults(ctx.results, "cached", ctx.results.length));
829
+ return;
830
+ case "device":
831
+ await paged(renderDeviceCard(ctx.device));
832
+ return;
833
+ case "callouts":
834
+ if (ctx.results.length > 0) await paged(renderCallouts(ctx.results));
835
+ return;
836
+ case "changelogs":
837
+ if (ctx.results.length > 0) await paged(renderChangelogs(ctx.results));
838
+ return;
839
+ case "videos":
840
+ if (ctx.results.length > 0) await paged(renderVideos(ctx.results));
841
+ return;
842
+ case "page": {
843
+ const page = getPage(ctx.pageId);
844
+ if (page) await paged(renderPage(page));
845
+ return;
846
+ }
847
+ case "sections": {
848
+ const page = getPage(ctx.pageId);
849
+ if (page) await paged(renderPage(page));
850
+ return;
851
+ }
852
+ default:
853
+ console.log(dim(` In ${ctx.type} context. Type a command or help.`));
854
+ }
855
+ }
856
+
755
857
  async function dispatch(input: string): Promise<void> {
756
858
  const trimmed = input.trim();
757
859
  if (!trimmed) return;
@@ -786,7 +888,8 @@ async function dispatch(input: string): Promise<void> {
786
888
  if (!popCtx()) {
787
889
  console.log(dim(" Already at top."));
788
890
  } else {
789
- console.log(dim(` ← back to ${ctx.type}`));
891
+ console.log(dim(` ← ${ctx.type}`));
892
+ await renderCurrentCtx();
790
893
  }
791
894
  return;
792
895
 
@@ -801,7 +904,20 @@ async function dispatch(input: string): Promise<void> {
801
904
  return;
802
905
 
803
906
  case "page": {
804
- if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
907
+ if (!rest) {
908
+ // From commands context: navigate to the linked page
909
+ if (ctx.type === "commands") {
910
+ const row = db.prepare("SELECT page_id FROM commands WHERE path = ? LIMIT 1").get(ctx.path) as { page_id: number | null } | null;
911
+ if (row?.page_id) {
912
+ await doPage(String(row.page_id));
913
+ return;
914
+ }
915
+ console.log(dim(` No linked page for ${ctx.path}.`));
916
+ return;
917
+ }
918
+ console.log(dim(" Usage: page <id|title>"));
919
+ return;
920
+ }
805
921
  await doPage(rest);
806
922
  return;
807
923
  }
@@ -809,13 +925,12 @@ async function dispatch(input: string): Promise<void> {
809
925
  case "p":
810
926
  case "prop": {
811
927
  if (!rest) {
812
- // Context-scoped: show properties for current page
813
- if (ctx.type === "page") {
814
- const page = getPage(ctx.pageId, 0); // just get metadata
815
- if (page) {
816
- await doPropsForPage(ctx.pageId, ctx.title);
817
- return;
818
- }
928
+ // Context-scoped: show properties for current page or sections context
929
+ const pageId = ctx.type === "page" ? ctx.pageId : ctx.type === "sections" ? ctx.pageId : null;
930
+ const title = ctx.type === "page" ? ctx.title : ctx.type === "sections" ? ctx.title : null;
931
+ if (pageId !== null && title !== null) {
932
+ await doPropsForPage(pageId, title);
933
+ return;
819
934
  }
820
935
  console.log(dim(" Usage: prop <name> — or navigate to a page first"));
821
936
  return;
@@ -833,8 +948,15 @@ async function dispatch(input: string): Promise<void> {
833
948
 
834
949
  case "cmd":
835
950
  case "tree": {
836
- const path = rest || (ctx.type === "commands" ? ctx.path : "");
837
- await doCommandTree(path);
951
+ let cmdPath = rest;
952
+ if (!cmdPath && ctx.type === "commands") {
953
+ // Re-display current commands context
954
+ cmdPath = ctx.path;
955
+ } else if (cmdPath && !cmdPath.startsWith("/") && ctx.type === "commands") {
956
+ // Relative path: resolve against current commands path
957
+ cmdPath = `${ctx.path}/${cmdPath}`;
958
+ }
959
+ await doCommandTree(cmdPath);
838
960
  return;
839
961
  }
840
962
 
@@ -852,13 +974,24 @@ async function dispatch(input: string): Promise<void> {
852
974
 
853
975
  case "cal":
854
976
  case "callouts": {
855
- if (!rest && ctx.type === "page") {
856
- // Show callouts for current page
857
- const results = searchCallouts("", undefined, 50);
858
- const pageCallouts = results.filter((c) => c.page_id === (ctx as { pageId: number }).pageId);
859
- if (pageCallouts.length > 0) {
860
- await paged(renderCallouts(pageCallouts));
861
- pushCtx({ type: "callouts", query: "" });
977
+ if (!rest) {
978
+ // Context-aware: show callouts for the current page directly
979
+ const pageId = ctx.type === "page" ? ctx.pageId
980
+ : ctx.type === "sections" ? ctx.pageId
981
+ : null;
982
+ if (pageId !== null) {
983
+ const pageCallouts = db.prepare(
984
+ `SELECT c.type, c.content, p.title as page_title, p.url as page_url,
985
+ c.page_id, c.content as excerpt
986
+ FROM callouts c JOIN pages p ON p.id = c.page_id
987
+ WHERE c.page_id = ? ORDER BY c.sort_order`,
988
+ ).all(pageId) as CalloutResult[];
989
+ if (pageCallouts.length > 0) {
990
+ await paged(renderCallouts(pageCallouts));
991
+ pushCtx({ type: "callouts", query: "", results: pageCallouts });
992
+ } else {
993
+ console.log(dim(" No callouts for this page."));
994
+ }
862
995
  return;
863
996
  }
864
997
  }
@@ -873,6 +1006,7 @@ async function dispatch(input: string): Promise<void> {
873
1006
  }
874
1007
 
875
1008
  case "vid":
1009
+ case "video":
876
1010
  case "videos": {
877
1011
  if (!rest) { console.log(dim(" Usage: videos <query>")); return; }
878
1012
  await doSearchVideos(rest);
@@ -935,6 +1069,39 @@ async function handleNumberSelect(idx: number): Promise<void> {
935
1069
  }
936
1070
  return;
937
1071
  }
1072
+ if (ctx.type === "callouts" && ctx.results[idx]) {
1073
+ const c = ctx.results[idx];
1074
+ await doPage(String(c.page_id));
1075
+ return;
1076
+ }
1077
+ if (ctx.type === "videos" && ctx.results[idx]) {
1078
+ const v = ctx.results[idx];
1079
+ const timeUrl = v.start_s > 0 ? `${v.url}&t=${v.start_s}` : v.url;
1080
+ const out: string[] = [];
1081
+ out.push(`\n ${bold(v.title)}`);
1082
+ if (v.chapter_title) out.push(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${formatTime(v.start_s)}`)}`);
1083
+ out.push(` ${cyan(link(timeUrl))}`);
1084
+ // Fetch and show full transcript for this segment
1085
+ const transcript = getVideoSegmentTranscript(v.video_id, v.start_s);
1086
+ if (transcript) {
1087
+ out.push("");
1088
+ out.push(` ${dim("── transcript ──")}`);
1089
+ out.push(transcript.split("\n").map((l) => ` ${l}`).join("\n"));
1090
+ }
1091
+ await paged(out.join("\n"));
1092
+ return;
1093
+ }
1094
+ if (ctx.type === "properties" && ctx.results[idx]) {
1095
+ const p = ctx.results[idx];
1096
+ await doPage(String(p.page_id));
1097
+ return;
1098
+ }
1099
+ if (ctx.type === "changelogs" && ctx.results[idx]) {
1100
+ const c = ctx.results[idx];
1101
+ console.log(`\n ${bold(c.version)} ${dim(c.released ?? "")} ${dim(c.category)}${c.is_breaking ? ` ${red("⚠ BREAKING")}` : ""}`);
1102
+ console.log(` ${c.description}\n`);
1103
+ return;
1104
+ }
938
1105
  console.log(dim(` No item #${idx + 1} in current view.`));
939
1106
  }
940
1107
 
@@ -987,7 +1154,7 @@ async function doPropsForPage(pageId: number, title: string): Promise<void> {
987
1154
  return;
988
1155
  }
989
1156
  await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
990
- pushCtx({ type: "properties", query: title, pageId });
1157
+ pushCtx({ type: "properties", query: title, pageId, results: pageProps.map(p => ({ name: p.name, page_id: p.page_id ?? pageId, page_title: p.page_title })) });
991
1158
  }
992
1159
 
993
1160
  async function doLookupProperty(name: string): Promise<void> {
@@ -999,7 +1166,7 @@ async function doLookupProperty(name: string): Promise<void> {
999
1166
  return;
1000
1167
  }
1001
1168
  await paged(renderProperties(results));
1002
- pushCtx({ type: "properties", query: name });
1169
+ pushCtx({ type: "properties", query: name, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
1003
1170
  }
1004
1171
 
1005
1172
  async function doSearchProperties(query: string): Promise<void> {
@@ -1009,7 +1176,7 @@ async function doSearchProperties(query: string): Promise<void> {
1009
1176
  return;
1010
1177
  }
1011
1178
  await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
1012
- pushCtx({ type: "properties", query });
1179
+ pushCtx({ type: "properties", query, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
1013
1180
  }
1014
1181
 
1015
1182
  async function doCommandTree(path: string): Promise<void> {
@@ -1048,24 +1215,64 @@ async function doDeviceLookup(query: string): Promise<void> {
1048
1215
  }
1049
1216
 
1050
1217
  async function doTests(argsStr: string): Promise<void> {
1051
- if (!argsStr) {
1052
- // Show available filter values
1218
+ const parts = argsStr.split(/\s+/).filter(Boolean);
1219
+ const filters: Record<string, string | number> = {};
1220
+
1221
+ // Parse args if provided
1222
+ if (argsStr) {
1223
+ const knownTypes = ["ethernet", "ipsec"];
1224
+ let offset = 0;
1225
+ if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
1226
+ filters.device = parts[0];
1227
+ offset = 1;
1228
+ }
1229
+ if (parts[offset]) filters.test_type = parts[offset];
1230
+ // packet_size is always numeric; mode can be multi-word — identify by type
1231
+ const remaining = parts.slice(offset + 1);
1232
+ const pktIdx = remaining.findIndex((p) => /^\d+$/.test(p));
1233
+ if (pktIdx !== -1) {
1234
+ filters.packet_size = Number.parseInt(remaining[pktIdx], 10);
1235
+ const modeParts = [...remaining.slice(0, pktIdx), ...remaining.slice(pktIdx + 1)];
1236
+ if (modeParts.length > 0) filters.mode = modeParts.join(" ");
1237
+ } else if (remaining.length > 0) {
1238
+ filters.mode = remaining.join(" ");
1239
+ }
1240
+ }
1241
+
1242
+ // Auto-inject device name from context when in a device view and none specified
1243
+ if (!filters.device && ctx.type === "device") {
1244
+ filters.device = ctx.device.product_name;
1245
+ }
1246
+
1247
+ // Show help if no filters could be determined
1248
+ if (!argsStr && !filters.device) {
1053
1249
  const meta = getTestResultMeta();
1054
1250
  console.log(` ${bold("Test filters:")}`);
1055
1251
  console.log(` ${dim("Types:")} ${meta.test_types.join(", ")}`);
1056
1252
  console.log(` ${dim("Modes:")} ${meta.modes.join(", ")}`);
1057
1253
  console.log(` ${dim("Packet sizes:")} ${meta.packet_sizes.join(", ")}`);
1058
1254
  console.log("");
1059
- console.log(` ${dim("Usage: tests <type> [mode] [packet_size]")}`);
1255
+ console.log(` ${dim("Usage: tests [device] <type> [packet_size] [mode]")}`);
1060
1256
  console.log(` ${dim("Example: tests ethernet Routing 1518")}`);
1257
+ console.log(` ${dim("Example: tests rb5009 ethernet 1518")}`);
1061
1258
  return;
1062
1259
  }
1063
1260
 
1064
- const parts = argsStr.split(/\s+/);
1065
- const filters: Record<string, string | number> = {};
1066
- if (parts[0]) filters.test_type = parts[0];
1067
- if (parts[1]) filters.mode = parts[1];
1068
- if (parts[2] && /^\d+$/.test(parts[2])) filters.packet_size = Number.parseInt(parts[2], 10);
1261
+ // Round packet_size to the nearest valid test packet size
1262
+ if (filters.packet_size) {
1263
+ const validSizes = [64, 512, 1400, 1518];
1264
+ const size = Number(filters.packet_size);
1265
+ const rounded = validSizes.reduce((prev, curr) =>
1266
+ Math.abs(curr - size) < Math.abs(prev - size) ? curr : prev,
1267
+ );
1268
+ if (rounded !== size) {
1269
+ console.log(dim(` Rounding ${size}B → ${rounded}B (nearest test packet size)`));
1270
+ filters.packet_size = rounded;
1271
+ }
1272
+ }
1273
+
1274
+ // Default packet_size to 512 for a focused baseline comparison
1275
+ if (!filters.packet_size && !filters.mode) filters.packet_size = 512;
1069
1276
 
1070
1277
  const result = searchDeviceTests(filters);
1071
1278
  if (result.results.length === 0) {
@@ -1094,7 +1301,7 @@ async function doSearchCallouts(query: string): Promise<void> {
1094
1301
  return;
1095
1302
  }
1096
1303
  await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
1097
- pushCtx({ type: "callouts", query });
1304
+ pushCtx({ type: "callouts", query, results });
1098
1305
  }
1099
1306
 
1100
1307
  async function doSearchChangelogs(query: string): Promise<void> {
@@ -1135,7 +1342,7 @@ async function doSearchChangelogs(query: string): Promise<void> {
1135
1342
  return;
1136
1343
  }
1137
1344
  await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
1138
- pushCtx({ type: "changelogs" });
1345
+ pushCtx({ type: "changelogs", results });
1139
1346
  }
1140
1347
 
1141
1348
  async function doSearchVideos(query: string): Promise<void> {
@@ -1145,7 +1352,7 @@ async function doSearchVideos(query: string): Promise<void> {
1145
1352
  return;
1146
1353
  }
1147
1354
  await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
1148
- pushCtx({ type: "videos", query });
1355
+ pushCtx({ type: "videos", query, results });
1149
1356
  }
1150
1357
 
1151
1358
  async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
@@ -1210,7 +1417,7 @@ async function main() {
1210
1417
  const rl = readline.createInterface({
1211
1418
  input: process.stdin,
1212
1419
  output: process.stdout,
1213
- prompt: `${cyan("rosetta")}${dim(">")} `,
1420
+ prompt: buildPrompt(),
1214
1421
  terminal: true,
1215
1422
  });
1216
1423
 
@@ -1222,6 +1429,10 @@ async function main() {
1222
1429
  } catch (err) {
1223
1430
  console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
1224
1431
  }
1432
+ // Clear any keys that leaked from the pager into readline's internal line buffer
1433
+ // (the key pressed to exit paging — q/SPACE/ENTER — can appear in the next prompt)
1434
+ rl.write(null as unknown as string, { ctrl: true, name: "u" });
1435
+ rl.setPrompt(buildPrompt());
1225
1436
  rl.prompt();
1226
1437
  });
1227
1438
 
@@ -179,6 +179,13 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
179
179
  const codeLang = codeLangs.size > 0 ? [...codeLangs].join(",") : null;
180
180
  const codeLines = code.split("\n").filter((l) => l.trim()).length;
181
181
 
182
+ // Remove <style> elements before extracting text — they contain Confluence
183
+ // table-of-contents CSS (CDATA blocks like div.rbtoc... {padding:0}) that
184
+ // otherwise pollute the plain-text index and appear in page views.
185
+ for (const styleEl of mainContent?.querySelectorAll("style") ?? []) {
186
+ styleEl.remove();
187
+ }
188
+
182
189
  // Plain text from main content (includes code block text too, which is fine for FTS)
183
190
  const text = mainContent?.textContent?.trim() || "";
184
191
  const wordCount = text.split(/\s+/).filter(Boolean).length;
package/src/mcp.ts CHANGED
@@ -978,6 +978,10 @@ Workflow:
978
978
  → routeros_device_lookup: get full specs (CPU, RAM, pricing) + block diagram for a specific device
979
979
  → routeros_search: find documentation about features relevant to the test type`,
980
980
  inputSchema: {
981
+ device: z
982
+ .string()
983
+ .optional()
984
+ .describe("Filter by device product name (substring match, e.g., 'RB5009', 'hAP', 'CCR2216')"),
981
985
  test_type: z
982
986
  .string()
983
987
  .optional()
@@ -1010,8 +1014,8 @@ Workflow:
1010
1014
  .describe("Max results (default 50, max 200)"),
1011
1015
  },
1012
1016
  },
1013
- async ({ test_type, mode, configuration, packet_size, sort_by, limit }) => {
1014
- const hasFilters = test_type || mode || configuration || packet_size;
1017
+ async ({ device, test_type, mode, configuration, packet_size, sort_by, limit }) => {
1018
+ const hasFilters = device || test_type || mode || configuration || packet_size;
1015
1019
 
1016
1020
  if (!hasFilters) {
1017
1021
  // Discovery mode: return available filter values
@@ -1028,8 +1032,17 @@ Workflow:
1028
1032
  };
1029
1033
  }
1030
1034
 
1035
+ // Round packet_size to the nearest valid test size (64, 512, 1400, 1518)
1036
+ let effectivePacketSize = packet_size;
1037
+ if (packet_size !== undefined) {
1038
+ const validSizes = [64, 512, 1400, 1518];
1039
+ effectivePacketSize = validSizes.reduce((prev, curr) =>
1040
+ Math.abs(curr - packet_size) < Math.abs(prev - packet_size) ? curr : prev,
1041
+ );
1042
+ }
1043
+
1031
1044
  const result = searchDeviceTests(
1032
- { test_type, mode, configuration, packet_size, sort_by },
1045
+ { device, test_type, mode, configuration, packet_size: effectivePacketSize, sort_by },
1033
1046
  limit,
1034
1047
  );
1035
1048
 
@@ -1052,6 +1065,9 @@ Workflow:
1052
1065
  text: JSON.stringify({
1053
1066
  ...result,
1054
1067
  has_more: result.total > result.results.length,
1068
+ ...(effectivePacketSize !== packet_size && packet_size !== undefined
1069
+ ? { packet_size_rounded: `${packet_size} → ${effectivePacketSize} (nearest valid test size)` }
1070
+ : {}),
1055
1071
  }, null, 2),
1056
1072
  }],
1057
1073
  };
package/src/query.test.ts CHANGED
@@ -267,6 +267,10 @@ beforeAll(() => {
267
267
  db.run(`INSERT INTO device_test_results
268
268
  (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
269
269
  VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
270
+ // RB5009UG+S+IN = id 9 (9th device inserted)
271
+ db.run(`INSERT INTO device_test_results
272
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
273
+ VALUES (9, 'ethernet', 'Routing', 'none (fast path)', 1518, 1613.0, 19577.3)`);
270
274
 
271
275
  // Page 3: a "large" page with sections for TOC testing
272
276
  // Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
@@ -1154,14 +1158,14 @@ describe("searchDevices", () => {
1154
1158
  describe("searchDeviceTests", () => {
1155
1159
  test("returns all test results with no filters", () => {
1156
1160
  const res = searchDeviceTests({});
1157
- expect(res.results.length).toBe(3);
1158
- expect(res.total).toBe(3);
1161
+ expect(res.results.length).toBe(4);
1162
+ expect(res.total).toBe(4);
1159
1163
  });
1160
1164
 
1161
1165
  test("filters by test_type", () => {
1162
1166
  const res = searchDeviceTests({ test_type: "ethernet" });
1163
1167
  expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
1164
- expect(res.results.length).toBe(2);
1168
+ expect(res.results.length).toBe(3);
1165
1169
  });
1166
1170
 
1167
1171
  test("filters by test_type and mode", () => {
@@ -1181,6 +1185,32 @@ describe("searchDeviceTests", () => {
1181
1185
  expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
1182
1186
  });
1183
1187
 
1188
+ test("filters by device name (substring match)", () => {
1189
+ const res = searchDeviceTests({ device: "hAP" });
1190
+ expect(res.results).toHaveLength(3);
1191
+ expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
1192
+ });
1193
+
1194
+ test("device filter combined with test_type", () => {
1195
+ const res = searchDeviceTests({ device: "hAP", test_type: "ethernet" });
1196
+ expect(res.results).toHaveLength(2);
1197
+ expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
1198
+ expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
1199
+ });
1200
+
1201
+ test("device filter with no matches returns empty", () => {
1202
+ const res = searchDeviceTests({ device: "Audience" });
1203
+ expect(res.results).toHaveLength(0);
1204
+ expect(res.total).toBe(0);
1205
+ });
1206
+
1207
+ test("device filter for RB5009 returns its test results", () => {
1208
+ const res = searchDeviceTests({ device: "RB5009" });
1209
+ expect(res.results).toHaveLength(1);
1210
+ expect(res.results[0].product_name).toBe("RB5009UG+S+IN");
1211
+ expect(res.results[0].packet_size).toBe(1518);
1212
+ });
1213
+
1184
1214
  test("sorts by mbps descending by default", () => {
1185
1215
  const res = searchDeviceTests({ test_type: "ethernet" });
1186
1216
  if (res.results.length >= 2) {
@@ -1210,7 +1240,7 @@ describe("searchDeviceTests", () => {
1210
1240
  test("respects limit", () => {
1211
1241
  const res = searchDeviceTests({}, 1);
1212
1242
  expect(res.results).toHaveLength(1);
1213
- expect(res.total).toBe(3);
1243
+ expect(res.total).toBe(4);
1214
1244
  });
1215
1245
  });
1216
1246
 
@@ -1220,9 +1250,10 @@ describe("dataset CSV exports", () => {
1220
1250
  const lines = csv.trim().split("\n");
1221
1251
 
1222
1252
  expect(lines[0]).toBe("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency,test_type,mode,configuration,packet_size,throughput_kpps,throughput_mbps,product_url");
1223
- expect(lines).toHaveLength(4);
1253
+ expect(lines).toHaveLength(5);
1224
1254
  expect(csv).toContain("hAP ax3");
1225
1255
  expect(csv).toContain("IPQ-6010");
1256
+ expect(csv).toContain("RB5009UG+S+IN");
1226
1257
  expect(csv).toContain("https://mikrotik.com/product/hap_ax3");
1227
1258
  });
1228
1259
 
package/src/query.ts CHANGED
@@ -458,6 +458,7 @@ export function searchProperties(
458
458
  default_val: string | null;
459
459
  description: string;
460
460
  section: string | null;
461
+ page_id: number;
461
462
  page_title: string;
462
463
  page_url: string;
463
464
  excerpt: string;
@@ -486,6 +487,7 @@ function runPropertiesFtsQuery(
486
487
  default_val: string | null;
487
488
  description: string;
488
489
  section: string | null;
490
+ page_id: number;
489
491
  page_title: string;
490
492
  page_url: string;
491
493
  excerpt: string;
@@ -495,7 +497,7 @@ function runPropertiesFtsQuery(
495
497
  return db
496
498
  .prepare(
497
499
  `SELECT p.name, p.type, p.default_val, p.description, p.section,
498
- pg.title as page_title, pg.url as page_url,
500
+ pg.id as page_id, pg.title as page_title, pg.url as page_url,
499
501
  snippet(properties_fts, 1, '**', '**', '...', 20) as excerpt
500
502
  FROM properties_fts fts
501
503
  JOIN properties p ON p.id = fts.rowid
@@ -509,6 +511,7 @@ function runPropertiesFtsQuery(
509
511
  default_val: string | null;
510
512
  description: string;
511
513
  section: string | null;
514
+ page_id: number;
512
515
  page_title: string;
513
516
  page_url: string;
514
517
  excerpt: string;
@@ -518,7 +521,7 @@ function runPropertiesFtsQuery(
518
521
  }
519
522
  }
520
523
 
521
- type CalloutResult = {
524
+ export type CalloutResult = {
522
525
  type: string;
523
526
  content: string;
524
527
  page_title: string;
@@ -1088,6 +1091,7 @@ export type DeviceTestRow = {
1088
1091
  };
1089
1092
 
1090
1093
  type DeviceTestFilters = {
1094
+ device?: string;
1091
1095
  test_type?: string;
1092
1096
  mode?: string;
1093
1097
  configuration?: string;
@@ -1099,6 +1103,10 @@ function buildTestWhereClause(filters: DeviceTestFilters): { whereClause: string
1099
1103
  const where: string[] = [];
1100
1104
  const params: (string | number)[] = [];
1101
1105
 
1106
+ if (filters.device) {
1107
+ where.push("d.product_name LIKE ?");
1108
+ params.push(`%${filters.device}%`);
1109
+ }
1102
1110
  if (filters.test_type) {
1103
1111
  where.push("t.test_type = ?");
1104
1112
  params.push(filters.test_type);