kiku-agent 1.2.0 → 1.3.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 (131) hide show
  1. package/README.md +459 -407
  2. package/package.json +32 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +637 -0
  6. package/server/agent/citations.js +0 -21
  7. package/server/agent/clarificationAnalytics.js +1 -1
  8. package/server/agent/clarifyingQuestions.js +286 -216
  9. package/server/agent/cli.js +532 -73
  10. package/server/agent/commandFeatures.js +123 -24
  11. package/server/agent/commandRegistry.js +128 -62
  12. package/server/agent/commands.js +1410 -95
  13. package/server/agent/config.js +253 -163
  14. package/server/agent/contextCompactor.js +239 -25
  15. package/server/agent/dynamicCatalog.js +192 -0
  16. package/server/agent/envBootstrap.js +46 -0
  17. package/server/agent/envManager.js +317 -0
  18. package/server/agent/eventBus.js +3 -3
  19. package/server/agent/feedbackLearning.js +0 -8
  20. package/server/agent/gpuMetrics.js +188 -55
  21. package/server/agent/ingestionWorker.js +339 -0
  22. package/server/agent/ink/App.jsx +348 -0
  23. package/server/agent/ink/Panel.jsx +54 -0
  24. package/server/agent/ink/PromptInput.jsx +321 -0
  25. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  26. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  27. package/server/agent/ink/ToolResult.jsx +133 -0
  28. package/server/agent/ink/main.jsx +195 -0
  29. package/server/agent/ink/useEventBus.js +218 -0
  30. package/server/agent/lanDiscovery.js +369 -0
  31. package/server/agent/llmClient.js +111 -39
  32. package/server/agent/lmStudioWakeup.js +144 -0
  33. package/server/agent/mcpClient.js +2 -2
  34. package/server/agent/memory/contextAllocator.js +214 -0
  35. package/server/agent/memory/hierarchicalStore.js +523 -0
  36. package/server/agent/memory/reflectionWorker.js +110 -0
  37. package/server/agent/memory.js +635 -494
  38. package/server/agent/modelConfig.js +487 -0
  39. package/server/agent/newsIntelligence.js +219 -0
  40. package/server/agent/newsWatch.js +85 -0
  41. package/server/agent/prompts.js +146 -0
  42. package/server/agent/publicTools.js +7 -0
  43. package/server/agent/queryDetails.js +62 -1
  44. package/server/agent/research.js +314 -35
  45. package/server/agent/routingInterceptor.js +77 -0
  46. package/server/agent/rumoredProjects.js +15 -0
  47. package/server/agent/sanitizer.js +117 -17
  48. package/server/agent/subagent.js +52 -9
  49. package/server/agent/suggestionTui.js +33 -13
  50. package/server/agent/suggestions.js +29 -13
  51. package/server/agent/telemetry/hardwareTiers.js +332 -0
  52. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  53. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  54. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  55. package/server/agent/telemetry.js +0 -11
  56. package/server/agent/terminalImage.js +0 -1
  57. package/server/agent/textToolProtocol.js +190 -0
  58. package/server/agent/tokenTracker.js +220 -0
  59. package/server/agent/toolRouter.js +362 -216
  60. package/server/agent/tools.js +1155 -194
  61. package/server/agent/ui.js +144 -41
  62. package/server/agent/vramService.js +572 -0
  63. package/server/agent/webSearch.js +2279 -273
  64. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  65. package/server/agent/websearch/adapters/jina.js +77 -0
  66. package/server/agent/websearch/adapters/searxng.js +57 -0
  67. package/server/agent/websearch/core/canonical.js +89 -0
  68. package/server/agent/websearch/core/fusion.js +81 -0
  69. package/server/agent/websearch/core/types.js +29 -0
  70. package/server/agent/websearch/extract.js +605 -0
  71. package/server/agent/websearch/index.js +43 -0
  72. package/server/agent/websearch/orchestrator.js +266 -0
  73. package/server/agent/websearch/resilience/breaker.js +97 -0
  74. package/server/agent/websearch/resilience/cache.js +59 -0
  75. package/server/agent/websearch/resilience/limiter.js +93 -0
  76. package/server/agent/websearch/security/filter.js +36 -0
  77. package/server/agent/websearch/security/ssrf.js +289 -0
  78. package/server/agent/websearch/telemetry.js +71 -0
  79. package/server/agent/youtubeAuth.js +82 -73
  80. package/server/agent/youtubeTranscript.js +209 -0
  81. package/server/apiRouter.js +390 -190
  82. package/server/mcp/data/brands.js +5 -5
  83. package/server/mcp/data/discography.js +56 -45
  84. package/server/mcp/data/gallery.js +9 -0
  85. package/server/mcp/data/index.js +1 -0
  86. package/server/mcp/data/industryIndex.js +251 -0
  87. package/server/mcp/data/profile.js +5 -5
  88. package/server/mcp/data/timeline.js +2 -2
  89. package/server/mcp/data/works.js +2 -2
  90. package/server/mcp/handler.js +0 -2
  91. package/server/mcp/prompts.js +42 -2
  92. package/server/mcp/resources.js +5 -1
  93. package/server/mcp/security.js +25 -14
  94. package/server/mcp/server.js +92 -3
  95. package/server/mcp/tools.js +20 -11
  96. package/server/routes/agent.js +55 -0
  97. package/server/web-chat/core/connection.js +218 -194
  98. package/server/web-chat/core/conversationContext.js +1 -1
  99. package/server/web-chat/core/directCatalogEngine.js +190 -184
  100. package/server/web-chat/core/followUpEngine.js +88 -11
  101. package/server/web-chat/core/security.js +7 -4
  102. package/server/web-chat/router.js +54 -17
  103. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  104. package/server/web-chat/routes/chatRoute.js +488 -397
  105. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  106. package/server/web-chat/routes/exportRoute.js +2 -1
  107. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  108. package/server/web-chat/routes/newsRoute.js +36 -6
  109. package/server/web-chat/routes/providerRoute.js +87 -0
  110. package/server/web-chat/routes/staticRoute.js +61 -13
  111. package/server/web-chat/routes/statusRoute.js +23 -2
  112. package/server/web-chat/routes/vramRoute.js +22 -0
  113. package/server/web-chat/server.js +135 -106
  114. package/server/web-chat/ui/htmlBuilder.js +8 -8
  115. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  116. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  117. package/server/web-chat/ui/scripts/client.js +10526 -8397
  118. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  119. package/server/web-chat/ui/styles/main.css +7471 -4246
  120. package/server/web-chat/ui/templates/body.html +305 -179
  121. package/src/data/brandsData.js +29 -6
  122. package/src/data/discographyData.js +918 -900
  123. package/src/data/galleryData.js +66 -0
  124. package/src/data/industryIndexData.js +292 -0
  125. package/src/data/musicVideoData.js +48 -5
  126. package/src/data/profileData.js +15 -1
  127. package/src/data/timelineData.js +4 -3
  128. package/src/data/worksData.js +536 -535
  129. package/src/lib/vram/estimator.js +464 -0
  130. package/src/lib/vram/modelRegistry.js +387 -0
  131. package/tokens.css +33 -2
