@tikoci/rosetta 0.8.9 → 0.8.10

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/src/mcp.ts CHANGED
@@ -53,8 +53,8 @@ function link(url: string, display?: string): string {
53
53
  * on fresh installs.
54
54
  *
55
55
  * Failure modes are explicit:
56
- * - Missing or empty DB → download once. If download fails, return server
57
- * will start but tool calls will surface the underlying SQL error.
56
+ * - Missing or empty DB → download once. If download fails, abort startup
57
+ * package-mode startup must not continue into db.ts and create a schema-only DB.
58
58
  * - Schema mismatch → re-download once, then re-probe. If still mismatched,
59
59
  * fail hard with an actionable message rather than silently using a DB
60
60
  * that the running code can't query correctly.
@@ -101,14 +101,17 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
101
101
  p = probe();
102
102
  } catch (e) {
103
103
  log(`Auto-download failed: ${e instanceof Error ? e.message : e}`);
104
- log(`Run: bunx @tikoci/rosetta --refresh`);
105
- return;
104
+ log(`Close other rosetta clients and run: bunx @tikoci/rosetta --refresh`);
105
+ throw new Error(`Unable to start rosetta without a usable database at ${dbPath}.`);
106
106
  }
107
107
  }
108
108
 
109
109
  if (!p) {
110
- log(`Database probe failed after download.`);
111
- return;
110
+ throw new Error(`Database probe failed after download: ${dbPath}`);
111
+ }
112
+
113
+ if (p.pages === 0) {
114
+ throw new Error(`Database remained empty after recovery: ${dbPath}`);
112
115
  }
113
116
 
114
117
  // Case 2: Schema mismatch → re-download, then re-probe and fail hard if
@@ -124,9 +127,9 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
124
127
  log(`✗ Auto-recovery download failed: ${e instanceof Error ? e.message : e}`);
125
128
  log(
126
129
  ` This rosetta build (v${runningVersion}) cannot use the existing DB. ` +
127
- `Run \`bun pm cache rm\` to clear the bunx cache and relaunch.`,
130
+ `Close other rosetta clients, then run \`bun pm cache rm && bunx @tikoci/rosetta --refresh\`.`,
128
131
  );
129
- process.exit(1);
132
+ throw new Error(`Unable to recover an incompatible database at ${dbPath}.`);
130
133
  }
131
134
  const p2 = probe();
132
135
  if (!p2 || p2.schemaVersion !== SCHEMA_VERSION) {
@@ -137,7 +140,7 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
137
140
  ` The published database does not match this rosetta build (v${runningVersion}). ` +
138
141
  `Run \`bun pm cache rm && bunx @tikoci/rosetta --refresh\` to update both the package and the DB.`,
139
142
  );
140
- process.exit(1);
143
+ throw new Error(`Database remained incompatible after recovery: ${dbPath}`);
141
144
  }
142
145
  p = p2;
143
146
  }
@@ -226,6 +229,7 @@ const {
226
229
  browseCommandsAtVersion,
227
230
  checkCommandVersions,
228
231
  diffCommandVersions,
232
+ explainCommand,
229
233
  exportDevicesCsv,
230
234
  exportDeviceTestsCsv,
231
235
  fetchCurrentVersions,
@@ -575,8 +579,8 @@ and FTS in parallel, returning pages plus classifier-informed side queries in a
575
579
  single response. Consolidates what used to require 3–5 separate tool calls.
576
580
 
577
581
  Response shape:
578
- - classified: { version, topics, command_path, device, property } — what the
579
- classifier detected from your input
582
+ - classified: { version, topics, command_path, command_path_confidence, device, property } — what the
583
+ classifier detected from your input; command_path_confidence is high/medium/low
580
584
  - pages: top FTS matches (title, path, URL, excerpt, best_section)
581
585
  - related: callouts, properties, changelogs, videos, commands, devices, skills,
582
586
  glossary — empty sections are omitted. Cap scales with \`limit\`: small limit
@@ -696,8 +700,10 @@ server.registerTool(
696
700
  {
697
701
  description: `Look up a specific RouterOS configuration property by exact name.
698
702
 
699
- Returns type, default value, description, and documentation page.
703
+ Returns type, default value, description, documentation page, and confidence.
700
704
  Optionally filter by command path to disambiguate (e.g., "disabled" appears everywhere).
705
+ Confidence is high for exact matches on the page linked from command_path, medium for
706
+ global exact matches without command_path, and low for global fallback with command_path.
701
707
 
702
708
  This requires the **exact property name**. If you don't know the name:
703
709
  → routeros_search: find the documentation page, then routeros_get_page to read properties in context
@@ -734,6 +740,57 @@ Examples:
734
740
  },
