@open-mercato/web-research 0.7.1-develop.7194.1.ab4fc81f82

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 (121) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +67 -0
  3. package/build.mjs +10 -0
  4. package/dist/contract/adapter.js +13 -0
  5. package/dist/contract/adapter.js.map +7 -0
  6. package/dist/contract/errors.js +28 -0
  7. package/dist/contract/errors.js.map +7 -0
  8. package/dist/contract/http.js +7 -0
  9. package/dist/contract/http.js.map +7 -0
  10. package/dist/contract/index.js +9 -0
  11. package/dist/contract/index.js.map +7 -0
  12. package/dist/contract/outcomes.js +58 -0
  13. package/dist/contract/outcomes.js.map +7 -0
  14. package/dist/contract/policy.js +91 -0
  15. package/dist/contract/policy.js.map +7 -0
  16. package/dist/contract/results.js +13 -0
  17. package/dist/contract/results.js.map +7 -0
  18. package/dist/contract/steps.js +1 -0
  19. package/dist/contract/steps.js.map +7 -0
  20. package/dist/contract/version.js +11 -0
  21. package/dist/contract/version.js.map +7 -0
  22. package/dist/engine/cache.js +29 -0
  23. package/dist/engine/cache.js.map +7 -0
  24. package/dist/engine/deadline.js +54 -0
  25. package/dist/engine/deadline.js.map +7 -0
  26. package/dist/engine/engine.js +481 -0
  27. package/dist/engine/engine.js.map +7 -0
  28. package/dist/engine/index.js +20 -0
  29. package/dist/engine/index.js.map +7 -0
  30. package/dist/engine/runAdapter.js +57 -0
  31. package/dist/engine/runAdapter.js.map +7 -0
  32. package/dist/engine/types.js +1 -0
  33. package/dist/engine/types.js.map +7 -0
  34. package/dist/extract/classify.js +51 -0
  35. package/dist/extract/classify.js.map +7 -0
  36. package/dist/extract/content.js +104 -0
  37. package/dist/extract/content.js.map +7 -0
  38. package/dist/extract/entities.js +95 -0
  39. package/dist/extract/entities.js.map +7 -0
  40. package/dist/extract/index.js +24 -0
  41. package/dist/extract/index.js.map +7 -0
  42. package/dist/extract/text.js +114 -0
  43. package/dist/extract/text.js.map +7 -0
  44. package/dist/extract/tokenizer.js +141 -0
  45. package/dist/extract/tokenizer.js.map +7 -0
  46. package/dist/fusion/canonicalize.js +88 -0
  47. package/dist/fusion/canonicalize.js.map +7 -0
  48. package/dist/fusion/fuse.js +88 -0
  49. package/dist/fusion/fuse.js.map +7 -0
  50. package/dist/fusion/index.js +11 -0
  51. package/dist/fusion/index.js.map +7 -0
  52. package/dist/index.js +21 -0
  53. package/dist/index.js.map +7 -0
  54. package/dist/loader/describe.js +92 -0
  55. package/dist/loader/describe.js.map +7 -0
  56. package/dist/loader/index.js +31 -0
  57. package/dist/loader/index.js.map +7 -0
  58. package/dist/loader/manifest.js +32 -0
  59. package/dist/loader/manifest.js.map +7 -0
  60. package/dist/loader/registry.js +98 -0
  61. package/dist/loader/registry.js.map +7 -0
  62. package/dist/net/client.js +298 -0
  63. package/dist/net/client.js.map +7 -0
  64. package/dist/net/ssrf.js +165 -0
  65. package/dist/net/ssrf.js.map +7 -0
  66. package/dist/net/url.js +9 -0
  67. package/dist/net/url.js.map +7 -0
  68. package/dist/testing/contract.js +118 -0
  69. package/dist/testing/contract.js.map +7 -0
  70. package/dist/testing/fakeAdapter.js +44 -0
  71. package/dist/testing/fakeAdapter.js.map +7 -0
  72. package/dist/testing/index.js +15 -0
  73. package/dist/testing/index.js.map +7 -0
  74. package/jest.config.cjs +24 -0
  75. package/package.json +65 -0
  76. package/src/contract/adapter.ts +100 -0
  77. package/src/contract/errors.ts +48 -0
  78. package/src/contract/http.ts +46 -0
  79. package/src/contract/index.ts +8 -0
  80. package/src/contract/outcomes.ts +91 -0
  81. package/src/contract/policy.ts +157 -0
  82. package/src/contract/results.ts +79 -0
  83. package/src/contract/steps.ts +45 -0
  84. package/src/contract/version.ts +25 -0
  85. package/src/engine/__tests__/engine.test.ts +597 -0
  86. package/src/engine/cache.ts +46 -0
  87. package/src/engine/deadline.ts +65 -0
  88. package/src/engine/engine.ts +633 -0
  89. package/src/engine/index.ts +22 -0
  90. package/src/engine/runAdapter.ts +89 -0
  91. package/src/engine/types.ts +137 -0
  92. package/src/extract/__tests__/content.test.ts +81 -0
  93. package/src/extract/__tests__/text.test.ts +61 -0
  94. package/src/extract/__tests__/tokenizer.test.ts +85 -0
  95. package/src/extract/classify.ts +81 -0
  96. package/src/extract/content.ts +136 -0
  97. package/src/extract/entities.ts +98 -0
  98. package/src/extract/index.ts +11 -0
  99. package/src/extract/text.ts +124 -0
  100. package/src/extract/tokenizer.ts +197 -0
  101. package/src/fusion/__tests__/fusion.test.ts +198 -0
  102. package/src/fusion/canonicalize.ts +119 -0
  103. package/src/fusion/fuse.ts +145 -0
  104. package/src/fusion/index.ts +8 -0
  105. package/src/index.ts +15 -0
  106. package/src/loader/__tests__/describe.test.ts +119 -0
  107. package/src/loader/__tests__/registry.test.ts +134 -0
  108. package/src/loader/describe.ts +145 -0
  109. package/src/loader/index.ts +25 -0
  110. package/src/loader/manifest.ts +44 -0
  111. package/src/loader/registry.ts +156 -0
  112. package/src/net/__tests__/client.test.ts +273 -0
  113. package/src/net/__tests__/ssrf.test.ts +165 -0
  114. package/src/net/client.ts +434 -0
  115. package/src/net/ssrf.ts +235 -0
  116. package/src/net/url.ts +13 -0
  117. package/src/testing/contract.ts +172 -0
  118. package/src/testing/fakeAdapter.ts +69 -0
  119. package/src/testing/index.ts +9 -0
  120. package/tsconfig.json +10 -0
  121. package/watch.mjs +7 -0
