@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,134 @@
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 { createTestContext } from '../../testing/contract'
6
+ import { readAdapterManifest } from '../manifest'
7
+ import { instantiateAdapter, resolveAdapterModules } from '../registry'
8
+
9
+ const workingModule = defineAdapterModule({
10
+ id: 'working',
11
+ kind: 'api',
12
+ contractVersion: CONTRACT_VERSION,
13
+ optionsSchema: z.object({ apiKey: z.string().min(1) }),
14
+ createAdapter: (options) => createFakeAdapter({ id: 'working', results: [{ url: `https://x/${options.apiKey}` }] }),
15
+ })
16
+
17
+ describe('readAdapterManifest', () => {
18
+ it('reads a well-formed manifest', () => {
19
+ const result = readAdapterManifest({
20
+ name: '@acme/web-research-brave',
21
+ openMercato: { webResearchAdapter: { id: 'brave' } },
22
+ })
23
+ expect(result).toEqual({ packageName: '@acme/web-research-brave', manifest: { id: 'brave' } })
24
+ })
25
+
26
+ it('ignores a package that is not an adapter', () => {
27
+ expect(readAdapterManifest({ name: 'lodash' })).toBeNull()
28
+ expect(readAdapterManifest({ name: 'x', openMercato: {} })).toBeNull()
29
+ expect(readAdapterManifest(null)).toBeNull()
30
+ })
31
+
32
+ it('reports a malformed manifest instead of ignoring it', () => {
33
+ const result = readAdapterManifest({
34
+ name: '@acme/broken',
35
+ openMercato: { webResearchAdapter: { id: '' } },
36
+ })
37
+ expect(result).toMatchObject({ error: expect.stringContaining('@acme/broken') })
38
+ })
39
+ })
40
+
41
+ describe('resolveAdapterModules', () => {
42
+ it('accepts a valid descriptor and unwraps a default export', () => {
43
+ const registry = resolveAdapterModules([
44
+ { packageName: 'a', module: workingModule },
45
+ { packageName: 'b', module: { default: { ...workingModule, id: 'other' } } },
46
+ ])
47
+ expect(registry.rejected).toEqual([])
48
+ expect(registry.loaded.map((entry) => entry.module.id)).toEqual(['working', 'other'])
49
+ })
50
+
51
+ it('rejects a contract-version mismatch without throwing', () => {
52
+ const registry = resolveAdapterModules([
53
+ { packageName: 'stale', module: { ...workingModule, contractVersion: CONTRACT_VERSION + 1 } },
54
+ ])
55
+ expect(registry.loaded).toEqual([])
56
+ expect(registry.rejected[0].reason).toContain('contract version')
57
+ })
58
+
59
+ it.each([
60
+ ['not an object', 42],
61
+ ['missing id', { ...workingModule, id: '' }],
62
+ ['unknown kind', { ...workingModule, kind: 'telepathy' }],
63
+ ['missing createAdapter', { ...workingModule, createAdapter: undefined }],
64
+ ['missing optionsSchema', { ...workingModule, optionsSchema: undefined }],
65
+ ])('rejects a descriptor that is %s', (_label, module) => {
66
+ const registry = resolveAdapterModules([{ packageName: 'bad', module }])
67
+ expect(registry.loaded).toEqual([])
68
+ expect(registry.rejected).toHaveLength(1)
69
+ })
70
+
71
+ it('keeps the first package to claim an adapter id and rejects the duplicate', () => {
72
+ const registry = resolveAdapterModules([
73
+ { packageName: 'first', module: workingModule },
74
+ { packageName: 'second', module: { ...workingModule } },
75
+ ])
76
+ expect(registry.loaded.map((entry) => entry.packageName)).toEqual(['first'])
77
+ expect(registry.rejected[0].reason).toContain('already provided by first')
78
+ })
79
+
80
+ // The MCP server loads every module tool through one ESM graph, where a single
81
+ // failed import drops all of them. One broken adapter must never do that.
82
+ it('isolates a bad descriptor from the good ones in the same batch', () => {
83
+ const registry = resolveAdapterModules([
84
+ { packageName: 'broken', module: null },
85
+ { packageName: 'fine', module: workingModule },
86
+ ])
87
+ expect(registry.loaded.map((entry) => entry.module.id)).toEqual(['working'])
88
+ expect(registry.rejected.map((entry) => entry.packageName)).toEqual(['broken'])
89
+ })
90
+ })
91
+
92
+ describe('instantiateAdapter', () => {
93
+ const loaded = { packageName: 'pkg', module: workingModule as AdapterModule<unknown> }
94
+
95
+ it('builds an adapter from valid options', () => {
96
+ const result = instantiateAdapter(loaded, { apiKey: 'secret' })
97
+ expect(result.error).toBeUndefined()
98
+ expect(result.adapter.readiness().ready).toBe(true)
99
+ })
100
+
101
+ it('stands in an unavailable adapter when options do not validate', async () => {
102
+ const result = instantiateAdapter(loaded, { apiKey: '' })
103
+ expect(result.error).toContain('invalid options')
104
+ const readiness = result.adapter.readiness()
105
+ expect(readiness.ready).toBe(false)
106
+ await expect(result.adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
107
+ status: 'unavailable',
108
+ })
109
+ })
110
+
111
+ it('stands in an unavailable adapter when the constructor throws', () => {
112
+ const exploding = {
113
+ packageName: 'pkg',
114
+ module: {
115
+ ...workingModule,
116
+ createAdapter: () => {
117
+ throw new Error('boom')
118
+ },
119
+ } as AdapterModule<unknown>,
120
+ }
121
+ const result = instantiateAdapter(exploding, { apiKey: 'k' })
122
+ expect(result.error).toContain('threw while being constructed')
123
+ expect(result.adapter.readiness().ready).toBe(false)
124
+ })
125
+
126
+ it('rejects an object that does not implement the adapter interface', () => {
127
+ const bogus = {
128
+ packageName: 'pkg',
129
+ module: { ...workingModule, createAdapter: () => ({ id: 'x' }) } as unknown as AdapterModule<unknown>,
130
+ }
131
+ const result = instantiateAdapter(bogus, { apiKey: 'k' })
132
+ expect(result.error).toContain('does not implement SearchAdapter')
133
+ })
134
+ })
@@ -0,0 +1,145 @@
1
+ import type { AdapterModule } from '../contract/adapter'
2
+
3
+ export type OptionFieldKind = 'string' | 'number' | 'boolean' | 'enum' | 'stringList'
4
+
5
+ export type OptionField = {
6
+ readonly name: string
7
+ readonly kind: OptionFieldKind
8
+ readonly required: boolean
9
+ /** True when the value must never be echoed back to a browser. */
10
+ readonly secret: boolean
11
+ readonly choices?: readonly string[]
12
+ readonly format?: string
13
+ readonly min?: number
14
+ readonly max?: number
15
+ }
16
+
17
+ const SECRET_WORD = /(^|_)(api_?key|key|token|secret|password|passwd|credential)s?(_|$)/
18
+
19
+ /**
20
+ * Whether a field name looks like a credential. The name is split at camelCase
21
+ * boundaries first, so `accessToken` and `access_token` are treated alike — a
22
+ * plain word-boundary match misses the camelCase spelling entirely.
23
+ */
24
+ function isSecretName(name: string): boolean {
25
+ return SECRET_WORD.test(name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase())
26
+ }
27
+
28
+ type ZodInternals = {
29
+ type?: string
30
+ innerType?: { def?: ZodInternals }
31
+ entries?: Record<string, unknown>
32
+ format?: string
33
+ element?: { def?: ZodInternals }
34
+ checks?: Array<{ _zod?: { def?: { check?: string; value?: number } } }>
35
+ }
36
+
37
+ type ZodLike = { def?: ZodInternals; shape?: Record<string, ZodLike> }
38
+
39
+ function unwrap(schema: ZodLike): { inner: ZodInternals; required: boolean } {
40
+ let def = schema.def
41
+ let required = true
42
+ // `.optional()` / `.nullable()` / `.default()` wrap the real type.
43
+ while (def && (def.type === 'optional' || def.type === 'nullable' || def.type === 'default')) {
44
+ if (def.type !== 'nullable') required = false
45
+ def = def.innerType?.def
46
+ }
47
+ return { inner: def ?? {}, required }
48
+ }
49
+
50
+ function boundsOf(inner: ZodInternals): { min?: number; max?: number } {
51
+ const bounds: { min?: number; max?: number } = {}
52
+ for (const check of inner.checks ?? []) {
53
+ const def = check?._zod?.def
54
+ if (!def || typeof def.value !== 'number') continue
55
+ if (def.check === 'greater_than' || def.check === 'min_length') bounds.min = def.value
56
+ if (def.check === 'less_than' || def.check === 'max_length') bounds.max = def.value
57
+ }
58
+ return bounds
59
+ }
60
+
61
+ function kindOf(inner: ZodInternals): OptionFieldKind | null {
62
+ switch (inner.type) {
63
+ case 'string':
64
+ return 'string'
65
+ case 'number':
66
+ case 'int':
67
+ return 'number'
68
+ case 'boolean':
69
+ return 'boolean'
70
+ case 'enum':
71
+ return 'enum'
72
+ case 'array':
73
+ // Only string arrays are renderable as a plain comma-separated input.
74
+ return inner.element?.def?.type === 'string' ? 'stringList' : null
75
+ default:
76
+ // `custom` is how an adapter declares a host-supplied capability
77
+ // (a model resolver, a sidecar handle). Not user-configurable, so it must
78
+ // never reach the admin form.
79
+ return null
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Describes an adapter's options as renderable fields, so the settings UI can
85
+ * build a form for an adapter it has never heard of — including one published by
86
+ * a third party — without the adapter shipping any UI of its own.
87
+ */
88
+ export function describeOptionsSchema(module: AdapterModule<unknown>): OptionField[] {
89
+ const schema = module.optionsSchema as unknown as ZodLike
90
+ const shape = schema.shape ?? (schema.def as { shape?: Record<string, ZodLike> } | undefined)?.shape
91
+ if (!shape) return []
92
+
93
+ const fields: OptionField[] = []
94
+ for (const [name, entry] of Object.entries(shape)) {
95
+ const { inner, required } = unwrap(entry)
96
+ const kind = kindOf(inner)
97
+ if (kind === null) continue
98
+ const bounds = boundsOf(inner)
99
+ fields.push({
100
+ name,
101
+ kind,
102
+ required,
103
+ secret: isSecretName(name),
104
+ ...(kind === 'enum' && inner.entries ? { choices: Object.keys(inner.entries) } : {}),
105
+ ...(inner.format ? { format: inner.format } : {}),
106
+ ...(bounds.min === undefined ? {} : { min: bounds.min }),
107
+ ...(bounds.max === undefined ? {} : { max: bounds.max }),
108
+ })
109
+ }
110
+ return fields
111
+ }
112
+
113
+ /** Placeholder returned instead of a stored secret, and accepted back unchanged. */
114
+ export const SECRET_PLACEHOLDER = '__om_secret_unchanged__'
115
+
116
+ export function maskSecrets(
117
+ values: Record<string, unknown>,
118
+ fields: readonly OptionField[],
119
+ ): Record<string, unknown> {
120
+ const masked: Record<string, unknown> = { ...values }
121
+ for (const field of fields) {
122
+ if (!field.secret) continue
123
+ const value = masked[field.name]
124
+ if (typeof value === 'string' && value.length > 0) masked[field.name] = SECRET_PLACEHOLDER
125
+ }
126
+ return masked
127
+ }
128
+
129
+ /** Restores stored secrets the client echoed back as the placeholder. */
130
+ export function unmaskSecrets(
131
+ incoming: Record<string, unknown>,
132
+ stored: Record<string, unknown>,
133
+ fields: readonly OptionField[],
134
+ ): Record<string, unknown> {
135
+ const merged: Record<string, unknown> = { ...incoming }
136
+ for (const field of fields) {
137
+ if (!field.secret) continue
138
+ if (merged[field.name] === SECRET_PLACEHOLDER) {
139
+ const previous = stored[field.name]
140
+ if (previous === undefined) delete merged[field.name]
141
+ else merged[field.name] = previous
142
+ }
143
+ }
144
+ return merged
145
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ SECRET_PLACEHOLDER,
3
+ describeOptionsSchema,
4
+ maskSecrets,
5
+ unmaskSecrets,
6
+ type OptionField,
7
+ type OptionFieldKind,
8
+ } from './describe'
9
+ export {
10
+ ADAPTER_MANIFEST_KEY,
11
+ MANIFEST_NAMESPACE,
12
+ adapterManifestSchema,
13
+ readAdapterManifest,
14
+ type AdapterManifest,
15
+ } from './manifest'
16
+ export {
17
+ createUnavailableAdapter,
18
+ instantiateAdapter,
19
+ resolveAdapterModules,
20
+ type AdapterModuleRegistry,
21
+ type AdapterRegistryEntry,
22
+ type InstantiatedAdapter,
23
+ type LoadedAdapterModule,
24
+ type RejectedAdapterModule,
25
+ } from './registry'
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod'
2
+
3
+ /** `package.json` → `openMercato.webResearchAdapter`. */
4
+ export const MANIFEST_NAMESPACE = 'openMercato'
5
+ export const ADAPTER_MANIFEST_KEY = 'webResearchAdapter'
6
+
7
+ export const adapterManifestSchema = z.object({
8
+ id: z.string().min(1).max(64),
9
+ /** Import specifier relative to the package root; defaults to the package main. */
10
+ entry: z.string().min(1).optional(),
11
+ })
12
+
13
+ export type AdapterManifest = z.infer<typeof adapterManifestSchema>
14
+
15
+ type PackageJsonLike = {
16
+ readonly name?: unknown
17
+ readonly [MANIFEST_NAMESPACE]?: unknown
18
+ }
19
+
20
+ /**
21
+ * Reads the adapter manifest out of a parsed `package.json`. Returns null for any
22
+ * package that simply is not an adapter, and only reports a problem when a
23
+ * package claims to be one but declares it wrongly.
24
+ */
25
+ export function readAdapterManifest(
26
+ packageJson: unknown,
27
+ ): { manifest: AdapterManifest; packageName: string } | { error: string } | null {
28
+ if (typeof packageJson !== 'object' || packageJson === null) return null
29
+ const parsed = packageJson as PackageJsonLike
30
+ const namespace = parsed[MANIFEST_NAMESPACE]
31
+ if (typeof namespace !== 'object' || namespace === null) return null
32
+ const raw = (namespace as Record<string, unknown>)[ADAPTER_MANIFEST_KEY]
33
+ if (raw === undefined) return null
34
+
35
+ const packageName = typeof parsed.name === 'string' ? parsed.name : ''
36
+ if (packageName.length === 0) {
37
+ return { error: 'package declares a web-research adapter but has no name' }
38
+ }
39
+ const result = adapterManifestSchema.safeParse(raw)
40
+ if (!result.success) {
41
+ return { error: `${packageName} has an invalid ${MANIFEST_NAMESPACE}.${ADAPTER_MANIFEST_KEY}: ${result.error.message}` }
42
+ }
43
+ return { manifest: result.data, packageName }
44
+ }
@@ -0,0 +1,156 @@
1
+ import type { AdapterModule, SearchAdapter } from '../contract/adapter'
2
+ import { notReady } from '../contract/adapter'
3
+ import { errorMessage } from '../contract/errors'
4
+ import { unavailable } from '../contract/outcomes'
5
+ import { CONTRACT_VERSION, MIN_SUPPORTED_CONTRACT_VERSION, isSupportedContractVersion } from '../contract/version'
6
+
7
+ export type AdapterRegistryEntry = {
8
+ readonly packageName: string
9
+ /** The statically imported module namespace or its default export. */
10
+ readonly module: unknown
11
+ }
12
+
13
+ export type LoadedAdapterModule = {
14
+ readonly packageName: string
15
+ readonly module: AdapterModule<unknown>
16
+ }
17
+
18
+ export type RejectedAdapterModule = {
19
+ readonly packageName: string
20
+ readonly id: string | null
21
+ readonly reason: string
22
+ }
23
+
24
+ export type AdapterModuleRegistry = {
25
+ readonly loaded: readonly LoadedAdapterModule[]
26
+ readonly rejected: readonly RejectedAdapterModule[]
27
+ }
28
+
29
+ const ADAPTER_KINDS = new Set(['serp', 'api', 'model', 'browser'])
30
+
31
+ /**
32
+ * A stand-in for an adapter that could not be built. Returning one of these
33
+ * instead of throwing is what keeps a broken third-party package from taking the
34
+ * whole tool set down: the scheduler simply reports it as skipped, with the
35
+ * reason, alongside every adapter that did work.
36
+ */
37
+ export function createUnavailableAdapter(id: string, reason: string): SearchAdapter {
38
+ return {
39
+ id,
40
+ kind: 'api',
41
+ capabilities: {
42
+ search: false,
43
+ fetch: false,
44
+ snippets: false,
45
+ content: false,
46
+ freshness: false,
47
+ siteFilter: false,
48
+ cost: 'free',
49
+ },
50
+ readiness: () => notReady(reason),
51
+ search: async () => unavailable(reason),
52
+ healthCheck: async () => ({ ok: false, detail: reason }),
53
+ }
54
+ }
55
+
56
+ function describeModule(candidate: unknown): AdapterModule<unknown> | string {
57
+ const unwrapped =
58
+ typeof candidate === 'object' &&
59
+ candidate !== null &&
60
+ 'default' in candidate &&
61
+ !('createAdapter' in candidate)
62
+ ? (candidate as { default: unknown }).default
63
+ : candidate
64
+
65
+ if (typeof unwrapped !== 'object' || unwrapped === null) {
66
+ return 'module does not export an adapter descriptor'
67
+ }
68
+ const module = unwrapped as Partial<AdapterModule<unknown>>
69
+ if (typeof module.id !== 'string' || module.id.length === 0) return 'adapter descriptor has no id'
70
+ if (typeof module.kind !== 'string' || !ADAPTER_KINDS.has(module.kind)) {
71
+ return `adapter "${module.id}" declares an unknown kind`
72
+ }
73
+ if (typeof module.createAdapter !== 'function') {
74
+ return `adapter "${module.id}" does not expose createAdapter()`
75
+ }
76
+ if (
77
+ typeof module.optionsSchema !== 'object' ||
78
+ module.optionsSchema === null ||
79
+ typeof (module.optionsSchema as { safeParse?: unknown }).safeParse !== 'function'
80
+ ) {
81
+ return `adapter "${module.id}" does not expose a zod optionsSchema`
82
+ }
83
+ if (!isSupportedContractVersion(module.contractVersion)) {
84
+ return `adapter "${module.id}" targets contract version ${String(module.contractVersion)}, this engine speaks ${MIN_SUPPORTED_CONTRACT_VERSION}..${CONTRACT_VERSION}`
85
+ }
86
+ return module as AdapterModule<unknown>
87
+ }
88
+
89
+ /**
90
+ * Validates statically imported adapter modules. Discovery itself is done by the
91
+ * generator so imports stay static — a bundler cannot follow an `import()` with a
92
+ * runtime-computed specifier.
93
+ */
94
+ export function resolveAdapterModules(entries: readonly AdapterRegistryEntry[]): AdapterModuleRegistry {
95
+ const loaded: LoadedAdapterModule[] = []
96
+ const rejected: RejectedAdapterModule[] = []
97
+ const seen = new Map<string, string>()
98
+
99
+ for (const entry of entries) {
100
+ const described = describeModule(entry.module)
101
+ if (typeof described === 'string') {
102
+ rejected.push({ packageName: entry.packageName, id: null, reason: described })
103
+ continue
104
+ }
105
+ const owner = seen.get(described.id)
106
+ if (owner !== undefined) {
107
+ rejected.push({
108
+ packageName: entry.packageName,
109
+ id: described.id,
110
+ reason: `adapter id "${described.id}" is already provided by ${owner}`,
111
+ })
112
+ continue
113
+ }
114
+ seen.set(described.id, entry.packageName)
115
+ loaded.push({ packageName: entry.packageName, module: described })
116
+ }
117
+
118
+ return { loaded, rejected }
119
+ }
120
+
121
+ export type InstantiatedAdapter = {
122
+ readonly id: string
123
+ readonly packageName: string
124
+ readonly adapter: SearchAdapter
125
+ /** Present when the adapter could not be built and a placeholder is standing in. */
126
+ readonly error?: string
127
+ }
128
+
129
+ /** Builds one adapter, converting any options or constructor fault into a placeholder. */
130
+ export function instantiateAdapter(
131
+ entry: LoadedAdapterModule,
132
+ rawOptions: unknown,
133
+ ): InstantiatedAdapter {
134
+ const { module, packageName } = entry
135
+ const parsed = module.optionsSchema.safeParse(rawOptions ?? {})
136
+ if (!parsed.success) {
137
+ const reason = `invalid options for "${module.id}": ${parsed.error.message}`
138
+ return { id: module.id, packageName, adapter: createUnavailableAdapter(module.id, reason), error: reason }
139
+ }
140
+ try {
141
+ const adapter = module.createAdapter(parsed.data)
142
+ if (typeof adapter?.search !== 'function' || typeof adapter?.readiness !== 'function') {
143
+ const reason = `adapter "${module.id}" returned an object that does not implement SearchAdapter`
144
+ return {
145
+ id: module.id,
146
+ packageName,
147
+ adapter: createUnavailableAdapter(module.id, reason),
148
+ error: reason,
149
+ }
150
+ }
151
+ return { id: module.id, packageName, adapter }
152
+ } catch (error) {
153
+ const reason = `adapter "${module.id}" threw while being constructed: ${errorMessage(error)}`
154
+ return { id: module.id, packageName, adapter: createUnavailableAdapter(module.id, reason), error: reason }
155
+ }
156
+ }