735
741
  );
736
742
 
743
+ // ---- routeros_explain_command ----
744
+
745
+ server.registerTool(
746
+ "routeros_explain_command",
747
+ {
748
+ description: `Explain a candidate RouterOS CLI command using rosetta's offline docs and command tree.
749
+
750
+ This is a read-only tier-1 helper for write-shaped questions: it canonicalizes
751
+ the command, annotates key=value arguments with documented properties, checks
752
+ tracked RouterOS version presence, and returns compact docs/changelog context.
753
+ It never connects to a router, validates against a live device, or executes anything.
754
+
755
+ Returns:
756
+ - command: original input
757
+ - canonical: { path, verb, args, confidence } for the primary non-subshell command
758
+ - confidence: high/medium/low/none from the CLI canonicalizer
759
+ - args: parsed key=value args with first property match and lookup confidence when found
760
+ - warnings: no-command, low-confidence, unknown-arg, command-not-in-version, or model-context-unused signals
761
+ - pages: compact documentation search hits
762
+ - changelog_hits: compact changelog hits
763
+ - version_check: command version range when a canonical path is available
764
+
765
+ Workflow:
766
+ → routeros_get_page: read full docs for a returned page
767
+ → routeros_lookup_property: inspect a specific argument/property in more detail
768
+ → routeros_command_tree: browse available commands/arguments under the canonical path
769
+ → routeros_command_version_check / routeros_command_diff: investigate version-specific availability
770
+
771
+ Boundaries: Documentation covers RouterOS v7, aligned with long-term ~7.22; command data covers 7.9–7.23beta2. This tool is explanatory only — use a separate validator/runner before touching a router.`,
772
+ inputSchema: {
773
+ command: z
774
+ .string()
775
+ .describe("RouterOS CLI command to explain (e.g., '/ip/firewall/filter add chain=forward action=drop')"),
776
+ ros_version: z
777
+ .string()
778
+ .optional()
779
+ .describe("Optional RouterOS version to check against tracked command availability (e.g., '7.22')."),
780
+ model: z
781
+ .string()
782
+ .optional()
783
+ .describe("Optional device model context. Accepted for future use; device-specific validation is not implemented."),
784
+ },
785
+ },
786
+ async ({ command, ros_version, model }) => {
787
+ const result = explainCommand(command, ros_version, model);
788
+ return {
789
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
790
+ };
791
+ },
792
+ );
793
+
737
794
  // ---- routeros_command_tree ----
738
795
 
739
796
  server.registerTool(
@@ -811,7 +868,9 @@ Knowledge boundaries:
811
868
  - No RouterOS v6 data available — v6 syntax and subsystems differ significantly from v7
812
869
  - For versions older than 7.9, no command tree data exists
813
870
  - Versions older than current long-term are unpatched by MikroTik
814
- - Absence of a peripheral in docs doesn't mean unsupported — most MBIM modems work`,
871
+ - Absence of a peripheral in docs doesn't mean unsupported — most MBIM modems work
872
+
873
+ → routeros_search: probe the corpus these stats describe with any RouterOS question`,
815
874
  inputSchema: {},
816
875
  },
817
876
  async () => {
@@ -1402,7 +1461,9 @@ Key context for version reasoning:
1402
1461
  - Our command tree data covers 7.9–7.23beta2
1403
1462
  - If a user's version is older than the current long-term, recommend upgrading
1404
1463
 
1405
- Requires network access to upgrade.mikrotik.com.`,
1464
+ Requires network access to upgrade.mikrotik.com.
1465
+
1466
+ → routeros_search_changelogs: read what changed *into* the current/target version (use to_version=<latest> and from_version=<user's version>)`,
1406
1467
  inputSchema: {},
1407
1468
  },
1408
1469
  async () => {
@@ -1558,4 +1619,8 @@ if (useHttp) {
1558
1619
  await server.connect(transport);
1559
1620
  }
1560
1621
 
1561
- })();
1622
+ })().catch((err) => {
1623
+ const message = err instanceof Error ? err.message : String(err);
1624
+ process.stderr.write(`${message}\n`);
1625
+ process.exit(1);
1626
+ });
package/src/query.test.ts CHANGED
@@ -54,6 +54,7 @@ const {
54
54
  listGlossary,
55
55
  KNOWN_TOPICS,
56
56
  searchAll,
57
+ explainCommand,
57
58
  } = await import("./query.ts");
