@tikoci/rosetta 0.6.7 → 0.6.9

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/query.test.ts CHANGED
@@ -34,6 +34,9 @@ const {
34
34
  searchVideos,
35
35
  searchDude,
36
36
  getDudePage,
37
+ listSkills,
38
+ getSkill,
39
+ getSkillReference,
37
40
  } = await import("./query.ts");
38
41
  const { parseChangelog } = await import("./extract-changelogs.ts");
39
42
  const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
@@ -92,6 +95,18 @@ beforeAll(() => {
92
95
  (id, path, name, type, parent_path, page_id, description, ros_version)
93
96
  VALUES (2, '/ip/dhcp-server', 'dhcp-server', 'dir', '/ip', 1, 'DHCP Server configuration', '7.22')`);
94
97
 
98
+ // schema_nodes for arch-filter tests: shared + x86-only child of /ip
99
+ db.run(`INSERT INTO schema_nodes (path, name, type, parent_path, dir_role, _arch)
100
+ VALUES ('/ip', 'ip', 'dir', NULL, 'namespace', NULL)`);
101
+ db.run(`INSERT INTO schema_nodes (path, name, type, parent_path, dir_role, _arch)
102
+ VALUES ('/ip/dhcp-server', 'dhcp-server', 'dir', '/ip', 'list', NULL)`);
103
+ db.run(`INSERT INTO schema_nodes (path, name, type, parent_path, dir_role, _arch)
104
+ VALUES ('/ip/x86-feature', 'x86-feature', 'cmd', '/ip', NULL, 'x86')`);
105
+ // Also insert a commands row for x86-feature so it participates in browseCommands
106
+ db.run(`INSERT INTO commands
107
+ (id, path, name, type, parent_path, page_id, description, ros_version)
108
+ VALUES (99, '/ip/x86-feature', 'x86-feature', 'cmd', '/ip', NULL, 'x86-only', '7.22')`);
109
+
95
110
  db.run(`INSERT INTO command_versions (command_path, ros_version)
96
111
  VALUES ('/ip/dhcp-server', '7.22')`);
97
112
  db.run(`INSERT INTO command_versions (command_path, ros_version)
@@ -765,6 +780,28 @@ describe("browseCommands", () => {
765
780
  const dhcp = children.find((c) => c.path === "/ip/dhcp-server");
766
781
  expect(dhcp?.page_title).toBe("DHCP Server");
767
782
  });
783
+
784
+ test("arch param binds correctly — x86-only node gets NULL enrichment when arch=arm64", () => {
785
+ // /ip/x86-feature has _arch='x86' in schema_nodes; browsing with arch='arm64'
786
+ // should still return the command (LEFT JOIN from commands), but schema enrichment
787
+ // (dir_role) should be NULL since the x86 schema_nodes row doesn't match arm64 filter.
788
+ const children = browseCommands("/ip", "arm64");
789
+ const x86Feature = children.find((c) => c.path === "/ip/x86-feature");
790
+ expect(x86Feature).toBeDefined(); // command still in results (from commands table)
791
+ expect(x86Feature?.dir_role).toBeNull(); // schema enrichment absent for other-arch node
792
+
793
+ // dhcp-server (_arch=NULL = shared) still gets enrichment
794
+ const dhcp = children.find((c) => c.path === "/ip/dhcp-server");
795
+ expect(dhcp?.dir_role).toBe("list"); // schema enrichment present for shared node
796
+ });
797
+
798
+ test("arch param binds correctly — x86 filter enriches x86-only node", () => {
799
+ const children = browseCommands("/ip", "x86");
800
+ const x86Feature = children.find((c) => c.path === "/ip/x86-feature");
801
+ expect(x86Feature).toBeDefined();
802
+ // x86-feature has _arch='x86', matches the arch='x86' filter, so _arch is populated
803
+ expect(x86Feature?._arch).toBe("x86");
804
+ });
768
805
  });
769
806
 
770
807
  // ---------------------------------------------------------------------------
