@tikoci/rosetta 0.6.6 → 0.6.8

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
@@ -298,6 +298,8 @@ The server exposes 16 tools designed to work together — agents start with `rou
298
298
 
299
299
  Each tool description includes workflow arrows (`→ next_tool`) and empty-result hints so agents chain tools effectively.
300
300
 
301
+ The server also exposes **MCP Resources** for bulk data and supplemental content — CSV datasets (`rosetta://datasets/...`), schema documentation (`rosetta://schema...`), and **agent skill guides** (`rosetta://skills/{name}`) from [tikoci/routeros-skills](https://github.com/tikoci/routeros-skills). Skills are community-created, human-reviewed guides served with provenance attribution. See [MANUAL.md](MANUAL.md) for details.
302
+
301
303
 
302
304
  ## RTFM for Details
303
305
  For additional install options, HTTP transport configuration, data source details, and the database schema, see [MANUAL.md](MANUAL.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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
@@ -27,12 +27,15 @@ import type {
27
27
  } from "./query.ts";
28
28
  import {
29
29
  browseCommands,
30
+ browseCommandsAtVersion,
30
31
  checkCommandVersions,
31
32
  diffCommandVersions,
32
33
  fetchCurrentVersions,
33
34
  getDudePage,
34
35
  getPage,
36
+ getSkill,
35
37
  getTestResultMeta,
38
+ listSkills,
36
39
  lookupProperty,
37
40
  searchCallouts,
38
41
  searchChangelogs,
@@ -200,6 +203,7 @@ type Context =
200
203
  | { type: "changelogs"; results: ChangelogResult[] }
201
204
  | { type: "videos"; query: string; results: VideoSearchResult[] }
202
205
  | { type: "dude"; query: string; results: DudeSearchResult[] }
206
+ | { type: "skills" }
203
207
  | { type: "properties"; query: string; pageId?: number; results: Array<{ name: string; page_id: number; page_title: string }> }
204
208
  | { type: "diff" }
205
209
  | { type: "vcheck"; path: string };
@@ -240,6 +244,7 @@ function contextLabel(c: Context): string {
240
244
  case "changelogs": return "changelogs";
241
245
  case "videos": return truncate(`vid: "${c.query}"`, 30);
242
246
  case "dude": return truncate(`dude: "${c.query}"`, 30);
247
+ case "skills": return "skills";
243
248
  case "diff": return "diff";
244
249
  case "vcheck": return truncate(`vc: ${c.path}`, 30);
245
250
  }
@@ -250,12 +255,17 @@ function contextLabel(c: Context): string {
250
255
  function renderWelcome(): string {
251
256
  const stats = getDbStats();
252
257
  const version = resolveVersion(import.meta.dirname);
258
+ const dbWarning = stats.commands < 1000
259
+ ? yellow(`⚠ DB has only ${fmt(stats.commands)} commands — use real DB: --db ~/.rosetta/ros-help.db`)
260
+ : null;
253
261
  const lines = [
254
262
  `RouterOS Documentation Browser ${dim(`v${version}`)}`,
255
263
  `${fmt(stats.pages)} pages · ${fmt(stats.properties)} properties · ${fmt(stats.commands)} commands`,
256
264
  `${fmt(stats.devices)} devices · ${fmt(stats.callouts)} callouts · ${fmt(stats.ros_versions)} versions`,
257
265
  ...(stats.videos > 0 ? [`${fmt(stats.videos)} videos · ${fmt(stats.video_segments)} transcript segments`] : []),
266
+ ...(stats.skills > 0 ? [`${fmt(stats.skills)} agent skills ${dim("(tikoci — community content)")}`] : []),
258
267
  "",
268
+ ...(dbWarning ? [dbWarning, ""] : []),
259
269
  `Type a search query, or ${bold("help")} for commands.`,
260
270
  ];
261
271
  return box(lines, "rosetta");
@@ -432,6 +442,11 @@ function renderCommandTree(path: string, children: Array<{
432
442
  description: string | null;
433
443
  page_title: string | null;
434
444
  page_url: string | null;
445
+ dir_role?: string | null;
446
+ data_type?: string | null;
447
+ enum_values?: string | null;
448
+ _arch?: string | null;
449
+ completion?: Record<string, { style?: string; preference?: number; desc?: string }> | null;
435
450
  }>): string {
436
451
  const out: string[] = [];
437
452
  out.push(` ${bold(path || "/")} ${dim(`(${children.length} children)`)}`);
@@ -441,23 +456,58 @@ function renderCommandTree(path: string, children: Array<{
441
456
  const cmds = children.filter((c) => c.type === "cmd");
442
457
  const args = children.filter((c) => c.type === "arg");
443
458
 
459
+ let globalIdx = 0;
444
460
  for (const group of [
445
- { items: dirs, icon: "📁", label: "directories" },
446
- { items: cmds, icon: "⚡", label: "commands" },
447
- { items: args, icon: " ", label: "arguments" },
461
+ { items: dirs, icon: "📁" },
462
+ { items: cmds, icon: "⚡" },
463
+ { items: args, icon: dim("·") },
448
464
  ]) {
449
465
  if (group.items.length === 0) continue;
450
- for (const c of group.items) {
466
+ for (let i = 0; i < group.items.length; i++) {
467
+ const c = group.items[i];
468
+ globalIdx++;
469
+ const num = dim(`${String(globalIdx).padStart(3)} `);
451
470
  const icon = group.icon;
452
471
  const name = c.type === "dir" ? bold(c.name) : c.name;
453
- const desc = c.description ? dim(` — ${truncate(c.description, 50)}`) : "";
472
+
473
+ // Build type hint: enum values, data_type, or completion-derived values
474
+ let typeHint = "";
475
+ if (c.data_type === "enum" && c.enum_values) {
476
+ try {
477
+ const vals = JSON.parse(c.enum_values) as string[];
478
+ const shown = vals.length > 6 ? `${vals.slice(0, 6).join("|")}|…` : vals.join("|");
479
+ typeHint = shown;
480
+ } catch {
481
+ typeHint = `<${c.data_type}>`;
482
+ }
483
+ } else if (c.data_type === "time" || c.data_type === "integer" || c.data_type === "range") {
484
+ // Already visible via description range — just tag the type
485
+ typeHint = `<${c.data_type}>`;
486
+ } else if (c.data_type) {
487
+ typeHint = `<${c.data_type}>`;
488
+ } else if (c.completion && Object.keys(c.completion).length > 0) {
489
+ // No parsed data_type but completion values exist — surface them
490
+ const keys = Object.keys(c.completion).filter((k) => k !== "");
491
+ if (keys.length > 0) {
492
+ const shown = keys.length > 6 ? `${keys.slice(0, 6).join("|")}|…` : keys.join("|");
493
+ typeHint = shown;
494
+ }
495
+ }
496
+
497
+ const parts: string[] = [];
498
+ if (c.description) parts.push(truncate(c.description, 40));
499
+ if (typeHint) parts.push(typeHint);
500
+ if (c.dir_role && c.dir_role !== "namespace") parts.push(`[${c.dir_role}]`);
501
+ const desc = parts.length > 0 ? dim(` — ${parts.join(" ")}`) : "";
502
+ const archTag = c._arch ? yellow(` [${c._arch}]`) : "";
454
503
  const pageLink = c.page_url ? ` ${cyan(link(c.page_url, dim("📄")))}` : "";
455
- out.push(` ${icon} ${name}${desc}${pageLink}`);
504
+ out.push(` ${num}${icon} ${name}${desc}${archTag}${pageLink}`);
456
505
  }
506
+ out.push("");
457
507
  }
458
508
 
459
- out.push("");
460
509
  const hints: string[] = [
510
+ `${cyan("[N]")} select`,
461
511
  `${cyan("[cmd <child>]")} drill down`,
462
512
  `${cyan("[page <id>]")} view linked page`,
463
513
  `${cyan("[b]")} back`,
@@ -847,6 +897,8 @@ function renderHelp(): string {
847
897
  cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
848
898
  cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
849
899
  cmd("dude <query>", "", "Search archived Dude wiki docs", "routeros_dude_search");
900
+ cmd("skills", "", "List agent skill guides", "rosetta://skills");
901
+ cmd("skill <name>", "", "View a skill guide", "rosetta://skills/{name}");
850
902
  cmd("diff <from> <to> [path]", "", "Command tree diff between versions", "routeros_command_diff");
851
903
  cmd("vcheck <path>", "vc", "Version range for a command path", "routeros_command_version_check");
852
904
  cmd("versions", "ver", "Live-fetch current RouterOS versions", "routeros_current_versions");
@@ -859,6 +911,11 @@ function renderHelp(): string {
859
911
  out.push(` ${dim("Navigation: type a number to select from results.")}`);
860
912
  out.push(` ${dim("After viewing a page, [p] = properties for that page.")}`);
861
913
  out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
914
+ out.push(` ${dim("cmd supports @version suffix: cmd /ip/address @7.15")}`);
915
+ out.push("");
916
+ out.push(` ${bold("CLI flags")}`);
917
+ out.push(` ${cyan(pad("--db <path>", 26))} ${dim("")} Use a specific database file`);
918
+ out.push(` ${cyan(pad("--once", 26))} ${dim("")} Search once and exit (for piping)`);
862
919
 
863
920
  return out.join("\n");
864
921
  }
@@ -998,6 +1055,17 @@ async function dispatch(input: string): Promise<void> {
998
1055
  return;
999
1056
  }
1000
1057
 
1058
+ case "skills": {
1059
+ await doListSkills();
1060
+ return;
1061
+ }
1062
+
1063
+ case "skill": {
1064
+ if (!rest) { console.log(dim(" Usage: skill <name>")); return; }
1065
+ await doViewSkill(rest);
1066
+ return;
1067
+ }
1068
+
1001
1069
  case "diff": {
1002
1070
  const diffParts = rest.split(/\s+/);
1003
1071
  if (diffParts.length < 2) {
@@ -1076,6 +1144,38 @@ async function handleNumberSelect(idx: number): Promise<void> {
1076
1144
  }
1077
1145
  return;
1078
1146
  }
1147
+ if (ctx.type === "skills") {
1148
+ const skills = listSkills();
1149
+ if (skills[idx]) {
1150
+ await doViewSkill(skills[idx].name);
1151
+ }
1152
+ return;
1153
+ }
1154
+ if (ctx.type === "commands") {
1155
+ // Re-query and navigate to the Nth child (dirs and cmds drill in; args show inline)
1156
+ const children = browseCommands(ctx.path);
1157
+ const child = children[idx];
1158
+ if (child) {
1159
+ if (child.type === "dir" || child.type === "cmd") {
1160
+ await doCommandTree(child.path);
1161
+ } else {
1162
+ // arg — display its details inline
1163
+ const parts: string[] = [];
1164
+ if (child.description) parts.push(child.description);
1165
+ if (child.data_type) parts.push(`type: ${child.data_type}`);
1166
+ if (child.enum_values) {
1167
+ try { parts.push(`values: ${JSON.parse(child.enum_values).join("|")}`); } catch { /* ignore */ }
1168
+ } else if (child.completion) {
1169
+ const keys = Object.keys(child.completion).filter((k) => k !== "");
1170
+ if (keys.length > 0) parts.push(`values: ${keys.join("|")}`);
1171
+ }
1172
+ console.log(`\n ${bold(child.name)} ${dim(`(${child.path})`)}`);
1173
+ if (parts.length > 0) console.log(` ${parts.join(" · ")}`);
1174
+ console.log("");
1175
+ }
1176
+ return;
1177
+ }
1178
+ }
1079
1179
  if (ctx.type === "properties" && ctx.results[idx]) {
1080
1180
  const p = ctx.results[idx];
1081
1181
  await doPage(String(p.page_id));
@@ -1166,23 +1266,58 @@ async function doSearchProperties(query: string): Promise<void> {
1166
1266
  }
1167
1267
 
1168
1268
  async function doCommandTree(path: string): Promise<void> {
1169
- const normalized = path.startsWith("/") ? path : path ? `/${path}` : "";
1170
- // If at a page context and no explicit path, try linked command
1171
- const cmdPath = !path && ctx.type === "page" && (ctx as { commandPath?: string }).commandPath
1172
- // biome-ignore lint/style/noNonNullAssertion: narrowed by the truthiness check above
1173
- ? (ctx as { commandPath?: string }).commandPath!
1174
- : normalized;
1175
-
1176
- const children = browseCommands(cmdPath);
1269
+ // Extract optional @version suffix: "cmd /ip/address @7.15" or "cmd /ip add @7.20"
1270
+ let version: string | undefined;
1271
+ let pathArg = path;
1272
+ const versionMatch = path.match(/^(.*?)\s*@(\S+)$/);
1273
+ if (versionMatch) {
1274
+ pathArg = versionMatch[1].trim();
1275
+ version = versionMatch[2];
1276
+ }
1277
+
1278
+ let cmdPath: string;
1279
+ if (!pathArg) {
1280
+ if (ctx.type === "page" && (ctx as { commandPath?: string }).commandPath) {
1281
+ // biome-ignore lint/style/noNonNullAssertion: narrowed above
1282
+ cmdPath = (ctx as { commandPath?: string }).commandPath!;
1283
+ } else if (ctx.type === "commands") {
1284
+ cmdPath = ctx.path;
1285
+ } else {
1286
+ cmdPath = "";
1287
+ }
1288
+ } else if (pathArg.startsWith("/")) {
1289
+ cmdPath = pathArg;
1290
+ } else if (ctx.type === "commands") {
1291
+ // Relative segment: "cmd add" at /ip/address → /ip/address/add
1292
+ cmdPath = `${ctx.path}/${pathArg}`;
1293
+ } else {
1294
+ cmdPath = `/${pathArg}`;
1295
+ }
1296
+
1297
+ const children = version
1298
+ ? browseCommandsAtVersion(cmdPath, version)
1299
+ : browseCommands(cmdPath);
1300
+
1177
1301
  if (children.length === 0) {
1178
- console.log(dim(` No children at "${cmdPath}".`));
1179
- // Try as a command version check instead
1180
- if (cmdPath) {
1181
- console.log(` Try: ${cyan("vc")} ${cmdPath}`);
1302
+ // Check if path itself exists as a leaf node
1303
+ const vcResult = cmdPath ? checkCommandVersions(cmdPath) : null;
1304
+ if (vcResult && vcResult.versions.length > 0) {
1305
+ console.log(` ${bold(cmdPath)} ${dim("(leaf — no children)")}`);
1306
+ console.log(` ${dim("First seen:")} ${bold(vcResult.first_seen ?? "?")} ${dim("Last seen:")} ${bold(vcResult.last_seen ?? "?")}`);
1307
+ console.log(` ${dim("Present in")} ${bold(String(vcResult.versions.length))} ${dim("versions")}`);
1308
+ if (vcResult.note) console.log(` ${dim(vcResult.note)}`);
1309
+ console.log(` ${dim("Try:")} ${cyan("b")} ${dim("to go up, or")} ${cyan(`s ${cmdPath.split("/").pop() ?? ""}`)} ${dim("to search docs")}`);
1310
+ } else {
1311
+ console.log(dim(` No results for "${cmdPath}".`));
1312
+ const term = cmdPath.split("/").filter(Boolean).pop() ?? "";
1313
+ if (term) {
1314
+ console.log(` ${dim("Try:")} ${cyan(`s ${term}`)} ${dim("(search docs) or")} ${cyan(`vc ${cmdPath}`)} ${dim("(version check)")}`);
1315
+ }
1182
1316
  }
1183
1317
  return;
1184
1318
  }
1185
- await paged(renderCommandTree(cmdPath, children));
1319
+ const label = version ? `${cmdPath} @${version}` : cmdPath;
1320
+ await paged(renderCommandTree(label, children));
1186
1321
  pushCtx({ type: "commands", path: cmdPath });
1187
1322
  }
1188
1323
 
@@ -1320,6 +1455,54 @@ async function doSearchDude(query: string): Promise<void> {
1320
1455
  pushCtx({ type: "dude", query, results });
1321
1456
  }
1322
1457
 
1458
+ async function doListSkills(): Promise<void> {
1459
+ const skills = listSkills();
1460
+ if (skills.length === 0) {
1461
+ console.log(dim(" No skills available. Run: make extract-skills"));
1462
+ return;
1463
+ }
1464
+ const out: string[] = [];
1465
+ out.push(` ${bold(`${skills.length} Agent Skills`)} ${dim("(tikoci/routeros-skills — community content)")}`);
1466
+ out.push(` ${yellow("⚠ AI-generated, human-reviewed. NOT official MikroTik docs.")}`);
1467
+ out.push("");
1468
+ skills.forEach((s, i) => {
1469
+ out.push(` ${cyan(String(i + 1).padStart(2))}. ${bold(s.name)} ${dim(`${s.word_count} words, ${s.ref_count} refs`)}`);
1470
+ out.push(` ${s.description}`);
1471
+ });
1472
+ out.push("");
1473
+ out.push(` ${dim("Type a number to view, or: skill <name>")}`);
1474
+ await paged(out.join("\n"));
1475
+ pushCtx({ type: "skills" });
1476
+ }
1477
+
1478
+ async function doViewSkill(name: string): Promise<void> {
1479
+ const skill = getSkill(name);
1480
+ if (!skill) {
1481
+ console.log(dim(` Skill "${name}" not found. Use 'skills' to list available skills.`));
1482
+ return;
1483
+ }
1484
+ const out: string[] = [];
1485
+ out.push(` ${yellow("⚠ PROVENANCE: Community content from tikoci/routeros-skills")}`);
1486
+ out.push(` ${yellow(" NOT official MikroTik documentation. May contain errors.")}`);
1487
+ out.push(` ${dim(`Source: ${link(skill.source_url)}`)}`);
1488
+ out.push("");
1489
+ out.push(` ${bold(skill.name)} ${dim(`${skill.word_count} words`)}`);
1490
+ out.push(` ${skill.description}`);
1491
+ out.push("");
1492
+ out.push(skill.content);
1493
+ if (skill.references.length > 0) {
1494
+ out.push("");
1495
+ out.push(` ${bold("Reference Files")} ${dim(`(${skill.references.length} files)`)}`);
1496
+ out.push("");
1497
+ for (const ref of skill.references) {
1498
+ out.push(` ${cyan("─")} ${bold(ref.filename)} ${dim(`${ref.word_count} words`)}`);
1499
+ }
1500
+ out.push("");
1501
+ out.push(` ${dim("To view a reference: skill <name> refs")}`);
1502
+ }
1503
+ await paged(out.join("\n"));
1504
+ }
1505
+
1323
1506
  async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
1324
1507
  const result = diffCommandVersions(from, to, pathPrefix);
1325
1508
  await paged(renderDiff(result));
@@ -1355,7 +1538,14 @@ async function main() {
1355
1538
 
1356
1539
  const args = process.argv.slice(2);
1357
1540
  const onceMode = args.includes("--once");
1358
- const queryArgs = args.filter((a) => a !== "--once" && a !== "browse");
1541
+ // Filter out --once, browse, and --db <path> so they don't become search queries
1542
+ const dbArgIdx = args.indexOf("--db");
1543
+ const queryArgs = args.filter((a, i) => {
1544
+ if (a === "--once" || a === "browse") return false;
1545
+ if (a === "--db") return false;
1546
+ if (dbArgIdx !== -1 && i === dbArgIdx + 1) return false;
1547
+ return true;
1548
+ });
1359
1549
  const initialQuery = queryArgs.join(" ");
1360
1550
 
1361
1551
  // Welcome banner (only in interactive mode)
package/src/db.ts CHANGED
@@ -13,6 +13,8 @@
13
13
  * commands — RouterOS command tree from inspect.json (latest version)
14
14
  * command_versions — junction: which commands exist in which RouterOS versions
15
15
  * ros_versions — metadata for each extracted RouterOS version
16
+ * schema_nodes — structured command tree from deep-inspect.json (richer desc, arch, completion)
17
+ * schema_node_presence — junction: which schema_nodes exist in which versions
16
18
  * devices — MikroTik product hardware specs from product matrix CSV
17
19
  * devices_fts — FTS5 over product name, code, architecture, CPU
18
20
  * changelogs — parsed changelog entries per RouterOS version
@@ -24,6 +26,9 @@
24
26
  * dude_pages — The Dude documentation pages (archived from wiki.mikrotik.com via Wayback Machine)
25
27
  * dude_pages_fts — FTS5 over title, path, text, code
26
28
  * dude_images — screenshot images from The Dude wiki pages
29
+ * skills — agent skill guides (from tikoci/routeros-skills, community content)
30
+ * skills_fts — FTS5 over name, description, content
31
+ * skill_references — reference documents for each skill
27
32
  */
28
33
 
29
34
  import sqlite from "bun:sqlite";
@@ -282,6 +287,20 @@ export function initDb() {
282
287
  PRIMARY KEY (version, arch)
283
288
  );`);
284
289
 
290
+ // Migration: add deep-inspect _meta provenance columns if missing
291
+ {
292
+ const rvCols2 = db.prepare("PRAGMA table_info(ros_versions)").all() as Array<{ name: string }>;
293
+ if (rvCols2.length > 0 && !rvCols2.some((c) => c.name === "api_transport")) {
294
+ db.run("ALTER TABLE ros_versions ADD COLUMN api_transport TEXT;");
295
+ }
296
+ if (rvCols2.length > 0 && !rvCols2.some((c) => c.name === "enrichment_duration_ms")) {
297
+ db.run("ALTER TABLE ros_versions ADD COLUMN enrichment_duration_ms INTEGER;");
298
+ }
299
+ if (rvCols2.length > 0 && !rvCols2.some((c) => c.name === "crash_paths_safe")) {
300
+ db.run("ALTER TABLE ros_versions ADD COLUMN crash_paths_safe TEXT;");
301
+ }
302
+ }
303
+
285
304
  db.run(`CREATE TABLE IF NOT EXISTS command_versions (
286
305
  command_path TEXT NOT NULL,
287
306
  ros_version TEXT NOT NULL,
@@ -290,6 +309,53 @@ export function initDb() {
290
309
 
291
310
  db.run(`CREATE INDEX IF NOT EXISTS idx_cmdver_version ON command_versions(ros_version);`);
292
311
 
312
+ // -- Schema nodes (structured command tree from deep-inspect.json) --
313
+ //
314
+ // schema_nodes replaces the flat commands table with richer structure:
315
+ // parsed desc fields (data_type, enum_values, range), arch tagging,
316
+ // dir_role classification, and a JSON _attrs catch-all for completion
317
+ // data and future metadata like _package.
318
+ //
319
+ // The `commands` table is regenerated from schema_nodes at import time
320
+ // by extract-schema.ts — existing queries continue to read `commands`
321
+ // with zero downstream churn.
322
+
323
+ db.run(`CREATE TABLE IF NOT EXISTS schema_nodes (
324
+ id INTEGER PRIMARY KEY,
325
+ path TEXT NOT NULL,
326
+ name TEXT NOT NULL,
327
+ type TEXT NOT NULL,
328
+ parent_id INTEGER REFERENCES schema_nodes(id),
329
+ parent_path TEXT,
330
+ dir_role TEXT,
331
+ desc_raw TEXT,
332
+ data_type TEXT,
333
+ enum_values TEXT,
334
+ enum_multi INTEGER,
335
+ type_tag TEXT,
336
+ range_min TEXT,
337
+ range_max TEXT,
338
+ max_length INTEGER,
339
+ _arch TEXT,
340
+ _package TEXT,
341
+ _attrs TEXT,
342
+ page_id INTEGER REFERENCES pages(id),
343
+ UNIQUE(path, type)
344
+ );`);
345
+
346
+ db.run(`CREATE INDEX IF NOT EXISTS idx_sn_parent ON schema_nodes(parent_path);`);
347
+ db.run(`CREATE INDEX IF NOT EXISTS idx_sn_type ON schema_nodes(type);`);
348
+ db.run(`CREATE INDEX IF NOT EXISTS idx_sn_path ON schema_nodes(path);`);
349
+ db.run(`CREATE INDEX IF NOT EXISTS idx_sn_page ON schema_nodes(page_id);`);
350
+
351
+ db.run(`CREATE TABLE IF NOT EXISTS schema_node_presence (
352
+ node_id INTEGER NOT NULL REFERENCES schema_nodes(id),
353
+ version TEXT NOT NULL,
354
+ PRIMARY KEY (node_id, version)
355
+ );`);
356
+
357
+ db.run(`CREATE INDEX IF NOT EXISTS idx_snp_version ON schema_node_presence(version);`);
358
+
293
359
  // -- Devices (MikroTik product matrix) --
294
360
 
295
361
  db.run(`CREATE TABLE IF NOT EXISTS devices (
@@ -547,6 +613,49 @@ export function initDb() {
547
613
  );`);
548
614
 
549
615
  db.run(`CREATE INDEX IF NOT EXISTS idx_dude_images_page ON dude_images(page_id);`);
616
+
617
+ // -- Skills (agent guides from tikoci/routeros-skills — community content, NOT official MikroTik docs) --
618
+
619
+ db.run(`CREATE TABLE IF NOT EXISTS skills (
620
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
621
+ name TEXT NOT NULL UNIQUE,
622
+ description TEXT,
623
+ content TEXT NOT NULL,
624
+ source_repo TEXT NOT NULL DEFAULT 'tikoci/routeros-skills',
625
+ source_sha TEXT,
626
+ source_url TEXT,
627
+ word_count INTEGER,
628
+ extracted_at TEXT
629
+ );`);
630
+
631
+ db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
632
+ name, description, content,
633
+ content=skills, content_rowid=id,
634
+ tokenize='porter unicode61'
635
+ );`);
636
+
637
+ db.run(`CREATE TRIGGER IF NOT EXISTS skills_ai AFTER INSERT ON skills BEGIN
638
+ INSERT INTO skills_fts(rowid, name, description, content) VALUES (new.id, new.name, new.description, new.content);
639
+ END;`);
640
+ db.run(`CREATE TRIGGER IF NOT EXISTS skills_ad AFTER DELETE ON skills BEGIN
641
+ INSERT INTO skills_fts(skills_fts, rowid, name, description, content) VALUES ('delete', old.id, old.name, old.description, old.content);
642
+ END;`);
643
+ db.run(`CREATE TRIGGER IF NOT EXISTS skills_au AFTER UPDATE ON skills BEGIN
644
+ INSERT INTO skills_fts(skills_fts, rowid, name, description, content) VALUES ('delete', old.id, old.name, old.description, old.content);
645
+ INSERT INTO skills_fts(rowid, name, description, content) VALUES (new.id, new.name, new.description, new.content);
646
+ END;`);
647
+
648
+ db.run(`CREATE TABLE IF NOT EXISTS skill_references (
649
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
650
+ skill_id INTEGER NOT NULL REFERENCES skills(id),
651
+ path TEXT NOT NULL,
652
+ filename TEXT NOT NULL,
653
+ content TEXT NOT NULL,
654
+ word_count INTEGER,
655
+ UNIQUE(skill_id, path)
656
+ );`);
657
+
658
+ db.run(`CREATE INDEX IF NOT EXISTS idx_skill_refs_skill ON skill_references(skill_id);`);
550
659
  }
551
660
 
552
661
  /**
@@ -596,6 +705,10 @@ export function getDbStats() {
596
705
  video_segments: count("SELECT COUNT(*) AS c FROM video_segments"),
597
706
  dude_pages: count("SELECT COUNT(*) AS c FROM dude_pages"),
598
707
  dude_images: count("SELECT COUNT(*) AS c FROM dude_images"),
708
+ skills: count("SELECT COUNT(*) AS c FROM skills"),
709
+ skill_references: count("SELECT COUNT(*) AS c FROM skill_references"),
710
+ schema_nodes: count("SELECT COUNT(*) AS c FROM schema_nodes"),
711
+ schema_node_presence: count("SELECT COUNT(*) AS c FROM schema_node_presence"),
599
712
  ...(() => {
600
713
  // Semantic version sort — SQL MIN/MAX is lexicographic ("7.10" < "7.9")
601
714
  const versions = (db.prepare("SELECT DISTINCT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);