@tikoci/rosetta 0.6.9 → 0.7.1
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 +2 -5
- package/package.json +1 -1
- package/src/browse.ts +135 -15
- package/src/classify.test.ts +205 -0
- package/src/classify.ts +216 -0
- package/src/db.ts +100 -0
- package/src/mcp-http.test.ts +8 -7
- package/src/mcp.ts +49 -185
- package/src/query.test.ts +129 -0
- package/src/query.ts +427 -4
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
|
|
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;
|