@tikoci/rosetta 0.6.9 → 0.7.2

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.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * no author/date/engagement signals — just text search with BM25 ranking.
6
6
  */
7
7
 
8
+ import { classifyQuery, type QueryClassification } from "./classify.ts";
8
9
  import { db } from "./db.ts";
9
10
 
10
11
  export type SearchResult = {
@@ -89,6 +90,47 @@ const COMPOUND_TERMS: [string, string][] = [
89
90
  ["address", "list"],
90
91
  ];
91
92
 
93
+ /**
94
+ * Known RouterOS topics — extracted from changelog categories, top-level
95
+ * command tree names, and common subsystem tokens. Used by the classifier
96
+ * (future) and glossary lookup to recognize domain-specific terms before
97
+ * FTS search. Maintained as a static Set for O(1) lookup.
98
+ *
99
+ * Source: `SELECT DISTINCT category FROM changelogs` (153 values) plus
100
+ * top-level commands (app, interface, ip, system) and common synonyms.
101
+ */
102
+ export const KNOWN_TOPICS = new Set([
103
+ // --- From changelog categories (high-frequency subsystems) ---
104
+ "api", "backup", "bgp", "bluetooth", "bonding", "bridge", "capsman",
105
+ "certificate", "chr", "cloud", "conntrack", "console", "container",
106
+ "defconf", "discovery", "disk", "dns", "dot1x", "dude",
107
+ "ethernet", "export", "fetch", "filesystem", "firewall",
108
+ "gps", "graphing", "hardware", "health", "hotspot",
109
+ "ike1", "ike2", "interface", "ipsec", "ipv6",
110
+ "l2tp", "l3hw", "ldp", "led", "log", "lora", "lte",
111
+ "macsec", "mlag", "modem", "mpls", "mqtt",
112
+ "netinstall", "netwatch", "ntp",
113
+ "ospf", "ovpn",
114
+ "poe", "ppp", "pppoe", "pptp", "ptp",
115
+ "queue", "quickset",
116
+ "radius", "resource", "rip", "romon", "route", "routing",
117
+ "serial", "sfp", "smb", "sms", "sniffer", "snmp", "socks", "ssh", "ssl", "sstp",
118
+ "switch", "system",
119
+ "traceroute", "tunnels", "upgrade", "upnp", "ups", "usb", "user",
120
+ "vlan", "vpls", "vrf", "vrrp", "vxlan",
121
+ "webfig", "wifi", "wifiwave2", "winbox", "wireguard", "wireless",
122
+ "zerotier",
123
+ // --- From changelog (DHCP variants, routing sub-protocols) ---
124
+ "dhcp", "dhcpv4", "dhcpv6", "rpki", "pimsm",
125
+ // --- Top-level command paths ---
126
+ "app", "ip",
127
+ // --- Common subsystem shorthand / synonyms ---
128
+ "nat", "mangle", "raw", "filter",
129
+ "bgp-vpn", "user-manager", "traffic-flow", "traffic-generator",
130
+ "route-filter", "routing-filter", "mac-telnet",
131
+ "w60g", "tr069",
132
+ ]);
133
+
92
134
  function getSpecialSearchHint(question: string): string | undefined {
93
135
  const normalized = question.toLowerCase();
94
136
  if (/(?:^|\b)(?:the\s+)?dude(?:\b|$)/.test(normalized)) {
@@ -173,6 +215,287 @@ export function searchPages(question: string, limit = DEFAULT_LIMIT): SearchResp
173
215
  return { query: question, ftsQuery, fallbackMode, results, total: results.length, ...(note ? { note } : {}) };
174
216
  }
175
217
 
218
+ // ───────────────────────────────────────────────────────────────────────────
219
+ // searchAll — unified entry point for MCP `routeros_search` and TUI `s`.
220
+ // Runs the classifier first, then the main pages FTS, then classifier-informed
221
+ // side queries in parallel (all sync under bun:sqlite). Returns a single
222
+ // enriched response so a typical question gets a multi-source answer in one
223
+ // roundtrip. See DESIGN.md "North Star Architecture".
224
+ // ───────────────────────────────────────────────────────────────────────────
225
+
226
+ type RelatedCommand = {
227
+ path: string;
228
+ name: string;
229
+ type: string;
230
+ description: string | null;
231
+ page_title: string | null;
232
+ page_url: string | null;
233
+ };
234
+
235
+ type RelatedProperty = {
236
+ name: string;
237
+ type: string | null;
238
+ default_val: string | null;
239
+ description: string;
240
+ page_title: string;
241
+ page_url: string;
242
+ page_id: number;
243
+ };
244
+
245
+ type RelatedDevice = {
246
+ product_name: string;
247
+ product_code: string | null;
248
+ architecture: string | null;
249
+ cpu: string | null;
250
+ license_level: number | null;
251
+ product_url: string | null;
252
+ };
253
+
254
+ type RelatedCallout = {
255
+ type: string;
256
+ content: string;
257
+ page_title: string;
258
+ page_url: string;
259
+ excerpt: string;
260
+ };
261
+
262
+ type CommandNodeMatch = {
263
+ path: string;
264
+ type: string;
265
+ description: string | null;
266
+ linked_page?: { id: number; title: string; url: string };
267
+ };
268
+
269
+ export type SearchAllRelated = {
270
+ callouts?: RelatedCallout[];
271
+ properties?: RelatedProperty[];
272
+ changelogs?: ChangelogResult[];
273
+ videos?: VideoSearchResult[];
274
+ commands?: RelatedCommand[];
275
+ devices?: RelatedDevice[];
276
+ skills?: SkillSummary[];
277
+ command_node?: CommandNodeMatch;
278
+ };
279
+
280
+ export type SearchAllResponse = {
281
+ query: string;
282
+ classified: QueryClassification;
283
+ pages: SearchResult[];
284
+ total_pages: number;
285
+ fallback_mode: "or" | null;
286
+ related: SearchAllRelated;
287
+ next_steps: string[];
288
+ note?: string;
289
+ };
290
+
291
+ const RELATED_CAP = 3;
292
+ const RELATED_VIDEO_CAP = 2;
293
+
294
+ export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllResponse {
295
+ const classified = classifyQuery(query);
296
+ const pagesResp = searchPages(query, limit);
297
+
298
+ const related: SearchAllRelated = {};
299
+
300
+ // ── Command-path side query ─────────────────────────────────────────────
301
+ if (classified.command_path) {
302
+ const node = db
303
+ .prepare(
304
+ `SELECT c.path, c.type, c.description,
305
+ p.id as page_id, p.title as page_title, p.url as page_url
306
+ FROM commands c
307
+ LEFT JOIN pages p ON p.id = c.page_id
308
+ WHERE c.path = ? LIMIT 1`,
309
+ )
310
+ .get(classified.command_path) as
311
+ | { path: string; type: string; description: string | null; page_id: number | null; page_title: string | null; page_url: string | null }
312
+ | null;
313
+
314
+ if (node) {
315
+ related.command_node = {
316
+ path: node.path,
317
+ type: node.type,
318
+ description: node.description,
319
+ ...(node.page_id
320
+ ? { linked_page: { id: node.page_id, title: node.page_title!, url: node.page_url! } }
321
+ : {}),
322
+ };
323
+ }
324
+
325
+ const children = db
326
+ .prepare(
327
+ `SELECT c.path, c.name, c.type, c.description,
328
+ p.title as page_title, p.url as page_url
329
+ FROM commands c
330
+ LEFT JOIN pages p ON p.id = c.page_id
331
+ WHERE c.parent_path = ?
332
+ ORDER BY c.type DESC, c.name
333
+ LIMIT ?`,
334
+ )
335
+ .all(classified.command_path, RELATED_CAP) as RelatedCommand[];
336
+ if (children.length > 0) related.commands = children;
337
+ }
338
+
339
+ // ── Property side query ─────────────────────────────────────────────────
340
+ if (classified.property) {
341
+ const props = lookupProperty(classified.property).slice(0, RELATED_CAP);
342
+ if (props.length > 0) {
343
+ related.properties = props.map((p) => ({
344
+ name: p.name,
345
+ type: p.type,
346
+ default_val: p.default_val,
347
+ description: p.description,
348
+ page_title: p.page_title,
349
+ page_url: p.page_url,
350
+ page_id: p.page_id,
351
+ }));
352
+ }
353
+ }
354
+
355
+ // ── Device side query ───────────────────────────────────────────────────
356
+ if (classified.device) {
357
+ const devResp = searchDevices(classified.device, {}, RELATED_CAP);
358
+ if (devResp.results.length > 0) {
359
+ related.devices = devResp.results.slice(0, RELATED_CAP).map((d) => ({
360
+ product_name: d.product_name,
361
+ product_code: d.product_code,
362
+ architecture: d.architecture,
363
+ cpu: d.cpu,
364
+ license_level: d.license_level,
365
+ product_url: d.product_url,
366
+ }));
367
+ }
368
+ }
369
+
370
+ // ── Changelog side query ────────────────────────────────────────────────
371
+ // Prefer classifier signals: version narrows, primary topic filters by category.
372
+ if (classified.version || classified.topics.length > 0) {
373
+ const opts: Parameters<typeof searchChangelogs>[1] = { limit: RELATED_CAP };
374
+ if (classified.version) opts.version = classified.version;
375
+ if (classified.topics[0]) {
376
+ // Category filter — only fire if the topic is actually a known changelog category.
377
+ const exists = db
378
+ .prepare("SELECT 1 FROM changelogs WHERE category = ? LIMIT 1")
379
+ .get(classified.topics[0]);
380
+ if (exists) opts.category = classified.topics[0];
381
+ }
382
+ // Only run if we actually have a narrowing signal (don't flood with recent generic entries).
383
+ if (opts.version || opts.category) {
384
+ const logs = searchChangelogs(query, opts);
385
+ if (logs.length > 0) related.changelogs = logs.slice(0, RELATED_CAP);
386
+ }
387
+ }
388
+
389
+ // ── Video side query ────────────────────────────────────────────────────
390
+ const videos = searchVideos(query, RELATED_VIDEO_CAP);
391
+ if (videos.length > 0) related.videos = videos;
392
+
393
+ // ── Callout side query ──────────────────────────────────────────────────
394
+ const calloutsRaw = searchCallouts(query, undefined, RELATED_CAP);
395
+ if (calloutsRaw.length > 0) {
396
+ related.callouts = calloutsRaw.slice(0, RELATED_CAP).map((c) => ({
397
+ type: c.type,
398
+ content: c.content,
399
+ page_title: c.page_title,
400
+ page_url: c.page_url,
401
+ excerpt: c.excerpt,
402
+ }));
403
+ }
404
+
405
+ // ── Skills side query ───────────────────────────────────────────────────
406
+ // Only include when a classifier topic overlaps a skill name (cheap string match).
407
+ if (classified.topics.length > 0) {
408
+ const allSkills = listSkills();
409
+ const matched = allSkills.filter((s) =>
410
+ classified.topics.some((t) => s.name.toLowerCase().includes(t)),
411
+ );
412
+ if (matched.length > 0) related.skills = matched.slice(0, 1);
413
+ }
414
+
415
+ const next_steps = buildNextSteps(classified, pagesResp.results, related);
416
+
417
+ const note = buildSearchAllNote(classified, pagesResp, related);
418
+
419
+ return {
420
+ query,
421
+ classified,
422
+ pages: pagesResp.results,
423
+ total_pages: pagesResp.total,
424
+ fallback_mode: pagesResp.fallbackMode,
425
+ related,
426
+ next_steps,
427
+ ...(note ? { note } : {}),
428
+ };
429
+ }
430
+
431
+ function buildNextSteps(
432
+ classified: QueryClassification,
433
+ pages: SearchResult[],
434
+ related: SearchAllRelated,
435
+ ): string[] {
436
+ const steps: string[] = [];
437
+
438
+ if (related.command_node) {
439
+ const p = related.command_node.path;
440
+ steps.push(`routeros_command_tree path="${p}" — browse children of ${p}`);
441
+ if (classified.version) {
442
+ steps.push(`routeros_command_version_check path="${p}" — confirm availability in ${classified.version}`);
443
+ }
444
+ if (related.command_node.linked_page) {
445
+ steps.push(`routeros_get_page id=${related.command_node.linked_page.id} — full docs for "${related.command_node.linked_page.title}"`);
446
+ }
447
+ }
448
+
449
+ if (classified.device) {
450
+ steps.push(`routeros_device_lookup query="${classified.device}" — hardware specs and test results`);
451
+ }
452
+
453
+ if (classified.property && !related.properties?.length) {
454
+ steps.push(`routeros_lookup_property name="${classified.property}" — direct property lookup (with optional command_path filter)`);
455
+ }
456
+
457
+ if (classified.version && !related.changelogs?.length) {
458
+ steps.push(`routeros_search_changelogs version="${classified.version}" — version-specific changes`);
459
+ }
460
+
461
+ // Zero-result fallback — steer the agent toward alternate tools.
462
+ if (pages.length === 0 && Object.keys(related).length === 0) {
463
+ steps.push(`routeros_search_changelogs query="${classified.input}" — check if the term appears in changelog history`);
464
+ if (!classified.device && /[A-Z]{2,}\d/.test(classified.input)) {
465
+ steps.push(`routeros_device_lookup query="${classified.input}" — could be a device model`);
466
+ }
467
+ steps.push(`routeros_stats — verify DB coverage and version range`);
468
+ }
469
+
470
+ if (pages.length > 0) {
471
+ const top = pages[0];
472
+ steps.push(`routeros_get_page id=${top.id} — open "${top.title}"`);
473
+ }
474
+
475
+ return steps;
476
+ }
477
+
478
+ function buildSearchAllNote(
479
+ classified: QueryClassification,
480
+ pagesResp: SearchResponse,
481
+ related: SearchAllRelated,
482
+ ): string | undefined {
483
+ const notes: string[] = [];
484
+ if (pagesResp.note) notes.push(pagesResp.note);
485
+ if (pagesResp.fallbackMode === "or") {
486
+ notes.push("No pages matched all terms — used OR fallback.");
487
+ }
488
+ if (pagesResp.results.length === 0 && Object.keys(related).length === 0) {
489
+ const hints: string[] = [];
490
+ if (classified.device) hints.push(`device "${classified.device}"`);
491
+ if (classified.version) hints.push(`version ${classified.version}`);
492
+ if (classified.topics.length > 0) hints.push(`topics: ${classified.topics.join(", ")}`);
493
+ const hintStr = hints.length > 0 ? ` Classifier detected: ${hints.join("; ")}.` : "";
494
+ notes.push(`Nothing matched for "${classified.input}".${hintStr} See next_steps for alternatives.`);
495
+ }
496
+ return notes.length > 0 ? notes.join(" ") : undefined;
497
+ }
498
+
176
499
  function runFtsQuery(ftsQuery: string, limit: number): SearchResult[] {
177
500
  if (!ftsQuery) return [];
178
501
  try {
@@ -238,9 +561,41 @@ export type SectionTocEntry = {
238
561
  url: string;
239
562
  };
240
563
 
564
+ /** Compact property summary included in TOC-mode responses to surface high-signal content early. */
565
+ type PagePropertySummary = { name: string; type: string | null; description: string };
566
+
567
+ /** Compact related-video segment included in TOC-mode responses. */
568
+ type RelatedVideo = { title: string; url: string; chapter_title: string | null; start_s: number; excerpt: string };
569
+
570
+ /** Top-N properties for a page (by sort_order). Description trimmed for compactness. */
571
+ function getPagePropertiesSummary(pageId: number, limit = 10): PagePropertySummary[] {
572
+ const rows = db
573
+ .prepare(
574
+ `SELECT name, type, description FROM properties WHERE page_id = ? ORDER BY sort_order LIMIT ?`,
575
+ )
576
+ .all(pageId, limit) as Array<{ name: string; type: string | null; description: string }>;
577
+ return rows.map((r) => ({
578
+ name: r.name,
579
+ type: r.type,
580
+ description: r.description.length > 200 ? `${r.description.slice(0, 200).trim()}...` : r.description,
581
+ }));
582
+ }
583
+
584
+ /** Related video segments via FTS5 match on page title. Compact — one line per segment. */
585
+ function getRelatedVideos(pageTitle: string, limit = 3): RelatedVideo[] {
586
+ try {
587
+ const results = searchVideos(pageTitle, limit);
588
+ return results.map((r) => ({ title: r.title, url: r.url, chapter_title: r.chapter_title, start_s: r.start_s, excerpt: r.excerpt }));
589
+ } catch {
590
+ return [];
591
+ }
592
+ }
593
+
241
594
  /** Get full page content by ID or title. Optional max_length truncates text+code.
242
595
  * If `section` is provided, returns only that section's content.
243
- * If content would be truncated and the page has sections, returns a TOC instead. */
596
+ * If content would be truncated and the page has sections, returns a TOC instead
597
+ * TOC responses include prioritized high-signal fields (`properties`, `related_videos`)
598
+ * so the caller sees valuable content without a second tool call. */
244
599
  export function getPage(idOrTitle: string | number, maxLength?: number, section?: string): {
245
600
  id: number;
246
601
  title: string;
@@ -255,6 +610,8 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
255
610
  truncated?: { text_total: number; code_total: number };
256
611
  sections?: SectionTocEntry[];
257
612
  section?: { heading: string; level: number; anchor_id: string };
613
+ properties?: PagePropertySummary[];
614
+ related_videos?: RelatedVideo[];
258
615
  note?: string;
259
616
  } | null {
260
617
  const row =
@@ -312,6 +669,8 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
312
669
  ...descendants.map((d) => ({ heading: d.heading, level: d.level, anchor_id: d.anchor_id, char_count: d.text.length + d.code.length, url: `${page.url}#${d.anchor_id}` })),
313
670
  ];
314
671
  const totalChars = fullText.length + fullCode.length;
672
+ const props = getPagePropertiesSummary(page.id);
673
+ const videos = getRelatedVideos(page.title);
315
674
  return {
316
675
  id: page.id, title: page.title, path: page.path, url: `${page.url}#${sec.anchor_id}`,
317
676
  text: "", code: "",
@@ -319,6 +678,8 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
319
678
  callouts: [], callout_summary: calloutSummary(callouts),
320
679
  sections: subToc,
321
680
  section: { heading: sec.heading, level: sec.level, anchor_id: sec.anchor_id },
681
+ ...(props.length > 0 ? { properties: props } : {}),
682
+ ...(videos.length > 0 ? { related_videos: videos } : {}),
322
683
  note: `Section "${sec.heading}" content (${totalChars} chars) exceeds max_length (${maxLength}). Showing ${subToc.length} sub-sections. Re-call with a more specific section heading or anchor_id.`,
323
684
  };
324
685
  }
@@ -377,18 +738,24 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
377
738
  const toc = getPageToc(page.id, page.url);
378
739
  if (toc.length > 0) {
379
740
  const totalChars = text.length + code.length;
741
+ const props = getPagePropertiesSummary(page.id);
742
+ const videos = getRelatedVideos(page.title);
380
743
  return {
381
744
  id: page.id, title: page.title, path: page.path, url: page.url,
382
745
  text: "", code: "",
383
746
  word_count: page.word_count, code_lines: page.code_lines,
384
747
  callouts: [], callout_summary: calloutSummary(callouts),
385
748
  sections: toc,
749
+ ...(props.length > 0 ? { properties: props } : {}),
750
+ ...(videos.length > 0 ? { related_videos: videos } : {}),
386
751
  truncated: { text_total: text.length, code_total: code.length },
387
- note: `Page content (${totalChars} chars) exceeds max_length (${maxLength}). Showing table of contents with ${toc.length} sections. Re-call with section parameter to retrieve specific sections.`,
752
+ note: `Page content (${totalChars} chars) exceeds max_length (${maxLength}). Showing table of contents with ${toc.length} sections${props.length > 0 ? ` + ${props.length} top properties` : ""}${videos.length > 0 ? ` + ${videos.length} related videos` : ""}. Re-call with section parameter to retrieve specific sections, or routeros_lookup_property for full property details.`,
388
753
  };
389
754
  }
390
755
 
391
- // No sections — fall back to truncation
756
+ // No sections — fall back to truncation. Callouts are already returned in full
757
+ // (they carry high-signal Warnings/Notes). Also surface properties so the caller
758
+ // doesn't need a follow-up routeros_lookup_property call to see them.
392
759
  const textTotal = text.length;
393
760
  const codeTotal = code.length;
394
761
  const codeBudget = Math.min(code.length, Math.floor(maxLength * 0.2));
@@ -396,9 +763,17 @@ export function getPage(idOrTitle: string | number, maxLength?: number, section?
396
763
  text = `${text.slice(0, textBudget)}\n\n[... truncated — ${textTotal} chars total, showing first ${textBudget}]`;
397
764
  code = codeTotal > codeBudget ? `${code.slice(0, codeBudget)}\n# [... truncated — ${codeTotal} chars total]` : code;
398
765
  truncated = { text_total: textTotal, code_total: codeTotal };
766
+ const props = getPagePropertiesSummary(page.id);
767
+ return {
768
+ id: page.id, title: page.title, path: page.path, url: page.url,
769
+ text, code, word_count: page.word_count, code_lines: page.code_lines,
770
+ callouts,
771
+ ...(props.length > 0 ? { properties: props } : {}),
772
+ truncated,
773
+ };
399
774
  }
400
775
 
401
- return { id: page.id, title: page.title, path: page.path, url: page.url, text, code, word_count: page.word_count, code_lines: page.code_lines, callouts, ...(truncated ? { truncated } : {}) };
776
+ return { id: page.id, title: page.title, path: page.path, url: page.url, text, code, word_count: page.word_count, code_lines: page.code_lines, callouts };
402
777
  }
403
778
 
404
779
  /** Build compact callout summary (count + type breakdown) for TOC-mode responses. */
@@ -603,6 +978,54 @@ function runPropertiesFtsQuery(
603
978
  }
604
979
  }
605
980
 
981
+ // ---- Glossary lookup ----
982
+
983
+ export type GlossaryEntry = {
984
+ term: string;
985
+ definition: string;
986
+ aliases: string | null;
987
+ category: string;
988
+ search_hint: string;
989
+ };
990
+
991
+ /**
992
+ * Look up a term in the glossary. O(1) lookup by exact term or alias match.
993
+ * Returns null if the term isn't recognized domain jargon.
994
+ */
995
+ export function lookupGlossary(input: string): GlossaryEntry | null {
996
+ const normalized = input.toLowerCase().trim();
997
+ if (!normalized) return null;
998
+
999
+ // Try exact term match
1000
+ const exact = db
1001
+ .prepare("SELECT term, definition, aliases, category, search_hint FROM glossary WHERE term = ?")
1002
+ .get(normalized) as GlossaryEntry | null;
1003
+ if (exact) return exact;
1004
+
1005
+ // Try alias match (aliases are comma-separated)
1006
+ const aliasMatch = db
1007
+ .prepare(
1008
+ `SELECT term, definition, aliases, category, search_hint FROM glossary
1009
+ WHERE ',' || REPLACE(aliases, ' ', '') || ',' LIKE '%,' || REPLACE(?, ' ', '') || ',%'`,
1010
+ )
1011
+ .get(normalized) as GlossaryEntry | null;
1012
+ return aliasMatch;
1013
+ }
1014
+
1015
+ /**
1016
+ * List all glossary entries, optionally filtered by category.
1017
+ */
1018
+ export function listGlossary(category?: string): GlossaryEntry[] {
1019
+ if (category) {
1020
+ return db
1021
+ .prepare("SELECT term, definition, aliases, category, search_hint FROM glossary WHERE category = ? ORDER BY term")
1022
+ .all(category) as GlossaryEntry[];
1023
+ }
1024
+ return db
1025
+ .prepare("SELECT term, definition, aliases, category, search_hint FROM glossary ORDER BY term")
1026
+ .all() as GlossaryEntry[];
1027
+ }
1028
+
606
1029
  export type CalloutResult = {
607
1030
  type: string;
608
1031
  content: string;