@tikoci/rosetta 0.4.3 → 0.4.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.
@@ -315,7 +315,7 @@ describe("HTTP transport: session lifecycle", () => {
315
315
  expect(serverInfo.name).toBe("rosetta");
316
316
  });
317
317
 
318
- test("tools/list returns all 11 tools after initialization", async () => {
318
+ test("tools/list returns all 14 tools after initialization", async () => {
319
319
  const { sessionId } = await mcpInitialize(server.url);
320
320
 
321
321
  // Send initialized notification first (required by protocol)
@@ -326,7 +326,7 @@ describe("HTTP transport: session lifecycle", () => {
326
326
 
327
327
  const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
328
328
  const tools = result.tools as Array<{ name: string }>;
329
- expect(tools.length).toBe(13);
329
+ expect(tools.length).toBe(14);
330
330
 
331
331
  const toolNames = tools.map((t) => t.name).sort();
332
332
  expect(toolNames).toContain("routeros_search");
@@ -547,8 +547,8 @@ describe("HTTP transport: multi-session", () => {
547
547
  const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
548
548
  const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
549
549
 
550
- expect(tools1.length).toBe(13);
551
- expect(tools2.length).toBe(13);
550
+ expect(tools1.length).toBe(14);
551
+ expect(tools2.length).toBe(14);
552
552
  });
553
553
 
554
554
  test("deleting one session does not affect another", async () => {
@@ -570,7 +570,7 @@ describe("HTTP transport: multi-session", () => {
570
570
  // Client2 still works
571
571
  const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
572
572
  const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
573
- expect(tools.length).toBe(13);
573
+ expect(tools.length).toBe(14);
574
574
 
575
575
  // Client1 is gone
576
576
  const resp = await fetch(server.url, {
package/src/mcp.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * and browse the command tree.
7
7
  *
8
8
  * CLI flags (for compiled binary or `bun run src/mcp.ts`):
9
+ * browse Interactive terminal browser (REPL)
9
10
  * --setup [--force] Download database + print MCP client config
10
11
  * --version Print version
11
12
  * --help Print usage
@@ -55,6 +56,7 @@ if (args.includes("--help") || args.includes("-h")) {
55
56
  console.log("Usage:");
56
57
  console.log(" rosetta Start MCP server (stdio transport)");
57
58
  console.log(" rosetta --http Start with Streamable HTTP transport");
59
+ console.log(" rosetta browse Interactive terminal browser");
58
60
  console.log(" rosetta --setup Download database + print MCP client config");
59
61
  console.log(" rosetta --setup --force Re-download database");
60
62
  console.log(" rosetta --version Print version");
@@ -80,6 +82,13 @@ if (args.includes("--help") || args.includes("-h")) {
80
82
  // Wrap in async IIFE — bun build --compile does not support top-level await
81
83
  (async () => {
82
84
 
85
+ if (args[0] === "browse") {
86
+ // Strip "browse" from argv so browse.ts only sees flags/queries
87
+ process.argv.splice(2, 1);
88
+ await import("./browse.ts");
89
+ return;
90
+ }
91
+
83
92
  if (args.includes("--setup")) {
84
93
  const { runSetup } = await import("./setup.ts");
85
94
  await runSetup(args.includes("--force"));
@@ -172,6 +181,7 @@ const {
172
181
  getTestResultMeta,
173
182
  searchPages,
174
183
  searchProperties,
184
+ searchVideos,
175
185
  } = await import("./query.ts");
176
186
 
177
187
  initDb();
@@ -240,6 +250,7 @@ Workflow — what to do next:
240
250
  → routeros_search_properties: find specific properties mentioned in results
241
251
  → routeros_search_callouts: find warnings/notes about topics in results
242
252
  → routeros_command_tree: browse the command hierarchy for a feature
253
+ → routeros_search_videos: search MikroTik YouTube video transcripts for tutorials and demos
243
254
 
244
255
  Tips:
245
256
  - Use specific technical terms: "DHCP relay agent" not "how to set up DHCP"
@@ -664,6 +675,56 @@ Coverage depends on which versions were extracted — typically matches ros_vers
664
675
  },
665
676
  );
666
677
 
678
+ // ---- routeros_search_videos ----
679
+
680
+ server.registerTool(
681
+ "routeros_search_videos",
682
+ {
683
+ description: `Search MikroTik YouTube video transcripts for RouterOS topics.
684
+
685
+ Searches chapter-level transcript segments from official MikroTik YouTube videos.
686
+ Returns matching segments with video title, URL, chapter name, and an excerpt.
687
+ Auto-caption quality varies — short config snippets may not appear verbatim.
688
+
689
+ MUM conference talks are excluded (those are long, off-topic lectures).
690
+ Results include the video URL and start timestamp for direct chapter linking.
691
+
692
+ Useful for: finding tutorial walkthroughs, feature announcements, demo configs,
693
+ and explanations that complement the text documentation.
694
+
695
+ → routeros_search: search official text documentation (more precise for property names)
696
+ → routeros_get_page: read full documentation page for a topic
697
+ → routeros_search_callouts: find Warnings/Notes embedded in documentation`,
698
+ inputSchema: {
699
+ query: z.string().describe("Topic to search for in video transcripts (e.g., 'VLAN trunking', 'BGP route reflection')"),
700
+ limit: z
701
+ .number()
702
+ .int()
703
+ .min(1)
704
+ .max(20)
705
+ .default(5)
706
+ .optional()
707
+ .describe("Max results (1–20, default 5)"),
708
+ },
709
+ },
710
+ async ({ query, limit }) => {
711
+ const results = searchVideos(query, limit ?? 5);
712
+ if (results.length === 0) {
713
+ return {
714
+ content: [
715
+ {
716
+ type: "text",
717
+ text: `No video transcript results for: "${query}"\n\nTry:\n- Broader or simpler search terms\n- routeros_search for official documentation\n- routeros_search_callouts for Notes and Warnings in docs`,
718
+ },
719
+ ],
720
+ };
721
+ }
722
+ return {
723
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
724
+ };
725
+ },
726
+ );
727
+
667
728
  // ---- routeros_command_version_check ----
668
729
 
669
730
  server.registerTool(
package/src/query.test.ts CHANGED
@@ -31,8 +31,10 @@ const {
31
31
  searchDeviceTests,
32
32
  getTestResultMeta,
33
33
  normalizeDeviceQuery,
34
+ searchVideos,
34
35
  } = await import("./query.ts");
35
36
  const { parseChangelog } = await import("./extract-changelogs.ts");
37
+ const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
36
38
 
37
39
  // ---------------------------------------------------------------------------
38
40
  // Fixtures: one "DHCP Server" page + one "Firewall Filter" page
@@ -320,6 +322,30 @@ beforeAll(() => {
320
322
  VALUES ('7.22', '2026-Mar-09 10:38', 'bridge', 0, 'added local and static MAC synchronization for MLAG', 2)`);
321
323
  db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
322
324
  VALUES ('7.22.1', '2026-Apr-01 09:00', 'wifi', 0, 'fixed channel switching for MediaTek access points', 0)`);
325
+
326
+ // Video and transcript fixtures for searchVideos tests
327
+ db.run(`INSERT INTO videos
328
+ (id, video_id, title, description, channel, upload_date, duration_s, url, has_chapters)
329
+ VALUES
330
+ (1, 'abc123', 'RouterOS VLAN Tutorial', 'How to configure VLANs on MikroTik', 'MikroTik', '20240101', 600,
331
+ 'https://www.youtube.com/watch?v=abc123', 1)`);
332
+ db.run(`INSERT INTO videos
333
+ (id, video_id, title, description, channel, upload_date, duration_s, url, has_chapters)
334
+ VALUES
335
+ (2, 'def456', 'BGP Routing with RouterOS', 'Advanced BGP configuration', 'MikroTik', '20240201', 900,
336
+ 'https://www.youtube.com/watch?v=def456', 0)`);
337
+ db.run(`INSERT INTO video_segments
338
+ (id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
339
+ VALUES
340
+ (1, 1, 'Introduction', 0, 120, 'Welcome to the VLAN tutorial. In this video we cover VLAN trunking on MikroTik bridge.', 0)`);
341
+ db.run(`INSERT INTO video_segments
342
+ (id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
343
+ VALUES
344
+ (2, 1, 'Bridge VLAN configuration', 120, 600, 'To set up bridge VLAN filtering enable vlan-filtering on the bridge interface.', 1)`);
345
+ db.run(`INSERT INTO video_segments
346
+ (id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
347
+ VALUES
348
+ (3, 2, NULL, 0, NULL, 'BGP peering and route reflection allow scalable routing in large networks.', 0)`);
323
349
  });
324
350
 
325
351
  // ---------------------------------------------------------------------------
@@ -1381,6 +1407,7 @@ describe("schema", () => {
1381
1407
  "pages", "properties", "callouts", "sections",
1382
1408
  "commands", "command_versions", "ros_versions",
1383
1409
  "devices", "device_test_results", "changelogs", "schema_migrations",
1410
+ "videos", "video_segments",
1384
1411
  ];
1385
1412
  for (const table of expected) {
1386
1413
  expect(names).toContain(table);
@@ -1389,7 +1416,7 @@ describe("schema", () => {
1389
1416
 
1390
1417
  test("all FTS5 virtual tables exist", () => {
1391
1418
  const names = tableNames();
1392
- const expected = ["pages_fts", "properties_fts", "callouts_fts", "devices_fts", "changelogs_fts"];
1419
+ const expected = ["pages_fts", "properties_fts", "callouts_fts", "devices_fts", "changelogs_fts", "videos_fts", "video_segments_fts"];
1393
1420
  for (const fts of expected) {
1394
1421
  expect(names).toContain(fts);
1395
1422
  }
@@ -1430,6 +1457,20 @@ describe("schema", () => {
1430
1457
  expect(triggers).toContain("changelogs_au");
1431
1458
  });
1432
1459
 
1460
+ test("content-sync triggers exist for videos", () => {
1461
+ const triggers = triggerNames();
1462
+ expect(triggers).toContain("videos_ai");
1463
+ expect(triggers).toContain("videos_ad");
1464
+ expect(triggers).toContain("videos_au");
1465
+ });
1466
+
1467
+ test("content-sync triggers exist for video_segments", () => {
1468
+ const triggers = triggerNames();
1469
+ expect(triggers).toContain("video_segs_ai");
1470
+ expect(triggers).toContain("video_segs_ad");
1471
+ expect(triggers).toContain("video_segs_au");
1472
+ });
1473
+
1433
1474
  test("PRAGMA user_version matches SCHEMA_VERSION", () => {
1434
1475
  const result = checkSchemaVersion();
1435
1476
  expect(result.ok).toBe(true);
@@ -1450,3 +1491,148 @@ describe("getDbStats", () => {
1450
1491
  expect(stats.ros_version_max).toBe("7.22");
1451
1492
  });
1452
1493
  });
1494
+
1495
+ // ---------------------------------------------------------------------------
1496
+ // parseVtt: WebVTT parsing (pure function, no DB)
1497
+ // ---------------------------------------------------------------------------
1498
+
1499
+ describe("parseVtt", () => {
1500
+ const SIMPLE_VTT = `WEBVTT
1501
+ Kind: captions
1502
+ Language: en
1503
+
1504
+ 00:00:01.000 --> 00:00:04.000
1505
+ Hello and welcome to the VLAN tutorial.
1506
+
1507
+ 00:00:04.000 --> 00:00:07.000
1508
+ Hello and welcome to the VLAN tutorial. Today we will cover trunking.
1509
+
1510
+ 00:00:07.000 --> 00:00:10.000
1511
+ Today we will cover trunking. Let us begin.
1512
+ `;
1513
+
1514
+ test("parses cue start times correctly", () => {
1515
+ const cues = parseVtt(SIMPLE_VTT);
1516
+ expect(cues.length).toBeGreaterThan(0);
1517
+ expect(cues[0].start_s).toBe(1);
1518
+ });
1519
+
1520
+ test("deduplicates overlapping auto-caption cues", () => {
1521
+ // Second cue text is suffix of third — only unique segments should survive
1522
+ const cues = parseVtt(SIMPLE_VTT);
1523
+ // All cue texts should be unique (no exact duplicates)
1524
+ const texts = cues.map((c) => c.text);
1525
+ const unique = new Set(texts);
1526
+ expect(unique.size).toBe(texts.length);
1527
+ });
1528
+
1529
+ test("returns empty array for empty input", () => {
1530
+ expect(parseVtt("")).toEqual([]);
1531
+ });
1532
+
1533
+ test("returns empty array for header-only VTT", () => {
1534
+ expect(parseVtt("WEBVTT\nKind: captions\n")).toEqual([]);
1535
+ });
1536
+
1537
+ test("strips HTML tags from cue text", () => {
1538
+ const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n<b>Bold text</b> and <i>italic</i>.\n`;
1539
+ const cues = parseVtt(vtt);
1540
+ expect(cues[0].text).not.toContain("<b>");
1541
+ expect(cues[0].text).toContain("Bold text");
1542
+ });
1543
+ });
1544
+
1545
+ // ---------------------------------------------------------------------------
1546
+ // segmentTranscript: chapter grouping (pure function, no DB)
1547
+ // ---------------------------------------------------------------------------
1548
+
1549
+ describe("segmentTranscript", () => {
1550
+ const cues = [
1551
+ { start_s: 0, text: "Introduction begins now." },
1552
+ { start_s: 10, text: "This covers the basics." },
1553
+ { start_s: 60, text: "Chapter two starts here." },
1554
+ { start_s: 90, text: "Configuration details follow." },
1555
+ ];
1556
+
1557
+ test("single segment when no chapters provided", () => {
1558
+ const segments = segmentTranscript(cues);
1559
+ expect(segments).toHaveLength(1);
1560
+ expect(segments[0].chapter_title).toBeNull();
1561
+ expect(segments[0].start_s).toBe(0);
1562
+ expect(segments[0].end_s).toBeNull();
1563
+ expect(segments[0].transcript).toContain("Introduction begins now.");
1564
+ });
1565
+
1566
+ test("splits by chapter boundaries", () => {
1567
+ const chapters = [
1568
+ { title: "Introduction", start_time: 0, end_time: 60 },
1569
+ { title: "Configuration", start_time: 60, end_time: 120 },
1570
+ ];
1571
+ const segments = segmentTranscript(cues, chapters);
1572
+ expect(segments).toHaveLength(2);
1573
+ expect(segments[0].chapter_title).toBe("Introduction");
1574
+ expect(segments[1].chapter_title).toBe("Configuration");
1575
+ });
1576
+
1577
+ test("cues are assigned to correct chapters", () => {
1578
+ const chapters = [
1579
+ { title: "Introduction", start_time: 0, end_time: 60 },
1580
+ { title: "Configuration", start_time: 60, end_time: 120 },
1581
+ ];
1582
+ const segments = segmentTranscript(cues, chapters);
1583
+ expect(segments[0].transcript).toContain("Introduction begins now.");
1584
+ expect(segments[0].transcript).not.toContain("Chapter two");
1585
+ expect(segments[1].transcript).toContain("Chapter two starts here.");
1586
+ });
1587
+
1588
+ test("chapter start_s and end_s are set correctly", () => {
1589
+ const chapters = [
1590
+ { title: "Intro", start_time: 0, end_time: 60 },
1591
+ { title: "Body", start_time: 60, end_time: 300 },
1592
+ ];
1593
+ const segments = segmentTranscript(cues, chapters);
1594
+ expect(segments[0].start_s).toBe(0);
1595
+ expect(segments[0].end_s).toBe(60); // next chapter start
1596
+ expect(segments[1].start_s).toBe(60);
1597
+ expect(segments[1].end_s).toBe(300);
1598
+ });
1599
+ });
1600
+
1601
+ // ---------------------------------------------------------------------------
1602
+ // searchVideos: FTS against video_segments_fts (integration, uses fixture DB)
1603
+ // ---------------------------------------------------------------------------
1604
+
1605
+ describe("searchVideos", () => {
1606
+ test("finds segments matching query", () => {
1607
+ const results = searchVideos("VLAN trunking bridge");
1608
+ expect(results.length).toBeGreaterThan(0);
1609
+ expect(results[0].title).toBe("RouterOS VLAN Tutorial");
1610
+ });
1611
+
1612
+ test("returns video_id and url", () => {
1613
+ const results = searchVideos("VLAN");
1614
+ expect(results[0].video_id).toBe("abc123");
1615
+ expect(results[0].url).toContain("youtube.com");
1616
+ });
1617
+
1618
+ test("returns chapter_title when available", () => {
1619
+ const results = searchVideos("vlan filtering bridge");
1620
+ const chapterResult = results.find((r) => r.chapter_title !== null);
1621
+ expect(chapterResult).toBeDefined();
1622
+ });
1623
+
1624
+ test("returns null chapter_title for no-chapter video", () => {
1625
+ const results = searchVideos("BGP peering route reflection");
1626
+ expect(results.length).toBeGreaterThan(0);
1627
+ expect(results[0].chapter_title).toBeNull();
1628
+ });
1629
+
1630
+ test("returns empty array for empty query", () => {
1631
+ expect(searchVideos("")).toEqual([]);
1632
+ });
1633
+
1634
+ test("respects limit parameter", () => {
1635
+ const results = searchVideos("RouterOS", 1);
1636
+ expect(results.length).toBeLessThanOrEqual(1);
1637
+ });
1638
+ });
package/src/query.ts CHANGED
@@ -1429,6 +1429,57 @@ function runChangelogFtsQuery(
1429
1429
  }
1430
1430
  }
1431
1431
 
1432
+ // ── Video/transcript search ──
1433
+
1434
+ export type VideoSearchResult = {
1435
+ video_id: string;
1436
+ title: string;
1437
+ url: string;
1438
+ upload_date: string | null;
1439
+ chapter_title: string | null;
1440
+ start_s: number;
1441
+ excerpt: string;
1442
+ };
1443
+
1444
+ /** Search YouTube video transcripts via FTS, joining segment → video metadata. */
1445
+ export function searchVideos(query: string, limit = 5): VideoSearchResult[] {
1446
+ const terms = extractTerms(query);
1447
+ if (terms.length === 0) return [];
1448
+
1449
+ let ftsQuery = buildFtsQuery(terms, "AND");
1450
+ if (!ftsQuery) return [];
1451
+ let results = runVideosFtsQuery(ftsQuery, limit);
1452
+
1453
+ // Fallback to OR if AND returns nothing and we have multiple terms
1454
+ if (results.length === 0 && terms.length > 1) {
1455
+ ftsQuery = buildFtsQuery(terms, "OR");
1456
+ results = runVideosFtsQuery(ftsQuery, limit);
1457
+ }
1458
+
1459
+ return results;
1460
+ }
1461
+
1462
+ function runVideosFtsQuery(ftsQuery: string, limit: number): VideoSearchResult[] {
1463
+ if (!ftsQuery) return [];
1464
+ try {
1465
+ return db
1466
+ .prepare(
1467
+ `SELECT v.video_id, v.title, v.url, v.upload_date,
1468
+ vs.chapter_title, vs.start_s,
1469
+ snippet(video_segments_fts, 1, '**', '**', '...', 25) as excerpt
1470
+ FROM video_segments_fts fts
1471
+ JOIN video_segments vs ON vs.id = fts.rowid
1472
+ JOIN videos v ON v.id = vs.video_id
1473
+ WHERE video_segments_fts MATCH ?
1474
+ ORDER BY rank
1475
+ LIMIT ?`,
1476
+ )
1477
+ .all(ftsQuery, limit) as VideoSearchResult[];
1478
+ } catch {
1479
+ return [];
1480
+ }
1481
+ }
1482
+
1432
1483
  // ── Current versions ──
1433
1484
 
1434
1485
  const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
@@ -190,6 +190,18 @@ describe("Makefile", () => {
190
190
  expect(makefile).toContain("release:");
191
191
  });
192
192
 
193
+ test("has extract-videos target", () => {
194
+ expect(makefile).toContain("extract-videos:");
195
+ });
196
+
197
+ test("extract-videos is in PHONY", () => {
198
+ // PHONY uses line continuation; check block before first blank line after .PHONY
199
+ const phonyStart = makefile.indexOf(".PHONY:");
200
+ const phonyEnd = makefile.indexOf("\n\n", phonyStart);
201
+ const phonyBlock = makefile.slice(phonyStart, phonyEnd);
202
+ expect(phonyBlock).toContain("extract-videos");
203
+ });
204
+
193
205
  test("release depends on preflight", () => {
194
206
  expect(makefile).toMatch(/^release:.*preflight/m);
195
207
  });