58
59
  const { parseChangelog } = await import("./extract-changelogs.ts");
59
60
  const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
@@ -122,6 +123,14 @@ beforeAll(() => {
122
123
  (id, page_id, name, type, default_val, description, section, sort_order)
123
124
  VALUES (2, 1, 'address-pool', 'string', '', 'Name of the address pool to use', NULL, 1)`);
124
125
 
126
+ db.run(`INSERT INTO properties
127
+ (id, page_id, name, type, default_val, description, section, sort_order)
128
+ VALUES (3, 2, 'chain', 'string', '', 'Firewall chain to match or create', NULL, 0)`);
129
+
130
+ db.run(`INSERT INTO properties
131
+ (id, page_id, name, type, default_val, description, section, sort_order)
132
+ VALUES (4, 2, 'action', 'string', 'accept', 'Action to take when a packet matches the rule', NULL, 1)`);
133
+
125
134
  db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
126
135
  VALUES ('7.22', 'x86', 'stable', 0, '2024-01-01T00:00:00Z')`);
127
136
  db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
@@ -137,6 +146,14 @@ beforeAll(() => {
137
146
  (id, path, name, type, parent_path, page_id, description, ros_version)
138
147
  VALUES (2, '/ip/dhcp-server', 'dhcp-server', 'dir', '/ip', 1, 'DHCP Server configuration', '7.22')`);
139
148
 
149
+ db.run(`INSERT INTO commands
150
+ (id, path, name, type, parent_path, page_id, description, ros_version)
151
+ VALUES (3, '/ip/firewall', 'firewall', 'dir', '/ip', 2, 'Firewall configuration', '7.22')`);
152
+
153
+ db.run(`INSERT INTO commands
154
+ (id, path, name, type, parent_path, page_id, description, ros_version)
155
+ VALUES (4, '/ip/firewall/filter', 'filter', 'dir', '/ip/firewall', 2, 'Firewall filter rules', '7.22')`);
156
+
140
157
  // schema_nodes for arch-filter tests: shared + x86-only child of /ip
141
158
  db.run(`INSERT INTO schema_nodes (path, name, type, parent_path, dir_role, _arch)
142
159
  VALUES ('/ip', 'ip', 'dir', NULL, 'namespace', NULL)`);
@@ -153,6 +170,8 @@ beforeAll(() => {
153
170
  VALUES ('/ip/dhcp-server', '7.22')`);
154
171
  db.run(`INSERT INTO command_versions (command_path, ros_version)
155
172
  VALUES ('/ip/dhcp-server', '7.9')`);
173
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
174
+ VALUES ('/ip/firewall/filter', '7.22')`);
156
175
 
157
176
  // Extra command_versions entries to support diffCommandVersions tests
158
177
  // /ip/dhcp-server/lease only in 7.22 (added)
@@ -383,8 +402,12 @@ beforeAll(() => {
383
402
  VALUES ('7.22', '2026-Mar-09 10:38', 'bgp', 0, 'added BGP unnumbered support', 1)`);
384
403
  db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
385
404
  VALUES ('7.22', '2026-Mar-09 10:38', 'bridge', 0, 'added local and static MAC synchronization for MLAG', 2)`);
405
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
406
+ VALUES ('7.22', '2026-Mar-09 10:38', 'firewall', 0, 'improved firewall filter rule handling', 3)`);
386
407
  db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
387
408
  VALUES ('7.22.1', '2026-Apr-01 09:00', 'wifi', 0, 'fixed channel switching for MediaTek access points', 0)`);
409
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
410
+ VALUES ('7.23.1', '2026-Apr-15 09:00', 'routing', 0, 'fixed route cache after upgrade', 0)`);
388
411
 
389
412
  // Video and transcript fixtures for searchVideos tests
