@get-technology-inc/jamf-docs-mcp-server 1.4.1 → 1.5.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.
Files changed (80) hide show
  1. package/dist/constants.d.ts.map +1 -1
  2. package/dist/constants.js +6 -4
  3. package/dist/constants.js.map +1 -1
  4. package/dist/index.js +17 -5
  5. package/dist/index.js.map +1 -1
  6. package/dist/resources/index.d.ts.map +1 -1
  7. package/dist/resources/index.js +3 -1
  8. package/dist/resources/index.js.map +1 -1
  9. package/dist/schemas/index.d.ts +30 -0
  10. package/dist/schemas/index.d.ts.map +1 -1
  11. package/dist/schemas/index.js +49 -0
  12. package/dist/schemas/index.js.map +1 -1
  13. package/dist/schemas/output.d.ts +30 -0
  14. package/dist/schemas/output.d.ts.map +1 -1
  15. package/dist/schemas/output.js +27 -0
  16. package/dist/schemas/output.js.map +1 -1
  17. package/dist/services/cache.d.ts.map +1 -1
  18. package/dist/services/cache.js +3 -1
  19. package/dist/services/cache.js.map +1 -1
  20. package/dist/services/glossary.d.ts +45 -0
  21. package/dist/services/glossary.d.ts.map +1 -0
  22. package/dist/services/glossary.js +323 -0
  23. package/dist/services/glossary.js.map +1 -0
  24. package/dist/services/logging.d.ts +32 -0
  25. package/dist/services/logging.d.ts.map +1 -0
  26. package/dist/services/logging.js +61 -0
  27. package/dist/services/logging.js.map +1 -0
  28. package/dist/services/metadata.d.ts.map +1 -1
  29. package/dist/services/metadata.js +8 -6
  30. package/dist/services/metadata.js.map +1 -1
  31. package/dist/services/scraper.d.ts +12 -0
  32. package/dist/services/scraper.d.ts.map +1 -1
  33. package/dist/services/scraper.js +242 -166
  34. package/dist/services/scraper.js.map +1 -1
  35. package/dist/services/tokenizer.d.ts +5 -0
  36. package/dist/services/tokenizer.d.ts.map +1 -1
  37. package/dist/services/tokenizer.js +30 -14
  38. package/dist/services/tokenizer.js.map +1 -1
  39. package/dist/tools/batch-get-articles.d.ts +14 -0
  40. package/dist/tools/batch-get-articles.d.ts.map +1 -0
  41. package/dist/tools/batch-get-articles.js +240 -0
  42. package/dist/tools/batch-get-articles.js.map +1 -0
  43. package/dist/tools/get-article.d.ts.map +1 -1
  44. package/dist/tools/get-article.js +6 -4
  45. package/dist/tools/get-article.js.map +1 -1
  46. package/dist/tools/get-toc.d.ts.map +1 -1
  47. package/dist/tools/get-toc.js +27 -18
  48. package/dist/tools/get-toc.js.map +1 -1
  49. package/dist/tools/glossary-lookup.d.ts +7 -0
  50. package/dist/tools/glossary-lookup.d.ts.map +1 -0
  51. package/dist/tools/glossary-lookup.js +183 -0
  52. package/dist/tools/glossary-lookup.js.map +1 -0
  53. package/dist/tools/list-products.d.ts.map +1 -1
  54. package/dist/tools/list-products.js +8 -1
  55. package/dist/tools/list-products.js.map +1 -1
  56. package/dist/tools/search.d.ts.map +1 -1
  57. package/dist/tools/search.js +93 -62
  58. package/dist/tools/search.js.map +1 -1
  59. package/dist/transport/http.d.ts.map +1 -1
  60. package/dist/transport/http.js +57 -8
  61. package/dist/transport/http.js.map +1 -1
  62. package/dist/transport/index.d.ts.map +1 -1
  63. package/dist/transport/index.js +4 -2
  64. package/dist/transport/index.js.map +1 -1
  65. package/dist/types.d.ts +11 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/dist/utils/bundle.d.ts +5 -0
  69. package/dist/utils/bundle.d.ts.map +1 -1
  70. package/dist/utils/bundle.js +29 -0
  71. package/dist/utils/bundle.js.map +1 -1
  72. package/dist/utils/concurrency.d.ts +6 -0
  73. package/dist/utils/concurrency.d.ts.map +1 -0
  74. package/dist/utils/concurrency.js +24 -0
  75. package/dist/utils/concurrency.js.map +1 -0
  76. package/dist/utils/progress.d.ts +7 -1
  77. package/dist/utils/progress.d.ts.map +1 -1
  78. package/dist/utils/progress.js +16 -5
  79. package/dist/utils/progress.js.map +1 -1
  80. package/package.json +2 -1
