@tikoci/rosetta 0.7.4 → 0.7.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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
@@ -70,6 +70,53 @@ function link(url: string, display?: string): string {
70
70
  return `${ESC}]8;;${url}\x07${display ?? url}${ESC}]8;;\x07`;
71
71
  }
72
72
 
73
+ /**
74
+ * Lightweight Markdown → ANSI renderer for text we render in the TUI.
75
+ * Handles **bold**, *italic*, `code`, headings (#/##/###), [text](url), and
76
+ * bullet lists. Skips fenced code blocks (``` ... ```) — they're left as
77
+ * indented dim text so RouterOS CLI snippets stay readable.
78
+ *
79
+ * Not a full Markdown parser. Designed to clean up content stored as Markdown
80
+ * (skill files, callout text) so the source markup tokens don't leak into
81
+ * the rendered output. See BACKLOG "TUI usability improvements".
82
+ */
83
+ function mdToAnsi(s: string): string {
84
+ if (!s) return s;
85
+ const lines = s.split("\n");
86
+ const out: string[] = [];
87
+ let inFence = false;
88
+ for (let raw of lines) {
89
+ if (/^\s*```/.test(raw)) {
90
+ inFence = !inFence;
91
+ out.push(dim(raw.replace(/```\w*/, "```")));
92
+ continue;
93
+ }
94
+ if (inFence) {
95
+ out.push(dim(` ${raw}`));
96
+ continue;
97
+ }
98
+ // Headings
99
+ const h = raw.match(/^(#{1,3})\s+(.*)$/);
100
+ if (h) {
101
+ const level = h[1].length;
102
+ const text = h[2];
103
+ if (level === 1) out.push(bold(`══ ${text}`));
104
+ else if (level === 2) out.push(bold(`── ${text}`));
105
+ else out.push(bold(text));
106
+ continue;
107
+ }
108
+ // Bullets
109
+ raw = raw.replace(/^(\s*)[-*]\s+/, (_m, sp) => `${sp}${cyan("•")} `);
110
+ // Inline: links, code, bold, italic — order matters.
111
+ raw = raw.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, txt, url) => link(url, cyan(txt)));
112
+ raw = raw.replace(/`([^`]+)`/g, (_m, code) => `${ESC}[48;5;236m ${code} ${ESC}[0m`);
113
+ raw = raw.replace(/\*\*([^*]+)\*\*/g, (_m, t) => bold(t));
114
+ raw = raw.replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, pre, t) => `${pre}${ESC}[3m${t}${ESC}[0m`);
115
+ out.push(raw);
116
+ }
117
+ return out.join("\n");
118
+ }
119
+
73
120
  /** Terminal width, with fallback */
74
121
  function termWidth(): number {
75
122
  return process.stdout.columns || 80;
@@ -147,9 +194,23 @@ function formatTime(seconds: number): string {
147
194
  // ── Pager ──
148
195
 
149
196
  /** Output lines with paging. Returns true if user quit early. */
197
+ /**
198
+ * Interactive pager.
199
+ *
200
+ * Keys:
201
+ * SPACE / f advance one page
202
+ * ENTER / j advance one line
203
+ * b / < previous page
204
+ * g jump to top
205
+ * G jump to bottom
206
+ * q quit pager (returns true)
207
+ *
208
+ * Shows a status line with page X/Y and line N/M. Returns true if user quit
209
+ * before EOF, false otherwise.
210
+ */
150
211
  async function paged(output: string): Promise<boolean> {
151
212
  const lines = output.split("\n");
152
- const pageSize = termHeight() - 2;
213
+ const pageSize = Math.max(5, termHeight() - 2);
153
214
  if (lines.length <= pageSize || !process.stdout.isTTY) {
154
215
  process.stdout.write(`${output}\n`);
155
216
  return false;
@@ -158,19 +219,29 @@ async function paged(output: string): Promise<boolean> {
158
219
  while (offset < lines.length) {
159
220
  const chunk = lines.slice(offset, offset + pageSize);
160
221
  process.stdout.write(`${chunk.join("\n")}\n`);
161
- offset += pageSize;
162
- if (offset < lines.length) {
163
- const remaining = lines.length - offset;
164
- process.stdout.write(dim(`-- ${remaining} more lines [Q quit | SPACE next page | ENTER next line]`));
165
- const key = await waitForKey();
166
- // Clear the "more" line
167
- process.stdout.write(`\r${" ".repeat(termWidth())}\r`);
168
- if (key === "q" || key === "Q") return true;
169
- // ENTER or arrow-down → advance one line; SPACE → advance one page
170
- if (key === "\r" || key === "\n" || key === "\x1b[B") {
171
- offset -= pageSize - 1; // back up so only 1 line advances
172
- }
222
+ const newOffset = offset + pageSize;
223
+ if (newOffset >= lines.length) return false;
224
+ const totalPages = Math.ceil(lines.length / pageSize);
225
+ const curPage = Math.floor(newOffset / pageSize);
226
+ const prompt = dim(
227
+ `── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back | g/G top/end | q quit]`,
228
+ );
229
+ process.stdout.write(prompt);
230
+ const key = await waitForKey();
231
+ process.stdout.write(`\r${" ".repeat(Math.min(termWidth(), stripAnsi(prompt).length))}\r`);
232
+ if (key === "q" || key === "Q") return true;
233
+ if (key === "g") { offset = 0; continue; }
234
+ if (key === "G") { offset = Math.max(0, lines.length - pageSize); continue; }
235
+ if (key === "b" || key === "<" || key === "\x1b[D") {
236
+ offset = Math.max(0, offset - pageSize);
237
+ continue;
238
+ }
239
+ if (key === "\r" || key === "\n" || key === "j" || key === "\x1b[B") {
240
+ offset += 1;
241
+ continue;
173
242
  }
243
+ // SPACE / f / anything else → next page
244
+ offset = newOffset;
174
245
  }
175
246
  return false;
176
247
  }
@@ -447,7 +518,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
447
518
  // Text content (if present and not a TOC-only view)
448
519
  if (page.text && !page.sections) {
449
520
  out.push(hr());
450
- out.push(page.text);
521
+ out.push(mdToAnsi(page.text));
451
522
  if (page.code) {
452
523
  out.push("");
453
524
  out.push(dim("── code ──"));
@@ -465,7 +536,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
465
536
  out.push(hr());
466
537
  out.push(` ${bold(`§ ${page.section.heading}`)} ${dim(`(level ${page.section.level})`)}`);
467
538
  out.push("");
468
- if (page.text) out.push(page.text);
539
+ if (page.text) out.push(mdToAnsi(page.text));
469
540
  if (page.code) {
470
541
  out.push("");
471
542
  out.push(dim("── code ──"));
@@ -988,6 +1059,12 @@ function renderHelp(): string {
988
1059
  cmd("help", "?", "This help");
989
1060
  cmd("quit", "q", "Exit");
990
1061
 
1062
+ out.push("");
1063
+ out.push(` ${bold("MCP probe (dot-commands)")} ${dim("— see what an agent sees")}`);
1064
+ out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Same query path as MCP, raw JSON output")}`);
1065
+ out.push(` ${cyan(pad(".routeros_get_page <id> [section=]", 38))} ${dim("Probe page response with JSON")}`);
1066
+ out.push(` ${cyan(pad(".routeros_lookup_property name= ...", 38))} ${dim("Direct property lookup")}`);
1067
+ out.push(` ${cyan(pad(".help", 38))} ${dim("Full list of dot-commands")}`);
991
1068
  out.push("");
992
1069
  out.push(` ${dim("Navigation: type a number to select from results.")}`);
993
1070
  out.push(` ${dim("After viewing a page, [p] = properties for that page.")}`);
@@ -1001,12 +1078,213 @@ function renderHelp(): string {
1001
1078
  return out.join("\n");
1002
1079
  }
1003
1080
 
1081
+ // ── MCP probe (dot-commands) ──────────────────────────────────────────────
1082
+ //
1083
+ // `.<tool_name> [positional...] [key=value ...]` invokes the same query
1084
+ // function the MCP server uses and dumps the raw JSON response. Lets a human
1085
+ // in the TUI see exactly what an agent would see — useful for debugging tool
1086
+ // descriptions, classifier output, and `related` block contents at any limit.
1087
+ //
1088
+ // Format mirrors the MCP tool name (e.g. `.routeros_search dhcp limit=12`).
1089
+ // Positional tokens are joined into the tool's "primary" argument (query,
1090
+ // path, name, etc.). `key=value` pairs override or add fields.
1091
+
1092
+ type DotArgs = Record<string, string | number | boolean>;
1093
+
1094
+ function parseDotArgs(rest: string, primary?: string): DotArgs {
1095
+ const args: DotArgs = {};
1096
+ const positional: string[] = [];
1097
+ // Split on whitespace but allow key="quoted value"
1098
+ const tokens = rest.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
1099
+ for (const t of tokens) {
1100
+ const m = t.match(/^([a-z_]\w*)=(.*)$/);
1101
+ if (m) {
1102
+ let v: string | number | boolean = m[2].replace(/^"|"$/g, "");
1103
+ if (v === "true") v = true;
1104
+ else if (v === "false") v = false;
1105
+ else if (/^-?\d+$/.test(v)) v = Number.parseInt(v, 10);
1106
+ args[m[1]] = v;
1107
+ } else {
1108
+ positional.push(t.replace(/^"|"$/g, ""));
1109
+ }
1110
+ }
1111
+ if (primary && positional.length > 0 && args[primary] === undefined) {
1112
+ args[primary] = positional.join(" ");
1113
+ }
1114
+ return args;
1115
+ }
1116
+
1117
+ type DotTool = {
1118
+ /** Field name that bare positional tokens are joined into. */
1119
+ primary?: string;
1120
+ /** Short one-line description shown by `.help`. */
1121
+ desc: string;
1122
+ /** Run the tool — return any JSON-serializable object. */
1123
+ run: (args: DotArgs) => unknown;
1124
+ };
1125
+
1126
+ const dotTools: Record<string, DotTool> = {
1127
+ routeros_search: {
1128
+ primary: "query",
1129
+ desc: "Unified search — same as `s <query>` but with raw JSON response",
1130
+ run: (a) => searchAll(String(a.query ?? ""), a.limit ? Number(a.limit) : undefined),
1131
+ },
1132
+ routeros_get_page: {
1133
+ primary: "page",
1134
+ desc: "Full page by id or title (args: page=, max_length=, section=)",
1135
+ run: (a) => {
1136
+ const p = a.page;
1137
+ const id = typeof p === "number" ? p : /^\d+$/.test(String(p)) ? Number.parseInt(String(p), 10) : String(p);
1138
+ return getPage(
1139
+ id,
1140
+ a.max_length !== undefined ? Number(a.max_length) : undefined,
1141
+ a.section !== undefined ? String(a.section) : undefined,
1142
+ );
1143
+ },
1144
+ },
1145
+ routeros_lookup_property: {
1146
+ primary: "name",
1147
+ desc: "Property by exact name (args: name=, command_path=)",
1148
+ run: (a) => lookupProperty(String(a.name ?? ""), a.command_path ? String(a.command_path) : undefined),
1149
+ },
1150
+ routeros_command_tree: {
1151
+ primary: "path",
1152
+ desc: "Browse command tree (args: path=, version=, arch=)",
1153
+ run: (a) => {
1154
+ const path = String(a.path ?? "");
1155
+ if (a.version) return browseCommandsAtVersion(path, String(a.version), a.arch ? String(a.arch) : undefined);
1156
+ return browseCommands(path, a.arch ? String(a.arch) : undefined);
1157
+ },
1158
+ },
1159
+ routeros_search_changelogs: {
1160
+ primary: "query",
1161
+ desc: "Search changelogs (args: query=, version=, from_version=, to_version=, category=, breaking_only=, limit=)",
1162
+ run: (a) => searchChangelogs(String(a.query ?? ""), {
1163
+ version: a.version ? String(a.version) : undefined,
1164
+ fromVersion: a.from_version ? String(a.from_version) : undefined,
1165
+ toVersion: a.to_version ? String(a.to_version) : undefined,
1166
+ category: a.category ? String(a.category) : undefined,
1167
+ breakingOnly: a.breaking_only === true || a.breaking_only === "true",
1168
+ limit: a.limit ? Number(a.limit) : undefined,
1169
+ }),
1170
+ },
1171
+ routeros_command_version_check: {
1172
+ primary: "command_path",
1173
+ desc: "Version range for a command path (args: command_path=)",
1174
+ run: (a) => {
1175
+ const p = String(a.command_path ?? "");
1176
+ return checkCommandVersions(p.startsWith("/") ? p : `/${p}`);
1177
+ },
1178
+ },
1179
+ routeros_command_diff: {
1180
+ desc: "Diff command tree between versions (args: from_version=, to_version=, path_prefix=, arch=)",
1181
+ run: (a) => diffCommandVersions(
1182
+ String(a.from_version ?? ""),
1183
+ String(a.to_version ?? ""),
1184
+ a.path_prefix ? String(a.path_prefix) : undefined,
1185
+ a.arch ? String(a.arch) : undefined,
1186
+ ),
1187
+ },
1188
+ routeros_device_lookup: {
1189
+ primary: "query",
1190
+ desc: "Device lookup with FTS+filters (args: query=, architecture=, license_level=, has_wireless=, ...)",
1191
+ run: (a) => searchDevices(String(a.query ?? ""), {
1192
+ architecture: a.architecture ? String(a.architecture) : undefined,
1193
+ license_level: a.license_level ? Number(a.license_level) : undefined,
1194
+ has_wireless: a.has_wireless === true || a.has_wireless === "true" ? true : undefined,
1195
+ has_poe: a.has_poe === true || a.has_poe === "true" ? true : undefined,
1196
+ has_lte: a.has_lte === true || a.has_lte === "true" ? true : undefined,
1197
+ min_ram_mb: a.min_ram_mb ? Number(a.min_ram_mb) : undefined,
1198
+ min_storage_mb: a.min_storage_mb ? Number(a.min_storage_mb) : undefined,
1199
+ }, a.limit ? Number(a.limit) : undefined),
1200
+ },
1201
+ routeros_search_tests: {
1202
+ desc: "Cross-device benchmarks (args: device=, test_type=, mode=, configuration=, packet_size=, sort_by=, limit=)",
1203
+ run: (a) => searchDeviceTests({
1204
+ device: a.device ? String(a.device) : undefined,
1205
+ test_type: a.test_type ? String(a.test_type) : undefined,
1206
+ mode: a.mode ? String(a.mode) : undefined,
1207
+ configuration: a.configuration ? String(a.configuration) : undefined,
1208
+ packet_size: a.packet_size ? Number(a.packet_size) : undefined,
1209
+ sort_by: (a.sort_by as "mbps" | "kpps") ?? undefined,
1210
+ }, a.limit ? Number(a.limit) : undefined),
1211
+ },
1212
+ routeros_dude_search: {
1213
+ primary: "query",
1214
+ desc: "Search archived Dude wiki (args: query=, limit=)",
1215
+ run: (a) => searchDude(String(a.query ?? ""), a.limit ? Number(a.limit) : undefined),
1216
+ },
1217
+ routeros_dude_get_page: {
1218
+ primary: "id",
1219
+ desc: "Full Dude wiki page (args: id=, max_length=)",
1220
+ run: (a) => {
1221
+ const id = typeof a.id === "number" ? a.id : /^\d+$/.test(String(a.id)) ? Number.parseInt(String(a.id), 10) : String(a.id);
1222
+ return getDudePage(id, a.max_length ? Number(a.max_length) : undefined);
1223
+ },
1224
+ },
1225
+ routeros_stats: {
1226
+ desc: "DB health / counts",
1227
+ run: () => getDbStats(),
1228
+ },
1229
+ routeros_current_versions: {
1230
+ desc: "Live-fetch RouterOS versions per channel",
1231
+ run: async () => await fetchCurrentVersions(),
1232
+ },
1233
+ };
1234
+
1235
+ async function dispatchDotCommand(input: string): Promise<void> {
1236
+ // .help / .tools — list available probes
1237
+ if (input === ".help" || input === ".?" || input === ".tools") {
1238
+ const out: string[] = [];
1239
+ out.push(` ${bold("MCP probe — direct tool invocation")}`);
1240
+ out.push(` ${dim("Format: .<tool_name> [positional...] [key=value ...]")}`);
1241
+ out.push(` ${dim("Output: raw JSON, exactly what an MCP client would receive.")}`);
1242
+ out.push("");
1243
+ for (const [name, t] of Object.entries(dotTools)) {
1244
+ out.push(` ${cyan(`.${name}`)}`);
1245
+ out.push(` ${dim(t.desc)}`);
1246
+ }
1247
+ out.push("");
1248
+ out.push(` ${dim("Example: .routeros_search firewall filter limit=20")}`);
1249
+ out.push(` ${dim("Example: .routeros_get_page 28282 max_length=4000")}`);
1250
+ out.push(` ${dim("Example: .routeros_lookup_property name=disabled command_path=/ip/firewall/filter")}`);
1251
+ await paged(out.join("\n"));
1252
+ return;
1253
+ }
1254
+ const m = input.match(/^\.([a-z_]\w*)\s*(.*)$/i);
1255
+ if (!m) {
1256
+ console.log(dim(` Bad dot-command syntax. Try '.help' for the list.`));
1257
+ return;
1258
+ }
1259
+ const name = m[1];
1260
+ const tool = dotTools[name];
1261
+ if (!tool) {
1262
+ console.log(dim(` Unknown MCP tool: .${name}. Try '.help' for the list.`));
1263
+ return;
1264
+ }
1265
+ try {
1266
+ const args = parseDotArgs(m[2] ?? "", tool.primary);
1267
+ const result = await tool.run(args);
1268
+ const json = JSON.stringify(result, null, 2);
1269
+ const banner = dim(`── .${name} args=${JSON.stringify(args)}`);
1270
+ await paged(`${banner}\n${json}`);
1271
+ } catch (e) {
1272
+ console.log(red(` Error invoking .${name}: ${(e as Error).message}`));
1273
+ }
1274
+ }
1275
+
1004
1276
  // ── Command dispatcher ──
1005
1277
 
1006
1278
  async function dispatch(input: string): Promise<void> {
1007
1279
  const trimmed = input.trim();
1008
1280
  if (!trimmed) return;
1009
1281
 
1282
+ // ── MCP probe (dot-command) ──
1283
+ if (trimmed.startsWith(".")) {
1284
+ await dispatchDotCommand(trimmed);
1285
+ return;
1286
+ }
1287
+
1010
1288
  // Parse command + args
1011
1289
  const parts = trimmed.split(/\s+/);
1012
1290
  const command = parts[0].toLowerCase();
@@ -1609,7 +1887,7 @@ async function doViewSkill(name: string): Promise<void> {
1609
1887
  out.push(` ${bold(skill.name)} ${dim(`${skill.word_count} words`)}`);
1610
1888
  out.push(` ${skill.description}`);
1611
1889
  out.push("");
1612
- out.push(skill.content);
1890
+ out.push(mdToAnsi(skill.content));
1613
1891
  if (skill.references.length > 0) {
1614
1892
  out.push("");
1615
1893
  out.push(` ${bold("Reference Files")} ${dim(`(${skill.references.length} files)`)}`);
package/src/mcp.ts CHANGED
@@ -569,8 +569,10 @@ Response shape:
569
569
  - classified: { version, topics, command_path, device, property } — what the
570
570
  classifier detected from your input
571
571
  - pages: top FTS matches (title, path, URL, excerpt, best_section)
572
- - related: callouts, properties, changelogs, videos, commands, devices, skills
573
- each capped at 2–3 entries, empty sections omitted
572
+ - related: callouts, properties, changelogs, videos, commands, devices, skills,
573
+ glossary empty sections are omitted. Cap scales with \`limit\`: small limit
574
+ (default 8) keeps related tight (~3 callouts, ~2 videos); larger limit widens
575
+ the net proportionally so a single "hungry" call can pull deeper context.
574
576
  - next_steps: concrete follow-up tool calls informed by the classification
575
577
 
576
578
  Capabilities:
@@ -603,7 +605,7 @@ Tips:
603
605
  .max(50)
604
606
  .optional()
605
607
  .default(8)
606
- .describe("Max page results (default 8). Related sections are always capped at 2–3."),
608
+ .describe("Max page results (default 8). Related-section caps scale with this set higher (e.g. 20) to pull more callouts/videos/properties in a single call."),
607
609
  },
608
610
  },
609
611
  async ({ query, limit }) => {
package/src/query.test.ts CHANGED
@@ -40,6 +40,7 @@ const {
40
40
  lookupGlossary,
41
41
  listGlossary,
42
42
  KNOWN_TOPICS,
43
+ searchAll,
43
44
  } = await import("./query.ts");
44
45
  const { parseChangelog } = await import("./extract-changelogs.ts");
45
46
  const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
@@ -2083,3 +2084,33 @@ describe("listGlossary", () => {
2083
2084
  expect(listGlossary("nonexistent")).toEqual([]);
2084
2085
  });
2085
2086
  });
2087
+
2088
+ // ---------------------------------------------------------------------------
2089
+ // searchAll — unified entrypoint behavior
2090
+ // ---------------------------------------------------------------------------
2091
+
2092
+ describe("searchAll related block", () => {
2093
+ test("includes glossary when input matches a glossary term", () => {
2094
+ const res = searchAll("chr");
2095
+ expect(res.related).toBeDefined();
2096
+ expect(res.related?.glossary).toBeDefined();
2097
+ expect(res.related?.glossary?.term).toBe("chr");
2098
+ });
2099
+
2100
+ test("does not include glossary for unknown terms", () => {
2101
+ const res = searchAll("nonexistentwidgetxyz");
2102
+ expect(res.related?.glossary).toBeUndefined();
2103
+ });
2104
+
2105
+ test("scales callout cap with limit (hunger knob)", () => {
2106
+ // Compare cap behaviour at limit=8 (default) vs limit=30.
2107
+ // We don't assert exact counts — fixture data may not have many callouts —
2108
+ // but we assert that the higher-limit response doesn't artificially cap
2109
+ // BELOW what the lower-limit one returns.
2110
+ const small = searchAll("dhcp", 8);
2111
+ const big = searchAll("dhcp", 30);
2112
+ const smallCallouts = small.related?.callouts?.length ?? 0;
2113
+ const bigCallouts = big.related?.callouts?.length ?? 0;
2114
+ expect(bigCallouts).toBeGreaterThanOrEqual(smallCallouts);
2115
+ });
2116
+ });
package/src/query.ts CHANGED
@@ -266,6 +266,13 @@ type CommandNodeMatch = {
266
266
  linked_page?: { id: number; title: string; url: string };
267
267
  };
268
268
 
269
+ type RelatedGlossary = {
270
+ term: string;
271
+ definition: string;
272
+ category: string;
273
+ search_hint: string;
274
+ };
275
+
269
276
  export type SearchAllRelated = {
270
277
  callouts?: RelatedCallout[];
271
278
  properties?: RelatedProperty[];
@@ -275,6 +282,7 @@ export type SearchAllRelated = {
275
282
  devices?: RelatedDevice[];
276
283
  skills?: SkillSummary[];
277
284
  command_node?: CommandNodeMatch;
285
+ glossary?: RelatedGlossary;
278
286
  };
279
287
 
280
288
  export type SearchAllResponse = {
@@ -288,12 +296,23 @@ export type SearchAllResponse = {
288
296
  note?: string;
289
297
  };
290
298
 
291
- const RELATED_CAP = 3;
292
- const RELATED_VIDEO_CAP = 2;
299
+ // Related-block caps scale with `limit` so an agent (or human) can express
300
+ // "hunger" through one knob — small limit = focused, large limit = wider net.
301
+ // See DESIGN.md "limit as hunger signal" (Parra-aligned: one knob, not many tools).
302
+ const BASE_RELATED_CAP = 3;
303
+ const BASE_RELATED_VIDEO_CAP = 2;
304
+
305
+ function relatedCaps(limit: number): { cap: number; videoCap: number } {
306
+ return {
307
+ cap: Math.max(BASE_RELATED_CAP, Math.floor(limit / 3)),
308
+ videoCap: Math.max(BASE_RELATED_VIDEO_CAP, Math.floor(limit / 4)),
309
+ };
310
+ }
293
311
 
294
312
  export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllResponse {
295
313
  const classified = classifyQuery(query);
296
314
  const pagesResp = searchPages(query, limit);
315
+ const { cap: RELATED_CAP, videoCap: RELATED_VIDEO_CAP } = relatedCaps(limit);
297
316
 
298
317
  const related: SearchAllRelated = {};
299
318
 
@@ -412,6 +431,28 @@ export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllRespon
412
431
  if (matched.length > 0) related.skills = matched.slice(0, 1);
413
432
  }
414
433
 
434
+ // ── Glossary side query ─────────────────────────────────────────────────
435
+ // For short single-term queries, attach a glossary entry if one matches the
436
+ // raw input (or a topic) by exact term or alias. Surfaces domain jargon to
437
+ // agents without adding a separate tool.
438
+ {
439
+ const candidates: string[] = [];
440
+ if (classified.input.split(/\s+/).length <= 2) candidates.push(classified.input);
441
+ candidates.push(...classified.topics);
442
+ for (const c of candidates) {
443
+ const g = lookupGlossary(c);
444
+ if (g) {
445
+ related.glossary = {
446
+ term: g.term,
447
+ definition: g.definition,
448
+ category: g.category,
449
+ search_hint: g.search_hint,
450
+ };
451
+ break;
452
+ }
453
+ }
454
+ }
455
+
415
456
  const next_steps = buildNextSteps(classified, pagesResp.results, related);
416
457
 
417
458
  const note = buildSearchAllNote(classified, pagesResp, related);