390
413
  db.run(`INSERT INTO videos
@@ -460,6 +483,10 @@ describe("extractTerms", () => {
460
483
  expect(extractTerms("how and the with without")).toEqual([]);
461
484
  });
462
485
 
486
+ test("drops switch as context for bridge VLAN filtering queries", () => {
487
+ expect(extractTerms("bridge vlan filtering on a switch")).toEqual(["bridge", "vlan", "filtering"]);
488
+ });
489
+
463
490
  test("filters terms shorter than 2 characters", () => {
464
491
  expect(extractTerms("a x y")).toEqual([]);
465
492
  });
@@ -777,11 +804,13 @@ describe("lookupProperty", () => {
777
804
  expect(rows.length).toBeGreaterThan(0);
778
805
  expect(rows[0].name).toBe("lease-time");
779
806
  expect(rows[0].page_title).toBe("DHCP Server");
807
+ expect(rows[0].confidence).toBe("medium");
780
808
  });
781
809
 
782
810
  test("case-insensitive name lookup", () => {
783
811
  const rows = lookupProperty("LEASE-TIME");
784
812
  expect(rows.length).toBeGreaterThan(0);
813
+ expect(rows.every((row) => row.confidence === "medium")).toBe(true);
785
814
  });
786
815
 
787
816
  test("returns empty for unknown property", () => {
@@ -792,12 +821,74 @@ describe("lookupProperty", () => {
792
821
  const rows = lookupProperty("lease-time", "/ip/dhcp-server");
793
822
  expect(rows.length).toBeGreaterThan(0);
794
823
  expect(rows[0].name).toBe("lease-time");
824
+ expect(rows[0].confidence).toBe("high");
795
825
  });
796
826
 
797
- test("returns empty when command path has no linked page", () => {
827
+ test("marks global fallback low when command path has no linked page", () => {
798
828
  const rows = lookupProperty("lease-time", "/ip/unlinked");
799
829
  // /ip/unlinked has no page_id → falls through to global search
800
830
  expect(Array.isArray(rows)).toBe(true);
831
+ expect(rows.length).toBeGreaterThan(0);
832
+ expect(rows.every((row) => row.confidence === "low")).toBe(true);
833
+ });
834
+ });
835
+
836
+ // ---------------------------------------------------------------------------
837
+ // DB integration: explainCommand
838
+ // ---------------------------------------------------------------------------
839
+
840
+ describe("explainCommand", () => {
841
+ test("returns canonical command, known arg property, pages, changelog hits, and version check", () => {
842
+ const result = explainCommand("/ip firewall filter add chain=forward action=drop", "7.22");
843
+
844
+ expect(result.command).toBe("/ip firewall filter add chain=forward action=drop");
845
+ expect(result.canonical).toEqual({
846
+ path: "/ip/firewall/filter",
847
+ verb: "add",
848
+ args: ["chain=forward", "action=drop"],
849
+ confidence: "high",
850
+ });
851
+ expect(result.confidence).toBe("high");
852
+ expect(result.args.map((arg) => arg.name)).toEqual(["chain", "action"]);
853
+ expect(result.args[0].property?.name).toBe("chain");
854
+ expect(result.args[0].property?.confidence).toBe("high");
855
+ expect(result.warnings).toEqual([]);
856
+ expect(result.pages.some((page) => page.title === "Firewall Filter")).toBe(true);
857
+ expect(result.changelog_hits.some((hit) => hit.category === "firewall")).toBe(true);
858
+ expect(result.version_check?.versions).toContain("7.22");
859
+ });
860
+
861
+ test("warns for unknown args, absent target versions, and unused model context", () => {
862
+ const result = explainCommand("/ip firewall filter add frobnicate=yes", "7.9", "hAP ax3");
863
+
864
+ expect(result.canonical?.path).toBe("/ip/firewall/filter");
865
+ expect(result.confidence).toBe("high");
866
+ expect(result.args[0]).toMatchObject({ name: "frobnicate", value: "yes" });
867
+ expect(result.args[0].property).toBeUndefined();
868
+ expect(result.warnings.map((warning) => warning.kind)).toEqual(
869
+ expect.arrayContaining([
870
+ "unknown-arg",
871
+ "command-not-in-version",
872
+ "model-context-unused",
873
+ ]),
874
+ );
875
+ });
876
+
877
+ test("warns on low-confidence canonicalization", () => {
878
+ const result = explainCommand("print");
879
+
880
+ expect(result.canonical).toMatchObject({ path: "/", verb: "print", confidence: "low" });
881
+ expect(result.confidence).toBe("low");
882
+ expect(result.warnings.some((warning) => warning.kind === "low-confidence")).toBe(true);
883
+ });
884
+
885
+ test("warns when no primary command can be extracted", () => {
886
+ const result = explainCommand("not a routeros command");
887
+
888
+ expect(result.canonical).toBeNull();
889
+ expect(result.confidence).toBe("none");
890
+ expect(result.args).toEqual([]);
891
+ expect(result.warnings.some((warning) => warning.kind === "no-command")).toBe(true);
801
892
  });
802
893
  });
803
894
 
@@ -1563,12 +1654,33 @@ describe("searchChangelogs", () => {
1563
1654
  expect(results.some((r) => r.category === "bgp")).toBe(true);
1564
1655
  });
1565
1656
 
1566
- test("version filter returns only that version", () => {
1657
+ test("exact patch version stays exact", () => {
1658
+ const results = searchChangelogs("", { version: "7.22.1" });
1659
+ expect(results.length).toBeGreaterThan(0);
1660
+ for (const r of results) {
1661
+ expect(r.version).toBe("7.22.1");
1662
+ }
1663
+ });
1664
+
1665
+ test("major.minor version filter preserves exact rows before patch fallback", () => {
1567
1666
  const results = searchChangelogs("", { version: "7.22" });
1568
1667
  expect(results.length).toBeGreaterThan(0);
1569
1668
  for (const r of results) {
1570
1669
  expect(r.version).toBe("7.22");
1571
1670
  }
1671
+ expect(results.some((r) => r.version === "7.22.1")).toBe(false);
1672
+ });
1673
+
1674
+ test("major.minor version filter falls back to patch rows when exact rows are absent", () => {
1675
+ const results = searchChangelogs("", { version: "7.23" });
1676
+ expect(results.length).toBeGreaterThan(0);
1677
+ expect(results.every((r) => r.version === "7.23.1")).toBe(true);
1678
+ });
1679
+
1680
+ test("generic major.minor version questions browse fallback patch rows", () => {
1681
+ const results = searchChangelogs("what changed in 7.23", { version: "7.23" });
1682
+ expect(results.length).toBeGreaterThan(0);
1683
+ expect(results.every((r) => r.version === "7.23.1")).toBe(true);
1572
1684
  });
1573
1685
 
1574
1686
  test("version range filter works (from_version exclusive, to_version inclusive)", () => {
@@ -1885,7 +1997,19 @@ Today we will cover trunking. Let us begin.
1885
1997
  const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n<b>Bold text</b> and <i>italic</i>.\n`;
1886
1998
  const cues = parseVtt(vtt);
1887
1999
  expect(cues[0].text).not.toContain("<b>");
1888
- expect(cues[0].text).toContain("Bold text");
2000
+ expect(cues[0].text).toBe("Bold text and italic.");
2001
+ });
2002
+
2003
+ test("drops malformed cue markup without leaking tag fragments", () => {
2004
+ const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n<scr<script>ipt>alert</scr<script>ipt> safe text.\n`;
2005
+ const cues = parseVtt(vtt);
2006
+ expect(cues[0].text).toBe("safe text.");
2007
+ });
2008
+
2009
+ test("recovers plain text after malformed cue markup", () => {
2010
+ const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nClick here<a h<a href='x'>ref='evil'>safe text.\n`;
2011
+ const cues = parseVtt(vtt);
2012
+ expect(cues[0].text).toBe("Click heresafe text.");
1889
2013
  });