@@ -0,0 +1,198 @@
1
+ import { canonicalizeUrl } from '../canonicalize'
2
+ import { fuseResults, type FusionInput } from '../fuse'
3
+
4
+ const OPTIONS = { limit: 10, minResults: 1, minConfidence: 0, maxPerDomain: 10 }
5
+
6
+ /**
7
+ * Matches on the registrable host rather than a URL substring: `includes()`
8
+ * would also match a host that merely embeds the name (`farm.com.evil.test`),
9
+ * which is both a weaker assertion and a CodeQL finding.
10
+ */
11
+ const hostOf = (url: string): string => new URL(url).hostname.replace(/^www\./, '')
12
+
13
+ describe('canonicalizeUrl', () => {
14
+ it('strips tracking parameters but keeps meaningful ones', () => {
15
+ const canonical = canonicalizeUrl('https://example.com/a?utm_source=x&id=7&fbclid=y')
16
+ expect(canonical?.url).toBe('https://example.com/a?id=7')
17
+ })
18
+
19
+ it('drops the fragment and trailing slash', () => {
20
+ expect(canonicalizeUrl('https://example.com/a/#section')?.url).toBe('https://example.com/a')
21
+ })
22
+
23
+ it('orders query parameters so equivalent URLs collapse', () => {
24
+ const left = canonicalizeUrl('https://example.com/a?b=2&a=1')
25
+ const right = canonicalizeUrl('https://example.com/a?a=1&b=2')
26
+ expect(left?.key).toBe(right?.key)
27
+ })
28
+
29
+ it('folds www into the dedup key while keeping the URL fetchable', () => {
30
+ const withWww = canonicalizeUrl('https://www.example.com/a')
31
+ const without = canonicalizeUrl('https://example.com/a')
32
+ expect(withWww?.key).toBe(without?.key)
33
+ expect(withWww?.url).toBe('https://www.example.com/a')
34
+ })
35
+
36
+ it('unwraps a DuckDuckGo redirect', () => {
37
+ const canonical = canonicalizeUrl(
38
+ 'https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Freal&rut=abc',
39
+ )
40
+ expect(canonical?.url).toBe('https://example.com/real')
41
+ })
42
+
43
+ it('rejects a DuckDuckGo sponsored link', () => {
44
+ // Ads sit in the same markup as organic hits, so the SERP parser picks them
45
+ // up; left alone they survive a `site:` filter and an agent cites an advert.
46
+ const advert =
47
+ 'https://duckduckgo.com/y.js?ad_domain=udemy.com&ad_provider=bingv7aa&ad_type=txad&u3=https%3A%2F%2Fwww.bing.com%2Faclick'
48
+ expect(canonicalizeUrl(advert)).toBeNull()
49
+ })
50
+
51
+ it('keeps an ordinary duckduckgo.com page', () => {
52
+ expect(canonicalizeUrl('https://duckduckgo.com/about')?.url).toBe('https://duckduckgo.com/about')
53
+ })
54
+
55
+ it('unwraps a Google redirect', () => {
56
+ expect(canonicalizeUrl('https://www.google.com/url?q=https://example.com/x')?.url).toBe(
57
+ 'https://example.com/x',
58
+ )
59
+ })
60
+
61
+ it('rejects non-http schemes and malformed input', () => {
62
+ expect(canonicalizeUrl('javascript:alert(1)')).toBeNull()
63
+ expect(canonicalizeUrl('not a url')).toBeNull()
64
+ })
65
+ })
66
+
67
+ describe('fuseResults', () => {
68
+ it('ranks a hit found by two adapters above one found by a single adapter first', () => {
69
+ const inputs: FusionInput[] = [
70
+ {
71
+ adapterId: 'alpha',
72
+ weight: 1,
73
+ results: [{ url: 'https://a.com/1' }, { url: 'https://shared.com/x' }],
74
+ },
75
+ {
76
+ adapterId: 'beta',
77
+ weight: 1,
78
+ results: [{ url: 'https://b.com/1' }, { url: 'https://shared.com/x' }],
79
+ },
80
+ ]
81
+ const { results } = fuseResults(inputs, OPTIONS)
82
+ expect(results[0].url).toBe('https://shared.com/x')
83
+ expect(results[0].sources).toHaveLength(2)
84
+ })
85
+
86
+ it('does not let one adapter outvote genuine agreement by repeating a URL', () => {
87
+ const inputs: FusionInput[] = [
88
+ {
89
+ adapterId: 'noisy',
90
+ weight: 1,
91
+ results: [
92
+ { url: 'https://noisy.com/x' },
93
+ { url: 'https://noisy.com/x?utm_source=feed' },
94
+ { url: 'https://www.noisy.com/x/' },
95
+ ],
96
+ },
97
+ { adapterId: 'alpha', weight: 1, results: [{ url: 'https://shared.com/y' }] },
98
+ { adapterId: 'beta', weight: 1, results: [{ url: 'https://shared.com/y' }] },
99
+ ]
100
+ const { results } = fuseResults(inputs, OPTIONS)
101
+
102
+ expect(results[0].url).toBe('https://shared.com/y')
103
+ expect(results.find((hit) => hostOf(hit.url) === 'noisy.com')?.sources).toHaveLength(1)
104
+ })
105
+
106
+ it('deduplicates across adapters and merges provenance', () => {
107
+ const inputs: FusionInput[] = [
108
+ { adapterId: 'alpha', weight: 1, results: [{ url: 'https://example.com/a?utm_source=x' }] },
109
+ { adapterId: 'beta', weight: 1, results: [{ url: 'https://www.example.com/a/' }] },
110
+ ]
111
+ const { results } = fuseResults(inputs, OPTIONS)
112
+ expect(results).toHaveLength(1)
113
+ expect(results[0].sources.map((source) => source.adapterId)).toEqual(['alpha', 'beta'])
114
+ })
115
+
116
+ it('prefers the longest title and snippet when adapters disagree', () => {
117
+ const inputs: FusionInput[] = [
118
+ { adapterId: 'alpha', weight: 1, results: [{ url: 'https://e.com/a', title: 'Short', snippet: 'a' }] },
119
+ {
120
+ adapterId: 'beta',
121
+ weight: 1,
122
+ results: [{ url: 'https://e.com/a', title: 'A much longer title', snippet: 'a longer snippet' }],
123
+ },
124
+ ]
125
+ const { results } = fuseResults(inputs, OPTIONS)
126
+ expect(results[0].title).toBe('A much longer title')
127
+ expect(results[0].snippet).toBe('a longer snippet')
128
+ })
129
+
130
+ it('applies adapter weight to the fused score', () => {
131
+ const inputs: FusionInput[] = [
132
+ { adapterId: 'trusted', weight: 5, results: [{ url: 'https://trusted.com/x' }] },
133
+ { adapterId: 'weak', weight: 1, results: [{ url: 'https://weak.com/x' }] },
134
+ ]
135
+ const { results } = fuseResults(inputs, OPTIONS)
136
+ expect(results[0].url).toBe('https://trusted.com/x')
137
+ expect(results[0].confidence).toBe(1)
138
+ })
139
+
140
+ it('caps results per domain', () => {
141
+ const inputs: FusionInput[] = [
142
+ {
143
+ adapterId: 'alpha',
144
+ weight: 1,
145
+ results: [
146
+ { url: 'https://farm.com/1' },
147
+ { url: 'https://farm.com/2' },
148
+ { url: 'https://farm.com/3' },
149
+ { url: 'https://other.com/1' },
150
+ ],
151
+ },
152
+ ]
153
+ const { results } = fuseResults(inputs, { ...OPTIONS, maxPerDomain: 2, minResults: 1 })
154
+ const farmHits = results.filter((result) => hostOf(result.url) === 'farm.com')
155
+ expect(farmHits).toHaveLength(2)
156
+ expect(results.map((result) => result.url)).toContain('https://other.com/1')
157
+ })
158
+
159
+ it('skips results whose URL cannot be canonicalized', () => {
160
+ const inputs: FusionInput[] = [
161
+ { adapterId: 'alpha', weight: 1, results: [{ url: 'garbage' }, { url: 'https://ok.com/1' }] },
162
+ ]
163
+ const { results } = fuseResults(inputs, OPTIONS)
164
+ expect(results.map((result) => result.url)).toEqual(['https://ok.com/1'])
165
+ })
166
+
167
+ it('relaxes the confidence threshold rather than returning nothing', () => {
168
+ const inputs: FusionInput[] = [
169
+ {
170
+ adapterId: 'alpha',
171
+ weight: 1,
172
+ results: [{ url: 'https://a.com/1' }, { url: 'https://b.com/2' }, { url: 'https://c.com/3' }],
173
+ },
174
+ ]
175
+ const { results, degraded } = fuseResults(inputs, {
176
+ ...OPTIONS,
177
+ minConfidence: 0.99,
178
+ minResults: 3,
179
+ })
180
+ expect(results.length).toBe(3)
181
+ expect(degraded).toBe(true)
182
+ })
183
+
184
+ it('is deterministic for equally scored hits', () => {
185
+ const inputs: FusionInput[] = [
186
+ { adapterId: 'alpha', weight: 1, results: [{ url: 'https://z.com/1' }] },
187
+ { adapterId: 'beta', weight: 1, results: [{ url: 'https://a.com/1' }] },
188
+ ]
189
+ const first = fuseResults(inputs, OPTIONS).results.map((result) => result.url)
190
+ const second = fuseResults(inputs, OPTIONS).results.map((result) => result.url)
191
+ expect(first).toEqual(second)
192
+ expect(first[0]).toBe('https://a.com/1')
193
+ })
194
+
195
+ it('returns nothing when every adapter returned nothing', () => {
196
+ expect(fuseResults([], OPTIONS)).toEqual({ results: [], degraded: false })
197
+ })
198
+ })
@@ -0,0 +1,119 @@
1
+ export type CanonicalUrl = {
2
+ /** Cleaned URL suitable for display and re-fetching. */
3
+ readonly url: string
4
+ /** Aggressively normalized form used only for dedup. */
5
+ readonly key: string
6
+ readonly domain: string
7
+ }
8
+
9
+ const TRACKING_PARAMS = new Set([
10
+ 'fbclid',
11
+ 'gclid',
12
+ 'gclsrc',
13
+ 'dclid',
14
+ 'msclkid',
15
+ 'yclid',
16
+ 'twclid',
17
+ 'igshid',
18
+ 'mc_cid',
19
+ 'mc_eid',
20
+ 'ref_src',
21
+ 'ref_url',
22
+ 's_kwcid',
23
+ 'vero_id',
24
+ 'wickedid',
25
+ '_ga',
26
+ '_gl',
27
+ 'spm',
28
+ ])
29
+
30
+ /** Query keys carrying the real destination on known redirect hops. */
31
+ const REDIRECT_HOSTS: ReadonlyArray<{ host: RegExp; path?: RegExp; params: readonly string[] }> = [
32
+ { host: /(^|\.)duckduckgo\.com$/, path: /^\/l\/?$/, params: ['uddg'] },
33
+ { host: /(^|\.)google\.[a-z.]+$/, path: /^\/url$/, params: ['q', 'url'] },
34
+ { host: /(^|\.)out\.reddit\.com$/, params: ['url'] },
35
+ { host: /(^|\.)steamcommunity\.com$/, path: /^\/linkfilter\/?$/, params: ['url'] },
36
+ ]
37
+
38
+ /**
39
+ * Endpoints that are never a real result. DuckDuckGo renders sponsored slots as
40
+ * `y.js` click-tracking links carrying `ad_domain`, and they sit in the same
41
+ * markup as organic hits — so a SERP parser picks them up, they survive a
42
+ * `site:` filter that should have excluded them, and an agent ends up citing or
43
+ * fetching an advert. There is nothing to unwrap here that is worth keeping.
44
+ */
45
+ const AD_ENDPOINTS: ReadonlyArray<{ host: RegExp; path: RegExp }> = [
46
+ { host: /(^|\.)duckduckgo\.com$/, path: /^\/y\.js$/ },
47
+ ]
48
+
49
+ function isAdEndpoint(url: URL): boolean {
50
+ return AD_ENDPOINTS.some((rule) => rule.host.test(url.hostname) && rule.path.test(url.pathname))
51
+ }
52
+
53
+ function isTracking(key: string): boolean {
54
+ const lower = key.toLowerCase()
55
+ return lower.startsWith('utm_') || TRACKING_PARAMS.has(lower)
56
+ }
57
+
58
+ function unwrapRedirect(url: URL): URL | null {
59
+ for (const rule of REDIRECT_HOSTS) {
60
+ if (!rule.host.test(url.hostname)) continue
61
+ if (rule.path && !rule.path.test(url.pathname)) continue
62
+ for (const param of rule.params) {
63
+ const target = url.searchParams.get(param)
64
+ if (!target) continue
65
+ try {
66
+ const parsed = new URL(target)
67
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed
68
+ } catch {
69
+ continue
70
+ }
71
+ }
72
+ }
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * Normalizes a result URL for display and produces a separate dedup key. The two
78
+ * differ on purpose: the key folds `www.` and the scheme so the same page found
79
+ * by two adapters collapses into one hit, while the displayed URL stays faithful
80
+ * enough to fetch.
81
+ */
82
+ export function canonicalizeUrl(raw: string): CanonicalUrl | null {
83
+ let parsed: URL
84
+ try {
85
+ parsed = new URL(raw.trim())
86
+ } catch {
87
+ return null
88
+ }
89
+
90
+ for (let hop = 0; hop < 3; hop += 1) {
91
+ const unwrapped = unwrapRedirect(parsed)
92
+ if (!unwrapped) break
93
+ parsed = unwrapped
94
+ }
95
+
96
+ if (isAdEndpoint(parsed)) return null
97
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
98
+ if (parsed.hostname.length === 0) return null
99
+
100
+ parsed.hash = ''
101
+ parsed.username = ''
102
+ parsed.password = ''
103
+
104
+ const retained = [...parsed.searchParams.entries()].filter(([key]) => !isTracking(key))
105
+ retained.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
106
+ parsed.search = ''
107
+ for (const [key, value] of retained) parsed.searchParams.append(key, value)
108
+
109
+ const host = parsed.hostname.toLowerCase()
110
+ parsed.hostname = host
111
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith('/')) {
112
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '')
113
+ }
114
+
115
+ const domain = host.startsWith('www.') ? host.slice(4) : host
116
+ const key = `${domain}${parsed.pathname}${parsed.search}`
117
+
118
+ return { url: parsed.toString(), key, domain }
119
+ }
@@ -0,0 +1,145 @@
1
+ import type { RawResult, ResultProvenance, SearchResult } from '../contract/results'
2
+ import { canonicalizeUrl } from './canonicalize'
3
+
4
+ /**
5
+ * Reciprocal-rank-fusion damping constant. 60 is the value from the original
6
+ * Cormack et al. formulation and is deliberately large relative to typical result
7
+ * counts, so a hit ranked 1 by one adapter cannot dominate a hit ranked 2 by two.
8
+ */
9
+ export const RRF_K = 60
10
+
11
+ export type FusionInput = {
12
+ readonly adapterId: string
13
+ readonly weight: number
14
+ readonly results: readonly RawResult[]
15
+ }
16
+
17
+ export type FusionOptions = {
18
+ readonly limit: number
19
+ readonly minResults: number
20
+ readonly minConfidence: number
21
+ readonly maxPerDomain: number
22
+ }
23
+
24
+ export type FusionOutput = {
25
+ readonly results: readonly SearchResult[]
26
+ /** True when the confidence threshold or domain cap had to be relaxed. */
27
+ readonly degraded: boolean
28
+ }
29
+
30
+ type Accumulator = {
31
+ readonly key: string
32
+ readonly domain: string
33
+ url: string
34
+ title: string | null
35
+ snippet: string | null
36
+ content: string | null
37
+ publishedAt: string | null
38
+ score: number
39
+ readonly sources: ResultProvenance[]
40
+ }
41
+
42
+ function longest(current: string | null, candidate: string | null | undefined): string | null {
43
+ const next = candidate?.trim()
44
+ if (!next) return current
45
+ if (current === null || next.length > current.length) return next
46
+ return current
47
+ }
48
+
49
+ /**
50
+ * Merges per-adapter result lists into one ranked set.
51
+ *
52
+ * RRF is used rather than blending native scores because the sources are not
53
+ * comparable: an LLM citation list has no score at all, a SERP has an opaque
54
+ * one, and a paid API has its own scale. Rank is the only signal every adapter
55
+ * genuinely agrees on.
56
+ */
57
+ export function fuseResults(inputs: readonly FusionInput[], options: FusionOptions): FusionOutput {
58
+ const accumulators = new Map<string, Accumulator>()
59
+
60
+ for (const input of inputs) {
61
+ const weight = input.weight > 0 ? input.weight : 1
62
+ // Two spellings of one URL inside a single adapter's list must not each earn
63
+ // that adapter a rank slot: the duplicate would compound its own score and
64
+ // let one source outvote the agreement between two independent ones.
65
+ const contributed = new Set<string>()
66
+ let rank = 0
67
+ for (const result of input.results) {
68
+ const canonical = canonicalizeUrl(result.url)
69
+ if (canonical === null) continue
70
+ if (contributed.has(canonical.key)) continue
71
+ contributed.add(canonical.key)
72
+ rank += 1
73
+ const existing = accumulators.get(canonical.key)
74
+ const accumulator: Accumulator = existing ?? {
75
+ key: canonical.key,
76
+ domain: canonical.domain,
77
+ url: canonical.url,
78
+ title: null,
79
+ snippet: null,
80
+ content: null,
81
+ publishedAt: null,
82
+ score: 0,
83
+ sources: [],
84
+ }
85
+ accumulator.title = longest(accumulator.title, result.title)
86
+ accumulator.snippet = longest(accumulator.snippet, result.snippet)
87
+ accumulator.content = accumulator.content ?? (result.content?.trim() || null)
88
+ accumulator.publishedAt = accumulator.publishedAt ?? (result.publishedAt?.trim() || null)
89
+ accumulator.score += weight / (RRF_K + rank)
90
+ accumulator.sources.push({ adapterId: input.adapterId, rank })
91
+ if (!existing) accumulators.set(canonical.key, accumulator)
92
+ }
93
+ }
94
+
95
+ if (accumulators.size === 0) return { results: [], degraded: false }
96
+
97
+ const maxScore = Math.max(...[...accumulators.values()].map((entry) => entry.score))
98
+ const ranked = [...accumulators.values()]
99
+ .map((entry) => ({
100
+ entry,
101
+ result: {
102
+ url: entry.url,
103
+ title: entry.title ?? entry.url,
104
+ snippet: entry.snippet,
105
+ content: entry.content,
106
+ publishedAt: entry.publishedAt,
107
+ confidence: maxScore > 0 ? Number((entry.score / maxScore).toFixed(6)) : 0,
108
+ sources: entry.sources,
109
+ } satisfies SearchResult,
110
+ }))
111
+ .sort((left, right) => {
112
+ if (right.result.confidence !== left.result.confidence) {
113
+ return right.result.confidence - left.result.confidence
114
+ }
115
+ return left.entry.key < right.entry.key ? -1 : left.entry.key > right.entry.key ? 1 : 0
116
+ })
117
+
118
+ const perDomain = new Map<string, number>()
119
+ const primary: SearchResult[] = []
120
+ const overflow: SearchResult[] = []
121
+
122
+ for (const { entry, result } of ranked) {
123
+ const used = perDomain.get(entry.domain) ?? 0
124
+ if (used >= options.maxPerDomain || result.confidence < options.minConfidence) {
125
+ overflow.push(result)
126
+ continue
127
+ }
128
+ perDomain.set(entry.domain, used + 1)
129
+ primary.push(result)
130
+ }
131
+
132
+ // A confidence threshold or a domain cap must never manufacture an empty
133
+ // answer: fall back to the best rejected hits and report the relaxation.
134
+ let degraded = false
135
+ const selected = primary.slice(0, options.limit)
136
+ if (selected.length < Math.min(options.minResults, ranked.length)) {
137
+ const needed = Math.min(options.minResults, options.limit) - selected.length
138
+ if (needed > 0 && overflow.length > 0) {
139
+ degraded = true
140
+ selected.push(...overflow.slice(0, needed))
141
+ }
142
+ }
143
+
144
+ return { results: selected, degraded }
145
+ }
@@ -0,0 +1,8 @@
1
+ export { canonicalizeUrl, type CanonicalUrl } from './canonicalize'
2
+ export {
3
+ RRF_K,
4
+ fuseResults,
5
+ type FusionInput,
6
+ type FusionOptions,
7
+ type FusionOutput,
8
+ } from './fuse'
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export * from './contract'
2
+ export * from './engine'
3
+ export * from './extract'
4
+ export * from './fusion'
5
+ export * from './loader'
6
+ export { createHttpClient, createPinnedLookup, type HttpClientOptions } from './net/client'
7
+ export { stripTrailingSlashes } from './net/url'
8
+ export {
9
+ assertPublicUrl,
10
+ isLoopbackHostname,
11
+ isPrivateAddress,
12
+ type AssertPublicUrlOptions,
13
+ type LookupFn,
14
+ type VettedUrl,
15
+ } from './net/ssrf'
@@ -0,0 +1,119 @@
1
+ import { z } from 'zod'
2
+ import { defineAdapterModule, type AdapterModule } from '../../contract/adapter'
3
+ import { CONTRACT_VERSION } from '../../contract/version'
4
+ import { createFakeAdapter } from '../../testing/fakeAdapter'
5
+ import { SECRET_PLACEHOLDER, describeOptionsSchema, maskSecrets, unmaskSecrets } from '../describe'
6
+
7
+ const moduleWith = (schema: z.ZodType<Record<string, unknown>>): AdapterModule<unknown> =>
8
+ defineAdapterModule({
9
+ id: 'fixture',
10
+ kind: 'api',
11
+ contractVersion: CONTRACT_VERSION,
12
+ optionsSchema: schema,
13
+ createAdapter: () => createFakeAdapter({ id: 'fixture' }),
14
+ }) as unknown as AdapterModule<unknown>
15
+
16
+ describe('describeOptionsSchema', () => {
17
+ it('describes each primitive kind', () => {
18
+ const fields = describeOptionsSchema(
19
+ moduleWith(
20
+ z.object({
21
+ apiKey: z.string().optional(),
22
+ depth: z.enum(['basic', 'advanced']).optional(),
23
+ timeoutMs: z.number().int().optional(),
24
+ enabled: z.boolean().optional(),
25
+ engines: z.array(z.string()).optional(),
26
+ }),
27
+ ),
28
+ )
29
+
30
+ expect(fields.map((field) => [field.name, field.kind])).toEqual([
31
+ ['apiKey', 'string'],
32
+ ['depth', 'enum'],
33
+ ['timeoutMs', 'number'],
34
+ ['enabled', 'boolean'],
35
+ ['engines', 'stringList'],
36
+ ])
37
+ expect(fields.find((field) => field.name === 'depth')?.choices).toEqual(['basic', 'advanced'])
38
+ })
39
+
40
+ // A host capability (a model resolver, a sidecar handle) is declared with
41
+ // z.custom. Rendering it would put a function in an admin form.
42
+ it('skips host-capability fields declared with z.custom', () => {
43
+ const fields = describeOptionsSchema(
44
+ moduleWith(
45
+ z.object({
46
+ apiKey: z.string().optional(),
47
+ resolveModel: z.custom<() => void>((value) => typeof value === 'function').optional(),
48
+ generate: z.custom<() => void>((value) => typeof value === 'function').optional(),
49
+ }),
50
+ ),
51
+ )
52
+ expect(fields.map((field) => field.name)).toEqual(['apiKey'])
53
+ })
54
+
55
+ it('marks credential-shaped names as secret', () => {
56
+ const fields = describeOptionsSchema(
57
+ moduleWith(
58
+ z.object({
59
+ apiKey: z.string().optional(),
60
+ accessToken: z.string().optional(),
61
+ password: z.string().optional(),
62
+ baseUrl: z.string().optional(),
63
+ engines: z.array(z.string()).optional(),
64
+ }),
65
+ ),
66
+ )
67
+ const secrets = fields.filter((field) => field.secret).map((field) => field.name)
68
+ expect(secrets).toEqual(['apiKey', 'accessToken', 'password'])
69
+ })
70
+
71
+ it('reports required vs optional and carries the string format', () => {
72
+ const fields = describeOptionsSchema(
73
+ moduleWith(z.object({ baseUrl: z.url(), label: z.string().optional() })),
74
+ )
75
+ expect(fields.find((field) => field.name === 'baseUrl')).toMatchObject({
76
+ required: true,
77
+ format: 'url',
78
+ })
79
+ expect(fields.find((field) => field.name === 'label')?.required).toBe(false)
80
+ })
81
+
82
+ it('returns nothing for a schema that is not an object', () => {
83
+ expect(describeOptionsSchema(moduleWith(z.string() as never))).toEqual([])
84
+ })
85
+ })
86
+
87
+ describe('secret round-trip', () => {
88
+ const fields = describeOptionsSchema(
89
+ moduleWith(z.object({ apiKey: z.string().optional(), baseUrl: z.string().optional() })),
90
+ )
91
+
92
+ it('never echoes a stored secret back to the client', () => {
93
+ const masked = maskSecrets({ apiKey: 'sk-live-123', baseUrl: 'https://x' }, fields)
94
+ expect(masked.apiKey).toBe(SECRET_PLACEHOLDER)
95
+ expect(masked.baseUrl).toBe('https://x')
96
+ })
97
+
98
+ it('leaves an unset secret unset rather than masking nothing', () => {
99
+ expect(maskSecrets({ baseUrl: 'https://x' }, fields).apiKey).toBeUndefined()
100
+ })
101
+
102
+ it('keeps the stored secret when the client sends the placeholder back', () => {
103
+ const merged = unmaskSecrets(
104
+ { apiKey: SECRET_PLACEHOLDER, baseUrl: 'https://new' },
105
+ { apiKey: 'sk-live-123', baseUrl: 'https://old' },
106
+ fields,
107
+ )
108
+ expect(merged).toEqual({ apiKey: 'sk-live-123', baseUrl: 'https://new' })
109
+ })
110
+
111
+ it('accepts a genuinely new secret', () => {
112
+ const merged = unmaskSecrets({ apiKey: 'sk-live-new' }, { apiKey: 'sk-live-old' }, fields)
113
+ expect(merged.apiKey).toBe('sk-live-new')
114
+ })
115
+
116
+ it('drops the placeholder when nothing was stored, rather than persisting it', () => {
117
+ expect(unmaskSecrets({ apiKey: SECRET_PLACEHOLDER }, {}, fields).apiKey).toBeUndefined()
118
+ })
119
+ })