@@ -1494,6 +1531,7 @@ describe("schema", () => {
1494
1531
  "commands", "command_versions", "ros_versions",
1495
1532
  "devices", "device_test_results", "changelogs", "schema_migrations",
1496
1533
  "videos", "video_segments",
1534
+ "skills", "skill_references",
1497
1535
  ];
1498
1536
  for (const table of expected) {
1499
1537
  expect(names).toContain(table);
@@ -1502,7 +1540,7 @@ describe("schema", () => {
1502
1540
 
1503
1541
  test("all FTS5 virtual tables exist", () => {
1504
1542
  const names = tableNames();
1505
- const expected = ["pages_fts", "properties_fts", "callouts_fts", "devices_fts", "changelogs_fts", "videos_fts", "video_segments_fts"];
1543
+ const expected = ["pages_fts", "properties_fts", "callouts_fts", "devices_fts", "changelogs_fts", "videos_fts", "video_segments_fts", "skills_fts"];
1506
1544
  for (const fts of expected) {
1507
1545
  expect(names).toContain(fts);
1508
1546
  }
@@ -1564,6 +1602,13 @@ describe("schema", () => {
1564
1602
  expect(triggers).toContain("dude_pages_au");
1565
1603
  });
1566
1604
 
1605
+ test("content-sync triggers exist for skills", () => {
1606
+ const triggers = triggerNames();
1607
+ expect(triggers).toContain("skills_ai");
1608
+ expect(triggers).toContain("skills_ad");
1609
+ expect(triggers).toContain("skills_au");
1610
+ });
1611
+
1567
1612
  test("PRAGMA user_version matches SCHEMA_VERSION", () => {
1568
1613
  const result = checkSchemaVersion();
1569
1614
  expect(result.ok).toBe(true);
@@ -1583,6 +1628,88 @@ describe("getDbStats", () => {
1583
1628
  expect(stats.ros_version_min).toBe("7.9");
1584
1629
  expect(stats.ros_version_max).toBe("7.22");
1585
1630
  });
1631
+
1632
+ test("includes skills count", () => {
1633
+ const stats = getDbStats();
1634
+ expect(stats).toHaveProperty("skills");
1635
+ expect(typeof stats.skills).toBe("number");
1636
+ });
1637
+ });
1638
+
1639
+ // ---------------------------------------------------------------------------
1640
+ // Skills: agent guides from tikoci/routeros-skills
1641
+ // ---------------------------------------------------------------------------
1642
+
1643
+ describe("skills", () => {
1644
+ // Insert fixture data for skills tests
1645
+ beforeAll(() => {
1646
+ db.run("DELETE FROM skill_references");
1647
+ db.run("DELETE FROM skills");
1648
+
1649
+ db.run(`INSERT INTO skills (name, description, content, source_repo, source_sha, source_url, word_count, extracted_at)
1650
+ VALUES ('routeros-test-skill', 'Test skill for unit tests', 'This is test content for the skill.', 'tikoci/routeros-skills', 'abc123', 'https://github.com/tikoci/routeros-skills/blob/abc123/routeros-test-skill/SKILL.md', 7, '2025-01-01T00:00:00Z')`);
1651
+
1652
+ const row = db.prepare("SELECT id FROM skills WHERE name = 'routeros-test-skill'").get() as { id: number };
1653
+
1654
+ db.run(`INSERT INTO skill_references (skill_id, path, filename, content, word_count)
1655
+ VALUES (${row.id}, 'references/test-ref.md', 'test-ref.md', 'Reference content here.', 3)`);
1656
+ });
1657
+
1658
+ test("listSkills returns all skills", () => {
1659
+ const skills = listSkills();
1660
+ expect(skills.length).toBeGreaterThanOrEqual(1);
1661
+ const testSkill = skills.find(s => s.name === "routeros-test-skill");
1662
+ expect(testSkill).toBeDefined();
1663
+ if (!testSkill) throw new Error("Expected test skill to exist");
1664
+ expect(testSkill.description).toBe("Test skill for unit tests");
1665
+ expect(testSkill.word_count).toBe(7);
1666
+ expect(testSkill.ref_count).toBe(1);
1667
+ });
1668
+
1669
+ test("getSkill returns full skill with provenance and references", () => {
1670
+ const skill = getSkill("routeros-test-skill");
1671
+ expect(skill).not.toBeNull();
1672
+ if (!skill) throw new Error("Expected skill details to exist");
1673
+ expect(skill.name).toBe("routeros-test-skill");
1674
+ expect(skill.content).toBe("This is test content for the skill.");
1675
+ expect(skill.provenance).toContain("PROVENANCE");
1676
+ expect(skill.provenance).toContain("tikoci/routeros-skills");
1677
+ expect(skill.source_sha).toBe("abc123");
1678
+ expect(skill.references).toHaveLength(1);
1679
+ expect(skill.references[0]?.filename).toBe("test-ref.md");
1680
+ });
1681
+
1682
+ test("getSkill is case-insensitive", () => {
1683
+ const skill = getSkill("ROUTEROS-TEST-SKILL");
1684
+ expect(skill).not.toBeNull();
1685
+ if (!skill) throw new Error("Expected case-insensitive skill lookup to work");
1686
+ expect(skill.name).toBe("routeros-test-skill");
1687
+ });
1688
+
1689
+ test("getSkill returns null for nonexistent skill", () => {
1690
+ const skill = getSkill("nonexistent-skill");
1691
+ expect(skill).toBeNull();
1692
+ });
1693
+
1694
+ test("getSkillReference returns a specific reference file", () => {
1695
+ const ref = getSkillReference("routeros-test-skill", "test-ref.md");
1696
+ expect(ref).not.toBeNull();
1697
+ if (!ref) throw new Error("Expected skill reference to exist");
1698
+ expect(ref.content).toBe("Reference content here.");
1699
+ expect(ref.word_count).toBe(3);
1700
+ });
1701
+
1702
+ test("getSkillReference returns null for nonexistent reference", () => {
1703
+ const ref = getSkillReference("routeros-test-skill", "nonexistent.md");
1704
+ expect(ref).toBeNull();
1705
+ });
1706
+
1707
+ test("FTS5 indexes skill content via triggers", () => {
1708
+ const results = db.prepare(
1709
+ "SELECT rowid FROM skills_fts WHERE skills_fts MATCH 'test'"
1710
+ ).all();
1711
+ expect(results.length).toBeGreaterThanOrEqual(1);
1712
+ });
1586
1713
  });
1587
1714
 
1588
1715
  // ---------------------------------------------------------------------------
package/src/query.ts CHANGED
@@ -475,9 +475,29 @@ export function lookupProperty(
475
475
  .all(name) as typeof lookupProperty extends (...a: unknown[]) => infer R ? R : never;
476
476
  }
477
477
 
478
+ /** Parse _attrs JSON and extract completion, stripping _attrs from output. */
479
+ function enrichWithCompletion(row: unknown): Record<string, unknown> {
480
+ const r = row as Record<string, unknown>;
481
+ const attrs = r._attrs as string | null;
482
+ const result = { ...r };
483
+ delete result._attrs;
484
+ if (attrs) {
485
+ try {
486
+ const parsed = JSON.parse(attrs);
487
+ result.completion = parsed.completion ?? null;
488
+ } catch {
489
+ result.completion = null;
490
+ }
491
+ } else {
492
+ result.completion = null;
493
+ }
494
+ return result;
495
+ }
496
+
478
497
  /** Browse the command tree at a given path. */
479
498
  export function browseCommands(
480
499
  cmdPath: string,
500
+ arch?: string,
481
501
  ): Array<{
482
502
  path: string;
483
503
  name: string;
@@ -485,17 +505,29 @@ export function browseCommands(
485
505
  description: string | null;
486
506
  page_title: string | null;
487
507
  page_url: string | null;
508
+ dir_role: string | null;
509
+ data_type: string | null;
510
+ enum_values: string | null;
511
+ _arch: string | null;
512
+ completion: Record<string, { style: string; preference: number; desc?: string }> | null;
488
513
  }> {
514
+ const archFilter = arch ? " AND (sn._arch IS NULL OR sn._arch = ?)" : "";
515
+ // arch placeholder is in the JOIN clause (before WHERE), so it must come first
516
+ const params: string[] = [...(arch ? [arch] : []), cmdPath];
517
+
489
518
  return db
490
519
  .prepare(
491
520
  `SELECT c.path, c.name, c.type, c.description,
492
- p.title as page_title, p.url as page_url
521
+ p.title as page_title, p.url as page_url,
522
+ sn.dir_role, sn.data_type, sn.enum_values, sn._arch, sn._attrs
493
523
  FROM commands c
494
524
  LEFT JOIN pages p ON c.page_id = p.id
525
+ LEFT JOIN schema_nodes sn ON sn.path = c.path AND sn.type = c.type${archFilter}
495
526
  WHERE c.parent_path = ?
496
527
  ORDER BY c.type DESC, c.name`,
497
528
  )
498
- .all(cmdPath) as typeof browseCommands extends (...a: unknown[]) => infer R ? R : never;
529
+ .all(...params)
530
+ .map(enrichWithCompletion) as ReturnType<typeof browseCommands>;
499
531
  }
500
532
 
501
533
  /** Search properties by FTS query. */
@@ -663,6 +695,7 @@ export function diffCommandVersions(
663
695
  fromVersion: string,
664
696
  toVersion: string,
665
697
  pathPrefix?: string,
698
+ arch?: string,
666
699
  ): CommandDiffResult {
667
700
  const allVersionRows = db
668
701
  .prepare("SELECT DISTINCT version FROM ros_versions")
@@ -682,31 +715,46 @@ export function diffCommandVersions(
682
715
  const prefixFilter = prefix ? " AND (command_path = ? OR command_path LIKE ? || '/%')" : "";
683
716
  const prefixParams = (v: string) => prefix ? [v, prefix, prefix] : [v];
684
717
 
718
+ // When arch is specified, exclude paths belonging to the other arch
719
+ const otherArch = arch === "x86" ? "arm64" : arch === "arm64" ? "x86" : null;
720
+ const archExclude = otherArch
721
+ ? " AND cv_to.command_path NOT IN (SELECT path FROM schema_nodes WHERE _arch = ?)"
722
+ : "";
723
+ const archExcludeFrom = otherArch
724
+ ? " AND cv_from.command_path NOT IN (SELECT path FROM schema_nodes WHERE _arch = ?)"
725
+ : "";
726
+
685
727
  type Row = { command_path: string };
686
728
 
729
+ const addedParams = [...prefixParams(toVersion), ...(otherArch ? [otherArch] : []), ...prefixParams(fromVersion)];
687
730
  const addedRows = db
688
731
  .prepare(
689
732
  `SELECT DISTINCT cv_to.command_path
690
733
  FROM command_versions cv_to
691
- WHERE cv_to.ros_version = ?${prefixFilter}
734
+ WHERE cv_to.ros_version = ?${prefixFilter}${archExclude}
692
735
  AND cv_to.command_path NOT IN (
693
736
  SELECT command_path FROM command_versions WHERE ros_version = ?${prefixFilter}
694
737
  )
695
738
  ORDER BY cv_to.command_path`,
696
739
  )
697
- .all(...prefixParams(toVersion), ...prefixParams(fromVersion)) as Row[];
740
+ .all(...addedParams) as Row[];
698
741
 
742
+ const removedParams = [...prefixParams(fromVersion), ...(otherArch ? [otherArch] : []), ...prefixParams(toVersion)];
699
743
  const removedRows = db
700
744
  .prepare(
701
745
  `SELECT DISTINCT cv_from.command_path
702
746
  FROM command_versions cv_from
703
- WHERE cv_from.ros_version = ?${prefixFilter}
747
+ WHERE cv_from.ros_version = ?${prefixFilter}${archExcludeFrom}
704
748
  AND cv_from.command_path NOT IN (
705
749
  SELECT command_path FROM command_versions WHERE ros_version = ?${prefixFilter}
706
750
  )
707
751
  ORDER BY cv_from.command_path`,
708
752
  )
709
- .all(...prefixParams(fromVersion), ...prefixParams(toVersion)) as Row[];
753
+ .all(...removedParams) as Row[];
754
+
755
+ if (arch) {
756
+ notes.push(`Filtered to ${arch} architecture (excluding ${otherArch}-only paths).`);
757
+ }
710
758
 
711
759
  return {
712
760
  from_version: fromVersion,
@@ -789,6 +837,7 @@ export function compareVersions(a: string, b: string): number {
789
837
  export function browseCommandsAtVersion(
790
838
  cmdPath: string,
791
839
  version: string,
840
+ arch?: string,
792
841
  ): Array<{
793
842
  path: string;
794
843
  name: string;
@@ -796,18 +845,30 @@ export function browseCommandsAtVersion(
796
845
  description: string | null;
797
846
  page_title: string | null;
798
847
  page_url: string | null;
848
+ dir_role: string | null;
849
+ data_type: string | null;
850
+ enum_values: string | null;
851
+ _arch: string | null;
852
+ completion: Record<string, { style: string; preference: number; desc?: string }> | null;
799
853
  }> {
854
+ const archFilter = arch ? " AND (sn._arch IS NULL OR sn._arch = ?)" : "";
855
+ // arch placeholder is in the JOIN clause (before WHERE), so it must come first
856
+ const params: string[] = [...(arch ? [arch] : []), cmdPath, version];
857
+
800
858
  return db
801
859
  .prepare(
802
860
  `SELECT c.path, c.name, c.type, c.description,
803
- p.title as page_title, p.url as page_url
861
+ p.title as page_title, p.url as page_url,
862
+ sn.dir_role, sn.data_type, sn.enum_values, sn._arch, sn._attrs
804
863
  FROM commands c
805
864
  LEFT JOIN pages p ON c.page_id = p.id
806
865
  JOIN command_versions cv ON cv.command_path = c.path
866
+ LEFT JOIN schema_nodes sn ON sn.path = c.path AND sn.type = c.type${archFilter}
807
867
  WHERE c.parent_path = ? AND cv.ros_version = ?
808
868
  ORDER BY c.type DESC, c.name`,
809
869
  )
810
- .all(cmdPath, version) as Array<{ path: string; name: string; type: string; description: string | null; page_title: string | null; page_url: string | null }>;
870
+ .all(...params)
871
+ .map(enrichWithCompletion) as ReturnType<typeof browseCommandsAtVersion>;
811
872
  }
812
873
 
813
874
  // ── Device lookup and search ──
@@ -1648,7 +1709,94 @@ export function getDudePage(idOrTitle: number | string, maxLength?: number): Dud
1648
1709
  return page;
1649
1710
  }
1650
1711
 
1651
- // ── Current versions (live fetch) ──
1712
+ // ── Skills (agent guides from tikoci/routeros-skills) ──
1713
+
1714
+ const SKILLS_PROVENANCE = `⚠️ PROVENANCE: Community-created content from tikoci/routeros-skills —
1715
+ NOT official MikroTik documentation. AI-generated, human-reviewed. May contain errors.
1716
+ Verify claims using routeros_search and routeros_get_page for official documentation.`;
1717
+
1718
+ export type SkillSummary = {
1719
+ name: string;
1720
+ description: string;
1721
+ word_count: number;
1722
+ ref_count: number;
1723
+ source_url: string;
1724
+ };
1725
+
1726
+ export type SkillReference = {
1727
+ path: string;
1728
+ filename: string;
1729
+ content: string;
1730
+ word_count: number;
1731
+ };
1732
+
1733
+ export type SkillDetail = {
1734
+ name: string;
1735
+ description: string;
1736
+ content: string;
1737
+ provenance: string;
1738
+ source_repo: string;
1739
+ source_sha: string | null;
1740
+ source_url: string;
1741
+ word_count: number;
1742
+ extracted_at: string | null;
1743
+ references: SkillReference[];
1744
+ };
1745
+
1746
+ /** List all available skills with reference counts. */
1747
+ export function listSkills(): SkillSummary[] {
1748
+ return db
1749
+ .prepare(
1750
+ `SELECT s.name, s.description, s.word_count, s.source_url,
1751
+ (SELECT COUNT(*) FROM skill_references sr WHERE sr.skill_id = s.id) as ref_count
1752
+ FROM skills s
1753
+ ORDER BY s.name`,
1754
+ )
1755
+ .all() as SkillSummary[];
1756
+ }
1757
+
1758
+ /** Get a single skill by name, including its references and provenance header. */
1759
+ export function getSkill(name: string): SkillDetail | null {
1760
+ const row = db
1761
+ .prepare("SELECT * FROM skills WHERE name = ? COLLATE NOCASE")
1762
+ .get(name) as Record<string, unknown> | null;
1763
+ if (!row) return null;
1764
+
1765
+ const refs = db
1766
+ .prepare(
1767
+ `SELECT path, filename, content, word_count
1768
+ FROM skill_references
1769
+ WHERE skill_id = ?
1770
+ ORDER BY path`,
1771
+ )
1772
+ .all(row.id as number) as SkillReference[];
1773
+
1774
+ return {
1775
+ name: row.name as string,
1776
+ description: row.description as string,
1777
+ content: row.content as string,
1778
+ provenance: `${SKILLS_PROVENANCE}\nSource: ${row.source_url as string}`,
1779
+ source_repo: row.source_repo as string,
1780
+ source_sha: row.source_sha as string | null,
1781
+ source_url: row.source_url as string,
1782
+ word_count: row.word_count as number,
1783
+ extracted_at: row.extracted_at as string | null,
1784
+ references: refs,
1785
+ };
1786
+ }
1787
+
1788
+ /** Get a single reference file from a skill. */
1789
+ export function getSkillReference(skillName: string, refFilename: string): SkillReference | null {
1790
+ const row = db
1791
+ .prepare(
1792
+ `SELECT sr.path, sr.filename, sr.content, sr.word_count
1793
+ FROM skill_references sr
1794
+ JOIN skills s ON s.id = sr.skill_id
1795
+ WHERE s.name = ? COLLATE NOCASE AND sr.filename = ? COLLATE NOCASE`,
1796
+ )
1797
+ .get(skillName, refFilename) as SkillReference | null;
1798
+ return row;
1799
+ }
1652
1800
 
1653
1801
  const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
1654
1802
 
@@ -210,6 +210,21 @@ describe("Makefile", () => {
210
210
  expect(makefile).toContain("extract-dude-from-cache:");
211
211
  });
212
212
 
213
+ test("has extract-skills target", () => {
214
+ expect(makefile).toContain("extract-skills:");
215
+ });
216
+
217
+ test("has extract-skills-from-cache target", () => {
218
+ expect(makefile).toContain("extract-skills-from-cache:");
219
+ });
220
+
221
+ test("extract-skills is in PHONY", () => {
222
+ const phonyStart = makefile.indexOf(".PHONY:");
223
+ const phonyEnd = makefile.indexOf("\n\n", phonyStart);
224
+ const phonyBlock = makefile.slice(phonyStart, phonyEnd);
225
+ expect(phonyBlock).toContain("extract-skills");
226
+ });
227
+
213
228
  test("extract-dude is in PHONY", () => {
214
229
  const phonyStart = makefile.indexOf(".PHONY:");
215
230
  const phonyEnd = makefile.indexOf("\n\n", phonyStart);
@@ -225,6 +240,14 @@ describe("Makefile", () => {
225
240
  expect(makefile).toMatch(/^release:.*build-release/m);
226
241
  });
227
242
 
243
+ test("extract target includes skills", () => {
244
+ expect(makefile).toMatch(/^extract:.*extract-skills/m);
245
+ });
246
+
247
+ test("extract-full target includes skills", () => {
248
+ expect(makefile).toMatch(/^extract-full:.*extract-skills/m);
249
+ });
250
+
228
251
  test("extract target includes Dude cache import", () => {
229
252
  expect(makefile).toMatch(/^extract:.*extract-dude-from-cache/m);
230
253
  });
@@ -275,6 +298,7 @@ describe("release.yml", () => {
275
298
  expect(src).toContain("extract-devices.ts");
276
299
  expect(src).toContain("extract-test-results.ts");
277
300
  expect(src).toContain("extract-changelogs.ts");
301
+ expect(src).toContain("extract-skills.ts");
278
302
  expect(src).toContain("link-commands.ts");
279
303
  });
280
304
 
@@ -283,6 +307,11 @@ describe("release.yml", () => {
283
307
  expect(src).toContain("extract-dude-from-cache");
284
308
  });
285
309
 
310
+ test("extracts agent skills in CI", () => {
311
+ const src = readText(".github/workflows/release.yml");
312
+ expect(src).toContain("extract-skills.ts");
313
+ });
314
+
286
315
  test("runs quality gate before release", () => {
287
316
  const src = readText(".github/workflows/release.yml");
288
317
  expect(src).toContain("bun run typecheck");