1890
2014
  });
1891
2015
 
@@ -2169,6 +2293,25 @@ describe("listGlossary", () => {
2169
2293
  // ---------------------------------------------------------------------------
2170
2294
 
2171
2295
  describe("searchAll related block", () => {
2296
+ test("exposes command path confidence in classified output", () => {
2297
+ const res = searchAll("/ip/dhcp-server");
2298
+ expect(res.classified.command_path).toBe("/ip/dhcp-server");
2299
+ expect(res.classified.command_path_confidence).toBe("medium");
2300
+ });
2301
+
2302
+ test("includes property lookup confidence in related properties", () => {
2303
+ const res = searchAll("lease-time");
2304
+ expect(res.related.properties?.length).toBeGreaterThan(0);
2305
+ expect(res.related.properties?.[0].confidence).toBe("medium");
2306
+ });
2307
+
2308
+ test("includes changelogs for major.minor version questions backed only by patch rows", () => {
2309
+ const res = searchAll("what changed in 7.23");
2310
+ expect(res.classified.version).toBe("7.23");
2311
+ expect(res.related.changelogs?.length).toBeGreaterThan(0);
2312
+ expect(res.related.changelogs?.every((row) => row.version === "7.23.1")).toBe(true);
2313
+ });
2314
+
2172
2315
  test("includes glossary when input matches a glossary term", () => {
2173
2316
  const res = searchAll("chr");
2174
2317
  expect(res.related).toBeDefined();