@@ -0,0 +1,97 @@
1
+ import { createSearchResult } from '../core/types.js';
2
+ import { safeFetchWithPinning } from '../security/ssrf.js';
3
+ import { loadCheerio, decodeEntities, normalizeText } from '../extract.js';
4
+
5
+ function cleanHtml(raw) {
6
+ if (!raw || typeof raw !== 'string') return '';
7
+ return normalizeText(decodeEntities(raw.replace(/<[^>]+>/g, ' ')));
8
+ }
9
+
10
+ async function parseResultsWithCheerio(html, maxResults) {
11
+ const cheerioLoad = await loadCheerio();
12
+ if (!cheerioLoad) return [];
13
+ const $ = cheerioLoad(html);
14
+ const results = [];
15
+ $('div.result, div.web-result').each((_, el) => {
16
+ if (results.length >= maxResults) return;
17
+ const $el = $(el);
18
+ const anchor = $el.find('a.result__a').first();
19
+ let link = anchor.attr('href') || '';
20
+ if (link.includes('uddg=')) {
21
+ try {
22
+ const decoded = new URL('https://duckduckgo.com' + link).searchParams.get('uddg');
23
+ if (decoded) link = decoded;
24
+ } catch {}
25
+ }
26
+ const title = normalizeText(decodeEntities(anchor.text()));
27
+ const snippet = normalizeText(decodeEntities($el.find('.result__snippet').first().text()));
28
+ if (link && title) results.push({ link, title, snippet });
29
+ });
30
+ return results;
31
+ }
32
+
33
+ function parseResultsWithRegex(html, maxResults) {
34
+ const results = [];
35
+ const resultRegex = /<div[^>]*class="[^"]*result[^"]*"[^>]*>[\s\S]*?<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
36
+ let match;
37
+ while ((match = resultRegex.exec(html)) !== null && results.length < maxResults) {
38
+ results.push({ link: match[1], title: cleanHtml(match[2]), snippet: cleanHtml(match[3]) });
39
+ }
40
+ return results;
41
+ }
42
+
43
+ export async function searchDuckDuckGo(query, options = {}) {
44
+ const maxResults = Number(options.limit) || 10;
45
+ const timeoutMs = Number(options.timeoutMs) || 8000;
46
+ const results = [];
47
+ const seenLinks = new Set();
48
+
49
+ try {
50
+ const lang = options.language || 'en';
51
+ const kl = lang === 'th' ? 'th-th' : (lang === 'zh' ? 'zh-cn' : 'en-us');
52
+ const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}&kl=${kl}`;
53
+
54
+ const res = await safeFetchWithPinning(searchUrl, {
55
+ headers: {
56
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
57
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
58
+ 'Accept-Language': 'th,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7'
59
+ },
60
+ timeoutMs,
61
+ signal: options.signal
62
+ });
63
+
64
+ if (!res.ok) {
65
+ return [];
66
+ }
67
+
68
+ const html = await res.text();
69
+ const cheerioParsed = await parseResultsWithCheerio(html, maxResults);
70
+ const parsed = cheerioParsed.length > 0 ? cheerioParsed : parseResultsWithRegex(html, maxResults);
71
+
72
+ for (const item of parsed) {
73
+ if (results.length >= maxResults) break;
74
+ let rawLink = item.link;
75
+ if (rawLink.includes('uddg=')) {
76
+ try {
77
+ const decoded = new URL('https://duckduckgo.com' + rawLink).searchParams.get('uddg');
78
+ if (decoded) rawLink = decoded;
79
+ } catch {}
80
+ }
81
+
82
+ if (rawLink && item.title && !seenLinks.has(rawLink)) {
83
+ seenLinks.add(rawLink);
84
+ results.push(createSearchResult({
85
+ link: rawLink,
86
+ title: item.title,
87
+ snippet: item.snippet,
88
+ engine: 'duckduckgo',
89
+ score: 1.0 - (results.length * 0.05)
90
+ }));
91
+ }
92
+ }
93
+ } catch {
94
+ }
95
+
96
+ return results;
97
+ }
@@ -0,0 +1,77 @@
1
+ import { createSearchResult } from '../core/types.js';
2
+ import { safeFetchWithPinning } from '../security/ssrf.js';
3
+
4
+
5
+ export async function searchJina(query, options = {}) {
6
+ const maxResults = Number(options.limit) || 10;
7
+ const timeoutMs = Number(options.timeoutMs) || 10000;
8
+ const results = [];
9
+ const seenLinks = new Set();
10
+
11
+ try {
12
+ const searchUrl = `https://s.jina.ai/${encodeURIComponent(query)}`;
13
+ const res = await safeFetchWithPinning(searchUrl, {
14
+ headers: {
15
+ 'Accept': 'application/json, text/plain, */*',
16
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
17
+ 'X-No-Cache': 'false'
18
+ },
19
+ timeoutMs,
20
+ signal: options.signal
21
+ });
22
+
23
+ if (!res.ok) {
24
+ return [];
25
+ }
26
+
27
+ const contentType = res.headers.get('content-type') || '';
28
+ if (contentType.includes('application/json')) {
29
+ const json = await res.json();
30
+ const items = Array.isArray(json?.data) ? json.data : (Array.isArray(json?.results) ? json.results : []);
31
+
32
+ for (const item of items.slice(0, maxResults)) {
33
+ const link = item?.url || item?.link;
34
+ if (link && !seenLinks.has(link)) {
35
+ seenLinks.add(link);
36
+ results.push(createSearchResult({
37
+ link,
38
+ title: item.title || query,
39
+ snippet: item.description || item.content || '',
40
+ engine: 'jina',
41
+ score: 1.0 - (results.length * 0.05),
42
+ metadata: {
43
+ publishedTime: item.publishedTime || null
44
+ }
45
+ }));
46
+ }
47
+ }
48
+ } else {
49
+
50
+ const text = await res.text();
51
+
52
+ const blockRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)([\s\S]*?)(?=\n\[|$)/g;
53
+ let match;
54
+ while ((match = blockRegex.exec(text)) !== null && results.length < maxResults) {
55
+ const title = match[1].trim();
56
+ const link = match[2].trim();
57
+ const snippet = match[3].replace(/\s+/g, ' ').trim().slice(0, 300);
58
+
59
+ if (link && !seenLinks.has(link)) {
60
+ seenLinks.add(link);
61
+ results.push(createSearchResult({
62
+ link,
63
+ title,
64
+ snippet,
65
+ engine: 'jina',
66
+ score: 1.0 - (results.length * 0.05)
67
+ }));
68
+ }
69
+ }
70
+ }
71
+ } catch {
72
+
73
+ return [];
74
+ }
75
+
76
+ return results;
77
+ }
@@ -0,0 +1,57 @@
1
+ import { createSearchResult } from '../core/types.js';
2
+ import { safeFetchWithPinning } from '../security/ssrf.js';
3
+
4
+
5
+ export async function searchSearXNG(query, options = {}) {
6
+ const baseUrl = options.baseUrl || process.env.SEARXNG_URL;
7
+ if (!baseUrl) {
8
+
9
+ return [];
10
+ }
11
+
12
+ const maxResults = Number(options.limit) || 10;
13
+ const timeoutMs = Number(options.timeoutMs) || 8000;
14
+ const results = [];
15
+
16
+ try {
17
+ const cleanBase = baseUrl.replace(/\/+$/, '');
18
+ const searchUrl = `${cleanBase}/search?q=${encodeURIComponent(query)}&format=json&language=${options.language || 'auto'}`;
19
+
20
+ const res = await safeFetchWithPinning(searchUrl, {
21
+ headers: {
22
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
23
+ 'Accept': 'application/json, text/plain, */*'
24
+ },
25
+ timeoutMs,
26
+ signal: options.signal
27
+ });
28
+
29
+ if (!res.ok) {
30
+ return [];
31
+ }
32
+
33
+ const json = await res.json();
34
+ const rawResults = Array.isArray(json?.results) ? json.results : [];
35
+
36
+ for (const item of rawResults.slice(0, maxResults)) {
37
+ if (item?.url && item?.title) {
38
+ results.push(createSearchResult({
39
+ link: item.url,
40
+ title: item.title,
41
+ snippet: item.content || item.snippet || '',
42
+ engine: 'searxng',
43
+ score: Number(item.score) || (1.0 - results.length * 0.05),
44
+ metadata: {
45
+ engines: item.engines || [],
46
+ category: item.category || 'general'
47
+ }
48
+ }));
49
+ }
50
+ }
51
+ } catch {
52
+
53
+ return [];
54
+ }
55
+
56
+ return results;
57
+ }
@@ -0,0 +1,89 @@
1
+ const TRACKING_PARAMS = new Set([
2
+ 'utm_source',
3
+ 'utm_medium',
4
+ 'utm_campaign',
5
+ 'utm_term',
6
+ 'utm_content',
7
+ 'utm_id',
8
+ 'fbclid',
9
+ 'gclid',
10
+ 'dclid',
11
+ 'gclsrc',
12
+ 'ref',
13
+ 'ref_src',
14
+ 'spm',
15
+ 'spm_id_from',
16
+ 'from',
17
+ 'source',
18
+ '_ga',
19
+ '_gl',
20
+ 'mc_eid',
21
+ 'yclid',
22
+ 'trk',
23
+ 'wt_mc',
24
+ 'ocid'
25
+ ]);
26
+
27
+
28
+ export function canonicalizeUrl(rawUrl) {
29
+ if (!rawUrl || typeof rawUrl !== 'string') return '';
30
+ const trimmed = rawUrl.trim();
31
+
32
+ let parsed;
33
+ try {
34
+ parsed = new URL(trimmed);
35
+ } catch {
36
+ return trimmed;
37
+ }
38
+
39
+
40
+ if (parsed.protocol === 'http:' && !parsed.hostname.includes('localhost') && !parsed.hostname.startsWith('127.')) {
41
+ parsed.protocol = 'https:';
42
+ }
43
+
44
+
45
+ let hostname = parsed.hostname.toLowerCase();
46
+ if (hostname.startsWith('www.')) {
47
+ hostname = hostname.slice(4);
48
+ }
49
+ parsed.hostname = hostname;
50
+
51
+
52
+ if ((parsed.protocol === 'https:' && parsed.port === '443') || (parsed.protocol === 'http:' && parsed.port === '80')) {
53
+ parsed.port = '';
54
+ }
55
+
56
+
57
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith('/')) {
58
+ parsed.pathname = parsed.pathname.slice(0, -1);
59
+ }
60
+
61
+
62
+ const searchParams = new URLSearchParams(parsed.search);
63
+ const remainingKeys = [];
64
+ for (const [key] of searchParams.entries()) {
65
+ const lowerKey = key.toLowerCase();
66
+ if (TRACKING_PARAMS.has(lowerKey) || lowerKey.startsWith('utm_')) {
67
+ searchParams.delete(key);
68
+ } else {
69
+ if (!remainingKeys.includes(key)) remainingKeys.push(key);
70
+ }
71
+ }
72
+
73
+ remainingKeys.sort();
74
+ const sortedParams = new URLSearchParams();
75
+ for (const key of remainingKeys) {
76
+ const vals = searchParams.getAll(key);
77
+ for (const val of vals) {
78
+ sortedParams.append(key, val);
79
+ }
80
+ }
81
+
82
+ const sortedQueryString = sortedParams.toString();
83
+ parsed.search = sortedQueryString ? `?${sortedQueryString}` : '';
84
+
85
+
86
+ parsed.hash = '';
87
+
88
+ return parsed.href;
89
+ }
@@ -0,0 +1,81 @@
1
+ import { canonicalizeUrl } from './canonical.js';
2
+
3
+ export const DEFAULT_RRF_K = 60;
4
+
5
+ /**
6
+ * Merges and ranks search results across multiple engines using Reciprocal Rank Fusion (RRF).
7
+ * Formula: Score(d) = Sum_{e in E} ( 1 / (k + rank_e(d)) )
8
+ *
9
+ * @param {Array<Array<import('./types.js').SearchResult>>} engineResultLists - Array of result arrays from engines
10
+ * @param {Object} [options]
11
+ * @param {number} [options.k=60] - Smoothing constant
12
+ * @param {number} [options.limit=10] - Max fused results to return
13
+ * @returns {Array<import('./types.js').SearchResult>} Fused, deduplicated and ranked results
14
+ */
15
+ export function fuseSearchResults(engineResultLists = [], options = {}) {
16
+ const k = Number.isFinite(options.k) ? Math.max(1, options.k) : DEFAULT_RRF_K;
17
+ const limit = Number.isFinite(options.limit) ? Math.max(1, options.limit) : 10;
18
+
19
+
20
+ const aggregated = new Map();
21
+
22
+ for (const list of engineResultLists) {
23
+ if (!Array.isArray(list)) continue;
24
+
25
+ for (let i = 0; i < list.length; i++) {
26
+ const item = list[i];
27
+ if (!item || !item.link) continue;
28
+
29
+ const canonicalLink = canonicalizeUrl(item.link);
30
+ if (!canonicalLink) continue;
31
+
32
+ const rank = i + 1;
33
+ const rrfContribution = 1 / (k + rank);
34
+
35
+ let existing = aggregated.get(canonicalLink);
36
+ if (!existing) {
37
+ existing = {
38
+ link: canonicalLink,
39
+ title: item.title || '',
40
+ snippet: item.snippet || '',
41
+ engine: item.engine || 'meta',
42
+ score: 0,
43
+ metadata: {
44
+ engines: [item.engine].filter(Boolean),
45
+ ranks: { [item.engine]: rank },
46
+ originalLinks: [item.link],
47
+ snippets: item.snippet ? [item.snippet] : []
48
+ }
49
+ };
50
+ aggregated.set(canonicalLink, existing);
51
+ } else {
52
+ if (item.engine && !existing.metadata.engines.includes(item.engine)) {
53
+ existing.metadata.engines.push(item.engine);
54
+ }
55
+ if (item.engine) {
56
+ existing.metadata.ranks[item.engine] = rank;
57
+ }
58
+ if (item.link && !existing.metadata.originalLinks.includes(item.link)) {
59
+ existing.metadata.originalLinks.push(item.link);
60
+ }
61
+ if (item.title && (!existing.title || item.title.length > existing.title.length)) {
62
+ existing.title = item.title;
63
+ }
64
+ if (item.snippet && !existing.metadata.snippets.includes(item.snippet)) {
65
+ existing.metadata.snippets.push(item.snippet);
66
+
67
+ if (item.snippet.length > existing.snippet.length) {
68
+ existing.snippet = item.snippet;
69
+ }
70
+ }
71
+ }
72
+
73
+ existing.score += rrfContribution;
74
+ }
75
+ }
76
+
77
+ const results = Array.from(aggregated.values());
78
+ results.sort((a, b) => b.score - a.score);
79
+
80
+ return results.slice(0, limit);
81
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Canonical Search Result Data Contract
3
+ *
4
+ * @typedef {Object} SearchResult
5
+ * @property {string} link - Canonical destination URL
6
+ * @property {string} title - Sanitized result title
7
+ * @property {string} snippet - Extracted text snippet or abstract
8
+ * @property {'duckduckgo' | 'searxng' | 'jina' | 'openserp' | 'yacy' | string} engine - Origin search engine
9
+ * @property {number} [score] - Optional relevance / RRF score
10
+ * @property {Record<string, unknown>} [metadata] - Optional engine-specific metadata
11
+ */
12
+
13
+ export function createSearchResult({
14
+ link,
15
+ title = '',
16
+ snippet = '',
17
+ engine = 'duckduckgo',
18
+ score = 0,
19
+ metadata = {}
20
+ }) {
21
+ return {
22
+ link: String(link || '').trim(),
23
+ title: String(title || '').trim(),
24
+ snippet: String(snippet || '').trim(),
25
+ engine: String(engine || 'duckduckgo').toLowerCase(),
26
+ score: Number.isFinite(Number(score)) ? Number(score) : 0,
27
+ metadata: metadata && typeof metadata === 'object' ? metadata : {}
28
+ };
29
+ }