@@ -7,15 +7,17 @@
7
7
  import axios from 'axios';
8
8
  import * as cheerio from 'cheerio';
9
9
  import { sanitizeErrorMessage } from '../utils/sanitize.js';
10
- import { isAllowedHostname } from '../utils/url.js';
10
+ import { isAllowedHostname, extractLocaleFromUrl } from '../utils/url.js';
11
11
  import TurndownService from 'turndown';
12
- import { DOCS_BASE_URL, DOCS_API_URL, JAMF_PRODUCTS, JAMF_TOPICS, DOC_TYPE_LABEL_MAP, REQUEST_CONFIG, SELECTORS, CONTENT_LIMITS, TOKEN_CONFIG, PAGINATION_CONFIG, DEFAULT_LOCALE } from '../constants.js';
12
+ import { DOCS_BASE_URL, DOCS_API_URL, JAMF_PRODUCTS, JAMF_TOPICS, DOC_TYPE_LABEL_MAP, REQUEST_CONFIG, SELECTORS, CONTENT_LIMITS, TOKEN_CONFIG, PAGINATION_CONFIG, DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../constants.js';
13
13
  import { JamfDocsError, JamfDocsErrorCode } from '../types.js';
14
- import { extractVersionFromBundleId, extractProductSlug } from '../utils/bundle.js';
14
+ import { extractVersionFromBundleId, extractProductSlug, compareVersions } from '../utils/bundle.js';
15
15
  import { docTypeFromLabels } from '../utils/doc-type.js';
16
16
  import { cache } from './cache.js';
17
+ import { createLogger } from './logging.js';
17
18
  import { estimateTokens, createTokenInfo, extractSections, extractSection, extractSummary, truncateToTokenLimit, calculatePagination } from './tokenizer.js';
18
19
  import { getBundleIdForVersion } from './metadata.js';
20
+ const log = createLogger('scraper');
19
21
  // Initialize Turndown for HTML to Markdown conversion
20
22
  const turndown = new TurndownService({
21
23
  headingStyle: 'atx',
@@ -47,15 +49,34 @@ async function throttle() {
47
49
  }
48
50
  // Re-export URL utilities for backward compatibility
49
51
  export { ALLOWED_HOSTNAMES, isAllowedHostname } from '../utils/url.js';
52
+ /**
53
+ * Strip locale prefix (e.g. /en-US/, /ja-JP/) from URL path.
54
+ * Frontend URLs include locale in path but backend doesn't accept it.
55
+ */
56
+ export function stripLocalePrefix(urlStr) {
57
+ try {
58
+ const url = new URL(urlStr);
59
+ const segments = url.pathname.split('/').filter(Boolean);
60
+ const firstSegment = segments[0];
61
+ if (firstSegment !== undefined && firstSegment in SUPPORTED_LOCALES) {
62
+ url.pathname = `/${segments.slice(1).join('/')}`;
63
+ return url.toString();
64
+ }
65
+ }
66
+ catch {
67
+ // Invalid URL, return as-is
68
+ }
69
+ return urlStr;
70
+ }
50
71
  // URL transformation between frontend (learn.jamf.com) and backend (learn-be.jamf.com)
51
- function transformToBackendUrl(urlStr) {
72
+ export function transformToBackendUrl(urlStr) {
52
73
  const url = new URL(urlStr);
53
74
  if (url.hostname === 'learn.jamf.com') {
54
75
  url.hostname = 'learn-be.jamf.com';
55
76
  }
56
77
  return url.toString();
57
78
  }
58
- function transformToFrontendUrl(urlStr) {
79
+ export function transformToFrontendUrl(urlStr) {
59
80
  const url = new URL(urlStr);
60
81
  if (url.hostname === 'learn-be.jamf.com') {
61
82
  url.hostname = 'learn.jamf.com';
@@ -140,7 +161,7 @@ function handleAxiosError(error, url, resourceType) {
140
161
  /**
141
162
  * Build Accept-Language header value for a given locale
142
163
  */
143
- function buildAcceptLanguage(locale) {
164
+ export function buildAcceptLanguage(locale) {
144
165
  if (locale === 'en-US') {
145
166
  return 'en-US,en;q=0.9';
146
167
  }
@@ -170,7 +191,7 @@ async function fetchUrl(url, accept, resourceType, locale) {
170
191
  }
171
192
  }
172
193
  const fetchJson = async (url, locale) => await fetchUrl(url, 'application/json', 'Resource', locale);
173
- const fetchHtml = async (url, locale) => await fetchUrl(url, 'text/html,application/xhtml+xml', 'Article', locale);
194
+ export const fetchHtml = async (url, locale) => await fetchUrl(url, 'text/html,application/xhtml+xml', 'Article', locale);
174
195
  /**
175
196
  * Clean HTML content by removing unwanted elements
176
197
  */
@@ -296,108 +317,46 @@ function selectVersionedEntry(leading, followers, requestedVersion) {
296
317
  return { url: leading.url, bundleId: leading.bundle_id, version: leadingVersion, versionMatched: false };
297
318
  }
298
319
  /**
299
- * Search Jamf documentation using Zoomin Search API
320
+ * Transform a Zoomin leading result into a SearchResultWithMeta
300
321
  */
301
- export async function searchDocumentation(params) {
302
- const page = params.page ?? PAGINATION_CONFIG.DEFAULT_PAGE;
303
- const pageSize = params.limit ?? CONTENT_LIMITS.DEFAULT_SEARCH_RESULTS;
304
- const maxTokens = params.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
305
- const locale = params.language ?? DEFAULT_LOCALE;
306
- const cacheKey = `${locale}:search:${JSON.stringify({
307
- query: params.query, product: params.product, topic: params.topic,
308
- docType: params.docType, version: params.version, limit: params.limit,
309
- maxTokens: params.maxTokens, page: 1
310
- })}`;
311
- // Check cache for full results
312
- let allResults = await cache.get(cacheKey);
313
- if (allResults === null) {
314
- // Over-fetch when client-side filters (docType/version) are active
315
- const hasClientFilter = params.docType !== undefined || (params.version !== undefined && params.version !== 'current');
316
- const fetchLimit = hasClientFilter
317
- ? Math.min(CONTENT_LIMITS.MAX_SEARCH_RESULTS * CONTENT_LIMITS.FILTER_OVERFETCH_MULTIPLIER, CONTENT_LIMITS.FILTER_OVERFETCH_CAP)
318
- : CONTENT_LIMITS.MAX_SEARCH_RESULTS;
319
- // Build API URL with query params
320
- const apiUrl = new URL(`${DOCS_API_URL}/api/search`);
321
- apiUrl.searchParams.set('q', params.query);
322
- apiUrl.searchParams.set('rpp', fetchLimit.toString());
323
- if (locale !== DEFAULT_LOCALE) {
324
- apiUrl.searchParams.set('lang', locale);
325
- }
326
- console.error(`[SEARCH] Query: "${params.query}", Product: ${params.product ?? 'all'}, Topic: ${params.topic ?? 'all'}, Locale: ${locale}, URL: ${apiUrl.toString()}`);
327
- try {
328
- const response = await fetchJson(apiUrl.toString(), locale);
329
- // Transform Zoomin results to SearchResult format with metadata
330
- allResults = response.Results
331
- .filter((wrapper) => wrapper.leading_result !== null && wrapper.leading_result !== undefined)
332
- .filter(wrapper => {
333
- // Validate URL hostname from external API
334
- const resultUrl = wrapper.leading_result.url;
335
- if (resultUrl !== '' && !isAllowedHostname(resultUrl)) {
336
- console.error(`[SEARCH] Skipping result with unexpected hostname: ${resultUrl}`);
337
- return false;
338
- }
339
- return true;
340
- })
341
- .map(wrapper => {
342
- const leading = wrapper.leading_result;
343
- // Select versioned entry (leading or matching follower)
344
- const versioned = selectVersionedEntry(leading, wrapper.follower_result, params.version);
345
- // Extract product from bundle_id (works for all bundle types: documentation, release-notes, etc.)
346
- const { bundleId } = versioned;
347
- const bundleSlug = extractProductSlug(bundleId);
348
- const productEntry = bundleSlug !== null
349
- ? Object.entries(JAMF_PRODUCTS).find(([id]) => id === bundleSlug)
350
- : null;
351
- const resultTitle = leading.title !== '' ? leading.title : 'Untitled';
352
- const resultProduct = productEntry !== null && productEntry !== undefined ? productEntry[1].name : (leading.publication_title !== '' ? leading.publication_title : 'Jamf');
353
- const rawSnippet = stripHtml(leading.snippet !== '' ? leading.snippet : '').slice(0, CONTENT_LIMITS.MAX_SNIPPET_LENGTH);
354
- // Derive docType from labels instead of bundle_id regex
355
- const { labels } = leading;
356
- const labelKeys = (labels ?? []).map(l => l.key);
357
- const searchResult = {
358
- title: resultTitle,
359
- url: transformToFrontendUrl(versioned.url !== '' ? versioned.url : ''),
360
- snippet: cleanSnippet(rawSnippet, resultTitle, resultProduct),
361
- product: resultProduct,
362
- version: versioned.version,
363
- docType: docTypeFromLabels(labels)
364
- };
365
- if (leading.score !== undefined) {
366
- searchResult.relevance = leading.score;
367
- }
368
- // Find matched topics (searchText lowercased once, keywords pre-computed)
369
- const searchText = `${searchResult.title} ${searchResult.snippet}`.toLowerCase();
370
- const matchedTopics = ALL_TOPIC_IDS
371
- .filter(topicId => TOPIC_KEYWORDS_LOWER[topicId].some(kw => searchText.includes(kw)));
372
- return {
373
- result: searchResult, bundleSlug, bundleId, matchedTopics, labelKeys,
374
- versionMatched: versioned.versionMatched
375
- };
376
- });
377
- // Cache full results
378
- await cache.set(cacheKey, allResults);
379
- }
380
- catch (error) {
381
- console.error('[SEARCH] Error:', error);
382
- if (error instanceof JamfDocsError) {
383
- throw error;
384
- }
385
- // Return empty results on error
386
- return {
387
- results: [],
388
- pagination: {
389
- page: 1,
390
- pageSize,
391
- totalPages: 0,
392
- totalItems: 0,
393
- hasNext: false,
394
- hasPrev: false
395
- },
396
- tokenInfo: createTokenInfo('', maxTokens)
397
- };
398
- }
322
+ function transformZoominResult(wrapper, requestedVersion) {
323
+ const leading = wrapper.leading_result;
324
+ const versioned = selectVersionedEntry(leading, wrapper.follower_result, requestedVersion);
325
+ const { bundleId } = versioned;
326
+ const bundleSlug = extractProductSlug(bundleId);
327
+ const productEntry = bundleSlug !== null
328
+ ? Object.entries(JAMF_PRODUCTS).find(([id]) => id === bundleSlug)
329
+ : null;
330
+ const resultTitle = leading.title !== '' ? leading.title : 'Untitled';
331
+ const resultProduct = productEntry !== null && productEntry !== undefined
332
+ ? productEntry[1].name
333
+ : (leading.publication_title !== '' ? leading.publication_title : 'Jamf');
334
+ const rawSnippet = stripHtml(leading.snippet !== '' ? leading.snippet : '').slice(0, CONTENT_LIMITS.MAX_SNIPPET_LENGTH);
335
+ const { labels } = leading;
336
+ const labelKeys = (labels ?? []).map(l => l.key);
337
+ const searchResult = {
338
+ title: resultTitle,
339
+ url: transformToFrontendUrl(versioned.url !== '' ? versioned.url : ''),
340
+ snippet: cleanSnippet(rawSnippet, resultTitle, resultProduct),
341
+ product: resultProduct,
342
+ version: versioned.version,
343
+ docType: docTypeFromLabels(labels)
344
+ };
345
+ if (leading.score !== undefined) {
346
+ searchResult.relevance = leading.score;
399
347
  }
400
- // Build active filters
348
+ const searchText = `${searchResult.title} ${searchResult.snippet}`.toLowerCase();
349
+ const matchedTopics = ALL_TOPIC_IDS
350
+ .filter(topicId => TOPIC_KEYWORDS_LOWER[topicId].some(kw => searchText.includes(kw)));
351
+ return {
352
+ result: searchResult, bundleSlug, bundleId, matchedTopics, labelKeys,
353
+ versionMatched: versioned.versionMatched
354
+ };
355
+ }
356
+ /**
357
+ * Build active filters from search params for progressive relaxation
358
+ */
359
+ function buildActiveFilters(params) {
401
360
  const activeFilters = [];
402
361
  if (params.product !== undefined) {
403
362
  activeFilters.push({
@@ -417,13 +376,11 @@ export async function searchDocumentation(params) {
417
376
  if (params.docType !== undefined) {
418
377
  const docTypeFilter = params.docType;
419
378
  const targetLabelKey = DOC_TYPE_LABEL_MAP[docTypeFilter];
420
- // Only apply filter if the docType maps to a known label key
421
379
  if (targetLabelKey !== undefined) {
422
380
  activeFilters.push({
423
381
  name: 'docType',
424
382
  value: docTypeFilter,
425
383
  apply: (results) => results.filter(r => {
426
- // If result has no labels, include it (don't filter out)
427
384
  if (r.labelKeys.length === 0) {
428
385
  return true;
429
386
  }
@@ -432,56 +389,158 @@ export async function searchDocumentation(params) {
432
389
  });
433
390
  }
434
391
  }
435
- // Apply filters with progressive relaxation
436
- const { filtered: filteredResults, relaxation: filterRelaxation } = applyFiltersWithFallback(allResults, activeFilters);
437
- // Calculate pagination
438
- const paginationInfo = calculatePagination(filteredResults.length, page, pageSize);
439
- // Get paginated results
440
- const paginatedResults = filteredResults
441
- .slice(paginationInfo.startIndex, paginationInfo.endIndex)
442
- .map(r => r.result);
443
- // Calculate token info
444
- const resultText = paginatedResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n');
445
- const tokenCount = estimateTokens(resultText);
446
- // Check if we need to truncate
447
- let finalResults = paginatedResults;
448
- let truncated = false;
449
- if (tokenCount > maxTokens) {
450
- // Truncate results to fit token limit
451
- let runningTokens = 0;
452
- finalResults = [];
453
- for (const result of paginatedResults) {
454
- const resultTokens = estimateTokens(`${result.title}\n${result.snippet}\n${result.url}`);
455
- if (runningTokens + resultTokens > maxTokens) {
456
- truncated = true;
457
- break;
392
+ return activeFilters;
393
+ }
394
+ /**
395
+ * Truncate search results to fit within token budget.
396
+ * Returns the truncated results and info about omitted items.
397
+ */
398
+ function truncateSearchResults(paginatedResults, maxTokens) {
399
+ const tokenCount = estimateTokens(paginatedResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n'));
400
+ if (tokenCount <= maxTokens) {
401
+ return { finalResults: paginatedResults, truncated: false };
402
+ }
403
+ let runningTokens = 0;
404
+ const finalResults = [];
405
+ for (const result of paginatedResults) {
406
+ const resultTokens = estimateTokens(`${result.title}\n${result.snippet}\n${result.url}`);
407
+ if (runningTokens + resultTokens > maxTokens) {
408
+ break;
409
+ }
410
+ finalResults.push(result);
411
+ runningTokens += resultTokens;
412
+ }
413
+ const omittedResults = paginatedResults.slice(finalResults.length);
414
+ const truncatedContent = {
415
+ omittedCount: omittedResults.length,
416
+ omittedItems: omittedResults.map(r => ({
417
+ title: r.title,
418
+ estimatedTokens: estimateTokens(`${r.title}\n${r.snippet}\n${r.url}`)
419
+ }))
420
+ };
421
+ return { finalResults, truncated: true, truncatedContent };
422
+ }
423
+ /**
424
+ * Fetch search results from API or cache
425
+ */
426
+ async function fetchSearchResults(params, locale, cacheKey) {
427
+ const cached = await cache.get(cacheKey);
428
+ if (cached !== null) {
429
+ return cached;
430
+ }
431
+ const hasClientFilter = params.docType !== undefined || (params.version !== undefined && params.version !== 'current');
432
+ const fetchLimit = hasClientFilter
433
+ ? Math.min(CONTENT_LIMITS.MAX_SEARCH_RESULTS * CONTENT_LIMITS.FILTER_OVERFETCH_MULTIPLIER, CONTENT_LIMITS.FILTER_OVERFETCH_CAP)
434
+ : CONTENT_LIMITS.MAX_SEARCH_RESULTS;
435
+ const apiUrl = new URL(`${DOCS_API_URL}/api/search`);
436
+ apiUrl.searchParams.set('q', params.query);
437
+ apiUrl.searchParams.set('rpp', fetchLimit.toString());
438
+ if (locale !== DEFAULT_LOCALE) {
439
+ apiUrl.searchParams.set('lang', locale);
440
+ }
441
+ log.debug(`Query: "${params.query}", Product: ${params.product ?? 'all'}, Topic: ${params.topic ?? 'all'}, Locale: ${locale}, URL: ${apiUrl.toString()}`);
442
+ const response = await fetchJson(apiUrl.toString(), locale);
443
+ const results = response.Results
444
+ .filter((wrapper) => wrapper.leading_result !== null && wrapper.leading_result !== undefined)
445
+ .filter(wrapper => {
446
+ const resultUrl = wrapper.leading_result.url;
447
+ if (resultUrl !== '' && !isAllowedHostname(resultUrl)) {
448
+ log.warning(`Skipping result with unexpected hostname: ${resultUrl}`);
449
+ return false;
450
+ }
451
+ return true;
452
+ })
453
+ .map(wrapper => transformZoominResult(wrapper, params.version));
454
+ await cache.set(cacheKey, results);
455
+ return results;
456
+ }
457
+ /**
458
+ * Deduplicate search results by product + page slug, keeping the latest version.
459
+ * Only applied when no version filter is specified.
460
+ */
461
+ function deduplicateByLatestVersion(results) {
462
+ const seen = new Map();
463
+ for (const item of results) {
464
+ const pageMatch = /\/page\/([^?#]+)/.exec(item.result.url);
465
+ if (pageMatch === null) {
466
+ seen.set(`_no_page_${seen.size}`, item);
467
+ continue;
468
+ }
469
+ const pageSlug = pageMatch[1] ?? '';
470
+ const productSlug = item.bundleSlug ?? '_unknown';
471
+ const key = `${productSlug}:${pageSlug}`;
472
+ const existing = seen.get(key);
473
+ if (existing === undefined) {
474
+ seen.set(key, item);
475
+ }
476
+ else {
477
+ const existingVersion = existing.result.version ?? '';
478
+ const newVersion = item.result.version ?? '';
479
+ if (compareVersions(newVersion, existingVersion) > 0) {
480
+ seen.set(key, item);
458
481
  }
459
- finalResults.push(result);
460
- runningTokens += resultTokens;
461
482
  }
462
483
  }
463
- // Reuse already-computed token count when no truncation occurred
464
- const finalTokenCount = truncated
465
- ? estimateTokens(finalResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n'))
466
- : tokenCount;
467
- // Build truncated content info
468
- let truncatedContent;
469
- if (truncated) {
470
- const omittedResults = paginatedResults.slice(finalResults.length);
471
- truncatedContent = {
472
- omittedCount: omittedResults.length,
473
- omittedItems: omittedResults.map(r => ({
474
- title: r.title,
475
- estimatedTokens: estimateTokens(`${r.title}\n${r.snippet}\n${r.url}`)
476
- }))
484
+ const kept = new Set(seen.values());
485
+ return results.filter(r => kept.has(r));
486
+ }
487
+ /**
488
+ * Search Jamf documentation using Zoomin Search API
489
+ */
490
+ export async function searchDocumentation(params) {
491
+ const page = params.page ?? PAGINATION_CONFIG.DEFAULT_PAGE;
492
+ const pageSize = params.limit ?? CONTENT_LIMITS.DEFAULT_SEARCH_RESULTS;
493
+ const maxTokens = params.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
494
+ const locale = params.language ?? DEFAULT_LOCALE;
495
+ const cacheKey = `${locale}:search:${JSON.stringify({
496
+ query: params.query, product: params.product, topic: params.topic,
497
+ docType: params.docType, version: params.version, limit: params.limit,
498
+ maxTokens: params.maxTokens, page: 1
499
+ })}`;
500
+ let allResults;
501
+ try {
502
+ allResults = await fetchSearchResults(params, locale, cacheKey);
503
+ }
504
+ catch (error) {
505
+ log.error(`Search error: ${String(error)}`);
506
+ if (error instanceof JamfDocsError) {
507
+ throw error;
508
+ }
509
+ return {
510
+ results: [],
511
+ pagination: {
512
+ page: 1,
513
+ pageSize,
514
+ totalPages: 0,
515
+ totalItems: 0,
516
+ hasNext: false,
517
+ hasPrev: false
518
+ },
519
+ tokenInfo: createTokenInfo('', maxTokens)
477
520
  };
478
521
  }
522
+ // Build and apply filters with progressive relaxation
523
+ const activeFilters = buildActiveFilters(params);
524
+ const { filtered: filteredResults, relaxation: filterRelaxation } = applyFiltersWithFallback(allResults, activeFilters);
525
+ // Deduplicate cross-version results when no version filter
526
+ const dedupedResults = params.version === undefined || params.version === ''
527
+ ? deduplicateByLatestVersion(filteredResults)
528
+ : filteredResults;
529
+ // Calculate pagination
530
+ const paginationInfo = calculatePagination(dedupedResults.length, page, pageSize);
531
+ // Get paginated results
532
+ const paginatedResults = dedupedResults
533
+ .slice(paginationInfo.startIndex, paginationInfo.endIndex)
534
+ .map(r => r.result);
535
+ // Truncate results to fit within token budget
536
+ const { finalResults, truncated, truncatedContent } = truncateSearchResults(paginatedResults, maxTokens);
537
+ const finalTokenCount = estimateTokens(finalResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n'));
479
538
  // Generate versionNote only when version was requested but not found in some results
480
539
  const requestedVersion = params.version;
481
540
  const hasVersionMismatch = requestedVersion !== undefined
482
541
  && requestedVersion !== 'current'
483
542
  && requestedVersion !== ''
484
- && filteredResults.some(r => !r.versionMatched);
543
+ && dedupedResults.some(r => !r.versionMatched);
485
544
  const versionNote = hasVersionMismatch
486
545
  ? `Version "${requestedVersion}" was not available for some results. Showing the latest version instead.`
487
546
  : undefined;
@@ -491,8 +550,8 @@ export async function searchDocumentation(params) {
491
550
  page: paginationInfo.page,
492
551
  pageSize: paginationInfo.pageSize,
493
552
  totalPages: paginationInfo.totalPages,
494
- totalItems: filteredResults.length,
495
- hasNext: paginationInfo.hasNext || truncated,
553
+ totalItems: dedupedResults.length,
554
+ hasNext: paginationInfo.hasNext,
496
555
  hasPrev: paginationInfo.hasPrev
497
556
  },
498
557
  tokenInfo: {
@@ -511,10 +570,12 @@ export async function searchDocumentation(params) {
511
570
  */
512
571
  export async function fetchArticle(url, options = {}) {
513
572
  const maxTokens = options.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
514
- const locale = options.locale ?? DEFAULT_LOCALE;
515
- // Store original URL for display, use backend URL for fetching
516
- const displayUrl = transformToFrontendUrl(url);
517
- const articleFetchUrl = transformToBackendUrl(url);
573
+ // Infer locale from URL if not explicitly provided (e.g., /ja-JP/bundle/...)
574
+ const locale = options.locale ?? extractLocaleFromUrl(url);
575
+ // Strip locale prefix before transforming — backend doesn't accept /en-US/ etc. in path
576
+ const cleanUrl = stripLocalePrefix(url);
577
+ const displayUrl = transformToFrontendUrl(cleanUrl);
578
+ const articleFetchUrl = transformToBackendUrl(cleanUrl);
518
579
  const cacheKey = `${locale}:article:${displayUrl}`;
519
580
  // Check cache for raw article (without section/token processing)
520
581
  let rawArticle = await cache.get(cacheKey);
@@ -539,10 +600,25 @@ export async function fetchArticle(url, options = {}) {
539
600
  .filter(Boolean);
540
601
  // Extract related articles
541
602
  const relatedArticles = options.includeRelated === true
542
- ? $(SELECTORS.RELATED).map((_, el) => ({
543
- title: $(el).text().trim(),
544
- url: $(el).attr('href') ?? ''
545
- })).get().filter(r => r.title !== '' && r.url !== '')
603
+ ? $(SELECTORS.RELATED).map((_, el) => {
604
+ const rawHref = $(el).attr('href') ?? '';
605
+ // Skip empty or hash-only URLs (same-page anchors)
606
+ if (rawHref === '' || rawHref.startsWith('#')) {
607
+ return { title: '', url: '' };
608
+ }
609
+ // Resolve relative URLs against the article's fetch URL
610
+ let resolvedUrl;
611
+ try {
612
+ resolvedUrl = new URL(rawHref, articleFetchUrl).toString();
613
+ }
614
+ catch {
615
+ resolvedUrl = rawHref;
616
+ }
617
+ return {
618
+ title: $(el).text().trim(),
619
+ url: transformToFrontendUrl(resolvedUrl)
620
+ };
621
+ }).get().filter(r => r.title !== '' && r.url !== '')
546
622
  : undefined;
547
623
  // Extract product info from URL
548
624
  const { product, version } = extractProductInfo(displayUrl);
@@ -629,7 +705,7 @@ async function discoverLatestBundleId(product) {
629
705
  }
630
706
  }
631
707
  catch (error) {
632
- console.error(`[TOC] Error discovering bundle version for ${product}:`, error);
708
+ log.error(`Error discovering bundle version for ${product}: ${String(error)}`);
633
709
  }
634
710
  return null;
635
711
  }
@@ -722,12 +798,12 @@ async function buildTocFromSearch(product, locale) {
722
798
  entries.push({ title, url });
723
799
  }
724
800
  if (entries.length > 0) {
725
- console.error(`[TOC] Built fallback TOC with ${entries.length} entries from search for ${product}`);
801
+ log.info(`Built fallback TOC with ${entries.length} entries from search for ${product}`);
726
802
  }
727
803
  return entries;
728
804
  }
729
805
  catch (error) {
730
- console.error(`[TOC] Failed to build TOC from search for ${product}:`, error);
806
+ log.error(`Failed to build TOC from search for ${product}: ${String(error)}`);
731
807
  return [];
732
808
  }
733
809
  }
@@ -752,7 +828,7 @@ export async function fetchTableOfContents(product, version = 'current', options
752
828
  }
753
829
  // Fetch TOC from backend (may 404 for some products)
754
830
  const tocUrl = `${DOCS_API_URL}/bundle/${bundleId}/toc`;
755
- console.error(`[TOC] Fetching TOC from: ${tocUrl}, Locale: ${locale}`);
831
+ log.debug(`Fetching TOC from: ${tocUrl}, Locale: ${locale}`);
756
832
  allToc = [];
757
833
  try {
758
834
  const tocJson = await fetchJson(tocUrl, locale);
@@ -766,11 +842,11 @@ export async function fetchTableOfContents(product, version = 'current', options
766
842
  }
767
843
  catch (error) {
768
844
  // TOC endpoint may 404 or fail for some products — fall through to search fallback
769
- console.error(`[TOC] Backend TOC request failed for ${product}: ${error instanceof Error ? error.message : String(error)}`);
845
+ log.error(`Backend TOC request failed for ${product}: ${error instanceof Error ? error.message : String(error)}`);
770
846
  }
771
847
  // Fallback: build TOC from search results when backend returns empty or fails
772
848
  if (allToc.length === 0) {
773
- console.error(`[TOC] No TOC entries for ${product}, falling back to search-based discovery`);
849
+ log.info(`No TOC entries for ${product}, falling back to search-based discovery`);
774
850
  allToc = await buildTocFromSearch(product, locale);
775
851
  }
776
852
  // Cache result
@@ -812,7 +888,7 @@ export async function fetchTableOfContents(product, version = 'current', options
812
888
  pageSize: paginationInfo.pageSize,
813
889
  totalPages: paginationInfo.totalPages,
814
890
  totalItems,
815
- hasNext: paginationInfo.hasNext || truncated,
891
+ hasNext: paginationInfo.hasNext,
816
892
  hasPrev: paginationInfo.hasPrev
817
893
  },
818
894
  tokenInfo: {