@tikoci/rosetta 0.5.2 → 0.6.0

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
@@ -269,7 +269,7 @@ Ask your AI assistant questions like:
269
269
 
270
270
  ## MCP Tools
271
271
 
272
- The server exposes 14 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
272
+ The server exposes 16 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
273
273
 
274
274
  | Tool | What it does |
275
275
  |------|-------------|
@@ -285,6 +285,8 @@ The server exposes 14 tools designed to work together — agents start with `rou
285
285
  | `routeros_device_lookup` | Hardware specs — filter by architecture, RAM, PoE, wireless, etc. |
286
286
  | `routeros_search_tests` | Cross-device ethernet and IPSec benchmarks |
287
287
  | `routeros_search_videos` | YouTube transcript search with chapter timestamps |
288
+ | `routeros_dude_search` | FTS across archived Dude wiki docs (separate from RouterOS search) |
289
+ | `routeros_dude_get_page` | Full Dude wiki page by ID or title, with screenshot metadata |
288
290
  | `routeros_stats` | Database health and coverage stats |
289
291
  | `routeros_current_versions` | Live-fetch current RouterOS versions from MikroTik |
290
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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
@@ -19,6 +19,7 @@ import type {
19
19
  ChangelogResult,
20
20
  DeviceResult,
21
21
  DeviceTestRow,
22
+ DudeSearchResult,
22
23
  SearchResponse,
23
24
  SearchResult,
24
25
  SectionTocEntry,
@@ -29,6 +30,7 @@ import {
29
30
  checkCommandVersions,
30
31
  diffCommandVersions,
31
32
  fetchCurrentVersions,
33
+ getDudePage,
32
34
  getPage,
33
35
  getTestResultMeta,
34
36
  lookupProperty,
@@ -36,6 +38,7 @@ import {
36
38
  searchChangelogs,
37
39
  searchDevices,
38
40
  searchDeviceTests,
41
+ searchDude,
39
42
  searchPages,
40
43
  searchProperties,
41
44
  searchVideos,
@@ -196,6 +199,7 @@ type Context =
196
199
  | { type: "callouts"; query: string; results: CalloutResult[] }
197
200
  | { type: "changelogs"; results: ChangelogResult[] }
198
201
  | { type: "videos"; query: string; results: VideoSearchResult[] }
202
+ | { type: "dude"; query: string; results: DudeSearchResult[] }
199
203
  | { type: "properties"; query: string; pageId?: number; results: Array<{ name: string; page_id: number; page_title: string }> }
200
204
  | { type: "diff" }
201
205
  | { type: "vcheck"; path: string };
@@ -235,6 +239,7 @@ function contextLabel(c: Context): string {
235
239
  case "callouts": return c.query ? truncate(`cal: "${c.query}"`, 30) : "callouts";
236
240
  case "changelogs": return "changelogs";
237
241
  case "videos": return truncate(`vid: "${c.query}"`, 30);
242
+ case "dude": return truncate(`dude: "${c.query}"`, 30);
238
243
  case "diff": return "diff";
239
244
  case "vcheck": return truncate(`vc: ${c.path}`, 30);
240
245
  }
@@ -662,6 +667,65 @@ function renderVideos(results: VideoSearchResult[]): string {
662
667
  return out.join("\n");
663
668
  }
664
669
 
670
+ function renderDudeResults(results: DudeSearchResult[]): string {
671
+ const out: string[] = [];
672
+ if (results.length === 0) {
673
+ out.push(` ${dim("No Dude wiki results found.")}`);
674
+ return out.join("\n");
675
+ }
676
+
677
+ for (let i = 0; i < results.length; i++) {
678
+ const d = results[i];
679
+ const num = dim(`${String(i + 1).padStart(3)} `);
680
+ const title = bold(truncate(d.title, 60));
681
+ const ver = dim(`[${d.version}]`);
682
+ const imgs = d.image_count > 0 ? dim(`📷 ${d.image_count}`) : "";
683
+ out.push(`${num}${title} ${ver} ${imgs}`);
684
+ out.push(` ${dim(d.path)}`);
685
+ const excerpt = d.excerpt.replace(/\*\*/g, `${ESC}[1m`);
686
+ out.push(` ${dim(truncate(excerpt, termWidth() - 8))}`);
687
+ out.push("");
688
+ }
689
+
690
+ out.push(` ${cyan("[<n>]")} view page ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
691
+ return out.join("\n");
692
+ }
693
+
694
+ function renderDudePage(page: import("./query.ts").DudePageResult): string {
695
+ const out: string[] = [];
696
+ out.push(` ${bold(page.title)} ${dim(`[${page.version}]`)}`);
697
+ out.push(` ${dim(page.path)}`);
698
+ out.push(` ${cyan(link(page.url))}`);
699
+ if (page.wayback_url) out.push(` ${dim(`Archived: ${page.wayback_url}`)}`);
700
+ out.push("");
701
+
702
+ if (page.images.length > 0) {
703
+ out.push(` ${bold("Screenshots:")} ${page.images.length}`);
704
+ for (const img of page.images) {
705
+ out.push(` ${dim("•")} ${img.filename}${img.caption ? ` ${dim(img.caption)}` : ""}`);
706
+ out.push(` ${dim(img.local_path)}`);
707
+ }
708
+ out.push("");
709
+ }
710
+
711
+ // Truncate long pages for TUI display
712
+ const maxChars = 8000;
713
+ const text = page.text.length > maxChars
714
+ ? `${page.text.slice(0, maxChars)}\n\n ${dim(`... truncated (${page.text.length} chars total)`)}`
715
+ : page.text;
716
+ out.push(text);
717
+
718
+ if (page.code) {
719
+ out.push("");
720
+ out.push(` ${bold("Code:")}`);
721
+ out.push(page.code.length > 2000 ? `${page.code.slice(0, 2000)}\n ${dim("... truncated")}` : page.code);
722
+ }
723
+
724
+ out.push("");
725
+ out.push(` ${cyan("[dude <query>]")} search dude ${cyan("[s <query>]")} search docs ${cyan("[b]")} back`);
726
+ return out.join("\n");
727
+ }
728
+
665
729
  function renderDiff(result: ReturnType<typeof diffCommandVersions>): string {
666
730
  const out: string[] = [];
667
731
  out.push(` ${bold("Command diff:")} ${result.from_version} → ${result.to_version}`);
@@ -775,6 +839,7 @@ function renderHelp(): string {
775
839
  cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)", "routeros_search_callouts");
776
840
  cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
777
841
  cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
842
+ cmd("dude <query>", "", "Search archived Dude wiki docs", "routeros_dude_search");
778
843
  cmd("diff <from> <to> [path]", "", "Command tree diff between versions", "routeros_command_diff");
779
844
  cmd("vcheck <path>", "vc", "Version range for a command path", "routeros_command_version_check");
780
845
  cmd("versions", "ver", "Live-fetch current RouterOS versions", "routeros_current_versions");
@@ -920,6 +985,12 @@ async function dispatch(input: string): Promise<void> {
920
985
  return;
921
986
  }
922
987
 
988
+ case "dude": {
989
+ if (!rest) { console.log(dim(" Usage: dude <query>")); return; }
990
+ await doSearchDude(rest);
991
+ return;
992
+ }
993
+
923
994
  case "diff": {
924
995
  const diffParts = rest.split(/\s+/);
925
996
  if (diffParts.length < 2) {
@@ -989,6 +1060,15 @@ async function handleNumberSelect(idx: number): Promise<void> {
989
1060
  console.log(` ${cyan(link(timeUrl))}\n`);
990
1061
  return;
991
1062
  }
1063
+ if (ctx.type === "dude" && ctx.results[idx]) {
1064
+ const d = ctx.results[idx];
1065
+ const page = getDudePage(d.id);
1066
+ if (page) {
1067
+ await paged(renderDudePage(page));
1068
+ pushCtx({ type: "dude", query: ctx.query, results: ctx.results });
1069
+ }
1070
+ return;
1071
+ }
992
1072
  if (ctx.type === "properties" && ctx.results[idx]) {
993
1073
  const p = ctx.results[idx];
994
1074
  await doPage(String(p.page_id));
@@ -1222,6 +1302,16 @@ async function doSearchVideos(query: string): Promise<void> {
1222
1302
  pushCtx({ type: "videos", query, results });
1223
1303
  }
1224
1304
 
1305
+ async function doSearchDude(query: string): Promise<void> {
1306
+ const results = searchDude(query, 10);
1307
+ if (results.length === 0) {
1308
+ console.log(dim(` No Dude wiki results for "${query}".`));
1309
+ return;
1310
+ }
1311
+ await paged(` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`);
1312
+ pushCtx({ type: "dude", query, results });
1313
+ }
1314
+
1225
1315
  async function doDiff(from: string, to: string, pathPrefix?: string): Promise<void> {
1226
1316
  const result = diffCommandVersions(from, to, pathPrefix);
1227
1317
  await paged(renderDiff(result));
package/src/db.ts CHANGED
@@ -21,6 +21,9 @@
21
21
  * videos_fts — FTS5 over title, description
22
22
  * video_segments — transcript segments (one per chapter, or full video if no chapters)
23
23
  * video_segments_fts — FTS5 over chapter_title, transcript
24
+ * dude_pages — The Dude documentation pages (archived from wiki.mikrotik.com via Wayback Machine)
25
+ * dude_pages_fts — FTS5 over title, path, text, code
26
+ * dude_images — screenshot images from The Dude wiki pages
24
27
  */
25
28
 
26
29
  import sqlite from "bun:sqlite";
@@ -205,17 +208,83 @@ export function initDb() {
205
208
  db.run(`CREATE INDEX IF NOT EXISTS idx_commands_type ON commands(type);`);
206
209
 
207
210
  // -- Command version tracking --
211
+ //
212
+ // ros_versions is keyed on (version, arch) — restraml emits per-arch
213
+ // deep-inspect.<arch>.json (x86 vs arm64; arm64 carries ~1K extra nodes
214
+ // for wifi-qcom etc.). _meta fields capture provenance from the inspect
215
+ // file (generatedAt, crashPaths, completionStats). _attrs is a JSON
216
+ // catch-all for forward-compat: anything restraml later emits lands here
217
+ // first and gets promoted to a column once shape is stable.
218
+ //
219
+ // command_versions intentionally has no FK to ros_versions — the composite
220
+ // PK on the parent makes a single-column FK invalid, and command_versions
221
+ // is slated for replacement by schema_node_presence in the upcoming
222
+ // schema_nodes refactor (BACKLOG.md "Multi-arch schema import").
223
+
224
+ // Migration: legacy ros_versions had `version TEXT PRIMARY KEY` with no
225
+ // arch column. Detect via PRAGMA table_info and rebuild both tables in
226
+ // place — the FK on command_versions.ros_version requires the dance.
227
+ // Idempotent: only fires when arch column is missing.
228
+ {
229
+ const rvCols = db.prepare("PRAGMA table_info(ros_versions)").all() as Array<{ name: string }>;
230
+ if (rvCols.length > 0 && !rvCols.some((c) => c.name === "arch")) {
231
+ db.run("PRAGMA foreign_keys=OFF;");
232
+ db.run("BEGIN;");
233
+ try {
234
+ db.run(`CREATE TABLE ros_versions_new (
235
+ version TEXT NOT NULL,
236
+ arch TEXT NOT NULL DEFAULT 'x86',
237
+ channel TEXT,
238
+ extra_packages INTEGER NOT NULL DEFAULT 0,
239
+ extracted_at TEXT NOT NULL,
240
+ generated_at TEXT,
241
+ crash_paths_tested TEXT,
242
+ crash_paths_crashed TEXT,
243
+ completion_stats TEXT,
244
+ source_url TEXT,
245
+ _attrs TEXT,
246
+ PRIMARY KEY (version, arch)
247
+ );`);
248
+ db.run(`INSERT INTO ros_versions_new (version, arch, channel, extra_packages, extracted_at)
249
+ SELECT version, 'x86', channel, extra_packages, extracted_at FROM ros_versions;`);
250
+ db.run("DROP TABLE ros_versions;");
251
+ db.run("ALTER TABLE ros_versions_new RENAME TO ros_versions;");
252
+
253
+ db.run(`CREATE TABLE command_versions_new (
254
+ command_path TEXT NOT NULL,
255
+ ros_version TEXT NOT NULL,
256
+ PRIMARY KEY (command_path, ros_version)
257
+ );`);
258
+ db.run("INSERT INTO command_versions_new SELECT command_path, ros_version FROM command_versions;");
259
+ db.run("DROP TABLE command_versions;");
260
+ db.run("ALTER TABLE command_versions_new RENAME TO command_versions;");
261
+ db.run("COMMIT;");
262
+ } catch (e) {
263
+ db.run("ROLLBACK;");
264
+ throw e;
265
+ }
266
+ db.run("PRAGMA foreign_keys=ON;");
267
+ }
268
+ }
208
269
 
209
270
  db.run(`CREATE TABLE IF NOT EXISTS ros_versions (
210
- version TEXT PRIMARY KEY,
211
- channel TEXT,
212
- extra_packages INTEGER NOT NULL DEFAULT 0,
213
- extracted_at TEXT NOT NULL
271
+ version TEXT NOT NULL,
272
+ arch TEXT NOT NULL DEFAULT 'x86',
273
+ channel TEXT,
274
+ extra_packages INTEGER NOT NULL DEFAULT 0,
275
+ extracted_at TEXT NOT NULL,
276
+ generated_at TEXT,
277
+ crash_paths_tested TEXT,
278
+ crash_paths_crashed TEXT,
279
+ completion_stats TEXT,
280
+ source_url TEXT,
281
+ _attrs TEXT,
282
+ PRIMARY KEY (version, arch)
214
283
  );`);
215
284
 
216
285
  db.run(`CREATE TABLE IF NOT EXISTS command_versions (
217
286
  command_path TEXT NOT NULL,
218
- ros_version TEXT NOT NULL REFERENCES ros_versions(version),
287
+ ros_version TEXT NOT NULL,
219
288
  PRIMARY KEY (command_path, ros_version)
220
289
  );`);
221
290
 
@@ -426,6 +495,58 @@ export function initDb() {
426
495
  END;`);
427
496
 
428
497
  db.run(`CREATE INDEX IF NOT EXISTS idx_video_segs_video ON video_segments(video_id);`);
498
+
499
+ // -- The Dude documentation (archived from MikroTik wiki via Wayback Machine) --
500
+
501
+ db.run(`CREATE TABLE IF NOT EXISTS dude_pages (
502
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
503
+ slug TEXT NOT NULL UNIQUE,
504
+ title TEXT NOT NULL,
505
+ path TEXT NOT NULL,
506
+ version TEXT NOT NULL DEFAULT 'v6',
507
+ url TEXT NOT NULL,
508
+ wayback_url TEXT NOT NULL,
509
+ text TEXT NOT NULL,
510
+ code TEXT,
511
+ last_edited TEXT,
512
+ word_count INTEGER
513
+ );`);
514
+
515
+ db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS dude_pages_fts USING fts5(
516
+ title, path, text, code,
517
+ content=dude_pages,
518
+ content_rowid=id,
519
+ tokenize='porter unicode61'
520
+ );`);
521
+
522
+ db.run(`CREATE TRIGGER IF NOT EXISTS dude_pages_ai AFTER INSERT ON dude_pages BEGIN
523
+ INSERT INTO dude_pages_fts(rowid, title, path, text, code)
524
+ VALUES (new.id, new.title, new.path, new.text, new.code);
525
+ END;`);
526
+ db.run(`CREATE TRIGGER IF NOT EXISTS dude_pages_ad AFTER DELETE ON dude_pages BEGIN
527
+ INSERT INTO dude_pages_fts(dude_pages_fts, rowid, title, path, text, code)
528
+ VALUES('delete', old.id, old.title, old.path, old.text, old.code);
529
+ END;`);
530
+ db.run(`CREATE TRIGGER IF NOT EXISTS dude_pages_au AFTER UPDATE ON dude_pages BEGIN
531
+ INSERT INTO dude_pages_fts(dude_pages_fts, rowid, title, path, text, code)
532
+ VALUES('delete', old.id, old.title, old.path, old.text, old.code);
533
+ INSERT INTO dude_pages_fts(rowid, title, path, text, code)
534
+ VALUES (new.id, new.title, new.path, new.text, new.code);
535
+ END;`);
536
+
537
+ db.run(`CREATE TABLE IF NOT EXISTS dude_images (
538
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
539
+ page_id INTEGER NOT NULL REFERENCES dude_pages(id),
540
+ filename TEXT NOT NULL,
541
+ alt_text TEXT,
542
+ caption TEXT,
543
+ local_path TEXT NOT NULL,
544
+ original_url TEXT,
545
+ wayback_url TEXT,
546
+ sort_order INTEGER NOT NULL
547
+ );`);
548
+
549
+ db.run(`CREATE INDEX IF NOT EXISTS idx_dude_images_page ON dude_images(page_id);`);
429
550
  }
430
551
 
431
552
  /**
@@ -470,12 +591,14 @@ export function getDbStats() {
470
591
  devices_with_tests: count("SELECT COUNT(DISTINCT device_id) AS c FROM device_test_results"),
471
592
  changelogs: count("SELECT COUNT(*) AS c FROM changelogs"),
472
593
  changelog_versions: count("SELECT COUNT(DISTINCT version) AS c FROM changelogs"),
473
- ros_versions: count("SELECT COUNT(*) AS c FROM ros_versions"),
594
+ ros_versions: count("SELECT COUNT(DISTINCT version) AS c FROM ros_versions"),
474
595
  videos: count("SELECT COUNT(*) AS c FROM videos"),
475
596
  video_segments: count("SELECT COUNT(*) AS c FROM video_segments"),
597
+ dude_pages: count("SELECT COUNT(*) AS c FROM dude_pages"),
598
+ dude_images: count("SELECT COUNT(*) AS c FROM dude_images"),
476
599
  ...(() => {
477
600
  // Semantic version sort — SQL MIN/MAX is lexicographic ("7.10" < "7.9")
478
- const versions = (db.prepare("SELECT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
601
+ const versions = (db.prepare("SELECT DISTINCT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
479
602
  if (versions.length === 0) return { ros_version_min: null, ros_version_max: null };
480
603
  const norm = (v: string) => {
481
604
  const clean = v.replace(/beta\d*/, "").replace(/rc\d*/, "");
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * extract-commands.ts — Load RouterOS command tree from inspect.json into SQLite.
3
+ * extract-commands.ts — Load RouterOS command tree from inspect.json (or
4
+ * deep-inspect.<arch>.json) into SQLite.
4
5
  *
5
6
  * Walks the nested JSON tree and flattens it into a `commands` table with:
6
7
  * path, name, type (dir|cmd|arg), parent_path, description
7
8
  *
8
- * Also populates command_versions for version tracking.
9
+ * Also populates command_versions for version tracking, and ros_versions
10
+ * with provenance (arch, _meta.generatedAt, crashPaths, completionStats)
11
+ * when the source file is a deep-inspect.json (i.e. has a `_meta` envelope).
9
12
  *
10
13
  * Usage:
11
- * bun run src/extract-commands.ts [inspect.json-path-or-url] [--version=7.22] [--channel=stable] [--extra]
12
- * bun run src/extract-commands.ts --accumulate [inspect.json-path] [--version=X]
14
+ * bun run src/extract-commands.ts [path-or-url] [--version=7.22] [--channel=stable] [--extra] [--arch=x86|arm64]
15
+ * bun run src/extract-commands.ts --accumulate [path] [--version=X]
13
16
  *
14
17
  * In default mode: replaces commands table and sets as primary version.
15
18
  * With --accumulate: only adds to command_versions, does not touch commands table.
19
+ *
20
+ * --arch defaults to x86 (back-compat for legacy single-arch inspect.json).
21
+ * deep-inspect.x86.json / deep-inspect.arm64.json filenames auto-derive arch.
16
22
  */
17
23
 
18
24
  import { db, initDb } from "./db.ts";
@@ -33,6 +39,13 @@ const flagArgs = Object.fromEntries(
33
39
  const DEFAULT_INSPECT_URL = `${RESTRAML_PAGES_URL}/7.22.1/extra/inspect.json`;
34
40
  const INSPECT_SOURCE = positional[0] || DEFAULT_INSPECT_URL;
35
41
 
42
+ function deriveArch(filepath: string, flag: string | undefined): "x86" | "arm64" {
43
+ if (flag === "x86" || flag === "arm64") return flag;
44
+ if (/deep-inspect\.arm64\b/.test(filepath)) return "arm64";
45
+ if (/deep-inspect\.x86\b/.test(filepath)) return "x86";
46
+ return "x86";
47
+ }
48
+
36
49
  // Derive version from path if not explicitly set
37
50
  function deriveVersion(filepath: string): string {
38
51
  const match = filepath.match(/\/(\d+\.\d+(?:\.\d+)?(?:beta\d+|rc\d+)?)\//);
@@ -48,11 +61,30 @@ function deriveChannel(version: string): string {
48
61
 
49
62
  const version = flagArgs.version || deriveVersion(INSPECT_SOURCE);
50
63
  const channel = flagArgs.channel || deriveChannel(version);
64
+ const arch = deriveArch(INSPECT_SOURCE, flagArgs.arch);
51
65
 
52
66
  console.log(`Loading inspect.json from ${INSPECT_SOURCE}...`);
53
- console.log(`Version: ${version}, Channel: ${channel}, Extra: ${extraPackages}, Accumulate: ${accumulate}`);
67
+ console.log(`Version: ${version}, Channel: ${channel}, Arch: ${arch}, Extra: ${extraPackages}, Accumulate: ${accumulate}`);
54
68
  const inspectData = await loadJson<Record<string, unknown>>(INSPECT_SOURCE);
55
69
 
70
+ // deep-inspect.json files carry a `_meta` envelope; vanilla inspect.json does not.
71
+ interface DeepInspectMeta {
72
+ version?: string;
73
+ generatedAt?: string;
74
+ crashPathsTested?: string[];
75
+ crashPathsCrashed?: string[];
76
+ completionStats?: Record<string, unknown>;
77
+ }
78
+ const meta = (inspectData._meta && typeof inspectData._meta === "object")
79
+ ? (inspectData._meta as DeepInspectMeta)
80
+ : null;
81
+ if (meta) {
82
+ console.log(` _meta.version=${meta.version} generatedAt=${meta.generatedAt}`);
83
+ if (meta.completionStats) {
84
+ console.log(` completionStats=${JSON.stringify(meta.completionStats)}`);
85
+ }
86
+ }
87
+
56
88
  interface CommandRow {
57
89
  path: string;
58
90
  name: string;
@@ -65,7 +97,7 @@ const rows: CommandRow[] = [];
65
97
 
66
98
  function walk(obj: Record<string, unknown>, parentPath: string) {
67
99
  for (const [key, value] of Object.entries(obj)) {
68
- if (key === "_type" || key === "desc") continue;
100
+ if (key === "_type" || key === "desc" || key === "_meta") continue;
69
101
  if (typeof value !== "object" || value === null) continue;
70
102
 
71
103
  const node = value as Record<string, unknown>;
@@ -104,11 +136,24 @@ console.log(` dirs: ${dirs}, cmds: ${cmds}, args: ${args}`);
104
136
  // Initialize DB and insert
105
137
  initDb();
106
138
 
107
- // Register this version
139
+ // Register this version (per-arch row). _meta fields are populated when the
140
+ // source is a deep-inspect.json; legacy inspect.json leaves them NULL.
108
141
  db.run(
109
- `INSERT OR REPLACE INTO ros_versions (version, channel, extra_packages, extracted_at)
110
- VALUES (?, ?, ?, datetime('now'))`,
111
- [version, channel, extraPackages ? 1 : 0],
142
+ `INSERT OR REPLACE INTO ros_versions
143
+ (version, arch, channel, extra_packages, extracted_at,
144
+ generated_at, crash_paths_tested, crash_paths_crashed, completion_stats, source_url)
145
+ VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?)`,
146
+ [
147
+ version,
148
+ arch,
149
+ channel,
150
+ extraPackages ? 1 : 0,
151
+ meta?.generatedAt ?? null,
152
+ meta?.crashPathsTested ? JSON.stringify(meta.crashPathsTested) : null,
153
+ meta?.crashPathsCrashed ? JSON.stringify(meta.crashPathsCrashed) : null,
154
+ meta?.completionStats ? JSON.stringify(meta.completionStats) : null,
155
+ INSPECT_SOURCE,
156
+ ],
112
157
  );
113
158
 
114
159
  if (!accumulate) {