@tikoci/rosetta 0.7.5 → 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 +1 -1
- package/src/browse.ts +294 -16
- package/src/mcp.ts +5 -3
- package/src/query.test.ts +31 -0
- package/src/query.ts +43 -2
package/package.json
CHANGED
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
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
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);
|